feat: Add user preference for history item preview line limit

This commit introduces a new user setting allowing you to define the
maximum number of lines displayed in the preview for text or code
clipboard history items.

Key changes:
- Added a "History Item Preview Line Limit" setting in
  `ClipboardHistorySettings.tsx` (defaulting to 5 lines), stored in
  `settingsStore.ts`.
- The `historyPreviewLineLimit` setting is now passed as a prop to
  `ClipboardHistoryRow.tsx` and `ClipboardHistoryQuickPasteRow.tsx`
  from their parent pages (`ClipboardHistoryPage.tsx` and
  `ClipboardHistoryQuickPastePage.tsx` respectively).
- Modified `ClipboardHistoryRow.tsx` and
  `ClipboardHistoryQuickPasteRow.tsx` to truncate the displayed text
  or code preview based on the `historyPreviewLineLimit` prop when
  the item is not expanded.
- The "Show all / Show less" indicators (+X lines / +Y chars) have
  been updated to accurately reflect the truncation based on the new
  line limit.
- Added comprehensive unit tests for both `ClipboardHistoryRow.tsx`
  and `ClipboardHistoryQuickPasteRow.tsx` using Vitest and React
  Testing Library to ensure the new functionality works as expected
  across various scenarios (different limits, content lengths, text vs.
  code, expanded state).
This commit is contained in:
google-labs-jules[bot] 2025-06-20 20:30:21 +00:00
parent 1a1b58a528
commit bfb5821078
8 changed files with 812 additions and 58 deletions

View File

@ -0,0 +1,292 @@
import { render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ClipboardHistoryQuickPasteRow } from './ClipboardHistoryQuickPasteRow'
import { ClipboardHistoryItem } from '~/types/history'
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number }) => {
if (key === 'lines') return options?.count === 1 ? 'line' : 'lines'
if (key === 'chars') return options?.count === 1 ? 'char' : 'chars'
if (key === 'Show all') return 'Show all'
if (key === 'show less') return 'show less'
return key
},
}),
}))
// Mock Lucide icons
vi.mock('lucide-react', async () => {
const actual = await vi.importActual<typeof import('lucide-react')>('lucide-react')
return {
...actual,
WrapIcon: () => <div data-testid="wrap-icon" />,
NoWrapIcon: () => <div data-testid="nowrap-icon" />,
Star: () => <div data-testid="star-icon" />,
Dot: () => <div data-testid="dot-icon" />,
Check: () => <div data-testid="check-icon" />,
}
})
vi.mock('~/components/atoms/image/image-with-fallback-on-error', () => ({
__esModule: true,
default: ({ src }: { src: string }) => <img src={src} alt="mocked image" />,
}))
vi.mock('~/components/atoms/link-card/link-card', () => ({
__esModule: true,
default: () => <div data-testid="link-card-mock">Link Card</div>,
}))
vi.mock('~/components/video-player/YoutubeEmbed', () => ({
__esModule: true,
default: () => <div data-testid="youtube-embed-mock">Youtube Embed</div>,
}))
const createMockClipboardItem = (
id: string,
value: string,
valuePreview?: string,
detectedLanguage?: string,
isLink?: boolean,
isImage?: boolean,
isImageData?: boolean,
isVideo?: boolean
): ClipboardHistoryItem => {
const lines = value.split('\n')
const previewLines = valuePreview?.split('\n') ?? lines
return {
historyId: id,
value,
valuePreview: valuePreview ?? value, // QuickPasteRow uses `value` directly for text sometimes, but preview logic relies on this structure
valueLines: lines.length,
valueMorePreviewLines: previewLines.length < lines.length ? lines.length - previewLines.length : 0,
valueMorePreviewChars: valuePreview && value.length > valuePreview.length ? value.length - valuePreview.length : 0,
detectedLanguage,
isLink: isLink ?? false,
isImage: isImage ?? false,
isImageData: isImageData ?? false,
isVideo: isVideo ?? false,
isFavorite: false,
isPinned: false,
updatedAt: Date.now(),
createdAt: Date.now(),
copiedFromApp: 'test-app',
historyOptions: null,
options: null,
arrLinks: [],
hasEmoji: false,
hasMaskedWords: false,
isMasked: false,
imageHeight: null,
imageWidth: null,
imageDataUrl: null,
linkMetadata: null,
timeAgo: 'just now',
timeAgoShort: 'now',
showTimeAgo: false,
pinnedOrderNumber: 0,
}
}
const defaultProps = {
isDark: false,
showSelectHistoryItems: false,
setBrokenImageItem: vi.fn(),
setSelectHistoryItem: vi.fn(),
onCopy: vi.fn(),
onCopyPaste: vi.fn(),
setKeyboardSelected: vi.fn(),
setExpanded: vi.fn(),
setWrapText: vi.fn(),
setSavingItem: vi.fn(),
setLargeViewItemId: vi.fn(),
invalidateClipboardHistoryQuery: vi.fn(),
isExpanded: false,
isWrapText: false,
isKeyboardSelected: false, // Important for QuickPasteRow
}
describe('ClipboardHistoryQuickPasteRow', () => {
// Text content tests
describe('Text Content Preview', () => {
it('renders default preview lines when historyPreviewLineLimit is not provided', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5\nline6', 'line1\nline2\nline3\nline4\nline5')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line5/)).toBeInTheDocument()
expect(screen.queryByText(/line6/)).not.toBeInTheDocument()
})
it('renders limited lines when historyPreviewLineLimit is set (e.g., 3)', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line3/)).toBeInTheDocument()
expect(screen.queryByText(/line4/)).not.toBeInTheDocument()
expect(screen.getByText(/\.\.\./)).toBeInTheDocument()
expect(screen.getByText(/Show all/)).toHaveTextContent('+2 lines')
})
it('renders all lines if content lines are fewer than historyPreviewLineLimit', () => {
const item = createMockClipboardItem('1', 'line1\nline2')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line2/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument()
expect(screen.queryByText(/Show all/)).not.toBeInTheDocument()
})
it('renders all lines if content lines are equal to historyPreviewLineLimit', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line3/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument()
expect(screen.queryByText(/Show all/)).not.toBeInTheDocument()
})
it('renders full preview when historyPreviewLineLimit is 0', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5\nline6')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={0} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line6/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument()
})
it('renders full content when isExpanded is true, ignoring historyPreviewLineLimit', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5')
render(
<ClipboardHistoryQuickPasteRow
{...defaultProps}
clipboard={item}
historyPreviewLineLimit={2}
isExpanded={true}
/>
)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line5/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument()
expect(screen.getByText(/show less/)).toBeInTheDocument()
})
})
// Code content tests
describe('Code Content Preview (Highlight component)', () => {
const codeItem = (lines: number) => createMockClipboardItem(
'code1',
Array.from({ length: lines }, (_, i) => `const line${i + 1} = ${i + 1};`).join('\n'),
undefined,
'javascript'
)
it('renders limited lines for code when historyPreviewLineLimit is set (e.g., 3)', () => {
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={codeItem(5)} historyPreviewLineLimit={3} />)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line3 = 3;/)).toBeInTheDocument()
expect(screen.queryByText(/const line4 = 4;/)).not.toBeInTheDocument()
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(3)
expect(within(lines[2].closest('div')!).getByText('...')).toBeInTheDocument()
expect(screen.getByText(/Show all/)).toHaveTextContent('+2 lines')
})
it('renders all lines for code if content lines are fewer than historyPreviewLineLimit', () => {
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={codeItem(2)} historyPreviewLineLimit={3} />)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line2 = 2;/)).toBeInTheDocument()
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(2)
lines.forEach(line => {
expect(within(line.closest('div')!).queryByText('...')).not.toBeInTheDocument()
})
expect(screen.queryByText(/Show all/)).not.toBeInTheDocument()
})
it('renders full code preview when historyPreviewLineLimit is 0', () => {
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={codeItem(6)} historyPreviewLineLimit={0} />)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line6 = 6;/)).toBeInTheDocument()
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(6)
lines.forEach(line => {
expect(within(line.closest('div')!).queryByText('...')).not.toBeInTheDocument()
})
})
it('renders full code content when isExpanded is true, ignoring historyPreviewLineLimit', () => {
render(
<ClipboardHistoryQuickPasteRow
{...defaultProps}
clipboard={codeItem(5)}
historyPreviewLineLimit={2}
isExpanded={true}
/>
)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line5 = 5;/)).toBeInTheDocument()
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(5)
lines.forEach(line => {
expect(within(line.closest('div')!).queryByText('...')).not.toBeInTheDocument()
})
expect(screen.getByText(/show less/)).toBeInTheDocument()
})
})
describe('Show all / Show less button text', () => {
it('shows correct "+X lines" when truncated by line limit (text)', () => {
const item = createMockClipboardItem('txt1', 'l1\nl2\nl3\nl4\nl5')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/Show all/)).toHaveTextContent('+2 lines')
})
it('shows correct "+X lines" when truncated by line limit (code)', () => {
const item = createMockClipboardItem('code1', 'c1\nc2\nc3\nc4\nc5', undefined, 'javascript')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={2} />)
expect(screen.getByText(/Show all/)).toHaveTextContent('+3 lines')
})
it('shows correct "+X chars" when truncated by char limit (line limit is 0 or not applicable)', () => {
const longLine = 'a'.repeat(100)
const previewChar = 'a'.repeat(50)
const item = createMockClipboardItem('char1', `${longLine}\nline2`, `${previewChar}\nline2`)
item.valueMorePreviewChars = 50
item.valueMorePreviewLines = 0
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={0} />)
const showAllButton = screen.getByText(/Show all/)
expect(showAllButton).toBeInTheDocument()
expect(showAllButton).toHaveTextContent('+50 chars')
})
it('prioritizes line limit message over char limit message when both could apply', () => {
const longText = Array.from({ length: 5 }, (_, i) => `line${i+1} ` + 'char'.repeat(10)).join('\n');
const previewText = "line1 " + 'char'.repeat(5);
const item = createMockClipboardItem('combo1', longText, previewText);
item.valueMorePreviewLines = 4;
item.valueMorePreviewChars = (("line1 " + 'char'.repeat(10)).length - previewText.length) +
(4 * ("lineX " + 'char'.repeat(10)).length);
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} historyPreviewLineLimit={2} />);
expect(screen.getByText(/Show all/)).toHaveTextContent('+3 lines');
expect(screen.queryByText(/chars/)).not.toBeInTheDocument();
});
it('shows "show less" when expanded', () => {
const item = createMockClipboardItem('any', 'l1\nl2\nl3')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={item} isExpanded={true} />)
expect(screen.getByText(/show less/)).toBeInTheDocument()
})
})
})
// Basic test to confirm the component renders
it('ClipboardHistoryQuickPasteRow renders', () => {
const mockItem = createMockClipboardItem('id1', 'Test content quick paste')
render(<ClipboardHistoryQuickPasteRow {...defaultProps} clipboard={mockItem} />)
expect(screen.getByText('Test content quick paste')).toBeInTheDocument()
})

View File

@ -103,6 +103,7 @@ interface ClipboardHistoryQuickPasteRowProps {
setHistoryFilters?: Dispatch<SetStateAction<string[]>>
setAppFilters?: Dispatch<SetStateAction<string[]>>
isSingleClickToCopyPaste?: boolean
historyPreviewLineLimit?: number
}
// eslint-disable-next-line sonarjs/cognitive-complexity
@ -155,6 +156,7 @@ export function ClipboardHistoryQuickPasteRowComponent({
isDragPreview = false,
setRowHeight = () => {},
isSingleClickToCopyPaste = false,
historyPreviewLineLimit = 5,
}: ClipboardHistoryQuickPasteRowProps) {
const { t } = useTranslation()
const rowRef = useRef<HTMLDivElement>(null)
@ -541,12 +543,21 @@ export function ClipboardHistoryQuickPasteRowComponent({
language={clipboard.detectedLanguage}
>
{({ className, style, tokens, getLineProps, getTokenProps }) => {
const limitedTokens =
historyPreviewLineLimit && historyPreviewLineLimit > 0 && !isExpanded
? tokens.slice(0, historyPreviewLineLimit)
: tokens
const remainingLines = tokens.length - limitedTokens.length
return (
<code className={`${className}`} style={style}>
{tokens.map((line, i) => {
const isLastLine =
i === tokens.length - 1 &&
clipboard.valueMorePreviewLines &&
{limitedTokens.map((line, i) => {
const isLastLineOfPreview = i === limitedTokens.length - 1
const isActuallyLastLineOfAllTokens = i === tokens.length - 1
const showEllipsis =
isLastLineOfPreview &&
!isActuallyLastLineOfAllTokens &&
remainingLines > 0 &&
!isExpanded
return (
<div
@ -567,7 +578,7 @@ export function ClipboardHistoryQuickPasteRowComponent({
: highlightMatchedText(token.content, searchTerm)}
</span>
))}
{isLastLine && <span className="select-none">...</span>}
{showEllipsis && <span className="select-none">...</span>}
</div>
)
})}
@ -575,7 +586,7 @@ export function ClipboardHistoryQuickPasteRowComponent({
)
}}
</Highlight>
{clipboard.valueMorePreviewLines && (
{(remainingLines > 0 || clipboard.valueMorePreviewLines) && !isExpanded && (
<Box className="select-none"> {'\u00A0'} </Box>
)}
</Box>
@ -610,28 +621,38 @@ export function ClipboardHistoryQuickPasteRowComponent({
{searchTerm
? highlightMatchedText(textValue, searchTerm)
: hyperlinkText(textValue, clipboard.arrLinks)}
{clipboard.valueMorePreviewChars && (
{clipboard.valueMorePreviewChars && !isExpanded && (
<Box className="select-none"> {'\u00A0'} </Box>
)}
</code>
) : (
<code className="justify-start cursor-pointer">
{searchTerm
? highlightWithPreviewMatchedText(textValue ?? '', searchTerm)
: hyperlinkTextWithPreview({
previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled,
isPreviewError: hasClipboardHistoryURLErrors,
value: clipboard.valuePreview ?? '',
links: clipboard.arrLinks,
itemId: null,
historyId: clipboard.historyId,
})}
{clipboard.valueMorePreviewChars && (
<>
<span className="select-none">...</span>
<Box className="select-none"> {'\u00A0'} </Box>
</>
)}
<code className="justify-start cursor-pointer whitespace-pre">
{hyperlinkTextWithPreview({
previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled,
isPreviewError: hasClipboardHistoryURLErrors,
value:
historyPreviewLineLimit && historyPreviewLineLimit > 0
? textValue
.split('\n')
.slice(0, historyPreviewLineLimit)
.join('\n')
: clipboard.valuePreview ?? '',
links: clipboard.arrLinks,
itemId: null,
historyId: clipboard.historyId,
searchTerm: searchTerm,
})}
{(historyPreviewLineLimit &&
historyPreviewLineLimit > 0 &&
textValue.split('\n').length > historyPreviewLineLimit
? true
: clipboard.valueMorePreviewChars) &&
!isExpanded && (
<>
<span className="select-none">...</span>
<Box className="select-none"> {'\u00A0'} </Box>
</>
)}
{isMp3 && (
<PlayButton
src={textValue}
@ -647,7 +668,14 @@ export function ClipboardHistoryQuickPasteRowComponent({
)}
</Box>
)}
{(clipboard.valueMorePreviewLines || clipboard.valueMorePreviewChars) && (
{((historyPreviewLineLimit &&
historyPreviewLineLimit > 0 &&
!isExpanded &&
(textValue.split('\n').length > historyPreviewLineLimit ||
(clipboard.detectedLanguage &&
clipboard.valuePreview &&
clipboard.value.split('\n').length > historyPreviewLineLimit))) ||
clipboard.valueMorePreviewChars) && (
<Box
className={`absolute left-1 bottom-1 flex flex-row items-center rounded mb-[2px] pl-0.5 ${bgToolsPanel}`}
>
@ -666,17 +694,30 @@ export function ClipboardHistoryQuickPasteRowComponent({
sideOffset={10}
>
{!isExpanded ? (
clipboard?.valueMorePreviewChars ? (
historyPreviewLineLimit &&
historyPreviewLineLimit > 0 &&
(textValue.split('\n').length > historyPreviewLineLimit ||
(clipboard.detectedLanguage &&
clipboard.valuePreview &&
clipboard.value.split('\n').length > historyPreviewLineLimit)) ? (
<>
+
{clipboard.detectedLanguage
? clipboard.value.split('\n').length - historyPreviewLineLimit
: textValue.split('\n').length - historyPreviewLineLimit}{' '}
{t('lines', { ns: 'common' })}
</>
) : clipboard?.valueMorePreviewChars ? (
<>
+{clipboard.valueMorePreviewChars}{' '}
{t('chars', { ns: 'common' })}
</>
) : (
) : clipboard?.valueMorePreviewLines ? (
<>
+{clipboard.valueMorePreviewLines}{' '}
{t('lines', { ns: 'common' })}
</>
)
) : null
) : (
<>- {t('show less', { ns: 'common' })}</>
)}

View File

@ -0,0 +1,306 @@
import { render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ClipboardHistoryRow } from './ClipboardHistoryRow'
import { ClipboardHistoryItem } from '~/types/history'
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number }) => {
if (key === 'lines') return options?.count === 1 ? 'line' : 'lines'
if (key === 'chars') return options?.count === 1 ? 'char' : 'chars'
if (key === 'Show all') return 'Show all'
if (key === 'show less') return 'show less'
return key
},
}),
}))
// Mock Lucide icons
vi.mock('lucide-react', async () => {
const actual = await vi.importActual<typeof import('lucide-react')>('lucide-react')
return {
...actual,
WrapIcon: () => <div data-testid="wrap-icon" />,
NoWrapIcon: () => <div data-testid="nowrap-icon" />,
Star: () => <div data-testid="star-icon" />,
Dot: () => <div data-testid="dot-icon" />,
Clipboard: () => <div data-testid="clipboard-icon" />,
ClipboardPaste: () => <div data-testid="clipboard-paste-icon" />,
Grip: () => <div data-testid="grip-icon" />,
MoreVertical: () => <div data-testid="more-vertical-icon" />,
MoveUp: () => <div data-testid="move-up-icon" />,
MoveDown: () => <div data-testid="move-down-icon" />,
Check: () => <div data-testid="check-icon" />,
ArrowDownToLine: () => <div data-testid="arrow-down-to-line-icon" />,
X: () => <div data-testid="x-icon" />,
}
})
vi.mock('~/components/atoms/image/image-with-fallback-on-error', () => ({
__esModule: true,
default: ({ src }: { src: string }) => <img src={src} alt="mocked image" />,
}))
vi.mock('~/components/atoms/link-card/link-card', () => ({
__esModule: true,
default: () => <div data-testid="link-card-mock">Link Card</div>,
}))
vi.mock('~/components/video-player/YoutubeEmbed', () => ({
__esModule: true,
default: () => <div data-testid="youtube-embed-mock">Youtube Embed</div>,
}))
const createMockClipboardItem = (
id: string,
value: string,
valuePreview?: string,
detectedLanguage?: string,
isLink?: boolean,
isImage?: boolean,
isImageData?: boolean,
isVideo?: boolean
): ClipboardHistoryItem => {
const lines = value.split('\n')
const previewLines = valuePreview?.split('\n') ?? lines
return {
historyId: id,
value,
valuePreview: valuePreview ?? value,
valueLines: lines.length,
valueMorePreviewLines: previewLines.length < lines.length ? lines.length - previewLines.length : 0,
valueMorePreviewChars: valuePreview && value.length > valuePreview.length ? value.length - valuePreview.length : 0,
detectedLanguage,
isLink: isLink ?? false,
isImage: isImage ?? false,
isImageData: isImageData ?? false,
isVideo: isVideo ?? false,
isFavorite: false,
isPinned: false,
updatedAt: Date.now(),
createdAt: Date.now(),
copiedFromApp: 'test-app',
historyOptions: null,
options: null,
arrLinks: [],
hasEmoji: false,
hasMaskedWords: false,
isMasked: false,
imageHeight: null,
imageWidth: null,
imageDataUrl: null,
linkMetadata: null,
timeAgo: 'just now',
timeAgoShort: 'now',
showTimeAgo: false,
pinnedOrderNumber: 0,
}
}
const defaultProps = {
isDark: false,
showSelectHistoryItems: false,
setBrokenImageItem: vi.fn(),
setSelectHistoryItem: vi.fn(),
onCopy: vi.fn(),
onCopyPaste: vi.fn(),
setExpanded: vi.fn(),
setWrapText: vi.fn(),
setSavingItem: vi.fn(),
setLargeViewItemId: vi.fn(),
invalidateClipboardHistoryQuery: vi.fn(),
isExpanded: false,
isWrapText: false,
isKeyboardSelected: false,
}
describe('ClipboardHistoryRow', () => {
// Text content tests
describe('Text Content Preview', () => {
it('renders default preview lines when historyPreviewLineLimit is not provided', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5\nline6', 'line1\nline2\nline3\nline4\nline5')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line5/)).toBeInTheDocument()
expect(screen.queryByText(/line6/)).not.toBeInTheDocument()
// Assuming default char limit might show '...' or line count based on original component logic
// This test focuses on line limit overriding, so exact char limit assertion isn't primary here.
})
it('renders limited lines when historyPreviewLineLimit is set (e.g., 3)', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line3/)).toBeInTheDocument()
expect(screen.queryByText(/line4/)).not.toBeInTheDocument()
expect(screen.getByText(/\.\.\./)).toBeInTheDocument() // For the ellipsis
expect(screen.getByText(/Show all/)).toHaveTextContent('+2 lines')
})
it('renders all lines if content lines are fewer than historyPreviewLineLimit', () => {
const item = createMockClipboardItem('1', 'line1\nline2')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line2/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument()
expect(screen.queryByText(/Show all/)).not.toBeInTheDocument() // No "Show all" if not truncated
})
it('renders all lines if content lines are equal to historyPreviewLineLimit', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line3/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument()
expect(screen.queryByText(/Show all/)).not.toBeInTheDocument()
})
it('renders full preview when historyPreviewLineLimit is 0', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5\nline6')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={0} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line6/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument() // No line-based ellipsis
// It might still show ellipsis due to character limit if that's separate logic
})
it('renders full content when isExpanded is true, ignoring historyPreviewLineLimit', () => {
const item = createMockClipboardItem('1', 'line1\nline2\nline3\nline4\nline5')
render(
<ClipboardHistoryRow
{...defaultProps}
clipboard={item}
historyPreviewLineLimit={2}
isExpanded={true}
/>
)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(screen.getByText(/line5/)).toBeInTheDocument()
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument()
expect(screen.getByText(/show less/)).toBeInTheDocument()
})
})
// Code content tests
describe('Code Content Preview (Highlight component)', () => {
const codeItem = (lines: number) => createMockClipboardItem(
'code1',
Array.from({ length: lines }, (_, i) => `const line${i + 1} = ${i + 1};`).join('\n'),
undefined, // let valuePreview be same as value initially
'javascript'
)
it('renders limited lines for code when historyPreviewLineLimit is set (e.g., 3)', () => {
render(<ClipboardHistoryRow {...defaultProps} clipboard={codeItem(5)} historyPreviewLineLimit={3} />)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line3 = 3;/)).toBeInTheDocument()
expect(screen.queryByText(/const line4 = 4;/)).not.toBeInTheDocument()
// Highlight component renders each line in a div, check for ellipsis in the last visible line
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(3)
expect(within(lines[2].closest('div')!).getByText('...')).toBeInTheDocument()
expect(screen.getByText(/Show all/)).toHaveTextContent('+2 lines')
})
it('renders all lines for code if content lines are fewer than historyPreviewLineLimit', () => {
render(<ClipboardHistoryRow {...defaultProps} clipboard={codeItem(2)} historyPreviewLineLimit={3} />)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line2 = 2;/)).toBeInTheDocument()
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(2)
lines.forEach(line => {
expect(within(line.closest('div')!).queryByText('...')).not.toBeInTheDocument()
})
expect(screen.queryByText(/Show all/)).not.toBeInTheDocument()
})
it('renders full code preview when historyPreviewLineLimit is 0', () => {
render(<ClipboardHistoryRow {...defaultProps} clipboard={codeItem(6)} historyPreviewLineLimit={0} />)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line6 = 6;/)).toBeInTheDocument()
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(6)
lines.forEach(line => {
expect(within(line.closest('div')!).queryByText('...')).not.toBeInTheDocument()
})
})
it('renders full code content when isExpanded is true, ignoring historyPreviewLineLimit', () => {
render(
<ClipboardHistoryRow
{...defaultProps}
clipboard={codeItem(5)}
historyPreviewLineLimit={2}
isExpanded={true}
/>
)
expect(screen.getByText(/const line1 = 1;/)).toBeInTheDocument()
expect(screen.getByText(/const line5 = 5;/)).toBeInTheDocument()
const lines = screen.getAllByText(/const line/i)
expect(lines.length).toBe(5)
lines.forEach(line => {
expect(within(line.closest('div')!).queryByText('...')).not.toBeInTheDocument()
})
expect(screen.getByText(/show less/)).toBeInTheDocument()
})
})
describe('Show all / Show less button text', () => {
it('shows correct "+X lines" when truncated by line limit (text)', () => {
const item = createMockClipboardItem('txt1', 'l1\nl2\nl3\nl4\nl5')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={3} />)
expect(screen.getByText(/Show all/)).toHaveTextContent('+2 lines')
})
it('shows correct "+X lines" when truncated by line limit (code)', () => {
const item = createMockClipboardItem('code1', 'c1\nc2\nc3\nc4\nc5', undefined, 'javascript')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={2} />)
expect(screen.getByText(/Show all/)).toHaveTextContent('+3 lines')
})
it('shows correct "+X chars" when truncated by char limit (line limit is 0 or not applicable)', () => {
const longLine = 'a'.repeat(100)
const previewChar = 'a'.repeat(50)
const item = createMockClipboardItem('char1', `${longLine}\nline2`, `${previewChar}\nline2`)
item.valueMorePreviewChars = 50 // Manually set for this test case
item.valueMorePreviewLines = 0
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={0} />)
// Check if the "Show all" button exists and then check its content
const showAllButton = screen.getByText(/Show all/)
expect(showAllButton).toBeInTheDocument()
expect(showAllButton).toHaveTextContent('+50 chars')
})
it('prioritizes line limit message over char limit message when both could apply', () => {
const longText = Array.from({ length: 5 }, (_, i) => `line${i+1} ` + 'char'.repeat(10)).join('\n'); // 5 lines, each long
// Preview is only 1 line, and that line is also char limited
const previewText = "line1 " + 'char'.repeat(5);
const item = createMockClipboardItem('combo1', longText, previewText);
// Simulate that the original logic determined these char/line differences based on `valuePreview`
item.valueMorePreviewLines = 4; // 5 total - 1 previewed = 4 more lines
item.valueMorePreviewChars = (("line1 " + 'char'.repeat(10)).length - previewText.length) +
(4 * ("lineX " + 'char'.repeat(10)).length); // remaining chars
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} historyPreviewLineLimit={2} />);
// With historyPreviewLineLimit={2}, it should show "+3 lines" (5 total - 2 displayed)
expect(screen.getByText(/Show all/)).toHaveTextContent('+3 lines');
expect(screen.queryByText(/chars/)).not.toBeInTheDocument();
});
it('shows "show less" when expanded', () => {
const item = createMockClipboardItem('any', 'l1\nl2\nl3')
render(<ClipboardHistoryRow {...defaultProps} clipboard={item} isExpanded={true} />)
expect(screen.getByText(/show less/)).toBeInTheDocument()
})
})
})
// Basic test to confirm the component renders
it('ClipboardHistoryRow renders', () => {
const mockItem = createMockClipboardItem('id1', 'Test content')
render(<ClipboardHistoryRow {...defaultProps} clipboard={mockItem} />)
expect(screen.getByText('Test content')).toBeInTheDocument()
})

View File

@ -122,6 +122,7 @@ interface ClipboardHistoryRowProps {
setHistoryFilters?: Dispatch<SetStateAction<string[]>>
setAppFilters?: Dispatch<SetStateAction<string[]>>
isSingleClickToCopyPaste?: boolean
historyPreviewLineLimit?: number
}
// eslint-disable-next-line sonarjs/cognitive-complexity
@ -180,6 +181,7 @@ export function ClipboardHistoryRowComponent({
setHistoryFilters = () => {},
setAppFilters = () => {},
isSingleClickToCopyPaste = false,
historyPreviewLineLimit = 5,
}: ClipboardHistoryRowProps) {
const { t } = useTranslation()
const rowRef = useRef<HTMLDivElement>(null)
@ -618,13 +620,23 @@ export function ClipboardHistoryRowComponent({
language={clipboard.detectedLanguage}
>
{({ className, style, tokens, getLineProps, getTokenProps }) => {
const limitedTokens =
historyPreviewLineLimit && historyPreviewLineLimit > 0 && !isExpanded
? tokens.slice(0, historyPreviewLineLimit)
: tokens
const remainingLines = tokens.length - limitedTokens.length
return (
<code className={`${className}`} style={style}>
{tokens.map((line, i) => {
const isLastLine =
i === tokens.length - 1 &&
clipboard.valueMorePreviewLines &&
{limitedTokens.map((line, i) => {
const isLastLineOfPreview = i === limitedTokens.length - 1
const isActuallyLastLineOfAllTokens = i === tokens.length - 1
const showEllipsis =
isLastLineOfPreview &&
!isActuallyLastLineOfAllTokens &&
remainingLines > 0 &&
!isExpanded
return (
<div
key={i}
@ -649,7 +661,7 @@ export function ClipboardHistoryRowComponent({
)}
</span>
))}
{isLastLine && (
{showEllipsis && (
<span className="select-none">...</span>
)}
</div>
@ -659,7 +671,7 @@ export function ClipboardHistoryRowComponent({
)
}}
</Highlight>
{clipboard.valueMorePreviewLines && (
{(remainingLines > 0 || clipboard.valueMorePreviewLines) && !isExpanded && (
<Box className="select-none"> {'\u00A0'} </Box>
)}
</Box>
@ -694,31 +706,38 @@ export function ClipboardHistoryRowComponent({
{searchTerm
? highlightMatchedText(stringValue, searchTerm)
: hyperlinkText(stringValue, clipboard.arrLinks)}
{clipboard.valueMorePreviewChars && (
{clipboard.valueMorePreviewChars && !isExpanded && (
<Box className="select-none"> {'\u00A0'} </Box>
)}
</code>
) : (
<code className="justify-start cursor-pointer whitespace-pre">
{searchTerm
? highlightWithPreviewMatchedText(
stringValue ?? '',
searchTerm
)
: hyperlinkTextWithPreview({
previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled,
isPreviewError: hasClipboardHistoryURLErrors,
value: clipboard.valuePreview ?? '',
links: clipboard.arrLinks,
itemId: null,
historyId: clipboard.historyId,
})}
{clipboard.valueMorePreviewChars && (
<>
<span className="select-none">...</span>
<Box className="select-none"> {'\u00A0'} </Box>
</>
)}
{hyperlinkTextWithPreview({
previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled,
isPreviewError: hasClipboardHistoryURLErrors,
value:
historyPreviewLineLimit && historyPreviewLineLimit > 0
? stringValue
.split('\n')
.slice(0, historyPreviewLineLimit)
.join('\n')
: clipboard.valuePreview ?? '',
links: clipboard.arrLinks,
itemId: null,
historyId: clipboard.historyId,
searchTerm: searchTerm,
})}
{(historyPreviewLineLimit &&
historyPreviewLineLimit > 0 &&
stringValue.split('\n').length > historyPreviewLineLimit
? true
: clipboard.valueMorePreviewChars) &&
!isExpanded && (
<>
<span className="select-none">...</span>
<Box className="select-none"> {'\u00A0'} </Box>
</>
)}
{isMp3 && (
<PlayButton
src={stringValue}
@ -734,7 +753,13 @@ export function ClipboardHistoryRowComponent({
)}
</Box>
)}
{(clipboard.valueMorePreviewLines ||
{((historyPreviewLineLimit &&
historyPreviewLineLimit > 0 &&
!isExpanded &&
(stringValue.split('\n').length > historyPreviewLineLimit ||
(clipboard.detectedLanguage &&
clipboard.valuePreview &&
clipboard.value.split('\n').length > historyPreviewLineLimit))) ||
clipboard.valueMorePreviewChars) && (
<Box
className={`absolute left-1 bottom-1 flex flex-row items-center rounded mb-[2px] pl-0.5 ${bgToolsPanel}`}
@ -754,17 +779,33 @@ export function ClipboardHistoryRowComponent({
sideOffset={10}
>
{!isExpanded ? (
clipboard?.valueMorePreviewChars ? (
historyPreviewLineLimit &&
historyPreviewLineLimit > 0 &&
(stringValue.split('\n').length > historyPreviewLineLimit ||
(clipboard.detectedLanguage &&
clipboard.valuePreview &&
clipboard.value.split('\n').length >
historyPreviewLineLimit)) ? (
<>
+
{clipboard.detectedLanguage
? clipboard.value.split('\n').length -
historyPreviewLineLimit
: stringValue.split('\n').length -
historyPreviewLineLimit}{' '}
{t('lines', { ns: 'common' })}
</>
) : clipboard?.valueMorePreviewChars ? (
<>
+{clipboard.valueMorePreviewChars}{' '}
{t('chars', { ns: 'common' })}
</>
) : (
) : clipboard?.valueMorePreviewLines ? (
<>
+{clipboard.valueMorePreviewLines}{' '}
{t('lines', { ns: 'common' })}
</>
)
) : null
) : (
<>- {t('show less', { ns: 'common' })}</>
)}

View File

@ -243,6 +243,7 @@ export default function ClipboardHistoryPage() {
isSimplifiedLayout,
isSavedClipsPanelVisibleOnly,
isSingleClickToCopyPaste,
historyPreviewLineLimit,
} = useAtomValue(settingsStoreAtom)
const { t } = useTranslation()
@ -1522,6 +1523,7 @@ export default function ClipboardHistoryPage() {
isSingleClickToCopyPaste={
isSingleClickToCopyPaste
}
historyPreviewLineLimit={historyPreviewLineLimit}
/>
</Box>
)
@ -2148,6 +2150,7 @@ export default function ClipboardHistoryPage() {
isSingleClickToCopyPaste={
isSingleClickToCopyPaste
}
historyPreviewLineLimit={historyPreviewLineLimit}
index={index}
style={style}
/>
@ -2208,6 +2211,7 @@ export default function ClipboardHistoryPage() {
activeDragId.toString().split('::pinned')[0]
})}
isSingleClickToCopyPaste={isSingleClickToCopyPaste}
historyPreviewLineLimit={historyPreviewLineLimit}
/>
) : null}
</DragOverlay>

View File

@ -123,6 +123,7 @@ export default function ClipboardHistoryQuickPastePage() {
isQuickPasteAutoClose,
isSingleClickToCopyPaste,
isSingleClickToCopyPasteQuickWindow,
historyPreviewLineLimit,
} = useAtomValue(settingsStoreAtom)
const [historyFilters, setHistoryFilters] = useState<string[]>([])
@ -849,6 +850,7 @@ export default function ClipboardHistoryQuickPastePage() {
isSingleClickToCopyPaste ||
isSingleClickToCopyPasteQuickWindow
}
historyPreviewLineLimit={historyPreviewLineLimit}
/>
)
})}
@ -1048,6 +1050,7 @@ export default function ClipboardHistoryQuickPastePage() {
isSingleClickToCopyPaste ||
isSingleClickToCopyPasteQuickWindow
}
historyPreviewLineLimit={historyPreviewLineLimit}
index={index}
style={style}
/>

View File

@ -84,8 +84,10 @@ export default function ClipboardHistorySettings() {
isHistoryEnabled,
clipTextMinLength,
clipTextMaxLength,
historyPreviewLineLimit,
setClipTextMinLength,
setClipTextMaxLength,
setHistoryPreviewLineLimit,
setIsHistoryEnabled,
isImageCaptureDisabled,
setIsImageCaptureDisabled,
@ -357,6 +359,64 @@ export default function ClipboardHistorySettings() {
</Card>
</Box>
<Box className="max-w-xl mt-4 animate-in fade-in">
<Card>
<CardHeader className="pb-1">
<CardTitle className="animate-in fade-in text-md font-medium w-full">
{t('History Item Preview Line Limit', {
ns: 'settings',
})}
</CardTitle>
</CardHeader>
<CardContent>
<Text className="text-sm text-muted-foreground">
{t(
'Set the maximum number of lines to display in the preview of a history item. Setting it to 0 means unlimited.',
{
ns: 'settings',
}
)}
</Text>
<Flex className="w-full gap-10 my-4 items-start justify-start">
<InputField
className="text-md !w-36"
type="number"
step="1"
min={0}
small
label={t('Line limit', { ns: 'common' })}
value={historyPreviewLineLimit}
onBlur={() => {
if (historyPreviewLineLimit < 0) {
setHistoryPreviewLineLimit(0)
}
}}
onChange={e => {
const value = e.target.value
if (value === '') {
setHistoryPreviewLineLimit(0)
} else {
const number = parseInt(value)
setHistoryPreviewLineLimit(number)
}
}}
/>
</Flex>
<Button
variant="secondary"
size="sm"
disabled={historyPreviewLineLimit === 5}
onClick={() => {
setHistoryPreviewLineLimit(5)
}}
className="text-sm bg-slate-200 dark:bg-slate-700 dark:text-slate-200 mt-1"
>
{t('Reset', { ns: 'common' })}
</Button>
</CardContent>
</Card>
</Box>
<Box className="max-w-xl animate-in fade-in mt-4">
<Card
className={`${

View File

@ -89,6 +89,7 @@ type Settings = {
isScreenLockPassCodeRequireOnStart: boolean
clipTextMinLength: number
clipTextMaxLength: number
historyPreviewLineLimit: number
isImageCaptureDisabled: boolean
isMenuItemCopyOnlyEnabled: boolean
isNoteIconsEnabled: boolean
@ -213,6 +214,7 @@ export interface SettingsStoreState {
initSettings: (settings: Settings) => void
setClipTextMinLength: (width: number) => void
setClipTextMaxLength: (height: number) => void
setHistoryPreviewLineLimit: (limit: number) => void
setProtectedCollections: (ids: string[]) => void
setHasPinProtectedCollections: (hasPinProtectedCollections: boolean) => Promise<void>
}
@ -280,6 +282,7 @@ const initialState: SettingsStoreState & Settings = {
isFirstRunAfterUpdate: false,
clipTextMinLength: 0,
clipTextMaxLength: 5000,
historyPreviewLineLimit: 5,
isImageCaptureDisabled: false,
isMenuItemCopyOnlyEnabled: false,
isNoteIconsEnabled: true,
@ -353,6 +356,7 @@ const initialState: SettingsStoreState & Settings = {
setIsShowNavBarItemsOnHoverOnly: () => {},
setClipTextMinLength: () => {},
setClipTextMaxLength: () => {},
setHistoryPreviewLineLimit: () => {},
setIsImageCaptureDisabled: () => {},
setIsMenuItemCopyOnlyEnabled: () => {},
setIsNoteIconsEnabled: () => {},
@ -681,6 +685,9 @@ export const settingsStore = createStore<SettingsStoreState & Settings>()((set,
setClipTextMaxLength: async (length: number) => {
return get().updateSetting('clipTextMaxLength', length)
},
setHistoryPreviewLineLimit: async (limit: number) => {
return get().updateSetting('historyPreviewLineLimit', limit)
},
setIsImageCaptureDisabled: async (isEnabled: boolean) => {
return get().updateSetting('isImageCaptureDisabled', isEnabled)
},