Add search to kanban view (#11244)
* 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. * feat: add search to workspace kanban board Adds a search field to filter the kanban board by workspace name. Range selections now index rendered cards only, preventing silent selection of hidden items when filtering. Selection badges count only the visible cards that drag/context-menu actions will move. Lane totals distinguish between empty-by-definition and filtered-away cards. Drop operations commit against the full lane while displaying filtered indices. Whitespace-only queries don't show match counts, since they don't narrow the board. * fix(kanban-search): let the board search field own Escape The board's Escape handler is a capture-phase listener on document, so it runs before React's handlers and the search field's stopPropagation could never reach it — pressing Escape to clear a query dismissed the whole board instead, and the reopen reset then discarded the query too. useWorkspaceBoardPanel now defers Escape to editable targets inside the board sheet, and the field handles both outcomes itself: clear when it has text, close the board when it does not. Also: keep focus in the field when the clear button unmounts itself, reserve counter width from the rendered text so three-digit counts cannot overlap typed text, and align the icon centering, X size, and placeholder with the sibling search fields. Co-authored-by: Orca <help@stably.ai> * perf(kanban-search): defer the filter and stabilize its derived identities Clearing a query re-mounts every hidden card, so it costs roughly what opening the board costs. The input stays controlled and undebounced, but the filter now reads a deferred query so React can interrupt that work and the caret stays responsive. The match set also keeps its identity when the matched ids are unchanged. Board worktree identities churn on agent-status ticks, and a fresh Set on every tick cascaded new identities through the lane views, the rendered selection, and every memoized card. Also harden the lane full-id channel: the identity guard in resolveFullLaneDropIndex compares membership rather than length, so a stale lane of equal size no longer skips translation; serialization declines ids containing the newline delimiter instead of inventing phantom lane members; the sidebar drop path scans lane cards once instead of twice; and the unfiltered full-id fallback is no longer offsetParent-filtered, restoring the pre-branch notion of lane membership. Adds coverage for the stale-equal-length lane, the full-id round trip, regex metacharacters and non-ASCII queries, and the over-bound query at the drawer level. Co-authored-by: Orca <help@stably.ai> * fix(kanban-search): leave mid-composition Escape to the IME Escape during an IME composition cancels the in-progress reading. The search field was clearing the query behind it instead, matching the isComposing guard other keyboard handlers in the app already use. Co-authored-by: Orca <help@stably.ai> * fix(kanban-search): stop a hidden anchor from collapsing a shift-click A query can hide the selection anchor while leaving the rest of the selection on screen. updateWorktreeSelection reads an anchor missing from visibleIds as "no anchor" and replaces the selection with the clicked card, so shift-clicking dropped the still-visible cards too. Re-anchor onto the first still-rendered selected card, and carry hidden selections through a range so the query cannot silently discard them. A plain click still clears everything. Also, in the drop-index translation: - a lane filtered down to nothing now appends rather than always prepending (an empty rendered lane reports index 0 for every pointer position, so the old branch could only prepend, disagreeing with the document-drop path) - an unresolvable rendered id falls back toward the end of the lane its branch was aiming at, instead of sending every head drop to the bottom - the full-id channel uses NUL, the one character no path can contain, so serialization can no longer be defeated by a newline in a repo path. Dropping the channel was the wrong fallback: under a query the reader would scan the DOM and see only the matched cards. Tests now build the channel through its own serializer rather than hardcoding the delimiter. Co-authored-by: Orca <help@stably.ai> * fix(kanban-search): explain a query discarded for length Past the palette byte bound the query is dropped and the board stays unfiltered, which looks identical to a query that matched everything — full field, untouched board, no counter. The field now marks itself invalid, shows a "Too long" badge carrying the full reason, and announces it. Whitespace-only text stays silent: it is also non-filtering, but self- evidently so. Co-authored-by: Orca <help@stably.ai> * fix(kanban-search): derive the too-long badge from the deferred query The badge describes the board, so reading the live query made it flip a frame before the filter it is describing. Co-authored-by: Orca <help@stably.ai> * fix(kanban-search): let a range replace a hidden selection like every other gesture Carrying hidden cards through a shift-click made it the only replace-shaped gesture that did so — a plain click and a non-additive marquee both drop them. It also left the user unable to narrow a selection: shift-clicking the two visible matches silently re-added the six hidden ones, and the badge counts only rendered cards, so nothing disclosed it. Re-anchoring onto the first still-rendered selected card, which is what actually fixed the collapse, is kept. Also state the Escape contract where a reader will look: SheetContent now declines Radix's dismiss explicitly instead of depending on handleSheetOpenChange quietly dropping the request, and the overlay reserve is capped so a wide counter in a narrow drawer cannot squeeze the typed text to nothing. The reserve is exported and tested directly — happy-dom cannot parse min(), so it could not be read back off a style. Co-authored-by: Orca <help@stably.ai> * fix(kanban-search): stop mutating match-set ref during render React Doctor blocks ref writes during render; keep match-set identity stable with setState-during-render so discarded renders cannot leak it. --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
48184b9e21
commit
4e99602ac8
|
|
@ -0,0 +1,416 @@
|
|||
// @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 { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds'
|
||||
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 = {
|
||||
selectedCount: number
|
||||
query: string
|
||||
isFiltering: boolean
|
||||
isTooLarge: boolean
|
||||
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,
|
||||
selectionScopeState
|
||||
} = 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[] },
|
||||
selectionScopeState: { current: [] as readonly 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: (
|
||||
_open: boolean,
|
||||
boardWorktrees: readonly Worktree[],
|
||||
renderedWorktrees?: readonly Worktree[]
|
||||
) => {
|
||||
selectionScopeState.current = renderedWorktrees ?? boardWorktrees
|
||||
return {
|
||||
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 = []
|
||||
selectionScopeState.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('counts only the rendered cards in the header selection badge', () => {
|
||||
selectionState.current = [alpha, gamma]
|
||||
renderDrawer()
|
||||
expect(headerState.current?.selectedCount).toBe(2)
|
||||
|
||||
typeQuery('gamma')
|
||||
|
||||
expect(headerState.current?.selectedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('scopes selection gestures to the rendered cards', () => {
|
||||
renderDrawer()
|
||||
expect(selectionScopeState.current).toHaveLength(5)
|
||||
|
||||
typeQuery('gamma')
|
||||
|
||||
expect(selectionScopeState.current).toEqual([gamma])
|
||||
})
|
||||
|
||||
it('reports a non-filtering query so the header withholds match counts', () => {
|
||||
renderDrawer()
|
||||
|
||||
typeQuery(' ')
|
||||
|
||||
expect(headerState.current).toMatchObject({ isFiltering: false, matchCount: 5, totalCount: 5 })
|
||||
expect(laneIds('todo')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('leaves the whole board unfiltered for an over-bound query', () => {
|
||||
// Why: searchWorktrees returns [] past the byte bound, which would read as
|
||||
// "matched nothing" and blank every lane on a paste accident.
|
||||
renderDrawer()
|
||||
|
||||
typeQuery('x'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES + 1))
|
||||
|
||||
expect(laneIds('todo')).toHaveLength(4)
|
||||
expect(laneIds('in-review')).toEqual([omega.id])
|
||||
expect(gridState.current?.hasQuery).toBe(false)
|
||||
expect(headerState.current).toMatchObject({
|
||||
isFiltering: false,
|
||||
isTooLarge: true,
|
||||
matchCount: 5,
|
||||
totalCount: 5
|
||||
})
|
||||
})
|
||||
|
||||
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,29 @@ 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, isQueryTooLarge } =
|
||||
useWorkspaceKanbanSearch({
|
||||
open,
|
||||
worktrees: boardWorktrees,
|
||||
repoMap
|
||||
})
|
||||
const laneViews = useMemo(
|
||||
() => buildWorkspaceKanbanLaneViews({ worktreesByStatus, matchingWorktreeIds }),
|
||||
[matchingWorktreeIds, worktreesByStatus]
|
||||
)
|
||||
// Why: range and area gestures must index the cards the user can actually see,
|
||||
// or a shift-click across a filtered gap silently selects hidden workspaces.
|
||||
const renderedBoardWorktrees = useMemo(
|
||||
() =>
|
||||
matchingWorktreeIds
|
||||
? boardWorktrees.filter((worktree) => matchingWorktreeIds.has(worktree.id))
|
||||
: boardWorktrees,
|
||||
[boardWorktrees, matchingWorktreeIds]
|
||||
)
|
||||
const {
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
|
|
@ -196,7 +222,7 @@ export default function WorkspaceKanbanDrawer({
|
|||
updateSelectionForArea,
|
||||
clearSelection,
|
||||
selectForContextMenu
|
||||
} = useWorkspaceKanbanSelection(open, boardWorktrees)
|
||||
} = useWorkspaceKanbanSelection(open, boardWorktrees, renderedBoardWorktrees)
|
||||
const { handleAreaSelectionPointerDown } = useWorkspaceKanbanAreaSelection({
|
||||
open,
|
||||
boardRef,
|
||||
|
|
@ -439,12 +465,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,
|
||||
|
|
@ -695,6 +759,13 @@ export default function WorkspaceKanbanDrawer({
|
|||
// its tooltip without hover and makes the drawer feel noisy.
|
||||
event.preventDefault()
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
// Why: the board owns Escape — useWorkspaceBoardPanel closes it, and
|
||||
// defers to board text fields so the search field can clear itself.
|
||||
// Radix's own dismiss would bypass both, so keep it out of the path
|
||||
// rather than relying on handleSheetOpenChange dropping the request.
|
||||
event.preventDefault()
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
const originalEvent = event.detail.originalEvent
|
||||
const target = originalEvent.target
|
||||
|
|
@ -746,7 +817,16 @@ export default function WorkspaceKanbanDrawer({
|
|||
}}
|
||||
>
|
||||
<WorkspaceKanbanDrawerHeader
|
||||
selectedCount={selectedWorktrees.length}
|
||||
// Why: the badge has to count what a drag or context-menu action will
|
||||
// actually move, which under a query is the rendered subset.
|
||||
selectedCount={renderedSelectedWorktrees.length}
|
||||
query={query}
|
||||
isFiltering={hasQuery}
|
||||
isTooLarge={isQueryTooLarge}
|
||||
matchCount={matchingWorktreeIds?.size ?? boardWorktrees.length}
|
||||
totalCount={boardWorktrees.length}
|
||||
onQueryChange={setQuery}
|
||||
onClearQuery={clearQuery}
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
syncTaskStatusFromWorkspaceBoard={syncTaskStatusFromWorkspaceBoard}
|
||||
onSyncTaskStatusFromWorkspaceBoardChange={setSyncTaskStatusFromWorkspaceBoard}
|
||||
|
|
@ -782,7 +862,9 @@ export default function WorkspaceKanbanDrawer({
|
|||
>
|
||||
<WorkspaceKanbanLaneGrid
|
||||
statuses={workspaceStatuses}
|
||||
worktreesByStatus={worktreesByStatus}
|
||||
laneViews={laneViews}
|
||||
laneFullWorktreeIds={laneFullWorktreeIds}
|
||||
hasQuery={hasQuery}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
columnWidth={columnWidth}
|
||||
|
|
@ -790,13 +872,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,38 @@ 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: '',
|
||||
isFiltering: false,
|
||||
isTooLarge: false,
|
||||
matchCount: 0,
|
||||
totalCount: 0,
|
||||
onQueryChange: vi.fn(),
|
||||
onClearQuery: vi.fn(),
|
||||
workspaceStatuses: statuses,
|
||||
syncTaskStatusFromWorkspaceBoard: false,
|
||||
onSyncTaskStatusFromWorkspaceBoardChange: vi.fn(),
|
||||
|
|
@ -44,7 +73,8 @@ function renderHeader(onClose: () => void): React.ReactElement {
|
|||
onRemoveStatus: vi.fn(),
|
||||
onAddStatus: vi.fn(),
|
||||
onFilterMenuOpenChange: vi.fn(),
|
||||
onClose
|
||||
onClose,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -62,4 +92,51 @@ 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',
|
||||
isFiltering: true,
|
||||
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',
|
||||
isFiltering: true,
|
||||
matchCount: 2,
|
||||
totalCount: 15
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the filter, settings, and close cluster reachable alongside the field', () => {
|
||||
const header = renderHeader(vi.fn(), {
|
||||
query: 'orca',
|
||||
isFiltering: true,
|
||||
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,19 @@ 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
|
||||
isFiltering: boolean
|
||||
isTooLarge: boolean
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
onClearQuery: () => void
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
syncTaskStatusFromWorkspaceBoard: boolean
|
||||
onSyncTaskStatusFromWorkspaceBoardChange: (enabled: boolean) => void
|
||||
|
|
@ -24,6 +32,13 @@ type WorkspaceKanbanDrawerHeaderProps = {
|
|||
|
||||
export default function WorkspaceKanbanDrawerHeader({
|
||||
selectedCount,
|
||||
query,
|
||||
isFiltering,
|
||||
isTooLarge,
|
||||
matchCount,
|
||||
totalCount,
|
||||
onQueryChange,
|
||||
onClearQuery,
|
||||
workspaceStatuses,
|
||||
syncTaskStatusFromWorkspaceBoard,
|
||||
onSyncTaskStatusFromWorkspaceBoardChange,
|
||||
|
|
@ -39,23 +54,37 @@ 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}
|
||||
isFiltering={isFiltering}
|
||||
isTooLarge={isTooLarge}
|
||||
matchCount={matchCount}
|
||||
totalCount={totalCount}
|
||||
onQueryChange={onQueryChange}
|
||||
onClear={onClearQuery}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</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,221 @@
|
|||
// @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, { overlayReserve } 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()
|
||||
const onClose = vi.fn()
|
||||
|
||||
function renderField(props: {
|
||||
query: string
|
||||
isFiltering?: boolean
|
||||
isTooLarge?: boolean
|
||||
matchCount?: number
|
||||
totalCount?: number
|
||||
}): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceKanbanSearchField
|
||||
query={props.query}
|
||||
isFiltering={props.isFiltering ?? props.query.trim() !== ''}
|
||||
isTooLarge={props.isTooLarge ?? false}
|
||||
matchCount={props.matchCount ?? 0}
|
||||
totalCount={props.totalCount ?? 0}
|
||||
onQueryChange={onQueryChange}
|
||||
onClear={onClear}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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('withholds counts for text that never narrows the board', () => {
|
||||
renderField({ query: ' ', isFiltering: false, matchCount: 12, totalCount: 12 })
|
||||
|
||||
expect(container.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||
expect(clearButton()).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
expect(liveRegion().textContent).toBe('')
|
||||
})
|
||||
|
||||
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 closes the board on an empty one', () => {
|
||||
// Why: useWorkspaceBoardPanel's Escape listener is capture-phase on
|
||||
// document, so it runs before this handler and stopPropagation cannot
|
||||
// reach it. The panel defers to board text fields instead, which makes
|
||||
// this field solely responsible for both Escape outcomes.
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
|
||||
act(() => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
)
|
||||
})
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
renderField({ query: '' })
|
||||
act(() => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
)
|
||||
})
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('says so when a query was discarded for length instead of silently not filtering', () => {
|
||||
// Why: an over-bound query and a query that matched everything look
|
||||
// identical — full field, untouched board — without this.
|
||||
renderField({ query: 'x'.repeat(3000), isFiltering: false, isTooLarge: true })
|
||||
|
||||
expect(container.textContent).toContain('Too long')
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true')
|
||||
expect(liveRegion().textContent).toContain('too long')
|
||||
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
expect(container.textContent).not.toContain('Too long')
|
||||
expect(input().getAttribute('aria-invalid')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves Escape to the IME while a composition is in progress', () => {
|
||||
renderField({ query: '検索', matchCount: 1, totalCount: 12 })
|
||||
|
||||
act(() => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
isComposing: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(onClear).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps focus in the field after the clear button unmounts itself', () => {
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
act(() => {
|
||||
input().focus()
|
||||
clearButton()?.click()
|
||||
})
|
||||
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(document.activeElement).toBe(input())
|
||||
})
|
||||
|
||||
it('reserves overlay width in font-relative units, capped so text stays visible', () => {
|
||||
// '298 / 1024' is 10 characters; a fixed reserve would let it overlap.
|
||||
expect(overlayReserve('298 / 1024')).toContain('10ch')
|
||||
expect(overlayReserve('3 / 9')).toContain('5ch')
|
||||
|
||||
// Capped, so a wide counter in a narrow drawer cannot squeeze the typed
|
||||
// text to nothing — overlapping is the better failure at that size.
|
||||
expect(overlayReserve('298 / 1024')).toContain('55%')
|
||||
|
||||
// No overlay means only the clear button needs clearing.
|
||||
expect(overlayReserve(null)).toBe('32px')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const ANNOUNCE_DEBOUNCE_MS = 400
|
||||
// Why: the counter overlays the input, so its width has to be reserved rather
|
||||
// than guessed — a fixed reserve overlaps typed text once counts reach 3 digits.
|
||||
// `ch` keeps that reserve font-relative across platforms: it measures the input's
|
||||
// own 12px font while the counter renders at 10px tabular-nums, so digits always
|
||||
// over-reserve and '/' and ' ' are narrower still.
|
||||
const CLEAR_BUTTON_RESERVE_PX = 32
|
||||
const OVERLAY_GAP_PX = 4
|
||||
// Why: a wide counter in a narrow drawer could otherwise reserve the whole
|
||||
// field and squeeze the typed text to nothing. Overlapping the counter is the
|
||||
// better failure at that size.
|
||||
const MAX_OVERLAY_RESERVE = '55%'
|
||||
|
||||
type WorkspaceKanbanSearchFieldProps = {
|
||||
query: string
|
||||
/** False for text that never narrows the board (whitespace-only, over-bound). */
|
||||
isFiltering: boolean
|
||||
/** True when the text was discarded for length, which needs saying out loud. */
|
||||
isTooLarge: boolean
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
onClear: () => void
|
||||
/** Escape in an empty field has nothing local to cancel, so it dismisses the board. */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Exported for test: happy-dom drops `min()`, so this cannot be read back off a style. */
|
||||
export function overlayReserve(overlayText: string | null): string {
|
||||
if (!overlayText) {
|
||||
return `${CLEAR_BUTTON_RESERVE_PX}px`
|
||||
}
|
||||
return `min(calc(${CLEAR_BUTTON_RESERVE_PX + OVERLAY_GAP_PX}px + ${overlayText.length}ch), ${MAX_OVERLAY_RESERVE})`
|
||||
}
|
||||
|
||||
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,
|
||||
isFiltering,
|
||||
isTooLarge,
|
||||
matchCount,
|
||||
totalCount,
|
||||
onQueryChange,
|
||||
onClear,
|
||||
onClose
|
||||
}: WorkspaceKanbanSearchFieldProps): React.JSX.Element {
|
||||
const hasText = query !== ''
|
||||
const counterText = isFiltering ? `${matchCount} / ${totalCount}` : null
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
const tooLargeMessage = isTooLarge
|
||||
? translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.7f1c2e94a5',
|
||||
'Search text is too long — the board is unfiltered'
|
||||
)
|
||||
: null
|
||||
const tooLargeLabel = isTooLarge
|
||||
? translate('auto.components.sidebar.WorkspaceKanbanSearchField.9a4d0f6b21', 'Too long')
|
||||
: null
|
||||
const badgeText = tooLargeLabel ?? counterText
|
||||
|
||||
// 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 (tooLargeMessage) {
|
||||
setAnnouncement(tooLargeMessage)
|
||||
return
|
||||
}
|
||||
if (!isFiltering) {
|
||||
setAnnouncement('')
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
() => setAnnouncement(formatAnnouncement(matchCount, totalCount)),
|
||||
ANNOUNCE_DEBOUNCE_MS
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isFiltering, matchCount, totalCount, tooLargeMessage])
|
||||
|
||||
return (
|
||||
<div className="relative flex min-w-0 max-w-xs flex-1 items-center">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.c0cd6bdf6c',
|
||||
'Search workspaces'
|
||||
)}
|
||||
placeholder={translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.c0cd6bdf6c',
|
||||
'Search workspaces'
|
||||
)}
|
||||
aria-invalid={isTooLarge || undefined}
|
||||
className="h-7 border-worktree-sidebar-border bg-background pl-7 text-xs"
|
||||
style={hasText ? { paddingRight: overlayReserve(badgeText) } : undefined}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
// Why: useWorkspaceBoardPanel defers Escape to board text fields, so
|
||||
// this field is the only handler — it must cover both outcomes.
|
||||
// Mid-composition Escape belongs to the IME, which cancels the
|
||||
// in-progress reading rather than the query behind it.
|
||||
if (event.key !== 'Escape' || event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (hasText) {
|
||||
onClear()
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
{hasText ? (
|
||||
<div className="absolute right-1 flex items-center gap-0.5">
|
||||
{/* Why: a discarded query looks exactly like one that matched
|
||||
everything, so the field has to say which it was. */}
|
||||
{tooLargeLabel ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
title={tooLargeMessage ?? undefined}
|
||||
className="text-[10px] text-destructive"
|
||||
>
|
||||
{tooLargeLabel}
|
||||
</span>
|
||||
) : counterText ? (
|
||||
<span aria-hidden="true" className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{counterText}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanSearchField.3b7ea51793',
|
||||
'Clear search'
|
||||
)}
|
||||
// Why: clearing unmounts this button, so focus would fall to
|
||||
// <body>. Keep it in the field the user is still typing in.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
onClear()
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
// @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 { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index'
|
||||
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('leaves an already-empty lane as Empty under a query rather than "No matches"', () => {
|
||||
renderLane({ items: [], totalCount: 0, hasQuery: true, fullWorktreeIds: [] })
|
||||
|
||||
expect(container.textContent).toContain('Empty')
|
||||
expect(container.textContent).not.toContain('No matches')
|
||||
expect(container.textContent).not.toContain('0 / 0')
|
||||
})
|
||||
|
||||
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(serializeWorkspaceLaneFullIds(['a', 'b', 'c']))
|
||||
expect(container.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stays off the full-id channel when nothing is filtered', () => {
|
||||
// Why: without a query the rendered card scan already is the full lane, and
|
||||
// the attribute would carry every board id for no reader.
|
||||
renderLane({
|
||||
items: [worktree('a'), worktree('b')],
|
||||
totalCount: 2,
|
||||
hasQuery: false,
|
||||
fullWorktreeIds: ['a', 'b']
|
||||
})
|
||||
|
||||
expect(lane().dataset.workspaceLaneFullIds).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type {
|
||||
Repo,
|
||||
|
|
@ -14,12 +14,17 @@ 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[]
|
||||
/** Lane membership before search filtering; defaults to the rendered items. */
|
||||
totalCount?: number
|
||||
hasQuery?: boolean
|
||||
fullWorktreeIds?: readonly string[]
|
||||
repoMap: Map<string, Repo>
|
||||
activeWorktreeId: string | null
|
||||
columnWidth: number
|
||||
|
|
@ -47,6 +52,9 @@ type WorkspaceKanbanStatusLaneProps = {
|
|||
export default function WorkspaceKanbanStatusLane({
|
||||
status,
|
||||
items,
|
||||
totalCount,
|
||||
hasQuery = false,
|
||||
fullWorktreeIds,
|
||||
repoMap,
|
||||
activeWorktreeId,
|
||||
columnWidth,
|
||||
|
|
@ -68,6 +76,21 @@ export default function WorkspaceKanbanStatusLane({
|
|||
onColumnResizeKeyDown
|
||||
}: WorkspaceKanbanStatusLaneProps): React.JSX.Element {
|
||||
const meta = getWorkspaceStatusVisualMeta(status)
|
||||
// Why: a lane that is empty on its own merits is still "Empty" under a query —
|
||||
// only a lane whose cards were filtered away has anything to say about matches.
|
||||
const laneTotalCount = totalCount ?? items.length
|
||||
const isFiltered = hasQuery && laneTotalCount > 0
|
||||
// Why: this joins every id in the lane, so it must not rerun on unrelated
|
||||
// board re-renders — at a few hundred cards it is ~25KB of string per pass.
|
||||
const laneFullIdsAttribute = useMemo(() => {
|
||||
if (!hasQuery) {
|
||||
return undefined
|
||||
}
|
||||
return (
|
||||
serializeWorkspaceLaneFullIds(fullWorktreeIds ?? items.map((worktree) => worktree.id)) ??
|
||||
undefined
|
||||
)
|
||||
}, [fullWorktreeIds, hasQuery, items])
|
||||
const createTooltip = canCreateWorktree
|
||||
? `New workspace in ${status.label}`
|
||||
: 'Add a project to create workspaces'
|
||||
|
|
@ -89,6 +112,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.
|
||||
// Unfiltered lanes stay off this channel — the rendered scan already is the
|
||||
// full lane, and every board id in an attribute is real DOM weight.
|
||||
data-workspace-lane-full-ids={laneFullIdsAttribute}
|
||||
data-contextual-tour-target={
|
||||
status.id === 'completed' ? 'workspace-board-done-lane' : undefined
|
||||
}
|
||||
|
|
@ -140,7 +168,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}
|
||||
{isFiltered ? `${items.length} / ${laneTotalCount}` : items.length}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
|
|
@ -180,7 +208,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')}
|
||||
{isFiltered
|
||||
? translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanStatusLane.2df01a03ff',
|
||||
'No matches'
|
||||
)
|
||||
: translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')}
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ import {
|
|||
getWorkspaceKanbanSidebarDropTarget,
|
||||
hasWorkspaceKanbanSidebarDropBoard,
|
||||
isWorkspaceKanbanSidebarDropPointInBoard,
|
||||
resolveWorkspaceKanbanSidebarFullLaneDropIndex,
|
||||
updateWorkspaceKanbanSidebarDropTargetVisual
|
||||
} from './workspace-kanban-sidebar-drop'
|
||||
import {
|
||||
|
|
@ -3220,7 +3221,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
onDropWorktreesOnWorkspaceBoard({
|
||||
worktreeIds: drag.reorderDraggedIds,
|
||||
status: boardDropTarget.status,
|
||||
dropIndex: boardDropTarget.dropIndex,
|
||||
// Why: the target counts rendered cards, but the groups are the full
|
||||
// lane. Board search can make those two differ.
|
||||
dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex(
|
||||
boardDropTarget.status,
|
||||
boardDropTarget.dropIndex
|
||||
),
|
||||
groups: getWorkspaceKanbanSidebarDropGroups()
|
||||
})
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { useCallback, useDeferredValue, useMemo, useState } from 'react'
|
||||
import { isWorktreePaletteQueryTooLarge } from '@/lib/worktree-palette-query-bounds'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import { matchWorkspaceBoardWorktrees } from './workspace-kanban-search'
|
||||
|
||||
function areWorktreeIdSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
if (a.size !== b.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of a) {
|
||||
if (!b.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
/** True when the query was discarded for exceeding the palette byte bound. */
|
||||
isQueryTooLarge: boolean
|
||||
} {
|
||||
const [query, setQuery] = useState('')
|
||||
// Why: board identities churn on agent-status ticks, so an unchanged match set
|
||||
// must keep its identity or every memoized card re-renders on every tick.
|
||||
// Store via setState-during-render (not a ref write) so discarded renders do
|
||||
// not leak a match set that never committed.
|
||||
const [stableMatched, setStableMatched] = useState<ReadonlySet<string> | null>(null)
|
||||
|
||||
// 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('')
|
||||
}
|
||||
|
||||
// Why: the input stays fully controlled and undebounced, but a query change
|
||||
// mounts or unmounts every hidden card — clearing one costs about what opening
|
||||
// the board costs. Deferring only the filter keeps the caret responsive and
|
||||
// lets React interrupt the board re-render.
|
||||
const deferredQuery = useDeferredValue(query)
|
||||
|
||||
const matched = useMemo(
|
||||
() =>
|
||||
matchWorkspaceBoardWorktrees({
|
||||
worktrees: args.worktrees,
|
||||
query: deferredQuery,
|
||||
repoMap: args.repoMap
|
||||
}),
|
||||
[args.repoMap, args.worktrees, deferredQuery]
|
||||
)
|
||||
|
||||
const matchingWorktreeIds =
|
||||
stableMatched && matched && areWorktreeIdSetsEqual(stableMatched, matched)
|
||||
? stableMatched
|
||||
: matched
|
||||
if (matchingWorktreeIds !== stableMatched) {
|
||||
setStableMatched(matchingWorktreeIds)
|
||||
}
|
||||
|
||||
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,
|
||||
// Why: whitespace-only text is also non-filtering, but it is self-evidently
|
||||
// so. A discarded 2KB paste looks identical to a query that matched
|
||||
// everything, so only that case earns an explanation. Read the deferred
|
||||
// query, not the live one — this describes the board, so it has to change
|
||||
// on the same frame the board does.
|
||||
isQueryTooLarge: isWorktreePaletteQueryTooLarge(deferredQuery)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { useWorkspaceKanbanSelection } from './use-workspace-kanban-selection'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
type Selection = ReturnType<typeof useWorkspaceKanbanSelection>
|
||||
|
||||
function worktree(id: string): Worktree {
|
||||
return { id, repoId: 'repo-a', displayName: id } as Worktree
|
||||
}
|
||||
|
||||
const alpha = worktree('alpha')
|
||||
const beta = worktree('beta')
|
||||
const gamma = worktree('gamma')
|
||||
const delta = worktree('delta')
|
||||
const fullBoard = [alpha, beta, gamma, delta]
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let selection: Selection
|
||||
|
||||
function Probe({
|
||||
board,
|
||||
rendered
|
||||
}: {
|
||||
board: readonly Worktree[]
|
||||
rendered: readonly Worktree[]
|
||||
}): null {
|
||||
selection = useWorkspaceKanbanSelection(true, board, rendered)
|
||||
return null
|
||||
}
|
||||
|
||||
function renderSelection(
|
||||
rendered: readonly Worktree[] = fullBoard,
|
||||
board: readonly Worktree[] = fullBoard
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(<Probe board={board} rendered={rendered} />)
|
||||
})
|
||||
}
|
||||
|
||||
function click(worktreeId: string, shiftKey = false): void {
|
||||
act(() => {
|
||||
selection.updateSelectionForGesture(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey } as React.MouseEvent<HTMLElement>,
|
||||
worktreeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function toggleClick(worktreeId: string): void {
|
||||
act(() => {
|
||||
selection.updateSelectionForGesture(
|
||||
{
|
||||
metaKey: navigator.userAgent.includes('Mac'),
|
||||
ctrlKey: !navigator.userAgent.includes('Mac'),
|
||||
shiftKey: false
|
||||
} as React.MouseEvent<HTMLElement>,
|
||||
worktreeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function selectedIds(): string[] {
|
||||
return [...selection.selectedWorktreeIds].sort()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('useWorkspaceKanbanSelection', () => {
|
||||
it('ranges across the whole board when nothing is filtered', () => {
|
||||
renderSelection()
|
||||
|
||||
click(alpha.id)
|
||||
click(gamma.id, true)
|
||||
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta', 'gamma'])
|
||||
})
|
||||
|
||||
it('never ranges through cards a search has hidden', () => {
|
||||
renderSelection([alpha, gamma])
|
||||
|
||||
click(alpha.id)
|
||||
click(gamma.id, true)
|
||||
|
||||
expect(selectedIds()).toEqual(['alpha', 'gamma'])
|
||||
})
|
||||
|
||||
it('keeps a hidden card selected so clearing the search restores the selection', () => {
|
||||
renderSelection()
|
||||
click(alpha.id)
|
||||
click(beta.id, true)
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
|
||||
renderSelection([alpha])
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
|
||||
renderSelection()
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('extends the range from a visible card when a search hides the anchor', () => {
|
||||
// Anchor lands on delta, then a query hides only delta.
|
||||
renderSelection()
|
||||
click(alpha.id)
|
||||
toggleClick(beta.id)
|
||||
toggleClick(delta.id)
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta', 'delta'])
|
||||
|
||||
renderSelection([alpha, beta, gamma])
|
||||
click(gamma.id, true)
|
||||
|
||||
// Without a rendered anchor this collapsed to just gamma, dropping the
|
||||
// still-visible alpha and beta along with it.
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta', 'gamma'])
|
||||
})
|
||||
|
||||
it('replaces a hidden selection on every replace-shaped gesture alike', () => {
|
||||
// Why: a range, a plain click and a non-additive marquee all mean "replace".
|
||||
// If a range alone carried hidden cards through, the user would be left with
|
||||
// a selection they cannot see, count, or narrow.
|
||||
renderSelection()
|
||||
click(delta.id)
|
||||
toggleClick(alpha.id)
|
||||
expect(selectedIds()).toEqual(['alpha', 'delta'])
|
||||
|
||||
renderSelection([alpha, beta, gamma])
|
||||
click(gamma.id, true)
|
||||
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta', 'gamma'])
|
||||
})
|
||||
|
||||
it('lets a plain click clear a selection the search is hiding', () => {
|
||||
renderSelection()
|
||||
click(alpha.id)
|
||||
toggleClick(delta.id)
|
||||
|
||||
renderSelection([alpha, beta, gamma])
|
||||
click(beta.id)
|
||||
|
||||
expect(selectedIds()).toEqual(['beta'])
|
||||
})
|
||||
|
||||
it('still prunes ids that leave the board entirely', () => {
|
||||
renderSelection()
|
||||
click(alpha.id)
|
||||
click(gamma.id, true)
|
||||
|
||||
renderSelection([alpha, beta], [alpha, beta])
|
||||
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
})
|
||||
|
|
@ -8,11 +8,35 @@ import {
|
|||
updateWorktreeSelection
|
||||
} from './worktree-multi-selection'
|
||||
|
||||
export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: readonly Worktree[]) {
|
||||
/** Returns the first still-rendered selected id, or `null` if the anchor is fine. */
|
||||
function resolveRenderedAnchorId(
|
||||
renderedWorktreeIds: readonly string[],
|
||||
selectedWorktreeIds: ReadonlySet<string>,
|
||||
anchorId: string
|
||||
): string | null {
|
||||
if (renderedWorktreeIds.includes(anchorId)) {
|
||||
return null
|
||||
}
|
||||
return renderedWorktreeIds.find((id) => selectedWorktreeIds.has(id)) ?? null
|
||||
}
|
||||
|
||||
// Why: board search hides cards without dropping them from the board, so range
|
||||
// and area gestures index the rendered subset while pruning still spans the
|
||||
// whole board — a card hidden by a query keeps its selection until a gesture
|
||||
// replaces it, and every action path narrows to the rendered cards anyway.
|
||||
export function useWorkspaceKanbanSelection(
|
||||
open: boolean,
|
||||
boardWorktrees: readonly Worktree[],
|
||||
renderedWorktrees: readonly Worktree[] = boardWorktrees
|
||||
) {
|
||||
const boardWorktreeIds = useMemo(
|
||||
() => boardWorktrees.map((worktree) => worktree.id),
|
||||
[boardWorktrees]
|
||||
)
|
||||
const renderedWorktreeIds = useMemo(
|
||||
() => renderedWorktrees.map((worktree) => worktree.id),
|
||||
[renderedWorktrees]
|
||||
)
|
||||
const [selectedWorktreeIds, setSelectedWorktreeIds] = useState<Set<string>>(new Set())
|
||||
const [selectionAnchorId, setSelectionAnchorId] = useState<string | null>(null)
|
||||
const selectedWorktrees = useMemo(
|
||||
|
|
@ -42,18 +66,30 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
|
|||
const updateSelectionForGesture = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>, worktreeId: string): boolean => {
|
||||
const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac'))
|
||||
// Why: a search can hide the anchor while leaving the rest of the
|
||||
// selection on screen. updateWorktreeSelection reads an anchor missing
|
||||
// from visibleIds as "no anchor" and collapses the range to the click,
|
||||
// so re-anchor onto the first still-rendered selected card instead.
|
||||
const anchorId =
|
||||
intent === 'range' && selectionAnchorId !== null
|
||||
? (resolveRenderedAnchorId(renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId) ??
|
||||
selectionAnchorId)
|
||||
: selectionAnchorId
|
||||
const result = updateWorktreeSelection({
|
||||
visibleIds: boardWorktreeIds,
|
||||
visibleIds: renderedWorktreeIds,
|
||||
previousSelectedIds: selectedWorktreeIds,
|
||||
previousAnchorId: selectionAnchorId,
|
||||
previousAnchorId: anchorId,
|
||||
targetId: worktreeId,
|
||||
intent
|
||||
})
|
||||
// Why: a range replaces the selection, exactly like a plain click and a
|
||||
// non-additive marquee. Carrying hidden cards through it would leave the
|
||||
// user with a selection they cannot see, count, or narrow.
|
||||
setSelectedWorktreeIds(result.selectedIds)
|
||||
setSelectionAnchorId(result.anchorId)
|
||||
return intent !== 'replace'
|
||||
},
|
||||
[boardWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
[renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
)
|
||||
|
||||
const selectForContextMenu = useCallback(
|
||||
|
|
@ -76,7 +112,7 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
|
|||
baseAnchorId: string | null = selectionAnchorId
|
||||
): void => {
|
||||
const result = updateWorktreeAreaSelection({
|
||||
visibleIds: boardWorktreeIds,
|
||||
visibleIds: renderedWorktreeIds,
|
||||
previousSelectedIds: baseSelectedIds,
|
||||
previousAnchorId: baseAnchorId,
|
||||
areaIds,
|
||||
|
|
@ -89,7 +125,7 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
|
|||
previous === result.anchorId ? previous : result.anchorId
|
||||
)
|
||||
},
|
||||
[boardWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
[renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -52,12 +52,23 @@ async function updatePanel(update: (state: WorkspaceBoardPanelState) => void): P
|
|||
})
|
||||
}
|
||||
|
||||
async function pressEscape(): Promise<void> {
|
||||
async function pressEscape(from: EventTarget = document): Promise<void> {
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
from.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function appendInput(inside: 'board' | 'app'): HTMLInputElement {
|
||||
const host = document.createElement('div')
|
||||
if (inside === 'board') {
|
||||
host.setAttribute('data-workspace-board-sheet', '')
|
||||
}
|
||||
const field = document.createElement('input')
|
||||
host.appendChild(field)
|
||||
document.body.appendChild(host)
|
||||
return field
|
||||
}
|
||||
|
||||
describe('useWorkspaceBoardPanel', () => {
|
||||
beforeEach(() => {
|
||||
latestState = null
|
||||
|
|
@ -190,6 +201,30 @@ describe('useWorkspaceBoardPanel', () => {
|
|||
expect(panelState().workspaceBoardOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('defers Escape to a text field inside the board', async () => {
|
||||
// Why: this listener is capture-phase on document, so it runs before React's
|
||||
// handlers and a board field cannot stopPropagation its way out. The field
|
||||
// owns Escape and calls closeWorkspaceBoard itself when it has nothing to
|
||||
// cancel — without this guard, clearing a search query dismissed the board.
|
||||
await renderHookProbe()
|
||||
const field = appendInput('board')
|
||||
|
||||
await updatePanel((state) => state.openWorkspaceBoard())
|
||||
await pressEscape(field)
|
||||
|
||||
expect(panelState().workspaceBoardOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('still closes the board on Escape from a text field outside it', async () => {
|
||||
await renderHookProbe()
|
||||
const field = appendInput('app')
|
||||
|
||||
await updatePanel((state) => state.openWorkspaceBoard())
|
||||
await pressEscape(field)
|
||||
|
||||
expect(panelState().workspaceBoardOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the board open on Escape while a nested dialog is open', async () => {
|
||||
await renderHookProbe()
|
||||
const dialog = document.createElement('div')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { isEditableTarget } from '@/lib/editable-target'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const WORKSPACE_BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]'
|
||||
|
||||
// Why: the board's Escape listener is capture-phase on document, so it runs
|
||||
// before React's handlers and a text field inside the board cannot stop it.
|
||||
// Board fields own Escape and close the board themselves when they have
|
||||
// nothing left to cancel.
|
||||
function isWorkspaceBoardEditableTarget(target: EventTarget | null): boolean {
|
||||
return (
|
||||
isEditableTarget(target) &&
|
||||
target instanceof HTMLElement &&
|
||||
target.closest(WORKSPACE_BOARD_SHEET_SELECTOR) !== null
|
||||
)
|
||||
}
|
||||
|
||||
const WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR = [
|
||||
'[data-slot="dropdown-menu-content"][data-state="open"]',
|
||||
'[data-slot="context-menu-content"][data-state="open"]',
|
||||
|
|
@ -124,6 +139,9 @@ export function useWorkspaceBoardPanel(): WorkspaceBoardPanelState {
|
|||
if (workspaceBoardMenuOpen) {
|
||||
return
|
||||
}
|
||||
if (isWorkspaceBoardEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
// Why: Escape should dismiss interactive nested overlays before this
|
||||
// companion panel, but non-interactive tooltips should not trap it.
|
||||
if (document.querySelector(WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseWorkspaceLaneFullIds,
|
||||
resolveFullLaneDropIndex,
|
||||
serializeWorkspaceLaneFullIds
|
||||
} 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('appends into a lane whose cards are all filtered away', () => {
|
||||
// Why: an empty rendered lane reports drop index 0 for every pointer
|
||||
// position, so honouring it would always prepend. The document-drop path
|
||||
// appends for the same gesture, and these must not disagree.
|
||||
expect(
|
||||
resolveFullLaneDropIndex({ fullLaneIds: FULL, renderedIds: [], filteredDropIndex: 0 })
|
||||
).toBe(FULL.length)
|
||||
expect(
|
||||
resolveFullLaneDropIndex({ fullLaneIds: FULL, renderedIds: [], filteredDropIndex: 3 })
|
||||
).toBe(FULL.length)
|
||||
expect(
|
||||
resolveFullLaneDropIndex({ fullLaneIds: [], renderedIds: [], filteredDropIndex: 0 })
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back toward the end of the lane the branch was aiming at', () => {
|
||||
// A head drop resolves to the head, not the tail — the opposite fallback
|
||||
// would land a card at the bottom of a lane the user dropped it on top of.
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['stale', 'e'],
|
||||
filteredDropIndex: 0
|
||||
})
|
||||
).toBe(0)
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['b', 'stale'],
|
||||
filteredDropIndex: 2
|
||||
})
|
||||
).toBe(FULL.length)
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: FULL,
|
||||
renderedIds: ['b', 'stale', 'g'],
|
||||
filteredDropIndex: 1
|
||||
})
|
||||
).toBe(FULL.length)
|
||||
})
|
||||
|
||||
it('still translates when a stale lane has the same length but different members', () => {
|
||||
// Why: a length-only guard would take the identity branch here and skip
|
||||
// translation, landing the card at an index that means nothing in FULL.
|
||||
expect(
|
||||
resolveFullLaneDropIndex({
|
||||
fullLaneIds: ['a', 'b', 'c'],
|
||||
renderedIds: ['a', 'x', 'c'],
|
||||
filteredDropIndex: 1
|
||||
})
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace lane full-id channel', () => {
|
||||
it('round-trips lane membership through the delimiter', () => {
|
||||
const ids = ['repo-a::/Users/dev/projects/orca/main', 'repo-b::C:\\src\\atlas, v2']
|
||||
const serialized = serializeWorkspaceLaneFullIds(ids)
|
||||
|
||||
expect(serialized).not.toBeNull()
|
||||
expect(parseWorkspaceLaneFullIds(serialized ?? undefined)).toEqual(ids)
|
||||
})
|
||||
|
||||
it('distinguishes an unpublished lane from an empty one', () => {
|
||||
expect(parseWorkspaceLaneFullIds(undefined)).toBeNull()
|
||||
expect(parseWorkspaceLaneFullIds('')).toEqual([])
|
||||
expect(serializeWorkspaceLaneFullIds([])).toBe('')
|
||||
})
|
||||
|
||||
it('survives ids holding every character a path can legally contain', () => {
|
||||
// Why: a POSIX path may hold any byte but NUL and '/', so a newline, comma
|
||||
// or colon delimiter would split one id into phantom lane members. Dropping
|
||||
// the channel is not an escape hatch either — under a query the reader
|
||||
// would fall back to the DOM and see only the matched cards.
|
||||
const ids = ['repo-a::/Users/dev/we\nird, one: two', 'repo-b::C:\\src\\atlas']
|
||||
|
||||
expect(parseWorkspaceLaneFullIds(serializeWorkspaceLaneFullIds(ids) ?? undefined)).toEqual(ids)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
// Why: worktree ids embed repo paths, so commas, colons and newlines are all
|
||||
// unusable as a separator in the `data-workspace-lane-full-ids` channel — a
|
||||
// POSIX path may contain any byte but NUL and '/', and a Windows path carries
|
||||
// a drive colon. NUL is the one character no path can hold, so it cannot split
|
||||
// an id into phantom lane members. Verified to round-trip through setAttribute
|
||||
// and dataset in Chromium — but HTML *parsing* rewrites NUL to U+FFFD, so this
|
||||
// channel must stay setAttribute-only and never pass through innerHTML.
|
||||
export const WORKSPACE_LANE_FULL_IDS_DELIMITER = '\0'
|
||||
|
||||
/**
|
||||
* Returns `null` when the lane cannot be represented on this channel. Defence
|
||||
* only: no real worktree id can contain the NUL delimiter. Dropping the channel
|
||||
* is the wrong fallback under an active query — the reader would then scan the
|
||||
* DOM and see only the matched cards — so this must stay unreachable.
|
||||
*/
|
||||
export function serializeWorkspaceLaneFullIds(worktreeIds: readonly string[]): string | null {
|
||||
if (worktreeIds.some((worktreeId) => worktreeId.includes(WORKSPACE_LANE_FULL_IDS_DELIMITER))) {
|
||||
return null
|
||||
}
|
||||
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
|
||||
// Why: equal lengths alone would take this branch for a stale DOM lane that
|
||||
// holds the same card count but different membership, skipping translation.
|
||||
if (isSameLane(fullLaneIds, renderedIds)) {
|
||||
return filteredDropIndex
|
||||
}
|
||||
// Why: a lane filtered down to nothing reports drop index 0 for every pointer
|
||||
// position, so honouring it would silently prepend. Append instead, matching
|
||||
// dropWorktreesAtEndOfStatus for the same gesture on the document-drop path.
|
||||
if (renderedIds.length === 0) {
|
||||
return fullLaneIds.length
|
||||
}
|
||||
|
||||
if (filteredDropIndex <= 0) {
|
||||
// Why: the head branch means "above the first match", so an unresolvable id
|
||||
// falls back to the lane head. Using the tail would invert the gesture.
|
||||
return indexInFullLane(fullLaneIds, renderedIds[0]!, 0)
|
||||
}
|
||||
if (filteredDropIndex >= renderedIds.length) {
|
||||
const lastIndex = indexInFullLane(fullLaneIds, renderedIds.at(-1)!, fullLaneIds.length - 1)
|
||||
return Math.min(fullLaneIds.length, lastIndex + 1)
|
||||
}
|
||||
return indexInFullLane(fullLaneIds, renderedIds[filteredDropIndex]!, fullLaneIds.length)
|
||||
}
|
||||
|
||||
function isSameLane(fullLaneIds: readonly string[], renderedIds: readonly string[]): boolean {
|
||||
return (
|
||||
renderedIds.length === fullLaneIds.length &&
|
||||
renderedIds.every((worktreeId, index) => worktreeId === fullLaneIds[index])
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a rendered id missing from the full lane is a stale-DOM race, so the
|
||||
// caller supplies the end of the lane its branch was aiming at — a raw -1 would
|
||||
// clamp to 0 downstream and teleport a tail drop to the top.
|
||||
function indexInFullLane(
|
||||
fullLaneIds: readonly string[],
|
||||
worktreeId: string,
|
||||
fallbackIndex: number
|
||||
): number {
|
||||
const index = fullLaneIds.indexOf(worktreeId)
|
||||
return index === -1 ? fallbackIndex : index
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
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 regex metacharacters as literal text', () => {
|
||||
// Why: matching is indexOf, never RegExp. This pins that, so swapping in a
|
||||
// regex later fails here instead of silently changing what users can search.
|
||||
const worktrees = [
|
||||
worktree({ id: 'literal', displayName: 'feat.*fix' }),
|
||||
worktree({ id: 'would-match-as-regex', displayName: 'featANYfix' })
|
||||
]
|
||||
|
||||
expect(match(worktrees, 'feat.*fix')).toEqual(new Set(['literal']))
|
||||
expect(match(worktrees, '(')).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('matches non-ASCII display names and comments', () => {
|
||||
const worktrees = [
|
||||
worktree({ id: 'cjk', displayName: '検索フィールド' }),
|
||||
worktree({ id: 'accent', displayName: 'Other', comment: 'Añadir búsqueda' }),
|
||||
worktree({ id: 'miss', displayName: 'Other' })
|
||||
]
|
||||
|
||||
expect(match(worktrees, 'フィールド')).toEqual(new Set(['cjk']))
|
||||
expect(match(worktrees, 'BÚSQUEDA')).toEqual(new Set(['accent']))
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/types'
|
||||
import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index'
|
||||
import {
|
||||
buildWorkspaceKanbanSidebarDropUpdates,
|
||||
clearWorkspaceKanbanSidebarDropTargetVisual,
|
||||
getWorkspaceKanbanSidebarDropGroups,
|
||||
getWorkspaceKanbanSidebarDropTarget,
|
||||
isWorkspaceKanbanSidebarDropPointInBoard,
|
||||
resolveWorkspaceKanbanSidebarFullLaneDropIndex,
|
||||
updateWorkspaceKanbanSidebarDropTargetVisual
|
||||
} from './workspace-kanban-sidebar-drop'
|
||||
|
||||
|
|
@ -247,6 +249,48 @@ 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 =
|
||||
serializeWorkspaceLaneFullIds(['doing-x', 'doing-a', 'doing-y', 'doing-b']) ?? ''
|
||||
setElementFromPoint(lane)
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
|
||||
{ key: 'doing', worktreeIds: ['doing-x', 'doing-a', 'doing-y', 'doing-b'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the tracked drop target in the rendered index space of the indicator', () => {
|
||||
const { lane } = appendBoard()
|
||||
lane.dataset.workspaceLaneFullIds =
|
||||
serializeWorkspaceLaneFullIds(['doing-x', 'doing-a', 'doing-y', 'doing-b']) ?? ''
|
||||
setElementFromPoint(lane)
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropTarget(24, 60)).toMatchObject({
|
||||
status: 'doing',
|
||||
dropIndex: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('translates a rendered drop index onto the full lane at the commit boundary', () => {
|
||||
const { lane } = appendBoard()
|
||||
lane.dataset.workspaceLaneFullIds =
|
||||
serializeWorkspaceLaneFullIds(['doing-x', 'doing-a', 'doing-y', 'doing-b']) ?? ''
|
||||
setElementFromPoint(lane)
|
||||
|
||||
// Rendered index 1 means "before doing-b", which is index 3 in the full lane.
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 1)).toBe(3)
|
||||
// Why: a tracked target can be committed after the pointer left the lane,
|
||||
// so the translation must not depend on the current pointer position.
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 2)).toBe(4)
|
||||
})
|
||||
|
||||
it('passes the drop index through for a lane it cannot find', () => {
|
||||
appendBoard()
|
||||
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('todo', 2)).toBe(2)
|
||||
})
|
||||
|
||||
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,30 @@ export function isWorkspaceKanbanSidebarDropPointInBoard(x: number, y: number):
|
|||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
||||
}
|
||||
|
||||
function getLaneCardIds(lane: HTMLElement): HTMLElement[] {
|
||||
return Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
|
||||
}
|
||||
|
||||
// Mirrors getCardDropTarget's card scan so both sides share one index space.
|
||||
// The offsetParent read forces layout, so callers pass the card list they
|
||||
// already collected rather than re-querying.
|
||||
function toRenderedCardIds(cards: readonly HTMLElement[]): string[] {
|
||||
return cards
|
||||
.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.
|
||||
// The fallback is the unfiltered card list — a lane member the browser is not
|
||||
// laying out is still a member for manual-order purposes.
|
||||
function toLaneFullWorktreeIds(lane: HTMLElement, cards: readonly HTMLElement[]): string[] {
|
||||
return (
|
||||
parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ??
|
||||
cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? [])
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusDropTargetElement(
|
||||
board: HTMLElement,
|
||||
status: WorkspaceStatus
|
||||
|
|
@ -80,14 +108,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: toLaneFullWorktreeIds(lane, getLaneCardIds(lane)) }]
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +123,30 @@ export function getWorkspaceKanbanSidebarDropTarget(
|
|||
return getCardDropTarget(board, x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a tracked drop index — which counts *rendered* cards, matching the
|
||||
* drop indicator — onto the full lane that `getWorkspaceKanbanSidebarDropGroups`
|
||||
* reports. Call this once, at the commit boundary: a tracked target can be
|
||||
* committed after the pointer has left the lane, so translating earlier would
|
||||
* miss that path.
|
||||
*/
|
||||
export function resolveWorkspaceKanbanSidebarFullLaneDropIndex(
|
||||
status: WorkspaceStatus,
|
||||
renderedDropIndex: number
|
||||
): number {
|
||||
const board = getWorkspaceKanbanBoardElement()
|
||||
const lane = board ? getStatusDropTargetElement(board, status) : null
|
||||
if (!lane) {
|
||||
return renderedDropIndex
|
||||
}
|
||||
const cards = getLaneCardIds(lane)
|
||||
return resolveFullLaneDropIndex({
|
||||
fullLaneIds: toLaneFullWorktreeIds(lane, cards),
|
||||
renderedIds: toRenderedCardIds(cards),
|
||||
filteredDropIndex: renderedDropIndex
|
||||
})
|
||||
}
|
||||
|
||||
export function updateWorkspaceKanbanSidebarDropTargetVisual(args: {
|
||||
x: number
|
||||
y: number
|
||||
|
|
|
|||
|
|
@ -4415,7 +4415,8 @@
|
|||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "Empty",
|
||||
"3611d1ae7f": "Resize workspace board columns"
|
||||
"3611d1ae7f": "Resize workspace board columns",
|
||||
"2df01a03ff": "No matches"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "Set {{value0}} color to {{value1}}",
|
||||
|
|
@ -5028,6 +5029,14 @@
|
|||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"3b7ea51793": "Clear search",
|
||||
"7f1c2e94a5": "Search text is too long — the board is unfiltered",
|
||||
"9a4d0f6b21": "Too long"
|
||||
}
|
||||
},
|
||||
"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 hay coincidencias"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "Establecer color de {{value0}} en {{value1}}",
|
||||
|
|
@ -5001,6 +5002,14 @@
|
|||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "Ningún espacio de trabajo coincide",
|
||||
"4d96c209d6": "{{value0}} de {{value1}} espacios de trabajo coinciden",
|
||||
"c0cd6bdf6c": "Buscar espacios de trabajo",
|
||||
"3b7ea51793": "Borrar búsqueda",
|
||||
"7f1c2e94a5": "El texto de búsqueda es demasiado largo: el tablero no está filtrado",
|
||||
"9a4d0f6b21": "Demasiado largo"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -4346,7 +4346,8 @@
|
|||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空の",
|
||||
"3611d1ae7f": "ワークスペースボードの列のサイズを変更する"
|
||||
"3611d1ae7f": "ワークスペースボードの列のサイズを変更する",
|
||||
"2df01a03ff": "一致なし"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "{{value0}} の色を {{value1}} に設定します",
|
||||
|
|
@ -5001,6 +5002,14 @@
|
|||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "一致するワークスペースはありません",
|
||||
"4d96c209d6": "{{value1}} 件中 {{value0}} 件のワークスペースが一致します",
|
||||
"c0cd6bdf6c": "ワークスペースの検索",
|
||||
"3b7ea51793": "検索をクリア",
|
||||
"7f1c2e94a5": "検索テキストが長すぎます — ボードは絞り込まれていません",
|
||||
"9a4d0f6b21": "長すぎます"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -4346,7 +4346,8 @@
|
|||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "비어 있음",
|
||||
"3611d1ae7f": "워크스페이스 보드 열 크기 조정"
|
||||
"3611d1ae7f": "워크스페이스 보드 열 크기 조정",
|
||||
"2df01a03ff": "일치하는 항목 없음"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "{{value0}} 색상을 {{value1}}로 설정",
|
||||
|
|
@ -5001,6 +5002,14 @@
|
|||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "일치하는 워크스페이스가 없습니다",
|
||||
"4d96c209d6": "워크스페이스 {{value1}}개 중 {{value0}}개 일치",
|
||||
"c0cd6bdf6c": "워크스페이스 검색",
|
||||
"3b7ea51793": "검색 지우기",
|
||||
"7f1c2e94a5": "검색어가 너무 깁니다 — 보드가 필터링되지 않았습니다",
|
||||
"9a4d0f6b21": "너무 김"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
|
|
@ -4346,7 +4346,8 @@
|
|||
},
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空的",
|
||||
"3611d1ae7f": "调整工作区板列的大小"
|
||||
"3611d1ae7f": "调整工作区板列的大小",
|
||||
"2df01a03ff": "没有匹配项"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "将 {{value0}} 颜色设置为 {{value1}}",
|
||||
|
|
@ -5001,6 +5002,14 @@
|
|||
"WorktreeDeveloperMenu": {
|
||||
"developer": "Developer",
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "没有匹配的工作区",
|
||||
"4d96c209d6": "{{value1}} 个工作区中有 {{value0}} 个匹配",
|
||||
"c0cd6bdf6c": "搜索工作区",
|
||||
"3b7ea51793": "清除搜索",
|
||||
"7f1c2e94a5": "搜索文本过长 — 看板未被筛选",
|
||||
"9a4d0f6b21": "过长"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue