Find projects and repo groups from Cmd+J (#6193)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
0880021665
commit
cf39bd7abe
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
import { isCmdJPaletteQueryTooLarge } from './palette-results'
|
||||
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getProjectGroupHeaderKey,
|
||||
getProjectHeaderRevealTarget,
|
||||
type ProjectGroupingModel
|
||||
} from '../sidebar/worktree-list-groups'
|
||||
|
||||
export type CmdJProjectGroupResult = {
|
||||
id: string
|
||||
kind: 'project-group'
|
||||
title: string
|
||||
description: string
|
||||
rowKey: string
|
||||
order: number
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type CmdJProjectResult = {
|
||||
id: string
|
||||
kind: 'project'
|
||||
title: string
|
||||
description: string
|
||||
rowKey: string
|
||||
repo: Repo
|
||||
order: number
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type CmdJProjectSearchResult = CmdJProjectGroupResult | CmdJProjectResult
|
||||
|
||||
type RankedProjectResult = {
|
||||
result: CmdJProjectSearchResult
|
||||
rule: number
|
||||
score: number
|
||||
}
|
||||
|
||||
const PROJECT_GROUP_ALIASES = ['group', 'repo group']
|
||||
const PROJECT_ALIASES = ['project', 'repo']
|
||||
|
||||
function normalizeQuery(value: string): string {
|
||||
let normalized = ''
|
||||
let pendingWhitespace = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (isCmdJPaletteWhitespace(code)) {
|
||||
pendingWhitespace = normalized.length > 0
|
||||
continue
|
||||
}
|
||||
if (pendingWhitespace) {
|
||||
normalized += ' '
|
||||
pendingWhitespace = false
|
||||
}
|
||||
normalized += value.charAt(index).toLowerCase()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isCmdJPaletteWhitespace(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
code === 160 ||
|
||||
code === 5760 ||
|
||||
(code >= 8192 && code <= 8202) ||
|
||||
code === 8232 ||
|
||||
code === 8233 ||
|
||||
code === 8239 ||
|
||||
code === 8287 ||
|
||||
code === 12288 ||
|
||||
code === 65279
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueNormalized(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return normalizeQuery(value)
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function tokenScore(query: string, values: readonly string[]): number {
|
||||
const candidateTokens = values.flatMap(tokenize)
|
||||
if (candidateTokens.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let score = 0
|
||||
for (const queryToken of tokenize(query)) {
|
||||
let best = 0
|
||||
for (const candidateToken of candidateTokens) {
|
||||
if (candidateToken === queryToken) {
|
||||
best = Math.max(best, 3)
|
||||
} else if (candidateToken.startsWith(queryToken)) {
|
||||
best = Math.max(best, 2)
|
||||
} else if (candidateToken.includes(queryToken)) {
|
||||
best = Math.max(best, 1)
|
||||
}
|
||||
}
|
||||
score += best
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function buildCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}: {
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
projects: readonly Project[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
renderableRepoIds?: ReadonlySet<string>
|
||||
}): CmdJProjectSearchResult[] {
|
||||
const projectGrouping: ProjectGroupingModel = { projects, projectHostSetups }
|
||||
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
|
||||
const candidates: CmdJProjectSearchResult[] = []
|
||||
|
||||
projectGroups.forEach((group, order) => {
|
||||
candidates.push({
|
||||
id: `project-group:${group.id}`,
|
||||
kind: 'project-group',
|
||||
title: group.name,
|
||||
description: translate(
|
||||
'auto.components.cmd.j.palette.project.results.repoGroup',
|
||||
'Repo group'
|
||||
),
|
||||
rowKey: getProjectGroupHeaderKey(group.id),
|
||||
order,
|
||||
keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES])
|
||||
})
|
||||
})
|
||||
|
||||
const seenRowKeys = new Set<string>()
|
||||
repos.forEach((repo, repoIndex) => {
|
||||
if (renderableRepoIds && !renderableRepoIds.has(repo.id)) {
|
||||
return
|
||||
}
|
||||
const target = getProjectHeaderRevealTarget(repo.id, repoMap, projectGrouping)
|
||||
if (!target.repo || seenRowKeys.has(target.key)) {
|
||||
return
|
||||
}
|
||||
seenRowKeys.add(target.key)
|
||||
candidates.push({
|
||||
id: `project:${target.key}`,
|
||||
kind: 'project',
|
||||
title: target.label,
|
||||
description: translate('auto.components.cmd.j.palette.project.results.project', 'Project'),
|
||||
rowKey: target.key,
|
||||
repo: target.repo,
|
||||
order: projectGroups.length + repoIndex,
|
||||
keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES])
|
||||
})
|
||||
})
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function hasCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}: {
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
projects: readonly Project[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
renderableRepoIds?: ReadonlySet<string>
|
||||
}): boolean {
|
||||
return (
|
||||
buildCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function projectRankingForCandidate(
|
||||
query: string,
|
||||
candidate: CmdJProjectSearchResult
|
||||
): RankedProjectResult | null {
|
||||
const title = normalizeQuery(candidate.title)
|
||||
if (query === title) {
|
||||
return { result: candidate, rule: 1, score: 0 }
|
||||
}
|
||||
if (title.startsWith(query)) {
|
||||
return { result: candidate, rule: 2, score: 0 }
|
||||
}
|
||||
const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES
|
||||
if (aliasKeywords.map(normalizeQuery).includes(query)) {
|
||||
return { result: candidate, rule: 3, score: 0 }
|
||||
}
|
||||
if (candidate.keywords.some((keyword) => keyword.startsWith(query))) {
|
||||
return { result: candidate, rule: 4, score: 0 }
|
||||
}
|
||||
const score = tokenScore(query, [candidate.title, ...candidate.keywords])
|
||||
return score > 0 ? { result: candidate, rule: 5, score } : null
|
||||
}
|
||||
|
||||
function compareProjectRanked(a: RankedProjectResult, b: RankedProjectResult): number {
|
||||
if (a.rule !== b.rule) {
|
||||
return a.rule - b.rule
|
||||
}
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score
|
||||
}
|
||||
if (a.result.order !== b.result.order) {
|
||||
return a.result.order - b.result.order
|
||||
}
|
||||
return a.result.id.localeCompare(b.result.id)
|
||||
}
|
||||
|
||||
export function searchCmdJProjectResults({
|
||||
query,
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}: {
|
||||
query: string
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
projects: readonly Project[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
renderableRepoIds?: ReadonlySet<string>
|
||||
}): CmdJProjectSearchResult[] {
|
||||
// Why: oversized pasted input should not force the palette to scan project,
|
||||
// repo, or group names that may include private workspace details.
|
||||
if (isCmdJPaletteQueryTooLarge(query)) {
|
||||
return []
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
// Why: project/group rows sit after worktree matches, so one-character
|
||||
// searches would add broad noisy navigation targets before intent is clear.
|
||||
if (normalizedQuery.length < 2) {
|
||||
return []
|
||||
}
|
||||
return buildCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
})
|
||||
.map((candidate) => projectRankingForCandidate(normalizedQuery, candidate))
|
||||
.filter((entry): entry is RankedProjectResult => entry !== null)
|
||||
.sort(compareProjectRanked)
|
||||
.map((entry) => entry.result)
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Globe, Settings } from 'lucide-react'
|
||||
import type { CmdJQuickAction } from './quick-actions'
|
||||
|
|
@ -10,7 +11,9 @@ import {
|
|||
type CmdJActionResult,
|
||||
type CmdJSettingsResult
|
||||
} from './palette-results'
|
||||
import { hasCmdJProjectSearchCandidates, searchCmdJProjectResults } from './palette-project-results'
|
||||
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
|
||||
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
|
||||
const noopRun: CmdJQuickAction['run'] = async () => ({ status: 'ok' })
|
||||
const available: CmdJQuickAction['isAvailable'] = () => ({ available: true })
|
||||
|
|
@ -259,3 +262,191 @@ describe('Cmd+J palette middle-band ranking', () => {
|
|||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function repo(id: string, displayName: string, projectGroupId?: string | null): Repo {
|
||||
return {
|
||||
id,
|
||||
path: path.join('/repos', displayName),
|
||||
displayName,
|
||||
badgeColor: '#999999',
|
||||
addedAt: 1,
|
||||
projectGroupId
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function project(id: string, displayName: string): Project {
|
||||
return {
|
||||
id,
|
||||
displayName,
|
||||
badgeColor: '#999999',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function setup(id: string, projectId: string, hostId: string, repoId: string): ProjectHostSetup {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
hostId: hostId as ProjectHostSetup['hostId'],
|
||||
repoId,
|
||||
path: path.join('/repos', repoId),
|
||||
displayName: repoId,
|
||||
setupState: 'ready',
|
||||
setupMethod: 'cloned',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function projectGroup(id: string, name: string, parentGroupId: string | null = null): ProjectGroup {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
parentPath: null,
|
||||
parentGroupId,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('Cmd+J project and repo-group search', () => {
|
||||
it('finds a Project Group by name', () => {
|
||||
const [result] = searchCmdJProjectResults({
|
||||
query: 'infra',
|
||||
projectGroups: [projectGroup('group-1', 'Infrastructure')],
|
||||
repos: [],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'project-group',
|
||||
title: 'Infrastructure',
|
||||
description: 'Repo group',
|
||||
rowKey: 'project-group:group-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('finds a Project by project name and a repo-backed fallback by repo name', () => {
|
||||
const projectResults = searchCmdJProjectResults({
|
||||
query: 'api',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')],
|
||||
projects: [project('project-1', 'API Service')],
|
||||
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')]
|
||||
})
|
||||
const fallbackResults = searchCmdJProjectResults({
|
||||
query: 'source',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')],
|
||||
projects: [project('project-1', 'API Service')],
|
||||
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')]
|
||||
})
|
||||
|
||||
expect(projectResults.map((result) => [result.title, result.rowKey])).toEqual([
|
||||
['API Service', 'project:project-1']
|
||||
])
|
||||
expect(fallbackResults.map((result) => [result.title, result.rowKey])).toEqual([
|
||||
['source-folder', 'repo:repo-2']
|
||||
])
|
||||
})
|
||||
|
||||
it('uses setup-specific project header keys for multi-setup projects on one host', () => {
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'platform',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-b')],
|
||||
projects: [project('project-1', 'Platform')],
|
||||
projectHostSetups: [
|
||||
setup('setup-1', 'project-1', 'local', 'repo-1'),
|
||||
setup('setup-2', 'project-1', 'local', 'repo-2')
|
||||
]
|
||||
})
|
||||
|
||||
expect(results.map((result) => result.rowKey)).toEqual([
|
||||
'project:project-1::setup:repo-1',
|
||||
'project:project-1::setup:repo-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('suppresses raw Project records without renderable repo header targets', () => {
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'orphan',
|
||||
projectGroups: [],
|
||||
repos: [],
|
||||
projects: [project('project-1', 'Orphan Project')],
|
||||
projectHostSetups: []
|
||||
})
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('suppresses repo-backed projects when the sidebar cannot render their header row', () => {
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'archived',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'archived-service')],
|
||||
projects: [project('project-1', 'Archived Service')],
|
||||
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')],
|
||||
renderableRepoIds: new Set()
|
||||
})
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('reports searchable project candidates even when a query has no match', () => {
|
||||
expect(
|
||||
hasCmdJProjectSearchCandidates({
|
||||
projectGroups: [projectGroup('group-1', 'Infrastructure')],
|
||||
repos: [],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
searchCmdJProjectResults({
|
||||
query: 'zzzz',
|
||||
projectGroups: [projectGroup('group-1', 'Infrastructure')],
|
||||
repos: [],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects oversized project queries before reading names', () => {
|
||||
const oversizedQuery = 'secret-palette-query'.repeat(CMD_J_PALETTE_QUERY_MAX_BYTES)
|
||||
const throwingGroup = {
|
||||
get id() {
|
||||
throw new Error('oversized palette queries must not scan project groups')
|
||||
},
|
||||
get name() {
|
||||
throw new Error('oversized palette queries must not scan project groups')
|
||||
}
|
||||
} as unknown as ProjectGroup
|
||||
const throwingRepo = {
|
||||
get id() {
|
||||
throw new Error('oversized palette queries must not scan repos')
|
||||
},
|
||||
get displayName() {
|
||||
throw new Error('oversized palette queries must not scan repos')
|
||||
}
|
||||
} as unknown as Repo
|
||||
|
||||
expect(
|
||||
searchCmdJProjectResults({
|
||||
query: oversizedQuery,
|
||||
projectGroups: [throwingGroup],
|
||||
repos: [throwingRepo],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue