Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
32 changes: 29 additions & 3 deletions src/renderer/src/components/clips/QuickClipsScanner.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -291,7 +304,7 @@

.templateButton:disabled {
opacity: 0.5;
cursor: wait;
cursor: not-allowed;
}

.toolDetails {
Expand Down Expand Up @@ -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);
Expand Down
102 changes: 102 additions & 0 deletions src/renderer/src/components/clips/QuickClipsScanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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());
});
});
164 changes: 115 additions & 49 deletions src/renderer/src/components/clips/QuickClipsScanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -447,9 +474,27 @@ export function QuickClipsScanner({
<>
{/* Left Side - Capture Items Section */}
<div className={classNames(styles.section, styles.leftSection)}>
<h3 className={classNames(styles.sectionTitle, { [styles.light]: isLight })}>
Found Patterns ({captureItems.length})
</h3>
<div className={classNames(styles.sectionTitleRow, { [styles.light]: isLight })}>
<label className={styles.selectAllLabel}>
<input
type="checkbox"
checked={allCapturesSelected}
ref={(el) => {
if (el) {
el.indeterminate = selectedCaptureItems.size > 0 && !allCapturesSelected;
}
}}
onChange={handleToggleAllCaptures}
className={styles.checkbox}
aria-label={
allCapturesSelected ? 'Deselect all patterns' : 'Select all patterns'
}
/>
<h3 className={classNames(styles.sectionTitle, { [styles.light]: isLight })}>
Found Patterns ({captureItems.length})
</h3>
</label>
</div>
<div className={styles.matchesList}>
{captureItems.map((item) => {
const isSelected = selectedCaptureItems.has(item.uniqueKey);
Expand Down Expand Up @@ -664,46 +709,67 @@ export function QuickClipsScanner({
function renderTemplateList(templateList: Template[], showNamedTokens: boolean) {
return (
<div className={styles.toolsList}>
{templateList.map((template) => (
<div
key={template.id}
className={classNames(styles.toolItem, { [styles.light]: isLight })}
>
<button
className={classNames(styles.toolLabel, styles.templateButton)}
onClick={() => handleTemplateSelect(template)}
disabled={generatingTemplateId === template.id}
{templateList.map((template) => {
const conflicts = showNamedTokens
? extractNamedTokens(template.content).filter((t) => ambiguousGroups.has(t))
: [];
const isAmbiguous = conflicts.length > 0;
const disabled = generatingTemplateId === template.id || isAmbiguous;

return (
<div
key={template.id}
className={classNames(styles.toolItem, { [styles.light]: isLight })}
>
<div className={styles.toolDetails}>
<div
className={classNames(styles.toolName, {
[styles.light]: isLight,
})}
>
{generatingTemplateId === template.id ? (
<FontAwesomeIcon icon="spinner" spin />
) : (
<FontAwesomeIcon icon="file-lines" />
)}{' '}
{template.name}
</div>
<div
className={classNames(styles.toolGroups, {
[styles.light]: isLight,
})}
>
Tokens:{' '}
{showNamedTokens
? extractAllTokens(template.content).join(', ')
: extractAllTokens(template.content)
.filter((t) => /^c\d+$/.test(t))
.map((t) => `{${t}}`)
.join(', ')}
<button
className={classNames(styles.toolLabel, styles.templateButton)}
onClick={() => handleTemplateSelect(template)}
disabled={disabled}
title={
isAmbiguous
? `Multiple values selected for ${conflicts.map((c) => `"${c}"`).join(', ')}. Pick a single value per group to use this template.`
: undefined
}
>
<div className={styles.toolDetails}>
<div
className={classNames(styles.toolName, {
[styles.light]: isLight,
})}
>
{generatingTemplateId === template.id ? (
<FontAwesomeIcon icon="spinner" spin />
) : (
<FontAwesomeIcon icon="file-lines" />
)}{' '}
{template.name}
</div>
<div
className={classNames(styles.toolGroups, {
[styles.light]: isLight,
})}
>
Tokens:{' '}
{showNamedTokens
? extractAllTokens(template.content).join(', ')
: extractAllTokens(template.content)
.filter((t) => /^c\d+$/.test(t))
.map((t) => `{${t}}`)
.join(', ')}
</div>
{isAmbiguous && (
<div
className={classNames(styles.templateConflict, { [styles.light]: isLight })}
>
<FontAwesomeIcon icon="triangle-exclamation" /> Pick a single value for{' '}
{conflicts.map((c) => `"${c}"`).join(', ')} to use this template.
</div>
)}
</div>
</div>
</button>
</div>
))}
</button>
</div>
);
})}
</div>
);
}
Expand Down
Loading
Loading