mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
feat: add search to workspace kanban board
Search filters workspace cards by display name, branch, repo, and comment. Lanes show match counts (e.g., "2 / 5") when filtered and reset to full counts when cleared. Drag-drop indices are mapped from rendered cards to the full lane so manual-order math is correct even when hidden. Query clears when the board closes to prevent stale filters on reopen. Includes keyboard shortcuts (Escape to clear), live region announcements for matches, and i18n support.
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Repo, Worktree, WorktreeMeta } from '../../../../shared/types'
|
||||
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
import type { WorkspaceKanbanLaneView } from './workspace-kanban-search'
|
||||
|
||||
type HeaderCapture = {
|
||||
query: string
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
onClearQuery: () => void
|
||||
}
|
||||
|
||||
type GridCapture = {
|
||||
laneViews: ReadonlyMap<string, WorkspaceKanbanLaneView>
|
||||
laneFullWorktreeIds: ReadonlyMap<string, readonly string[]>
|
||||
hasQuery: boolean
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onContextMenuSelect: (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
}
|
||||
|
||||
type PointerDragCapture = {
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onDropWorktreesInStatus: (args: {
|
||||
worktreeIds: readonly string[]
|
||||
status: string
|
||||
dropIndex: number
|
||||
}) => void
|
||||
}
|
||||
|
||||
const {
|
||||
syncWorkspaceBoardTaskStatusesMock,
|
||||
headerState,
|
||||
gridState,
|
||||
pointerDragState,
|
||||
selectionState
|
||||
} = vi.hoisted(() => ({
|
||||
syncWorkspaceBoardTaskStatusesMock: vi.fn(() =>
|
||||
Promise.resolve({ updated: 1, skipped: 0, failed: 0, messages: [] })
|
||||
),
|
||||
headerState: { current: null as HeaderCapture | null },
|
||||
gridState: { current: null as GridCapture | null },
|
||||
pointerDragState: { current: null as PointerDragCapture | null },
|
||||
selectionState: { current: [] as Worktree[] }
|
||||
}))
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } }))
|
||||
|
||||
vi.mock('@/components/ui/sheet', () => ({
|
||||
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanDrawerHeader', () => ({
|
||||
default: (props: HeaderCapture) => {
|
||||
headerState.current = props
|
||||
return <div data-testid="workspace-board-header" />
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanLaneGrid', () => ({
|
||||
default: (props: GridCapture) => {
|
||||
gridState.current = props
|
||||
return <div data-testid="workspace-board-lanes" />
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({
|
||||
default: React.forwardRef<HTMLDivElement>((_, ref) => <div ref={ref} />)
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanPinDropTarget', () => ({ default: () => <div /> }))
|
||||
|
||||
vi.mock('./use-visible-workspace-kanban-worktree-ids', () => ({
|
||||
useVisibleWorkspaceKanbanWorktreeIds: ({ allWorktrees }: { allWorktrees: readonly Worktree[] }) =>
|
||||
new Set(allWorktrees.map((worktree) => worktree.id))
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-selection', () => ({
|
||||
useWorkspaceKanbanSelection: () => ({
|
||||
selectedWorktreeIds: new Set(selectionState.current.map((worktree) => worktree.id)),
|
||||
selectedWorktrees: selectionState.current,
|
||||
selectionAnchorId: null,
|
||||
updateSelectionForGesture: vi.fn(),
|
||||
updateSelectionForArea: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
selectForContextMenu: vi.fn(() => selectionState.current)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-area-selection', () => ({
|
||||
useWorkspaceKanbanAreaSelection: () => ({ handleAreaSelectionPointerDown: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-column-resize', () => ({
|
||||
useWorkspaceKanbanColumnResize: () => ({
|
||||
columnWidth: 308,
|
||||
isResizingColumn: false,
|
||||
onColumnResizeStart: vi.fn(),
|
||||
onColumnResizeKeyDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-create-worktree', () => ({
|
||||
useWorkspaceKanbanCreateWorktree: () => ({
|
||||
canCreateWorktree: true,
|
||||
createWorktreeForStatus: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-shift-wheel-scroll', () => ({
|
||||
useWorkspaceKanbanShiftWheelScroll: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-outside-dismiss', () => ({
|
||||
isWorkspaceBoardKeepOpenTarget: () => false,
|
||||
useWorkspaceKanbanOutsideDismiss: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
|
||||
useContextualTour: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-card-pointer-drag', () => ({
|
||||
useWorkspaceKanbanCardPointerDrag: (params: PointerDragCapture) => {
|
||||
pointerDragState.current = params
|
||||
return { isPointerDragActiveRef: { current: false }, onCardPointerDownCapture: vi.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-status-drop', () => ({
|
||||
useWorkspaceStatusDocumentDrop: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-board-task-status-sync', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
syncWorkspaceBoardTaskStatuses: syncWorkspaceBoardTaskStatusesMock
|
||||
}))
|
||||
|
||||
type UpdateWorktreesMeta = (
|
||||
updatesByWorktreeId: ReadonlyMap<string, Partial<WorktreeMeta>>
|
||||
) => Promise<void>
|
||||
|
||||
const statuses = [
|
||||
{ id: 'todo', label: 'Todo' },
|
||||
{ id: 'in-review', label: 'In review' }
|
||||
]
|
||||
|
||||
function worktree(name: string, manualOrder: number, workspaceStatus: string): Worktree {
|
||||
return {
|
||||
id: `repo-a::/${name.toLowerCase()}`,
|
||||
repoId: 'repo-a',
|
||||
displayName: name,
|
||||
path: `/${name.toLowerCase()}`,
|
||||
branch: `feature/${name.toLowerCase()}`,
|
||||
baseBranch: 'main',
|
||||
isPinned: false,
|
||||
sortOrder: manualOrder,
|
||||
manualOrder,
|
||||
lastActivityAt: 1,
|
||||
workspaceStatus
|
||||
} as unknown as Worktree
|
||||
}
|
||||
|
||||
const alpha = worktree('Alpha', 100, 'todo')
|
||||
const beta = worktree('Beta', 200, 'todo')
|
||||
const gamma = worktree('Gamma', 300, 'todo')
|
||||
const delta = worktree('Delta', 400, 'todo')
|
||||
const omega = worktree('Omega', 100, 'in-review')
|
||||
const allWorktrees = [alpha, beta, gamma, delta, omega]
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let updateWorktreesMeta: ReturnType<typeof vi.fn<UpdateWorktreesMeta>>
|
||||
|
||||
function renderDrawer(open = true): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceKanbanDrawer
|
||||
open={open}
|
||||
statusBarVisible={true}
|
||||
dragPreview={false}
|
||||
preserveOpenForMenu={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onMenuOpenChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function typeQuery(query: string): void {
|
||||
act(() => {
|
||||
headerState.current?.onQueryChange(query)
|
||||
})
|
||||
}
|
||||
|
||||
function laneIds(status: string): string[] {
|
||||
return (gridState.current?.laneViews.get(status)?.items ?? []).map((item) => item.id)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
headerState.current = null
|
||||
gridState.current = null
|
||||
pointerDragState.current = null
|
||||
selectionState.current = []
|
||||
syncWorkspaceBoardTaskStatusesMock.mockClear()
|
||||
updateWorktreesMeta = vi.fn<UpdateWorktreesMeta>(() => Promise.resolve())
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{ id: 'repo-a', path: '/repo-a', name: 'repo-a', connectionId: null } as unknown as Repo
|
||||
],
|
||||
worktreesByRepo: { 'repo-a': allWorktrees },
|
||||
activeWorktreeId: alpha.id,
|
||||
workspaceStatuses: statuses,
|
||||
syncTaskStatusFromWorkspaceBoard: true,
|
||||
setSyncTaskStatusFromWorkspaceBoard: vi.fn(),
|
||||
workspaceBoardColumnWidth: 308,
|
||||
sidebarOpen: true,
|
||||
sidebarWidth: 280,
|
||||
sortBy: 'manual',
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
updateWorktreesMeta,
|
||||
getKnownWorktreeById: (id: string) => allWorktrees.find((item) => item.id === id),
|
||||
recordFeatureInteraction: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('WorkspaceKanbanDrawer search', () => {
|
||||
it('filters every lane in place and reports lane totals', () => {
|
||||
renderDrawer()
|
||||
expect(laneIds('todo')).toHaveLength(4)
|
||||
|
||||
typeQuery('gamma')
|
||||
|
||||
expect(laneIds('todo')).toEqual([gamma.id])
|
||||
expect(laneIds('in-review')).toEqual([])
|
||||
expect(gridState.current?.hasQuery).toBe(true)
|
||||
expect(gridState.current?.laneViews.get('todo')?.totalCount).toBe(4)
|
||||
expect(gridState.current?.laneViews.get('in-review')?.totalCount).toBe(1)
|
||||
expect(headerState.current).toMatchObject({ matchCount: 1, totalCount: 5 })
|
||||
})
|
||||
|
||||
it('restores every lane when the query is cleared', () => {
|
||||
renderDrawer()
|
||||
typeQuery('gamma')
|
||||
|
||||
act(() => {
|
||||
headerState.current?.onClearQuery()
|
||||
})
|
||||
|
||||
expect(laneIds('todo')).toHaveLength(4)
|
||||
expect(laneIds('in-review')).toEqual([omega.id])
|
||||
expect(gridState.current?.hasQuery).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the query when the board closes so a reopen starts unfiltered', () => {
|
||||
renderDrawer()
|
||||
typeQuery('gamma')
|
||||
expect(headerState.current?.query).toBe('gamma')
|
||||
|
||||
renderDrawer(false)
|
||||
renderDrawer(true)
|
||||
|
||||
expect(headerState.current?.query).toBe('')
|
||||
expect(laneIds('todo')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('still runs the Linear status sync for a drop made under an active query', () => {
|
||||
renderDrawer()
|
||||
typeQuery('gamma')
|
||||
|
||||
act(() => {
|
||||
pointerDragState.current?.onDropWorktreesInStatus({
|
||||
worktreeIds: [omega.id],
|
||||
status: 'todo',
|
||||
dropIndex: 0
|
||||
})
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeIds: [omega.id],
|
||||
targetStatus: { id: 'todo', label: 'Todo' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('narrows the pointer-drag payload to the rendered cards', () => {
|
||||
selectionState.current = [alpha, gamma]
|
||||
renderDrawer()
|
||||
expect(pointerDragState.current?.selectedWorktrees).toEqual([alpha, gamma])
|
||||
|
||||
typeQuery('gamma')
|
||||
|
||||
expect(pointerDragState.current?.selectedWorktrees).toEqual([gamma])
|
||||
})
|
||||
|
||||
it('narrows the context-menu "Move to Status" payload to the rendered cards', () => {
|
||||
selectionState.current = [alpha, gamma]
|
||||
renderDrawer()
|
||||
typeQuery('gamma')
|
||||
|
||||
const event = {} as React.MouseEvent<HTMLElement>
|
||||
expect(gridState.current?.onContextMenuSelect(event, gamma)).toEqual([gamma])
|
||||
})
|
||||
|
||||
it('keeps selection highlighting unfiltered while a query is active', () => {
|
||||
selectionState.current = [alpha, gamma]
|
||||
renderDrawer()
|
||||
typeQuery('gamma')
|
||||
|
||||
expect(gridState.current?.selectedWorktreeIds.has(alpha.id)).toBe(true)
|
||||
expect(gridState.current?.selectedWorktrees).toEqual([gamma])
|
||||
})
|
||||
|
||||
it('ranks a drop into a filtered lane against the full lane, not the rendered one', () => {
|
||||
renderDrawer()
|
||||
typeQuery('gamma')
|
||||
|
||||
act(() => {
|
||||
// Manual order is descending, so the lane is Delta, Gamma, Beta, Alpha.
|
||||
// Rendered index 0 means "above Gamma" — full-lane index 1, not the top.
|
||||
pointerDragState.current?.onDropWorktreesInStatus({
|
||||
worktreeIds: [omega.id],
|
||||
status: 'todo',
|
||||
dropIndex: 0
|
||||
})
|
||||
})
|
||||
|
||||
const dropped = updateWorktreesMeta.mock.calls.at(-1)?.[0].get(omega.id)
|
||||
expect(dropped?.workspaceStatus).toBe('todo')
|
||||
expect(dropped?.manualOrder).toBeGreaterThan(gamma.manualOrder ?? 0)
|
||||
expect(dropped?.manualOrder).toBeLessThan(delta.manualOrder ?? 0)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
import { useVisibleWorkspaceKanbanWorktreeIds } from './use-visible-workspace-kanban-worktree-ids'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { groupWorkspaceKanbanWorktrees } from './workspace-kanban-worktree-groups'
|
||||
import { resolveFullLaneDropIndex } from './workspace-kanban-filtered-drop-index'
|
||||
import { buildWorkspaceKanbanLaneViews } from './workspace-kanban-search'
|
||||
import { useWorkspaceKanbanSearch } from './use-workspace-kanban-search'
|
||||
import {
|
||||
getWorkspaceBoardTaskStatusSyncRequest,
|
||||
syncWorkspaceBoardTaskStatuses,
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
shouldWriteManualOrderForGroupDrop,
|
||||
type WorktreeDragGroup
|
||||
} from './worktree-manual-order'
|
||||
import type { WorkspaceStatus, WorktreeMeta } from '../../../../shared/types'
|
||||
import type { WorkspaceStatus, Worktree, WorktreeMeta } from '../../../../shared/types'
|
||||
import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
|
||||
import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from './workspace-chrome-metrics'
|
||||
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
|
||||
@@ -188,6 +191,19 @@ export default function WorkspaceKanbanDrawer({
|
||||
})),
|
||||
[worktreesByStatus, workspaceStatuses]
|
||||
)
|
||||
const laneFullWorktreeIds = useMemo(
|
||||
() => new Map(boardDragGroups.map((group) => [group.key, group.worktreeIds])),
|
||||
[boardDragGroups]
|
||||
)
|
||||
const { query, setQuery, clearQuery, matchingWorktreeIds, hasQuery } = useWorkspaceKanbanSearch({
|
||||
open,
|
||||
worktrees: boardWorktrees,
|
||||
repoMap
|
||||
})
|
||||
const laneViews = useMemo(
|
||||
() => buildWorkspaceKanbanLaneViews({ worktreesByStatus, matchingWorktreeIds }),
|
||||
[matchingWorktreeIds, worktreesByStatus]
|
||||
)
|
||||
const {
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
@@ -439,12 +455,50 @@ export default function WorkspaceKanbanDrawer({
|
||||
},
|
||||
[updateWorktreesMeta, worktreeById]
|
||||
)
|
||||
// Why: getCardDropTarget indexes the rendered cards, but manual-order math runs
|
||||
// against the full lane. Translate at the pointer-drag boundary only —
|
||||
// dropWorktreesAtEndOfStatus already passes a full-lane index.
|
||||
const dropPointerDraggedWorktreesInStatus = useCallback(
|
||||
(args: { worktreeIds: readonly string[]; status: WorkspaceStatus; dropIndex: number }) => {
|
||||
dropWorktreesInStatus({
|
||||
worktreeIds: args.worktreeIds,
|
||||
status: args.status,
|
||||
dropIndex: resolveFullLaneDropIndex({
|
||||
fullLaneIds: laneFullWorktreeIds.get(args.status) ?? [],
|
||||
renderedIds: (laneViews.get(args.status)?.items ?? []).map((worktree) => worktree.id),
|
||||
filteredDropIndex: args.dropIndex
|
||||
})
|
||||
})
|
||||
},
|
||||
[dropWorktreesInStatus, laneFullWorktreeIds, laneViews]
|
||||
)
|
||||
// Why: dragging or right-clicking one visible match must not silently move
|
||||
// hidden selected cards. selectedWorktreeIds stays unfiltered so highlighting
|
||||
// and area-selection anchoring still see the whole selection.
|
||||
const renderedSelectedWorktrees = useMemo(
|
||||
() =>
|
||||
matchingWorktreeIds
|
||||
? selectedWorktrees.filter((worktree) => matchingWorktreeIds.has(worktree.id))
|
||||
: selectedWorktrees,
|
||||
[matchingWorktreeIds, selectedWorktrees]
|
||||
)
|
||||
// Why: selectForContextMenu closes over the unfiltered selection, so the
|
||||
// "Move to Status" payload has to be narrowed here too.
|
||||
const selectRenderedForContextMenu = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>, worktree: Worktree): readonly Worktree[] => {
|
||||
const selection = selectForContextMenu(event, worktree)
|
||||
return matchingWorktreeIds
|
||||
? selection.filter((item) => matchingWorktreeIds.has(item.id))
|
||||
: selection
|
||||
},
|
||||
[matchingWorktreeIds, selectForContextMenu]
|
||||
)
|
||||
const { isPointerDragActiveRef, onCardPointerDownCapture } = useWorkspaceKanbanCardPointerDrag({
|
||||
open,
|
||||
boardRef,
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
onDropWorktreesInStatus: dropWorktreesInStatus,
|
||||
selectedWorktrees: renderedSelectedWorktrees,
|
||||
onDropWorktreesInStatus: dropPointerDraggedWorktreesInStatus,
|
||||
onPinWorktrees: pinWorktrees,
|
||||
onDragTargetChange: setDragOverStatus,
|
||||
onShouldShowDropIndicator: shouldWriteDropManualOrder,
|
||||
@@ -747,6 +801,11 @@ export default function WorkspaceKanbanDrawer({
|
||||
>
|
||||
<WorkspaceKanbanDrawerHeader
|
||||
selectedCount={selectedWorktrees.length}
|
||||
query={query}
|
||||
matchCount={matchingWorktreeIds?.size ?? boardWorktrees.length}
|
||||
totalCount={boardWorktrees.length}
|
||||
onQueryChange={setQuery}
|
||||
onClearQuery={clearQuery}
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
syncTaskStatusFromWorkspaceBoard={syncTaskStatusFromWorkspaceBoard}
|
||||
onSyncTaskStatusFromWorkspaceBoardChange={setSyncTaskStatusFromWorkspaceBoard}
|
||||
@@ -782,7 +841,9 @@ export default function WorkspaceKanbanDrawer({
|
||||
>
|
||||
<WorkspaceKanbanLaneGrid
|
||||
statuses={workspaceStatuses}
|
||||
worktreesByStatus={worktreesByStatus}
|
||||
laneViews={laneViews}
|
||||
laneFullWorktreeIds={laneFullWorktreeIds}
|
||||
hasQuery={hasQuery}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
columnWidth={columnWidth}
|
||||
@@ -790,13 +851,13 @@ export default function WorkspaceKanbanDrawer({
|
||||
dragOverStatus={dragOverStatus}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
selectedWorktrees={renderedSelectedWorktrees}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onActivate={handleWorktreeActivate}
|
||||
onSelectionGesture={updateSelectionForGesture}
|
||||
onContextMenuSelect={selectForContextMenu}
|
||||
onContextMenuSelect={selectRenderedForContextMenu}
|
||||
onAssignWorkspaceStatus={moveWorktreesToStatus}
|
||||
onCreateWorktree={createWorktreeForStatus}
|
||||
onColumnResizeStart={onColumnResizeStart}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
|
||||
import SidebarFilter from './SidebarFilter'
|
||||
import WorkspaceKanbanDrawerHeader from './WorkspaceKanbanDrawerHeader'
|
||||
import WorkspaceKanbanSearchField from './WorkspaceKanbanSearchField'
|
||||
import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu'
|
||||
|
||||
type InspectableProps = {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
'aria-label'?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const statuses: WorkspaceStatusDefinition[] = [{ id: 'todo', label: 'Todo' }]
|
||||
|
||||
function findElement(
|
||||
function findNode(
|
||||
node: React.ReactNode,
|
||||
predicate: (props: InspectableProps) => boolean
|
||||
predicate: (element: React.ReactElement<InspectableProps>) => boolean
|
||||
): React.ReactElement<InspectableProps> | null {
|
||||
if (!isValidElement<InspectableProps>(node)) {
|
||||
return null
|
||||
}
|
||||
if (predicate(node.props)) {
|
||||
if (predicate(node)) {
|
||||
return node
|
||||
}
|
||||
let match: React.ReactElement<InspectableProps> | null = null
|
||||
@@ -26,14 +31,36 @@ function findElement(
|
||||
if (match) {
|
||||
return
|
||||
}
|
||||
match = findElement(child, predicate)
|
||||
match = findNode(child, predicate)
|
||||
})
|
||||
return match
|
||||
}
|
||||
|
||||
function renderHeader(onClose: () => void): React.ReactElement {
|
||||
function findElement(
|
||||
node: React.ReactNode,
|
||||
predicate: (props: InspectableProps) => boolean
|
||||
): React.ReactElement<InspectableProps> | null {
|
||||
return findNode(node, (element) => predicate(element.props))
|
||||
}
|
||||
|
||||
function findByType(
|
||||
node: React.ReactNode,
|
||||
type: React.ElementType
|
||||
): React.ReactElement<InspectableProps> | null {
|
||||
return findNode(node, (element) => element.type === type)
|
||||
}
|
||||
|
||||
function renderHeader(
|
||||
onClose: () => void,
|
||||
overrides: Partial<Parameters<typeof WorkspaceKanbanDrawerHeader>[0]> = {}
|
||||
): React.ReactElement {
|
||||
return WorkspaceKanbanDrawerHeader({
|
||||
selectedCount: 0,
|
||||
query: '',
|
||||
matchCount: 0,
|
||||
totalCount: 0,
|
||||
onQueryChange: vi.fn(),
|
||||
onClearQuery: vi.fn(),
|
||||
workspaceStatuses: statuses,
|
||||
syncTaskStatusFromWorkspaceBoard: false,
|
||||
onSyncTaskStatusFromWorkspaceBoardChange: vi.fn(),
|
||||
@@ -44,7 +71,8 @@ function renderHeader(onClose: () => void): React.ReactElement {
|
||||
onRemoveStatus: vi.fn(),
|
||||
onAddStatus: vi.fn(),
|
||||
onFilterMenuOpenChange: vi.fn(),
|
||||
onClose
|
||||
onClose,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,4 +90,36 @@ describe('WorkspaceKanbanDrawerHeader', () => {
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the search field as a sibling of the sheet title, not inside it', () => {
|
||||
const header = renderHeader(vi.fn(), { query: 'orca', matchCount: 2, totalCount: 15 })
|
||||
|
||||
const title = findByType(header, SheetTitle)
|
||||
expect(title).not.toBeNull()
|
||||
expect(findByType(title, WorkspaceKanbanSearchField)).toBeNull()
|
||||
|
||||
const field = findByType(header, WorkspaceKanbanSearchField)
|
||||
expect(field?.props).toMatchObject({ query: 'orca', matchCount: 2, totalCount: 15 })
|
||||
})
|
||||
|
||||
it('keeps the filter, settings, and close cluster reachable alongside the field', () => {
|
||||
const header = renderHeader(vi.fn(), { query: 'orca', matchCount: 2, totalCount: 15 })
|
||||
|
||||
expect(findByType(header, SidebarFilter)).not.toBeNull()
|
||||
expect(findByType(header, WorkspaceKanbanSettingsMenu)).not.toBeNull()
|
||||
expect(findElement(header, (props) => props['aria-label'] === 'Close')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the selected-count badge and the field clear of the control cluster', () => {
|
||||
const header = renderHeader(vi.fn(), { selectedCount: 3, query: 'orca' })
|
||||
|
||||
// The title (with its badge) never shrinks the field into the absolute cluster,
|
||||
// which the header reserves space for with pr-32.
|
||||
expect(findByType(header, SheetTitle)?.props.className).toContain('shrink-0')
|
||||
expect(findByType(header, SheetHeader)?.props.className).toContain('pr-32')
|
||||
expect(
|
||||
findElement(header, (props) => Boolean(props.className?.includes('rounded-full')))
|
||||
).not.toBeNull()
|
||||
expect(findByType(header, WorkspaceKanbanSearchField)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,17 @@ import { Button } from '@/components/ui/button'
|
||||
import { SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
|
||||
import SidebarFilter from './SidebarFilter'
|
||||
import WorkspaceKanbanSearchField from './WorkspaceKanbanSearchField'
|
||||
import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type WorkspaceKanbanDrawerHeaderProps = {
|
||||
selectedCount: number
|
||||
query: string
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
onClearQuery: () => void
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
syncTaskStatusFromWorkspaceBoard: boolean
|
||||
onSyncTaskStatusFromWorkspaceBoardChange: (enabled: boolean) => void
|
||||
@@ -24,6 +30,11 @@ type WorkspaceKanbanDrawerHeaderProps = {
|
||||
|
||||
export default function WorkspaceKanbanDrawerHeader({
|
||||
selectedCount,
|
||||
query,
|
||||
matchCount,
|
||||
totalCount,
|
||||
onQueryChange,
|
||||
onClearQuery,
|
||||
workspaceStatuses,
|
||||
syncTaskStatusFromWorkspaceBoard,
|
||||
onSyncTaskStatusFromWorkspaceBoardChange,
|
||||
@@ -39,23 +50,34 @@ export default function WorkspaceKanbanDrawerHeader({
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="border-b border-worktree-sidebar-border px-4 py-3 pr-32">
|
||||
<SheetTitle className="flex items-center gap-2 text-sm">
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawerHeader.c6a77ab0f4',
|
||||
'Workspace board'
|
||||
)}
|
||||
</span>
|
||||
{selectedCount > 1 ? (
|
||||
<span className="rounded-full bg-worktree-sidebar-accent px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{selectedCount}{' '}
|
||||
{/* Why: SheetTitle is the sheet's aria-labelledby target and renders an
|
||||
<h2>, so the field must be its sibling, not a descendant. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SheetTitle className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawerHeader.81870af08f',
|
||||
'selected'
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawerHeader.c6a77ab0f4',
|
||||
'Workspace board'
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</SheetTitle>
|
||||
{selectedCount > 1 ? (
|
||||
<span className="rounded-full bg-worktree-sidebar-accent px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{selectedCount}{' '}
|
||||
{translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawerHeader.81870af08f',
|
||||
'selected'
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</SheetTitle>
|
||||
<WorkspaceKanbanSearchField
|
||||
query={query}
|
||||
matchCount={matchCount}
|
||||
totalCount={totalCount}
|
||||
onQueryChange={onQueryChange}
|
||||
onClear={onClearQuery}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanDrawerHeader.e1a34450fc',
|
||||
|
||||
@@ -5,11 +5,14 @@ import type {
|
||||
WorkspaceStatusDefinition,
|
||||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
import type { WorkspaceKanbanLaneView } from './workspace-kanban-search'
|
||||
import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
|
||||
|
||||
type WorkspaceKanbanLaneGridProps = {
|
||||
statuses: readonly WorkspaceStatusDefinition[]
|
||||
worktreesByStatus: ReadonlyMap<WorkspaceStatus, readonly Worktree[]>
|
||||
laneViews: ReadonlyMap<WorkspaceStatus, WorkspaceKanbanLaneView>
|
||||
laneFullWorktreeIds: ReadonlyMap<WorkspaceStatus, readonly string[]>
|
||||
hasQuery: boolean
|
||||
repoMap: Map<string, Repo>
|
||||
activeWorktreeId: string | null
|
||||
columnWidth: number
|
||||
@@ -35,7 +38,9 @@ type WorkspaceKanbanLaneGridProps = {
|
||||
|
||||
export default function WorkspaceKanbanLaneGrid({
|
||||
statuses,
|
||||
worktreesByStatus,
|
||||
laneViews,
|
||||
laneFullWorktreeIds,
|
||||
hasQuery,
|
||||
repoMap,
|
||||
activeWorktreeId,
|
||||
columnWidth,
|
||||
@@ -67,7 +72,10 @@ export default function WorkspaceKanbanLaneGrid({
|
||||
<WorkspaceKanbanStatusLane
|
||||
key={status.id}
|
||||
status={status}
|
||||
items={worktreesByStatus.get(status.id) ?? []}
|
||||
items={laneViews.get(status.id)?.items ?? []}
|
||||
totalCount={laneViews.get(status.id)?.totalCount ?? 0}
|
||||
hasQuery={hasQuery}
|
||||
fullWorktreeIds={laneFullWorktreeIds.get(status.id) ?? []}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
columnWidth={columnWidth}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WorkspaceKanbanSearchField from './WorkspaceKanbanSearchField'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
const onQueryChange = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
|
||||
function renderField(props: { query: string; matchCount?: number; totalCount?: number }): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceKanbanSearchField
|
||||
query={props.query}
|
||||
matchCount={props.matchCount ?? 0}
|
||||
totalCount={props.totalCount ?? 0}
|
||||
onQueryChange={onQueryChange}
|
||||
onClear={onClear}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
const element = container.querySelector<HTMLInputElement>('input')
|
||||
if (!element) {
|
||||
throw new Error('field not rendered')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function clearButton(): HTMLButtonElement | null {
|
||||
return container.querySelector<HTMLButtonElement>('button[aria-label="Clear search"]')
|
||||
}
|
||||
|
||||
function liveRegion(): HTMLElement {
|
||||
const element = container.querySelector<HTMLElement>('[aria-live="polite"]')
|
||||
if (!element) {
|
||||
throw new Error('live region not rendered')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('WorkspaceKanbanSearchField', () => {
|
||||
it('reports every keystroke without debouncing', () => {
|
||||
renderField({ query: '' })
|
||||
|
||||
act(() => {
|
||||
// Why: React's value tracker shadows the `value` property, so a plain
|
||||
// assignment would look like a no-op and never fire onChange.
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input(), 'or')
|
||||
input().dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onQueryChange).toHaveBeenCalledWith('or')
|
||||
})
|
||||
|
||||
it('only offers the clear affordance for a non-empty query', () => {
|
||||
renderField({ query: '' })
|
||||
expect(clearButton()).toBeNull()
|
||||
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
act(() => {
|
||||
clearButton()?.click()
|
||||
})
|
||||
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the visual match count from assistive tech but keeps the clear button named', () => {
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
|
||||
const count = container.querySelector('span[aria-hidden="true"]')
|
||||
expect(count?.textContent).toBe('3 / 12')
|
||||
expect(clearButton()?.getAttribute('aria-hidden')).toBeNull()
|
||||
expect(clearButton()?.getAttribute('aria-label')).toBe('Clear search')
|
||||
})
|
||||
|
||||
it('announces match counts only after the query settles', () => {
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
expect(liveRegion().textContent).toBe('')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
expect(liveRegion().textContent).toBe('3 of 12 workspaces match')
|
||||
|
||||
renderField({ query: 'zzz', matchCount: 0, totalCount: 12 })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
expect(liveRegion().textContent).toBe('No workspaces match')
|
||||
|
||||
renderField({ query: '' })
|
||||
expect(liveRegion().textContent).toBe('')
|
||||
})
|
||||
|
||||
it('clears a non-empty query on Escape and leaves an empty one to the drawer', () => {
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
// Why: React roots at `container`, so only a listener above it can observe
|
||||
// whether the field let Escape through.
|
||||
const bubbled = vi.fn()
|
||||
document.body.addEventListener('keydown', bubbled)
|
||||
|
||||
act(() => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
)
|
||||
})
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(bubbled).not.toHaveBeenCalled()
|
||||
|
||||
renderField({ query: '' })
|
||||
act(() => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
)
|
||||
})
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(bubbled).toHaveBeenCalledOnce()
|
||||
|
||||
document.body.removeEventListener('keydown', bubbled)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const ANNOUNCE_DEBOUNCE_MS = 400
|
||||
|
||||
type WorkspaceKanbanSearchFieldProps = {
|
||||
query: string
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
function formatAnnouncement(matchCount: number, totalCount: number): string {
|
||||
return matchCount === 0
|
||||
? translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.bdb753c78d',
|
||||
'No workspaces match'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.4d96c209d6',
|
||||
'{{value0}} of {{value1}} workspaces match',
|
||||
{ value0: matchCount, value1: totalCount }
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorkspaceKanbanSearchField({
|
||||
query,
|
||||
matchCount,
|
||||
totalCount,
|
||||
onQueryChange,
|
||||
onClear
|
||||
}: WorkspaceKanbanSearchFieldProps): React.JSX.Element {
|
||||
const hasQuery = query !== ''
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
// Why: the filter itself is undebounced, but a polite live region that changes
|
||||
// on every keystroke produces continuous speech and makes the field unusable.
|
||||
useEffect(() => {
|
||||
if (!hasQuery) {
|
||||
setAnnouncement('')
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
() => setAnnouncement(formatAnnouncement(matchCount, totalCount)),
|
||||
ANNOUNCE_DEBOUNCE_MS
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [hasQuery, matchCount, totalCount])
|
||||
|
||||
return (
|
||||
<div className="relative flex min-w-0 flex-1 items-center max-w-[268px]">
|
||||
<Search className="pointer-events-none absolute left-2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.c0cd6bdf6c',
|
||||
'Search workspaces'
|
||||
)}
|
||||
placeholder={translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.49c266baaa',
|
||||
'Search'
|
||||
)}
|
||||
className={cn(
|
||||
'h-7 border-worktree-sidebar-border bg-background pl-7 text-xs',
|
||||
hasQuery && 'pr-16'
|
||||
)}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
// Why: the board swallows Radix close requests, so an empty-field
|
||||
// Escape has nothing local to do and is left to bubble.
|
||||
if (event.key !== 'Escape' || !hasQuery) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClear()
|
||||
}}
|
||||
/>
|
||||
{hasQuery ? (
|
||||
<div className="absolute right-1 flex items-center gap-0.5">
|
||||
<span aria-hidden="true" className="text-[10px] text-muted-foreground">
|
||||
{matchCount} / {totalCount}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.3b7ea51793',
|
||||
'Clear search'
|
||||
)}
|
||||
onClick={onClear}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
vi.mock('./WorkspaceKanbanCard', () => ({
|
||||
default: ({ worktree }: { worktree: Worktree }) => (
|
||||
<div data-workspace-board-card-id={worktree.id} />
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null
|
||||
}))
|
||||
|
||||
const status = { id: 'todo', label: 'Todo' }
|
||||
const repoMap = new Map<string, Repo>()
|
||||
|
||||
function worktree(id: string): Worktree {
|
||||
return { id, repoId: 'repo-a', displayName: id } as Worktree
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function renderLane(props: {
|
||||
items: Worktree[]
|
||||
totalCount: number
|
||||
hasQuery: boolean
|
||||
fullWorktreeIds?: string[]
|
||||
}): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceKanbanStatusLane
|
||||
status={status}
|
||||
items={props.items}
|
||||
totalCount={props.totalCount}
|
||||
hasQuery={props.hasQuery}
|
||||
fullWorktreeIds={props.fullWorktreeIds}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={null}
|
||||
columnWidth={308}
|
||||
isResizingColumn={false}
|
||||
isDragTarget={false}
|
||||
canCreateWorktree={true}
|
||||
selectedWorktreeIds={new Set()}
|
||||
selectedWorktrees={[]}
|
||||
onDragOver={vi.fn()}
|
||||
onDragLeave={vi.fn()}
|
||||
onDrop={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
onSelectionGesture={vi.fn(() => false)}
|
||||
onContextMenuSelect={vi.fn(() => [])}
|
||||
onCreateWorktree={vi.fn()}
|
||||
onColumnResizeStart={vi.fn()}
|
||||
onColumnResizeKeyDown={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function lane(): HTMLElement {
|
||||
const element = container.querySelector<HTMLElement>('[data-workspace-status-drop-target]')
|
||||
if (!element) {
|
||||
throw new Error('lane not rendered')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('WorkspaceKanbanStatusLane', () => {
|
||||
it('shows a plain count without a query and a matches/total count with one', () => {
|
||||
renderLane({ items: [worktree('a'), worktree('b')], totalCount: 2, hasQuery: false })
|
||||
expect(container.textContent).toContain('2')
|
||||
expect(container.textContent).not.toContain('2 / 2')
|
||||
|
||||
renderLane({ items: [worktree('a')], totalCount: 5, hasQuery: true })
|
||||
expect(container.textContent).toContain('1 / 5')
|
||||
})
|
||||
|
||||
it('keeps a fully filtered lane as a labeled drop target', () => {
|
||||
renderLane({ items: [], totalCount: 5, hasQuery: true })
|
||||
|
||||
expect(container.textContent).toContain('No matches')
|
||||
expect(lane().hasAttribute('data-workspace-status-drop-target')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the empty placeholder when there is no query', () => {
|
||||
renderLane({ items: [], totalCount: 0, hasQuery: false })
|
||||
|
||||
expect(container.textContent).toContain('Empty')
|
||||
expect(container.textContent).not.toContain('No matches')
|
||||
})
|
||||
|
||||
it('publishes the full lane membership even when the rendered set is a subset', () => {
|
||||
renderLane({
|
||||
items: [worktree('b')],
|
||||
totalCount: 3,
|
||||
hasQuery: true,
|
||||
fullWorktreeIds: ['a', 'b', 'c']
|
||||
})
|
||||
|
||||
expect(lane().dataset.workspaceLaneFullIds).toBe('a\nb\nc')
|
||||
expect(container.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -14,12 +14,16 @@ import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import WorkspaceKanbanCard from './WorkspaceKanbanCard'
|
||||
import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index'
|
||||
import { getWorkspaceStatusVisualMeta } from './workspace-status'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type WorkspaceKanbanStatusLaneProps = {
|
||||
status: WorkspaceStatusDefinition
|
||||
items: readonly Worktree[]
|
||||
totalCount?: number
|
||||
hasQuery?: boolean
|
||||
fullWorktreeIds?: readonly string[]
|
||||
repoMap: Map<string, Repo>
|
||||
activeWorktreeId: string | null
|
||||
columnWidth: number
|
||||
@@ -47,6 +51,9 @@ type WorkspaceKanbanStatusLaneProps = {
|
||||
export default function WorkspaceKanbanStatusLane({
|
||||
status,
|
||||
items,
|
||||
totalCount,
|
||||
hasQuery = false,
|
||||
fullWorktreeIds,
|
||||
repoMap,
|
||||
activeWorktreeId,
|
||||
columnWidth,
|
||||
@@ -89,6 +96,11 @@ export default function WorkspaceKanbanStatusLane({
|
||||
<section
|
||||
data-workspace-status-drop-target=""
|
||||
data-workspace-status={status.id}
|
||||
// Why: sidebar→board drops read lane membership straight out of the DOM,
|
||||
// where a search query would otherwise leave them only the rendered cards.
|
||||
data-workspace-lane-full-ids={serializeWorkspaceLaneFullIds(
|
||||
fullWorktreeIds ?? items.map((worktree) => worktree.id)
|
||||
)}
|
||||
data-contextual-tour-target={
|
||||
status.id === 'completed' ? 'workspace-board-done-lane' : undefined
|
||||
}
|
||||
@@ -140,7 +152,7 @@ export default function WorkspaceKanbanStatusLane({
|
||||
{status.label}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
|
||||
{items.length}
|
||||
{hasQuery ? `${items.length} / ${totalCount ?? items.length}` : items.length}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
@@ -180,7 +192,12 @@ export default function WorkspaceKanbanStatusLane({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-20 items-center justify-center rounded-md border border-dashed border-border/70 text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')}
|
||||
{hasQuery
|
||||
? translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanStatusLane.2df01a03ff',
|
||||
'No matches'
|
||||
)
|
||||
: translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')}
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { matchWorkspaceBoardWorktrees } from './workspace-kanban-search'
|
||||
|
||||
export function useWorkspaceKanbanSearch(args: {
|
||||
open: boolean
|
||||
worktrees: Worktree[]
|
||||
repoMap: Map<string, Repo>
|
||||
}): {
|
||||
query: string
|
||||
setQuery: (query: string) => void
|
||||
clearQuery: () => void
|
||||
matchingWorktreeIds: ReadonlySet<string> | null
|
||||
hasQuery: boolean
|
||||
} {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// Why: a stale query silently hiding cards on reopen is a trap. Reset during
|
||||
// render like useWorkspaceKanbanSelection, so no frame paints the old filter.
|
||||
if (!args.open && query !== '') {
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const matchingWorktreeIds = useMemo(
|
||||
() =>
|
||||
matchWorkspaceBoardWorktrees({
|
||||
worktrees: args.worktrees,
|
||||
query,
|
||||
repoMap: args.repoMap
|
||||
}),
|
||||
[args.repoMap, args.worktrees, query]
|
||||
)
|
||||
|
||||
const clearQuery = useCallback(() => setQuery(''), [])
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
clearQuery,
|
||||
matchingWorktreeIds,
|
||||
// Why: an over-bound query is non-empty but non-filtering, and the lane
|
||||
// counts must not switch to "n / m" for it.
|
||||
hasQuery: matchingWorktreeIds !== null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveFullLaneDropIndex } from './workspace-kanban-filtered-drop-index'
|
||||
|
||||
const FULL = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
|
||||
|
||||
describe('resolveFullLaneDropIndex', () => {
|
||||
it('is the identity when nothing is filtered', () => {
|
||||
for (let index = 0; index <= FULL.length; index++) {
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: FULL,
|
||||
filteredDropIndex: index
|
||||
})
|
||||
).toBe(index)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps the first filtered slot onto the first match position', () => {
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['h'],
|
||||
filteredDropIndex: 0
|
||||
})
|
||||
).toBe(7)
|
||||
})
|
||||
|
||||
it('maps the end of a filtered lane one past the last match', () => {
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['b', 'e'],
|
||||
filteredDropIndex: 2
|
||||
})
|
||||
).toBe(5)
|
||||
})
|
||||
|
||||
it('maps a slot between two matches onto the following match', () => {
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['b', 'e', 'g'],
|
||||
filteredDropIndex: 1
|
||||
})
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
it('handles a lane filtered to zero cards', () => {
|
||||
expect(
|
||||
resolveFullLaneDropIndex({ fullLaneIds: FULL, renderedIds: [], filteredDropIndex: 0 })
|
||||
).toBe(0)
|
||||
expect(
|
||||
resolveFullLaneDropIndex({ fullLaneIds: FULL, renderedIds: [], filteredDropIndex: 3 })
|
||||
).toBe(FULL.length)
|
||||
})
|
||||
|
||||
it('falls back to the lane end when a rendered id is missing from the full lane', () => {
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['stale', 'e'],
|
||||
filteredDropIndex: 0
|
||||
})
|
||||
).toBe(FULL.length)
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['b', 'stale'],
|
||||
filteredDropIndex: 2
|
||||
})
|
||||
).toBe(FULL.length)
|
||||
})
|
||||
|
||||
it('clamps out-of-range filtered indices to the first and last branches', () => {
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['b', 'e'],
|
||||
filteredDropIndex: -3
|
||||
})
|
||||
).toBe(1)
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['b', 'e'],
|
||||
filteredDropIndex: 99
|
||||
})
|
||||
).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
// Why: worktree ids embed repo paths, so commas and colons are unusable as a
|
||||
// separator in the `data-workspace-lane-full-ids` channel; newlines are not.
|
||||
export const WORKSPACE_LANE_FULL_IDS_DELIMITER = '\n'
|
||||
|
||||
export function serializeWorkspaceLaneFullIds(worktreeIds: readonly string[]): string {
|
||||
return worktreeIds.join(WORKSPACE_LANE_FULL_IDS_DELIMITER)
|
||||
}
|
||||
|
||||
/** Returns `null` when the lane never published the attribute. */
|
||||
export function parseWorkspaceLaneFullIds(value: string | undefined): string[] | null {
|
||||
if (value === undefined) {
|
||||
return null
|
||||
}
|
||||
return value === '' ? [] : value.split(WORKSPACE_LANE_FULL_IDS_DELIMITER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a drop index derived from the *rendered* cards of a lane onto the
|
||||
* lane's full membership. Board search hides non-matching cards, but manual-order
|
||||
* math runs against the full lane, so the two sides must be reconciled.
|
||||
*
|
||||
* Both id lists still contain the dragged ids — `getCardDropTarget` counts the
|
||||
* dragged card and `buildManualOrderUpdatesForGroupDrop` computes
|
||||
* `removedBeforeDrop` against the pre-removal group. Keep it that way.
|
||||
*/
|
||||
export function resolveFullLaneDropIndex(args: {
|
||||
fullLaneIds: readonly string[]
|
||||
renderedIds: readonly string[]
|
||||
filteredDropIndex: number
|
||||
}): number {
|
||||
const { fullLaneIds, renderedIds, filteredDropIndex } = args
|
||||
if (renderedIds.length === fullLaneIds.length) {
|
||||
return filteredDropIndex
|
||||
}
|
||||
if (renderedIds.length === 0) {
|
||||
return filteredDropIndex <= 0 ? 0 : fullLaneIds.length
|
||||
}
|
||||
|
||||
if (filteredDropIndex <= 0) {
|
||||
return indexInFullLane(fullLaneIds, renderedIds[0]!)
|
||||
}
|
||||
if (filteredDropIndex >= renderedIds.length) {
|
||||
const lastIndex = indexInFullLane(fullLaneIds, renderedIds.at(-1)!)
|
||||
return Math.min(fullLaneIds.length, lastIndex + 1)
|
||||
}
|
||||
return indexInFullLane(fullLaneIds, renderedIds[filteredDropIndex]!)
|
||||
}
|
||||
|
||||
// Why: a rendered id missing from the full lane is a stale-DOM race. Returning
|
||||
// -1 would clamp to 0 downstream and teleport the card to the top of the lane.
|
||||
function indexInFullLane(fullLaneIds: readonly string[], worktreeId: string): number {
|
||||
const index = fullLaneIds.indexOf(worktreeId)
|
||||
return index === -1 ? fullLaneIds.length : index
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
buildWorkspaceKanbanLaneViews,
|
||||
matchWorkspaceBoardWorktrees
|
||||
} from './workspace-kanban-search'
|
||||
|
||||
function worktree(overrides: Partial<Worktree> & { id: string }): Worktree {
|
||||
return {
|
||||
repoId: 'repo-a',
|
||||
displayName: 'Workspace',
|
||||
path: `/${overrides.id}`,
|
||||
branch: 'main',
|
||||
baseBranch: 'main',
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
...overrides
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
const repoMap = new Map<string, Repo>([
|
||||
['repo-a', { id: 'repo-a', displayName: 'orca' } as Repo],
|
||||
['repo-b', { id: 'repo-b', displayName: 'atlas' } as Repo]
|
||||
])
|
||||
|
||||
function match(worktrees: Worktree[], query: string): ReadonlySet<string> | null {
|
||||
return matchWorkspaceBoardWorktrees({ worktrees, query, repoMap })
|
||||
}
|
||||
|
||||
describe('matchWorkspaceBoardWorktrees', () => {
|
||||
it('treats blank and whitespace-only queries as no filtering', () => {
|
||||
const worktrees = [worktree({ id: 'a' })]
|
||||
expect(match(worktrees, '')).toBeNull()
|
||||
expect(match(worktrees, ' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches display name, branch, and repo display name', () => {
|
||||
const worktrees = [
|
||||
worktree({ id: 'name', displayName: 'Search field' }),
|
||||
worktree({ id: 'branch', displayName: 'Other', branch: 'refs/heads/feat/search-lane' }),
|
||||
worktree({ id: 'repo', displayName: 'Other', repoId: 'repo-b' }),
|
||||
worktree({ id: 'miss', displayName: 'Other' })
|
||||
]
|
||||
|
||||
expect(match(worktrees, 'search')).toEqual(new Set(['name', 'branch']))
|
||||
expect(match(worktrees, 'atlas')).toEqual(new Set(['repo']))
|
||||
})
|
||||
|
||||
it('matches the workspace comment', () => {
|
||||
const worktrees = [
|
||||
worktree({ id: 'commented', displayName: 'Other', comment: 'blocked on review' }),
|
||||
worktree({ id: 'miss', displayName: 'Other' })
|
||||
]
|
||||
|
||||
expect(match(worktrees, 'blocked')).toEqual(new Set(['commented']))
|
||||
})
|
||||
|
||||
it('excludes worktrees that only match on PR, issue, or port', () => {
|
||||
const worktrees = [
|
||||
worktree({ id: 'pr', displayName: 'Other', linkedPR: 4242 }),
|
||||
worktree({ id: 'issue', displayName: 'Other', linkedIssue: 4242 })
|
||||
]
|
||||
|
||||
expect(match(worktrees, '4242')).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('matches composite repo/branch queries', () => {
|
||||
const worktrees = [
|
||||
worktree({ id: 'hit', displayName: 'Other', branch: 'main' }),
|
||||
worktree({ id: 'wrong-repo', displayName: 'Other', repoId: 'repo-b', branch: 'main' })
|
||||
]
|
||||
|
||||
expect(match(worktrees, 'orca/main')).toEqual(new Set(['hit']))
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const worktrees = [worktree({ id: 'a', displayName: 'Search Field' })]
|
||||
|
||||
expect(match(worktrees, 'SEARCH')).toEqual(new Set(['a']))
|
||||
})
|
||||
|
||||
it('treats an over-bound query as no filtering rather than zero matches', () => {
|
||||
const worktrees = [worktree({ id: 'a', displayName: 'Search field' })]
|
||||
|
||||
expect(match(worktrees, 'x'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES + 1))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorkspaceKanbanLaneViews', () => {
|
||||
const todo = [worktree({ id: 'todo-a', displayName: 'Alpha' }), worktree({ id: 'todo-b' })]
|
||||
const doing = [worktree({ id: 'doing-a', displayName: 'Alpha' })]
|
||||
const worktreesByStatus = new Map([
|
||||
['todo', todo],
|
||||
['doing', doing]
|
||||
])
|
||||
|
||||
it('reuses the input arrays when no query is active', () => {
|
||||
const views = buildWorkspaceKanbanLaneViews({ worktreesByStatus, matchingWorktreeIds: null })
|
||||
|
||||
expect(views.get('todo')?.items).toBe(todo)
|
||||
expect(views.get('doing')?.items).toBe(doing)
|
||||
expect(views.get('todo')?.totalCount).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves lane order and per-lane sort order', () => {
|
||||
const views = buildWorkspaceKanbanLaneViews({
|
||||
worktreesByStatus,
|
||||
matchingWorktreeIds: new Set(['todo-b', 'todo-a', 'doing-a'])
|
||||
})
|
||||
|
||||
expect(Array.from(views.keys())).toEqual(['todo', 'doing'])
|
||||
expect(views.get('todo')?.items.map((item) => item.id)).toEqual(['todo-a', 'todo-b'])
|
||||
})
|
||||
|
||||
it('keeps a fully filtered lane with an empty item list and its real total', () => {
|
||||
const views = buildWorkspaceKanbanLaneViews({
|
||||
worktreesByStatus,
|
||||
matchingWorktreeIds: new Set(['doing-a'])
|
||||
})
|
||||
|
||||
expect(views.get('todo')).toEqual({ items: [], totalCount: 2 })
|
||||
expect(views.get('doing')?.items.map((item) => item.id)).toEqual(['doing-a'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isWorktreePaletteQueryTooLarge } from '@/lib/worktree-palette-query-bounds'
|
||||
import { searchWorktrees, type PaletteMatchedField } from '@/lib/worktree-palette-search'
|
||||
import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types'
|
||||
|
||||
export type WorkspaceKanbanLaneView = {
|
||||
items: readonly Worktree[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
// Why: the board is a drag surface for named workspaces, so a card may only be
|
||||
// hidden by fields the user can read on it. PR/issue/port matches are palette-only.
|
||||
const BOARD_MATCHED_FIELDS: ReadonlySet<PaletteMatchedField> = new Set<PaletteMatchedField>([
|
||||
'displayName',
|
||||
'branch',
|
||||
'repo',
|
||||
'comment'
|
||||
])
|
||||
|
||||
/**
|
||||
* Returns `null` when no filtering is active — distinct from an empty set, which
|
||||
* means a real query matched nothing.
|
||||
*/
|
||||
export function matchWorkspaceBoardWorktrees(args: {
|
||||
worktrees: Worktree[]
|
||||
query: string
|
||||
repoMap: Map<string, Repo>
|
||||
}): ReadonlySet<string> | null {
|
||||
if (!args.query.trim()) {
|
||||
return null
|
||||
}
|
||||
// Why: searchWorktrees returns [] for an over-bound query, which downstream
|
||||
// reads as "matched nothing" and blanks the whole board on a paste accident.
|
||||
if (isWorktreePaletteQueryTooLarge(args.query)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matched = new Set<string>()
|
||||
for (const result of searchWorktrees(args.worktrees, args.query, args.repoMap, null, null)) {
|
||||
if (result.matchedField && BOARD_MATCHED_FIELDS.has(result.matchedField)) {
|
||||
matched.add(result.worktreeId)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
export function buildWorkspaceKanbanLaneViews(args: {
|
||||
worktreesByStatus: ReadonlyMap<WorkspaceStatus, readonly Worktree[]>
|
||||
matchingWorktreeIds: ReadonlySet<string> | null
|
||||
}): Map<WorkspaceStatus, WorkspaceKanbanLaneView> {
|
||||
const matchingWorktreeIds = args.matchingWorktreeIds
|
||||
const views = new Map<WorkspaceStatus, WorkspaceKanbanLaneView>()
|
||||
for (const [status, items] of args.worktreesByStatus) {
|
||||
views.set(status, {
|
||||
// Why: the no-query path must not reallocate a lane array per keystroke.
|
||||
items: matchingWorktreeIds
|
||||
? items.filter((worktree) => matchingWorktreeIds.has(worktree.id))
|
||||
: items,
|
||||
totalCount: items.length
|
||||
})
|
||||
}
|
||||
return views
|
||||
}
|
||||
@@ -247,6 +247,28 @@ describe('workspace kanban sidebar drop DOM bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers the published full lane membership over the rendered card scan', () => {
|
||||
const { lane } = appendBoard()
|
||||
lane.dataset.workspaceLaneFullIds = ['doing-x', 'doing-a', 'doing-y', 'doing-b'].join('\n')
|
||||
setElementFromPoint(lane)
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
|
||||
{ key: 'doing', worktreeIds: ['doing-x', 'doing-a', 'doing-y', 'doing-b'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('translates the rendered drop index onto the full lane', () => {
|
||||
const { lane } = appendBoard()
|
||||
lane.dataset.workspaceLaneFullIds = ['doing-x', 'doing-a', 'doing-y', 'doing-b'].join('\n')
|
||||
setElementFromPoint(lane)
|
||||
|
||||
// Rendered index 1 means "before doing-b", which is index 3 in the full lane.
|
||||
expect(getWorkspaceKanbanSidebarDropTarget(24, 60)).toMatchObject({
|
||||
status: 'doing',
|
||||
dropIndex: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('marks and clears the external board hover target', () => {
|
||||
const { lane } = appendBoard()
|
||||
setElementFromPoint(lane)
|
||||
|
||||
@@ -4,6 +4,10 @@ import type {
|
||||
Worktree,
|
||||
WorktreeMeta
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
parseWorkspaceLaneFullIds,
|
||||
resolveFullLaneDropIndex
|
||||
} from './workspace-kanban-filtered-drop-index'
|
||||
import { getWorkspaceStatus } from './workspace-status'
|
||||
import {
|
||||
buildManualOrderUpdatesForGroupDrop,
|
||||
@@ -44,6 +48,21 @@ export function isWorkspaceKanbanSidebarDropPointInBoard(x: number, y: number):
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
||||
}
|
||||
|
||||
// Mirrors getCardDropTarget's card scan so both sides share one index space.
|
||||
function getRenderedLaneCardIds(lane: HTMLElement): string[] {
|
||||
return Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
|
||||
.filter((card) => card.offsetParent !== null)
|
||||
.flatMap((card) => card.dataset.workspaceBoardCardId ?? [])
|
||||
}
|
||||
|
||||
// Why: board search hides non-matching cards, so the rendered card scan is a
|
||||
// filtered lane. Lanes publish their full membership for exactly this reader.
|
||||
function getLaneFullWorktreeIds(lane: HTMLElement): string[] {
|
||||
return (
|
||||
parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ?? getRenderedLaneCardIds(lane)
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusDropTargetElement(
|
||||
board: HTMLElement,
|
||||
status: WorkspaceStatus
|
||||
@@ -80,14 +99,7 @@ export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] {
|
||||
if (!status) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: status,
|
||||
worktreeIds: Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR)).flatMap(
|
||||
(card) => card.dataset.workspaceBoardCardId ?? []
|
||||
)
|
||||
}
|
||||
]
|
||||
return [{ key: status, worktreeIds: getLaneFullWorktreeIds(lane) }]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,7 +111,24 @@ export function getWorkspaceKanbanSidebarDropTarget(
|
||||
if (!board) {
|
||||
return { status: null, isPinDrop: false, dropIndex: 0 }
|
||||
}
|
||||
return getCardDropTarget(board, x, y)
|
||||
const target = getCardDropTarget(board, x, y)
|
||||
if (!target.status) {
|
||||
return target
|
||||
}
|
||||
const lane = getStatusDropTargetElement(board, target.status)
|
||||
if (!lane) {
|
||||
return target
|
||||
}
|
||||
// Why: getCardDropTarget counts rendered cards, but the groups above are the
|
||||
// full lane. Committing the two together would interpolate the wrong rank.
|
||||
return {
|
||||
...target,
|
||||
dropIndex: resolveFullLaneDropIndex({
|
||||
fullLaneIds: getLaneFullWorktreeIds(lane),
|
||||
renderedIds: getRenderedLaneCardIds(lane),
|
||||
filteredDropIndex: target.dropIndex
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function updateWorkspaceKanbanSidebarDropTargetVisual(args: {
|
||||
|
||||
@@ -4411,7 +4411,8 @@
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "Empty",
|
||||
"3611d1ae7f": "Resize workspace board columns"
|
||||
"3611d1ae7f": "Resize workspace board columns",
|
||||
"2df01a03ff": "No matches"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "Set {{value0}} color to {{value1}}",
|
||||
@@ -5024,6 +5025,13 @@
|
||||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4365,7 +4365,8 @@
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "Vacío",
|
||||
"3611d1ae7f": "Cambiar tamaño de columnas del tablero de espacios de trabajo"
|
||||
"3611d1ae7f": "Cambiar tamaño de columnas del tablero de espacios de trabajo",
|
||||
"2df01a03ff": "No matches"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "Establecer color de {{value0}} en {{value1}}",
|
||||
@@ -5001,6 +5002,13 @@
|
||||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4346,7 +4346,8 @@
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空の",
|
||||
"3611d1ae7f": "ワークスペースボードの列のサイズを変更する"
|
||||
"3611d1ae7f": "ワークスペースボードの列のサイズを変更する",
|
||||
"2df01a03ff": "No matches"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "{{value0}} の色を {{value1}} に設定します",
|
||||
@@ -5001,6 +5002,13 @@
|
||||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4346,7 +4346,8 @@
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "비어 있음",
|
||||
"3611d1ae7f": "워크스페이스 보드 열 크기 조정"
|
||||
"3611d1ae7f": "워크스페이스 보드 열 크기 조정",
|
||||
"2df01a03ff": "No matches"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "{{value0}} 색상을 {{value1}}로 설정",
|
||||
@@ -5001,6 +5002,13 @@
|
||||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4346,7 +4346,8 @@
|
||||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空的",
|
||||
"3611d1ae7f": "调整工作区板列的大小"
|
||||
"3611d1ae7f": "调整工作区板列的大小",
|
||||
"2df01a03ff": "No matches"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "将 {{value0}} 颜色设置为 {{value1}}",
|
||||
@@ -5001,6 +5002,13 @@
|
||||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
Reference in New Issue
Block a user