From 0551564812e2be14f46afd98b20a59d9b2bd8d09 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:27:54 -0700 Subject: [PATCH] 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. --- .../WorkspaceKanbanDrawer.search.test.tsx | 356 ++++++++++++++++++ .../sidebar/WorkspaceKanbanDrawer.tsx | 73 +++- .../WorkspaceKanbanDrawerHeader.test.tsx | 72 +++- .../sidebar/WorkspaceKanbanDrawerHeader.tsx | 50 ++- .../sidebar/WorkspaceKanbanLaneGrid.tsx | 14 +- .../WorkspaceKanbanSearchField.test.tsx | 145 +++++++ .../sidebar/WorkspaceKanbanSearchField.tsx | 108 ++++++ .../WorkspaceKanbanStatusLane.test.tsx | 125 ++++++ .../sidebar/WorkspaceKanbanStatusLane.tsx | 21 +- .../sidebar/use-workspace-kanban-search.ts | 45 +++ ...rkspace-kanban-filtered-drop-index.test.ts | 91 +++++ .../workspace-kanban-filtered-drop-index.ts | 54 +++ .../sidebar/workspace-kanban-search.test.ts | 125 ++++++ .../sidebar/workspace-kanban-search.ts | 62 +++ .../workspace-kanban-sidebar-drop.test.ts | 22 ++ .../sidebar/workspace-kanban-sidebar-drop.ts | 47 ++- src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- 21 files changed, 1415 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx create mode 100644 src/renderer/src/components/sidebar/use-workspace-kanban-search.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-search.test.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-search.ts 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": {