mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
refactor: organize worktree-list into lifecycle dest folders
This commit is contained in:
@@ -235,8 +235,7 @@ inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list/rows/groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list/rows/groups.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts
|
||||
inline src/renderer/src/components/stats/usage-overview-model.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
|
||||
@@ -132,7 +132,7 @@ import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/p
|
||||
import PaletteFilterMenu from '@/components/cmd-j/PaletteFilterMenu'
|
||||
import PaletteFilterChips from '@/components/cmd-j/PaletteFilterChips'
|
||||
import { buildPaletteFilterModel } from '@/components/cmd-j/palette-filter-options'
|
||||
import { getProjectGroupExecutionHostIdForRows } from '@/components/sidebar/worktree-list/viewport/host-filtering'
|
||||
import { getProjectGroupExecutionHostIdForRows } from '@/components/sidebar/worktree-list/listing/host-filtering'
|
||||
import {
|
||||
buildPaletteFilterPredicate,
|
||||
EMPTY_PALETTE_FILTER,
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
getProjectHeaderRevealTarget,
|
||||
type ProjectGroupingModel
|
||||
} from '../sidebar/worktree-list/rows/groups'
|
||||
import { getProjectHeaderRevealTarget } from '../sidebar/worktree-list/grouping/project-grouping'
|
||||
import type { ProjectGroupingModel } from '../sidebar/worktree-list/grouping/project-grouping'
|
||||
import type { SidebarHostOption } from '../sidebar/sidebar-host-options'
|
||||
import { buildPaletteFilterOptionSearchText } from './palette-filter-option-list'
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@ import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getProjectGroupHeaderKey,
|
||||
getProjectHeaderRevealTarget,
|
||||
type ProjectGroupingModel
|
||||
} from '../sidebar/worktree-list/rows/groups'
|
||||
import { getProjectGroupHeaderKey } from '../sidebar/worktree-list/grouping/group-keys'
|
||||
import { getProjectHeaderRevealTarget } from '../sidebar/worktree-list/grouping/project-grouping'
|
||||
import type { ProjectGroupingModel } from '../sidebar/worktree-list/grouping/project-grouping'
|
||||
|
||||
export type CmdJProjectGroupResult = {
|
||||
id: string
|
||||
|
||||
@@ -31,8 +31,8 @@ const HOVER_REVEAL_FILES = [
|
||||
resolve(__dirname, 'sidebar/WorkspaceKanbanStatusLane.tsx'),
|
||||
resolve(__dirname, 'sidebar/WorktreeCardPorts.tsx'),
|
||||
resolve(__dirname, 'sidebar/WorktreeList.tsx'),
|
||||
resolve(__dirname, 'sidebar/worktree-list/headers/HostSectionHeader.tsx'),
|
||||
resolve(__dirname, 'sidebar/worktree-list/headers/SectionHeader.tsx'),
|
||||
resolve(__dirname, 'sidebar/worktree-list/rows/HostSectionHeader.tsx'),
|
||||
resolve(__dirname, 'sidebar/worktree-list/rows/SectionHeader.tsx'),
|
||||
resolve(__dirname, 'sidebar/worktree-list/rows/item-row.tsx'),
|
||||
resolve(__dirname, 'status-bar/ResourceUsageStatusSegment.tsx'),
|
||||
resolve(__dirname, 'status-bar/ports-status-popover-rows.tsx'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SidebarGroupByToggle } from './SidebarGroupByToggle'
|
||||
import type { WorktreeGroupBy } from './worktree-list/rows/groups'
|
||||
import type { WorktreeGroupBy } from './worktree-list/grouping/row-types'
|
||||
|
||||
const roots: Root[] = []
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import type { WorktreeGroupBy } from './worktree-list/rows/groups'
|
||||
import type { WorktreeGroupBy } from './worktree-list/grouping/row-types'
|
||||
import { GROUP_BY_OPTIONS } from './sidebar-workspace-option-items'
|
||||
|
||||
type SidebarGroupByToggleProps = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './worktree-list/reveal/ancestors'
|
||||
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './worktree-list/navigation/reveal-ancestors'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
|
||||
@@ -14,29 +14,27 @@ import {
|
||||
getSettingsFocusedExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope'
|
||||
import { getPinnedWorktreeDisplayPolicy } from './worktree-list/rows/groups'
|
||||
import { selectWorktreeListReviewCacheInputs } from './worktree-list/viewport/review-cache-inputs'
|
||||
import { getPinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-types'
|
||||
import { selectWorktreeListReviewCacheInputs } from './worktree-list/listing/review-cache-inputs'
|
||||
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { SidebarWorktreeListDialogs } from './worktree-list/SidebarWorktreeListDialogs'
|
||||
import { SidebarWorktreeListEmptyState } from './worktree-list/SidebarWorktreeListEmptyState'
|
||||
import { SidebarWorktreeListDialogs } from './worktree-list/rows/ProjectGroupDialogs'
|
||||
import { SidebarWorktreeListEmptyState } from './worktree-list/listing/EmptyState'
|
||||
import { VirtualizedWorktreeViewport } from './worktree-list/viewport/VirtualizedWorktreeViewport'
|
||||
import { markSidebarWorktreeActiveImmediately } from './worktree-list/option-dom'
|
||||
import {
|
||||
EMPTY_PROJECT_GROUPS,
|
||||
NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK
|
||||
} from './worktree-list/viewport/viewport-props'
|
||||
import { useAgentSendTargetWorktreeId } from './worktree-list/use-agent-send-target-worktree'
|
||||
import { useEffectiveCollapsedGroups } from './worktree-list/use-effective-collapsed-groups'
|
||||
import { useProjectGroupDialogs } from './worktree-list/use-project-group-dialogs'
|
||||
import { useSidebarExternalWorktreeCards } from './worktree-list/use-sidebar-external-worktree-cards'
|
||||
import { useSidebarHostVisibleScope } from './worktree-list/use-sidebar-host-visible-scope'
|
||||
import { useSidebarRevealRequests } from './worktree-list/reveal/use-requests'
|
||||
import { useSidebarSectionRows } from './worktree-list/use-sidebar-section-rows'
|
||||
import { useSidebarWorktreeFilters } from './worktree-list/use-sidebar-worktree-filters'
|
||||
import { useSidebarWorktreeSelection } from './worktree-list/use-sidebar-worktree-selection'
|
||||
import { useSidebarWorktreeSortOrder } from './worktree-list/use-sidebar-worktree-sort-order'
|
||||
import { useVisibleSidebarWorktrees } from './worktree-list/use-visible-sidebar-worktrees'
|
||||
import { useWorktreeStatusMutations } from './worktree-list/use-status-mutations'
|
||||
import { markSidebarWorktreeActiveImmediately } from './worktree-list/rows/option-dom'
|
||||
import { EMPTY_PROJECT_GROUPS } from './worktree-list/viewport/viewport-props'
|
||||
import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from './worktree-list/drag/drop-commit-context'
|
||||
import { useAgentSendTargetWorktreeId } from './worktree-list/listing/use-agent-send-target'
|
||||
import { useEffectiveCollapsedGroups } from './worktree-list/listing/use-collapsed-groups'
|
||||
import { useProjectGroupDialogs } from './worktree-list/rows/use-project-group-dialogs'
|
||||
import { useSidebarExternalWorktreeCards } from './worktree-list/listing/use-external-worktree-cards'
|
||||
import { useSidebarHostVisibleScope } from './worktree-list/listing/use-host-visible-scope'
|
||||
import { useSidebarRevealRequests } from './worktree-list/navigation/use-reveal-requests'
|
||||
import { useSidebarSectionRows } from './worktree-list/listing/use-section-rows'
|
||||
import { useSidebarWorktreeFilters } from './worktree-list/listing/use-filters'
|
||||
import { useSidebarWorktreeSelection } from './worktree-list/navigation/use-selection'
|
||||
import { useSidebarWorktreeSortOrder } from './worktree-list/listing/use-sort-order'
|
||||
import { useVisibleSidebarWorktrees } from './worktree-list/listing/use-visible-worktrees'
|
||||
import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations'
|
||||
|
||||
type WorktreeListProps = {
|
||||
scrollOffsetRef: React.MutableRefObject<number>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { WorktreeGroupBy } from './worktree-list/rows/groups'
|
||||
import type { WorktreeGroupBy } from './worktree-list/grouping/row-types'
|
||||
|
||||
export function getEmptyProjectPlaceholderRepoIds(args: {
|
||||
groupBy: WorktreeGroupBy
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { PINNED_GROUP_KEY, type Row } from './worktree-list/rows/groups'
|
||||
import { PINNED_GROUP_KEY } from './worktree-list/grouping/group-keys'
|
||||
import type { Row } from './worktree-list/grouping/row-types'
|
||||
import { addHostSectionRows, type HostSectionRow } from './host-section-rows'
|
||||
|
||||
function repo(id: string, connectionId?: string | null): Repo {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Row } from './worktree-list/rows/groups'
|
||||
import type { Row } from './worktree-list/grouping/row-types'
|
||||
|
||||
export type HostHeaderRow = {
|
||||
type: 'host-header'
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
effectiveExternalWorktreeVisibility,
|
||||
isLegacyRepoForExternalWorktreeVisibility
|
||||
} from '../../../../shared/worktree/ownership'
|
||||
import type { ImportedWorktreesCardCandidate } from './worktree-list/rows/groups'
|
||||
import type { ImportedWorktreesCardCandidate } from './worktree-list/grouping/row-types'
|
||||
|
||||
export function getHiddenImportedWorktrees(
|
||||
detected: DetectedWorktreeListResult | undefined
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types'
|
||||
import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { NewExternalWorktreesInboxCandidate } from './worktree-list/rows/groups'
|
||||
import type { NewExternalWorktreesInboxCandidate } from './worktree-list/grouping/row-types'
|
||||
|
||||
export function buildNewExternalWorktreesInboxCandidates(args: {
|
||||
repos: readonly Repo[]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
function readWorktreeListSource(): string {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL('./worktree-list/headers/SectionHeader.tsx', import.meta.url)),
|
||||
fileURLToPath(new URL('./worktree-list/rows/SectionHeader.tsx', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getSidebarOrderedProjectGroupHeaderIdsByBucket,
|
||||
mapSidebarProjectGroupDropIndexToSiblingInsertIndex
|
||||
} from './project-group-header-drop'
|
||||
import type { Row } from './worktree-list/rows/groups'
|
||||
import type { Row } from './worktree-list/grouping/row-types'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
computeWorktreeSidebarHeaderDropPreview,
|
||||
type WorktreeSidebarHeaderDropPreview
|
||||
} from './worktree-sidebar-header-drop-preview'
|
||||
import type { Row } from './worktree-list/rows/groups'
|
||||
import type { Row } from './worktree-list/grouping/row-types'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
|
||||
export type ProjectGroupHeaderDragBucketKey = string
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
mapSidebarProjectHeaderDropIndexToSiblingInsertIndex,
|
||||
mapSidebarRepoDropIndexToAllRepoInsertAt
|
||||
} from './project-header-drop'
|
||||
import type { Row } from './worktree-list/rows/groups'
|
||||
import type { Row } from './worktree-list/grouping/row-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
describe('getProjectHeaderDragBucketKey', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
computeWorktreeSidebarHeaderDropPreview,
|
||||
type WorktreeSidebarHeaderDropPreview
|
||||
} from './worktree-sidebar-header-drop-preview'
|
||||
import type { Row } from './worktree-list/rows/groups'
|
||||
import type { Row } from './worktree-list/grouping/row-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
export type ProjectHeaderDragBucketKey = string
|
||||
|
||||
@@ -7,18 +7,19 @@ import {
|
||||
} from '../../../../shared/execution-host'
|
||||
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector'
|
||||
import { buildRows, getPinnedWorktreeDisplayPolicy } from './worktree-list/rows/groups'
|
||||
import { buildRows } from './worktree-list/grouping/build-rows'
|
||||
import { getPinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-types'
|
||||
import { addHostSectionRows } from './host-section-rows'
|
||||
import { orderHostSectionOptions } from './host-section-order'
|
||||
import { buildSidebarHostOptions } from './sidebar-host-options'
|
||||
import { getLogicalRepoOrderRankById } from './project-header-drop'
|
||||
import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference'
|
||||
import { selectWorktreeListReviewCacheInputs } from './worktree-list/viewport/review-cache-inputs'
|
||||
import { selectWorktreeListReviewCacheInputs } from './worktree-list/listing/review-cache-inputs'
|
||||
import {
|
||||
filterFolderWorkspacesForVisibleHosts,
|
||||
filterProjectGroupsForVisibleHosts,
|
||||
getVisibleSidebarHostIdSet
|
||||
} from './worktree-list/viewport/host-filtering'
|
||||
} from './worktree-list/listing/host-filtering'
|
||||
|
||||
const EMPTY_REPO_ID_SET: ReadonlySet<string> = Object.freeze(new Set<string>())
|
||||
const EMPTY_IMPORTED_BY_REPO = Object.freeze(new Map()) as never
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorktreeDragGroup } from './worktree-manual-order'
|
||||
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from './worktree-list/rows/groups'
|
||||
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from './worktree-list/grouping/group-keys'
|
||||
|
||||
export type WorktreeDragUnitGroup = WorktreeDragGroup & {
|
||||
units: { worktreeId: string; worktreeIds: string[] }[]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { estimateRenderRowSize, type RenderRow } from './worktree-list/viewport/virtual-rows'
|
||||
import { estimateRenderRowSize } from './worktree-list/viewport/virtual-rows'
|
||||
import type { RenderRow } from './worktree-list/listing/render-row'
|
||||
|
||||
function getEstimatedRenderRowStarts(
|
||||
rows: readonly RenderRow[],
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('getCyclableWorktreeIds', () => {
|
||||
describe('WorktreeList keyboard cycling', () => {
|
||||
it('cycles over the rendered rows instead of rebuilding a parallel layout', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('./worktree-list/viewport/use-keyboard.ts', import.meta.url)),
|
||||
fileURLToPath(new URL('./worktree-list/navigation/use-keyboard.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const navigateWorktree = source.slice(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HostSectionRow } from './host-section-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy, WorktreeRow } from './worktree-list/rows/groups'
|
||||
import type { PinnedWorktreeDisplayPolicy, WorktreeRow } from './worktree-list/grouping/row-types'
|
||||
import { getPreferredWorktreeRows } from './worktree-sidebar-row-preference'
|
||||
|
||||
/** Worktree ids in sidebar order, taken from the rows the sidebar actually
|
||||
|
||||
@@ -6,10 +6,18 @@ import type {
|
||||
import type { WorktreeDragGroup } from '../../worktree-manual-order'
|
||||
import type { WorktreeDragUnitGroup } from '../../worktree-drag-units'
|
||||
import type { WorktreeSidebarDropPreview } from '../../worktree-sidebar-drop-preview'
|
||||
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
|
||||
import type { WorktreeStatusDropRequest } from './use-session'
|
||||
import type { WorktreeSidebarLineageDropTarget } from './row-state'
|
||||
|
||||
export const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {}
|
||||
|
||||
export type WorktreeStatusDropAtIndexArgs = {
|
||||
worktreeIds: readonly string[]
|
||||
status: WorkspaceStatus
|
||||
dropIndex: number
|
||||
groups: readonly WorktreeDragGroup[]
|
||||
}
|
||||
|
||||
// The shared surface every drop path (pointer, native drag, document capture) commits through.
|
||||
export type WorktreeDropCommitContext = {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from '../rows/groups'
|
||||
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from '../grouping/group-keys'
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
import type { WorktreeDragGroup } from '../../worktree-manual-order'
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import { resolveWorkspaceKanbanCardDropCommitTarget } from '../../workspace-kanb
|
||||
import { getFullDropIndexForWorktreeDragUnit } from '../../worktree-drag-units'
|
||||
import { resolveWorktreeSidebarStatusDropCommitTarget } from '../../worktree-sidebar-drop-preview'
|
||||
import { getPointerDropStatusTarget, shouldPreferSidebarStatusDropTarget } from './status-target'
|
||||
import type { WorktreeDropCommitContext } from './drop-commit-context'
|
||||
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
|
||||
import type {
|
||||
WorktreeDropCommitContext,
|
||||
WorktreeStatusDropAtIndexArgs
|
||||
} from './drop-commit-context'
|
||||
import type { WorktreeSidebarStatusDropTarget } from '../../worktree-sidebar-drop-preview'
|
||||
import { NO_WORKTREE_SIDEBAR_DROP_TARGET, type WorktreePointerDrag } from './row-state'
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeDropCommitContext } from './drop-commit-context'
|
||||
import type { useWorktreeDragRuntime } from './use-runtime'
|
||||
import type { useWorktreeDragSession } from './use-session'
|
||||
import type { useWorktreeLineageDropCommit } from './use-lineage-drop-commit'
|
||||
|
||||
/** Bundles the drag session, lineage commits, and viewport callbacks every drop path reads. */
|
||||
export function useWorktreeDropCommitContext(args: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
session: ReturnType<typeof useWorktreeDragSession>
|
||||
lineageDrop: ReturnType<typeof useWorktreeLineageDropCommit>
|
||||
runtime: ReturnType<typeof useWorktreeDragRuntime>
|
||||
onMoveWorktreesToStatus: WorktreeDropCommitContext['onMoveWorktreesToStatus']
|
||||
onMoveWorktreesToStatusAtIndex: WorktreeDropCommitContext['onMoveWorktreesToStatusAtIndex']
|
||||
onReorderWorktrees: WorktreeDropCommitContext['onReorderWorktrees']
|
||||
onPinWorktrees: WorktreeDropCommitContext['onPinWorktrees']
|
||||
}): WorktreeDropCommitContext {
|
||||
const { scrollRef, workspaceStatuses, session, lineageDrop, runtime } = args
|
||||
const {
|
||||
onMoveWorktreesToStatus,
|
||||
onMoveWorktreesToStatusAtIndex,
|
||||
onReorderWorktrees,
|
||||
onPinWorktrees
|
||||
} = args
|
||||
return useMemo<WorktreeDropCommitContext>(
|
||||
() => ({
|
||||
scrollRef,
|
||||
workspaceStatuses,
|
||||
worktreeDragGroups: session.worktreeDragGroups,
|
||||
worktreeDragUnitGroups: session.worktreeDragUnitGroups,
|
||||
computeWorktreeDrop: session.computeWorktreeDrop,
|
||||
computeWorktreeStatusDrop: session.computeWorktreeStatusDrop,
|
||||
refreshWorktreeDragSession: session.refreshWorktreeDragSession,
|
||||
getEligibleLineageDropTarget: lineageDrop.getEligibleLineageDropTarget,
|
||||
commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop,
|
||||
clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents,
|
||||
clearWorktreeDrag: runtime.clearWorktreeDrag,
|
||||
onMoveWorktreesToStatus,
|
||||
onMoveWorktreesToStatusAtIndex,
|
||||
onReorderWorktrees,
|
||||
onPinWorktrees
|
||||
}),
|
||||
[
|
||||
lineageDrop.clearReorderedWorktreeParents,
|
||||
lineageDrop.commitWorktreeLineageParentDrop,
|
||||
lineageDrop.getEligibleLineageDropTarget,
|
||||
onMoveWorktreesToStatus,
|
||||
onMoveWorktreesToStatusAtIndex,
|
||||
onPinWorktrees,
|
||||
onReorderWorktrees,
|
||||
runtime.clearWorktreeDrag,
|
||||
scrollRef,
|
||||
session.computeWorktreeDrop,
|
||||
session.computeWorktreeStatusDrop,
|
||||
session.refreshWorktreeDragSession,
|
||||
session.worktreeDragGroups,
|
||||
session.worktreeDragUnitGroups,
|
||||
workspaceStatuses
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { HostHeaderRow, HostSectionRow } from '../../host-section-rows'
|
||||
import type { Row, WorktreeGroupBy } from '../rows/groups'
|
||||
import type { RenderRow } from '../viewport/virtual-rows'
|
||||
import type { Row, WorktreeGroupBy } from '../grouping/row-types'
|
||||
import type { RenderRow } from '../listing/render-row'
|
||||
import {
|
||||
getProjectGroupHeaderSectionEndByGroupId,
|
||||
getRepoHeaderSectionEndByRepoId
|
||||
@@ -17,7 +17,7 @@ import { useRepoHeaderDrag } from '../../project-header-drag'
|
||||
import { getSidebarOrderedRepoHeaderIdsByBucket } from '../../project-header-drop'
|
||||
import { useProjectGroupHeaderDrag } from '../../project-group-header-drag'
|
||||
import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from '../../project-group-header-drop'
|
||||
import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from '../scroll/tuning'
|
||||
import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from '../viewport/use-scroll-suppression'
|
||||
|
||||
export type WorktreeSidebarHeaderDrag = ReturnType<typeof useWorktreeSidebarHeaderDrag>
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from '../../worktree-sidebar-pointer-drag-dom'
|
||||
import { getWorktreeSidebarDragRectsForGroup } from '../../worktree-sidebar-drag-autoscroll'
|
||||
import { getWorktreeSidebarDragGrab } from '../../worktree-sidebar-drag-geometry'
|
||||
import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from '../viewport/viewport-props'
|
||||
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
|
||||
import type { WorktreeDropCommitContext } from './drop-commit-context'
|
||||
import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from './drop-commit-context'
|
||||
import type {
|
||||
WorktreeDropCommitContext,
|
||||
WorktreeStatusDropAtIndexArgs
|
||||
} from './drop-commit-context'
|
||||
import type { WorktreeDragRuntime } from './use-runtime'
|
||||
import type { WorktreeDragSession } from './use-session'
|
||||
import { useWorktreePointerDragAutoscroll } from './use-pointer-autoscroll'
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { WorktreeDragRuntime } from './use-runtime'
|
||||
import type { WorktreeDropCommitContext } from './drop-commit-context'
|
||||
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
|
||||
import type {
|
||||
WorktreeDropCommitContext,
|
||||
WorktreeStatusDropAtIndexArgs
|
||||
} from './drop-commit-context'
|
||||
import { commitWorktreePointerDrop } from './pointer-commit'
|
||||
import { SIDEBAR_POINTER_DRAG_THRESHOLD_PX } from '../scroll/tuning'
|
||||
import type { WorktreePointerDrag } from './row-state'
|
||||
|
||||
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
|
||||
|
||||
// Pointer drags escape the row that started them, so move/up/cancel are tracked on the
|
||||
// window in capture phase.
|
||||
export function useWorktreePointerDragWindowEvents(args: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react'
|
||||
import type React from 'react'
|
||||
import type { WorkspaceStatus } from '../../../../../../shared/worktree/types'
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
import { PINNED_GROUP_KEY } from '../rows/groups'
|
||||
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
|
||||
import { WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP } from '../viewport/virtual-rows'
|
||||
import { getWorkspaceStatusGroupKey } from '../../workspace-status'
|
||||
import { expandDraggedWorktreeIdsForVisibleLineage } from '../../worktree-manual-order'
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type WorktreeSidebarDropPreview
|
||||
} from '../../worktree-sidebar-drop-preview'
|
||||
import { getWorktreeDragGroups, getWorktreeDragIndexes } from './groups'
|
||||
import type { WorktreeItemRow } from '../rows/renderable-rows'
|
||||
import type { WorktreeItemRow } from '../listing/renderable-rows'
|
||||
|
||||
export type WorktreeStatusDropRequest = {
|
||||
pointerY: number
|
||||
|
||||
+7
-7
@@ -4,18 +4,18 @@ import type {
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition,
|
||||
Worktree
|
||||
} from '../../../../../shared/worktree/types'
|
||||
import type { WorktreeMeta } from '../../../../../shared/worktree/meta-types'
|
||||
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../workspace-status'
|
||||
} from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
|
||||
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
|
||||
import {
|
||||
buildManualOrderUpdatesForGroupDrop,
|
||||
buildManualOrderUpdatesForVisibleGroups,
|
||||
shouldWriteManualOrderForGroupDrop,
|
||||
type WorktreeDragGroup
|
||||
} from '../worktree-manual-order'
|
||||
import { buildWorkspaceKanbanSidebarDropUpdates } from '../workspace-kanban-sidebar-drop'
|
||||
import type { SortBy } from '../smart-sort'
|
||||
import type { WorktreeStatusDropAtIndexArgs } from './viewport/viewport-props'
|
||||
} from '../../worktree-manual-order'
|
||||
import { buildWorkspaceKanbanSidebarDropUpdates } from '../../workspace-kanban-sidebar-drop'
|
||||
import type { SortBy } from '../../smart-sort'
|
||||
import type { WorktreeStatusDropAtIndexArgs } from './drop-commit-context'
|
||||
|
||||
function buildRankByWorktreeId(
|
||||
groups: readonly WorktreeDragGroup[],
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useMemo } from 'react'
|
||||
import type React from 'react'
|
||||
import type { WorkspaceStatus } from '../../../../../../shared/worktree/types'
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
import { PINNED_GROUP_KEY, type WorktreeGroupBy } from '../rows/groups'
|
||||
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
|
||||
import type { WorktreeGroupBy } from '../grouping/row-types'
|
||||
import { hasWorkspaceDragData, readWorkspaceDragDataIds } from '../../workspace-status'
|
||||
import { useWorkspaceStatusDocumentDrop } from '../../use-workspace-status-drop'
|
||||
import type { WorktreeDropCommitContext } from './drop-commit-context'
|
||||
|
||||
+9
-16
@@ -4,22 +4,18 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getExecutionHostLabel } from '../../../../../../shared/execution-host'
|
||||
import { projectHostSetupProjectionFromRepos } from '../../../../../../shared/project-host-setup-projection'
|
||||
import {
|
||||
ALL_GROUP_META,
|
||||
buildRows,
|
||||
getGroupKeyForWorktree,
|
||||
getGroupKeysForWorktree,
|
||||
getLineageGroupKey,
|
||||
getLineageRenderInfo,
|
||||
getPRGroupKey,
|
||||
PINNED_GROUP_KEY,
|
||||
type PendingCreationRef
|
||||
} from './groups'
|
||||
import { buildRows } from './build-rows'
|
||||
import { ALL_GROUP_META, getLineageGroupKey, getPRGroupKey, PINNED_GROUP_KEY } from './group-keys'
|
||||
import { getGroupKeyForWorktree, getGroupKeysForWorktree } from './worktree-group-keys'
|
||||
import type { PendingCreationRef } from './row-types'
|
||||
import {
|
||||
REPO_HEADER_ACTION_BUTTON_CLASS,
|
||||
REPO_HEADER_ACTION_REVEAL_CLASS
|
||||
} from '../../repo-header-action-button-class'
|
||||
import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection'
|
||||
import {
|
||||
getLineageRenderInfo,
|
||||
getWorktreeLineageAncestors
|
||||
} from '../../worktree-lineage-projection'
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types'
|
||||
@@ -64,10 +60,7 @@ function readWorktreeListSource(): string {
|
||||
}
|
||||
|
||||
function readSectionHeaderRowSource(): string {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL('../headers/SectionHeader.tsx', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
return readFileSync(fileURLToPath(new URL('../rows/SectionHeader.tsx', import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const remoteRepo: Repo = {
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses'
|
||||
import type { AppState } from '../../../../store/types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { getCyclicProjectedWorktreeLineageIds } from '../../worktree-lineage-projection'
|
||||
import { ALL_GROUP_KEY, ALL_GROUP_META } from './group-keys'
|
||||
import { appendOrderedGroups } from './group-sections'
|
||||
import type { SectionAppendContext } from './group-sections'
|
||||
import {
|
||||
getHostWorktreeCounts,
|
||||
getHostWorktreeIds,
|
||||
getMixedWorktreeHostContextLabels
|
||||
} from './host-labels'
|
||||
import { buildProjectGroupingIndex } from './project-grouping'
|
||||
import type { ProjectGroupingModel } from './project-grouping'
|
||||
import { appendProjectGroupSections } from './project-group-sections'
|
||||
import { appendWorktreeRows, buildPendingCreationRow, emitPinnedGroup } from './row-builders'
|
||||
import { getPinnedWorktreeDisplayPolicy } from './row-types'
|
||||
import type {
|
||||
ImportedWorktreesCardCandidate,
|
||||
NewExternalWorktreesInboxCandidate,
|
||||
PendingCreationRef,
|
||||
PinnedWorktreeDisplayPolicy,
|
||||
Row,
|
||||
WorktreeGroupBy
|
||||
} from './row-types'
|
||||
import { getRenderedNaturalAnchorRepoIds, withRepoSectionDisplayLabels } from './section-order'
|
||||
import { buildOrderedGroups } from './worktree-grouping'
|
||||
|
||||
export function buildRows(
|
||||
groupBy: WorktreeGroupBy,
|
||||
worktrees: Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, unknown> | null,
|
||||
collapsedGroups: Set<string>,
|
||||
repoOrder?: Map<string, number>,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
projectOrderBy: ProjectOrderBy = 'manual',
|
||||
lineageById: Record<string, WorktreeLineage> = {},
|
||||
worktreeMap = new Map<string, Worktree>(worktrees.map((worktree) => [worktree.id, worktree])),
|
||||
nestLineage = false,
|
||||
settings?: AppState['settings'],
|
||||
projectGroups: readonly ProjectGroup[] = [],
|
||||
placeholderRepoIds: ReadonlySet<string> = new Set(),
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
|
||||
newExternalWorktreesInboxByRepo: ReadonlyMap<
|
||||
string,
|
||||
NewExternalWorktreesInboxCandidate
|
||||
> = new Map(),
|
||||
pendingCreations: readonly PendingCreationRef[] = [],
|
||||
projectGrouping?: ProjectGroupingModel,
|
||||
folderWorkspaces: readonly FolderWorkspace[] = [],
|
||||
hostLabelById?: ReadonlyMap<string, string>,
|
||||
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID,
|
||||
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings)
|
||||
): Row[] {
|
||||
const result: Row[] = []
|
||||
const projectIndex = buildProjectGroupingIndex(projectGrouping)
|
||||
const cyclicLineageIds = nestLineage
|
||||
? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
|
||||
: new Set<string>()
|
||||
|
||||
const pendingByRepo = new Map<string, PendingCreationRef[]>()
|
||||
for (const creation of pendingCreations) {
|
||||
const list = pendingByRepo.get(creation.repoId) ?? []
|
||||
list.push(creation)
|
||||
pendingByRepo.set(creation.repoId, list)
|
||||
}
|
||||
|
||||
// Why: non-repo groupings have no repo section to nest an in-progress create
|
||||
// under, so surface them at the very top (where the old global strip sat)
|
||||
// rather than dropping them. Repo grouping nests them under their repo below.
|
||||
if (groupBy !== 'repo' && pendingCreations.length > 0) {
|
||||
for (const creation of pendingCreations) {
|
||||
result.push(buildPendingCreationRow(creation, repoMap))
|
||||
}
|
||||
}
|
||||
|
||||
const naturalWorktrees =
|
||||
pinnedDisplayPolicy === 'duplicate-in-groups'
|
||||
? worktrees
|
||||
: worktrees.filter((worktree) => !worktree.isPinned)
|
||||
const mixedWorktreeHostContextLabels = getMixedWorktreeHostContextLabels(
|
||||
naturalWorktrees,
|
||||
repoMap,
|
||||
hostLabelById,
|
||||
defaultHostId
|
||||
)
|
||||
const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({
|
||||
groupBy,
|
||||
worktrees: naturalWorktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
collapsedGroups,
|
||||
workspaceStatuses,
|
||||
settings,
|
||||
projectGrouping
|
||||
})
|
||||
emitPinnedGroup(
|
||||
worktrees,
|
||||
repoMap,
|
||||
defaultHostId,
|
||||
collapsedGroups,
|
||||
renderedNaturalAnchorRepoIds,
|
||||
importedWorktreesByRepo,
|
||||
groupBy !== 'repo',
|
||||
result
|
||||
)
|
||||
if (groupBy === 'none') {
|
||||
if (naturalWorktrees.length > 0) {
|
||||
result.push({
|
||||
type: 'header',
|
||||
key: ALL_GROUP_KEY,
|
||||
label: ALL_GROUP_META.label,
|
||||
count: naturalWorktrees.length,
|
||||
tone: ALL_GROUP_META.tone,
|
||||
icon: ALL_GROUP_META.icon,
|
||||
hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId),
|
||||
hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId),
|
||||
worktreeIds: naturalWorktrees.map((worktree) => worktree.id)
|
||||
})
|
||||
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
|
||||
appendWorktreeRows(result, naturalWorktrees, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
collapsedGroups,
|
||||
groupDepth: 0,
|
||||
sectionKey: ALL_GROUP_KEY,
|
||||
hostContextLabelByWorktreeId: mixedWorktreeHostContextLabels,
|
||||
cyclicLineageIds
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const orderedGroups = buildOrderedGroups({
|
||||
groupBy,
|
||||
naturalWorktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
settings,
|
||||
workspaceStatuses,
|
||||
projectIndex,
|
||||
placeholderRepoIds,
|
||||
importedWorktreesByRepo,
|
||||
newExternalWorktreesInboxByRepo,
|
||||
pendingByRepo,
|
||||
repoOrder,
|
||||
projectOrderBy
|
||||
})
|
||||
|
||||
const sectionContext: SectionAppendContext = {
|
||||
result,
|
||||
groupBy,
|
||||
collapsedGroups,
|
||||
workspaceStatuses,
|
||||
repoMap,
|
||||
defaultHostId,
|
||||
hostLabelById,
|
||||
projectIndex,
|
||||
importedWorktreesByRepo,
|
||||
newExternalWorktreesInboxByRepo,
|
||||
pendingByRepo,
|
||||
mixedWorktreeHostContextLabels,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
nestLineage,
|
||||
cyclicLineageIds
|
||||
}
|
||||
|
||||
if (groupBy !== 'repo' || projectGroups.length === 0) {
|
||||
appendOrderedGroups(
|
||||
sectionContext,
|
||||
groupBy === 'repo' ? withRepoSectionDisplayLabels(orderedGroups) : orderedGroups
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
appendProjectGroupSections(sectionContext, {
|
||||
orderedGroups,
|
||||
projectGroups,
|
||||
folderWorkspaces,
|
||||
projectOrderBy,
|
||||
repoOrder
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { CircleX, FolderTree, List, Pin } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { branchName } from '../../../../lib/git-utils'
|
||||
import {
|
||||
ConductorDoneIcon,
|
||||
ConductorProgressIcon,
|
||||
ConductorReviewIcon
|
||||
} from '../../workspace-status-icons'
|
||||
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../../../shared/project-groups'
|
||||
import type { AppState } from '../../../../store/types'
|
||||
import {
|
||||
getGitHubPRCacheKey,
|
||||
getLegacyGitHubPRCacheKey
|
||||
} from '../../../../store/slices/github-cache-key'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
|
||||
|
||||
export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
|
||||
|
||||
export const PR_GROUP_META: Record<
|
||||
PRGroupKey,
|
||||
{
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
tone: string
|
||||
}
|
||||
> = {
|
||||
done: {
|
||||
get label() {
|
||||
return translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done')
|
||||
},
|
||||
icon: ConductorDoneIcon,
|
||||
tone: 'text-[#c7a594]'
|
||||
},
|
||||
'in-review': {
|
||||
get label() {
|
||||
return translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review')
|
||||
},
|
||||
icon: ConductorReviewIcon,
|
||||
tone: 'text-[#16a34a]'
|
||||
},
|
||||
'in-progress': {
|
||||
get label() {
|
||||
return translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress')
|
||||
},
|
||||
icon: ConductorProgressIcon,
|
||||
tone: 'text-[#d4a300]'
|
||||
},
|
||||
closed: {
|
||||
get label() {
|
||||
return translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed')
|
||||
},
|
||||
icon: CircleX,
|
||||
tone: 'text-zinc-600 dark:text-zinc-300'
|
||||
}
|
||||
}
|
||||
|
||||
export const PROJECT_GROUP_META = {
|
||||
tone: 'text-foreground',
|
||||
icon: FolderTree
|
||||
} as const
|
||||
|
||||
export function getProjectGroupHeaderKey(groupId: string | null): string {
|
||||
return groupId ? `project-group:${groupId}` : UNGROUPED_PROJECT_GROUP_KEY
|
||||
}
|
||||
|
||||
export const PINNED_GROUP_KEY = 'pinned'
|
||||
|
||||
export const PINNED_GROUP_META = {
|
||||
get label() {
|
||||
return translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned')
|
||||
},
|
||||
tone: 'text-foreground',
|
||||
icon: Pin
|
||||
} as const
|
||||
|
||||
export const ALL_GROUP_KEY = 'all'
|
||||
|
||||
export const ALL_GROUP_META = {
|
||||
get label() {
|
||||
return translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All')
|
||||
},
|
||||
tone: 'text-foreground',
|
||||
icon: List
|
||||
} as const
|
||||
|
||||
export const LINEAGE_GROUP_PREFIX = 'lineage:'
|
||||
|
||||
export function getLineageGroupKey(worktreeId: string): string {
|
||||
return `${LINEAGE_GROUP_PREFIX}${worktreeId}`
|
||||
}
|
||||
|
||||
export function getPRGroupKey(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, unknown> | null,
|
||||
settings?: AppState['settings']
|
||||
): PRGroupKey {
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
const branch = branchName(worktree.branch)
|
||||
const repoScopedCacheKey =
|
||||
repo && branch
|
||||
? getGitHubPRCacheKey(
|
||||
repo.path,
|
||||
repo.id,
|
||||
branch,
|
||||
settings,
|
||||
repo.connectionId,
|
||||
repo.executionHostId,
|
||||
true
|
||||
)
|
||||
: ''
|
||||
const canUseLegacyPRCache = repo !== undefined && !repo.connectionId && !repo.executionHostId
|
||||
const legacyRepoScopedCacheKey =
|
||||
canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) : ''
|
||||
const legacyPathScopedCacheKey =
|
||||
canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) : ''
|
||||
// Why: PR refreshes now write repo-id scoped entries; legacy path entries may
|
||||
// still exist from persisted cache, but must not override fresher repo data.
|
||||
const prEntry = prCache
|
||||
? ((repoScopedCacheKey
|
||||
? (prCache[repoScopedCacheKey] as { data?: { state?: string } } | undefined)
|
||||
: undefined) ??
|
||||
(legacyRepoScopedCacheKey
|
||||
? (prCache[legacyRepoScopedCacheKey] as { data?: { state?: string } } | undefined)
|
||||
: undefined) ??
|
||||
(legacyPathScopedCacheKey
|
||||
? (prCache[legacyPathScopedCacheKey] as { data?: { state?: string } } | undefined)
|
||||
: undefined))
|
||||
: undefined
|
||||
const pr = prEntry?.data
|
||||
|
||||
if (!pr) {
|
||||
return 'in-progress'
|
||||
}
|
||||
if (pr.state === 'merged') {
|
||||
return 'done'
|
||||
}
|
||||
if (pr.state === 'closed') {
|
||||
return 'closed'
|
||||
}
|
||||
if (pr.state === 'draft') {
|
||||
return 'in-progress'
|
||||
}
|
||||
return 'in-review'
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a "Pinned" header + its items into `result`.
|
||||
*
|
||||
* Why: the dedicated Pinned section is always present for pinned worktrees;
|
||||
* the display policy decides whether their natural group rows also render.
|
||||
*/
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import {
|
||||
getWorkspaceStatusFromGroupKey,
|
||||
getWorkspaceStatusVisualMeta
|
||||
} from '../../workspace-status'
|
||||
import { PROJECT_GROUP_META, PR_GROUP_META } from './group-keys'
|
||||
import type { PRGroupKey } from './group-keys'
|
||||
import { getHostWorktreeCounts, getHostWorktreeIds, getMixedHostContextLabels } from './host-labels'
|
||||
import type { OrderedGroupEntry, ProjectGroupingIndex } from './project-grouping'
|
||||
import {
|
||||
appendWorktreeRows,
|
||||
buildImportedWorktreesCardRow,
|
||||
buildNewExternalWorktreesInboxRow,
|
||||
buildPendingCreationRow
|
||||
} from './row-builders'
|
||||
import type {
|
||||
ImportedWorktreesCardCandidate,
|
||||
NewExternalWorktreesInboxCandidate,
|
||||
PendingCreationRef,
|
||||
Row,
|
||||
WorktreeGroupBy
|
||||
} from './row-types'
|
||||
import { orderMainWorktreeFirst } from './section-order'
|
||||
|
||||
/** Everything section emission reads that stays fixed for one buildRows call. */
|
||||
export type SectionAppendContext = {
|
||||
result: Row[]
|
||||
groupBy: WorktreeGroupBy
|
||||
collapsedGroups: Set<string>
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
repoMap: Map<string, Repo>
|
||||
defaultHostId: ExecutionHostId
|
||||
hostLabelById: ReadonlyMap<string, string> | undefined
|
||||
projectIndex: ProjectGroupingIndex | null
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
|
||||
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
|
||||
pendingByRepo: ReadonlyMap<string, PendingCreationRef[]>
|
||||
mixedWorktreeHostContextLabels: Map<string, string> | undefined
|
||||
lineageById: Record<string, WorktreeLineage>
|
||||
worktreeMap: Map<string, Worktree>
|
||||
nestLineage: boolean
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export function appendOrderedGroups(
|
||||
ctx: SectionAppendContext,
|
||||
groupsToAppend: OrderedGroupEntry[],
|
||||
projectGroupDepth = 0
|
||||
): void {
|
||||
const {
|
||||
result,
|
||||
groupBy,
|
||||
collapsedGroups,
|
||||
workspaceStatuses,
|
||||
repoMap,
|
||||
defaultHostId,
|
||||
hostLabelById,
|
||||
projectIndex,
|
||||
importedWorktreesByRepo,
|
||||
newExternalWorktreesInboxByRepo,
|
||||
pendingByRepo,
|
||||
mixedWorktreeHostContextLabels,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
nestLineage,
|
||||
cyclicLineageIds
|
||||
} = ctx
|
||||
for (const [key, group] of groupsToAppend) {
|
||||
const isCollapsed = collapsedGroups.has(key)
|
||||
const repo = group.repo
|
||||
const header =
|
||||
groupBy === 'repo'
|
||||
? {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: group.label,
|
||||
count: group.items.length,
|
||||
tone: PROJECT_GROUP_META.tone,
|
||||
icon: PROJECT_GROUP_META.icon,
|
||||
repo,
|
||||
projectGroupDepth
|
||||
}
|
||||
: groupBy === 'workspace-status'
|
||||
? (() => {
|
||||
const workspaceStatus =
|
||||
getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ??
|
||||
workspaceStatuses[0]?.id ??
|
||||
'in-progress'
|
||||
const definition = workspaceStatuses.find((status) => status.id === workspaceStatus)
|
||||
const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus)
|
||||
return {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: definition?.label ?? workspaceStatus,
|
||||
count: group.items.length,
|
||||
tone: meta.tone,
|
||||
icon: meta.icon,
|
||||
hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
|
||||
hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
|
||||
worktreeIds: group.items.map((worktree) => worktree.id)
|
||||
}
|
||||
})()
|
||||
: (() => {
|
||||
const prGroup = key.replace(/^pr:/, '') as PRGroupKey
|
||||
const meta = PR_GROUP_META[prGroup]
|
||||
return {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: meta.label,
|
||||
count: group.items.length,
|
||||
tone: meta.tone,
|
||||
icon: meta.icon,
|
||||
hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
|
||||
hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
|
||||
worktreeIds: group.items.map((worktree) => worktree.id)
|
||||
}
|
||||
})()
|
||||
|
||||
result.push(header)
|
||||
if (!isCollapsed) {
|
||||
if (groupBy === 'repo') {
|
||||
const repoIds =
|
||||
group.repoIds.size > 0
|
||||
? [...group.repoIds]
|
||||
: repo
|
||||
? [repo.id]
|
||||
: key.startsWith('repo:')
|
||||
? [key.slice('repo:'.length)]
|
||||
: []
|
||||
for (const repoId of repoIds) {
|
||||
const candidate = importedWorktreesByRepo.get(repoId)
|
||||
if (candidate) {
|
||||
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
|
||||
}
|
||||
}
|
||||
for (const repoId of repoIds) {
|
||||
const candidate = newExternalWorktreesInboxByRepo.get(repoId)
|
||||
if (candidate) {
|
||||
result.push(buildNewExternalWorktreesInboxRow(candidate))
|
||||
}
|
||||
}
|
||||
// Why: surface in-progress creates at the top of their own repo so the
|
||||
// new workspace appears where it will land, not flashed to the very top
|
||||
// of the sidebar.
|
||||
for (const repoId of repoIds) {
|
||||
for (const creation of pendingByRepo.get(repoId) ?? []) {
|
||||
result.push(buildPendingCreationRow(creation, repoMap))
|
||||
}
|
||||
}
|
||||
}
|
||||
const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items
|
||||
const hostContextLabelByRepoId =
|
||||
groupBy === 'repo'
|
||||
? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById)
|
||||
: undefined
|
||||
const hostContextLabelByWorktreeId =
|
||||
groupBy === 'repo' ? undefined : mixedWorktreeHostContextLabels
|
||||
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
collapsedGroups,
|
||||
groupDepth: projectGroupDepth,
|
||||
sectionKey: key,
|
||||
hostContextLabelByRepoId,
|
||||
hostContextLabelByWorktreeId,
|
||||
cyclicLineageIds
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
getWorktreeExecutionHostId
|
||||
} from '../../../../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { ProjectGroupingIndex, WorktreeGroupEntry } from './project-grouping'
|
||||
|
||||
function getRepoHostLabel(
|
||||
repoId: string,
|
||||
repoMap: Map<string, Repo>,
|
||||
projectIndex: ProjectGroupingIndex | null,
|
||||
hostLabelById: ReadonlyMap<string, string> | undefined
|
||||
): string | null {
|
||||
const setup = projectIndex?.setupByRepoId.get(repoId)
|
||||
if (setup) {
|
||||
return hostLabelById?.get(setup.hostId) ?? getExecutionHostLabel(setup.hostId)
|
||||
}
|
||||
const repo = repoMap.get(repoId)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
return hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId)
|
||||
}
|
||||
|
||||
export function getMixedHostContextLabels(
|
||||
group: WorktreeGroupEntry,
|
||||
repoMap: Map<string, Repo>,
|
||||
projectIndex: ProjectGroupingIndex | null,
|
||||
hostLabelById: ReadonlyMap<string, string> | undefined
|
||||
): Map<string, string> | undefined {
|
||||
const labelsByRepoId = new Map<string, string>()
|
||||
const uniqueLabels = new Set<string>()
|
||||
for (const repoId of group.repoIds) {
|
||||
const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById)
|
||||
if (!label) {
|
||||
continue
|
||||
}
|
||||
labelsByRepoId.set(repoId, label)
|
||||
uniqueLabels.add(label)
|
||||
}
|
||||
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
|
||||
}
|
||||
|
||||
export function getMixedWorktreeHostContextLabels(
|
||||
worktrees: readonly Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
hostLabelById: ReadonlyMap<string, string> | undefined,
|
||||
defaultHostId: ExecutionHostId
|
||||
): Map<string, string> | undefined {
|
||||
const labelsByWorktreeId = new Map<string, string>()
|
||||
const uniqueHostIds = new Set<ExecutionHostId>()
|
||||
for (const worktree of worktrees) {
|
||||
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
|
||||
uniqueHostIds.add(hostId)
|
||||
labelsByWorktreeId.set(worktree.id, hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId))
|
||||
}
|
||||
return uniqueHostIds.size > 1 ? labelsByWorktreeId : undefined
|
||||
}
|
||||
|
||||
export function getHostWorktreeCounts(
|
||||
worktrees: readonly Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
defaultHostId: ExecutionHostId
|
||||
): Map<ExecutionHostId, number> | undefined {
|
||||
if (worktrees.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const counts = new Map<ExecutionHostId, number>()
|
||||
const seenWorktreeIds = new Set<string>()
|
||||
for (const worktree of worktrees) {
|
||||
if (seenWorktreeIds.has(worktree.id)) {
|
||||
continue
|
||||
}
|
||||
seenWorktreeIds.add(worktree.id)
|
||||
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
|
||||
counts.set(hostId, (counts.get(hostId) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function getHostWorktreeIds(
|
||||
worktrees: readonly Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
defaultHostId: ExecutionHostId
|
||||
): Map<ExecutionHostId, string[]> | undefined {
|
||||
if (worktrees.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const idsByHost = new Map<ExecutionHostId, string[]>()
|
||||
const seenWorktreeIds = new Set<string>()
|
||||
for (const worktree of worktrees) {
|
||||
if (seenWorktreeIds.has(worktree.id)) {
|
||||
continue
|
||||
}
|
||||
seenWorktreeIds.add(worktree.id)
|
||||
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
|
||||
const ids = idsByHost.get(hostId) ?? []
|
||||
ids.push(worktree.id)
|
||||
idsByHost.set(hostId, ids)
|
||||
}
|
||||
return idsByHost
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canKeepImportedWorktreesHidden } from './notice-rows'
|
||||
import { getRenderRowKey } from '../viewport/virtual-rows'
|
||||
import { canKeepImportedWorktreesHidden } from '../rows/notice-rows'
|
||||
import { getRenderRowKey } from '../listing/render-row'
|
||||
import { getWorktreeDragGroups, getWorktreeDragIndexes } from '../drag/groups'
|
||||
import { renderRowContainsWorktree } from './render-row-lookup'
|
||||
import { renderRowContainsWorktree } from '../navigation/render-row-lookup'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { Row } from './groups'
|
||||
import type { Row } from './row-types'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
|
||||
import { getEffectiveProjectGroupManualRank } from '../../../../../../shared/project-groups'
|
||||
import { PROJECT_GROUP_META, getProjectGroupHeaderKey } from './group-keys'
|
||||
import { appendOrderedGroups } from './group-sections'
|
||||
import type { SectionAppendContext } from './group-sections'
|
||||
import type { OrderedGroupEntry } from './project-grouping'
|
||||
import {
|
||||
compareRecentRank,
|
||||
recentRankForEntry,
|
||||
withRepoSectionDisplayLabels
|
||||
} from './section-order'
|
||||
|
||||
export function appendProjectGroupSections(
|
||||
ctx: SectionAppendContext,
|
||||
args: {
|
||||
orderedGroups: OrderedGroupEntry[]
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
projectOrderBy: ProjectOrderBy
|
||||
repoOrder: Map<string, number> | undefined
|
||||
}
|
||||
): void {
|
||||
const { orderedGroups, projectGroups, folderWorkspaces, projectOrderBy, repoOrder } = args
|
||||
const { result, collapsedGroups } = ctx
|
||||
|
||||
const groupByProjectGroupId = new Map<string | null, OrderedGroupEntry[]>()
|
||||
for (const entry of orderedGroups) {
|
||||
const repo = entry[1].repo
|
||||
const projectGroupId = repo?.projectGroupId ?? null
|
||||
const list = groupByProjectGroupId.get(projectGroupId) ?? []
|
||||
list.push(entry)
|
||||
groupByProjectGroupId.set(projectGroupId, list)
|
||||
}
|
||||
|
||||
const sortRepoEntriesWithinGroup = (entries: OrderedGroupEntry[]): OrderedGroupEntry[] => {
|
||||
if (projectOrderBy === 'recent') {
|
||||
return [...entries].sort((left, right) =>
|
||||
compareRecentRank(recentRankForEntry(left), recentRankForEntry(right))
|
||||
)
|
||||
}
|
||||
// Manual: within a Project Group, projects order by their per-group rank
|
||||
// (projectGroupOrder), falling back to global repoOrder when unset so drag
|
||||
// midpoint commits and the rendered order stay aligned.
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftRank = getEffectiveProjectGroupManualRank(left[1].repo, repoOrder)
|
||||
const rightRank = getEffectiveProjectGroupManualRank(right[1].repo, repoOrder)
|
||||
return leftRank - rightRank
|
||||
})
|
||||
}
|
||||
|
||||
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const folderWorkspacesByProjectGroupId = new Map<string, FolderWorkspace[]>()
|
||||
for (const workspace of folderWorkspaces) {
|
||||
const group = projectGroupsById.get(workspace.projectGroupId)
|
||||
if (!group?.parentPath) {
|
||||
continue
|
||||
}
|
||||
const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? []
|
||||
list.push(workspace)
|
||||
folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list)
|
||||
}
|
||||
for (const list of folderWorkspacesByProjectGroupId.values()) {
|
||||
list.sort((left, right) => {
|
||||
const leftOrder = left.manualOrder ?? left.sortOrder
|
||||
const rightOrder = right.manualOrder ?? right.sortOrder
|
||||
return rightOrder - leftOrder || left.name.localeCompare(right.name)
|
||||
})
|
||||
}
|
||||
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
|
||||
for (const group of projectGroups) {
|
||||
const parentId =
|
||||
group.parentGroupId && projectGroupsById.has(group.parentGroupId) ? group.parentGroupId : null
|
||||
const children = childGroupsByParentId.get(parentId) ?? []
|
||||
children.push(group)
|
||||
childGroupsByParentId.set(parentId, children)
|
||||
}
|
||||
for (const groups of childGroupsByParentId.values()) {
|
||||
groups.sort(
|
||||
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
||||
const getProjectGroupSubtreeCount = (groupId: string): number => {
|
||||
const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0
|
||||
const folderWorkspaceCount = folderWorkspacesByProjectGroupId.get(groupId)?.length ?? 0
|
||||
const children = childGroupsByParentId.get(groupId) ?? []
|
||||
return children.reduce(
|
||||
(count, child) => count + getProjectGroupSubtreeCount(child.id),
|
||||
directCount + folderWorkspaceCount
|
||||
)
|
||||
}
|
||||
|
||||
const appendProjectGroup = (projectGroup: ProjectGroup, depth: number): void => {
|
||||
const repoEntries = sortRepoEntriesWithinGroup(groupByProjectGroupId.get(projectGroup.id) ?? [])
|
||||
const childGroups = childGroupsByParentId.get(projectGroup.id) ?? []
|
||||
const key = getProjectGroupHeaderKey(projectGroup.id)
|
||||
result.push({
|
||||
type: 'header',
|
||||
key,
|
||||
label: projectGroup.name,
|
||||
count: getProjectGroupSubtreeCount(projectGroup.id),
|
||||
tone: PROJECT_GROUP_META.tone,
|
||||
icon: PROJECT_GROUP_META.icon,
|
||||
projectGroup,
|
||||
projectGroupDepth: depth
|
||||
})
|
||||
if (!collapsedGroups.has(key)) {
|
||||
for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
|
||||
result.push({
|
||||
type: 'folder-workspace',
|
||||
key: `folder-workspace:${folderWorkspace.id}`,
|
||||
folderWorkspace,
|
||||
projectGroup,
|
||||
depth: 0,
|
||||
groupDepth: depth + 1
|
||||
})
|
||||
}
|
||||
appendOrderedGroups(ctx, withRepoSectionDisplayLabels(repoEntries), depth + 1)
|
||||
for (const childGroup of childGroups) {
|
||||
appendProjectGroup(childGroup, depth + 1)
|
||||
}
|
||||
}
|
||||
groupByProjectGroupId.delete(projectGroup.id)
|
||||
}
|
||||
|
||||
for (const projectGroup of childGroupsByParentId.get(null) ?? []) {
|
||||
appendProjectGroup(projectGroup, 0)
|
||||
}
|
||||
|
||||
const remainingRepoEntries = [...(groupByProjectGroupId.get(null) ?? [])]
|
||||
for (const [projectGroupId, entries] of groupByProjectGroupId) {
|
||||
if (projectGroupId === null || projectGroupsById.has(projectGroupId)) {
|
||||
continue
|
||||
}
|
||||
// Why: startup can have repos from hosts whose project-group metadata was
|
||||
// not fetched yet; missing metadata must not make those repos disappear.
|
||||
remainingRepoEntries.push(...entries)
|
||||
}
|
||||
appendOrderedGroups(
|
||||
ctx,
|
||||
withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(remainingRepoEntries)),
|
||||
0
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { toSshExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { parseWslUncPath } from '../../../../../../shared/wsl-paths'
|
||||
import {
|
||||
isWindowsAbsolutePathLike,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../../../../shared/cross-platform-path'
|
||||
|
||||
export type OrderedGroupEntry = [string, WorktreeGroupEntry]
|
||||
|
||||
export type ProjectGroupingModel = {
|
||||
projects: readonly Project[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
}
|
||||
|
||||
export type WorktreeGroupEntry = {
|
||||
label: string
|
||||
items: Worktree[]
|
||||
repo?: Repo
|
||||
repoIds: Set<string>
|
||||
}
|
||||
|
||||
export type ProjectGroupingIndex = {
|
||||
projectById: Map<string, Project>
|
||||
setupByRepoId: Map<string, ProjectHostSetup>
|
||||
surfaceKeysRequiringSetupGroups: Set<string>
|
||||
}
|
||||
|
||||
const projectGroupingIndexCache = new WeakMap<ProjectGroupingModel, ProjectGroupingIndex | null>()
|
||||
|
||||
// Why: provisioned and folder setups are not independent Git checkouts.
|
||||
function isDistinctUserCheckout(setup: ProjectHostSetup): boolean {
|
||||
return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder'
|
||||
}
|
||||
|
||||
// Why: execution target and filesystem namespace independently identify a surface.
|
||||
function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string {
|
||||
return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}`
|
||||
}
|
||||
|
||||
function getExecutionSurface(setup: ProjectHostSetup): string {
|
||||
const connectionId = setup.connectionId?.trim()
|
||||
if (connectionId) {
|
||||
return toSshExecutionHostId(connectionId)
|
||||
}
|
||||
return setup.executionHostId?.trim() || setup.hostId
|
||||
}
|
||||
|
||||
// Why: projection twins differ by row identity, not checkout directory.
|
||||
function getCheckoutIdentity(setup: ProjectHostSetup): string {
|
||||
return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id
|
||||
}
|
||||
|
||||
function getPathSurface(setup: ProjectHostSetup): string {
|
||||
const wslPath = parseWslUncPath(setup.path)
|
||||
if (wslPath) {
|
||||
return `wsl:${wslPath.distro.toLowerCase()}`
|
||||
}
|
||||
if (isWindowsAbsolutePathLike(setup.path)) {
|
||||
return 'windows-host'
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function buildProjectGroupingIndex(
|
||||
model?: ProjectGroupingModel
|
||||
): ProjectGroupingIndex | null {
|
||||
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 checkoutsByProjectSurface = new Map<string, Set<string>>()
|
||||
for (const setup of projectHostSetups) {
|
||||
if (!isDistinctUserCheckout(setup)) {
|
||||
continue
|
||||
}
|
||||
const key = getProjectSetupSurfaceKey(setup)
|
||||
const existing = checkoutsByProjectSurface.get(key)
|
||||
if (existing) {
|
||||
existing.add(getCheckoutIdentity(setup))
|
||||
} else {
|
||||
checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)]))
|
||||
}
|
||||
}
|
||||
const surfaceKeysRequiringSetupGroups = new Set<string>()
|
||||
for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) {
|
||||
if (checkouts.size > 1) {
|
||||
surfaceKeysRequiringSetupGroups.add(surfaceKey)
|
||||
}
|
||||
}
|
||||
const index = {
|
||||
projectById: new Map(projects.map((project) => [project.id, project])),
|
||||
setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])),
|
||||
surfaceKeysRequiringSetupGroups
|
||||
}
|
||||
projectGroupingIndexCache.set(model, index)
|
||||
return index
|
||||
}
|
||||
|
||||
export type ProjectHeaderRevealTarget = {
|
||||
key: string
|
||||
label: string
|
||||
repo?: Repo
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
export function getProjectGroupingForRepo(
|
||||
repoId: string,
|
||||
repoMap: Map<string, Repo>,
|
||||
projectIndex: ProjectGroupingIndex | null
|
||||
): ProjectHeaderRevealTarget {
|
||||
const repo = repoMap.get(repoId)
|
||||
const setup = projectIndex?.setupByRepoId.get(repoId)
|
||||
const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined
|
||||
if (!setup || !project) {
|
||||
return {
|
||||
key: `repo:${repoId}`,
|
||||
label: repo?.displayName ?? 'Unknown',
|
||||
repo
|
||||
}
|
||||
}
|
||||
if (
|
||||
projectIndex?.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) &&
|
||||
isDistinctUserCheckout(setup)
|
||||
) {
|
||||
// Why: only the ambiguous surface needs checkout-specific headers.
|
||||
return {
|
||||
key: `project:${project.id}::setup:${repoId}`,
|
||||
label: repo?.displayName ?? setup.displayName,
|
||||
repo,
|
||||
projectId: project.id
|
||||
}
|
||||
}
|
||||
// Why: provisioned runtime copies and non-ambiguous checkouts follow project
|
||||
// identity rather than path-scoped setup identity, so they stay in one project.
|
||||
return {
|
||||
key: `project:${project.id}`,
|
||||
label: project.displayName,
|
||||
repo,
|
||||
projectId: project.id
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectHeaderRevealTarget(
|
||||
repoId: string,
|
||||
repoMap: Map<string, Repo>,
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
): ProjectHeaderRevealTarget {
|
||||
return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping))
|
||||
}
|
||||
|
||||
export function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void {
|
||||
group.repoIds.add(repoId)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { getLineageRenderInfo } from '../../worktree-lineage-projection'
|
||||
import { PINNED_GROUP_KEY, PINNED_GROUP_META, getLineageGroupKey } from './group-keys'
|
||||
import type {
|
||||
ImportedWorktreesCardCandidate,
|
||||
ImportedWorktreesCardRow,
|
||||
NewExternalWorktreesInboxCandidate,
|
||||
NewExternalWorktreesInboxRow,
|
||||
PendingCreationRef,
|
||||
PendingCreationRow,
|
||||
Row,
|
||||
WorktreeRow
|
||||
} from './row-types'
|
||||
|
||||
export function buildPendingCreationRow(
|
||||
creation: PendingCreationRef,
|
||||
repoMap: Map<string, Repo>
|
||||
): PendingCreationRow {
|
||||
return {
|
||||
type: 'pending-creation',
|
||||
key: `pending:${creation.creationId}`,
|
||||
creationId: creation.creationId,
|
||||
repo: repoMap.get(creation.repoId)
|
||||
}
|
||||
}
|
||||
|
||||
export function emitPinnedGroup(
|
||||
worktrees: Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
defaultHostId: ExecutionHostId,
|
||||
collapsedGroups: Set<string>,
|
||||
renderedNaturalAnchorRepoIds: ReadonlySet<string>,
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>,
|
||||
allowImportedFallback: boolean,
|
||||
result: Row[]
|
||||
): void {
|
||||
const pinned = worktrees.filter((w) => w.isPinned)
|
||||
if (pinned.length === 0) {
|
||||
return
|
||||
}
|
||||
const hostWorktreeCounts = new Map<ExecutionHostId, number>()
|
||||
const hostWorktreeIds = new Map<ExecutionHostId, string[]>()
|
||||
const pinnedRepoOrder: string[] = []
|
||||
const seenPinnedRepoIds = new Set<string>()
|
||||
for (const worktree of pinned) {
|
||||
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
|
||||
hostWorktreeCounts.set(hostId, (hostWorktreeCounts.get(hostId) ?? 0) + 1)
|
||||
const hostIds = hostWorktreeIds.get(hostId) ?? []
|
||||
hostIds.push(worktree.id)
|
||||
hostWorktreeIds.set(hostId, hostIds)
|
||||
if (!seenPinnedRepoIds.has(worktree.repoId)) {
|
||||
pinnedRepoOrder.push(worktree.repoId)
|
||||
seenPinnedRepoIds.add(worktree.repoId)
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
type: 'header',
|
||||
key: PINNED_GROUP_KEY,
|
||||
label: PINNED_GROUP_META.label,
|
||||
count: pinned.length,
|
||||
tone: PINNED_GROUP_META.tone,
|
||||
icon: PINNED_GROUP_META.icon,
|
||||
hostWorktreeCounts,
|
||||
hostWorktreeIds,
|
||||
worktreeIds: pinned.map((worktree) => worktree.id)
|
||||
})
|
||||
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
|
||||
for (const repoId of pinnedRepoOrder) {
|
||||
const candidate = importedWorktreesByRepo.get(repoId)
|
||||
if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) {
|
||||
result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lastPinnedIndexByRepoId = new Map<string, number>()
|
||||
pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index))
|
||||
for (const [index, worktree] of pinned.entries()) {
|
||||
result.push(
|
||||
buildWorktreeRow(worktree, repoMap, {
|
||||
rowKey: `${PINNED_GROUP_KEY}:${worktree.id}`,
|
||||
sectionKey: PINNED_GROUP_KEY,
|
||||
depth: 0,
|
||||
groupDepth: 0,
|
||||
lineageTrail: [],
|
||||
isLastLineageChild: false,
|
||||
lineageChildCount: 0,
|
||||
lineageCollapsed: false
|
||||
})
|
||||
)
|
||||
const candidate = importedWorktreesByRepo.get(worktree.repoId)
|
||||
if (
|
||||
allowImportedFallback &&
|
||||
candidate &&
|
||||
!renderedNaturalAnchorRepoIds.has(worktree.repoId) &&
|
||||
lastPinnedIndexByRepoId.get(worktree.repoId) === index
|
||||
) {
|
||||
result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildImportedWorktreesCardRow(
|
||||
candidate: ImportedWorktreesCardCandidate,
|
||||
placement: ImportedWorktreesCardRow['placement']
|
||||
): ImportedWorktreesCardRow {
|
||||
return {
|
||||
type: 'imported-worktrees-card',
|
||||
key: `imported-worktrees-card:${placement}:${candidate.repo.id}`,
|
||||
repo: candidate.repo,
|
||||
hiddenWorktrees: candidate.hiddenWorktrees,
|
||||
placement
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNewExternalWorktreesInboxRow(
|
||||
candidate: NewExternalWorktreesInboxCandidate
|
||||
): NewExternalWorktreesInboxRow {
|
||||
return {
|
||||
type: 'new-external-worktrees-inbox',
|
||||
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
|
||||
repo: candidate.repo,
|
||||
inboxWorktrees: candidate.inboxWorktrees
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorktreeRow(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
options: {
|
||||
rowKey: string
|
||||
sectionKey: string
|
||||
depth: number
|
||||
groupDepth: number
|
||||
lineageTrail: boolean[]
|
||||
isLastLineageChild: boolean
|
||||
lineageChildCount: number
|
||||
lineageCollapsed: boolean
|
||||
hostContextLabel?: string
|
||||
}
|
||||
): WorktreeRow {
|
||||
return {
|
||||
type: 'item',
|
||||
rowKey: options.rowKey,
|
||||
sectionKey: options.sectionKey,
|
||||
worktree,
|
||||
repo: repoMap.get(worktree.repoId),
|
||||
depth: options.depth,
|
||||
groupDepth: options.groupDepth,
|
||||
lineageTrail: options.lineageTrail,
|
||||
isLastLineageChild: options.isLastLineageChild,
|
||||
lineageChildCount: options.lineageChildCount,
|
||||
...(options.hostContextLabel ? { hostContextLabel: options.hostContextLabel } : {}),
|
||||
...(options.lineageChildCount > 0 ? { lineageGroupKey: getLineageGroupKey(worktree.id) } : {}),
|
||||
...(options.lineageChildCount > 0 ? { lineageCollapsed: options.lineageCollapsed } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function appendWorktreeRows(
|
||||
result: Row[],
|
||||
worktrees: Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
lineageById: Record<string, WorktreeLineage>,
|
||||
worktreeMap: Map<string, Worktree>,
|
||||
options: {
|
||||
nestLineage: boolean
|
||||
collapsedGroups: Set<string>
|
||||
groupDepth: number
|
||||
sectionKey: string
|
||||
hostContextLabelByRepoId?: ReadonlyMap<string, string>
|
||||
hostContextLabelByWorktreeId?: ReadonlyMap<string, string>
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
}
|
||||
): void {
|
||||
const {
|
||||
nestLineage,
|
||||
collapsedGroups,
|
||||
groupDepth,
|
||||
sectionKey,
|
||||
hostContextLabelByRepoId,
|
||||
hostContextLabelByWorktreeId,
|
||||
cyclicLineageIds
|
||||
} = options
|
||||
if (!nestLineage) {
|
||||
for (const worktree of worktrees) {
|
||||
result.push(
|
||||
buildWorktreeRow(worktree, repoMap, {
|
||||
rowKey: `${sectionKey}:${worktree.id}`,
|
||||
sectionKey,
|
||||
depth: 0,
|
||||
groupDepth,
|
||||
lineageTrail: [],
|
||||
isLastLineageChild: false,
|
||||
lineageChildCount: 0,
|
||||
lineageCollapsed: false,
|
||||
hostContextLabel:
|
||||
hostContextLabelByWorktreeId?.get(worktree.id) ??
|
||||
hostContextLabelByRepoId?.get(worktree.repoId)
|
||||
})
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const visibleIds = new Set(worktrees.map((worktree) => worktree.id))
|
||||
const childrenByParentId = new Map<string, Worktree[]>()
|
||||
const childIds = new Set<string>()
|
||||
for (const worktree of worktrees) {
|
||||
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds)
|
||||
if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) {
|
||||
continue
|
||||
}
|
||||
childIds.add(worktree.id)
|
||||
const children = childrenByParentId.get(lineage.parent.id) ?? []
|
||||
children.push(worktree)
|
||||
childrenByParentId.set(lineage.parent.id, children)
|
||||
}
|
||||
|
||||
const emitted = new Set<string>()
|
||||
const emit = (
|
||||
worktree: Worktree,
|
||||
depth: number,
|
||||
lineageTrail: boolean[],
|
||||
isLastChild: boolean
|
||||
): void => {
|
||||
if (emitted.has(worktree.id)) {
|
||||
return
|
||||
}
|
||||
const children = childrenByParentId.get(worktree.id) ?? []
|
||||
const lineageGroupKey = getLineageGroupKey(worktree.id)
|
||||
const lineageCollapsed = collapsedGroups.has(lineageGroupKey)
|
||||
emitted.add(worktree.id)
|
||||
result.push(
|
||||
buildWorktreeRow(worktree, repoMap, {
|
||||
rowKey: `${sectionKey}:${worktree.id}`,
|
||||
sectionKey,
|
||||
depth,
|
||||
groupDepth,
|
||||
lineageTrail,
|
||||
isLastLineageChild: isLastChild,
|
||||
lineageChildCount: children.length,
|
||||
lineageCollapsed,
|
||||
hostContextLabel:
|
||||
hostContextLabelByWorktreeId?.get(worktree.id) ??
|
||||
hostContextLabelByRepoId?.get(worktree.repoId)
|
||||
})
|
||||
)
|
||||
if (lineageCollapsed) {
|
||||
return
|
||||
}
|
||||
children.forEach((child, index) => {
|
||||
emit(
|
||||
child,
|
||||
depth + 1,
|
||||
[...lineageTrail, index < children.length - 1],
|
||||
index === children.length - 1
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const roots = worktrees.filter((worktree) => !childIds.has(worktree.id))
|
||||
for (const [index, worktree] of roots.entries()) {
|
||||
emit(worktree, 0, [], index === roots.length - 1)
|
||||
}
|
||||
if (roots.length === 0) {
|
||||
for (const worktree of worktrees) {
|
||||
if (!emitted.has(worktree.id)) {
|
||||
// Why: malformed cyclic lineage should not hide every participant.
|
||||
// Render any leftovers as roots rather than recursing forever.
|
||||
emit(worktree, 0, [], true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type React from 'react'
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { DetectedWorktree, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
|
||||
export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status'
|
||||
export type PinnedWorktreeDisplayPolicy = 'single-location' | 'duplicate-in-groups'
|
||||
|
||||
export function getPinnedWorktreeDisplayPolicy(
|
||||
settings?: { showPinnedWorktreesInGroups?: boolean } | null
|
||||
): PinnedWorktreeDisplayPolicy {
|
||||
return settings?.showPinnedWorktreesInGroups === true ? 'duplicate-in-groups' : 'single-location'
|
||||
}
|
||||
|
||||
export type GroupHeaderRow = {
|
||||
type: 'header'
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
tone: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
repo?: Repo
|
||||
projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number }
|
||||
projectGroupDepth?: number
|
||||
hostId?: ExecutionHostId
|
||||
hostWorktreeCounts?: ReadonlyMap<ExecutionHostId, number>
|
||||
hostWorktreeIds?: ReadonlyMap<ExecutionHostId, readonly string[]>
|
||||
worktreeIds?: readonly string[]
|
||||
}
|
||||
|
||||
export type WorktreeRow = {
|
||||
type: 'item'
|
||||
rowKey: string
|
||||
sectionKey: string
|
||||
worktree: Worktree
|
||||
repo: Repo | undefined
|
||||
depth: number
|
||||
groupDepth: number
|
||||
lineageTrail: boolean[]
|
||||
isLastLineageChild: boolean
|
||||
lineageChildCount: number
|
||||
lineageGroupKey?: string
|
||||
lineageCollapsed?: boolean
|
||||
hostContextLabel?: string
|
||||
}
|
||||
|
||||
export type ImportedWorktreesCardCandidate = {
|
||||
repo: Repo
|
||||
hiddenWorktrees: DetectedWorktree[]
|
||||
}
|
||||
|
||||
export type ImportedWorktreesCardRow = {
|
||||
type: 'imported-worktrees-card'
|
||||
key: string
|
||||
repo: Repo
|
||||
hiddenWorktrees: DetectedWorktree[]
|
||||
placement: 'repo-group' | 'pinned-fallback'
|
||||
}
|
||||
|
||||
export type NewExternalWorktreesInboxCandidate = {
|
||||
repo: Repo
|
||||
inboxWorktrees: DetectedWorktree[]
|
||||
}
|
||||
|
||||
export type NewExternalWorktreesInboxRow = {
|
||||
type: 'new-external-worktrees-inbox'
|
||||
key: string
|
||||
repo: Repo
|
||||
inboxWorktrees: DetectedWorktree[]
|
||||
}
|
||||
|
||||
export type PendingCreationRow = {
|
||||
type: 'pending-creation'
|
||||
key: string
|
||||
creationId: string
|
||||
repo: Repo | undefined
|
||||
}
|
||||
|
||||
export type FolderWorkspaceRow = {
|
||||
type: 'folder-workspace'
|
||||
key: string
|
||||
folderWorkspace: FolderWorkspace
|
||||
projectGroup: ProjectGroup
|
||||
depth: number
|
||||
groupDepth: number
|
||||
}
|
||||
|
||||
/** Minimal shape buildRows needs for an in-flight create. Deliberately not the
|
||||
* full PendingWorktreeCreation: row identity depends only on which creates
|
||||
* exist and their repo, so callers can subscribe on this stable shape and keep
|
||||
* progress-field churn (phase/loaderVisible) from rebuilding the whole list. */
|
||||
export type PendingCreationRef = { creationId: string; repoId: string }
|
||||
|
||||
export type Row =
|
||||
| GroupHeaderRow
|
||||
| WorktreeRow
|
||||
| ImportedWorktreesCardRow
|
||||
| NewExternalWorktreesInboxRow
|
||||
| PendingCreationRow
|
||||
| FolderWorkspaceRow
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { AppState } from '../../../../store/types'
|
||||
import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
|
||||
import { ALL_GROUP_KEY } from './group-keys'
|
||||
import type {
|
||||
OrderedGroupEntry,
|
||||
ProjectGroupingModel,
|
||||
WorktreeGroupEntry
|
||||
} from './project-grouping'
|
||||
import type { WorktreeGroupBy } from './row-types'
|
||||
import { getGroupKeyForWorktree } from './worktree-group-keys'
|
||||
|
||||
export function getRenderedNaturalAnchorRepoIds({
|
||||
groupBy,
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
collapsedGroups,
|
||||
workspaceStatuses,
|
||||
settings,
|
||||
projectGrouping
|
||||
}: {
|
||||
groupBy: WorktreeGroupBy
|
||||
worktrees: readonly Worktree[]
|
||||
repoMap: Map<string, Repo>
|
||||
prCache: Record<string, unknown> | null
|
||||
collapsedGroups: ReadonlySet<string>
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
settings?: AppState['settings']
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
}): Set<string> {
|
||||
const renderedRepoIds = new Set<string>()
|
||||
if (groupBy === 'none') {
|
||||
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
|
||||
for (const worktree of worktrees) {
|
||||
renderedRepoIds.add(worktree.repoId)
|
||||
}
|
||||
}
|
||||
return renderedRepoIds
|
||||
}
|
||||
if (groupBy === 'repo') {
|
||||
for (const worktree of worktrees) {
|
||||
renderedRepoIds.add(worktree.repoId)
|
||||
}
|
||||
return renderedRepoIds
|
||||
}
|
||||
for (const worktree of worktrees) {
|
||||
const groupKey = getGroupKeyForWorktree(
|
||||
groupBy,
|
||||
worktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
settings,
|
||||
projectGrouping
|
||||
)
|
||||
if (groupKey && !collapsedGroups.has(groupKey)) {
|
||||
renderedRepoIds.add(worktree.repoId)
|
||||
}
|
||||
}
|
||||
return renderedRepoIds
|
||||
}
|
||||
|
||||
export function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] {
|
||||
const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree)
|
||||
if (mainWorktrees.length === 0) {
|
||||
return worktrees
|
||||
}
|
||||
// Why: project groups are scanned by repo; keep the repo's canonical
|
||||
// workspace anchored even when dynamic sorts rank a child workspace first.
|
||||
return [...mainWorktrees, ...worktrees.filter((worktree) => !worktree.isMainWorktree)]
|
||||
}
|
||||
|
||||
export function withRepoSectionDisplayLabels(
|
||||
entries: readonly OrderedGroupEntry[]
|
||||
): OrderedGroupEntry[] {
|
||||
const repos = entries
|
||||
.map((entry) => entry[1].repo)
|
||||
.filter((repo): repo is Repo => repo !== undefined)
|
||||
if (repos.length < 2) {
|
||||
return [...entries]
|
||||
}
|
||||
const labelsByPath = getRepoDisplayLabelsByPath(repos)
|
||||
return entries.map(([key, group]) => [
|
||||
key,
|
||||
group.repo
|
||||
? { ...group, label: labelsByPath.get(getRepoDisplayLabelKey(group.repo)) ?? group.label }
|
||||
: group
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent rank for a project header. `hasActivity` projects (at least one
|
||||
* visible worktree) always sort before fallback projects, regardless of the
|
||||
* numeric values — a placeholder's `addedAt` must never outrank real activity.
|
||||
* Within each tier, higher timestamps come first.
|
||||
*/
|
||||
type RecentRank = { hasActivity: boolean; ts: number }
|
||||
|
||||
export function recentRankForEntry(entry: OrderedGroupEntry): RecentRank {
|
||||
let max = Number.NEGATIVE_INFINITY
|
||||
for (const worktree of entry[1].items) {
|
||||
if (worktree.lastActivityAt > max) {
|
||||
max = worktree.lastActivityAt
|
||||
}
|
||||
}
|
||||
if (max !== Number.NEGATIVE_INFINITY) {
|
||||
// Why: Recent must be timestamp-based, not encounter order — the incoming
|
||||
// array is no longer pre-sorted by recency once decoupled from sortBy.
|
||||
return { hasActivity: true, ts: max }
|
||||
}
|
||||
const addedAt = entry[1].repo?.addedAt
|
||||
return {
|
||||
hasActivity: false,
|
||||
ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
export function compareRecentRank(a: RecentRank, b: RecentRank): number {
|
||||
if (a.hasActivity !== b.hasActivity) {
|
||||
return a.hasActivity ? -1 : 1
|
||||
}
|
||||
return b.ts - a.ts
|
||||
}
|
||||
|
||||
function manualRankForEntry(
|
||||
entry: OrderedGroupEntry,
|
||||
repoOrder: Map<string, number> | undefined
|
||||
): number {
|
||||
const key = entry[0]
|
||||
const repoIds =
|
||||
entry[1].repoIds.size > 0
|
||||
? [...entry[1].repoIds]
|
||||
: [key.startsWith('repo:') ? key.slice('repo:'.length) : key]
|
||||
let rank = Number.POSITIVE_INFINITY
|
||||
for (const repoId of repoIds) {
|
||||
const repoRank = repoOrder?.get(repoId)
|
||||
if (repoRank !== undefined && repoRank < rank) {
|
||||
rank = repoRank
|
||||
}
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
export function getManualOrderAnchorRepo(
|
||||
group: WorktreeGroupEntry,
|
||||
repoMap: Map<string, Repo>,
|
||||
repoOrder: Map<string, number> | undefined
|
||||
): Repo | undefined {
|
||||
let anchor = group.repo
|
||||
let anchorRank = anchor ? (repoOrder?.get(anchor.id) ?? Number.POSITIVE_INFINITY) : undefined
|
||||
for (const repoId of group.repoIds) {
|
||||
const repo = repoMap.get(repoId)
|
||||
if (!repo) {
|
||||
continue
|
||||
}
|
||||
const rank = repoOrder?.get(repoId) ?? Number.POSITIVE_INFINITY
|
||||
if (!anchor || rank < (anchorRank ?? Number.POSITIVE_INFINITY)) {
|
||||
anchor = repo
|
||||
anchorRank = rank
|
||||
}
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
/**
|
||||
* Order project header entries by the user's project-order preference. Manual
|
||||
* follows the canonical repoOrder; Recent follows each project's most recent
|
||||
* visible workspace activity (descending), with empty/imported-only projects
|
||||
* sorting after active ones, then by manual rank, then label.
|
||||
*/
|
||||
export function sortProjectEntries(
|
||||
entries: OrderedGroupEntry[],
|
||||
projectOrderBy: ProjectOrderBy,
|
||||
repoOrder: Map<string, number> | undefined
|
||||
): OrderedGroupEntry[] {
|
||||
if (projectOrderBy === 'recent') {
|
||||
return [...entries].sort((a, b) => {
|
||||
const byRecent = compareRecentRank(recentRankForEntry(a), recentRankForEntry(b))
|
||||
if (byRecent !== 0) {
|
||||
return byRecent
|
||||
}
|
||||
const ma = manualRankForEntry(a, repoOrder)
|
||||
const mb = manualRankForEntry(b, repoOrder)
|
||||
if (ma !== mb) {
|
||||
return ma - mb
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
}
|
||||
if (!repoOrder) {
|
||||
return entries
|
||||
}
|
||||
return [...entries].sort((a, b) => {
|
||||
const ra = manualRankForEntry(a, repoOrder)
|
||||
const rb = manualRankForEntry(b, repoOrder)
|
||||
if (ra !== rb) {
|
||||
return ra - rb
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flat row list consumed by the virtualizer.
|
||||
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
|
||||
*/
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses'
|
||||
import type { AppState } from '../../../../store/types'
|
||||
import { ALL_GROUP_KEY, getPRGroupKey, getProjectGroupHeaderKey } from './group-keys'
|
||||
import { buildProjectGroupingIndex, getProjectGroupingForRepo } from './project-grouping'
|
||||
import type { ProjectGroupingModel } from './project-grouping'
|
||||
import type { WorktreeGroupBy } from './row-types'
|
||||
|
||||
export function getGroupKeyForWorktree(
|
||||
groupBy: WorktreeGroupBy,
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, unknown> | null,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
settings?: AppState['settings'],
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
): string | null {
|
||||
if (groupBy === 'none') {
|
||||
return ALL_GROUP_KEY
|
||||
}
|
||||
if (groupBy === 'workspace-status') {
|
||||
return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses))
|
||||
}
|
||||
if (groupBy === 'repo') {
|
||||
return getProjectGroupingForRepo(
|
||||
worktree.repoId,
|
||||
repoMap,
|
||||
buildProjectGroupingIndex(projectGrouping)
|
||||
).key
|
||||
}
|
||||
return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}`
|
||||
}
|
||||
|
||||
export function getGroupKeysForWorktree(
|
||||
groupBy: WorktreeGroupBy,
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, unknown> | null,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
settings?: AppState['settings'],
|
||||
projectGroups: readonly ProjectGroup[] = [],
|
||||
projectGrouping?: ProjectGroupingModel
|
||||
): string[] {
|
||||
const groupKey = getGroupKeyForWorktree(
|
||||
groupBy,
|
||||
worktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
settings,
|
||||
projectGrouping
|
||||
)
|
||||
if (!groupKey) {
|
||||
return []
|
||||
}
|
||||
if (groupBy !== 'repo') {
|
||||
return [groupKey]
|
||||
}
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
const groupIds: string[] = []
|
||||
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const visited = new Set<string>()
|
||||
let currentGroupId = repo?.projectGroupId ?? null
|
||||
while (currentGroupId && !visited.has(currentGroupId)) {
|
||||
const group = groupsById.get(currentGroupId)
|
||||
if (!group) {
|
||||
// Why: repos can arrive before their remote Project Group metadata; reveal
|
||||
// keys must match the top-level fallback rows buildRows actually renders.
|
||||
break
|
||||
}
|
||||
visited.add(currentGroupId)
|
||||
groupIds.unshift(currentGroupId)
|
||||
const parentId = group.parentGroupId ?? null
|
||||
currentGroupId = parentId && groupsById.has(parentId) ? parentId : null
|
||||
}
|
||||
return [...groupIds.map((id) => getProjectGroupHeaderKey(id)), groupKey]
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { AppState } from '../../../../store/types'
|
||||
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
|
||||
import { PR_GROUP_META, PR_GROUP_ORDER, getPRGroupKey } from './group-keys'
|
||||
import { addRepoIdToGroup, getProjectGroupingForRepo } from './project-grouping'
|
||||
import type {
|
||||
OrderedGroupEntry,
|
||||
ProjectGroupingIndex,
|
||||
WorktreeGroupEntry
|
||||
} from './project-grouping'
|
||||
import type {
|
||||
ImportedWorktreesCardCandidate,
|
||||
NewExternalWorktreesInboxCandidate,
|
||||
PendingCreationRef,
|
||||
WorktreeGroupBy
|
||||
} from './row-types'
|
||||
import { getManualOrderAnchorRepo, sortProjectEntries } from './section-order'
|
||||
|
||||
/** Buckets worktrees into their sections and returns them in render order. */
|
||||
export function buildOrderedGroups(args: {
|
||||
groupBy: WorktreeGroupBy
|
||||
naturalWorktrees: readonly Worktree[]
|
||||
repoMap: Map<string, Repo>
|
||||
prCache: Record<string, unknown> | null
|
||||
settings: AppState['settings'] | undefined
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
projectIndex: ProjectGroupingIndex | null
|
||||
placeholderRepoIds: ReadonlySet<string>
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
|
||||
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
|
||||
pendingByRepo: ReadonlyMap<string, PendingCreationRef[]>
|
||||
repoOrder: Map<string, number> | undefined
|
||||
projectOrderBy: ProjectOrderBy
|
||||
}): OrderedGroupEntry[] {
|
||||
const {
|
||||
groupBy,
|
||||
naturalWorktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
settings,
|
||||
workspaceStatuses,
|
||||
projectIndex,
|
||||
placeholderRepoIds,
|
||||
importedWorktreesByRepo,
|
||||
newExternalWorktreesInboxByRepo,
|
||||
pendingByRepo,
|
||||
repoOrder,
|
||||
projectOrderBy
|
||||
} = args
|
||||
|
||||
const grouped = new Map<string, WorktreeGroupEntry>()
|
||||
for (const w of naturalWorktrees) {
|
||||
let key: string
|
||||
let label: string
|
||||
let repo: Repo | undefined
|
||||
if (groupBy === 'repo') {
|
||||
const grouping = getProjectGroupingForRepo(w.repoId, repoMap, projectIndex)
|
||||
key = grouping.key
|
||||
label = grouping.label
|
||||
repo = grouping.repo
|
||||
} else if (groupBy === 'workspace-status') {
|
||||
const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses)
|
||||
key = getWorkspaceStatusGroupKey(workspaceStatus)
|
||||
label =
|
||||
workspaceStatuses.find((status) => status.id === workspaceStatus)?.label ?? workspaceStatus
|
||||
} else {
|
||||
const prGroup = getPRGroupKey(w, repoMap, prCache, settings)
|
||||
key = `pr:${prGroup}`
|
||||
label = PR_GROUP_META[prGroup].label
|
||||
}
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, { label, items: [], repo, repoIds: new Set() })
|
||||
}
|
||||
const group = grouped.get(key)!
|
||||
group.items.push(w)
|
||||
addRepoIdToGroup(group, w.repoId)
|
||||
}
|
||||
if (groupBy === 'repo') {
|
||||
for (const repoId of placeholderRepoIds) {
|
||||
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
|
||||
if (!grouping.repo) {
|
||||
continue
|
||||
}
|
||||
const key = grouping.key
|
||||
if (!grouped.has(key)) {
|
||||
// Why: repos can arrive before worktree scans, but stale IDs passed by
|
||||
// older snapshots must not render an "Unknown" project header.
|
||||
grouped.set(key, {
|
||||
label: grouping.label,
|
||||
items: [],
|
||||
repo: grouping.repo,
|
||||
repoIds: new Set([repoId])
|
||||
})
|
||||
} else {
|
||||
addRepoIdToGroup(grouped.get(key)!, repoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (groupBy === 'repo') {
|
||||
for (const [repoId, candidate] of importedWorktreesByRepo) {
|
||||
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
|
||||
const key = grouping.key
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
label: grouping.label,
|
||||
items: [],
|
||||
repo: grouping.repo ?? candidate.repo,
|
||||
repoIds: new Set([repoId])
|
||||
})
|
||||
} else if (grouped.has(key)) {
|
||||
addRepoIdToGroup(grouped.get(key)!, repoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (groupBy === 'repo') {
|
||||
for (const [repoId, candidate] of newExternalWorktreesInboxByRepo) {
|
||||
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
|
||||
const key = grouping.key
|
||||
if (!grouped.has(key)) {
|
||||
// Why: the default policy removes pinned worktrees from natural groups,
|
||||
// but actionable inbox rows still need a project section to render in.
|
||||
grouped.set(key, {
|
||||
label: grouping.label,
|
||||
items: [],
|
||||
repo: grouping.repo ?? candidate.repo,
|
||||
repoIds: new Set([repoId])
|
||||
})
|
||||
} else if (grouped.has(key)) {
|
||||
addRepoIdToGroup(grouped.get(key)!, repoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (groupBy === 'repo') {
|
||||
for (const repoId of pendingByRepo.keys()) {
|
||||
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
|
||||
const key = grouping.key
|
||||
if (!grouped.has(key)) {
|
||||
// Why: creating the first worktree in a repo leaves it with no group yet;
|
||||
// ensure one so the in-progress row nests under its repo instead of being
|
||||
// dropped.
|
||||
grouped.set(key, {
|
||||
label: grouping.label,
|
||||
items: [],
|
||||
repo: grouping.repo,
|
||||
repoIds: new Set([repoId])
|
||||
})
|
||||
} else {
|
||||
addRepoIdToGroup(grouped.get(key)!, repoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedGroups: OrderedGroupEntry[] = []
|
||||
if (groupBy === 'pr-status') {
|
||||
for (const prGroup of PR_GROUP_ORDER) {
|
||||
const key = `pr:${prGroup}`
|
||||
const group = grouped.get(key)
|
||||
if (group) {
|
||||
orderedGroups.push([key, group])
|
||||
}
|
||||
}
|
||||
} else if (groupBy === 'workspace-status') {
|
||||
// Why: status grouping is opt-in while the board drawer remains the wider
|
||||
// all-lanes drag target; keep the sidebar compact by omitting empty lanes.
|
||||
for (const status of workspaceStatuses) {
|
||||
const key = getWorkspaceStatusGroupKey(status.id)
|
||||
const group = grouped.get(key)
|
||||
if (group) {
|
||||
orderedGroups.push([key, group])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const group of grouped.values()) {
|
||||
// Why: logical project headers can contain multiple host setup repos.
|
||||
// Use the repo that anchors manual order so drag and actions target the
|
||||
// same persisted order source the row sorter reads.
|
||||
group.repo = getManualOrderAnchorRepo(group, repoMap, repoOrder)
|
||||
}
|
||||
// Why: project header order is its own user choice (projectOrderBy),
|
||||
// decoupled from workspace sortBy. Manual uses the canonical repoOrder so
|
||||
// header drag has a stable source of truth; Recent follows activity.
|
||||
const entries = sortProjectEntries(Array.from(grouped.entries()), projectOrderBy, repoOrder)
|
||||
// Why: large imported repo sets can have one group per repo; spreading
|
||||
// those entries into push can exceed V8's argument limit.
|
||||
for (const entry of entries) {
|
||||
orderedGroups.push(entry)
|
||||
}
|
||||
}
|
||||
return orderedGroups
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
|
||||
type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }>
|
||||
export type RenderRow =
|
||||
| HostSectionRow
|
||||
| { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
|
||||
|
||||
export function getRenderRowKey(row: RenderRow): string {
|
||||
if (row.type === 'host-header') {
|
||||
return `host:${row.hostId}`
|
||||
}
|
||||
if (row.type === 'header') {
|
||||
return row.hostId ? `hdr:${row.hostId}:${row.key}` : `hdr:${row.key}`
|
||||
}
|
||||
if (row.type === 'lineage-group') {
|
||||
return `lineage-group:${row.key}`
|
||||
}
|
||||
if (row.type === 'imported-worktrees-card') {
|
||||
return `imported:${row.key}`
|
||||
}
|
||||
if (row.type === 'new-external-worktrees-inbox') {
|
||||
return `inbox:${row.key}`
|
||||
}
|
||||
if (row.type === 'pending-creation') {
|
||||
return `pending:${row.creationId}`
|
||||
}
|
||||
if (row.type === 'folder-workspace') {
|
||||
return `folder-workspace:${row.folderWorkspace.id}`
|
||||
}
|
||||
return `wt:${row.rowKey}`
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { getLineageGroupKey, PINNED_GROUP_KEY } from './groups'
|
||||
import { PINNED_GROUP_KEY, getLineageGroupKey } from '../grouping/group-keys'
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
import type { RenderRow } from '../viewport/virtual-rows'
|
||||
import type { RenderRow } from './render-row'
|
||||
|
||||
export type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }>
|
||||
export type FolderWorkspaceItemRow = Extract<HostSectionRow, { type: 'folder-workspace' }>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
|
||||
import type { WorktreeGroupBy } from '../rows/groups'
|
||||
import type { WorktreeGroupBy } from '../grouping/row-types'
|
||||
|
||||
export type WorktreeListReviewCacheState = Pick<
|
||||
AppState,
|
||||
+9
-12
@@ -1,17 +1,14 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { ProjectGroup } from '../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types'
|
||||
import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types'
|
||||
import {
|
||||
getGroupKeysForWorktree,
|
||||
getLineageGroupKey,
|
||||
PINNED_GROUP_KEY,
|
||||
type ProjectGroupingModel,
|
||||
type WorktreeGroupBy
|
||||
} from './rows/groups'
|
||||
import { getWorktreeLineageAncestors } from '../worktree-lineage-projection'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import { PINNED_GROUP_KEY, getLineageGroupKey } from '../grouping/group-keys'
|
||||
import type { ProjectGroupingModel } from '../grouping/project-grouping'
|
||||
import type { WorktreeGroupBy } from '../grouping/row-types'
|
||||
import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys'
|
||||
import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection'
|
||||
|
||||
// While the agent send picker targets a workspace, force open every section that hides it.
|
||||
export function useEffectiveCollapsedGroups(args: {
|
||||
+5
-5
@@ -1,22 +1,22 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import {
|
||||
IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR,
|
||||
keepImportedWorktreesHiddenCard,
|
||||
showImportedWorktreesCard,
|
||||
type ImportedWorktreeCardActionState
|
||||
} from '../imported-worktrees-card-actions'
|
||||
} from '../../imported-worktrees-card-actions'
|
||||
import {
|
||||
buildImportedWorktreesCardCandidates,
|
||||
getHiddenImportedWorktrees
|
||||
} from '../imported-worktrees-card-candidates'
|
||||
} from '../../imported-worktrees-card-candidates'
|
||||
import {
|
||||
suppressNewExternalWorktreeInbox,
|
||||
type NewExternalWorktreesInboxActionState
|
||||
} from '../new-external-worktrees-inbox-actions'
|
||||
import { buildNewExternalWorktreesInboxCandidates } from '../new-external-worktrees-inbox-candidates'
|
||||
} from '../../new-external-worktrees-inbox-actions'
|
||||
import { buildNewExternalWorktreesInboxCandidates } from '../../new-external-worktrees-inbox-candidates'
|
||||
|
||||
// The two sidebar notice rows for worktrees Orca detected but does not yet show, plus the
|
||||
// pending/error state their inline actions surface.
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../../shared/constants'
|
||||
import { computeClearFilterActions, sidebarHasActiveFilters } from '../visible-worktrees'
|
||||
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../../../shared/constants'
|
||||
import { computeClearFilterActions, sidebarHasActiveFilters } from '../../visible-worktrees'
|
||||
|
||||
export type SidebarWorktreeFilters = ReturnType<typeof useSidebarWorktreeFilters>
|
||||
|
||||
+10
-7
@@ -1,15 +1,18 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters'
|
||||
import { filterFolderWorkspacesFromOtherDevices } from '../workspace-creator-visibility'
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../../../shared/execution-host'
|
||||
import type { SidebarWorktreeFilters } from './use-filters'
|
||||
import { filterFolderWorkspacesFromOtherDevices } from '../../workspace-creator-visibility'
|
||||
import {
|
||||
filterFolderWorkspacesForVisibleHosts,
|
||||
filterProjectGroupsForVisibleHosts,
|
||||
getVisibleSidebarHostIdSet
|
||||
} from './viewport/host-filtering'
|
||||
} from './host-filtering'
|
||||
|
||||
// Narrows repos, project groups, and folder workspaces to the hosts (and devices) the
|
||||
// current host filter admits.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react'
|
||||
import { reuseArrayIfEqual } from '../worktree-agent-row-selectors'
|
||||
import { reuseArrayIfEqual } from '../../worktree-agent-row-selectors'
|
||||
|
||||
// Why: epoch-driven recomputes often produce arrays whose contents and order are unchanged; reusing the previous identity when element-wise equal keeps downstream memos and React.memo'd cards bailing out. Safe only because elements (Worktree objects / id strings) are immutably REPLACED on change — never wrap arrays of mutated-in-place objects.
|
||||
export function useReusedArrayIdentity<T>(next: T[]): T[] {
|
||||
+17
-21
@@ -2,27 +2,23 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../shared/project-group-types'
|
||||
import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types'
|
||||
import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types'
|
||||
import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../../shared/host-setting-overrides'
|
||||
import {
|
||||
buildRows,
|
||||
type PinnedWorktreeDisplayPolicy,
|
||||
type ProjectGroupingModel,
|
||||
type Row,
|
||||
type WorktreeGroupBy
|
||||
} from './rows/groups'
|
||||
import { getLogicalRepoOrderRankById } from '../project-header-drop'
|
||||
import { getEmptyProjectPlaceholderRepoIds } from '../empty-project-placeholder-repos'
|
||||
import { addHostSectionRows } from '../host-section-rows'
|
||||
import { orderHostSectionOptions } from '../host-section-order'
|
||||
import { buildSidebarHostOptions } from '../sidebar-host-options'
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../../../shared/host-setting-overrides'
|
||||
import { buildRows } from '../grouping/build-rows'
|
||||
import type { ProjectGroupingModel } from '../grouping/project-grouping'
|
||||
import type { PinnedWorktreeDisplayPolicy, Row, WorktreeGroupBy } from '../grouping/row-types'
|
||||
import { getLogicalRepoOrderRankById } from '../../project-header-drop'
|
||||
import { getEmptyProjectPlaceholderRepoIds } from '../../empty-project-placeholder-repos'
|
||||
import { addHostSectionRows } from '../../host-section-rows'
|
||||
import { orderHostSectionOptions } from '../../host-section-order'
|
||||
import { buildSidebarHostOptions } from '../../sidebar-host-options'
|
||||
|
||||
type SectionRowsArgs = {
|
||||
groupBy: WorktreeGroupBy
|
||||
+7
-5
@@ -4,17 +4,19 @@ import { getAllWorktreesFromState } from '@/store/selectors'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../shared/worktree/types'
|
||||
import { buildWorktreeComparator, compareWorktreeSortLabel, type SortBy } from '../smart-sort'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { buildWorktreeComparator, compareWorktreeSortLabel, type SortBy } from '../../smart-sort'
|
||||
import {
|
||||
buildAttentionByWorktree,
|
||||
hasFreshAttributedAgentStatus,
|
||||
type SmartClass,
|
||||
type WorktreeAttention
|
||||
} from '../smart-attention'
|
||||
} from '../../smart-attention'
|
||||
import { useReusedArrayIdentity } from './use-reused-array-identity'
|
||||
import { SORT_SETTLE_MS } from './scroll/tuning'
|
||||
|
||||
// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions.
|
||||
const SORT_SETTLE_MS = 3_000
|
||||
|
||||
function trackSmartClassDistribution(attention: ReadonlyMap<string, WorktreeAttention>): void {
|
||||
let class1 = 0
|
||||
+9
-9
@@ -2,21 +2,21 @@ import { useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../shared/worktree/types'
|
||||
import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types'
|
||||
import { getSettingsFocusedExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import { computeVisibleWorktreeIds } from '../visible-worktrees'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import { getSettingsFocusedExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { computeVisibleWorktreeIds } from '../../visible-worktrees'
|
||||
import {
|
||||
EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
|
||||
getPairedDeviceIdsByEnvironment
|
||||
} from '../workspace-creator-visibility'
|
||||
} from '../../workspace-creator-visibility'
|
||||
import {
|
||||
getVisibleWorktreeBrowserActivityTabs,
|
||||
getVisibleWorktreeTerminalActivityTabs
|
||||
} from '../visible-worktree-activity-inputs'
|
||||
import type { SortBy } from '../smart-sort'
|
||||
import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters'
|
||||
} from '../../visible-worktree-activity-inputs'
|
||||
import type { SortBy } from '../../smart-sort'
|
||||
import type { SidebarWorktreeFilters } from './use-filters'
|
||||
import { useReusedArrayIdentity } from './use-reused-array-identity'
|
||||
|
||||
const EMPTY_WORKTREE_ID_SET: ReadonlySet<string> = new Set()
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import type { RenderRow } from './virtual-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../rows/groups'
|
||||
import { isPinnedWorktreeRow } from '../rows/renderable-rows'
|
||||
import { getRenderRowWorktreeItem, renderRowContainsWorktree } from '../rows/render-row-lookup'
|
||||
import { getWorktreeOptionId } from '../option-dom'
|
||||
import type { RenderRow } from '../listing/render-row'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
|
||||
import { isPinnedWorktreeRow } from '../listing/renderable-rows'
|
||||
import { getRenderRowWorktreeItem, renderRowContainsWorktree } from './render-row-lookup'
|
||||
import { getWorktreeOptionId } from '../rows/option-dom'
|
||||
|
||||
export function getRenderRowOptionId(
|
||||
row: RenderRow | undefined,
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
getKnownSidebarWorktreeById,
|
||||
sidebarWorkspaceStillExists
|
||||
} from './folder-reveal'
|
||||
import { getProjectGroupHeaderKey } from '../rows/groups'
|
||||
import { getProjectGroupHeaderKey } from '../grouping/group-keys'
|
||||
|
||||
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
|
||||
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { getProjectGroupHeaderKey } from '../rows/groups'
|
||||
import { getProjectGroupHeaderKey } from '../grouping/group-keys'
|
||||
|
||||
function findFolderWorkspaceByKey(
|
||||
worktreeId: string,
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { revealElementInScrollContainer } from '../../worktree-sidebar-reveal'
|
||||
import { getMountedWorktreeOptions, getWorktreeOptionId } from '../option-dom'
|
||||
import { getMountedWorktreeOptions, getWorktreeOptionId } from '../rows/option-dom'
|
||||
|
||||
export function revealMountedWorktreeElement(
|
||||
container: HTMLElement,
|
||||
+16
-9
@@ -9,17 +9,14 @@ import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shar
|
||||
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import type { RenderRow } from '../viewport/virtual-rows'
|
||||
import {
|
||||
getGroupKeysForWorktree,
|
||||
getLineageGroupKey,
|
||||
type PinnedWorktreeDisplayPolicy,
|
||||
type ProjectGroupingModel,
|
||||
type WorktreeGroupBy
|
||||
} from '../rows/groups'
|
||||
import type { RenderRow } from '../listing/render-row'
|
||||
import { getLineageGroupKey } from '../grouping/group-keys'
|
||||
import type { ProjectGroupingModel } from '../grouping/project-grouping'
|
||||
import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types'
|
||||
import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys'
|
||||
import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection'
|
||||
import { getFolderWorkspaceRevealGroupKeys } from './folder-reveal'
|
||||
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './ancestors'
|
||||
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './reveal-ancestors'
|
||||
|
||||
export const MAX_REVEAL_RETRIES = 8
|
||||
|
||||
@@ -114,3 +111,13 @@ export function expandGroupsForWorktreeReveal(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePendingSidebarReveal(args: {
|
||||
targetIndex: number
|
||||
targetWorktreeStillExists: boolean
|
||||
}): 'scroll-and-clear' | 'clear' | 'keep-pending' {
|
||||
if (args.targetIndex !== -1) {
|
||||
return 'scroll-and-clear'
|
||||
}
|
||||
return args.targetWorktreeStillExists ? 'keep-pending' : 'clear'
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import type { RenderRow } from '../viewport/virtual-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from './groups'
|
||||
import { isPinnedWorktreeRow, type WorktreeItemRow } from './renderable-rows'
|
||||
import type { RenderRow } from '../listing/render-row'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
|
||||
import { isPinnedWorktreeRow, type WorktreeItemRow } from '../listing/renderable-rows'
|
||||
|
||||
export function getRenderRowSidebarKey(row: RenderRow): string | null {
|
||||
if (row.type === 'header') {
|
||||
+2
-5
@@ -1,11 +1,8 @@
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import {
|
||||
getProjectGroupHeaderKey,
|
||||
PINNED_GROUP_KEY,
|
||||
type ProjectGroupingModel
|
||||
} from '../rows/groups'
|
||||
import { PINNED_GROUP_KEY, getProjectGroupHeaderKey } from '../grouping/group-keys'
|
||||
import type { ProjectGroupingModel } from '../grouping/project-grouping'
|
||||
|
||||
function getProjectIdFromHeaderRowKey(rowKey: string): string | null {
|
||||
if (!rowKey.startsWith('project:')) {
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useLayoutEffect, useState } from 'react'
|
||||
import type { ActiveSurfaceVariant } from '../../WorktreeCard'
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../rows/groups'
|
||||
import { isPinnedWorktreeRow, type WorktreeItemRow } from '../rows/renderable-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
|
||||
import { isPinnedWorktreeRow, type WorktreeItemRow } from '../listing/renderable-rows'
|
||||
|
||||
// A worktree can render in more than one section; the row the user actually clicked owns
|
||||
// the primary active surface so its duplicates stay visually secondary.
|
||||
+3
-3
@@ -6,10 +6,10 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { keybindingMatchesAction } from '../../../../../../shared/keybindings'
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../rows/groups'
|
||||
import type { RenderRow } from './virtual-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
|
||||
import type { RenderRow } from '../listing/render-row'
|
||||
import { getCyclableWorktreeIds, resolveCycledWorktreeId } from '../../worktree-keyboard-cycle'
|
||||
import { findPreferredRenderRowIndexForWorktree } from '../rows/render-row-lookup'
|
||||
import { findPreferredRenderRowIndexForWorktree } from './render-row-lookup'
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
+6
-6
@@ -2,21 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRenderRowOptionId } from '../viewport/active-descendant-option'
|
||||
import { getRenderRowOptionId } from './active-descendant-option'
|
||||
import {
|
||||
findPreferredRenderRowIndexForWorktree,
|
||||
getRenderRowSidebarKey,
|
||||
rowKeyMatchesRenderRow
|
||||
} from '../rows/render-row-lookup'
|
||||
import { revealMountedSidebarRowElement, revealMountedWorktreeElement } from './mounted-row'
|
||||
import { getSidebarRowRevealAncestorKeys } from './ancestors'
|
||||
} from './render-row-lookup'
|
||||
import { revealMountedSidebarRowElement, revealMountedWorktreeElement } from './mounted-row-reveal'
|
||||
import { getSidebarRowRevealAncestorKeys } from './reveal-ancestors'
|
||||
import { sidebarWorkspaceStillExists } from './folder-reveal'
|
||||
import { resolvePendingSidebarReveal } from '../scroll/tuning'
|
||||
import {
|
||||
expandGroupsForWorktreeReveal,
|
||||
MAX_REVEAL_RETRIES,
|
||||
resolvePendingSidebarReveal,
|
||||
type PendingSidebarRevealArgs
|
||||
} from './pending-inputs'
|
||||
} from './pending-reveal-inputs'
|
||||
|
||||
// Drives the store's two pending reveal requests (a worktree card, or any sidebar row)
|
||||
// from "expand the ancestors" through "scroll the mounted element into view".
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/lib/scroll-to-current-workspace-status'
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeGroupBy } from '../rows/groups'
|
||||
import type { WorktreeGroupBy } from '../grouping/row-types'
|
||||
import { getKnownSidebarWorktreeById } from './folder-reveal'
|
||||
|
||||
// Turns a "show me the current workspace" request into whatever the sidebar must change
|
||||
+7
-7
@@ -1,17 +1,17 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import type { Worktree } from '../../../../../shared/worktree/types'
|
||||
import type { HostSectionRow } from '../host-section-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from './rows/groups'
|
||||
import { getRenderedWorktreesInSidebarOrder } from '../worktree-sidebar-row-preference'
|
||||
import { setVisibleWorktreeIds } from '../visible-worktrees'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { HostSectionRow } from '../../host-section-rows'
|
||||
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
|
||||
import { getRenderedWorktreesInSidebarOrder } from '../../worktree-sidebar-row-preference'
|
||||
import { setVisibleWorktreeIds } from '../../visible-worktrees'
|
||||
import {
|
||||
areWorktreeSelectionsEqual,
|
||||
getWorktreeSelectionIntent,
|
||||
pruneWorktreeSelection,
|
||||
updateWorktreeSelection
|
||||
} from '../worktree-multi-selection'
|
||||
import { useReusedArrayIdentity } from './use-reused-array-identity'
|
||||
} from '../../worktree-multi-selection'
|
||||
import { useReusedArrayIdentity } from '../listing/use-reused-array-identity'
|
||||
|
||||
function uniqueWorktreeIds(ids: readonly string[]): string[] {
|
||||
return Array.from(new Set(ids))
|
||||
+5
-5
@@ -2,11 +2,11 @@ import React from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { findRepoForHost } from '@/store/slices/repo-host-identity'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import { ProjectGroupNameDialog } from '../ProjectGroupNameDialog'
|
||||
import { ProjectGroupDeleteDialog } from '../ProjectGroupDeleteDialog'
|
||||
import SuppressExternalWorktreeInboxDialog from '../SuppressExternalWorktreeInboxDialog'
|
||||
import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import { ProjectGroupNameDialog } from '../../ProjectGroupNameDialog'
|
||||
import { ProjectGroupDeleteDialog } from '../../ProjectGroupDeleteDialog'
|
||||
import SuppressExternalWorktreeInboxDialog from '../../SuppressExternalWorktreeInboxDialog'
|
||||
import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions'
|
||||
import type { ProjectGroupDialogs } from './use-project-group-dialogs'
|
||||
|
||||
export function SidebarWorktreeListDialogs({
|
||||
+5
-5
@@ -12,8 +12,8 @@ import type {
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition
|
||||
} from '../../../../../../shared/worktree/types'
|
||||
import type { GroupHeaderRow, WorktreeGroupBy } from '../rows/groups'
|
||||
import { PINNED_GROUP_KEY } from '../rows/groups'
|
||||
import type { GroupHeaderRow, WorktreeGroupBy } from '../grouping/row-types'
|
||||
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
|
||||
import { getWorkspaceStatusFromGroupKey } from '../../workspace-status'
|
||||
import { getVirtualRowTransform } from '../viewport/virtual-rows'
|
||||
import { resolveProjectGroupHeaderColor } from '../../project-header-color'
|
||||
@@ -22,7 +22,7 @@ import { ProjectHeaderActions } from '../../ProjectHeaderActions'
|
||||
import {
|
||||
getProjectGroupHeaderPaddingLeft,
|
||||
WORKTREE_SECTION_HEADER_PADDING_LEFT
|
||||
} from '../rows/indentation'
|
||||
} from './indentation'
|
||||
import { FolderPathStatusIndicator } from './FolderPathStatusIndicator'
|
||||
import {
|
||||
ProjectGroupCreateWorkspaceButton,
|
||||
@@ -36,9 +36,9 @@ import {
|
||||
import {
|
||||
handleRepoHeaderCollapseAffordancePointerDown,
|
||||
shouldIgnoreRepoHeaderToggle
|
||||
} from './event-guards'
|
||||
} from './header-event-guards'
|
||||
import type { WorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
|
||||
import { getWorktreeOptionId } from '../option-dom'
|
||||
import { getWorktreeOptionId } from './option-dom'
|
||||
|
||||
export type SectionHeaderRowContext = {
|
||||
groupBy: WorktreeGroupBy
|
||||
@@ -11,13 +11,13 @@ import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-
|
||||
import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status'
|
||||
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
|
||||
import WorktreeCard from '../../WorktreeCard'
|
||||
import type { WorktreeGroupBy } from './groups'
|
||||
import type { WorktreeGroupBy } from '../grouping/row-types'
|
||||
import { getVirtualRowTransform } from '../viewport/virtual-rows'
|
||||
import { getFolderWorkspaceRowGeometry } from './indentation'
|
||||
import { getFolderWorkspaceCardPrDisplay } from '../../folder-workspace-card-pr-display'
|
||||
import { FolderPathStatusIndicator } from '../headers/FolderPathStatusIndicator'
|
||||
import type { FolderWorkspaceItemRow } from './renderable-rows'
|
||||
import { getWorktreeOptionId } from '../option-dom'
|
||||
import { FolderPathStatusIndicator } from './FolderPathStatusIndicator'
|
||||
import type { FolderWorkspaceItemRow } from '../listing/renderable-rows'
|
||||
import { getWorktreeOptionId } from './option-dom'
|
||||
|
||||
export type FolderWorkspaceRowContext = {
|
||||
groupBy: WorktreeGroupBy
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@ import { cn } from '@/lib/utils'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import WorktreeCard, { type ActiveSurfaceVariant } from '../../WorktreeCard'
|
||||
import { PINNED_GROUP_KEY, type WorktreeGroupBy } from './groups'
|
||||
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
|
||||
import type { WorktreeGroupBy } from '../grouping/row-types'
|
||||
import {
|
||||
getFolderBackedRepoWorktreeCardContentIndent,
|
||||
getFolderBackedRepoWorktreeCardSurfaceInset,
|
||||
@@ -14,9 +15,9 @@ import {
|
||||
LINEAGE_CHILDREN_INLINE_OFFSET
|
||||
} from './indentation'
|
||||
import type { LineageToggleHandler } from '../../worktree-lineage-toggle-handler-cache'
|
||||
import { stopNestedWorktreeCardBubble } from '../headers/event-guards'
|
||||
import type { WorktreeItemRow } from './renderable-rows'
|
||||
import { getWorktreeOptionId } from '../option-dom'
|
||||
import { stopNestedWorktreeCardBubble } from './header-event-guards'
|
||||
import type { WorktreeItemRow } from '../listing/renderable-rows'
|
||||
import { getWorktreeOptionId } from './option-dom'
|
||||
import type { WorktreePointerDrag, WorktreeRowDragState } from '../drag/row-state'
|
||||
|
||||
export type WorktreeItemRowContext = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import type { VirtualItem } from '@tanstack/react-virtual'
|
||||
import type { Row } from './groups'
|
||||
import type { Row } from '../grouping/row-types'
|
||||
import { getVirtualRowTransform } from '../viewport/virtual-rows'
|
||||
import { PendingWorktreeRow } from '../../PendingWorktreeRow'
|
||||
import ImportedWorktreesVisibilityLine from '../../ImportedWorktreesVisibilityLine'
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import {
|
||||
handleRepoHeaderActionPointerDown,
|
||||
stopRepoHeaderKeyboardToggle,
|
||||
stopRepoHeaderMenuEvent
|
||||
} from './event-guards'
|
||||
} from './header-event-guards'
|
||||
|
||||
export function ProjectGroupHeaderMenu({
|
||||
groupId,
|
||||
+1
-1
@@ -42,7 +42,7 @@ import {
|
||||
handleRepoHeaderActionPointerDown,
|
||||
stopRepoHeaderKeyboardToggle,
|
||||
stopRepoHeaderMenuEvent
|
||||
} from './event-guards'
|
||||
} from './header-event-guards'
|
||||
|
||||
function getWorktreeVisibilityMenuLabel(
|
||||
repo: Repo,
|
||||
+2
-2
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets'
|
||||
import type { ProjectGroup } from '../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../shared/repo-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
|
||||
export type ProjectGroupNameDialogState =
|
||||
| { type: 'create-from-repo'; repo: Repo }
|
||||
@@ -6,17 +6,17 @@ import type {
|
||||
WorkspaceStatusDefinition
|
||||
} from '../../../../../../shared/worktree/types'
|
||||
import { getWorkspaceStatus } from '../../workspace-status'
|
||||
import type { WorktreeGroupBy } from './groups'
|
||||
import type { WorktreeGroupBy } from '../grouping/row-types'
|
||||
import {
|
||||
getVirtualRowTransform,
|
||||
getWorktreeVirtualRowTransform,
|
||||
shouldUseHeaderTopSpacing,
|
||||
type RenderRow
|
||||
shouldUseHeaderTopSpacing
|
||||
} from '../viewport/virtual-rows'
|
||||
import type { RenderRow } from '../listing/render-row'
|
||||
import type { ImportedWorktreeCardActionState } from '../../imported-worktrees-card-actions'
|
||||
import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions'
|
||||
import type { useHostHeaderDrag } from '../../host-header-drag'
|
||||
import { HostSectionHeader } from '../headers/HostSectionHeader'
|
||||
import { HostSectionHeader } from './HostSectionHeader'
|
||||
import { renderFolderWorkspaceVirtualRow, type FolderWorkspaceRowContext } from './folder-row'
|
||||
import {
|
||||
renderImportedWorktreesVirtualRow,
|
||||
@@ -28,10 +28,7 @@ import {
|
||||
renderWorktreeLineageDescendants,
|
||||
type WorktreeItemRowContext
|
||||
} from './item-row'
|
||||
import {
|
||||
renderWorktreeSectionHeaderRow,
|
||||
type SectionHeaderRowContext
|
||||
} from '../headers/SectionHeader'
|
||||
import { renderWorktreeSectionHeaderRow, type SectionHeaderRowContext } from './SectionHeader'
|
||||
import type { WorktreeRowDragState } from '../drag/row-state'
|
||||
|
||||
export type WorktreeVirtualRowContext = {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type React from 'react'
|
||||
|
||||
// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions.
|
||||
export const SORT_SETTLE_MS = 3_000
|
||||
export const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500
|
||||
export const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300
|
||||
export const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
|
||||
|
||||
export const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = {
|
||||
// Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps.
|
||||
overflowAnchor: 'none'
|
||||
}
|
||||
|
||||
const recordKeyCountCache = new WeakMap<Record<string, unknown>, number>()
|
||||
|
||||
export function countRecordKeysByReference(record: Record<string, unknown>): number {
|
||||
const cached = recordKeyCountCache.get(record)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const count = Object.keys(record).length
|
||||
recordKeyCountCache.set(record, count)
|
||||
return count
|
||||
}
|
||||
|
||||
export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
|
||||
isScrolling: boolean
|
||||
now: number
|
||||
suppressUntil: number
|
||||
}): boolean {
|
||||
return !args.isScrolling && args.now >= args.suppressUntil
|
||||
}
|
||||
|
||||
export function resolvePendingSidebarReveal(args: {
|
||||
targetIndex: number
|
||||
targetWorktreeStillExists: boolean
|
||||
}): 'scroll-and-clear' | 'clear' | 'keep-pending' {
|
||||
if (args.targetIndex !== -1) {
|
||||
return 'scroll-and-clear'
|
||||
}
|
||||
return args.targetWorktreeStillExists ? 'keep-pending' : 'clear'
|
||||
}
|
||||
|
||||
export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void {
|
||||
document.addEventListener('visibilitychange', onChange)
|
||||
return () => document.removeEventListener('visibilitychange', onChange)
|
||||
}
|
||||
+32
-69
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { WorktreeSidebarDropIndicator } from '../../WorktreeSidebarDropIndicator'
|
||||
import { WorktreeListScrollToTopButton } from '../../WorktreeListScrollToTopButton'
|
||||
import { useWorktreeListScrollToTop } from '../scroll/use-scroll-to-top'
|
||||
import { getActiveDescendantOptionId } from './active-descendant-option'
|
||||
import { buildRenderableRows } from '../rows/renderable-rows'
|
||||
import { useFolderWorkspacePathStatusRows } from './use-folder-path-statuses'
|
||||
import { renderWorktreeSidebarDropIndicators } from './drop-indicators'
|
||||
import { useWorktreeListScrollToTop } from './use-scroll-to-top'
|
||||
import { getActiveDescendantOptionId } from '../navigation/active-descendant-option'
|
||||
import { buildRenderableRows } from '../listing/renderable-rows'
|
||||
import { useFolderWorkspacePathStatusRows } from '../listing/use-folder-path-statuses'
|
||||
import { useGroupToggleWithScrollAnchor } from './use-group-toggle'
|
||||
import { usePendingSidebarReveal } from '../reveal/use-pending'
|
||||
import { usePrimaryActiveWorktreeRow } from './use-active-row'
|
||||
import { useSidebarRevealHighlight } from '../reveal/use-highlight'
|
||||
import { usePendingSidebarReveal } from '../navigation/use-pending-reveal'
|
||||
import { usePrimaryActiveWorktreeRow } from '../navigation/use-active-row'
|
||||
import { useSidebarRevealHighlight } from '../navigation/use-reveal-highlight'
|
||||
import { useVirtualRowMeasurementSync } from './use-row-measurement'
|
||||
import { useVisiblePrRefreshReporting } from './use-visible-review-refresh'
|
||||
import { useWorkspaceStatusRowDrag } from '../drag/use-status-row-drag'
|
||||
@@ -18,18 +18,22 @@ import { useWorktreeDragRuntime } from '../drag/use-runtime'
|
||||
import { useWorktreeDragSession } from '../drag/use-session'
|
||||
import { useWorktreeDocumentDrop } from '../drag/use-document-drop'
|
||||
import { useWorktreeLineageDropCommit } from '../drag/use-lineage-drop-commit'
|
||||
import { useWorktreeListKeyboardNavigation } from './use-keyboard'
|
||||
import { useWorktreeListKeyboardNavigation } from '../navigation/use-keyboard'
|
||||
import { useWorktreeListVirtualizer } from './use-virtualizer'
|
||||
import { useWorktreeNativeDrag } from '../drag/use-native-drag'
|
||||
import { useWorktreePointerDrag } from '../drag/use-pointer-drag'
|
||||
import { useWorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
|
||||
import { useWorktreeSidebarScrollSuppression } from '../scroll/use-suppression'
|
||||
import { useWorktreeSidebarScrollSuppression } from './use-scroll-suppression'
|
||||
import { EMPTY_PROJECT_GROUPS, type VirtualizedWorktreeViewportProps } from './viewport-props'
|
||||
import { WORKTREE_SIDEBAR_SCROLL_STYLE } from '../scroll/tuning'
|
||||
import type { WorktreeDropCommitContext } from '../drag/drop-commit-context'
|
||||
import { buildWorktreeVirtualRowContext } from '../rows/virtual-row-context'
|
||||
import { useWorktreeDropCommitContext } from '../drag/use-drop-commit-context'
|
||||
import { buildWorktreeVirtualRowContext } from './virtual-row-context'
|
||||
import { renderWorktreeVirtualRow } from '../rows/virtual-row-dispatch'
|
||||
|
||||
const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = {
|
||||
// Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps.
|
||||
overflowAnchor: 'none'
|
||||
}
|
||||
|
||||
export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport(
|
||||
props: VirtualizedWorktreeViewportProps
|
||||
) {
|
||||
@@ -188,41 +192,17 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
|
||||
markDirectScrollInput
|
||||
})
|
||||
|
||||
const dropCtx = useMemo<WorktreeDropCommitContext>(
|
||||
() => ({
|
||||
scrollRef,
|
||||
workspaceStatuses,
|
||||
worktreeDragGroups: session.worktreeDragGroups,
|
||||
worktreeDragUnitGroups: session.worktreeDragUnitGroups,
|
||||
computeWorktreeDrop: session.computeWorktreeDrop,
|
||||
computeWorktreeStatusDrop: session.computeWorktreeStatusDrop,
|
||||
refreshWorktreeDragSession: session.refreshWorktreeDragSession,
|
||||
getEligibleLineageDropTarget: lineageDrop.getEligibleLineageDropTarget,
|
||||
commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop,
|
||||
clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents,
|
||||
clearWorktreeDrag: runtime.clearWorktreeDrag,
|
||||
onMoveWorktreesToStatus: props.onMoveWorktreesToStatus,
|
||||
onMoveWorktreesToStatusAtIndex: props.onMoveWorktreesToStatusAtIndex,
|
||||
onReorderWorktrees: props.onReorderWorktrees,
|
||||
onPinWorktrees: props.onPinWorktrees
|
||||
}),
|
||||
[
|
||||
lineageDrop.clearReorderedWorktreeParents,
|
||||
lineageDrop.commitWorktreeLineageParentDrop,
|
||||
lineageDrop.getEligibleLineageDropTarget,
|
||||
props.onMoveWorktreesToStatus,
|
||||
props.onMoveWorktreesToStatusAtIndex,
|
||||
props.onPinWorktrees,
|
||||
props.onReorderWorktrees,
|
||||
runtime.clearWorktreeDrag,
|
||||
session.computeWorktreeDrop,
|
||||
session.computeWorktreeStatusDrop,
|
||||
session.refreshWorktreeDragSession,
|
||||
session.worktreeDragGroups,
|
||||
session.worktreeDragUnitGroups,
|
||||
workspaceStatuses
|
||||
]
|
||||
)
|
||||
const dropCtx = useWorktreeDropCommitContext({
|
||||
scrollRef,
|
||||
workspaceStatuses,
|
||||
session,
|
||||
lineageDrop,
|
||||
runtime,
|
||||
onMoveWorktreesToStatus: props.onMoveWorktreesToStatus,
|
||||
onMoveWorktreesToStatusAtIndex: props.onMoveWorktreesToStatusAtIndex,
|
||||
onReorderWorktrees: props.onReorderWorktrees,
|
||||
onPinWorktrees: props.onPinWorktrees
|
||||
})
|
||||
|
||||
const { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } = useWorktreePointerDrag({
|
||||
ctx: dropCtx,
|
||||
@@ -377,27 +357,10 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
|
||||
className="relative w-full"
|
||||
style={{ height: `${virtualization.virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{headerDrag.canReorderRepoHeaders &&
|
||||
headerDrag.repoDrag.state.draggingRepoId !== null &&
|
||||
headerDrag.repoDrag.state.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator y={headerDrag.repoDrag.state.dropIndicatorY} />
|
||||
) : null}
|
||||
{headerDrag.canReorderProjectGroupHeaders &&
|
||||
headerDrag.projectGroupDrag.state.draggingGroupId !== null &&
|
||||
headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator y={headerDrag.projectGroupDrag.state.dropIndicatorY} />
|
||||
) : null}
|
||||
{headerDrag.hostDrag.state.draggingHostId !== null &&
|
||||
headerDrag.hostDrag.state.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator
|
||||
y={headerDrag.hostDrag.state.dropIndicatorY}
|
||||
className="z-40"
|
||||
/>
|
||||
) : null}
|
||||
{runtime.worktreeDragState.draggingWorktreeId !== null &&
|
||||
runtime.worktreeDragState.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator y={runtime.worktreeDragState.dropIndicatorY} />
|
||||
) : null}
|
||||
{renderWorktreeSidebarDropIndicators({
|
||||
headerDrag,
|
||||
worktreeDragState: runtime.worktreeDragState
|
||||
})}
|
||||
{virtualItems.map((vItem) => {
|
||||
const row = renderRows[vItem.index]
|
||||
return row ? renderWorktreeVirtualRow(rowContext, row, vItem) : null
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { WorktreeSidebarDropIndicator } from '../../WorktreeSidebarDropIndicator'
|
||||
import type { useWorktreeDragRuntime } from '../drag/use-runtime'
|
||||
import type { useWorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
|
||||
|
||||
/** Header and worktree drop lines float above the virtual rows, so they render as siblings. */
|
||||
export function renderWorktreeSidebarDropIndicators(args: {
|
||||
headerDrag: ReturnType<typeof useWorktreeSidebarHeaderDrag>
|
||||
worktreeDragState: ReturnType<typeof useWorktreeDragRuntime>['worktreeDragState']
|
||||
}): React.ReactNode {
|
||||
const { headerDrag, worktreeDragState } = args
|
||||
return (
|
||||
<>
|
||||
{headerDrag.canReorderRepoHeaders &&
|
||||
headerDrag.repoDrag.state.draggingRepoId !== null &&
|
||||
headerDrag.repoDrag.state.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator y={headerDrag.repoDrag.state.dropIndicatorY} />
|
||||
) : null}
|
||||
{headerDrag.canReorderProjectGroupHeaders &&
|
||||
headerDrag.projectGroupDrag.state.draggingGroupId !== null &&
|
||||
headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator y={headerDrag.projectGroupDrag.state.dropIndicatorY} />
|
||||
) : null}
|
||||
{headerDrag.hostDrag.state.draggingHostId !== null &&
|
||||
headerDrag.hostDrag.state.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator
|
||||
y={headerDrag.hostDrag.state.dropIndicatorY}
|
||||
className="z-40"
|
||||
/>
|
||||
) : null}
|
||||
{worktreeDragState.draggingWorktreeId !== null &&
|
||||
worktreeDragState.dropIndicatorY !== null ? (
|
||||
<WorktreeSidebarDropIndicator y={worktreeDragState.dropIndicatorY} />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+5
-7
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
countRecordKeysByReference,
|
||||
resolvePendingSidebarReveal,
|
||||
shouldAdjustWorktreeSidebarMeasuredRowScroll
|
||||
} from './tuning'
|
||||
import { countRecordKeysByReference } from './use-row-measurement'
|
||||
import { shouldAdjustWorktreeSidebarMeasuredRowScroll } from './use-scroll-suppression'
|
||||
import { resolvePendingSidebarReveal } from '../navigation/pending-reveal-inputs'
|
||||
import {
|
||||
getScrollTopToRevealBounds,
|
||||
WORKTREE_SIDEBAR_REVEAL_TOP_INSET
|
||||
@@ -13,9 +11,9 @@ import {
|
||||
estimateRenderRowSize,
|
||||
GROUP_HEADER_ROW_HEIGHT,
|
||||
getActiveStickyHeaderIndexForScroll
|
||||
} from '../viewport/virtual-rows'
|
||||
} from './virtual-rows'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Row } from '../rows/groups'
|
||||
import type { Row } from '../grouping/row-types'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildRows } from '../rows/groups'
|
||||
import { buildRows } from '../grouping/build-rows'
|
||||
import { getStickyHeaderIndexes } from './virtual-rows'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { Row } from '../rows/groups'
|
||||
import type { Row } from '../grouping/row-types'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
|
||||
@@ -8,14 +8,25 @@ import {
|
||||
import {
|
||||
buildLineageRowRekeyMap,
|
||||
getActiveStickyIndexesForScroll,
|
||||
getRenderRowKey,
|
||||
getVirtualRowKey,
|
||||
pruneStaleVirtualRowElementCache,
|
||||
type RenderRow
|
||||
pruneStaleVirtualRowElementCache
|
||||
} from './virtual-rows'
|
||||
import { countRecordKeysByReference } from '../scroll/tuning'
|
||||
import { getRenderRowKey } from '../listing/render-row'
|
||||
import type { RenderRow } from '../listing/render-row'
|
||||
import type { WorktreeListVirtualizer } from './use-virtualizer'
|
||||
|
||||
const recordKeyCountCache = new WeakMap<Record<string, unknown>, number>()
|
||||
|
||||
export function countRecordKeysByReference(record: Record<string, unknown>): number {
|
||||
const cached = recordKeyCountCache.get(record)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const count = Object.keys(record).length
|
||||
recordKeyCountCache.set(record, count)
|
||||
return count
|
||||
}
|
||||
|
||||
// Re-measures only rows whose DOM node still matches its virtual key, and publishes the
|
||||
// sticky-header slots the render pass reads out of refs.
|
||||
export function useVirtualRowMeasurementSync(args: {
|
||||
|
||||
+11
-4
@@ -6,10 +6,17 @@ import {
|
||||
isRevealScrollSettling,
|
||||
type PendingRevealScroll
|
||||
} from '../../worktree-sidebar-reveal-scroll-settle'
|
||||
import {
|
||||
EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS,
|
||||
USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS
|
||||
} from './tuning'
|
||||
|
||||
export const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500
|
||||
export const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300
|
||||
|
||||
export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
|
||||
isScrolling: boolean
|
||||
now: number
|
||||
suppressUntil: number
|
||||
}): boolean {
|
||||
return !args.isScrolling && args.now >= args.suppressUntil
|
||||
}
|
||||
|
||||
export type WorktreeSidebarScrollSuppression = ReturnType<
|
||||
typeof useWorktreeSidebarScrollSuppression
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user