diff --git a/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx b/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx index c4eba432ca..3883db1eda 100644 --- a/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx +++ b/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx @@ -293,6 +293,44 @@ describe('CustomThemeBuilder', () => { expect(setCustomThemeBaseId).toHaveBeenCalledWith('monokai'); }); }); + + it('should import valid theme when the Option constructor is unavailable', async () => { + const originalOption = window.Option; + Object.defineProperty(window, 'Option', { + configurable: true, + value: undefined, + }); + + try { + render( + + ); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = createFileFromJSON(createValidThemeJSON()); + await simulateFileUpload(fileInput, file); + + await waitFor(() => { + expect(setCustomThemeColors).toHaveBeenCalledWith(mockThemeColors); + expect(onImportSuccess).toHaveBeenCalledWith('Theme imported successfully'); + }); + } finally { + Object.defineProperty(window, 'Option', { + configurable: true, + value: originalOption, + }); + } + }); }); describe('Color Validation', () => { diff --git a/src/__tests__/renderer/components/DocumentsPanel/DocumentsPanel.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/DocumentsPanel.test.tsx new file mode 100644 index 0000000000..26a9178da1 --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/DocumentsPanel.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { DocumentsPanel } from '../../../../renderer/components/DocumentsPanel'; +import type { BatchDocumentEntry } from '../../../../renderer/types'; +import { LayerStackProvider } from '../../../../renderer/contexts/LayerStackContext'; +import { mockTheme } from '../../../helpers/mockTheme'; + +function TestHost({ + initialDocuments = [{ id: '1', filename: 'alpha', resetOnCompletion: false }], +}: { + initialDocuments?: BatchDocumentEntry[]; +}) { + const [documents, setDocuments] = React.useState(initialDocuments); + const [loopEnabled, setLoopEnabled] = React.useState(false); + const [maxLoops, setMaxLoops] = React.useState(null); + + return ( + + + + ); +} + +describe('DocumentsPanel', () => { + it('exports and renders the public component', () => { + expect(DocumentsPanel).toBeDefined(); + render(); + + expect(screen.getByText('Documents to Run')).toBeInTheDocument(); + expect(screen.getByText('alpha.md')).toBeInTheDocument(); + }); + + it('opens selector and adds newly selected documents', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add Docs' })); + expect(screen.getByText('Select Documents')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('beta.md')); + fireEvent.click(screen.getByRole('button', { name: /Add 2 files/ })); + + await waitFor(() => expect(screen.queryByText('Select Documents')).not.toBeInTheDocument()); + expect(screen.getByText('alpha.md')).toBeInTheDocument(); + expect(screen.getByText('beta.md')).toBeInTheDocument(); + }); + + it('wires row actions and loop controls', () => { + render( + + ); + + fireEvent.click(screen.getAllByTitle(/Enable reset/)[0]); + expect(screen.getByTitle(/Reset enabled/)).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Loop')); + expect(screen.getByText('∞')).toBeInTheDocument(); + fireEvent.click(screen.getByText('max')); + expect(screen.getByRole('slider')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/components/DocumentList.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/components/DocumentList.test.tsx new file mode 100644 index 0000000000..c21cd6c34b --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/components/DocumentList.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { DocumentList } from '../../../../../renderer/components/DocumentsPanel/components/DocumentList'; +import type { BatchDocumentEntry } from '../../../../../renderer/types'; +import { mockTheme } from '../../../../helpers/mockTheme'; + +const documents: BatchDocumentEntry[] = [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'beta', resetOnCompletion: true }, + { id: '3', filename: 'missing', resetOnCompletion: false, isMissing: true }, +]; + +const handlers = { + handleDragStart: vi.fn(), + handleDrag: vi.fn(), + handleDragOver: vi.fn(), + handleDragLeave: vi.fn(), + handleDrop: vi.fn(), + handleDragEnd: vi.fn(), + onRemoveDocument: vi.fn(), + onToggleReset: vi.fn(), + onDuplicateDocument: vi.fn(), +}; + +function renderList(overrides = {}) { + const props = { + theme: mockTheme, + documents, + taskCounts: { alpha: 0, beta: 4 }, + loadingTaskCounts: false, + loopEnabled: false, + draggedId: null, + dropTargetIndex: null, + isCopyDrag: false, + ...handlers, + ...overrides, + }; + render(); + return props; +} + +describe('DocumentList', () => { + it('renders empty state', () => { + renderList({ documents: [] }); + + expect(screen.getByText('No documents selected')).toBeInTheDocument(); + expect(screen.getByText(/Load a playbook/)).toBeInTheDocument(); + }); + + it('renders rows, task badges, missing state, and actions', () => { + const props = renderList(); + + expect(screen.getByText('alpha.md')).toBeInTheDocument(); + expect(screen.getAllByText('0 tasks').length).toBeGreaterThan(0); + expect(screen.getByText('4 tasks')).toBeInTheDocument(); + expect(screen.getByText('Missing')).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle(/Enable reset/)); + expect(props.onToggleReset).toHaveBeenCalledWith('1'); + + fireEvent.click(screen.getByTitle('Duplicate document')); + expect(props.onDuplicateDocument).toHaveBeenCalledWith('2'); + + fireEvent.click(screen.getByTitle('Remove missing document')); + expect(props.onRemoveDocument).toHaveBeenCalledWith('3'); + }); + + it('shows loop path and drop indicators', () => { + renderList({ + loopEnabled: true, + draggedId: '1', + dropTargetIndex: 1, + isCopyDrag: true, + }); + + expect(document.querySelector('.ml-7')).toBeInTheDocument(); + expect(document.querySelector('.absolute.left-0.right-0')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/components/DocumentSelectorModal.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/components/DocumentSelectorModal.test.tsx new file mode 100644 index 0000000000..9cd8d7036d --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/components/DocumentSelectorModal.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { DocumentSelectorModal } from '../../../../../renderer/components/DocumentsPanel/components/DocumentSelectorModal'; +import type { DocTreeNode } from '../../../../../renderer/components/DocumentsPanel'; +import { LayerStackProvider } from '../../../../../renderer/contexts/LayerStackContext'; +import type { BatchDocumentEntry } from '../../../../../renderer/types'; +import { mockTheme } from '../../../../helpers/mockTheme'; + +const documents: BatchDocumentEntry[] = [{ id: '1', filename: 'alpha', resetOnCompletion: false }]; + +const tree: DocTreeNode[] = [ + { + name: 'folder', + type: 'folder', + path: 'folder', + children: [{ name: 'nested', type: 'file', path: 'folder/nested' }], + }, +]; + +function renderModal(overrides = {}) { + const props = { + theme: mockTheme, + allDocuments: ['alpha', 'beta'], + taskCounts: { alpha: 2, beta: 0, 'folder/nested': 3 }, + loadingTaskCounts: false, + documents, + onClose: vi.fn(), + onAdd: vi.fn(), + onRefresh: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; + render( + + + + ); + return props; +} + +describe('DocumentSelectorModal', () => { + it('renders flat documents, counts, and add footer', () => { + const props = renderModal(); + const dialog = screen.getByRole('dialog'); + + expect(screen.getByText('Select Documents')).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveClass('select-none'); + expect(screen.getAllByText('2 tasks').length).toBeGreaterThan(0); + expect(screen.getByText('alpha.md')).toBeInTheDocument(); + expect(screen.getByText('beta.md')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('beta.md')); + fireEvent.click(screen.getByRole('button', { name: /Add 2 files/ })); + + expect(props.onAdd).toHaveBeenCalledWith(new Set(['alpha', 'beta'])); + }); + + it('renders tree folders and toggles nested files', () => { + renderModal({ allDocuments: ['folder/nested'], documentTree: tree, documents: [] }); + + expect(screen.getByText('folder')).toBeInTheDocument(); + expect(screen.queryByText('nested.md')).not.toBeInTheDocument(); + + const expandButton = screen.getByRole('button', { name: 'Expand folder folder' }); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(expandButton); + expect(screen.getByRole('button', { name: 'Collapse folder folder' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(screen.getByText('nested.md')).toBeInTheDocument(); + expect(screen.getAllByText('3 tasks').length).toBeGreaterThan(0); + }); + + it('shows empty state and closes from footer cancel and Escape', async () => { + const props = renderModal({ allDocuments: [], documents: [] }); + + expect(screen.getByText('No documents found in folder')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onClose).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(props.onClose).toHaveBeenCalledTimes(2)); + }); + + it('refreshes and closes on backdrop without closing for inner content clicks', () => { + const props = renderModal(); + const modal = screen.getByText('Select Documents').closest('.fixed')!; + const content = within(modal).getByText('alpha.md'); + + fireEvent.click(content); + expect(props.onClose).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTitle('Refresh document list')); + expect(props.onRefresh).toHaveBeenCalledTimes(1); + + fireEvent.click(modal); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/components/LoopControls.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/components/LoopControls.test.tsx new file mode 100644 index 0000000000..83d94dc53c --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/components/LoopControls.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LoopControls } from '../../../../../renderer/components/DocumentsPanel/components/LoopControls'; +import type { BatchDocumentEntry } from '../../../../../renderer/types'; +import { mockTheme } from '../../../../helpers/mockTheme'; + +const docs: BatchDocumentEntry[] = [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'beta', resetOnCompletion: false }, +]; + +function renderControls(overrides = {}) { + const props = { + theme: mockTheme, + documents: docs, + loopEnabled: false, + setLoopEnabled: vi.fn(), + maxLoops: null, + setMaxLoops: vi.fn(), + totalTaskCount: 6, + missingDocCount: 0, + hasMissingDocs: false, + loadingTaskCounts: false, + ...overrides, + }; + render(); + return props; +} + +describe('LoopControls', () => { + it('shows a one-document hint and hides loop controls', () => { + renderControls({ documents: [docs[0]] }); + + expect(screen.getByText('You can enable loops with two or more documents')).toBeInTheDocument(); + expect(screen.queryByText('Loop')).not.toBeInTheDocument(); + }); + + it('shows the loop hint for zero documents', () => { + renderControls({ documents: [] }); + + expect(screen.getByText('You can enable loops with two or more documents')).toBeInTheDocument(); + expect(screen.queryByText('Loop')).not.toBeInTheDocument(); + }); + + it('toggles loop and shows summary for available documents', () => { + const props = renderControls(); + + fireEvent.click(screen.getByText('Loop')); + expect(props.setLoopEnabled).toHaveBeenCalledWith(true); + expect(screen.getByText(/Total: 6 tasks across 2 documents/)).toBeInTheDocument(); + }); + + it('switches between infinite and max loop controls', () => { + const props = renderControls({ loopEnabled: true, maxLoops: null }); + + fireEvent.click(screen.getByText('max')); + expect(props.setMaxLoops).toHaveBeenCalledWith(5); + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + }); + + it('updates slider value when max mode is active', () => { + const props = renderControls({ loopEnabled: true, maxLoops: 5 }); + + const slider = screen.getByRole('slider'); + expect(slider).toHaveAttribute('min', '1'); + expect(slider).toHaveAttribute('max', '25'); + fireEvent.change(slider, { target: { value: '12' } }); + + expect(props.setMaxLoops).toHaveBeenCalledWith(12); + fireEvent.click(screen.getByText('∞')); + expect(props.setMaxLoops).toHaveBeenCalledWith(null); + }); + + it('summarizes missing documents', () => { + renderControls({ + documents: [ + ...docs, + { id: '3', filename: 'gone', resetOnCompletion: false, isMissing: true }, + ], + missingDocCount: 1, + hasMissingDocs: true, + }); + + expect( + screen.getByText(/Total: 6 tasks across 2 available documents \(1 missing\)/) + ).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentDragReorder.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentDragReorder.test.tsx new file mode 100644 index 0000000000..a39201d3e8 --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentDragReorder.test.tsx @@ -0,0 +1,163 @@ +import { act, renderHook } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDocumentDragReorder } from '../../../../../renderer/components/DocumentsPanel/hooks/useDocumentDragReorder'; +import type { BatchDocumentEntry } from '../../../../../renderer/types'; + +vi.mock('../../../../../renderer/utils/ids', () => ({ + generateId: vi.fn(() => 'copy-id'), +})); + +const initialDocs: BatchDocumentEntry[] = [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'beta', resetOnCompletion: false }, + { id: '3', filename: 'gamma', resetOnCompletion: false }, +]; + +function dragEvent( + options: { + ctrlKey?: boolean; + metaKey?: boolean; + clientY?: number; + top?: number; + height?: number; + relatedTarget?: Node | null; + contains?: (node: Node) => boolean; + } = {} +): React.DragEvent { + const { + ctrlKey = false, + metaKey = false, + clientY = 0, + top = 0, + height = 20, + relatedTarget = null, + contains = () => false, + } = options; + return { + ctrlKey, + metaKey, + clientX: 10, + clientY, + relatedTarget, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { + effectAllowed: 'move', + dropEffect: 'move', + }, + currentTarget: { + getBoundingClientRect: () => ({ top, height }), + contains, + }, + } as unknown as React.DragEvent; +} + +function setup(docs = initialDocs) { + let currentDocs = docs; + const setDocuments = vi.fn((next: React.SetStateAction) => { + currentDocs = typeof next === 'function' ? next(currentDocs) : next; + }); + const hook = renderHook( + ({ documents }) => + useDocumentDragReorder({ + documents, + setDocuments, + }), + { initialProps: { documents: currentDocs } } + ); + return { + ...hook, + getDocs: () => currentDocs, + setDocuments, + rerenderWithCurrent: () => hook.rerender({ documents: currentDocs }), + }; +} + +describe('useDocumentDragReorder', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('moves a document down with adjusted insertion index', () => { + const { result, getDocs } = setup(); + + act(() => result.current.handleDragStart(dragEvent(), '1')); + act(() => result.current.handleDragOver(dragEvent({ clientY: 30 }), '3', 2)); + act(() => result.current.handleDragEnd()); + + expect(getDocs().map((doc) => doc.id)).toEqual(['2', '3', '1']); + expect(result.current.draggedId).toBeNull(); + }); + + it('does not move when dropped in the same position', () => { + const { result, getDocs, setDocuments } = setup(); + + act(() => result.current.handleDragStart(dragEvent(), '2')); + act(() => result.current.handleDragOver(dragEvent({ clientY: 1 }), '2', 1)); + act(() => result.current.handleDragEnd()); + + expect(setDocuments).not.toHaveBeenCalled(); + expect(getDocs().map((doc) => doc.id)).toEqual(['1', '2', '3']); + }); + + it('copies a document and enables reset for every same-filename row', () => { + const docs: BatchDocumentEntry[] = [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'alpha', resetOnCompletion: false, isDuplicate: true }, + { id: '3', filename: 'beta', resetOnCompletion: false }, + ]; + const { result, getDocs } = setup(docs); + + act(() => result.current.handleDragStart(dragEvent({ metaKey: true }), '1')); + act(() => result.current.handleDragOver(dragEvent({ metaKey: true, clientY: 30 }), '3', 2)); + act(() => result.current.handleDragEnd()); + + expect(getDocs()).toEqual([ + { id: '1', filename: 'alpha', resetOnCompletion: true }, + { id: '2', filename: 'alpha', resetOnCompletion: true, isDuplicate: true }, + { id: '3', filename: 'beta', resetOnCompletion: false }, + { id: 'copy-id', filename: 'alpha', resetOnCompletion: true, isDuplicate: true }, + ]); + }); + + it('uses handleDrop once and prevents handleDragEnd double execution', () => { + const { result, getDocs, setDocuments } = setup(); + const drop = dragEvent(); + + act(() => result.current.handleDragStart(dragEvent(), '1')); + act(() => result.current.handleDragOver(dragEvent({ clientY: 30 }), '3', 2)); + act(() => result.current.handleDrop(drop)); + act(() => result.current.handleDragEnd()); + + expect(drop.preventDefault).toHaveBeenCalled(); + expect(drop.stopPropagation).toHaveBeenCalled(); + expect(setDocuments).toHaveBeenCalledTimes(1); + expect(getDocs().map((doc) => doc.id)).toEqual(['2', '3', '1']); + }); + + it('clears a stale drop target when drag leaves before drag end', () => { + const { result, getDocs, setDocuments } = setup(); + + act(() => result.current.handleDragStart(dragEvent(), '1')); + act(() => result.current.handleDragOver(dragEvent({ clientY: 30 }), '3', 2)); + expect(result.current.dropTargetIndex).toBe(3); + + act(() => result.current.handleDragLeave(dragEvent())); + act(() => result.current.handleDragEnd()); + + expect(setDocuments).not.toHaveBeenCalled(); + expect(getDocs().map((doc) => doc.id)).toEqual(['1', '2', '3']); + }); + + it('tracks cursor and copy-drag state', () => { + const { result } = setup(); + + act(() => result.current.handleDragStart(dragEvent({ ctrlKey: true }), '1')); + expect(result.current.isCopyDrag).toBe(true); + expect(result.current.cursorPosition).toEqual({ x: 10, y: 0 }); + + act(() => result.current.handleDrag(dragEvent({ clientY: 15 }))); + expect(result.current.cursorPosition).toEqual({ x: 10, y: 15 }); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentListActions.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentListActions.test.tsx new file mode 100644 index 0000000000..ea66c7fce8 --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentListActions.test.tsx @@ -0,0 +1,85 @@ +import { act, renderHook } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDocumentListActions } from '../../../../../renderer/components/DocumentsPanel/hooks/useDocumentListActions'; +import type { BatchDocumentEntry } from '../../../../../renderer/types'; + +vi.mock('../../../../../renderer/utils/ids', () => ({ + generateId: vi.fn(() => 'generated-id'), +})); + +const initialDocs: BatchDocumentEntry[] = [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'beta', resetOnCompletion: true }, +]; + +function setup(docs = initialDocs) { + let currentDocs = docs; + const setDocuments = vi.fn((next: React.SetStateAction) => { + currentDocs = typeof next === 'function' ? next(currentDocs) : next; + }); + const onAddComplete = vi.fn(); + const hook = renderHook( + ({ documents }) => + useDocumentListActions({ + documents, + setDocuments, + onAddComplete, + }), + { initialProps: { documents: currentDocs } } + ); + return { + ...hook, + getDocs: () => currentDocs, + setDocuments, + onAddComplete, + rerenderWithCurrent: () => hook.rerender({ documents: currentDocs }), + }; +} + +describe('useDocumentListActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('removes documents by id', () => { + const { result, getDocs } = setup(); + + act(() => result.current.handleRemoveDocument('1')); + + expect(getDocs()).toEqual([{ id: '2', filename: 'beta', resetOnCompletion: true }]); + }); + + it('toggles reset on completion', () => { + const { result, getDocs } = setup(); + + act(() => result.current.handleToggleReset('1')); + + expect(getDocs()[0].resetOnCompletion).toBe(true); + expect(getDocs()[1].resetOnCompletion).toBe(true); + }); + + it('duplicates a document after the original with duplicate flags preserved', () => { + const { result, getDocs } = setup(); + + act(() => result.current.handleDuplicateDocument('2')); + + expect(getDocs()).toEqual([ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'beta', resetOnCompletion: true }, + { id: 'generated-id', filename: 'beta', resetOnCompletion: true, isDuplicate: true }, + ]); + }); + + it('filters removed selections and appends newly selected docs', () => { + const { result, getDocs, onAddComplete } = setup(); + + act(() => result.current.handleAddSelectedDocs(new Set(['beta', 'gamma']))); + + expect(getDocs()).toEqual([ + { id: '2', filename: 'beta', resetOnCompletion: true }, + { id: 'generated-id', filename: 'gamma', resetOnCompletion: false, isDuplicate: false }, + ]); + expect(onAddComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentSelection.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentSelection.test.tsx new file mode 100644 index 0000000000..cd95ca83e9 --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentSelection.test.tsx @@ -0,0 +1,87 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useDocumentSelection } from '../../../../../renderer/components/DocumentsPanel/hooks/useDocumentSelection'; +import type { BatchDocumentEntry } from '../../../../../renderer/types'; +import type { DocTreeNode } from '../../../../../renderer/components/DocumentsPanel'; + +const documents: BatchDocumentEntry[] = [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'beta', resetOnCompletion: true }, +]; + +const folder: DocTreeNode = { + name: 'folder', + type: 'folder', + path: 'folder', + children: [ + { name: 'gamma', type: 'file', path: 'folder/gamma' }, + { name: 'delta', type: 'file', path: 'folder/delta' }, + ], +}; + +describe('useDocumentSelection', () => { + it('preselects currently added documents and derives task totals', () => { + const { result } = renderHook(() => + useDocumentSelection({ + documents, + allDocuments: ['alpha', 'beta', 'gamma'], + taskCounts: { alpha: 2, beta: 3, gamma: 4 }, + }) + ); + + expect([...result.current.selectedDocs]).toEqual(['alpha', 'beta']); + expect(result.current.selectedTaskCount).toBe(5); + expect(result.current.totalTaskCount).toBe(9); + expect(result.current.allSelected).toBe(false); + }); + + it('toggles individual docs and select-all state', () => { + const { result } = renderHook(() => + useDocumentSelection({ + documents, + allDocuments: ['alpha', 'beta'], + taskCounts: {}, + }) + ); + + expect(result.current.allSelected).toBe(true); + act(() => result.current.toggleDoc('alpha')); + expect(result.current.selectedDocs.has('alpha')).toBe(false); + act(() => result.current.deselectAll()); + expect(result.current.selectedDocs.size).toBe(0); + act(() => result.current.selectAll()); + expect([...result.current.selectedDocs]).toEqual(['alpha', 'beta']); + }); + + it('keeps all-selected true when preselected docs include stale filenames', () => { + const { result } = renderHook(() => + useDocumentSelection({ + documents: [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: 'stale', filename: 'stale', resetOnCompletion: false }, + ], + allDocuments: ['alpha'], + taskCounts: {}, + }) + ); + + expect(result.current.allSelected).toBe(true); + }); + + it('toggles folder expansion and folder selection', () => { + const { result } = renderHook(() => + useDocumentSelection({ + documents: [], + allDocuments: ['folder/gamma', 'folder/delta'], + taskCounts: {}, + }) + ); + + act(() => result.current.toggleFolder('folder')); + expect(result.current.expandedFolders.has('folder')).toBe(true); + act(() => result.current.toggleFolderSelection(folder)); + expect([...result.current.selectedDocs]).toEqual(['folder/gamma', 'folder/delta']); + act(() => result.current.toggleFolderSelection(folder)); + expect(result.current.selectedDocs.size).toBe(0); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentSelectorRefresh.test.tsx b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentSelectorRefresh.test.tsx new file mode 100644 index 0000000000..0f5f0aba3a --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/hooks/useDocumentSelectorRefresh.test.tsx @@ -0,0 +1,115 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useDocumentSelectorRefresh } from '../../../../../renderer/components/DocumentsPanel/hooks/useDocumentSelectorRefresh'; + +describe('useDocumentSelectorRefresh', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('calls refresh, holds spinner for 500ms, and reports added documents', async () => { + vi.useFakeTimers(); + const onRefresh = vi.fn().mockResolvedValue(undefined); + const { result, rerender } = renderHook( + ({ length }) => + useDocumentSelectorRefresh({ + allDocumentsLength: length, + onRefresh, + }), + { initialProps: { length: 3 } } + ); + + await act(async () => { + await result.current.handleRefresh(); + }); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(result.current.refreshing).toBe(true); + + act(() => { + vi.advanceTimersByTime(500); + }); + act(() => { + rerender({ length: 4 }); + }); + + expect(result.current.refreshMessage).toBe('Found 1 new document'); + + await act(async () => {}); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(result.current.refreshMessage).toBeNull(); + }); + + it('reports removed documents', async () => { + vi.useFakeTimers(); + const { result, rerender } = renderHook( + ({ length }) => + useDocumentSelectorRefresh({ + allDocumentsLength: length, + onRefresh: vi.fn().mockResolvedValue(undefined), + }), + { initialProps: { length: 5 } } + ); + + await act(async () => { + await result.current.handleRefresh(); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + act(() => { + rerender({ length: 3 }); + }); + + expect(result.current.refreshMessage).toBe('2 documents removed'); + }); + + it('does not show a no-change message when count is unchanged', async () => { + vi.useFakeTimers(); + const { result, rerender } = renderHook( + ({ length }) => + useDocumentSelectorRefresh({ + allDocumentsLength: length, + onRefresh: vi.fn().mockResolvedValue(undefined), + }), + { initialProps: { length: 2 } } + ); + + await act(async () => { + await result.current.handleRefresh(); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + rerender({ length: 2 }); + + expect(result.current.refreshMessage).toBeNull(); + }); + + it('releases refreshing state when refresh rejects', async () => { + vi.useFakeTimers(); + const onRefresh = vi.fn().mockRejectedValue(new Error('refresh failed')); + const { result } = renderHook(() => + useDocumentSelectorRefresh({ + allDocumentsLength: 2, + onRefresh, + }) + ); + + await act(async () => { + await result.current.handleRefresh(); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(result.current.refreshing).toBe(true); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current.refreshing).toBe(false); + expect(result.current.refreshMessage).toBeNull(); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/utils/documentCounts.test.ts b/src/__tests__/renderer/components/DocumentsPanel/utils/documentCounts.test.ts new file mode 100644 index 0000000000..4da62b29fd --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/utils/documentCounts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + canDisableReset, + getAllDocumentsTaskCount, + getDocumentTaskCount, + getMissingDocumentCount, + getSelectedTaskCount, + getTotalDocumentTaskCount, + hasDuplicateFilename, +} from '../../../../../renderer/components/DocumentsPanel/utils/documentCounts'; +import type { BatchDocumentEntry } from '../../../../../renderer/types'; + +const docs: BatchDocumentEntry[] = [ + { id: '1', filename: 'alpha', resetOnCompletion: false }, + { id: '2', filename: 'beta', resetOnCompletion: true }, + { id: '3', filename: 'missing', resetOnCompletion: false, isMissing: true }, + { id: '4', filename: 'beta', resetOnCompletion: true, isDuplicate: true }, +]; + +describe('DocumentsPanel documentCounts utils', () => { + it('returns zero for missing task-count entries', () => { + expect(getDocumentTaskCount({ alpha: 2 }, 'none')).toBe(0); + }); + + it('sums selected documents by filename', () => { + expect(getSelectedTaskCount(new Set(['alpha', 'beta']), { alpha: 2, beta: 4 })).toBe(6); + }); + + it('sums all available document task counts without selected-list missing docs', () => { + expect(getTotalDocumentTaskCount(docs, { alpha: 2, beta: 4, missing: 99 })).toBe(10); + }); + + it('sums the selector document list', () => { + expect(getAllDocumentsTaskCount(['alpha', 'gamma'], { alpha: 2, gamma: 7 })).toBe(9); + }); + + it('counts missing docs and duplicate filenames', () => { + expect(getMissingDocumentCount(docs)).toBe(1); + expect(hasDuplicateFilename(docs, 'beta')).toBe(true); + expect(hasDuplicateFilename(docs, 'alpha')).toBe(false); + }); + + it('only allows reset disable when no duplicate filename exists', () => { + expect(canDisableReset(docs, 'alpha')).toBe(true); + expect(canDisableReset(docs, 'beta')).toBe(false); + }); +}); diff --git a/src/__tests__/renderer/components/DocumentsPanel/utils/documentTree.test.ts b/src/__tests__/renderer/components/DocumentsPanel/utils/documentTree.test.ts new file mode 100644 index 0000000000..2e60f9c67d --- /dev/null +++ b/src/__tests__/renderer/components/DocumentsPanel/utils/documentTree.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + getFilesInNode, + getFolderTaskCount, + isFolderFullySelected, + isFolderPartiallySelected, +} from '../../../../../renderer/components/DocumentsPanel/utils/documentTree'; +import type { DocTreeNode } from '../../../../../renderer/components/DocumentsPanel'; + +const tree: DocTreeNode = { + name: 'docs', + type: 'folder', + path: 'docs', + children: [ + { name: 'setup', type: 'file', path: 'docs/setup' }, + { + name: 'nested', + type: 'folder', + path: 'docs/nested', + children: [{ name: 'ship', type: 'file', path: 'custom/path/ship' }], + }, + ], +}; + +describe('DocumentsPanel documentTree utils', () => { + it('collects files using node.path, including custom file paths', () => { + expect(getFilesInNode(tree)).toEqual(['docs/setup', 'custom/path/ship']); + }); + + it('returns one path for a file node', () => { + expect(getFilesInNode({ name: 'readme', type: 'file', path: 'readme' })).toEqual(['readme']); + }); + + it('reports full and partial folder selection', () => { + expect(isFolderFullySelected(tree, new Set(['docs/setup', 'custom/path/ship']))).toBe(true); + expect(isFolderPartiallySelected(tree, new Set(['docs/setup']))).toBe(true); + expect(isFolderFullySelected(tree, new Set(['docs/setup']))).toBe(false); + }); + + it('does not treat empty folders as fully or partially selected', () => { + const empty: DocTreeNode = { name: 'empty', type: 'folder', path: 'empty', children: [] }; + expect(isFolderFullySelected(empty, new Set(['empty']))).toBe(false); + expect(isFolderPartiallySelected(empty, new Set(['empty']))).toBe(false); + }); + + it('sums task counts for files under a folder', () => { + expect(getFolderTaskCount(tree, { 'docs/setup': 2, 'custom/path/ship': 3 })).toBe(5); + }); +}); diff --git a/src/renderer/components/CustomThemeBuilder.tsx b/src/renderer/components/CustomThemeBuilder.tsx index c3a5218cf8..cf89563c9e 100644 --- a/src/renderer/components/CustomThemeBuilder.tsx +++ b/src/renderer/components/CustomThemeBuilder.tsx @@ -13,8 +13,8 @@ function isValidColor(color: string): boolean { // Handle empty strings if (!color || typeof color !== 'string') return false; - // Use the DOM to validate - create an option element and try to set its color - const testElement = new Option().style; + // Use a real DOM element so validation does not depend on the global Option constructor. + const testElement = document.createElement('span').style; testElement.color = color; // If the browser accepts the color, it will be non-empty return testElement.color !== ''; @@ -49,6 +49,8 @@ const COLOR_CONFIG: { key: keyof ThemeColors; label: string; description: string { key: 'error', label: 'Error', description: 'Red states' }, ]; +const OPTIONAL_IMPORT_COLOR_KEYS = new Set(['bgTitleBar']); + // Mini UI Preview component function MiniUIPreview({ colors }: { colors: ThemeColors }) { return ( @@ -370,7 +372,10 @@ export function CustomThemeBuilder({ const data = JSON.parse(e.target?.result as string); if (data.colors && typeof data.colors === 'object') { // Validate all required color keys exist - const requiredKeys = COLOR_CONFIG.map((c) => c.key); + const colorKeys = COLOR_CONFIG.map((c) => c.key); + const requiredKeys = colorKeys.filter( + (key) => !OPTIONAL_IMPORT_COLOR_KEYS.has(String(key)) + ); const hasAllKeys = requiredKeys.every((key) => key in data.colors); if (!hasAllKeys) { @@ -381,7 +386,9 @@ export function CustomThemeBuilder({ } // Validate all color values are valid CSS colors - const invalidColors = requiredKeys.filter((key) => !isValidColor(data.colors[key])); + const invalidColors = colorKeys.filter( + (key) => key in data.colors && !isValidColor(data.colors[key]) + ); if (invalidColors.length > 0) { const errorMsg = `Invalid theme file: invalid color values for ${invalidColors.slice(0, 3).join(', ')}${invalidColors.length > 3 ? '...' : ''}`; onImportError?.(errorMsg); diff --git a/src/renderer/components/DocumentsPanel.tsx b/src/renderer/components/DocumentsPanel.tsx deleted file mode 100644 index a3c82e2399..0000000000 --- a/src/renderer/components/DocumentsPanel.tsx +++ /dev/null @@ -1,1293 +0,0 @@ -import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; -import { - GripVertical, - Plus, - Repeat, - RotateCcw, - X, - AlertTriangle, - RefreshCw, - ChevronDown, - ChevronRight, - Folder, - CheckSquare, -} from 'lucide-react'; -import { GhostIconButton } from './ui/GhostIconButton'; -import type { Theme, BatchDocumentEntry } from '../types'; -import { generateId } from '../utils/ids'; -import { useModalLayer } from '../hooks/ui/useModalLayer'; -import { MODAL_PRIORITIES } from '../constants/modalPriorities'; -import { formatMetaKey } from '../utils/shortcutFormatter'; - -// Tree node type for folder structure -export interface DocTreeNode { - name: string; - type: 'file' | 'folder'; - path: string; - children?: DocTreeNode[]; -} - -interface DocumentsPanelProps { - theme: Theme; - documents: BatchDocumentEntry[]; - setDocuments: React.Dispatch>; - taskCounts: Record; - loadingTaskCounts: boolean; - loopEnabled: boolean; - setLoopEnabled: (enabled: boolean) => void; - maxLoops: number | null; - setMaxLoops: (maxLoops: number | null) => void; - allDocuments: string[]; - documentTree?: DocTreeNode[]; - onRefreshDocuments: () => Promise; -} - -// Document selector modal component -interface DocumentSelectorModalProps { - theme: Theme; - allDocuments: string[]; - documentTree?: DocTreeNode[]; - taskCounts: Record; - loadingTaskCounts: boolean; - documents: BatchDocumentEntry[]; - onClose: () => void; - onAdd: (selectedDocs: Set) => void; - onRefresh: () => Promise; -} - -function DocumentSelectorModal({ - theme, - allDocuments, - documentTree, - taskCounts, - loadingTaskCounts, - documents, - onClose, - onAdd, - onRefresh, -}: DocumentSelectorModalProps) { - // Layer stack for escape handling - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - - useModalLayer(MODAL_PRIORITIES.DOCUMENT_SELECTOR, 'Select Documents', () => { - onCloseRef.current(); - }); - - // Pre-select currently added documents - const [selectedDocs, setSelectedDocs] = useState>(() => { - return new Set(documents.map((d) => d.filename)); - }); - const [refreshing, setRefreshing] = useState(false); - const [refreshMessage, setRefreshMessage] = useState(null); - const [prevDocCount, setPrevDocCount] = useState(allDocuments.length); - const [expandedFolders, setExpandedFolders] = useState>(new Set()); - - // Toggle document selection - const toggleDoc = useCallback((filename: string) => { - setSelectedDocs((prev) => { - const next = new Set(prev); - if (next.has(filename)) { - next.delete(filename); - } else { - next.add(filename); - } - return next; - }); - }, []); - - // Select all documents - const selectAll = useCallback(() => { - setSelectedDocs(new Set(allDocuments)); - }, [allDocuments]); - - // Deselect all documents - const deselectAll = useCallback(() => { - setSelectedDocs(new Set()); - }, []); - - // Toggle folder expansion - const toggleFolder = useCallback((folderPath: string) => { - setExpandedFolders((prev) => { - const next = new Set(prev); - if (next.has(folderPath)) { - next.delete(folderPath); - } else { - next.add(folderPath); - } - return next; - }); - }, []); - - // Get all file paths under a folder (recursive) - const getFilesInFolder = useCallback((node: DocTreeNode): string[] => { - if (node.type === 'file') { - return [node.path]; - } - if (node.children) { - return node.children.flatMap((child) => getFilesInFolder(child)); - } - return []; - }, []); - - // Check if all files in a folder are selected - const isFolderFullySelected = useCallback( - (node: DocTreeNode): boolean => { - const files = getFilesInFolder(node); - return files.length > 0 && files.every((f) => selectedDocs.has(f)); - }, - [getFilesInFolder, selectedDocs] - ); - - // Check if some (but not all) files in a folder are selected - const isFolderPartiallySelected = useCallback( - (node: DocTreeNode): boolean => { - const files = getFilesInFolder(node); - const selectedCount = files.filter((f) => selectedDocs.has(f)).length; - return selectedCount > 0 && selectedCount < files.length; - }, - [getFilesInFolder, selectedDocs] - ); - - // Toggle all files in a folder - const toggleFolder_ = useCallback( - (node: DocTreeNode) => { - const files = getFilesInFolder(node); - const allSelected = files.every((f) => selectedDocs.has(f)); - - setSelectedDocs((prev) => { - const next = new Set(prev); - if (allSelected) { - // Deselect all - files.forEach((f) => next.delete(f)); - } else { - // Select all - files.forEach((f) => next.add(f)); - } - return next; - }); - }, - [getFilesInFolder, selectedDocs] - ); - - // Get total task count for files in a folder - const getFolderTaskCount = useCallback( - (node: DocTreeNode): number => { - const files = getFilesInFolder(node); - return files.reduce((sum, f) => sum + (taskCounts[f] ?? 0), 0); - }, - [getFilesInFolder, taskCounts] - ); - - // Get total task count for all documents - const totalTaskCount = useMemo(() => { - return allDocuments.reduce((sum, f) => sum + (taskCounts[f] ?? 0), 0); - }, [allDocuments, taskCounts]); - - // Handle refresh - const handleRefresh = useCallback(async () => { - setRefreshing(true); - setRefreshMessage(null); - - await onRefresh(); - - // Use a small timeout to let the prop update - setTimeout(() => { - setRefreshing(false); - }, 500); - }, [onRefresh, allDocuments.length]); - - // Track document count changes for refresh notification - useEffect(() => { - if (refreshing === false && prevDocCount !== allDocuments.length) { - const diff = allDocuments.length - prevDocCount; - let message: string; - if (diff > 0) { - message = `Found ${diff} new document${diff === 1 ? '' : 's'}`; - } else if (diff < 0) { - message = `${Math.abs(diff)} document${Math.abs(diff) === 1 ? '' : 's'} removed`; - } else { - message = 'No changes'; - } - setRefreshMessage(message); - setPrevDocCount(allDocuments.length); - - // Clear message after 3 seconds - const timer = setTimeout(() => setRefreshMessage(null), 3000); - return () => clearTimeout(timer); - } - }, [allDocuments.length, prevDocCount, refreshing]); - - // Render a tree node recursively with checkboxes - const renderTreeNode = (node: DocTreeNode, depth: number = 0): React.ReactNode => { - const paddingLeft = depth * 20 + 12; - - if (node.type === 'folder') { - const isExpanded = expandedFolders.has(node.path); - const isFullySelected = isFolderFullySelected(node); - const isPartiallySelected = isFolderPartiallySelected(node); - const filesInFolder = getFilesInFolder(node); - - return ( -
-
- {/* Expand/Collapse button */} - - - {/* Folder checkbox */} - - - {/* File count badge */} - - {filesInFolder.length} {filesInFolder.length === 1 ? 'file' : 'files'} - - - {/* Task count badge */} - {(() => { - const folderTaskCount = getFolderTaskCount(node); - return ( - - {loadingTaskCounts - ? '...' - : `${folderTaskCount} ${folderTaskCount === 1 ? 'task' : 'tasks'}`} - - ); - })()} -
- - {/* Children */} - {isExpanded && node.children && ( -
{node.children.map((child) => renderTreeNode(child, depth + 1))}
- )} -
- ); - } - - // File node - const isSelected = selectedDocs.has(node.path); - const docTaskCount = taskCounts[node.path] ?? 0; - - return ( - - ); - }; - - const allSelected = selectedDocs.size === allDocuments.length && allDocuments.length > 0; - - // Calculate task count for selected documents - const selectedTaskCount = useMemo(() => { - let count = 0; - selectedDocs.forEach((doc) => { - count += taskCounts[doc] ?? 0; - }); - return count; - }, [selectedDocs, taskCounts]); - - return ( -
- - - - - -
- - - {/* Document Checkboxes */} -
- {allDocuments.length === 0 ? ( -
-

No documents found in folder

-
- ) : documentTree && documentTree.length > 0 ? ( - // Render tree structure with folder checkboxes -
{documentTree.map((node) => renderTreeNode(node))}
- ) : ( - // Fallback to flat list -
- {allDocuments.map((filename) => { - const isSelected = selectedDocs.has(filename); - const docTaskCount = taskCounts[filename] ?? 0; - - return ( - - ); - })} -
- )} -
- - {/* Selector Footer */} -
- - -
- - - ); -} - -export function DocumentsPanel({ - theme, - documents, - setDocuments, - taskCounts, - loadingTaskCounts, - loopEnabled, - setLoopEnabled, - maxLoops, - setMaxLoops, - allDocuments, - documentTree, - onRefreshDocuments, -}: DocumentsPanelProps) { - // Document selector modal state - const [showDocSelector, setShowDocSelector] = useState(false); - - // Loop mode state - const showMaxLoopsSlider = maxLoops != null; - - // Drag state for reordering - const [draggedId, setDraggedId] = useState(null); - const [dropTargetIndex, setDropTargetIndex] = useState(null); // Index where item will be inserted (shown as line) - const [isCopyDrag, setIsCopyDrag] = useState(false); - const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null); - - // Refs to access current values in event handlers (avoids stale closure issues) - const draggedIdRef = useRef(draggedId); - const dropTargetIndexRef = useRef(dropTargetIndex); - const isCopyDragRef = useRef(isCopyDrag); - const dropPerformedRef = useRef(false); // Prevents double execution if both handleDrop and handleDragEnd fire - draggedIdRef.current = draggedId; - dropTargetIndexRef.current = dropTargetIndex; - isCopyDragRef.current = isCopyDrag; - - // Calculate counts - const totalTaskCount = documents.reduce((sum, doc) => { - if (doc.isMissing) return sum; - return sum + (taskCounts[doc.filename] || 0); - }, 0); - const missingDocCount = documents.filter((doc) => doc.isMissing).length; - const hasMissingDocs = missingDocCount > 0; - - // Document list handlers - const handleRemoveDocument = useCallback( - (id: string) => { - setDocuments((prev) => prev.filter((d) => d.id !== id)); - }, - [setDocuments] - ); - - const handleToggleReset = useCallback( - (id: string) => { - setDocuments((prev) => - prev.map((d) => (d.id === id ? { ...d, resetOnCompletion: !d.resetOnCompletion } : d)) - ); - }, - [setDocuments] - ); - - const handleDuplicateDocument = useCallback( - (id: string) => { - setDocuments((prev) => { - const index = prev.findIndex((d) => d.id === id); - if (index === -1) return prev; - - const original = prev[index]; - const duplicate: BatchDocumentEntry = { - id: generateId(), - filename: original.filename, - resetOnCompletion: original.resetOnCompletion, - isDuplicate: true, - }; - - return [...prev.slice(0, index + 1), duplicate, ...prev.slice(index + 1)]; - }); - }, - [setDocuments] - ); - - const handleOpenDocSelector = useCallback(() => { - setShowDocSelector(true); - }, []); - - const handleAddSelectedDocs = useCallback( - (selectedDocs: Set) => { - const existingFilenames = new Set(documents.map((d) => d.filename)); - - const newDocs: BatchDocumentEntry[] = []; - selectedDocs.forEach((filename) => { - if (!existingFilenames.has(filename)) { - newDocs.push({ - id: generateId(), - filename, - resetOnCompletion: false, - isDuplicate: false, - }); - } - }); - - const filteredDocs = documents.filter((d) => selectedDocs.has(d.filename)); - setDocuments([...filteredDocs, ...newDocs]); - setShowDocSelector(false); - }, - [documents, setDocuments] - ); - - // Drag and drop handlers - const handleDragStart = useCallback((e: React.DragEvent, id: string) => { - dropPerformedRef.current = false; // Reset for new drag operation - const isCopy = e.ctrlKey || e.metaKey; - setDraggedId(id); - setIsCopyDrag(isCopy); - e.dataTransfer.effectAllowed = isCopy ? 'copy' : 'move'; - setCursorPosition({ x: e.clientX, y: e.clientY }); - }, []); - - const handleDrag = useCallback((e: React.DragEvent) => { - // Update cursor position during drag (for the floating plus icon) - if (e.clientX !== 0 || e.clientY !== 0) { - setCursorPosition({ x: e.clientX, y: e.clientY }); - } - // Update copy state based on current modifier keys - setIsCopyDrag(e.ctrlKey || e.metaKey); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent, _id: string, index: number) => { - e.preventDefault(); - const isCopy = e.ctrlKey || e.metaKey; - setIsCopyDrag(isCopy); - e.dataTransfer.dropEffect = isCopy ? 'copy' : 'move'; - - const currentDraggedId = draggedIdRef.current; - if (!currentDraggedId) return; - - // Determine drop position based on cursor position relative to element midpoint - const rect = e.currentTarget.getBoundingClientRect(); - const dropIndex = e.clientY < rect.top + rect.height / 2 ? index : index + 1; - - // For copy mode, always show indicator. For move mode, only if position changes. - if (isCopy) { - setDropTargetIndex(dropIndex); - } else { - const draggedIndex = documents.findIndex((d) => d.id === currentDraggedId); - const isNewPosition = dropIndex !== draggedIndex && dropIndex !== draggedIndex + 1; - setDropTargetIndex(isNewPosition ? dropIndex : null); - } - }, - [documents] - ); - - // Note: We intentionally don't clear dropTargetIndex in dragLeave. - // The browser fires dragleave events during normal operations including right before drop. - // Cleanup happens in handleDrop or handleDragEnd. - const handleDragLeave = useCallback(() => {}, []); - - // Shared logic for performing the drop operation (copy or move) - const performDropOperation = useCallback(() => { - const currentDraggedId = draggedIdRef.current; - const currentDropTargetIndex = dropTargetIndexRef.current; - const currentIsCopyDrag = isCopyDragRef.current; - - if (currentDraggedId && currentDropTargetIndex !== null && !dropPerformedRef.current) { - dropPerformedRef.current = true; - setDocuments((prev) => { - const draggedIndex = prev.findIndex((d) => d.id === currentDraggedId); - if (draggedIndex === -1) return prev; - - const items = [...prev]; - if (currentIsCopyDrag) { - const original = items[draggedIndex]; - // Enable reset on ALL documents with the same filename (since duplicates require reset) - for (let i = 0; i < items.length; i++) { - if (items[i].filename === original.filename) { - items[i] = { ...items[i], resetOnCompletion: true }; - } - } - items.splice(currentDropTargetIndex, 0, { - id: generateId(), - filename: original.filename, - resetOnCompletion: true, - isDuplicate: true, - }); - } else { - const [removed] = items.splice(draggedIndex, 1); - const adjustedIndex = - draggedIndex < currentDropTargetIndex - ? currentDropTargetIndex - 1 - : currentDropTargetIndex; - items.splice(adjustedIndex, 0, removed); - } - return items; - }); - } - }, [setDocuments]); - - const resetDragState = useCallback(() => { - setDraggedId(null); - setDropTargetIndex(null); - setIsCopyDrag(false); - setCursorPosition(null); - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - performDropOperation(); - resetDragState(); - }, - [performDropOperation, resetDragState] - ); - - const handleDragEnd = useCallback(() => { - // Fallback: perform operation if handleDrop didn't fire (browser quirk) - performDropOperation(); - resetDragState(); - }, [performDropOperation, resetDragState]); - - return ( -
-
-
- Documents to Run -
- -
- - {/* Document List with Loop Indicator */} -
1 ? 'ml-7' : ''}`}> - {/* Loop path - right-angled lines from bottom around left to top */} - {loopEnabled && documents.length > 1 && ( - <> - {/* Left vertical line */} -
- {/* Top horizontal line - stops before document */} -
- {/* Bottom horizontal line - stops before document */} -
- {/* Arrow head pointing right (toward top doc) */} -
- - )} -
- {documents.length === 0 ? ( -
-

No documents selected

-

- Load a playbook or click "+ Add Docs" to select documents to run -

-
- ) : ( -
e.preventDefault()} - > - {documents.map((doc, index) => { - const docTaskCount = taskCounts[doc.filename] ?? 0; - const isBeingDragged = draggedId === doc.id; - const showDropIndicatorBefore = dropTargetIndex === index && draggedId !== null; - const showDropIndicatorAfter = - dropTargetIndex === index + 1 && - index === documents.length - 1 && - draggedId !== null; - - return ( -
0 ? { borderTop: `1px solid ${theme.colors.border}22` } : undefined - } - > - {/* Drop Indicator Line - Before */} - {showDropIndicatorBefore && ( -
- {/* Left circle */} -
- {/* Right circle */} -
-
- )} - -
!doc.isMissing && handleDragStart(e, doc.id)} - onDrag={handleDrag} - onDragOver={(e) => handleDragOver(e, doc.id, index)} - onDrop={handleDrop} - onDragEnd={handleDragEnd} - className={`flex items-center gap-3 px-3 py-2 transition-all ${ - isBeingDragged ? 'opacity-50' : '' - } hover:bg-white/5 ${doc.isMissing ? 'opacity-60' : ''}`} - style={{ - backgroundColor: doc.isMissing ? theme.colors.error + '08' : undefined, - }} - > - {/* Drag Handle */} - - - {/* Document Name - truncates from left to show filename */} - - {doc.filename}.md - - - {/* Missing Indicator */} - {doc.isMissing && ( - - Missing - - )} - - {/* Task Count Badge (invisible placeholder for missing docs) */} - {!doc.isMissing ? ( - - {loadingTaskCounts - ? '...' - : `${docTaskCount} ${docTaskCount === 1 ? 'task' : 'tasks'}`} - - ) : ( - 0 tasks - )} - - {/* Reset Toggle Button (invisible placeholder for missing docs) */} - {!doc.isMissing ? ( - (() => { - const hasDuplicates = - documents.filter((d) => d.filename === doc.filename).length > 1; - const canDisableReset = !hasDuplicates; - - const modifierKey = formatMetaKey(); - let tooltipText: string; - if (doc.resetOnCompletion) { - if (canDisableReset) { - tooltipText = - 'Reset enabled: uncompleted tasks will be re-checked when done. Click to disable.'; - } else { - tooltipText = - 'Reset enabled: uncompleted tasks will be re-checked when done. Remove duplicates to disable.'; - } - } else { - tooltipText = `Enable reset, or ${modifierKey}+drag to copy`; - } - - return ( - - ); - })() - ) : ( - - - - )} - - {/* Duplicate Button (invisible placeholder when not applicable) */} - {doc.resetOnCompletion && !doc.isMissing ? ( - - ) : ( - - - - )} - - {/* Remove Button */} - -
- - {/* Drop Indicator Line - After (only for last item) */} - {showDropIndicatorAfter && ( -
- {/* Left circle */} -
- {/* Right circle */} -
-
- )} -
- ); - })} -
- )} -
-
- - {/* Hint for enabling loop mode */} - {documents.length === 1 && ( -

- You can enable loops with two or more documents -

- )} - - {/* Missing Documents Warning */} - {hasMissingDocs && ( -
- - - {missingDocCount} document{missingDocCount > 1 ? 's' : ''} no longer exist - {missingDocCount === 1 ? 's' : ''} in the folder and will be skipped - -
- )} - - {/* Total Summary with Loop Button */} - {documents.length > 1 && ( -
- {/* Loop Mode Toggle with Max Loops Control */} -
- {/* Loop Toggle Button */} - - - {/* Max Loops Control - only shown when loop is enabled */} - {loopEnabled && ( -
- {/* Infinity Toggle */} - - {/* Max Toggle */} - -
- )} - - {/* Slider for max loops - shown when max is selected */} - {loopEnabled && showMaxLoopsSlider && ( -
- setMaxLoops(parseInt(e.target.value))} - className="w-32 h-1 rounded-lg appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, ${theme.colors.accent} 0%, ${theme.colors.accent} ${((maxLoops ?? 5) / 25) * 100}%, ${theme.colors.border} ${((maxLoops ?? 5) / 25) * 100}%, ${theme.colors.border} 100%)`, - }} - /> - - {maxLoops} - -
- )} -
- - Total: {loadingTaskCounts ? '...' : totalTaskCount} tasks across{' '} - {documents.length - missingDocCount} {hasMissingDocs ? 'available ' : ''}document - {documents.length - missingDocCount !== 1 ? 's' : ''} - {hasMissingDocs && ` (${missingDocCount} missing)`} - -
- )} - - {/* Document Selector Modal */} - {showDocSelector && ( - setShowDocSelector(false)} - onAdd={handleAddSelectedDocs} - onRefresh={onRefreshDocuments} - /> - )} - - {/* Floating Plus Icon (follows cursor during copy drag) */} - {isCopyDrag && cursorPosition && ( -
- -
- )} -
- ); -} diff --git a/src/renderer/components/DocumentsPanel/DocumentsPanel.tsx b/src/renderer/components/DocumentsPanel/DocumentsPanel.tsx new file mode 100644 index 0000000000..f0354a46b7 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/DocumentsPanel.tsx @@ -0,0 +1,134 @@ +import { useCallback, useState } from 'react'; +import { Plus } from 'lucide-react'; +import type { DocumentsPanelProps } from './types'; +import { + CopyDragIndicator, + DocumentList, + DocumentSelectorModal, + LoopControls, + MissingDocumentsWarning, +} from './components'; +import { useDocumentDragReorder, useDocumentListActions } from './hooks'; +import { getMissingDocumentCount, getTotalDocumentTaskCount } from './utils'; + +export function DocumentsPanel({ + theme, + documents, + setDocuments, + taskCounts, + loadingTaskCounts, + loopEnabled, + setLoopEnabled, + maxLoops, + setMaxLoops, + allDocuments, + documentTree, + onRefreshDocuments, +}: DocumentsPanelProps) { + const [showDocSelector, setShowDocSelector] = useState(false); + + const missingDocCount = getMissingDocumentCount(documents); + const hasMissingDocs = missingDocCount > 0; + const totalTaskCount = getTotalDocumentTaskCount(documents, taskCounts); + + const handleCloseDocSelector = useCallback(() => { + setShowDocSelector(false); + }, []); + + const { + handleRemoveDocument, + handleToggleReset, + handleDuplicateDocument, + handleAddSelectedDocs, + } = useDocumentListActions({ + documents, + setDocuments, + onAddComplete: handleCloseDocSelector, + }); + + const { + draggedId, + dropTargetIndex, + isCopyDrag, + cursorPosition, + handleDragStart, + handleDrag, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd, + } = useDocumentDragReorder({ + documents, + setDocuments, + }); + + return ( +
+
+
+ Documents to Run +
+ +
+ + + + + + + + {showDocSelector && ( + + )} + + +
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/CopyDragIndicator.tsx b/src/renderer/components/DocumentsPanel/components/CopyDragIndicator.tsx new file mode 100644 index 0000000000..58f73db052 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/CopyDragIndicator.tsx @@ -0,0 +1,29 @@ +import { Plus } from 'lucide-react'; +import type { Theme } from '../../../types'; + +interface CopyDragIndicatorProps { + theme: Theme; + cursorPosition: { x: number; y: number } | null; + isCopyDrag: boolean; +} + +export function CopyDragIndicator({ theme, cursorPosition, isCopyDrag }: CopyDragIndicatorProps) { + if (!isCopyDrag || !cursorPosition) return null; + + return ( +
+ +
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/DocumentList.tsx b/src/renderer/components/DocumentsPanel/components/DocumentList.tsx new file mode 100644 index 0000000000..ed4548dc14 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/DocumentList.tsx @@ -0,0 +1,133 @@ +import type { BatchDocumentEntry, Theme } from '../../../types'; +import type { DragHandlers } from '../types'; +import { DocumentRow } from './DocumentRow'; + +interface DocumentListProps extends DragHandlers { + theme: Theme; + documents: BatchDocumentEntry[]; + taskCounts: Record; + loadingTaskCounts: boolean; + loopEnabled: boolean; + draggedId: string | null; + dropTargetIndex: number | null; + isCopyDrag: boolean; + onRemoveDocument: (id: string) => void; + onToggleReset: (id: string) => void; + onDuplicateDocument: (id: string) => void; +} + +export function DocumentList({ + theme, + documents, + taskCounts, + loadingTaskCounts, + loopEnabled, + draggedId, + dropTargetIndex, + isCopyDrag, + handleDragStart, + handleDrag, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd, + onRemoveDocument, + onToggleReset, + onDuplicateDocument, +}: DocumentListProps) { + return ( +
1 ? 'ml-7' : ''}`}> + {loopEnabled && documents.length > 1 && ( + <> +
+
+
+
+ + )} +
+ {documents.length === 0 ? ( +
+

No documents selected

+

+ Load a playbook or click "+ Add Docs" to select documents to run +

+
+ ) : ( +
e.preventDefault()} + > + {documents.map((doc, index) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/DocumentRow.tsx b/src/renderer/components/DocumentsPanel/components/DocumentRow.tsx new file mode 100644 index 0000000000..6d4bcc05ec --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/DocumentRow.tsx @@ -0,0 +1,223 @@ +import { GripVertical, Plus, RotateCcw, X } from 'lucide-react'; +import type React from 'react'; +import type { BatchDocumentEntry, Theme } from '../../../types'; +import { formatMetaKey } from '../../../utils/shortcutFormatter'; +import { canDisableReset } from '../utils/documentCounts'; +import { TaskCountBadge } from './TaskCountBadge'; + +interface DocumentRowProps { + theme: Theme; + doc: BatchDocumentEntry; + index: number; + documents: BatchDocumentEntry[]; + taskCount: number; + loadingTaskCounts: boolean; + draggedId: string | null; + dropTargetIndex: number | null; + isCopyDrag: boolean; + onDragStart: (e: React.DragEvent, id: string) => void; + onDrag: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent, id: string, index: number) => void; + onDrop: (e: React.DragEvent) => void; + onDragEnd: () => void; + onRemove: (id: string) => void; + onToggleReset: (id: string) => void; + onDuplicate: (id: string) => void; +} + +function DropIndicator({ + theme, + isCopyDrag, + position, +}: { + theme: Theme; + isCopyDrag: boolean; + position: 'top' | 'bottom'; +}) { + const color = isCopyDrag ? theme.colors.success : theme.colors.accent; + return ( +
+
+
+
+ ); +} + +export function DocumentRow({ + theme, + doc, + index, + documents, + taskCount, + loadingTaskCounts, + draggedId, + dropTargetIndex, + isCopyDrag, + onDragStart, + onDrag, + onDragOver, + onDrop, + onDragEnd, + onRemove, + onToggleReset, + onDuplicate, +}: DocumentRowProps) { + const isBeingDragged = draggedId === doc.id; + const showDropIndicatorBefore = dropTargetIndex === index && draggedId !== null; + const showDropIndicatorAfter = + dropTargetIndex === index + 1 && index === documents.length - 1 && draggedId !== null; + const resetCanDisable = canDisableReset(documents, doc.filename); + + let tooltipText: string; + if (doc.resetOnCompletion) { + if (resetCanDisable) { + tooltipText = + 'Reset enabled: uncompleted tasks will be re-checked when done. Click to disable.'; + } else { + tooltipText = + 'Reset enabled: uncompleted tasks will be re-checked when done. Remove duplicates to disable.'; + } + } else { + tooltipText = `Enable reset, or ${formatMetaKey()}+drag to copy`; + } + + return ( +
0 ? { borderTop: `1px solid ${theme.colors.border}22` } : undefined} + > + {showDropIndicatorBefore && ( + + )} + +
!doc.isMissing && onDragStart(e, doc.id)} + onDrag={onDrag} + onDragOver={(e) => onDragOver(e, doc.id, index)} + onDrop={onDrop} + onDragEnd={onDragEnd} + className={`flex items-center gap-3 px-3 py-2 transition-all ${ + isBeingDragged ? 'opacity-50' : '' + } hover:bg-white/5 ${doc.isMissing ? 'opacity-60' : ''}`} + style={{ + backgroundColor: doc.isMissing ? theme.colors.error + '08' : undefined, + }} + > + + + + {doc.filename}.md + + + {doc.isMissing && ( + + Missing + + )} + + {!doc.isMissing ? ( + + ) : ( + 0 tasks + )} + + {!doc.isMissing ? ( + + ) : ( + + + + )} + + {doc.resetOnCompletion && !doc.isMissing ? ( + + ) : ( + + + + )} + + +
+ + {showDropIndicatorAfter && ( + + )} +
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/DocumentSelectorFlatList.tsx b/src/renderer/components/DocumentsPanel/components/DocumentSelectorFlatList.tsx new file mode 100644 index 0000000000..288959e9eb --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/DocumentSelectorFlatList.tsx @@ -0,0 +1,80 @@ +import type { Theme } from '../../../types'; +import { TaskCountBadge } from './TaskCountBadge'; + +interface DocumentSelectorFlatListProps { + theme: Theme; + allDocuments: string[]; + selectedDocs: Set; + taskCounts: Record; + loadingTaskCounts: boolean; + onToggleDoc: (filename: string) => void; +} + +function CheckMark() { + return ( + + + + ); +} + +export function DocumentSelectorFlatList({ + theme, + allDocuments, + selectedDocs, + taskCounts, + loadingTaskCounts, + onToggleDoc, +}: DocumentSelectorFlatListProps) { + return ( +
+ {allDocuments.map((filename) => { + const isSelected = selectedDocs.has(filename); + const docTaskCount = taskCounts[filename] ?? 0; + + return ( + + ); + })} +
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/DocumentSelectorFooter.tsx b/src/renderer/components/DocumentsPanel/components/DocumentSelectorFooter.tsx new file mode 100644 index 0000000000..a443812b9b --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/DocumentSelectorFooter.tsx @@ -0,0 +1,46 @@ +import type { Theme } from '../../../types'; + +interface DocumentSelectorFooterProps { + theme: Theme; + selectedDocs: Set; + selectedTaskCount: number; + loadingTaskCounts: boolean; + onClose: () => void; + onAdd: (selectedDocs: Set) => void; +} + +export function DocumentSelectorFooter({ + theme, + selectedDocs, + selectedTaskCount, + loadingTaskCounts, + onClose, + onAdd, +}: DocumentSelectorFooterProps) { + return ( +
+ + +
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/DocumentSelectorModal.tsx b/src/renderer/components/DocumentsPanel/components/DocumentSelectorModal.tsx new file mode 100644 index 0000000000..2446483749 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/DocumentSelectorModal.tsx @@ -0,0 +1,174 @@ +import { useRef } from 'react'; +import { CheckSquare, RefreshCw, X } from 'lucide-react'; +import { GhostIconButton } from '../../ui/GhostIconButton'; +import { useModalLayer } from '../../../hooks/ui/useModalLayer'; +import { MODAL_PRIORITIES } from '../../../constants/modalPriorities'; +import type { DocumentSelectorModalProps } from '../types'; +import { useDocumentSelection } from '../hooks/useDocumentSelection'; +import { useDocumentSelectorRefresh } from '../hooks/useDocumentSelectorRefresh'; +import { DocumentSelectorFlatList } from './DocumentSelectorFlatList'; +import { DocumentSelectorFooter } from './DocumentSelectorFooter'; +import { DocumentSelectorTree } from './DocumentSelectorTree'; + +export function DocumentSelectorModal({ + theme, + allDocuments, + documentTree, + taskCounts, + loadingTaskCounts, + documents, + onClose, + onAdd, + onRefresh, +}: DocumentSelectorModalProps) { + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useModalLayer(MODAL_PRIORITIES.DOCUMENT_SELECTOR, 'Select Documents', () => { + onCloseRef.current(); + }); + + const { + selectedDocs, + expandedFolders, + toggleDoc, + selectAll, + deselectAll, + toggleFolder, + toggleFolderSelection, + allSelected, + totalTaskCount, + selectedTaskCount, + } = useDocumentSelection({ + documents, + allDocuments, + taskCounts, + }); + + const { refreshing, refreshMessage, handleRefresh } = useDocumentSelectorRefresh({ + allDocumentsLength: allDocuments.length, + onRefresh, + }); + + return ( +
+ + + + + +
+
+ +
+ {allDocuments.length === 0 ? ( +
+

No documents found in folder

+
+ ) : documentTree && documentTree.length > 0 ? ( + + ) : ( + + )} +
+ + +
+
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/DocumentSelectorTree.tsx b/src/renderer/components/DocumentsPanel/components/DocumentSelectorTree.tsx new file mode 100644 index 0000000000..225f152060 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/DocumentSelectorTree.tsx @@ -0,0 +1,181 @@ +import { ChevronDown, ChevronRight, Folder } from 'lucide-react'; +import type React from 'react'; +import type { Theme } from '../../../types'; +import type { DocTreeNode } from '../types'; +import { + getFilesInNode, + getFolderTaskCount, + isFolderFullySelected, + isFolderPartiallySelected, +} from '../utils/documentTree'; +import { TaskCountBadge } from './TaskCountBadge'; + +interface DocumentSelectorTreeProps { + theme: Theme; + documentTree: DocTreeNode[]; + selectedDocs: Set; + expandedFolders: Set; + taskCounts: Record; + loadingTaskCounts: boolean; + onToggleDoc: (filename: string) => void; + onToggleFolder: (folderPath: string) => void; + onToggleFolderSelection: (node: DocTreeNode) => void; +} + +function CheckMark() { + return ( + + + + ); +} + +export function DocumentSelectorTree({ + theme, + documentTree, + selectedDocs, + expandedFolders, + taskCounts, + loadingTaskCounts, + onToggleDoc, + onToggleFolder, + onToggleFolderSelection, +}: DocumentSelectorTreeProps) { + const renderTreeNode = (node: DocTreeNode, depth = 0): React.ReactNode => { + const paddingLeft = depth * 20 + 12; + + if (node.type === 'folder') { + const isExpanded = expandedFolders.has(node.path); + const isFullySelected = isFolderFullySelected(node, selectedDocs); + const isPartiallySelected = isFolderPartiallySelected(node, selectedDocs); + const filesInFolder = getFilesInNode(node); + const folderTaskCount = getFolderTaskCount(node, taskCounts); + + return ( +
+
+ + + + + + {filesInFolder.length} {filesInFolder.length === 1 ? 'file' : 'files'} + + + +
+ + {isExpanded && node.children && ( +
{node.children.map((child) => renderTreeNode(child, depth + 1))}
+ )} +
+ ); + } + + const isSelected = selectedDocs.has(node.path); + const docTaskCount = taskCounts[node.path] ?? 0; + + return ( + + ); + }; + + return
{documentTree.map((node) => renderTreeNode(node))}
; +} diff --git a/src/renderer/components/DocumentsPanel/components/LoopControls.tsx b/src/renderer/components/DocumentsPanel/components/LoopControls.tsx new file mode 100644 index 0000000000..7eb1468841 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/LoopControls.tsx @@ -0,0 +1,139 @@ +import { Repeat } from 'lucide-react'; +import type { BatchDocumentEntry, Theme } from '../../../types'; + +interface LoopControlsProps { + theme: Theme; + documents: BatchDocumentEntry[]; + loopEnabled: boolean; + setLoopEnabled: (enabled: boolean) => void; + maxLoops: number | null; + setMaxLoops: (maxLoops: number | null) => void; + totalTaskCount: number; + missingDocCount: number; + hasMissingDocs: boolean; + loadingTaskCounts: boolean; +} + +export function LoopControls({ + theme, + documents, + loopEnabled, + setLoopEnabled, + maxLoops, + setMaxLoops, + totalTaskCount, + missingDocCount, + hasMissingDocs, + loadingTaskCounts, +}: LoopControlsProps) { + const showMaxLoopsSlider = maxLoops != null; + + return ( + <> + {documents.length < 2 && ( +

+ You can enable loops with two or more documents +

+ )} + + {documents.length > 1 && ( +
+
+ + + {loopEnabled && ( +
+ + +
+ )} + + {loopEnabled && showMaxLoopsSlider && ( +
+ setMaxLoops(parseInt(e.target.value))} + className="w-32 h-1 rounded-lg appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, ${theme.colors.accent} 0%, ${theme.colors.accent} ${((maxLoops ?? 5) / 25) * 100}%, ${theme.colors.border} ${((maxLoops ?? 5) / 25) * 100}%, ${theme.colors.border} 100%)`, + }} + /> + + {maxLoops} + +
+ )} +
+ + Total: {loadingTaskCounts ? '...' : totalTaskCount} tasks across{' '} + {documents.length - missingDocCount} {hasMissingDocs ? 'available ' : ''}document + {documents.length - missingDocCount !== 1 ? 's' : ''} + {hasMissingDocs && ` (${missingDocCount} missing)`} + +
+ )} + + ); +} diff --git a/src/renderer/components/DocumentsPanel/components/MissingDocumentsWarning.tsx b/src/renderer/components/DocumentsPanel/components/MissingDocumentsWarning.tsx new file mode 100644 index 0000000000..19e433b117 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/MissingDocumentsWarning.tsx @@ -0,0 +1,28 @@ +import { AlertTriangle } from 'lucide-react'; +import type { Theme } from '../../../types'; + +interface MissingDocumentsWarningProps { + theme: Theme; + missingDocCount: number; +} + +export function MissingDocumentsWarning({ theme, missingDocCount }: MissingDocumentsWarningProps) { + if (missingDocCount === 0) return null; + + return ( +
+ + + {missingDocCount} document{missingDocCount > 1 ? 's' : ''} no longer exist + {missingDocCount === 1 ? 's' : ''} in the folder and will be skipped + +
+ ); +} diff --git a/src/renderer/components/DocumentsPanel/components/TaskCountBadge.tsx b/src/renderer/components/DocumentsPanel/components/TaskCountBadge.tsx new file mode 100644 index 0000000000..89c5f61037 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/TaskCountBadge.tsx @@ -0,0 +1,32 @@ +import type { Theme } from '../../../types'; + +interface TaskCountBadgeProps { + theme: Theme; + count: number; + loading: boolean; + zeroTone: 'dim' | 'error'; + className?: string; +} + +export function TaskCountBadge({ + theme, + count, + loading, + zeroTone, + className = '', +}: TaskCountBadgeProps) { + const isZero = count === 0; + const zeroColor = zeroTone === 'error' ? theme.colors.error : theme.colors.textDim; + + return ( + + {loading ? '...' : `${count} ${count === 1 ? 'task' : 'tasks'}`} + + ); +} diff --git a/src/renderer/components/DocumentsPanel/components/index.ts b/src/renderer/components/DocumentsPanel/components/index.ts new file mode 100644 index 0000000000..135865e746 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/components/index.ts @@ -0,0 +1,10 @@ +export * from './CopyDragIndicator'; +export * from './DocumentList'; +export * from './DocumentRow'; +export * from './DocumentSelectorFlatList'; +export * from './DocumentSelectorFooter'; +export * from './DocumentSelectorModal'; +export * from './DocumentSelectorTree'; +export * from './LoopControls'; +export * from './MissingDocumentsWarning'; +export * from './TaskCountBadge'; diff --git a/src/renderer/components/DocumentsPanel/hooks/index.ts b/src/renderer/components/DocumentsPanel/hooks/index.ts new file mode 100644 index 0000000000..d2eb78d309 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './useDocumentDragReorder'; +export * from './useDocumentListActions'; +export * from './useDocumentSelection'; +export * from './useDocumentSelectorRefresh'; diff --git a/src/renderer/components/DocumentsPanel/hooks/useDocumentDragReorder.ts b/src/renderer/components/DocumentsPanel/hooks/useDocumentDragReorder.ts new file mode 100644 index 0000000000..cb65d08354 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/hooks/useDocumentDragReorder.ts @@ -0,0 +1,156 @@ +import { useCallback, useRef, useState } from 'react'; +import type React from 'react'; +import type { BatchDocumentEntry } from '../../../types'; +import { generateId } from '../../../utils/ids'; + +interface UseDocumentDragReorderArgs { + documents: BatchDocumentEntry[]; + setDocuments: React.Dispatch>; +} + +export function useDocumentDragReorder({ documents, setDocuments }: UseDocumentDragReorderArgs) { + const [draggedId, setDraggedId] = useState(null); + const [dropTargetIndex, setDropTargetIndex] = useState(null); + const [isCopyDrag, setIsCopyDrag] = useState(false); + const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null); + + const draggedIdRef = useRef(draggedId); + const dropTargetIndexRef = useRef(dropTargetIndex); + const isCopyDragRef = useRef(isCopyDrag); + const dropPerformedRef = useRef(false); + const isOverDropTargetRef = useRef(false); + draggedIdRef.current = draggedId; + dropTargetIndexRef.current = dropTargetIndex; + isCopyDragRef.current = isCopyDrag; + + const handleDragStart = useCallback((e: React.DragEvent, id: string) => { + dropPerformedRef.current = false; + const isCopy = e.ctrlKey || e.metaKey; + setDraggedId(id); + setIsCopyDrag(isCopy); + e.dataTransfer.effectAllowed = isCopy ? 'copy' : 'move'; + setCursorPosition({ x: e.clientX, y: e.clientY }); + }, []); + + const handleDrag = useCallback((e: React.DragEvent) => { + if (e.clientX !== 0 || e.clientY !== 0) { + setCursorPosition({ x: e.clientX, y: e.clientY }); + } + setIsCopyDrag(e.ctrlKey || e.metaKey); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent, _id: string, index: number) => { + e.preventDefault(); + isOverDropTargetRef.current = true; + const isCopy = e.ctrlKey || e.metaKey; + setIsCopyDrag(isCopy); + e.dataTransfer.dropEffect = isCopy ? 'copy' : 'move'; + + const currentDraggedId = draggedIdRef.current; + if (!currentDraggedId) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const dropIndex = e.clientY < rect.top + rect.height / 2 ? index : index + 1; + + if (isCopy) { + setDropTargetIndex(dropIndex); + } else { + const draggedIndex = documents.findIndex((doc) => doc.id === currentDraggedId); + const isNewPosition = dropIndex !== draggedIndex && dropIndex !== draggedIndex + 1; + setDropTargetIndex(isNewPosition ? dropIndex : null); + } + }, + [documents] + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + const nextTarget = e.relatedTarget; + if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) return; + + isOverDropTargetRef.current = false; + setDropTargetIndex(null); + }, []); + + const performDropOperation = useCallback( + (force = false) => { + const currentDraggedId = draggedIdRef.current; + const currentDropTargetIndex = dropTargetIndexRef.current; + const currentIsCopyDrag = isCopyDragRef.current; + + if ( + currentDraggedId && + currentDropTargetIndex !== null && + !dropPerformedRef.current && + (force || isOverDropTargetRef.current) + ) { + dropPerformedRef.current = true; + setDocuments((prev) => { + const draggedIndex = prev.findIndex((doc) => doc.id === currentDraggedId); + if (draggedIndex === -1) return prev; + + const items = [...prev]; + if (currentIsCopyDrag) { + const original = items[draggedIndex]; + for (let index = 0; index < items.length; index++) { + if (items[index].filename === original.filename) { + items[index] = { ...items[index], resetOnCompletion: true }; + } + } + items.splice(currentDropTargetIndex, 0, { + id: generateId(), + filename: original.filename, + resetOnCompletion: true, + isDuplicate: true, + }); + } else { + const [removed] = items.splice(draggedIndex, 1); + const adjustedIndex = + draggedIndex < currentDropTargetIndex + ? currentDropTargetIndex - 1 + : currentDropTargetIndex; + items.splice(adjustedIndex, 0, removed); + } + return items; + }); + } + }, + [setDocuments] + ); + + const resetDragState = useCallback(() => { + setDraggedId(null); + setDropTargetIndex(null); + setIsCopyDrag(false); + setCursorPosition(null); + isOverDropTargetRef.current = false; + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + performDropOperation(true); + resetDragState(); + }, + [performDropOperation, resetDragState] + ); + + const handleDragEnd = useCallback(() => { + performDropOperation(); + resetDragState(); + }, [performDropOperation, resetDragState]); + + return { + draggedId, + dropTargetIndex, + isCopyDrag, + cursorPosition, + handleDragStart, + handleDrag, + handleDragOver, + handleDragLeave, + handleDrop, + handleDragEnd, + }; +} diff --git a/src/renderer/components/DocumentsPanel/hooks/useDocumentListActions.ts b/src/renderer/components/DocumentsPanel/hooks/useDocumentListActions.ts new file mode 100644 index 0000000000..08bac199d3 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/hooks/useDocumentListActions.ts @@ -0,0 +1,84 @@ +import { useCallback } from 'react'; +import type React from 'react'; +import type { BatchDocumentEntry } from '../../../types'; +import { generateId } from '../../../utils/ids'; + +interface UseDocumentListActionsArgs { + documents: BatchDocumentEntry[]; + setDocuments: React.Dispatch>; + onAddComplete?: () => void; +} + +export function useDocumentListActions({ + documents, + setDocuments, + onAddComplete, +}: UseDocumentListActionsArgs) { + const handleRemoveDocument = useCallback( + (id: string) => { + setDocuments((prev) => prev.filter((doc) => doc.id !== id)); + }, + [setDocuments] + ); + + const handleToggleReset = useCallback( + (id: string) => { + setDocuments((prev) => + prev.map((doc) => + doc.id === id ? { ...doc, resetOnCompletion: !doc.resetOnCompletion } : doc + ) + ); + }, + [setDocuments] + ); + + const handleDuplicateDocument = useCallback( + (id: string) => { + setDocuments((prev) => { + const index = prev.findIndex((doc) => doc.id === id); + if (index === -1) return prev; + + const original = prev[index]; + const duplicate: BatchDocumentEntry = { + id: generateId(), + filename: original.filename, + resetOnCompletion: original.resetOnCompletion, + isDuplicate: true, + }; + + return [...prev.slice(0, index + 1), duplicate, ...prev.slice(index + 1)]; + }); + }, + [setDocuments] + ); + + const handleAddSelectedDocs = useCallback( + (selectedDocs: Set) => { + const existingFilenames = new Set(documents.map((doc) => doc.filename)); + + const newDocs: BatchDocumentEntry[] = []; + selectedDocs.forEach((filename) => { + if (!existingFilenames.has(filename)) { + newDocs.push({ + id: generateId(), + filename, + resetOnCompletion: false, + isDuplicate: false, + }); + } + }); + + const filteredDocs = documents.filter((doc) => selectedDocs.has(doc.filename)); + setDocuments([...filteredDocs, ...newDocs]); + onAddComplete?.(); + }, + [documents, onAddComplete, setDocuments] + ); + + return { + handleRemoveDocument, + handleToggleReset, + handleDuplicateDocument, + handleAddSelectedDocs, + }; +} diff --git a/src/renderer/components/DocumentsPanel/hooks/useDocumentSelection.ts b/src/renderer/components/DocumentsPanel/hooks/useDocumentSelection.ts new file mode 100644 index 0000000000..63512019c6 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/hooks/useDocumentSelection.ts @@ -0,0 +1,96 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { BatchDocumentEntry } from '../../../types'; +import type { DocTreeNode } from '../types'; +import { getFilesInNode } from '../utils/documentTree'; +import { getAllDocumentsTaskCount, getSelectedTaskCount } from '../utils/documentCounts'; + +interface UseDocumentSelectionArgs { + documents: BatchDocumentEntry[]; + allDocuments: string[]; + taskCounts: Record; +} + +export function useDocumentSelection({ + documents, + allDocuments, + taskCounts, +}: UseDocumentSelectionArgs) { + const [selectedDocs, setSelectedDocs] = useState>( + () => new Set(documents.map((doc) => doc.filename)) + ); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + + const toggleDoc = useCallback((filename: string) => { + setSelectedDocs((prev) => { + const next = new Set(prev); + if (next.has(filename)) { + next.delete(filename); + } else { + next.add(filename); + } + return next; + }); + }, []); + + const selectAll = useCallback(() => { + setSelectedDocs(new Set(allDocuments)); + }, [allDocuments]); + + const deselectAll = useCallback(() => { + setSelectedDocs(new Set()); + }, []); + + const toggleFolder = useCallback((folderPath: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderPath)) { + next.delete(folderPath); + } else { + next.add(folderPath); + } + return next; + }); + }, []); + + const toggleFolderSelection = useCallback( + (node: DocTreeNode) => { + const files = getFilesInNode(node); + const allSelected = files.every((file) => selectedDocs.has(file)); + + setSelectedDocs((prev) => { + const next = new Set(prev); + if (allSelected) { + files.forEach((file) => next.delete(file)); + } else { + files.forEach((file) => next.add(file)); + } + return next; + }); + }, + [selectedDocs] + ); + + const allSelected = + allDocuments.length > 0 && allDocuments.every((document) => selectedDocs.has(document)); + const totalTaskCount = useMemo( + () => getAllDocumentsTaskCount(allDocuments, taskCounts), + [allDocuments, taskCounts] + ); + const selectedTaskCount = useMemo( + () => getSelectedTaskCount(selectedDocs, taskCounts), + [selectedDocs, taskCounts] + ); + + return { + selectedDocs, + expandedFolders, + toggleDoc, + selectAll, + deselectAll, + toggleFolder, + toggleFolderSelection, + allSelected, + totalTaskCount, + selectedTaskCount, + }; +} diff --git a/src/renderer/components/DocumentsPanel/hooks/useDocumentSelectorRefresh.ts b/src/renderer/components/DocumentsPanel/hooks/useDocumentSelectorRefresh.ts new file mode 100644 index 0000000000..9be022117b --- /dev/null +++ b/src/renderer/components/DocumentsPanel/hooks/useDocumentSelectorRefresh.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface UseDocumentSelectorRefreshArgs { + allDocumentsLength: number; + onRefresh: () => Promise; +} + +export function useDocumentSelectorRefresh({ + allDocumentsLength, + onRefresh, +}: UseDocumentSelectorRefreshArgs) { + const [refreshing, setRefreshing] = useState(false); + const [refreshMessage, setRefreshMessage] = useState(null); + const prevDocCountRef = useRef(allDocumentsLength); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + setRefreshMessage(null); + + try { + await onRefresh(); + } catch { + // The caller owns user-facing error handling; this hook must always release the spinner. + } finally { + setTimeout(() => { + setRefreshing(false); + }, 500); + } + }, [onRefresh]); + + useEffect(() => { + if (refreshing === false && prevDocCountRef.current !== allDocumentsLength) { + const diff = allDocumentsLength - prevDocCountRef.current; + let message: string; + if (diff > 0) { + message = `Found ${diff} new document${diff === 1 ? '' : 's'}`; + } else if (diff < 0) { + message = `${Math.abs(diff)} document${Math.abs(diff) === 1 ? '' : 's'} removed`; + } else { + message = 'No changes'; + } + setRefreshMessage(message); + prevDocCountRef.current = allDocumentsLength; + + const timer = setTimeout(() => setRefreshMessage(null), 3000); + return () => clearTimeout(timer); + } + }, [allDocumentsLength, refreshing]); + + return { + refreshing, + refreshMessage, + handleRefresh, + }; +} diff --git a/src/renderer/components/DocumentsPanel/index.ts b/src/renderer/components/DocumentsPanel/index.ts new file mode 100644 index 0000000000..218815674a --- /dev/null +++ b/src/renderer/components/DocumentsPanel/index.ts @@ -0,0 +1,2 @@ +export { DocumentsPanel } from './DocumentsPanel'; +export type { DocTreeNode } from './types'; diff --git a/src/renderer/components/DocumentsPanel/types.ts b/src/renderer/components/DocumentsPanel/types.ts new file mode 100644 index 0000000000..69f52578af --- /dev/null +++ b/src/renderer/components/DocumentsPanel/types.ts @@ -0,0 +1,45 @@ +import type React from 'react'; +import type { BatchDocumentEntry, Theme } from '../../types'; + +export interface DocTreeNode { + name: string; + type: 'file' | 'folder'; + path: string; + children?: DocTreeNode[]; +} + +export interface DocumentsPanelProps { + theme: Theme; + documents: BatchDocumentEntry[]; + setDocuments: React.Dispatch>; + taskCounts: Record; + loadingTaskCounts: boolean; + loopEnabled: boolean; + setLoopEnabled: (enabled: boolean) => void; + maxLoops: number | null; + setMaxLoops: (maxLoops: number | null) => void; + allDocuments: string[]; + documentTree?: DocTreeNode[]; + onRefreshDocuments: () => Promise; +} + +export interface DocumentSelectorModalProps { + theme: Theme; + allDocuments: string[]; + documentTree?: DocTreeNode[]; + taskCounts: Record; + loadingTaskCounts: boolean; + documents: BatchDocumentEntry[]; + onClose: () => void; + onAdd: (selectedDocs: Set) => void; + onRefresh: () => Promise; +} + +export interface DragHandlers { + handleDragStart: (e: React.DragEvent, id: string) => void; + handleDrag: (e: React.DragEvent) => void; + handleDragOver: (e: React.DragEvent, id: string, index: number) => void; + handleDragLeave: (e: React.DragEvent) => void; + handleDrop: (e: React.DragEvent) => void; + handleDragEnd: () => void; +} diff --git a/src/renderer/components/DocumentsPanel/utils/documentCounts.ts b/src/renderer/components/DocumentsPanel/utils/documentCounts.ts new file mode 100644 index 0000000000..66b1561168 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/utils/documentCounts.ts @@ -0,0 +1,45 @@ +import type { BatchDocumentEntry } from '../../../types'; + +export function getDocumentTaskCount(taskCounts: Record, filename: string): number { + return taskCounts[filename] ?? 0; +} + +export function getTotalDocumentTaskCount( + documents: BatchDocumentEntry[], + taskCounts: Record +): number { + return documents.reduce((sum, doc) => { + if (doc.isMissing) return sum; + return sum + (taskCounts[doc.filename] || 0); + }, 0); +} + +export function getSelectedTaskCount( + selectedDocs: Set, + taskCounts: Record +): number { + let count = 0; + selectedDocs.forEach((doc) => { + count += taskCounts[doc] ?? 0; + }); + return count; +} + +export function getAllDocumentsTaskCount( + allDocuments: string[], + taskCounts: Record +): number { + return allDocuments.reduce((sum, filename) => sum + (taskCounts[filename] ?? 0), 0); +} + +export function getMissingDocumentCount(documents: BatchDocumentEntry[]): number { + return documents.filter((doc) => doc.isMissing).length; +} + +export function hasDuplicateFilename(documents: BatchDocumentEntry[], filename: string): boolean { + return documents.filter((doc) => doc.filename === filename).length > 1; +} + +export function canDisableReset(documents: BatchDocumentEntry[], filename: string): boolean { + return !hasDuplicateFilename(documents, filename); +} diff --git a/src/renderer/components/DocumentsPanel/utils/documentTree.ts b/src/renderer/components/DocumentsPanel/utils/documentTree.ts new file mode 100644 index 0000000000..36ccf57ca3 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/utils/documentTree.ts @@ -0,0 +1,26 @@ +import type { DocTreeNode } from '../types'; + +export function getFilesInNode(node: DocTreeNode): string[] { + if (node.type === 'file') { + return [node.path]; + } + if (node.children) { + return node.children.flatMap((child) => getFilesInNode(child)); + } + return []; +} + +export function isFolderFullySelected(node: DocTreeNode, selectedDocs: Set): boolean { + const files = getFilesInNode(node); + return files.length > 0 && files.every((file) => selectedDocs.has(file)); +} + +export function isFolderPartiallySelected(node: DocTreeNode, selectedDocs: Set): boolean { + const files = getFilesInNode(node); + const selectedCount = files.filter((file) => selectedDocs.has(file)).length; + return selectedCount > 0 && selectedCount < files.length; +} + +export function getFolderTaskCount(node: DocTreeNode, taskCounts: Record): number { + return getFilesInNode(node).reduce((sum, file) => sum + (taskCounts[file] ?? 0), 0); +} diff --git a/src/renderer/components/DocumentsPanel/utils/index.ts b/src/renderer/components/DocumentsPanel/utils/index.ts new file mode 100644 index 0000000000..12366f9ed1 --- /dev/null +++ b/src/renderer/components/DocumentsPanel/utils/index.ts @@ -0,0 +1,2 @@ +export * from './documentCounts'; +export * from './documentTree';