diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx index fb037ce8dc9..7b8ace6e4bc 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx @@ -3,6 +3,7 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds' import { useAppStore } from '@/store' import type { Repo, Worktree, WorktreeMeta } from '../../../../shared/types' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' @@ -374,6 +375,19 @@ describe('WorkspaceKanbanDrawer search', () => { expect(laneIds('todo')).toHaveLength(4) }) + it('leaves the whole board unfiltered for an over-bound query', () => { + // Why: searchWorktrees returns [] past the byte bound, which would read as + // "matched nothing" and blank every lane on a paste accident. + renderDrawer() + + typeQuery('x'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES + 1)) + + expect(laneIds('todo')).toHaveLength(4) + expect(laneIds('in-review')).toEqual([omega.id]) + expect(gridState.current?.hasQuery).toBe(false) + expect(headerState.current).toMatchObject({ isFiltering: false, matchCount: 5, totalCount: 5 }) + }) + it('ranks a drop into a filtered lane against the full lane, not the rendered one', () => { renderDrawer() typeQuery('gamma') diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx index 67ab3ccdf94..75dacf7b7c6 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx @@ -172,16 +172,16 @@ describe('WorkspaceKanbanSearchField', () => { expect(document.activeElement).toBe(input()) }) - it('reserves counter width so a large count cannot overlap typed text', () => { + it('reserves counter width in font-relative units so a large count cannot overlap typed text', () => { + // '298 / 1024' is 10 characters; a fixed reserve would let it overlap. renderField({ query: 'orca', matchCount: 298, totalCount: 1024 }) - const wide = Number.parseFloat(input().style.paddingRight) + expect(input().style.paddingRight).toContain('10ch') renderField({ query: 'orca', matchCount: 3, totalCount: 9 }) - const narrow = Number.parseFloat(input().style.paddingRight) + expect(input().style.paddingRight).toContain('5ch') - expect(wide).toBeGreaterThan(narrow) - // '298 / 1024' is 10 characters, so the reserve must clear the clear button - // plus the full counter rather than a fixed 64px guess. - expect(wide).toBeGreaterThanOrEqual(32 + 10 * 6) + // A non-filtering query shows no counter, so it reserves only the button. + renderField({ query: ' ', isFiltering: false }) + expect(input().style.paddingRight).not.toContain('ch') }) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx index c71054d1e14..c12dab5b6c6 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx @@ -7,9 +7,10 @@ import { translate } from '@/i18n/i18n' const ANNOUNCE_DEBOUNCE_MS = 400 // Why: the counter overlays the input, so its width has to be reserved rather // than guessed — a fixed reserve overlaps typed text once counts reach 3 digits. -// Safe over-estimates at text-[10px] tabular-nums, where '/' and ' ' are narrower. +// `ch` keeps that reserve font-relative across platforms: it measures the input's +// own 12px font while the counter renders at 10px tabular-nums, so digits always +// over-reserve and '/' and ' ' are narrower still. const CLEAR_BUTTON_RESERVE_PX = 32 -const COUNTER_CHAR_PX = 6 const COUNTER_GAP_PX = 4 type WorkspaceKanbanSearchFieldProps = { @@ -24,6 +25,13 @@ type WorkspaceKanbanSearchFieldProps = { onClose: () => void } +function counterReserve(counterText: string | null): string { + if (!counterText) { + return `${CLEAR_BUTTON_RESERVE_PX}px` + } + return `calc(${CLEAR_BUTTON_RESERVE_PX + COUNTER_GAP_PX}px + ${counterText.length}ch)` +} + function formatAnnouncement(matchCount: number, totalCount: number): string { return matchCount === 0 ? translate( @@ -80,15 +88,7 @@ export default function WorkspaceKanbanSearchField({ 'Search workspaces' )} className="h-7 border-worktree-sidebar-border bg-background pl-7 text-xs" - style={ - hasText - ? { - paddingRight: - CLEAR_BUTTON_RESERVE_PX + - (counterText ? counterText.length * COUNTER_CHAR_PX + COUNTER_GAP_PX : 0) - } - : undefined - } + style={hasText ? { paddingRight: counterReserve(counterText) } : undefined} onChange={(event) => onQueryChange(event.target.value)} onKeyDown={(event) => { // Why: useWorkspaceBoardPanel defers Escape to board text fields, so diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx index 8956cd06e7c..40e9f1e8360 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { Plus } from 'lucide-react' import type { Repo, @@ -80,6 +80,17 @@ export default function WorkspaceKanbanStatusLane({ // only a lane whose cards were filtered away has anything to say about matches. const laneTotalCount = totalCount ?? items.length const isFiltered = hasQuery && laneTotalCount > 0 + // Why: this joins every id in the lane, so it must not rerun on unrelated + // board re-renders — at a few hundred cards it is ~25KB of string per pass. + const laneFullIdsAttribute = useMemo(() => { + if (!hasQuery) { + return undefined + } + return ( + serializeWorkspaceLaneFullIds(fullWorktreeIds ?? items.map((worktree) => worktree.id)) ?? + undefined + ) + }, [fullWorktreeIds, hasQuery, items]) const createTooltip = canCreateWorktree ? `New workspace in ${status.label}` : 'Add a project to create workspaces' @@ -105,11 +116,7 @@ export default function WorkspaceKanbanStatusLane({ // where a search query would otherwise leave them only the rendered cards. // Unfiltered lanes stay off this channel — the rendered scan already is the // full lane, and every board id in an attribute is real DOM weight. - data-workspace-lane-full-ids={ - hasQuery - ? serializeWorkspaceLaneFullIds(fullWorktreeIds ?? items.map((worktree) => worktree.id)) - : undefined - } + data-workspace-lane-full-ids={laneFullIdsAttribute} data-contextual-tour-target={ status.id === 'completed' ? 'workspace-board-done-lane' : undefined } diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts index 8ff6b644f08..feed28f1b0e 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-search.ts @@ -1,7 +1,19 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react' import type { Repo, Worktree } from '../../../../shared/types' import { matchWorkspaceBoardWorktrees } from './workspace-kanban-search' +function areWorktreeIdSetsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) { + return false + } + for (const id of a) { + if (!b.has(id)) { + return false + } + } + return true +} + export function useWorkspaceKanbanSearch(args: { open: boolean worktrees: Worktree[] @@ -21,16 +33,32 @@ export function useWorkspaceKanbanSearch(args: { setQuery('') } - const matchingWorktreeIds = useMemo( + // Why: the input stays fully controlled and undebounced, but a query change + // mounts or unmounts every hidden card — clearing one costs about what opening + // the board costs. Deferring only the filter keeps the caret responsive and + // lets React interrupt the board re-render. + const deferredQuery = useDeferredValue(query) + + const matched = useMemo( () => matchWorkspaceBoardWorktrees({ worktrees: args.worktrees, - query, + query: deferredQuery, repoMap: args.repoMap }), - [args.repoMap, args.worktrees, query] + [args.repoMap, args.worktrees, deferredQuery] ) + // Why: board identities churn on agent-status ticks, so an unchanged match set + // must keep its identity or every memoized card re-renders on every tick. + const stableMatchedRef = useRef | null>(null) + const previousMatched = stableMatchedRef.current + const matchingWorktreeIds = + previousMatched && matched && areWorktreeIdSetsEqual(previousMatched, matched) + ? previousMatched + : matched + stableMatchedRef.current = matchingWorktreeIds + const clearQuery = useCallback(() => setQuery(''), []) return { 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 index 158e248e38c..4c68f93aad4 100644 --- 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 @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { resolveFullLaneDropIndex } from './workspace-kanban-filtered-drop-index' +import { + parseWorkspaceLaneFullIds, + resolveFullLaneDropIndex, + serializeWorkspaceLaneFullIds +} from './workspace-kanban-filtered-drop-index' const FULL = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] @@ -72,6 +76,18 @@ describe('resolveFullLaneDropIndex', () => { ).toBe(FULL.length) }) + it('still translates when a stale lane has the same length but different members', () => { + // Why: a length-only guard would take the identity branch here and skip + // translation, landing the card at an index that means nothing in FULL. + expect( + resolveFullLaneDropIndex({ + fullLaneIds: ['a', 'b', 'c'], + renderedIds: ['a', 'x', 'c'], + filteredDropIndex: 1 + }) + ).toBe(3) + }) + it('clamps out-of-range filtered indices to the first and last branches', () => { expect( resolveFullLaneDropIndex({ @@ -89,3 +105,25 @@ describe('resolveFullLaneDropIndex', () => { ).toBe(5) }) }) + +describe('workspace lane full-id channel', () => { + it('round-trips lane membership through the delimiter', () => { + const ids = ['repo-a::/Users/dev/projects/orca/main', 'repo-b::C:\\src\\atlas, v2'] + const serialized = serializeWorkspaceLaneFullIds(ids) + + expect(serialized).not.toBeNull() + expect(parseWorkspaceLaneFullIds(serialized ?? undefined)).toEqual(ids) + }) + + it('distinguishes an unpublished lane from an empty one', () => { + expect(parseWorkspaceLaneFullIds(undefined)).toBeNull() + expect(parseWorkspaceLaneFullIds('')).toEqual([]) + expect(serializeWorkspaceLaneFullIds([])).toBe('') + }) + + it('refuses to publish a lane whose ids would split on the delimiter', () => { + // Why: a newline is legal in a POSIX path. Publishing would invent phantom + // lane members; declining makes the reader fall back to scanning cards. + expect(serializeWorkspaceLaneFullIds(['repo-a::/Users/dev/we\nird', 'repo-a::/ok'])).toBeNull() + }) +}) 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 index de53250cf42..742efc2bcc9 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.ts @@ -2,7 +2,16 @@ // 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 { +/** + * Returns `null` when the lane cannot be represented on this channel, so the + * caller omits the attribute and the reader falls back to scanning cards. + * Why: a newline is legal in a POSIX path, and joining over one would silently + * split a single id into two phantom lane members. + */ +export function serializeWorkspaceLaneFullIds(worktreeIds: readonly string[]): string | null { + if (worktreeIds.some((worktreeId) => worktreeId.includes(WORKSPACE_LANE_FULL_IDS_DELIMITER))) { + return null + } return worktreeIds.join(WORKSPACE_LANE_FULL_IDS_DELIMITER) } @@ -29,7 +38,9 @@ export function resolveFullLaneDropIndex(args: { filteredDropIndex: number }): number { const { fullLaneIds, renderedIds, filteredDropIndex } = args - if (renderedIds.length === fullLaneIds.length) { + // Why: equal lengths alone would take this branch for a stale DOM lane that + // holds the same card count but different membership, skipping translation. + if (isSameLane(fullLaneIds, renderedIds)) { return filteredDropIndex } if (renderedIds.length === 0) { @@ -46,6 +57,13 @@ export function resolveFullLaneDropIndex(args: { return indexInFullLane(fullLaneIds, renderedIds[filteredDropIndex]!) } +function isSameLane(fullLaneIds: readonly string[], renderedIds: readonly string[]): boolean { + return ( + renderedIds.length === fullLaneIds.length && + renderedIds.every((worktreeId, index) => worktreeId === fullLaneIds[index]) + ) +} + // Why: a rendered id missing from the full lane is a stale-DOM race. 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 { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts index e6724562398..d5b42c45cde 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-search.test.ts @@ -80,6 +80,29 @@ describe('matchWorkspaceBoardWorktrees', () => { expect(match(worktrees, 'SEARCH')).toEqual(new Set(['a'])) }) + it('treats regex metacharacters as literal text', () => { + // Why: matching is indexOf, never RegExp. This pins that, so swapping in a + // regex later fails here instead of silently changing what users can search. + const worktrees = [ + worktree({ id: 'literal', displayName: 'feat.*fix' }), + worktree({ id: 'would-match-as-regex', displayName: 'featANYfix' }) + ] + + expect(match(worktrees, 'feat.*fix')).toEqual(new Set(['literal'])) + expect(match(worktrees, '(')).toEqual(new Set()) + }) + + it('matches non-ASCII display names and comments', () => { + const worktrees = [ + worktree({ id: 'cjk', displayName: '検索フィールド' }), + worktree({ id: 'accent', displayName: 'Other', comment: 'Añadir búsqueda' }), + worktree({ id: 'miss', displayName: 'Other' }) + ] + + expect(match(worktrees, 'フィールド')).toEqual(new Set(['cjk'])) + expect(match(worktrees, 'BÚSQUEDA')).toEqual(new Set(['accent'])) + }) + it('treats an over-bound query as no filtering rather than zero matches', () => { const worktrees = [worktree({ id: 'a', displayName: 'Search field' })] 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 f5daca23b7f..c48d5312ad1 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -48,18 +48,27 @@ 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[] { +function getLaneCardIds(lane: HTMLElement): HTMLElement[] { return Array.from(lane.querySelectorAll(CARD_SELECTOR)) +} + +// Mirrors getCardDropTarget's card scan so both sides share one index space. +// The offsetParent read forces layout, so callers pass the card list they +// already collected rather than re-querying. +function toRenderedCardIds(cards: readonly HTMLElement[]): string[] { + return cards .filter((card) => card.offsetParent !== null) .flatMap((card) => card.dataset.workspaceBoardCardId ?? []) } // Why: board search hides non-matching cards, so the rendered card scan is a // filtered lane. Lanes publish their full membership for exactly this reader. -function getLaneFullWorktreeIds(lane: HTMLElement): string[] { +// The fallback is the unfiltered card list — a lane member the browser is not +// laying out is still a member for manual-order purposes. +function toLaneFullWorktreeIds(lane: HTMLElement, cards: readonly HTMLElement[]): string[] { return ( - parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ?? getRenderedLaneCardIds(lane) + parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ?? + cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? []) ) } @@ -99,7 +108,7 @@ export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] { if (!status) { return [] } - return [{ key: status, worktreeIds: getLaneFullWorktreeIds(lane) }] + return [{ key: status, worktreeIds: toLaneFullWorktreeIds(lane, getLaneCardIds(lane)) }] }) } @@ -130,9 +139,10 @@ export function resolveWorkspaceKanbanSidebarFullLaneDropIndex( if (!lane) { return renderedDropIndex } + const cards = getLaneCardIds(lane) return resolveFullLaneDropIndex({ - fullLaneIds: getLaneFullWorktreeIds(lane), - renderedIds: getRenderedLaneCardIds(lane), + fullLaneIds: toLaneFullWorktreeIds(lane, cards), + renderedIds: toRenderedCardIds(cards), filteredDropIndex: renderedDropIndex }) }