diff --git a/src/background/__tests__/bulk-update-dispatch.test.ts b/src/background/__tests__/bulk-update-dispatch.test.ts index d16368d..60eb5df 100644 --- a/src/background/__tests__/bulk-update-dispatch.test.ts +++ b/src/background/__tests__/bulk-update-dispatch.test.ts @@ -21,14 +21,19 @@ vi.mock('@/background/concurrency', () => ({ })) vi.mock('@/lib/messages', () => ({ - onMessage: (type: string, handler: (typeof hoisted.handlers) extends Map ? H : never) => { + onMessage: ( + type: string, + handler: typeof hoisted.handlers extends Map ? H : never, + ) => { hoisted.handlers.set(type, handler) }, })) vi.mock('@/background/cache', () => ({ takeCachedResolvedItems: vi.fn() })) vi.mock('@/background/rest-helpers', () => ({ broadcastQueue: vi.fn(async () => {}) })) -vi.mock('@/background/relationship-helpers', () => ({ buildBulkRelationshipTasks: vi.fn(() => []) })) +vi.mock('@/background/relationship-helpers', () => ({ + buildBulkRelationshipTasks: vi.fn(() => []), +})) vi.mock('@/background/project-helpers', () => ({ resolveProjectItemIds: vi.fn(async () => []) })) vi.mock('@/lib/queue', () => ({ processQueue: vi.fn(async () => {}), sleep: vi.fn() })) vi.mock('@/lib/graphql-client', () => ({ gql: vi.fn() })) diff --git a/src/background/bulk-position.ts b/src/background/bulk-position.ts index c3a47a8..5c0e8b3 100644 --- a/src/background/bulk-position.ts +++ b/src/background/bulk-position.ts @@ -2,10 +2,7 @@ import { onMessage } from '@/lib/messages' import { gql } from '@/lib/graphql-client' -import { - GET_PROJECT_ITEMS_FOR_REORDER, - UPDATE_PROJECT_ITEM_POSITION, -} from '@/lib/graphql-queries' +import { GET_PROJECT_ITEMS_FOR_REORDER, UPDATE_PROJECT_ITEM_POSITION } from '@/lib/graphql-queries' import { processQueue, sleep } from '@/lib/queue' import type { QueueTask } from '@/lib/queue' import { logger } from '@/lib/debug-logger' diff --git a/src/background/duplicate-handlers.ts b/src/background/duplicate-handlers.ts index 9dec871..4ea248e 100644 --- a/src/background/duplicate-handlers.ts +++ b/src/background/duplicate-handlers.ts @@ -30,6 +30,9 @@ async function runDeepDuplicate( tabId?: number, plan?: DuplicateItemPlan, ) { + // Defensive backstop: the caller in registerDuplicateHandlers below already + // checks isDuplicateFull() before invoking this (and returns the verdict to + // the sender), so this only matters for other/future callers. if (isDuplicateFull()) { console.warn('[rgp:bg] max concurrent duplicates reached, rejecting new request') return @@ -416,6 +419,14 @@ export function registerDuplicateHandlers(): void { projectId: data.projectId, }) const tabId = sender.tab?.id - await runDeepDuplicate(data.itemId, data.projectId, tabId, data.plan) + // Decide acceptance synchronously (no await between this check and + // acquireDuplicate() inside runDeepDuplicate) so the verdict returned to + // the sender is truthful, then let the duplication run in the background. + if (isDuplicateFull()) { + console.warn('[rgp:bg] max concurrent duplicates reached, rejecting new request') + return { accepted: false } + } + void runDeepDuplicate(data.itemId, data.projectId, tabId, data.plan) + return { accepted: true } }) } diff --git a/src/background/project-service.ts b/src/background/project-service.ts index 783fae8..59f33db 100644 --- a/src/background/project-service.ts +++ b/src/background/project-service.ts @@ -1,7 +1,15 @@ import { Context, Effect, Layer } from 'effect' -import { listIssueRelationshipsSafe as listIssueRelationshipsSafeAsync, listSubIssuesSafe as listSubIssuesSafeAsync } from '@/background/relationship-helpers' -import { getProjectFieldsData as getProjectFieldsDataAsync, getRepositoryId as getRepositoryIdAsync, resolveProjectItemIds as resolveProjectItemIdsAsync, resolveProjectItemIdsWithTitles as resolveProjectItemIdsWithTitlesAsync } from '@/background/project-helpers' +import { + listIssueRelationshipsSafe as listIssueRelationshipsSafeAsync, + listSubIssuesSafe as listSubIssuesSafeAsync, +} from '@/background/relationship-helpers' +import { + getProjectFieldsData as getProjectFieldsDataAsync, + getRepositoryId as getRepositoryIdAsync, + resolveProjectItemIds as resolveProjectItemIdsAsync, + resolveProjectItemIdsWithTitles as resolveProjectItemIdsWithTitlesAsync, +} from '@/background/project-helpers' import type { FieldsResultProject, ResolvedItem, ResolvedItemWithTitle } from '@/background/types' import type { IssueRelationshipData, SubIssueData } from '@/lib/messages' diff --git a/src/background/relationship-helpers.ts b/src/background/relationship-helpers.ts index c1701a8..b81430a 100644 --- a/src/background/relationship-helpers.ts +++ b/src/background/relationship-helpers.ts @@ -20,15 +20,8 @@ import type { RelationshipSearchIssueNode, } from '@/background/types' -import { - githubRest, - parseRepoFromUrl, - withRateLimitRetry, -} from '@/background/rest-helpers' -import { - formatIssueReference, - relationshipKey, -} from '@/lib/relationship-utils' +import { githubRest, parseRepoFromUrl, withRateLimitRetry } from '@/background/rest-helpers' +import { formatIssueReference, relationshipKey } from '@/lib/relationship-utils' export { relationshipKey } from '@/lib/relationship-utils' diff --git a/src/background/sprint-handlers.ts b/src/background/sprint-handlers.ts index 3524c92..9e36677 100644 --- a/src/background/sprint-handlers.ts +++ b/src/background/sprint-handlers.ts @@ -9,7 +9,11 @@ import { allSprintSettingsStorage } from '@/lib/storage' import { todayUtc, isActive, nearestUpcoming, iterationEndDate } from '@/lib/sprint-utils' import { logger } from '@/lib/debug-logger' -import { sprintProgressCache, SPRINT_PROGRESS_CACHE_TTL_MS, pruneExpiredCache } from '@/background/cache' +import { + sprintProgressCache, + SPRINT_PROGRESS_CACHE_TTL_MS, + pruneExpiredCache, +} from '@/background/cache' import { isSprintEndFull, acquireSprintEnd, releaseSprintEnd } from '@/background/concurrency' @@ -46,15 +50,15 @@ export function registerSprintHandlers(): void { ] const today = todayUtc() + const withEndDate = [0]>( + iter: T, + ): T & { endDate: string } => ({ ...iter, endDate: iterationEndDate(iter) }) + const active = allIters.find((i) => isActive(i, today)) ?? null - const activeSprint: SprintInfo | null = active - ? { ...active, endDate: iterationEndDate(active) } - : null + const activeSprint: SprintInfo | null = active ? withEndDate(active) : null const upcoming = nearestUpcoming(iterField.configuration.iterations ?? [], today) - const nearestUpcomingSprint: SprintInfo | null = upcoming - ? { ...upcoming, endDate: iterationEndDate(upcoming) } - : null + const nearestUpcomingSprint: SprintInfo | null = upcoming ? withEndDate(upcoming) : null // check acknowledged sprint (if any) — clear stale IDs let acknowledgedSprint: SprintInfo | null = null @@ -63,7 +67,7 @@ export function registerSprintHandlers(): void { (i) => i.id === settings.acknowledgedSprintId, ) if (ackIter) { - acknowledgedSprint = { ...ackIter, endDate: iterationEndDate(ackIter) } + acknowledgedSprint = withEndDate(ackIter) } else { // stale — clear it const updated = { ...settings, acknowledgedSprintId: undefined } diff --git a/src/background/transfer-eligibility.ts b/src/background/transfer-eligibility.ts index b02597d..4fb30e0 100644 --- a/src/background/transfer-eligibility.ts +++ b/src/background/transfer-eligibility.ts @@ -9,7 +9,9 @@ export interface TransferEligibilityRow { title?: string } -export function unresolvedTransferEligibilityRows(itemIds: readonly string[]): TransferEligibilityRow[] { +export function unresolvedTransferEligibilityRows( + itemIds: readonly string[], +): TransferEligibilityRow[] { return itemIds.map((domId) => ({ domId, eligible: false, diff --git a/src/features/__tests__/bulk-duplicate-modal.test.tsx b/src/features/__tests__/bulk-duplicate-modal.test.tsx index 64ccb69..46f80aa 100644 --- a/src/features/__tests__/bulk-duplicate-modal.test.tsx +++ b/src/features/__tests__/bulk-duplicate-modal.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + drainPendingDuplicates, isAssigneesEdited, isFieldEdited, isLabelsEdited, @@ -134,3 +135,33 @@ describe('isFieldEdited', () => { expect(isFieldEdited({ ...baseIteration, iterationId: undefined }, baseIteration)).toBe(true) }) }) + +// §11.9 — race-window guard: `getActiveCount()` only reflects a duplicate +// once the BG's `queueStateUpdate` broadcast lands, one round-trip after the +// fire. `drainPendingDuplicates` tracks fires not yet reflected so rapid +// "Create more" clicks can't slip past the concurrency cap. +describe('drainPendingDuplicates', () => { + it('holds pending steady while the queue count has not moved (still in the lag window)', () => { + expect(drainPendingDuplicates(0, 0, 2)).toBe(2) + }) + + it('drains pending by the full rise once the queue catches up', () => { + expect(drainPendingDuplicates(0, 2, 2)).toBe(0) + }) + + it('drains only by the observed rise on a partial catch-up', () => { + expect(drainPendingDuplicates(1, 2, 2)).toBe(1) + }) + + it('never drains below zero when the rise exceeds pending', () => { + expect(drainPendingDuplicates(0, 5, 1)).toBe(0) + }) + + it('ignores a drop in active count (no negative rise)', () => { + expect(drainPendingDuplicates(2, 1, 2)).toBe(2) + }) + + it('clears any phantom once the queue is fully empty', () => { + expect(drainPendingDuplicates(3, 0, 2)).toBe(0) + }) +}) diff --git a/src/features/bulk-duplicate-modal.tsx b/src/features/bulk-duplicate-modal.tsx index 0a13a03..0fbf694 100644 --- a/src/features/bulk-duplicate-modal.tsx +++ b/src/features/bulk-duplicate-modal.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Tippy from '@/ui/tooltip' -import { Box, Button, Checkbox, Flash, FormControl, Text, TextInput } from '@primer/react' +import { Box, Button, Flash, FormControl, Text, TextInput } from '@primer/react' import { sendMessage, type DuplicateItemPlan, @@ -15,7 +15,6 @@ import { AlertIcon, ArrowRightIcon, CheckIcon, - CopyIcon, PersonIcon, ProjectBoardIcon, ShieldIcon, @@ -36,6 +35,7 @@ import { buildFieldValue, bulkDuplicateHeaderIcon, buttonMotionSx, + drainPendingDuplicates, duplicateValueTooltip, fieldSectionId, getFieldIcon, @@ -45,20 +45,19 @@ import { isLabelsEdited, isRelationshipsEdited, LABELS_SECTION_ID, + MAX_CONCURRENT_DUPLICATES, PARENT_SECTION_ID, prefixLabelIcon, - sectionGroupMeta, - sectionGroupOrder, - sectionLabel, TITLE_SECTION_ID, type DuplicateSection, type EditableField, - type SectionGroup, type SectionId, type Step, } from '@/features/bulk-duplicate-utils' import { RelationshipListEditor } from '@/features/bulk-duplicate-relationship-list' +import { ReviewStep, SelectSectionsStep } from '@/features/bulk-duplicate-steps' + interface Props { itemId: string projectId: string @@ -68,308 +67,12 @@ interface Props { onClose: () => void } -function SelectSectionsStep({ - sections, - selectedSections, - onToggleSection, - onSelectAll, - onDeselectAll, - onClose, - onNext, -}: { - sections: DuplicateSection[] - selectedSections: SectionId[] - onToggleSection: (sectionId: SectionId) => void - onSelectAll: () => void - onDeselectAll: () => void - onClose: () => void - onNext: () => void -}) { - const allSelected = - sections.length > 0 && sections.every((section) => selectedSections.includes(section.id)) - - return ( - <> - - - - - If Title is skipped, the duplicate falls back to the original title. - - - - {sectionGroupOrder.map((group) => { - const groupSections = sections.filter((section) => section.group === group) - if (groupSections.length === 0) return null - - return ( - - - - {sectionGroupMeta[group].icon} - - {sectionGroupMeta[group].label} - - - {groupSections.map((section) => { - const isSelected = selectedSections.includes(section.id) - return ( - onToggleSection(section.id)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 3, - width: '100%', - textAlign: 'left', - border: 'none', - borderRadius: 2, - bg: isSelected ? 'accent.subtle' : 'transparent', - px: 3, - py: 2, - cursor: 'pointer', - transition: 'background-color 150ms ease', - ':hover': { bg: isSelected ? 'accent.subtle' : 'canvas.subtle' }, - '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, - }} - > - {}} - sx={{ pointerEvents: 'none' }} - /> - - - {section.icon} - - - - {section.label} - - {section.helperText && ( - - {section.helperText} - - )} - - - {section.badge && ( - - {section.badge} - - )} - - ) - })} - - - ) - })} - - - - - - ) -} - /** * §11.2/§11.6 — combined REVIEW step. Renders each selected section's inline * editor (via `renderSection`) with a `· edited` / `· same as source` diff * badge in the row label. Footer hosts Back + Duplicate so the user confirms * in the same screen they edit in. */ -function ReviewStep({ - sections, - diffStatus, - concurrentError, - duplicateBtnRef, - onClose, - onBack, - onDuplicate, - renderSection, -}: { - sections: DuplicateSection[] - diffStatus: (sectionId: SectionId) => 'edited' | 'same' - concurrentError: boolean - duplicateBtnRef: React.RefObject - onClose: () => void - onBack: () => void - onDuplicate: () => void - renderSection: (section: DuplicateSection) => React.ReactNode -}) { - return ( - <> - - - {sections.length === 0 ? ( - - No sections selected. The duplicate will use the original title only. - - ) : ( - sectionGroupOrder.map((group) => { - const groupSections = sections.filter((section) => section.group === group) - if (groupSections.length === 0) return null - - return ( - - - - {sectionGroupMeta[group].icon} - - {sectionGroupMeta[group].label} - - {groupSections.map((section) => ( - - {renderSection(section)} - {/* §11.6 — diff badge appears under each editor so the user - sees at a glance which rows are edited vs unchanged. */} - - {diffStatus(section.id) === 'edited' ? '· edited' : '· same as source'} - - - ))} - - ) - }) - )} - - - {concurrentError && ( - - 3 duplications are already in progress. Wait for one to finish before starting another. - - )} - - - - - - - ) -} export function BulkDuplicateModal({ itemId, @@ -383,7 +86,19 @@ export function BulkDuplicateModal({ const [preview, setPreview] = useState(null) const [error, setError] = useState('') const [concurrentError, setConcurrentError] = useState(false) + const [createMore, setCreateMore] = useState(false) + // Lock the single-copy submit path while a non-Create-more request awaits + // its verdict, so repeated clicks can't queue extra copies in a mode that's + // meant to create exactly one and close (§cubic-dev-ai PR #50). + const [submitting, setSubmitting] = useState(false) const duplicateBtnRef = useRef(null) + // Race-window guard (§cubic-dev-ai): `queueStore.getActiveCount()` only + // reflects a fired duplicate once the BG's `queueStateUpdate` broadcast + // lands, one round-trip after the fire. Rapid "Create more" clicks can fire + // ahead of that broadcast, so track just-fired-but-not-yet-reflected + // duplicates locally and drain the tally as the real count catches up. + const pendingDuplicatesRef = useRef(0) + const prevActiveCountRef = useRef(0) const [selectedSections, setSelectedSections] = useState([]) const [editedTitle, setEditedTitle] = useState('') @@ -398,35 +113,57 @@ export function BulkDuplicateModal({ ensureTippyCss() }, []) + // Drain the pending-duplicates tally as `queueStore` catches up. `subscribe` + // pushes the current snapshot immediately, so `prevActiveCountRef` + // bootstraps to the real global count even if other duplicates are already + // active when this modal opens. + useEffect(() => { + return queueStore.subscribe(() => { + const nowActive = queueStore.getActiveCount() + pendingDuplicatesRef.current = drainPendingDuplicates( + prevActiveCountRef.current, + nowActive, + pendingDuplicatesRef.current, + ) + prevActiveCountRef.current = nowActive + }) + }, []) + + // Reset edit state to the source item's defaults. Shared by the initial + // preview load and the "Create more" re-arm (both revert to source defaults). + const applyPreviewDefaults = useCallback((data: ItemPreviewData) => { + // §11.5 — default duplicated title is ` (copy)` editable inline. + setEditedTitle(`${data.title} (copy)`) + setEditedBody(data.body) + setEditedAssignees( + data.assignees.map((assignee) => ({ + id: assignee.id, + name: assignee.login, + avatarUrl: assignee.avatarUrl, + })), + ) + setEditedLabels(data.labels) + setEditedFields(data.fields) + setBlockedByRelationships(data.relationships.blockedBy) + setBlockingRelationships(data.relationships.blocking) + // §11.4 — default Content (Title, Body) + Metadata (Assignees, Labels, + // Issue Type) + every Project Field; Relationships (Parent, Blocked-by, + // Blocking) unchecked by default — user opts in explicitly. + setSelectedSections([ + TITLE_SECTION_ID, + BODY_SECTION_ID, + ASSIGNEES_SECTION_ID, + LABELS_SECTION_ID, + ...(data.issueTypeName ? [ISSUE_TYPE_SECTION_ID] : []), + ...data.fields.map((field) => fieldSectionId(field.fieldId)), + ]) + }, []) + useEffect(() => { sendMessage('getItemPreview', { itemId, owner, number: projectNumber, isOrg }) .then((data) => { setPreview(data) - // §11.5 — default duplicated title is ` (copy)` editable inline. - setEditedTitle(`${data.title} (copy)`) - setEditedBody(data.body) - setEditedAssignees( - data.assignees.map((assignee) => ({ - id: assignee.id, - name: assignee.login, - avatarUrl: assignee.avatarUrl, - })), - ) - setEditedLabels(data.labels) - setEditedFields(data.fields) - setBlockedByRelationships(data.relationships.blockedBy) - setBlockingRelationships(data.relationships.blocking) - // §11.4 — default Content (Title, Body) + Metadata (Assignees, Labels, - // Issue Type) + every Project Field; Relationships (Parent, Blocked-by, - // Blocking) unchecked by default — user opts in explicitly. - setSelectedSections([ - TITLE_SECTION_ID, - BODY_SECTION_ID, - ASSIGNEES_SECTION_ID, - LABELS_SECTION_ID, - ...(data.issueTypeName ? [ISSUE_TYPE_SECTION_ID] : []), - ...data.fields.map((field) => fieldSectionId(field.fieldId)), - ]) + applyPreviewDefaults(data) setStep('SELECT') }) .catch((cause: Error) => { @@ -434,7 +171,7 @@ export function BulkDuplicateModal({ setError(cause.message || 'Failed to load item details') setStep('ERROR') }) - }, [isOrg, itemId, owner, projectNumber]) + }, [isOrg, itemId, owner, projectNumber, applyPreviewDefaults]) const availableSections = useMemo(() => { if (!preview) return [] @@ -675,24 +412,50 @@ export function BulkDuplicateModal({ return isFieldEdited(field, sourceField) ? 'edited' : 'same' } - async function handleDuplicate() { + function handleDuplicate() { if (!preview) return - if (queueStore.getActiveCount() >= 3) { + if (submitting) return + if (queueStore.getActiveCount() + pendingDuplicatesRef.current >= MAX_CONCURRENT_DUPLICATES) { setConcurrentError(true) return } setConcurrentError(false) + pendingDuplicatesRef.current += 1 + if (!createMore) setSubmitting(true) const rect = duplicateBtnRef.current?.getBoundingClientRect() if (rect) flyToTracker(rect) - await sendMessage('duplicateItem', { + // Fire-and-forget: the duplication runs to completion in the background SW + // regardless of the modal's lifecycle; errors surface via the queue tracker. + // The background handler still returns an immediate accept/reject verdict + // (rejected when its concurrency gate is saturated, e.g. by another tab) + // so we can roll back the optimistic tally above instead of leaking it. + void sendMessage('duplicateItem', { itemId: preview.resolvedItemId || itemId, projectId: preview.projectId || projectId, plan: buildDuplicatePlan(), }) - - onClose() + .then((result) => { + if (!result?.accepted) { + pendingDuplicatesRef.current = Math.max(0, pendingDuplicatesRef.current - 1) + setConcurrentError(true) + setSubmitting(false) + return + } + // Accepted: re-arm for another (Create more) or hand off and close. + if (createMore) { + applyPreviewDefaults(preview) + } else { + onClose() + } + }) + .catch((cause: Error) => { + pendingDuplicatesRef.current = Math.max(0, pendingDuplicatesRef.current - 1) + setConcurrentError(true) + setSubmitting(false) + console.error('[rgp] duplicateItem failed', cause) + }) } function renderValueSection(section: DuplicateSection): React.ReactNode { @@ -1477,6 +1240,9 @@ export function BulkDuplicateModal({ diffStatus={diffStatus} concurrentError={concurrentError} duplicateBtnRef={duplicateBtnRef} + submitting={submitting} + createMore={createMore} + onToggleCreateMore={() => setCreateMore((value) => !value)} onClose={onClose} onBack={() => setStep('SELECT')} onDuplicate={handleDuplicate} diff --git a/src/features/bulk-duplicate-steps.tsx b/src/features/bulk-duplicate-steps.tsx new file mode 100644 index 0000000..01b43c4 --- /dev/null +++ b/src/features/bulk-duplicate-steps.tsx @@ -0,0 +1,333 @@ +import React from 'react' +import { Box, Button, Checkbox, Flash, Text } from '@primer/react' +import { CopyIcon } from '@/ui/icons' +import { ModalStepHeader } from '@/ui/modal-step-header' +import { + bulkDuplicateHeaderIcon, + buttonMotionSx, + prefixLabelIcon, + sectionGroupMeta, + sectionGroupOrder, + sectionLabel, + type DuplicateSection, + type SectionId, +} from '@/features/bulk-duplicate-utils' + +export function SelectSectionsStep({ + sections, + selectedSections, + onToggleSection, + onSelectAll, + onDeselectAll, + onClose, + onNext, +}: { + sections: DuplicateSection[] + selectedSections: SectionId[] + onToggleSection: (sectionId: SectionId) => void + onSelectAll: () => void + onDeselectAll: () => void + onClose: () => void + onNext: () => void +}) { + const allSelected = + sections.length > 0 && sections.every((section) => selectedSections.includes(section.id)) + + return ( + <> + + + + + If Title is skipped, the duplicate falls back to the original title. + + + + {sectionGroupOrder.map((group) => { + const groupSections = sections.filter((section) => section.group === group) + if (groupSections.length === 0) return null + + return ( + + + + {sectionGroupMeta[group].icon} + + {sectionGroupMeta[group].label} + + + {groupSections.map((section) => { + const isSelected = selectedSections.includes(section.id) + return ( + onToggleSection(section.id)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 3, + width: '100%', + textAlign: 'left', + border: 'none', + borderRadius: 2, + bg: isSelected ? 'accent.subtle' : 'transparent', + px: 3, + py: 2, + cursor: 'pointer', + transition: 'background-color 150ms ease', + ':hover': { bg: isSelected ? 'accent.subtle' : 'canvas.subtle' }, + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + }} + > + {}} + sx={{ pointerEvents: 'none' }} + /> + + + {section.icon} + + + + {section.label} + + {section.helperText && ( + + {section.helperText} + + )} + + + {section.badge && ( + + {section.badge} + + )} + + ) + })} + + + ) + })} + + + + + + ) +} + +export function ReviewStep({ + sections, + diffStatus, + concurrentError, + duplicateBtnRef, + submitting, + createMore, + onToggleCreateMore, + onClose, + onBack, + onDuplicate, + renderSection, +}: { + sections: DuplicateSection[] + diffStatus: (sectionId: SectionId) => 'edited' | 'same' + concurrentError: boolean + duplicateBtnRef: React.RefObject + submitting: boolean + createMore: boolean + onToggleCreateMore: () => void + onClose: () => void + onBack: () => void + onDuplicate: () => void + renderSection: (section: DuplicateSection) => React.ReactNode +}) { + return ( + <> + + + {sections.length === 0 ? ( + + No sections selected. The duplicate will use the original title only. + + ) : ( + sectionGroupOrder.map((group) => { + const groupSections = sections.filter((section) => section.group === group) + if (groupSections.length === 0) return null + + return ( + + + + {sectionGroupMeta[group].icon} + + {sectionGroupMeta[group].label} + + {groupSections.map((section) => ( + + {renderSection(section)} + {/* §11.6 — diff badge appears under each editor so the user + sees at a glance which rows are edited vs unchanged. */} + + {diffStatus(section.id) === 'edited' ? '· edited' : '· same as source'} + + + ))} + + ) + }) + )} + + + {concurrentError && ( + + 3 duplications are already in progress. Wait for one to finish before starting another. + + )} + + + + + + Create more + + + + + + + ) +} diff --git a/src/features/bulk-duplicate-utils.tsx b/src/features/bulk-duplicate-utils.tsx index 18f6981..c70feb9 100644 --- a/src/features/bulk-duplicate-utils.tsx +++ b/src/features/bulk-duplicate-utils.tsx @@ -234,6 +234,32 @@ export function isFieldEdited(current: EditableField, source: EditableField): bo } } +/** Max duplicates the background SW runs at once (`MAX_CONCURRENT_DUPLICATES` + * in `src/background/concurrency.ts`) — kept in sync manually since the two + * files live in separate bundles (content script vs. background SW). */ +export const MAX_CONCURRENT_DUPLICATES = 3 + +/** + * `queueStore.getActiveCount()` only reflects a duplicate once the + * background SW's `queueStateUpdate` broadcast lands, one round-trip after + * the fire. Rapid "Create more" clicks fire ahead of that broadcast, so a + * local pending tally covers the gap; this drains it as the real count + * catches up (and clears any phantom once the queue is empty). + */ +export function drainPendingDuplicates( + prevActive: number, + nowActive: number, + pending: number, +): number { + // A drop to empty (not merely "still empty") means the queue just finished + // — any leftover pending tally is a phantom (e.g. a fire the BG silently + // dropped), so clear it. `prevActive === 0 && nowActive === 0` is the + // ordinary lag window and must NOT be treated as a completion. + if (nowActive === 0 && prevActive > 0) return 0 + const rise = Math.max(0, nowActive - prevActive) + return Math.max(0, pending - rise) +} + export function buildFieldValue(field: EditableField): Record { if (field.dataType === 'TEXT') return { text: field.text ?? '' } if (field.dataType === 'SINGLE_SELECT') diff --git a/src/features/bulk-edit-field-row.tsx b/src/features/bulk-edit-field-row.tsx new file mode 100644 index 0000000..ce2725c --- /dev/null +++ b/src/features/bulk-edit-field-row.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { ActionList, Box } from '@primer/react' +import { getFieldIcon, type ProjectField } from '@/features/bulk-edit-utils' + +export function SectionHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +export interface FieldRowProps { + field: ProjectField + onPick: (field: ProjectField) => void +} + +export function FieldRow({ field, onPick }: FieldRowProps) { + return ( + onPick(field)} data-testid={`rgp-edit-field-${field.id}`}> + + {getFieldIcon(field.dataType)} + + {field.name} + + {field.dataType.toLowerCase().replace(/_/g, ' ')} + + + ) +} diff --git a/src/features/bulk-edit-flyout-helpers.ts b/src/features/bulk-edit-flyout-helpers.ts index 308e70d..e208312 100644 --- a/src/features/bulk-edit-flyout-helpers.ts +++ b/src/features/bulk-edit-flyout-helpers.ts @@ -135,3 +135,15 @@ export async function submitBulkFieldUpdate(args: { return { ok: false, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE } } } + +export function firstRepoNameFromDom(owner: string): string | null { + if (typeof document === 'undefined') return null + const links = document.querySelectorAll( + 'a[href*="/issues/"], a[href*="/pull/"]', + ) + for (const link of Array.from(links)) { + const match = link.href.match(/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/\d+/) + if (match && match[1] === owner) return match[2] + } + return null +} diff --git a/src/features/bulk-edit-flyout.tsx b/src/features/bulk-edit-flyout.tsx index e783512..d6a5ca4 100644 --- a/src/features/bulk-edit-flyout.tsx +++ b/src/features/bulk-edit-flyout.tsx @@ -3,25 +3,14 @@ // Pane 2: per-`dataType` value picker or operation-first relationship editor. import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - ActionList, - Avatar, - Box, - Checkbox, - Flash, - Radio, - RadioGroup, - Spinner, - Text, - TextInput, - Textarea, -} from '@primer/react' +import { ActionList, Box, Flash, Text, TextInput } from '@primer/react' +import { ValuePicker } from '@/features/bulk-edit-value-picker' +import { FieldRow, SectionHeader } from '@/features/bulk-edit-field-row' import { SearchIcon } from '@/ui/icons' import { BulkFlyout, type BulkFlyoutPane, useDrilldownPane } from '@/ui/bulk-flyout' import { sendMessage } from '@/lib/messages' import { buildFieldCatalog, - getFieldIcon, isRelationshipFieldId, partitionFieldList, relationshipKeyFromFieldId, @@ -32,6 +21,7 @@ import { defaultValueFor, submitBulkFieldUpdate, type FieldValue, + firstRepoNameFromDom, } from '@/features/bulk-edit-flyout-helpers' import { BulkEditRelationshipPane, @@ -423,295 +413,3 @@ export function BulkEditFlyout({ /> ) } - -function SectionHeader({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -interface FieldRowProps { - field: ProjectField - onPick: (field: ProjectField) => void -} - -function FieldRow({ field, onPick }: FieldRowProps) { - return ( - onPick(field)} data-testid={`rgp-edit-field-${field.id}`}> - - {getFieldIcon(field.dataType)} - - {field.name} - - {field.dataType.toLowerCase().replace(/_/g, ' ')} - - - ) -} - -interface ValuePickerProps { - field: ProjectField - value: FieldValue | null - onChange: (next: FieldValue) => void - metaQuery: string - setMetaQuery: (q: string) => void - metaResults: Array<{ id: string; name: string; avatarUrl?: string }> - metaLoading: boolean -} - -function ValuePicker({ - field, - value, - onChange, - metaQuery, - setMetaQuery, - metaResults, - metaLoading, -}: ValuePickerProps) { - const dataType = field.dataType - - if (dataType === 'TEXT' || dataType === 'TITLE') { - return ( - - - {field.name} - - ) => - onChange({ kind: 'text', text: e.target.value }) - } - aria-label={field.name} - sx={{ width: '100%' }} - data-testid="rgp-edit-value-text" - /> - - ) - } - - if (dataType === 'BODY' || dataType === 'COMMENT') { - return ( - - - {field.name} - -