From bfb582107890d3d738e8c66a897059168482fdc3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:30:21 +0000 Subject: [PATCH 1/5] 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). --- .../ClipboardHistoryQuickPasteRow.test.tsx | 292 +++++++++++++++++ .../ClipboardHistoryQuickPasteRow.tsx | 97 ++++-- .../ClipboardHistoryRow.test.tsx | 306 ++++++++++++++++++ .../ClipboardHistory/ClipboardHistoryRow.tsx | 101 ++++-- .../src/pages/main/ClipboardHistoryPage.tsx | 4 + .../main/ClipboardHistoryQuickPastePage.tsx | 3 + .../settings/ClipboardHistorySettings.tsx | 60 ++++ .../src/store/settingsStore.ts | 7 + 8 files changed, 812 insertions(+), 58 deletions(-) create mode 100644 packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx create mode 100644 packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx new file mode 100644 index 00000000..578d1d4e --- /dev/null +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx @@ -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('lucide-react') + return { + ...actual, + WrapIcon: () =>
, + NoWrapIcon: () =>
, + Star: () =>
, + Dot: () =>
, + Check: () =>
, + } +}) + +vi.mock('~/components/atoms/image/image-with-fallback-on-error', () => ({ + __esModule: true, + default: ({ src }: { src: string }) => mocked image, +})) + +vi.mock('~/components/atoms/link-card/link-card', () => ({ + __esModule: true, + default: () =>
Link Card
, +})) + +vi.mock('~/components/video-player/YoutubeEmbed', () => ({ + __esModule: true, + default: () =>
Youtube Embed
, +})) + + +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() + 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() + 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() + 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() + 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() + 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( + + ) + 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() + 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() + 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() + 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( + + ) + 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() + 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() + 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() + 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(); + 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() + 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() + expect(screen.getByText('Test content quick paste')).toBeInTheDocument() +}) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx index 036c06a9..bfe030b1 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx @@ -103,6 +103,7 @@ interface ClipboardHistoryQuickPasteRowProps { setHistoryFilters?: Dispatch> setAppFilters?: Dispatch> 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(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 ( - {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 (
))} - {isLastLine && ...} + {showEllipsis && ...}
) })} @@ -575,7 +586,7 @@ export function ClipboardHistoryQuickPasteRowComponent({ ) }} - {clipboard.valueMorePreviewLines && ( + {(remainingLines > 0 || clipboard.valueMorePreviewLines) && !isExpanded && ( {'\u00A0'} )} @@ -610,28 +621,38 @@ export function ClipboardHistoryQuickPasteRowComponent({ {searchTerm ? highlightMatchedText(textValue, searchTerm) : hyperlinkText(textValue, clipboard.arrLinks)} - {clipboard.valueMorePreviewChars && ( + {clipboard.valueMorePreviewChars && !isExpanded && ( {'\u00A0'} )}
) : ( - - {searchTerm - ? highlightWithPreviewMatchedText(textValue ?? '', searchTerm) - : hyperlinkTextWithPreview({ - previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, - isPreviewError: hasClipboardHistoryURLErrors, - value: clipboard.valuePreview ?? '', - links: clipboard.arrLinks, - itemId: null, - historyId: clipboard.historyId, - })} - {clipboard.valueMorePreviewChars && ( - <> - ... - {'\u00A0'} - - )} + + {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 && ( + <> + ... + {'\u00A0'} + + )} {isMp3 && ( )} - {(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) && ( @@ -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' })} )} diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx new file mode 100644 index 00000000..2868ee3b --- /dev/null +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx @@ -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('lucide-react') + return { + ...actual, + WrapIcon: () =>
, + NoWrapIcon: () =>
, + Star: () =>
, + Dot: () =>
, + Clipboard: () =>
, + ClipboardPaste: () =>
, + Grip: () =>
, + MoreVertical: () =>
, + MoveUp: () =>
, + MoveDown: () =>
, + Check: () =>
, + ArrowDownToLine: () =>
, + X: () =>
, + } +}) + +vi.mock('~/components/atoms/image/image-with-fallback-on-error', () => ({ + __esModule: true, + default: ({ src }: { src: string }) => mocked image, +})) + +vi.mock('~/components/atoms/link-card/link-card', () => ({ + __esModule: true, + default: () =>
Link Card
, +})) + +vi.mock('~/components/video-player/YoutubeEmbed', () => ({ + __esModule: true, + default: () =>
Youtube Embed
, +})) + +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() + 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() + 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() + 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() + 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() + 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( + + ) + 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() + 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() + 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() + 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( + + ) + 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() + 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() + 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() + // 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(); + // 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() + expect(screen.getByText(/show less/)).toBeInTheDocument() + }) + }) +}) + +// Basic test to confirm the component renders +it('ClipboardHistoryRow renders', () => { + const mockItem = createMockClipboardItem('id1', 'Test content') + render() + expect(screen.getByText('Test content')).toBeInTheDocument() +}) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx index 177679dc..f2f12d64 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx @@ -122,6 +122,7 @@ interface ClipboardHistoryRowProps { setHistoryFilters?: Dispatch> setAppFilters?: Dispatch> 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(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 ( - {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 (
))} - {isLastLine && ( + {showEllipsis && ( ... )}
@@ -659,7 +671,7 @@ export function ClipboardHistoryRowComponent({ ) }} - {clipboard.valueMorePreviewLines && ( + {(remainingLines > 0 || clipboard.valueMorePreviewLines) && !isExpanded && ( {'\u00A0'} )} @@ -694,31 +706,38 @@ export function ClipboardHistoryRowComponent({ {searchTerm ? highlightMatchedText(stringValue, searchTerm) : hyperlinkText(stringValue, clipboard.arrLinks)} - {clipboard.valueMorePreviewChars && ( + {clipboard.valueMorePreviewChars && !isExpanded && ( {'\u00A0'} )}
) : ( - {searchTerm - ? highlightWithPreviewMatchedText( - stringValue ?? '', - searchTerm - ) - : hyperlinkTextWithPreview({ - previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, - isPreviewError: hasClipboardHistoryURLErrors, - value: clipboard.valuePreview ?? '', - links: clipboard.arrLinks, - itemId: null, - historyId: clipboard.historyId, - })} - {clipboard.valueMorePreviewChars && ( - <> - ... - {'\u00A0'} - - )} + {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 && ( + <> + ... + {'\u00A0'} + + )} {isMp3 && ( )} - {(clipboard.valueMorePreviewLines || + {((historyPreviewLineLimit && + historyPreviewLineLimit > 0 && + !isExpanded && + (stringValue.split('\n').length > historyPreviewLineLimit || + (clipboard.detectedLanguage && + clipboard.valuePreview && + clipboard.value.split('\n').length > historyPreviewLineLimit))) || clipboard.valueMorePreviewChars) && ( {!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' })} )} diff --git a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx index eef5c573..bf585ad5 100644 --- a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx +++ b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryPage.tsx @@ -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} /> ) @@ -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} diff --git a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryQuickPastePage.tsx b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryQuickPastePage.tsx index 849d451d..c2634519 100644 --- a/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryQuickPastePage.tsx +++ b/packages/pastebar-app-ui/src/pages/main/ClipboardHistoryQuickPastePage.tsx @@ -123,6 +123,7 @@ export default function ClipboardHistoryQuickPastePage() { isQuickPasteAutoClose, isSingleClickToCopyPaste, isSingleClickToCopyPasteQuickWindow, + historyPreviewLineLimit, } = useAtomValue(settingsStoreAtom) const [historyFilters, setHistoryFilters] = useState([]) @@ -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} /> diff --git a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx index 42ae97c6..6b92a7ac 100644 --- a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx +++ b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx @@ -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() { + + + + + {t('History Item Preview Line Limit', { + ns: 'settings', + })} + + + + + {t( + 'Set the maximum number of lines to display in the preview of a history item. Setting it to 0 means unlimited.', + { + ns: 'settings', + } + )} + + + { + if (historyPreviewLineLimit < 0) { + setHistoryPreviewLineLimit(0) + } + }} + onChange={e => { + const value = e.target.value + if (value === '') { + setHistoryPreviewLineLimit(0) + } else { + const number = parseInt(value) + setHistoryPreviewLineLimit(number) + } + }} + /> + + + + + + void setClipTextMinLength: (width: number) => void setClipTextMaxLength: (height: number) => void + setHistoryPreviewLineLimit: (limit: number) => void setProtectedCollections: (ids: string[]) => void setHasPinProtectedCollections: (hasPinProtectedCollections: boolean) => Promise } @@ -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()((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) }, From c10ec6347eadb51d7a71b531c5f4dec905afeba3 Mon Sep 17 00:00:00 2001 From: Sergey Kurdin Date: Fri, 20 Jun 2025 23:09:54 -0400 Subject: [PATCH 2/5] feat: Introduce setting for history item preview line limit Added a new user preference to control max lines displayed in history item previews, improving customization options --- .changeset/red-tools-kick.md | 5 + packages/pastebar-app-ui/src/App.tsx | 1 + .../pastebar-app-ui/src/QuickPasteApp.tsx | 1 + .../src/components/code-viewer/index.tsx | 2 +- .../src/locales/lang/de/settings2.yaml | 3 + .../src/locales/lang/en/settings2.yaml | 45 +-- .../src/locales/lang/esES/settings2.yaml | 3 + .../src/locales/lang/fr/settings2.yaml | 3 + .../src/locales/lang/it/settings2.yaml | 3 + .../src/locales/lang/ru/settings2.yaml | 3 + .../src/locales/lang/tr/settings2.yaml | 3 + .../src/locales/lang/uk/settings2.yaml | 3 + .../src/locales/lang/zhCN/settings2.yaml | 3 + .../ClipboardHistoryLargeView.tsx | 69 ++-- .../ClipboardHistoryQuickPasteRow.test.tsx | 292 ----------------- .../ClipboardHistoryQuickPasteRow.tsx | 145 ++++----- .../ClipboardHistoryRow.test.tsx | 306 ------------------ .../ClipboardHistory/ClipboardHistoryRow.tsx | 224 ++++++------- .../ClipboardHistoryRowContextMenu.tsx | 2 +- .../components/Dashboard/components/utils.ts | 21 +- .../Menu/components/MenuCardViewBody.tsx | 2 +- .../settings/ClipboardHistorySettings.tsx | 110 ++++--- .../src/store/settingsStore.ts | 10 +- 23 files changed, 338 insertions(+), 921 deletions(-) create mode 100644 .changeset/red-tools-kick.md delete mode 100644 packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx delete mode 100644 packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx diff --git a/.changeset/red-tools-kick.md b/.changeset/red-tools-kick.md new file mode 100644 index 00000000..d0b9e350 --- /dev/null +++ b/.changeset/red-tools-kick.md @@ -0,0 +1,5 @@ +--- +'pastebar-app-ui': patch +--- + +Added: history item preview max lines user setting diff --git a/packages/pastebar-app-ui/src/App.tsx b/packages/pastebar-app-ui/src/App.tsx index bc21d906..359a18f3 100644 --- a/packages/pastebar-app-ui/src/App.tsx +++ b/packages/pastebar-app-ui/src/App.tsx @@ -129,6 +129,7 @@ function App() { isCustomDbPathValid: null, customDbPathError: null, dbRelocationInProgress: false, + historyPreviewLineLimit: settings.historyPreviewLineLimit?.valueInt ?? null, appLastUpdateVersion: settings.appLastUpdateVersion?.valueText, appLastUpdateDate: settings.appLastUpdateDate?.valueText, isHideMacOSDockIcon: settings.isHideMacOSDockIcon?.valueBool, diff --git a/packages/pastebar-app-ui/src/QuickPasteApp.tsx b/packages/pastebar-app-ui/src/QuickPasteApp.tsx index 53785f20..9f7cb7c0 100644 --- a/packages/pastebar-app-ui/src/QuickPasteApp.tsx +++ b/packages/pastebar-app-ui/src/QuickPasteApp.tsx @@ -49,6 +49,7 @@ function QuickPasteApp() { settingsStore.initSettings({ appDataDir: '', isSingleClickToCopyPaste: settings.isSingleClickToCopyPaste?.valueBool, + historyPreviewLineLimit: settings.historyPreviewLineLimit?.valueInt ?? null, appLastUpdateVersion: settings.appLastUpdateVersion?.valueText, appLastUpdateDate: settings.appLastUpdateDate?.valueText, isHideMacOSDockIcon: settings.isHideMacOSDockIcon?.valueBool, diff --git a/packages/pastebar-app-ui/src/components/code-viewer/index.tsx b/packages/pastebar-app-ui/src/components/code-viewer/index.tsx index b02f968a..7f650dbf 100644 --- a/packages/pastebar-app-ui/src/components/code-viewer/index.tsx +++ b/packages/pastebar-app-ui/src/components/code-viewer/index.tsx @@ -181,7 +181,7 @@ export const CodeViewer: FC = ({ return ( ) @@ -291,9 +299,6 @@ export function ClipboardHistoryLargeViewComponent({ {searchTerm ? highlightMatchedText(textValue, searchTerm) : hyperlinkText(textValue, clipboard.arrLinks)} - {clipboard.valueMorePreviewChars && ( - {'\u00A0'} - )} )} diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx deleted file mode 100644 index 578d1d4e..00000000 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.test.tsx +++ /dev/null @@ -1,292 +0,0 @@ -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('lucide-react') - return { - ...actual, - WrapIcon: () =>
, - NoWrapIcon: () =>
, - Star: () =>
, - Dot: () =>
, - Check: () =>
, - } -}) - -vi.mock('~/components/atoms/image/image-with-fallback-on-error', () => ({ - __esModule: true, - default: ({ src }: { src: string }) => mocked image, -})) - -vi.mock('~/components/atoms/link-card/link-card', () => ({ - __esModule: true, - default: () =>
Link Card
, -})) - -vi.mock('~/components/video-player/YoutubeEmbed', () => ({ - __esModule: true, - default: () =>
Youtube Embed
, -})) - - -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() - 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() - 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() - 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() - 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() - 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( - - ) - 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() - 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() - 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() - 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( - - ) - 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() - 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() - 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() - 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(); - 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() - 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() - expect(screen.getByText('Test content quick paste')).toBeInTheDocument() -}) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx index bfe030b1..9cea2e51 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx @@ -13,6 +13,7 @@ import WrapIcon from '~/assets/icons/wrap' import { MINUTE_IN_MS } from '~/constants' import { isEmailNotUrl } from '~/libs/utils' import { formatLocale as format } from '~/locales/date-locales' +import { getValuePreview } from '~/pages/components/Dashboard/components/utils' import { hoveringHistoryRowId, isKeyAltPressed, isKeyCtrlPressed } from '~/store' import { Check, Dot, Star } from 'lucide-react' import { Highlight, themes } from 'prism-react-renderer' @@ -103,7 +104,7 @@ interface ClipboardHistoryQuickPasteRowProps { setHistoryFilters?: Dispatch> setAppFilters?: Dispatch> isSingleClickToCopyPaste?: boolean - historyPreviewLineLimit?: number + historyPreviewLineLimit?: number | null } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -117,8 +118,6 @@ export function ClipboardHistoryQuickPasteRowComponent({ isPinnedTop = false, isPinnedTopFirst = false, isWindows, - isDisabledPinnedMoveUp = false, - isDisabledPinnedMoveDown = false, isExpanded = false, isSelected = false, isWrapText = false, @@ -156,15 +155,13 @@ export function ClipboardHistoryQuickPasteRowComponent({ isDragPreview = false, setRowHeight = () => {}, isSingleClickToCopyPaste = false, - historyPreviewLineLimit = 5, + historyPreviewLineLimit, }: ClipboardHistoryQuickPasteRowProps) { const { t } = useTranslation() const rowRef = useRef(null) const rowKeyboardRef = useRef(null) const isCopiedOrPasted = isCopied || isPasted || isSaved - console.log('isSingleClickToCopyPaste', isSingleClickToCopyPaste) - const contentElementRendered = useSignal(false) const contextMenuOpen = useSignal(false) @@ -261,6 +258,41 @@ export function ClipboardHistoryQuickPasteRowComponent({ const isNowItem = index === 0 && clipboard.updatedAt > Date.now() - MINUTE_IN_MS const isMp3 = clipboard?.isLink && clipboard?.value?.endsWith('.mp3') + // Recalculate preview with custom line limit if provided + const { valuePreview, valueMorePreviewLines, valueMorePreviewChars } = useMemo(() => { + if (historyPreviewLineLimit && historyPreviewLineLimit > 0 && clipboard?.value) { + const result = getValuePreview( + clipboard.value, + clipboard.isImageData || false, + isExpanded, + historyPreviewLineLimit, + true + ) + // console.log('result', result) + return { + valuePreview: result.valuePreview, + valueMorePreviewLines: result.morePreviewLines, + valueMorePreviewChars: result.morePreviewChars, + } + } + // Use default preview from backend + return { + valuePreview: clipboard?.valuePreview || '', + valueMorePreviewLines: clipboard?.valueMorePreviewLines || null, + valueMorePreviewChars: clipboard?.valueMorePreviewChars || null, + } + }, [ + historyPreviewLineLimit, + clipboard?.value, + clipboard?.isImageData, + isExpanded, + clipboard?.valuePreview, + clipboard?.valueMorePreviewLines, + clipboard?.valueMorePreviewChars, + ]) + + console.log('valuePreview', valuePreview) + useEffect(() => { if ( !hasLinkCard && @@ -478,7 +510,7 @@ export function ClipboardHistoryQuickPasteRowComponent({ {searchTerm ? ( highlightWithPreviewMatchedText(clipboard.value, searchTerm) ) : ( - {clipboard.valuePreview} + {valuePreview} )} @@ -528,7 +560,7 @@ export function ClipboardHistoryQuickPasteRowComponent({ className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" /> - ) : clipboard.detectedLanguage && clipboard.valuePreview ? ( + ) : clipboard.detectedLanguage && valuePreview ? ( { if (ref) { @@ -539,25 +571,16 @@ export function ClipboardHistoryQuickPasteRowComponent({ > {({ className, style, tokens, getLineProps, getTokenProps }) => { - const limitedTokens = - historyPreviewLineLimit && historyPreviewLineLimit > 0 && !isExpanded - ? tokens.slice(0, historyPreviewLineLimit) - : tokens - const remainingLines = tokens.length - limitedTokens.length return ( - {limitedTokens.map((line, i) => { - const isLastLineOfPreview = i === limitedTokens.length - 1 - const isActuallyLastLineOfAllTokens = i === tokens.length - 1 - - const showEllipsis = - isLastLineOfPreview && - !isActuallyLastLineOfAllTokens && - remainingLines > 0 && + {tokens.map((line, i) => { + const isLastLine = + i === tokens.length - 1 && + valueMorePreviewLines && !isExpanded return (
))} - {showEllipsis && ...} + {isLastLine && ...}
) })} @@ -586,9 +609,6 @@ export function ClipboardHistoryQuickPasteRowComponent({ ) }}
- {(remainingLines > 0 || clipboard.valueMorePreviewLines) && !isExpanded && ( - {'\u00A0'} - )}
) : ( {'\u00A0'} - )} ) : ( - {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 && ( - <> - ... - {'\u00A0'} - - )} + {searchTerm + ? highlightWithPreviewMatchedText(textValue ?? '', searchTerm) + : hyperlinkTextWithPreview({ + previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, + isPreviewError: hasClipboardHistoryURLErrors, + value: valuePreview ?? '', + links: clipboard.arrLinks, + itemId: null, + historyId: clipboard.historyId, + })} {isMp3 && ( )} - {((historyPreviewLineLimit && - historyPreviewLineLimit > 0 && - !isExpanded && - (textValue.split('\n').length > historyPreviewLineLimit || - (clipboard.detectedLanguage && - clipboard.valuePreview && - clipboard.value.split('\n').length > historyPreviewLineLimit))) || - clipboard.valueMorePreviewChars) && ( + {(valueMorePreviewLines || valueMorePreviewChars) && ( @@ -694,30 +688,15 @@ export function ClipboardHistoryQuickPasteRowComponent({ sideOffset={10} > {!isExpanded ? ( - historyPreviewLineLimit && - historyPreviewLineLimit > 0 && - (textValue.split('\n').length > historyPreviewLineLimit || - (clipboard.detectedLanguage && - clipboard.valuePreview && - clipboard.value.split('\n').length > historyPreviewLineLimit)) ? ( + valueMorePreviewChars ? ( <> - + - {clipboard.detectedLanguage - ? clipboard.value.split('\n').length - historyPreviewLineLimit - : textValue.split('\n').length - historyPreviewLineLimit}{' '} - {t('lines', { ns: 'common' })} + +{valueMorePreviewChars} {t('chars', { ns: 'common' })} - ) : clipboard?.valueMorePreviewChars ? ( + ) : ( <> - +{clipboard.valueMorePreviewChars}{' '} - {t('chars', { ns: 'common' })} + +{valueMorePreviewLines} {t('lines', { ns: 'common' })} - ) : clipboard?.valueMorePreviewLines ? ( - <> - +{clipboard.valueMorePreviewLines}{' '} - {t('lines', { ns: 'common' })} - - ) : null + ) ) : ( <>- {t('show less', { ns: 'common' })} )} diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx deleted file mode 100644 index 2868ee3b..00000000 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.test.tsx +++ /dev/null @@ -1,306 +0,0 @@ -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('lucide-react') - return { - ...actual, - WrapIcon: () =>
, - NoWrapIcon: () =>
, - Star: () =>
, - Dot: () =>
, - Clipboard: () =>
, - ClipboardPaste: () =>
, - Grip: () =>
, - MoreVertical: () =>
, - MoveUp: () =>
, - MoveDown: () =>
, - Check: () =>
, - ArrowDownToLine: () =>
, - X: () =>
, - } -}) - -vi.mock('~/components/atoms/image/image-with-fallback-on-error', () => ({ - __esModule: true, - default: ({ src }: { src: string }) => mocked image, -})) - -vi.mock('~/components/atoms/link-card/link-card', () => ({ - __esModule: true, - default: () =>
Link Card
, -})) - -vi.mock('~/components/video-player/YoutubeEmbed', () => ({ - __esModule: true, - default: () =>
Youtube Embed
, -})) - -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() - 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() - 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() - 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() - 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() - 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( - - ) - 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() - 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() - 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() - 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( - - ) - 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() - 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() - 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() - // 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(); - // 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() - expect(screen.getByText(/show less/)).toBeInTheDocument() - }) - }) -}) - -// Basic test to confirm the component renders -it('ClipboardHistoryRow renders', () => { - const mockItem = createMockClipboardItem('id1', 'Test content') - render() - expect(screen.getByText('Test content')).toBeInTheDocument() -}) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx index a475e6e7..a32bde1e 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx @@ -13,6 +13,7 @@ import WrapIcon from '~/assets/icons/wrap' import { MINUTE_IN_MS } from '~/constants' import { isEmailNotUrl } from '~/libs/utils' import { formatLocale as format } from '~/locales/date-locales' +import { getValuePreview } from '~/pages/components/Dashboard/components/utils' import { hoveringHistoryRowId, isKeyAltPressed, @@ -181,7 +182,7 @@ export function ClipboardHistoryRowComponent({ setHistoryFilters = () => {}, setAppFilters = () => {}, isSingleClickToCopyPaste = false, - historyPreviewLineLimit = 5, + historyPreviewLineLimit, }: ClipboardHistoryRowProps) { const { t } = useTranslation() const rowRef = useRef(null) @@ -226,6 +227,7 @@ export function ClipboardHistoryRowComponent({ // eslint-disable-next-line }, [ contentElementRendered.value, + historyPreviewLineLimit, rowRef.current?.clientHeight, setRowHeight, timeAgo, @@ -287,6 +289,38 @@ export function ClipboardHistoryRowComponent({ const isNowItem = index === 0 && clipboard.updatedAt > Date.now() - MINUTE_IN_MS const isMp3 = clipboard?.isLink && clipboard?.value?.endsWith('.mp3') + // Recalculate preview with custom line limit if provided + const { valuePreview, valueMorePreviewLines, valueMorePreviewChars } = useMemo(() => { + if (historyPreviewLineLimit && historyPreviewLineLimit > 0 && clipboard?.value) { + const result = getValuePreview( + clipboard.value, + clipboard.isImageData || false, + isExpanded, + historyPreviewLineLimit, + true + ) + return { + valuePreview: result.valuePreview, + valueMorePreviewLines: result.morePreviewLines, + valueMorePreviewChars: result.morePreviewChars, + } + } + // Use default preview from backend + return { + valuePreview: clipboard?.valuePreview || '', + valueMorePreviewLines: clipboard?.valueMorePreviewLines || null, + valueMorePreviewChars: clipboard?.valueMorePreviewChars || null, + } + }, [ + historyPreviewLineLimit, + clipboard?.value, + clipboard?.isImageData, + isExpanded, + clipboard?.valuePreview, + clipboard?.valueMorePreviewLines, + clipboard?.valueMorePreviewChars, + ]) + useEffect(() => { if ( !hasLinkCard && @@ -556,7 +590,7 @@ export function ClipboardHistoryRowComponent({ {searchTerm ? ( highlightWithPreviewMatchedText(clipboard.value, searchTerm) ) : ( - {clipboard.valuePreview} + {valuePreview} )} @@ -606,7 +640,7 @@ export function ClipboardHistoryRowComponent({ className="max-w-full max-h-56 min-h-10 rounded-md shadow-sm border border-slate-100 dark:border-slate-700" /> - ) : clipboard.detectedLanguage && clipboard.valuePreview ? ( + ) : clipboard.detectedLanguage && valuePreview ? ( { if (ref) { @@ -617,27 +651,13 @@ export function ClipboardHistoryRowComponent({ > {({ className, style, tokens, getLineProps, getTokenProps }) => { - const limitedTokens = - historyPreviewLineLimit && historyPreviewLineLimit > 0 && !isExpanded - ? tokens.slice(0, historyPreviewLineLimit) - : tokens - const remainingLines = tokens.length - limitedTokens.length return ( - {limitedTokens.map((line, i) => { - const isLastLineOfPreview = i === limitedTokens.length - 1 - const isActuallyLastLineOfAllTokens = i === tokens.length - 1 - - const showEllipsis = - isLastLineOfPreview && - !isActuallyLastLineOfAllTokens && - remainingLines > 0 && - !isExpanded - + {tokens.map((line, i) => { return (
))} - {showEllipsis && ( - ... - )}
) })} @@ -672,9 +689,6 @@ export function ClipboardHistoryRowComponent({ ) }}
- {(remainingLines > 0 || clipboard.valueMorePreviewLines) && !isExpanded && ( - {'\u00A0'} - )}
) : ( {'\u00A0'} - )} ) : ( - {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 && ( - <> - ... - {'\u00A0'} - - )} + {searchTerm + ? highlightWithPreviewMatchedText( + stringValue ?? '', + searchTerm + ) + : hyperlinkTextWithPreview({ + previewLinkCard: !hasLinkCard && isLinkCardPreviewEnabled, + isPreviewError: hasClipboardHistoryURLErrors, + value: valuePreview ?? '', + links: clipboard.arrLinks, + itemId: null, + historyId: clipboard.historyId, + })} {isMp3 && ( )} - {((historyPreviewLineLimit && - historyPreviewLineLimit > 0 && - !isExpanded && - (stringValue.split('\n').length > historyPreviewLineLimit || - (clipboard.detectedLanguage && - clipboard.valuePreview && - clipboard.value.split('\n').length > historyPreviewLineLimit))) || - clipboard.valueMorePreviewChars) && ( - + {(valueMorePreviewLines || valueMorePreviewChars) && + !isCopiedOrPasted && ( { - setExpanded(clipboard.historyId, !isExpanded) - }} + className={`absolute left-1 bottom-1 flex flex-row items-center rounded mb-[2px] pl-0.5 ${bgToolsPanel}`} > - - {!isExpanded ? ( - 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' })} - )} - - - {isExpanded && ( setWrapText(clipboard.historyId, !isWrapText)} + className={`text-xs text-muted-foreground px-1 cursor-pointer`} + onClick={() => { + setExpanded(clipboard.historyId, !isExpanded) + }} > - {!isWrapText ? ( - + {!isExpanded ? ( + valueMorePreviewChars ? ( + <> + +{valueMorePreviewChars} {t('chars', { ns: 'common' })} + + ) : ( + <> + +{valueMorePreviewLines} {t('lines', { ns: 'common' })} + + ) ) : ( - + <>- {t('show less', { ns: 'common' })} )} - )} - - )} + {isExpanded && ( + setWrapText(clipboard.historyId, !isWrapText)} + > + + {!isWrapText ? ( + + ) : ( + + )} + + + )} + + )} {clipboard.isImage && !clipboard.isLink && ( 0 + ? historyPreviewLineLimit + : 5 const normalizedValue = value.replace(/\r\n/g, '\n') const allLines = normalizedValue.split('\n') @@ -305,18 +310,6 @@ export function getValuePreview( finalPreviewText = bbCode.closeTags(finalPreviewText) } - if (calculatedMorePreviewLines > 0) { - // Add ellipsis if lines were actually truncated. - // Avoid adding if bbCode.closeTags might have added its own form of ellipsis or if preview ends with one. - if (!finalPreviewText.trim().endsWith('...')) { - // Check if the last line of previewText is just "..." from a previous logic - const linesInPreview = finalPreviewText.split('\n') - if (linesInPreview[linesInPreview.length - 1] !== '...') { - finalPreviewText += '\n...' - } - } - } - return { valuePreview: finalPreviewText, morePreviewLines: calculatedMorePreviewLines > 0 ? calculatedMorePreviewLines : null, diff --git a/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx b/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx index 5b5f4c49..fd027fd5 100644 --- a/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx +++ b/packages/pastebar-app-ui/src/pages/components/Menu/components/MenuCardViewBody.tsx @@ -90,7 +90,7 @@ export function MenuCardViewBody({ const { t } = useTranslation() const isWrapText = useSignal(false) - const { valuePreview, morePreviewLines, morePreviewChars } = getValuePreview(value) + const { valuePreview, morePreviewLines, morePreviewChars } = getValuePreview(value, false, false) const textValue: string = value || '' const isBrokenImage = useSignal(false) const pathTypeCheck = useSignal('') diff --git a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx index 6b92a7ac..811a1f70 100644 --- a/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx +++ b/packages/pastebar-app-ui/src/pages/settings/ClipboardHistorySettings.tsx @@ -360,59 +360,85 @@ export default function ClipboardHistorySettings() { - - + + - {t('History Item Preview Line Limit', { - ns: 'settings', + {t('History Item Preview Max Lines', { + ns: 'settings2', })} + { + if (historyPreviewLineLimit) { + setHistoryPreviewLineLimit(null) + } else { + setHistoryPreviewLineLimit(5) + } + }} + /> {t( - 'Set the maximum number of lines to display in the preview of a history item. Setting it to 0 means unlimited.', + 'Set the maximum number of lines to display in the preview of a history item', { - ns: 'settings', + ns: 'settings2', } )} - - { - if (historyPreviewLineLimit < 0) { - setHistoryPreviewLineLimit(0) - } - }} - onChange={e => { - const value = e.target.value - if (value === '') { - setHistoryPreviewLineLimit(0) - } else { - const number = parseInt(value) - setHistoryPreviewLineLimit(number) - } - }} - /> - - + {historyPreviewLineLimit !== null && ( + <> + + { + if ( + historyPreviewLineLimit && + historyPreviewLineLimit < 0 + ) { + setHistoryPreviewLineLimit(null) + } + }} + onChange={e => { + const value = e.target.value + if (value === '') { + setHistoryPreviewLineLimit(null) + } else { + const number = parseInt(value, 10) + setHistoryPreviewLineLimit(number) + } + }} + /> + + + + )} diff --git a/packages/pastebar-app-ui/src/store/settingsStore.ts b/packages/pastebar-app-ui/src/store/settingsStore.ts index dc2b468f..e6069b82 100644 --- a/packages/pastebar-app-ui/src/store/settingsStore.ts +++ b/packages/pastebar-app-ui/src/store/settingsStore.ts @@ -89,7 +89,7 @@ type Settings = { isScreenLockPassCodeRequireOnStart: boolean clipTextMinLength: number clipTextMaxLength: number - historyPreviewLineLimit: number + historyPreviewLineLimit: number | null isImageCaptureDisabled: boolean isMenuItemCopyOnlyEnabled: boolean isNoteIconsEnabled: boolean @@ -216,7 +216,7 @@ export interface SettingsStoreState { initSettings: (settings: Settings) => void setClipTextMinLength: (width: number) => void setClipTextMaxLength: (height: number) => void - setHistoryPreviewLineLimit: (limit: number) => void + setHistoryPreviewLineLimit: (limit: number | null) => void setProtectedCollections: (ids: string[]) => void setHasPinProtectedCollections: (hasPinProtectedCollections: boolean) => Promise setGlobalTemplatesEnabled: (isEnabled: boolean) => void @@ -716,7 +716,11 @@ export const settingsStore = createStore()((set, setClipTextMaxLength: async (length: number) => { return get().updateSetting('clipTextMaxLength', length) }, - setHistoryPreviewLineLimit: async (limit: number) => { + setHistoryPreviewLineLimit: async (limit: number | null) => { + if (limit === 0) { + limit = null + } + get().syncStateUpdate('historyPreviewLineLimit', limit) return get().updateSetting('historyPreviewLineLimit', limit) }, setIsImageCaptureDisabled: async (isEnabled: boolean) => { From 618ba3ae0201017d50e492dba97ff9687f0a290b Mon Sep 17 00:00:00 2001 From: Sergey Kurdin Date: Fri, 20 Jun 2025 23:12:58 -0400 Subject: [PATCH 3/5] chore: comment removed --- .../components/ClipboardHistory/ClipboardHistoryLargeView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryLargeView.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryLargeView.tsx index d89180de..604ca5aa 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryLargeView.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryLargeView.tsx @@ -217,7 +217,6 @@ export function ClipboardHistoryLargeViewComponent({ {tokens .filter((line, i) => { - // Remove last line if it's empty (only contains whitespace or newlines) if (i === tokens.length - 1) { return line.some(token => token.content.trim() !== '') } From 9ba408d45e26dcea1e7b495b2ae307d3b9a7b297 Mon Sep 17 00:00:00 2001 From: Sergey Kurdin Date: Fri, 20 Jun 2025 23:14:31 -0400 Subject: [PATCH 4/5] chore: remove redundant comments in ClipboardHistory components for cleaner code readability --- .../ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx | 4 +--- .../pages/components/ClipboardHistory/ClipboardHistoryRow.tsx | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx index 9cea2e51..09f4286b 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx @@ -258,7 +258,6 @@ export function ClipboardHistoryQuickPasteRowComponent({ const isNowItem = index === 0 && clipboard.updatedAt > Date.now() - MINUTE_IN_MS const isMp3 = clipboard?.isLink && clipboard?.value?.endsWith('.mp3') - // Recalculate preview with custom line limit if provided const { valuePreview, valueMorePreviewLines, valueMorePreviewChars } = useMemo(() => { if (historyPreviewLineLimit && historyPreviewLineLimit > 0 && clipboard?.value) { const result = getValuePreview( @@ -268,14 +267,13 @@ export function ClipboardHistoryQuickPasteRowComponent({ historyPreviewLineLimit, true ) - // console.log('result', result) return { valuePreview: result.valuePreview, valueMorePreviewLines: result.morePreviewLines, valueMorePreviewChars: result.morePreviewChars, } } - // Use default preview from backend + return { valuePreview: clipboard?.valuePreview || '', valueMorePreviewLines: clipboard?.valueMorePreviewLines || null, diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx index a32bde1e..b7afc6e0 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryRow.tsx @@ -289,7 +289,6 @@ export function ClipboardHistoryRowComponent({ const isNowItem = index === 0 && clipboard.updatedAt > Date.now() - MINUTE_IN_MS const isMp3 = clipboard?.isLink && clipboard?.value?.endsWith('.mp3') - // Recalculate preview with custom line limit if provided const { valuePreview, valueMorePreviewLines, valueMorePreviewChars } = useMemo(() => { if (historyPreviewLineLimit && historyPreviewLineLimit > 0 && clipboard?.value) { const result = getValuePreview( @@ -305,7 +304,6 @@ export function ClipboardHistoryRowComponent({ valueMorePreviewChars: result.morePreviewChars, } } - // Use default preview from backend return { valuePreview: clipboard?.valuePreview || '', valueMorePreviewLines: clipboard?.valueMorePreviewLines || null, From 161b589f95012b9e8ec6103eeb8f144cc7c60c87 Mon Sep 17 00:00:00 2001 From: Sergey Kurdin Date: Fri, 20 Jun 2025 23:28:36 -0400 Subject: [PATCH 5/5] chore: removed console.log --- .../ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx index 09f4286b..90b0c433 100644 --- a/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx +++ b/packages/pastebar-app-ui/src/pages/components/ClipboardHistory/ClipboardHistoryQuickPasteRow.tsx @@ -289,8 +289,6 @@ export function ClipboardHistoryQuickPasteRowComponent({ clipboard?.valueMorePreviewChars, ]) - console.log('valuePreview', valuePreview) - useEffect(() => { if ( !hasLinkCard &&