diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx new file mode 100644 index 00000000000..f398b9c19ef --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx @@ -0,0 +1,356 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { Repo, Worktree, WorktreeMeta } from '../../../../shared/types' +import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' +import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' + +type HeaderCapture = { + query: string + matchCount: number + totalCount: number + onQueryChange: (query: string) => void + onClearQuery: () => void +} + +type GridCapture = { + laneViews: ReadonlyMap + laneFullWorktreeIds: ReadonlyMap + hasQuery: boolean + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] +} + +type PointerDragCapture = { + selectedWorktrees: readonly Worktree[] + onDropWorktreesInStatus: (args: { + worktreeIds: readonly string[] + status: string + dropIndex: number + }) => void +} + +const { + syncWorkspaceBoardTaskStatusesMock, + headerState, + gridState, + pointerDragState, + selectionState +} = vi.hoisted(() => ({ + syncWorkspaceBoardTaskStatusesMock: vi.fn(() => + Promise.resolve({ updated: 1, skipped: 0, failed: 0, messages: [] }) + ), + headerState: { current: null as HeaderCapture | null }, + gridState: { current: null as GridCapture | null }, + pointerDragState: { current: null as PointerDragCapture | null }, + selectionState: { current: [] as Worktree[] } +})) + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } })) + +vi.mock('@/components/ui/sheet', () => ({ + Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('./WorkspaceKanbanDrawerHeader', () => ({ + default: (props: HeaderCapture) => { + headerState.current = props + return
+ } +})) + +vi.mock('./WorkspaceKanbanLaneGrid', () => ({ + default: (props: GridCapture) => { + gridState.current = props + return
+ } +})) + +vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({ + default: React.forwardRef((_, ref) =>
) +})) + +vi.mock('./WorkspaceKanbanPinDropTarget', () => ({ default: () =>
})) + +vi.mock('./use-visible-workspace-kanban-worktree-ids', () => ({ + useVisibleWorkspaceKanbanWorktreeIds: ({ allWorktrees }: { allWorktrees: readonly Worktree[] }) => + new Set(allWorktrees.map((worktree) => worktree.id)) +})) + +vi.mock('./use-workspace-kanban-selection', () => ({ + useWorkspaceKanbanSelection: () => ({ + selectedWorktreeIds: new Set(selectionState.current.map((worktree) => worktree.id)), + selectedWorktrees: selectionState.current, + selectionAnchorId: null, + updateSelectionForGesture: vi.fn(), + updateSelectionForArea: vi.fn(), + clearSelection: vi.fn(), + selectForContextMenu: vi.fn(() => selectionState.current) + }) +})) + +vi.mock('./use-workspace-kanban-area-selection', () => ({ + useWorkspaceKanbanAreaSelection: () => ({ handleAreaSelectionPointerDown: vi.fn() }) +})) + +vi.mock('./use-workspace-kanban-column-resize', () => ({ + useWorkspaceKanbanColumnResize: () => ({ + columnWidth: 308, + isResizingColumn: false, + onColumnResizeStart: vi.fn(), + onColumnResizeKeyDown: vi.fn() + }) +})) + +vi.mock('./use-workspace-kanban-create-worktree', () => ({ + useWorkspaceKanbanCreateWorktree: () => ({ + canCreateWorktree: true, + createWorktreeForStatus: vi.fn() + }) +})) + +vi.mock('./use-workspace-kanban-shift-wheel-scroll', () => ({ + useWorkspaceKanbanShiftWheelScroll: vi.fn() +})) + +vi.mock('./use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: vi.fn() +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: vi.fn() +})) + +vi.mock('./use-workspace-kanban-card-pointer-drag', () => ({ + useWorkspaceKanbanCardPointerDrag: (params: PointerDragCapture) => { + pointerDragState.current = params + return { isPointerDragActiveRef: { current: false }, onCardPointerDownCapture: vi.fn() } + } +})) + +vi.mock('./use-workspace-status-drop', () => ({ + useWorkspaceStatusDocumentDrop: vi.fn() +})) + +vi.mock('./workspace-board-task-status-sync', async (importOriginal) => ({ + ...(await importOriginal>()), + syncWorkspaceBoardTaskStatuses: syncWorkspaceBoardTaskStatusesMock +})) + +type UpdateWorktreesMeta = ( + updatesByWorktreeId: ReadonlyMap> +) => Promise + +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> + +function renderDrawer(open = true): void { + act(() => { + root.render( + + ) + }) +} + +function typeQuery(query: string): void { + act(() => { + headerState.current?.onQueryChange(query) + }) +} + +function laneIds(status: string): string[] { + return (gridState.current?.laneViews.get(status)?.items ?? []).map((item) => item.id) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + headerState.current = null + gridState.current = null + pointerDragState.current = null + selectionState.current = [] + syncWorkspaceBoardTaskStatusesMock.mockClear() + updateWorktreesMeta = vi.fn(() => 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 + expect(gridState.current?.onContextMenuSelect(event, gamma)).toEqual([gamma]) + }) + + it('keeps selection highlighting unfiltered while a query is active', () => { + selectionState.current = [alpha, gamma] + renderDrawer() + typeQuery('gamma') + + expect(gridState.current?.selectedWorktreeIds.has(alpha.id)).toBe(true) + expect(gridState.current?.selectedWorktrees).toEqual([gamma]) + }) + + it('ranks a drop into a filtered lane against the full lane, not the rendered one', () => { + renderDrawer() + typeQuery('gamma') + + act(() => { + // Manual order is descending, so the lane is Delta, Gamma, Beta, Alpha. + // Rendered index 0 means "above Gamma" — full-lane index 1, not the top. + pointerDragState.current?.onDropWorktreesInStatus({ + worktreeIds: [omega.id], + status: 'todo', + dropIndex: 0 + }) + }) + + const dropped = updateWorktreesMeta.mock.calls.at(-1)?.[0].get(omega.id) + expect(dropped?.workspaceStatus).toBe('todo') + expect(dropped?.manualOrder).toBeGreaterThan(gamma.manualOrder ?? 0) + expect(dropped?.manualOrder).toBeLessThan(delta.manualOrder ?? 0) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index dde9e91f94a..ae896297df5 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -27,6 +27,9 @@ import { import { useVisibleWorkspaceKanbanWorktreeIds } from './use-visible-workspace-kanban-worktree-ids' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { groupWorkspaceKanbanWorktrees } from './workspace-kanban-worktree-groups' +import { resolveFullLaneDropIndex } from './workspace-kanban-filtered-drop-index' +import { buildWorkspaceKanbanLaneViews } from './workspace-kanban-search' +import { useWorkspaceKanbanSearch } from './use-workspace-kanban-search' import { getWorkspaceBoardTaskStatusSyncRequest, syncWorkspaceBoardTaskStatuses, @@ -38,7 +41,7 @@ import { shouldWriteManualOrderForGroupDrop, type WorktreeDragGroup } from './worktree-manual-order' -import type { WorkspaceStatus, WorktreeMeta } from '../../../../shared/types' +import type { WorkspaceStatus, Worktree, WorktreeMeta } from '../../../../shared/types' import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses' import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from './workspace-chrome-metrics' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' @@ -188,6 +191,19 @@ export default function WorkspaceKanbanDrawer({ })), [worktreesByStatus, workspaceStatuses] ) + const laneFullWorktreeIds = useMemo( + () => new Map(boardDragGroups.map((group) => [group.key, group.worktreeIds])), + [boardDragGroups] + ) + const { query, setQuery, clearQuery, matchingWorktreeIds, hasQuery } = useWorkspaceKanbanSearch({ + open, + worktrees: boardWorktrees, + repoMap + }) + const laneViews = useMemo( + () => buildWorkspaceKanbanLaneViews({ worktreesByStatus, matchingWorktreeIds }), + [matchingWorktreeIds, worktreesByStatus] + ) const { selectedWorktreeIds, selectedWorktrees, @@ -439,12 +455,50 @@ export default function WorkspaceKanbanDrawer({ }, [updateWorktreesMeta, worktreeById] ) + // Why: getCardDropTarget indexes the rendered cards, but manual-order math runs + // against the full lane. Translate at the pointer-drag boundary only — + // dropWorktreesAtEndOfStatus already passes a full-lane index. + const dropPointerDraggedWorktreesInStatus = useCallback( + (args: { worktreeIds: readonly string[]; status: WorkspaceStatus; dropIndex: number }) => { + dropWorktreesInStatus({ + worktreeIds: args.worktreeIds, + status: args.status, + dropIndex: resolveFullLaneDropIndex({ + fullLaneIds: laneFullWorktreeIds.get(args.status) ?? [], + renderedIds: (laneViews.get(args.status)?.items ?? []).map((worktree) => worktree.id), + filteredDropIndex: args.dropIndex + }) + }) + }, + [dropWorktreesInStatus, laneFullWorktreeIds, laneViews] + ) + // Why: dragging or right-clicking one visible match must not silently move + // hidden selected cards. selectedWorktreeIds stays unfiltered so highlighting + // and area-selection anchoring still see the whole selection. + const renderedSelectedWorktrees = useMemo( + () => + matchingWorktreeIds + ? selectedWorktrees.filter((worktree) => matchingWorktreeIds.has(worktree.id)) + : selectedWorktrees, + [matchingWorktreeIds, selectedWorktrees] + ) + // Why: selectForContextMenu closes over the unfiltered selection, so the + // "Move to Status" payload has to be narrowed here too. + const selectRenderedForContextMenu = useCallback( + (event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { + const selection = selectForContextMenu(event, worktree) + return matchingWorktreeIds + ? selection.filter((item) => matchingWorktreeIds.has(item.id)) + : selection + }, + [matchingWorktreeIds, selectForContextMenu] + ) const { isPointerDragActiveRef, onCardPointerDownCapture } = useWorkspaceKanbanCardPointerDrag({ open, boardRef, selectedWorktreeIds, - selectedWorktrees, - onDropWorktreesInStatus: dropWorktreesInStatus, + selectedWorktrees: renderedSelectedWorktrees, + onDropWorktreesInStatus: dropPointerDraggedWorktreesInStatus, onPinWorktrees: pinWorktrees, onDragTargetChange: setDragOverStatus, onShouldShowDropIndicator: shouldWriteDropManualOrder, @@ -747,6 +801,11 @@ export default function WorkspaceKanbanDrawer({ > void } const statuses: WorkspaceStatusDefinition[] = [{ id: 'todo', label: 'Todo' }] -function findElement( +function findNode( node: React.ReactNode, - predicate: (props: InspectableProps) => boolean + predicate: (element: React.ReactElement) => boolean ): React.ReactElement | null { if (!isValidElement(node)) { return null } - if (predicate(node.props)) { + if (predicate(node)) { return node } let match: React.ReactElement | null = null @@ -26,14 +31,36 @@ function findElement( if (match) { return } - match = findElement(child, predicate) + match = findNode(child, predicate) }) return match } -function renderHeader(onClose: () => void): React.ReactElement { +function findElement( + node: React.ReactNode, + predicate: (props: InspectableProps) => boolean +): React.ReactElement | null { + return findNode(node, (element) => predicate(element.props)) +} + +function findByType( + node: React.ReactNode, + type: React.ElementType +): React.ReactElement | null { + return findNode(node, (element) => element.type === type) +} + +function renderHeader( + onClose: () => void, + overrides: Partial[0]> = {} +): React.ReactElement { return WorkspaceKanbanDrawerHeader({ selectedCount: 0, + query: '', + matchCount: 0, + totalCount: 0, + onQueryChange: vi.fn(), + onClearQuery: vi.fn(), workspaceStatuses: statuses, syncTaskStatusFromWorkspaceBoard: false, onSyncTaskStatusFromWorkspaceBoardChange: vi.fn(), @@ -44,7 +71,8 @@ function renderHeader(onClose: () => void): React.ReactElement { onRemoveStatus: vi.fn(), onAddStatus: vi.fn(), onFilterMenuOpenChange: vi.fn(), - onClose + onClose, + ...overrides }) } @@ -62,4 +90,36 @@ describe('WorkspaceKanbanDrawerHeader', () => { expect(onClose).toHaveBeenCalledOnce() }) + + it('renders the search field as a sibling of the sheet title, not inside it', () => { + const header = renderHeader(vi.fn(), { query: 'orca', matchCount: 2, totalCount: 15 }) + + const title = findByType(header, SheetTitle) + expect(title).not.toBeNull() + expect(findByType(title, WorkspaceKanbanSearchField)).toBeNull() + + const field = findByType(header, WorkspaceKanbanSearchField) + expect(field?.props).toMatchObject({ query: 'orca', matchCount: 2, totalCount: 15 }) + }) + + it('keeps the filter, settings, and close cluster reachable alongside the field', () => { + const header = renderHeader(vi.fn(), { query: 'orca', matchCount: 2, totalCount: 15 }) + + expect(findByType(header, SidebarFilter)).not.toBeNull() + expect(findByType(header, WorkspaceKanbanSettingsMenu)).not.toBeNull() + expect(findElement(header, (props) => props['aria-label'] === 'Close')).not.toBeNull() + }) + + it('keeps the selected-count badge and the field clear of the control cluster', () => { + const header = renderHeader(vi.fn(), { selectedCount: 3, query: 'orca' }) + + // The title (with its badge) never shrinks the field into the absolute cluster, + // which the header reserves space for with pr-32. + expect(findByType(header, SheetTitle)?.props.className).toContain('shrink-0') + expect(findByType(header, SheetHeader)?.props.className).toContain('pr-32') + expect( + findElement(header, (props) => Boolean(props.className?.includes('rounded-full'))) + ).not.toBeNull() + expect(findByType(header, WorkspaceKanbanSearchField)).not.toBeNull() + }) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx index 723f373eab6..6d1b513f388 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx @@ -4,11 +4,17 @@ import { Button } from '@/components/ui/button' import { SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' import type { WorkspaceStatusDefinition } from '../../../../shared/types' import SidebarFilter from './SidebarFilter' +import WorkspaceKanbanSearchField from './WorkspaceKanbanSearchField' import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' import { translate } from '@/i18n/i18n' type WorkspaceKanbanDrawerHeaderProps = { selectedCount: number + query: string + matchCount: number + totalCount: number + onQueryChange: (query: string) => void + onClearQuery: () => void workspaceStatuses: readonly WorkspaceStatusDefinition[] syncTaskStatusFromWorkspaceBoard: boolean onSyncTaskStatusFromWorkspaceBoardChange: (enabled: boolean) => void @@ -24,6 +30,11 @@ type WorkspaceKanbanDrawerHeaderProps = { export default function WorkspaceKanbanDrawerHeader({ selectedCount, + query, + matchCount, + totalCount, + onQueryChange, + onClearQuery, workspaceStatuses, syncTaskStatusFromWorkspaceBoard, onSyncTaskStatusFromWorkspaceBoardChange, @@ -39,23 +50,34 @@ export default function WorkspaceKanbanDrawerHeader({ return ( <> - - - {translate( - 'auto.components.sidebar.WorkspaceKanbanDrawerHeader.c6a77ab0f4', - 'Workspace board' - )} - - {selectedCount > 1 ? ( - - {selectedCount}{' '} + {/* Why: SheetTitle is the sheet's aria-labelledby target and renders an +

, so the field must be its sibling, not a descendant. */} +
+ + {translate( - 'auto.components.sidebar.WorkspaceKanbanDrawerHeader.81870af08f', - 'selected' + 'auto.components.sidebar.WorkspaceKanbanDrawerHeader.c6a77ab0f4', + 'Workspace board' )} - ) : null} - + {selectedCount > 1 ? ( + + {selectedCount}{' '} + {translate( + 'auto.components.sidebar.WorkspaceKanbanDrawerHeader.81870af08f', + 'selected' + )} + + ) : null} + + +
{translate( 'auto.components.sidebar.WorkspaceKanbanDrawerHeader.e1a34450fc', diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 889b3cd3ba8..0b258d4dcf7 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -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 + laneViews: ReadonlyMap + laneFullWorktreeIds: ReadonlyMap + hasQuery: boolean repoMap: Map 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({ { + root.render( + + ) + }) +} + +function input(): HTMLInputElement { + const element = container.querySelector('input') + if (!element) { + throw new Error('field not rendered') + } + return element +} + +function clearButton(): HTMLButtonElement | null { + return container.querySelector('button[aria-label="Clear search"]') +} + +function liveRegion(): HTMLElement { + const element = container.querySelector('[aria-live="polite"]') + if (!element) { + throw new Error('live region not rendered') + } + return element +} + +beforeEach(() => { + vi.useFakeTimers() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('WorkspaceKanbanSearchField', () => { + it('reports every keystroke without debouncing', () => { + renderField({ query: '' }) + + act(() => { + // Why: React's value tracker shadows the `value` property, so a plain + // assignment would look like a no-op and never fire onChange. + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input(), 'or') + input().dispatchEvent(new Event('input', { bubbles: true })) + }) + + expect(onQueryChange).toHaveBeenCalledWith('or') + }) + + it('only offers the clear affordance for a non-empty query', () => { + renderField({ query: '' }) + expect(clearButton()).toBeNull() + + renderField({ query: 'orca', matchCount: 3, totalCount: 12 }) + act(() => { + clearButton()?.click() + }) + + expect(onClear).toHaveBeenCalledOnce() + }) + + it('hides the visual match count from assistive tech but keeps the clear button named', () => { + renderField({ query: 'orca', matchCount: 3, totalCount: 12 }) + + const count = container.querySelector('span[aria-hidden="true"]') + expect(count?.textContent).toBe('3 / 12') + expect(clearButton()?.getAttribute('aria-hidden')).toBeNull() + expect(clearButton()?.getAttribute('aria-label')).toBe('Clear search') + }) + + it('announces match counts only after the query settles', () => { + renderField({ query: 'orca', matchCount: 3, totalCount: 12 }) + expect(liveRegion().textContent).toBe('') + + act(() => { + vi.advanceTimersByTime(400) + }) + expect(liveRegion().textContent).toBe('3 of 12 workspaces match') + + renderField({ query: 'zzz', matchCount: 0, totalCount: 12 }) + act(() => { + vi.advanceTimersByTime(400) + }) + expect(liveRegion().textContent).toBe('No workspaces match') + + renderField({ query: '' }) + expect(liveRegion().textContent).toBe('') + }) + + it('clears a non-empty query on Escape and leaves an empty one to the drawer', () => { + renderField({ query: 'orca', matchCount: 3, totalCount: 12 }) + // Why: React roots at `container`, so only a listener above it can observe + // whether the field let Escape through. + const bubbled = vi.fn() + document.body.addEventListener('keydown', bubbled) + + act(() => { + input().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + }) + expect(onClear).toHaveBeenCalledOnce() + expect(bubbled).not.toHaveBeenCalled() + + renderField({ query: '' }) + act(() => { + input().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + }) + expect(onClear).toHaveBeenCalledOnce() + expect(bubbled).toHaveBeenCalledOnce() + + document.body.removeEventListener('keydown', bubbled) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx new file mode 100644 index 00000000000..4d5e36ce2cf --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react' +import { Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +const ANNOUNCE_DEBOUNCE_MS = 400 + +type WorkspaceKanbanSearchFieldProps = { + query: string + matchCount: number + totalCount: number + onQueryChange: (query: string) => void + onClear: () => void +} + +function formatAnnouncement(matchCount: number, totalCount: number): string { + return matchCount === 0 + ? translate( + 'auto.components.sidebar.WorkspaceKanbanSearchField.bdb753c78d', + 'No workspaces match' + ) + : translate( + 'auto.components.sidebar.WorkspaceKanbanSearchField.4d96c209d6', + '{{value0}} of {{value1}} workspaces match', + { value0: matchCount, value1: totalCount } + ) +} + +export default function WorkspaceKanbanSearchField({ + query, + matchCount, + totalCount, + onQueryChange, + onClear +}: WorkspaceKanbanSearchFieldProps): React.JSX.Element { + const hasQuery = query !== '' + const [announcement, setAnnouncement] = useState('') + + // Why: the filter itself is undebounced, but a polite live region that changes + // on every keystroke produces continuous speech and makes the field unusable. + useEffect(() => { + if (!hasQuery) { + setAnnouncement('') + return + } + const timer = window.setTimeout( + () => setAnnouncement(formatAnnouncement(matchCount, totalCount)), + ANNOUNCE_DEBOUNCE_MS + ) + return () => window.clearTimeout(timer) + }, [hasQuery, matchCount, totalCount]) + + return ( +
+ + onQueryChange(event.target.value)} + onKeyDown={(event) => { + // Why: the board swallows Radix close requests, so an empty-field + // Escape has nothing local to do and is left to bubble. + if (event.key !== 'Escape' || !hasQuery) { + return + } + event.preventDefault() + event.stopPropagation() + onClear() + }} + /> + {hasQuery ? ( +
+ + +
+ ) : null} +
+ {announcement} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx new file mode 100644 index 00000000000..8255a4ecc96 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree } from '../../../../shared/types' +import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('./WorkspaceKanbanCard', () => ({ + default: ({ worktree }: { worktree: Worktree }) => ( +
+ ) +})) + +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() + +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( + false)} + onContextMenuSelect={vi.fn(() => [])} + onCreateWorktree={vi.fn()} + onColumnResizeStart={vi.fn()} + onColumnResizeKeyDown={vi.fn()} + /> + ) + }) +} + +function lane(): HTMLElement { + const element = container.querySelector('[data-workspace-status-drop-target]') + if (!element) { + throw new Error('lane not rendered') + } + return element +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +describe('WorkspaceKanbanStatusLane', () => { + it('shows a plain count without a query and a matches/total count with one', () => { + renderLane({ items: [worktree('a'), worktree('b')], totalCount: 2, hasQuery: false }) + expect(container.textContent).toContain('2') + expect(container.textContent).not.toContain('2 / 2') + + renderLane({ items: [worktree('a')], totalCount: 5, hasQuery: true }) + expect(container.textContent).toContain('1 / 5') + }) + + it('keeps a fully filtered lane as a labeled drop target', () => { + renderLane({ items: [], totalCount: 5, hasQuery: true }) + + expect(container.textContent).toContain('No matches') + expect(lane().hasAttribute('data-workspace-status-drop-target')).toBe(true) + }) + + it('shows the empty placeholder when there is no query', () => { + renderLane({ items: [], totalCount: 0, hasQuery: false }) + + expect(container.textContent).toContain('Empty') + expect(container.textContent).not.toContain('No matches') + }) + + it('publishes the full lane membership even when the rendered set is a subset', () => { + renderLane({ + items: [worktree('b')], + totalCount: 3, + hasQuery: true, + fullWorktreeIds: ['a', 'b', 'c'] + }) + + expect(lane().dataset.workspaceLaneFullIds).toBe('a\nb\nc') + expect(container.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx index c4016cbbb94..c47abb5c06f 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -14,12 +14,16 @@ import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index' import { getWorkspaceStatusVisualMeta } from './workspace-status' import { translate } from '@/i18n/i18n' type WorkspaceKanbanStatusLaneProps = { status: WorkspaceStatusDefinition items: readonly Worktree[] + totalCount?: number + hasQuery?: boolean + fullWorktreeIds?: readonly string[] repoMap: Map activeWorktreeId: string | null columnWidth: number @@ -47,6 +51,9 @@ type WorkspaceKanbanStatusLaneProps = { export default function WorkspaceKanbanStatusLane({ status, items, + totalCount, + hasQuery = false, + fullWorktreeIds, repoMap, activeWorktreeId, columnWidth, @@ -89,6 +96,11 @@ export default function WorkspaceKanbanStatusLane({
worktree.id) + )} data-contextual-tour-target={ status.id === 'completed' ? 'workspace-board-done-lane' : undefined } @@ -140,7 +152,7 @@ export default function WorkspaceKanbanStatusLane({ {status.label}
- {items.length} + {hasQuery ? `${items.length} / ${totalCount ?? items.length}` : items.length}

@@ -180,7 +192,12 @@ export default function WorkspaceKanbanStatusLane({
) : (
- {translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')} + {hasQuery + ? translate( + 'auto.components.sidebar.WorkspaceKanbanStatusLane.2df01a03ff', + 'No matches' + ) + : translate('auto.components.sidebar.WorkspaceKanbanStatusLane.8ad104642b', 'Empty')}
)} diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts new file mode 100644 index 00000000000..8ff6b644f08 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts @@ -0,0 +1,45 @@ +import { useCallback, useMemo, useState } from 'react' +import type { Repo, Worktree } from '../../../../shared/types' +import { matchWorkspaceBoardWorktrees } from './workspace-kanban-search' + +export function useWorkspaceKanbanSearch(args: { + open: boolean + worktrees: Worktree[] + repoMap: Map +}): { + query: string + setQuery: (query: string) => void + clearQuery: () => void + matchingWorktreeIds: ReadonlySet | null + hasQuery: boolean +} { + const [query, setQuery] = useState('') + + // Why: a stale query silently hiding cards on reopen is a trap. Reset during + // render like useWorkspaceKanbanSelection, so no frame paints the old filter. + if (!args.open && query !== '') { + setQuery('') + } + + const matchingWorktreeIds = useMemo( + () => + matchWorkspaceBoardWorktrees({ + worktrees: args.worktrees, + query, + repoMap: args.repoMap + }), + [args.repoMap, args.worktrees, query] + ) + + const clearQuery = useCallback(() => setQuery(''), []) + + return { + query, + setQuery, + clearQuery, + matchingWorktreeIds, + // Why: an over-bound query is non-empty but non-filtering, and the lane + // counts must not switch to "n / m" for it. + hasQuery: matchingWorktreeIds !== null + } +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts new file mode 100644 index 00000000000..158e248e38c --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { resolveFullLaneDropIndex } from './workspace-kanban-filtered-drop-index' + +const FULL = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + +describe('resolveFullLaneDropIndex', () => { + it('is the identity when nothing is filtered', () => { + for (let index = 0; index <= FULL.length; index++) { + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: FULL, + filteredDropIndex: index + }) + ).toBe(index) + } + }) + + it('maps the first filtered slot onto the first match position', () => { + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: ['h'], + filteredDropIndex: 0 + }) + ).toBe(7) + }) + + it('maps the end of a filtered lane one past the last match', () => { + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: ['b', 'e'], + filteredDropIndex: 2 + }) + ).toBe(5) + }) + + it('maps a slot between two matches onto the following match', () => { + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: ['b', 'e', 'g'], + filteredDropIndex: 1 + }) + ).toBe(4) + }) + + it('handles a lane filtered to zero cards', () => { + expect( + resolveFullLaneDropIndex({ fullLaneIds: FULL, renderedIds: [], filteredDropIndex: 0 }) + ).toBe(0) + expect( + resolveFullLaneDropIndex({ fullLaneIds: FULL, renderedIds: [], filteredDropIndex: 3 }) + ).toBe(FULL.length) + }) + + it('falls back to the lane end when a rendered id is missing from the full lane', () => { + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: ['stale', 'e'], + filteredDropIndex: 0 + }) + ).toBe(FULL.length) + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: ['b', 'stale'], + filteredDropIndex: 2 + }) + ).toBe(FULL.length) + }) + + it('clamps out-of-range filtered indices to the first and last branches', () => { + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: ['b', 'e'], + filteredDropIndex: -3 + }) + ).toBe(1) + expect( + resolveFullLaneDropIndex({ + fullLaneIds: FULL, + renderedIds: ['b', 'e'], + filteredDropIndex: 99 + }) + ).toBe(5) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.ts b/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.ts new file mode 100644 index 00000000000..de53250cf42 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.ts @@ -0,0 +1,54 @@ +// Why: worktree ids embed repo paths, so commas and colons are unusable as a +// separator in the `data-workspace-lane-full-ids` channel; newlines are not. +export const WORKSPACE_LANE_FULL_IDS_DELIMITER = '\n' + +export function serializeWorkspaceLaneFullIds(worktreeIds: readonly string[]): string { + return worktreeIds.join(WORKSPACE_LANE_FULL_IDS_DELIMITER) +} + +/** Returns `null` when the lane never published the attribute. */ +export function parseWorkspaceLaneFullIds(value: string | undefined): string[] | null { + if (value === undefined) { + return null + } + return value === '' ? [] : value.split(WORKSPACE_LANE_FULL_IDS_DELIMITER) +} + +/** + * Translates a drop index derived from the *rendered* cards of a lane onto the + * lane's full membership. Board search hides non-matching cards, but manual-order + * math runs against the full lane, so the two sides must be reconciled. + * + * Both id lists still contain the dragged ids — `getCardDropTarget` counts the + * dragged card and `buildManualOrderUpdatesForGroupDrop` computes + * `removedBeforeDrop` against the pre-removal group. Keep it that way. + */ +export function resolveFullLaneDropIndex(args: { + fullLaneIds: readonly string[] + renderedIds: readonly string[] + filteredDropIndex: number +}): number { + const { fullLaneIds, renderedIds, filteredDropIndex } = args + if (renderedIds.length === fullLaneIds.length) { + return filteredDropIndex + } + if (renderedIds.length === 0) { + return filteredDropIndex <= 0 ? 0 : fullLaneIds.length + } + + if (filteredDropIndex <= 0) { + return indexInFullLane(fullLaneIds, renderedIds[0]!) + } + if (filteredDropIndex >= renderedIds.length) { + const lastIndex = indexInFullLane(fullLaneIds, renderedIds.at(-1)!) + return Math.min(fullLaneIds.length, lastIndex + 1) + } + return indexInFullLane(fullLaneIds, renderedIds[filteredDropIndex]!) +} + +// Why: a rendered id missing from the full lane is a stale-DOM race. Returning +// -1 would clamp to 0 downstream and teleport the card to the top of the lane. +function indexInFullLane(fullLaneIds: readonly string[], worktreeId: string): number { + const index = fullLaneIds.indexOf(worktreeId) + return index === -1 ? fullLaneIds.length : index +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts new file mode 100644 index 00000000000..e6724562398 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds' +import type { Repo, Worktree } from '../../../../shared/types' +import { + buildWorkspaceKanbanLaneViews, + matchWorkspaceBoardWorktrees +} from './workspace-kanban-search' + +function worktree(overrides: Partial & { 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([ + ['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 | null { + return matchWorkspaceBoardWorktrees({ worktrees, query, repoMap }) +} + +describe('matchWorkspaceBoardWorktrees', () => { + it('treats blank and whitespace-only queries as no filtering', () => { + const worktrees = [worktree({ id: 'a' })] + expect(match(worktrees, '')).toBeNull() + expect(match(worktrees, ' ')).toBeNull() + }) + + it('matches display name, branch, and repo display name', () => { + const worktrees = [ + worktree({ id: 'name', displayName: 'Search field' }), + worktree({ id: 'branch', displayName: 'Other', branch: 'refs/heads/feat/search-lane' }), + worktree({ id: 'repo', displayName: 'Other', repoId: 'repo-b' }), + worktree({ id: 'miss', displayName: 'Other' }) + ] + + expect(match(worktrees, 'search')).toEqual(new Set(['name', 'branch'])) + expect(match(worktrees, 'atlas')).toEqual(new Set(['repo'])) + }) + + it('matches the workspace comment', () => { + const worktrees = [ + worktree({ id: 'commented', displayName: 'Other', comment: 'blocked on review' }), + worktree({ id: 'miss', displayName: 'Other' }) + ] + + expect(match(worktrees, 'blocked')).toEqual(new Set(['commented'])) + }) + + it('excludes worktrees that only match on PR, issue, or port', () => { + const worktrees = [ + worktree({ id: 'pr', displayName: 'Other', linkedPR: 4242 }), + worktree({ id: 'issue', displayName: 'Other', linkedIssue: 4242 }) + ] + + expect(match(worktrees, '4242')).toEqual(new Set()) + }) + + it('matches composite repo/branch queries', () => { + const worktrees = [ + worktree({ id: 'hit', displayName: 'Other', branch: 'main' }), + worktree({ id: 'wrong-repo', displayName: 'Other', repoId: 'repo-b', branch: 'main' }) + ] + + expect(match(worktrees, 'orca/main')).toEqual(new Set(['hit'])) + }) + + it('is case-insensitive', () => { + const worktrees = [worktree({ id: 'a', displayName: 'Search Field' })] + + expect(match(worktrees, 'SEARCH')).toEqual(new Set(['a'])) + }) + + it('treats an over-bound query as no filtering rather than zero matches', () => { + const worktrees = [worktree({ id: 'a', displayName: 'Search field' })] + + expect(match(worktrees, 'x'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES + 1))).toBeNull() + }) +}) + +describe('buildWorkspaceKanbanLaneViews', () => { + const todo = [worktree({ id: 'todo-a', displayName: 'Alpha' }), worktree({ id: 'todo-b' })] + const doing = [worktree({ id: 'doing-a', displayName: 'Alpha' })] + const worktreesByStatus = new Map([ + ['todo', todo], + ['doing', doing] + ]) + + it('reuses the input arrays when no query is active', () => { + const views = buildWorkspaceKanbanLaneViews({ worktreesByStatus, matchingWorktreeIds: null }) + + expect(views.get('todo')?.items).toBe(todo) + expect(views.get('doing')?.items).toBe(doing) + expect(views.get('todo')?.totalCount).toBe(2) + }) + + it('preserves lane order and per-lane sort order', () => { + const views = buildWorkspaceKanbanLaneViews({ + worktreesByStatus, + matchingWorktreeIds: new Set(['todo-b', 'todo-a', 'doing-a']) + }) + + expect(Array.from(views.keys())).toEqual(['todo', 'doing']) + expect(views.get('todo')?.items.map((item) => item.id)).toEqual(['todo-a', 'todo-b']) + }) + + it('keeps a fully filtered lane with an empty item list and its real total', () => { + const views = buildWorkspaceKanbanLaneViews({ + worktreesByStatus, + matchingWorktreeIds: new Set(['doing-a']) + }) + + expect(views.get('todo')).toEqual({ items: [], totalCount: 2 }) + expect(views.get('doing')?.items.map((item) => item.id)).toEqual(['doing-a']) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-search.ts b/src/renderer/src/components/sidebar/workspace-kanban-search.ts new file mode 100644 index 00000000000..a7f8b0840d0 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-search.ts @@ -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 = new Set([ + '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 +}): ReadonlySet | 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() + 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 + matchingWorktreeIds: ReadonlySet | null +}): Map { + const matchingWorktreeIds = args.matchingWorktreeIds + const views = new Map() + 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 +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index e9ae00c76f1..4bf51612f09 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -247,6 +247,28 @@ describe('workspace kanban sidebar drop DOM bridge', () => { ]) }) + it('prefers the published full lane membership over the rendered card scan', () => { + const { lane } = appendBoard() + lane.dataset.workspaceLaneFullIds = ['doing-x', 'doing-a', 'doing-y', 'doing-b'].join('\n') + setElementFromPoint(lane) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-x', 'doing-a', 'doing-y', 'doing-b'] } + ]) + }) + + it('translates the rendered drop index onto the full lane', () => { + const { lane } = appendBoard() + lane.dataset.workspaceLaneFullIds = ['doing-x', 'doing-a', 'doing-y', 'doing-b'].join('\n') + setElementFromPoint(lane) + + // Rendered index 1 means "before doing-b", which is index 3 in the full lane. + expect(getWorkspaceKanbanSidebarDropTarget(24, 60)).toMatchObject({ + status: 'doing', + dropIndex: 3 + }) + }) + it('marks and clears the external board hover target', () => { const { lane } = appendBoard() setElementFromPoint(lane) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index befa87d9eee..f2a3361fca1 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -4,6 +4,10 @@ import type { Worktree, WorktreeMeta } from '../../../../shared/types' +import { + parseWorkspaceLaneFullIds, + resolveFullLaneDropIndex +} from './workspace-kanban-filtered-drop-index' import { getWorkspaceStatus } from './workspace-status' import { buildManualOrderUpdatesForGroupDrop, @@ -44,6 +48,21 @@ export function isWorkspaceKanbanSidebarDropPointInBoard(x: number, y: number): return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom } +// Mirrors getCardDropTarget's card scan so both sides share one index space. +function getRenderedLaneCardIds(lane: HTMLElement): string[] { + return Array.from(lane.querySelectorAll(CARD_SELECTOR)) + .filter((card) => card.offsetParent !== null) + .flatMap((card) => card.dataset.workspaceBoardCardId ?? []) +} + +// Why: board search hides non-matching cards, so the rendered card scan is a +// filtered lane. Lanes publish their full membership for exactly this reader. +function getLaneFullWorktreeIds(lane: HTMLElement): string[] { + return ( + parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ?? getRenderedLaneCardIds(lane) + ) +} + function getStatusDropTargetElement( board: HTMLElement, status: WorkspaceStatus @@ -80,14 +99,7 @@ export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] { if (!status) { return [] } - return [ - { - key: status, - worktreeIds: Array.from(lane.querySelectorAll(CARD_SELECTOR)).flatMap( - (card) => card.dataset.workspaceBoardCardId ?? [] - ) - } - ] + return [{ key: status, worktreeIds: getLaneFullWorktreeIds(lane) }] }) } @@ -99,7 +111,24 @@ export function getWorkspaceKanbanSidebarDropTarget( if (!board) { return { status: null, isPinDrop: false, dropIndex: 0 } } - return getCardDropTarget(board, x, y) + const target = getCardDropTarget(board, x, y) + if (!target.status) { + return target + } + const lane = getStatusDropTargetElement(board, target.status) + if (!lane) { + return target + } + // Why: getCardDropTarget counts rendered cards, but the groups above are the + // full lane. Committing the two together would interpolate the wrong rank. + return { + ...target, + dropIndex: resolveFullLaneDropIndex({ + fullLaneIds: getLaneFullWorktreeIds(lane), + renderedIds: getRenderedLaneCardIds(lane), + filteredDropIndex: target.dropIndex + }) + } } export function updateWorkspaceKanbanSidebarDropTargetVisual(args: { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3de51346cef..0b2f99d768d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4411,7 +4411,8 @@ }, "WorkspaceKanbanStatusLane": { "8ad104642b": "Empty", - "3611d1ae7f": "Resize workspace board columns" + "3611d1ae7f": "Resize workspace board columns", + "2df01a03ff": "No matches" }, "WorkspaceStatusAppearancePopover": { "514be2f569": "Set {{value0}} color to {{value1}}", @@ -5024,6 +5025,13 @@ "WorktreeDeveloperMenu": { "developer": "Developer", "parkTerminal": "Park terminal" + }, + "WorkspaceKanbanSearchField": { + "bdb753c78d": "No workspaces match", + "4d96c209d6": "{{value0}} of {{value1}} workspaces match", + "c0cd6bdf6c": "Search workspaces", + "49c266baaa": "Search", + "3b7ea51793": "Clear search" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2edcded718f..648122ce0c0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4365,7 +4365,8 @@ }, "WorkspaceKanbanStatusLane": { "8ad104642b": "Vacío", - "3611d1ae7f": "Cambiar tamaño de columnas del tablero de espacios de trabajo" + "3611d1ae7f": "Cambiar tamaño de columnas del tablero de espacios de trabajo", + "2df01a03ff": "No matches" }, "WorkspaceStatusAppearancePopover": { "514be2f569": "Establecer color de {{value0}} en {{value1}}", @@ -5001,6 +5002,13 @@ "WorktreeDeveloperMenu": { "developer": "Developer", "parkTerminal": "Park terminal" + }, + "WorkspaceKanbanSearchField": { + "bdb753c78d": "No workspaces match", + "4d96c209d6": "{{value0}} of {{value1}} workspaces match", + "c0cd6bdf6c": "Search workspaces", + "49c266baaa": "Search", + "3b7ea51793": "Clear search" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fffa62c2fb0..197c9d97362 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4346,7 +4346,8 @@ }, "WorkspaceKanbanStatusLane": { "8ad104642b": "空の", - "3611d1ae7f": "ワークスペースボードの列のサイズを変更する" + "3611d1ae7f": "ワークスペースボードの列のサイズを変更する", + "2df01a03ff": "No matches" }, "WorkspaceStatusAppearancePopover": { "514be2f569": "{{value0}} の色を {{value1}} に設定します", @@ -5001,6 +5002,13 @@ "WorktreeDeveloperMenu": { "developer": "Developer", "parkTerminal": "Park terminal" + }, + "WorkspaceKanbanSearchField": { + "bdb753c78d": "No workspaces match", + "4d96c209d6": "{{value0}} of {{value1}} workspaces match", + "c0cd6bdf6c": "Search workspaces", + "49c266baaa": "Search", + "3b7ea51793": "Clear search" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d96497fcd88..c8fffed7bd1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4346,7 +4346,8 @@ }, "WorkspaceKanbanStatusLane": { "8ad104642b": "비어 있음", - "3611d1ae7f": "워크스페이스 보드 열 크기 조정" + "3611d1ae7f": "워크스페이스 보드 열 크기 조정", + "2df01a03ff": "No matches" }, "WorkspaceStatusAppearancePopover": { "514be2f569": "{{value0}} 색상을 {{value1}}로 설정", @@ -5001,6 +5002,13 @@ "WorktreeDeveloperMenu": { "developer": "Developer", "parkTerminal": "Park terminal" + }, + "WorkspaceKanbanSearchField": { + "bdb753c78d": "No workspaces match", + "4d96c209d6": "{{value0}} of {{value1}} workspaces match", + "c0cd6bdf6c": "Search workspaces", + "49c266baaa": "Search", + "3b7ea51793": "Clear search" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d24db028e9c..0a5883e8bef 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4346,7 +4346,8 @@ }, "WorkspaceKanbanStatusLane": { "8ad104642b": "空的", - "3611d1ae7f": "调整工作区板列的大小" + "3611d1ae7f": "调整工作区板列的大小", + "2df01a03ff": "No matches" }, "WorkspaceStatusAppearancePopover": { "514be2f569": "将 {{value0}} 颜色设置为 {{value1}}", @@ -5001,6 +5002,13 @@ "WorktreeDeveloperMenu": { "developer": "Developer", "parkTerminal": "Park terminal" + }, + "WorkspaceKanbanSearchField": { + "bdb753c78d": "No workspaces match", + "4d96c209d6": "{{value0}} of {{value1}} workspaces match", + "c0cd6bdf6c": "Search workspaces", + "49c266baaa": "Search", + "3b7ea51793": "Clear search" } }, "shared": {