diff --git a/package-lock.json b/package-lock.json index 1937c86..eb2b557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "1.7.4", + "version": "1.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "1.7.4", + "version": "1.7.5", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index d6a722d..f8e4096 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "1.7.4", + "version": "1.7.5", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "Daniel Essig", diff --git a/src/renderer/src/components/clips/QuickClipsScanner.module.css b/src/renderer/src/components/clips/QuickClipsScanner.module.css index d78a019..dc2758c 100644 --- a/src/renderer/src/components/clips/QuickClipsScanner.module.css +++ b/src/renderer/src/components/clips/QuickClipsScanner.module.css @@ -163,13 +163,26 @@ min-width: 0; } +.sectionTitleRow { + display: flex; + align-items: center; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-secondary); +} + +.selectAllLabel { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + width: 100%; +} + .sectionTitle { font-size: 1rem; font-weight: 600; color: var(--text-primary); margin: 0; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-secondary); } /* Matches List */ @@ -291,7 +304,7 @@ .templateButton:disabled { opacity: 0.5; - cursor: wait; + cursor: not-allowed; } .toolDetails { @@ -320,6 +333,19 @@ font-style: italic; } +.templateConflict { + color: #d97706; + font-size: 0.7rem; + display: flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.125rem; +} + +.templateConflict.light { + color: #b45309; +} + /* Accordion */ .accordionSection { border: 1px solid var(--border-secondary); diff --git a/src/renderer/src/components/clips/QuickClipsScanner.test.tsx b/src/renderer/src/components/clips/QuickClipsScanner.test.tsx new file mode 100644 index 0000000..b4799d6 --- /dev/null +++ b/src/renderer/src/components/clips/QuickClipsScanner.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { + computeAmbiguousGroups, + computeInitialSelection, + type CaptureItem, +} from './quickClipsSelection'; + +function item(groupName: string, value: string): CaptureItem { + return { + groupName, + value, + searchTermId: `term-${groupName}`, + uniqueKey: `${groupName}-${value}`, + }; +} + +describe('computeInitialSelection', () => { + it('returns empty set when there are no items', () => { + expect(computeInitialSelection([])).toEqual(new Set()); + }); + + it('selects all items when every group is a singleton', () => { + const items = [item('url', 'a'), item('ticket', 'T-1'), item('email', 'a@b.c')]; + expect(computeInitialSelection(items)).toEqual(new Set(['url-a', 'ticket-T-1', 'email-a@b.c'])); + }); + + it('skips groups with multiple instances', () => { + const items = [item('url', 'a'), item('url', 'b'), item('ticket', 'T-1')]; + expect(computeInitialSelection(items)).toEqual(new Set(['ticket-T-1'])); + }); + + it('selects nothing when every group has multiple instances', () => { + const items = [ + item('url', 'a'), + item('url', 'b'), + item('ticket', 'T-1'), + item('ticket', 'T-2'), + ]; + expect(computeInitialSelection(items)).toEqual(new Set()); + }); + + it('returns empty set when total items exceed 5, even if all are singletons', () => { + const items = [ + item('a', '1'), + item('b', '2'), + item('c', '3'), + item('d', '4'), + item('e', '5'), + item('f', '6'), + ]; + expect(computeInitialSelection(items)).toEqual(new Set()); + }); + + it('still applies the singleton rule at exactly 5 items', () => { + const items = [ + item('url', 'a'), + item('url', 'b'), + item('ticket', 'T-1'), + item('email', 'x@y.z'), + item('phone', '555'), + ]; + expect(computeInitialSelection(items)).toEqual( + new Set(['ticket-T-1', 'email-x@y.z', 'phone-555']) + ); + }); +}); + +describe('computeAmbiguousGroups', () => { + it('returns empty when nothing is selected', () => { + const items = [item('url', 'a'), item('url', 'b')]; + expect(computeAmbiguousGroups(items, new Set())).toEqual(new Set()); + }); + + it('returns empty when each selected group has a single value', () => { + const items = [item('url', 'a'), item('url', 'b'), item('ticket', 'T-1')]; + expect(computeAmbiguousGroups(items, new Set(['url-a', 'ticket-T-1']))).toEqual(new Set()); + }); + + it('flags a group when more than one value is selected for it', () => { + const items = [item('url', 'a'), item('url', 'b'), item('ticket', 'T-1')]; + expect(computeAmbiguousGroups(items, new Set(['url-a', 'url-b', 'ticket-T-1']))).toEqual( + new Set(['url']) + ); + }); + + it('flags multiple groups independently', () => { + const items = [ + item('url', 'a'), + item('url', 'b'), + item('ticket', 'T-1'), + item('ticket', 'T-2'), + item('email', 'x@y.z'), + ]; + const selected = new Set(['url-a', 'url-b', 'ticket-T-1', 'ticket-T-2', 'email-x@y.z']); + expect(computeAmbiguousGroups(items, selected)).toEqual(new Set(['url', 'ticket'])); + }); + + it('ignores unselected items even if other items in the group are selected', () => { + const items = [item('url', 'a'), item('url', 'b'), item('url', 'c')]; + expect(computeAmbiguousGroups(items, new Set(['url-a']))).toEqual(new Set()); + }); +}); diff --git a/src/renderer/src/components/clips/QuickClipsScanner.tsx b/src/renderer/src/components/clips/QuickClipsScanner.tsx index 0a34df6..2f496d5 100644 --- a/src/renderer/src/components/clips/QuickClipsScanner.tsx +++ b/src/renderer/src/components/clips/QuickClipsScanner.tsx @@ -4,13 +4,11 @@ import { PatternMatch, QuickTool, Template } from '../../../../shared/types'; import { useTheme } from '../../providers/theme'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import styles from './QuickClipsScanner.module.css'; - -interface CaptureItem { - groupName: string; - value: string; - searchTermId: string; - uniqueKey: string; -} +import { + computeInitialSelection, + computeAmbiguousGroups, + type CaptureItem, +} from './quickClipsSelection'; interface QuickClipsScannerProps { isOpen: boolean; @@ -139,6 +137,19 @@ export function QuickClipsScanner({ } }, [matchedTemplates, accordionInitialized]); + // When tools become available, auto-expand that section + useEffect(() => { + if (!accordionInitialized) return; + if (availableTools.length > 0) { + setExpandedSections((prev) => { + if (prev.has('tools')) return prev; + const next = new Set(prev); + next.add('tools'); + return next; + }); + } + }, [availableTools, accordionInitialized]); + const toggleSection = (section: AccordionSection) => { setExpandedSections((prev) => { const next = new Set(prev); @@ -199,7 +210,7 @@ export function QuickClipsScanner({ const items = Array.from(captureMap.values()); setCaptureItems(items); - setSelectedCaptureItems(new Set(items.map((item) => item.uniqueKey))); + setSelectedCaptureItems(computeInitialSelection(items)); } catch (error) { console.error('Failed to scan content:', error); setMatches([]); @@ -296,6 +307,22 @@ export function QuickClipsScanner({ } }, [captureItems, templates, selectedCaptureItems, updateMatchedTemplates]); + const allCapturesSelected = + captureItems.length > 0 && selectedCaptureItems.size === captureItems.length; + + const ambiguousGroups = useMemo( + () => computeAmbiguousGroups(captureItems, selectedCaptureItems), + [captureItems, selectedCaptureItems] + ); + + const handleToggleAllCaptures = () => { + if (allCapturesSelected) { + setSelectedCaptureItems(new Set()); + } else { + setSelectedCaptureItems(new Set(captureItems.map((item) => item.uniqueKey))); + } + }; + const handleCaptureItemToggle = (uniqueKey: string) => { const newSelected = new Set(selectedCaptureItems); if (newSelected.has(uniqueKey)) { @@ -447,9 +474,27 @@ export function QuickClipsScanner({ <> {/* Left Side - Capture Items Section */}