From 9a942a417d5062f1f1c306d6678bd8f6c7bfc373 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Mon, 10 Nov 2025 14:49:39 +0100 Subject: [PATCH] fix: fix logviewer selection behavior Signed-off-by: Katharina Sick --- .changeset/floppy-bobcats-serve.md | 12 + .../components/LogViewer/RealLogViewer.tsx | 205 ++++++----- .../src/components/LogViewer/styles.ts | 6 + .../LogViewer/useLogViewerSelection.test.tsx | 341 +++++++++++++++--- .../LogViewer/useLogViewerSelection.tsx | 217 +++++++++-- 5 files changed, 613 insertions(+), 168 deletions(-) create mode 100644 .changeset/floppy-bobcats-serve.md diff --git a/.changeset/floppy-bobcats-serve.md b/.changeset/floppy-bobcats-serve.md new file mode 100644 index 0000000000..d698e1482c --- /dev/null +++ b/.changeset/floppy-bobcats-serve.md @@ -0,0 +1,12 @@ +--- +'@backstage/core-components': patch +--- + +Fixed bug in the `LogViewer` component where shift + click always opened a new window instead of just changing the selection. + +In addition, improved the `LogViewer` component by a few usability enhancements: + +- Added support for multiple selections using cmd/ctrl + click +- Improved the generated hash that is added to the URL to also support ranges & multiple selections +- Added an hover effect & info tooltip to the "Copy to clipboard" button to indicate its functionality +- Added some color and a separator to the line numbers to improve readability diff --git a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx index e384a8e882..1936061b1c 100644 --- a/packages/core-components/src/components/LogViewer/RealLogViewer.tsx +++ b/packages/core-components/src/components/LogViewer/RealLogViewer.tsx @@ -21,7 +21,7 @@ import classnames from 'classnames'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { VariableSizeList, FixedSizeList } from 'react-window'; +import { FixedSizeList, VariableSizeList } from 'react-window'; import { AnsiLine, AnsiProcessor } from './AnsiProcessor'; import { LogLine } from './LogLine'; @@ -29,6 +29,7 @@ import { LogViewerControls } from './LogViewerControls'; import { HEADER_SIZE, useStyles } from './styles'; import { useLogViewerSearch } from './useLogViewerSearch'; import { useLogViewerSelection } from './useLogViewerSelection'; +import Snackbar from '@material-ui/core/Snackbar'; export interface RealLogViewerProps { text: string; @@ -47,6 +48,7 @@ export function RealLogViewer(props: RealLogViewerProps) { // The processor keeps state that optimizes appending to the text const processor = useMemo(() => new AnsiProcessor(), []); const lines = processor.process(props.text); + const [showCopyInfo, setShowCopyInfo] = useState(false); const search = useLogViewerSearch(lines); const selection = useLogViewerSelection(lines); @@ -69,19 +71,39 @@ export function RealLogViewer(props: RealLogViewerProps) { } }, [listInstance, search.resultLine, lines]); + useEffect(() => { + const hash = selection.getHash(); + if (hash.length > 0) { + history.replaceState(null, '', hash); + } + }, [selection]); + useEffect(() => { if (location.hash) { - // #line-6 -> 6 - const line = parseInt(location.hash.replace(/\D/g, ''), 10); - selection.setSelection(line, false); + selection.selectAll(location.hash); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleSelectLine = ( line: number, - event: { shiftKey: boolean; preventDefault: () => void }, + event: { + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + preventDefault: () => void; + }, ) => { - selection.setSelection(line, event.shiftKey); + event.preventDefault(); + selection.setSelection( + line, + event.shiftKey, + event.metaKey || event.ctrlKey, + ); + }; + + const handleCopySelection = (line: number) => { + selection.copySelection(line); + setShowCopyInfo(true); }; function setRowHeight(index: number, size: number) { @@ -97,90 +119,99 @@ export function RealLogViewer(props: RealLogViewerProps) { } return ( - - {({ height, width }: { height?: number; width?: number }) => { - const commonProps = { - ref: setListInstance, - className: classes.log, - height: (height || 480) - HEADER_SIZE, - width: width || 640, - itemData: search.lines, - itemCount: search.lines.length, - }; + <> + + {({ height, width }: { height?: number; width?: number }) => { + const commonProps = { + ref: setListInstance, + className: classes.log, + height: (height || 480) - HEADER_SIZE, + width: width || 640, + itemData: search.lines, + itemCount: search.lines.length, + }; - const renderItem = ({ - index, - style, - data, - }: { - index: number; - style: React.CSSProperties; - data: AnsiLine[]; - }) => { - const line = data[index]; - const { lineNumber } = line; - return ( - - {selection.shouldShowButton(lineNumber) && ( - selection.copySelection()} - > - - - )} - handleSelectLine(lineNumber, event)} - onKeyPress={event => handleSelectLine(lineNumber, event)} + const renderItem = ({ + index, + style, + data, + }: { + index: number; + style: React.CSSProperties; + data: AnsiLine[]; + }) => { + const line = data[index]; + const { lineNumber } = line; + return ( + - {lineNumber} - - + {selection.shouldShowCopyButton(lineNumber) && ( + handleCopySelection(lineNumber)} + > + + + )} + handleSelectLine(lineNumber, event)} + onKeyPress={event => handleSelectLine(lineNumber, event)} + > + {lineNumber} + + + + ); + }; + + return ( + + + + + {shouldTextWrap ? ( + + {...commonProps} + itemSize={getRowHeight} + > + {renderItem} + + ) : ( + {...commonProps} itemSize={20}> + {renderItem} + + )} ); - }; - - return ( - - - - - {shouldTextWrap ? ( - - {...commonProps} - itemSize={getRowHeight} - > - {renderItem} - - ) : ( - {...commonProps} itemSize={20}> - {renderItem} - - )} - - ); - }} - + }} + + setShowCopyInfo(false)} + message="Lines copied to clipboard" + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> + ); } diff --git a/packages/core-components/src/components/LogViewer/styles.ts b/packages/core-components/src/components/LogViewer/styles.ts index 76e045ca32..83fb3a84ad 100644 --- a/packages/core-components/src/components/LogViewer/styles.ts +++ b/packages/core-components/src/components/LogViewer/styles.ts @@ -89,14 +89,20 @@ export const useStyles = makeStyles( position: 'absolute', paddingTop: 0, paddingBottom: 0, + '&:hover': { + color: theme.palette.linkHover, + }, }, lineNumber: { display: 'inline-block', textAlign: 'end', width: 60, + paddingRight: theme.spacing(1), marginRight: theme.spacing(1), cursor: 'pointer', flexShrink: 0, + color: colors.blue[300], + borderRight: `1px solid ${colors.blue[700]}`, }, textHighlight: { background: alpha(theme.palette.info.main, 0.15), diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx index 5314f4e7f6..13280d38a4 100644 --- a/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.test.tsx @@ -16,7 +16,7 @@ import { PropsWithChildren } from 'react'; import { act, renderHook } from '@testing-library/react'; -import { TestApiProvider, MockErrorApi } from '@backstage/test-utils'; +import { MockErrorApi, TestApiProvider } from '@backstage/test-utils'; import { errorApiRef } from '@backstage/core-plugin-api'; import { AnsiLine } from './AnsiProcessor'; import { useLogViewerSelection } from './useLogViewerSelection'; @@ -35,10 +35,26 @@ const lines = [ new AnsiLine(3, [{ text: '3', modifiers: {} }]), new AnsiLine(4, [{ text: '4', modifiers: {} }]), new AnsiLine(5, [{ text: '5', modifiers: {} }]), + new AnsiLine(6, [{ text: '6', modifiers: {} }]), + new AnsiLine(7, [{ text: '7', modifiers: {} }]), ]; +const expectSelectedLines = (rendered: any, selectedLines: number[]) => { + expect(rendered.result.current.isSelected(1)).toBe(selectedLines.includes(1)); + expect(rendered.result.current.isSelected(2)).toBe(selectedLines.includes(2)); + expect(rendered.result.current.isSelected(3)).toBe(selectedLines.includes(3)); + expect(rendered.result.current.isSelected(4)).toBe(selectedLines.includes(4)); + expect(rendered.result.current.isSelected(5)).toBe(selectedLines.includes(5)); + expect(rendered.result.current.isSelected(6)).toBe(selectedLines.includes(6)); + expect(rendered.result.current.isSelected(7)).toBe(selectedLines.includes(7)); +}; + describe('useLogViewerSelection', () => { - it('should manage a selection', () => { + beforeEach(() => { + (copyToClipboard as jest.Mock).mockClear(); + }); + + it('should select a new line when clicked', () => { const rendered = renderHook(() => useLogViewerSelection(lines), { wrapper: ({ children }: PropsWithChildren<{}>) => ( @@ -47,77 +63,290 @@ describe('useLogViewerSelection', () => { ), }); - expect(rendered.result.current.isSelected(1)).toBe(false); - expect(rendered.result.current.isSelected(2)).toBe(false); - expect(rendered.result.current.isSelected(3)).toBe(false); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(5, false, false)); + expectSelectedLines(rendered, [5]); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); + }); - expect(rendered.result.current.shouldShowButton(1)).toBe(false); - expect(rendered.result.current.shouldShowButton(2)).toBe(false); - expect(rendered.result.current.shouldShowButton(3)).toBe(false); + it('should deselect a selected line when clicked', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); - act(() => rendered.result.current.setSelection(2, false)); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, []); + }); - expect(rendered.result.current.isSelected(1)).toBe(false); - expect(rendered.result.current.isSelected(2)).toBe(true); - expect(rendered.result.current.isSelected(3)).toBe(false); + it('should select a new line on shift+click if nothing is selected', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); - expect(rendered.result.current.shouldShowButton(1)).toBe(false); - expect(rendered.result.current.shouldShowButton(2)).toBe(true); - expect(rendered.result.current.shouldShowButton(3)).toBe(false); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, true, false)); + expectSelectedLines(rendered, [2]); + }); - act(() => rendered.result.current.setSelection(3, false)); + it('should deselect a single line on shift+click', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); - expect(rendered.result.current.isSelected(1)).toBe(false); - expect(rendered.result.current.isSelected(2)).toBe(false); - expect(rendered.result.current.isSelected(3)).toBe(true); - expect(rendered.result.current.isSelected(4)).toBe(false); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, true, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(2, true, false)); + expectSelectedLines(rendered, []); + }); - expect(rendered.result.current.shouldShowButton(1)).toBe(false); - expect(rendered.result.current.shouldShowButton(2)).toBe(false); - expect(rendered.result.current.shouldShowButton(3)).toBe(true); - expect(rendered.result.current.shouldShowButton(4)).toBe(false); + it('should select a range below on shift+click', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); - act(() => rendered.result.current.setSelection(1, true)); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, true, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(5, true, false)); + expectSelectedLines(rendered, [2, 3, 4, 5]); + }); - expect(rendered.result.current.isSelected(1)).toBe(true); - expect(rendered.result.current.isSelected(2)).toBe(true); - expect(rendered.result.current.isSelected(3)).toBe(true); - expect(rendered.result.current.isSelected(4)).toBe(false); + it('should select a range above on shift+click', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); - expect(rendered.result.current.shouldShowButton(1)).toBe(true); - expect(rendered.result.current.shouldShowButton(2)).toBe(false); - expect(rendered.result.current.shouldShowButton(3)).toBe(true); - expect(rendered.result.current.shouldShowButton(4)).toBe(false); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(5, true, false)); + expectSelectedLines(rendered, [5]); + act(() => rendered.result.current.setSelection(2, true, false)); + expectSelectedLines(rendered, [2, 3, 4, 5]); + }); - act(() => rendered.result.current.setSelection(4, true)); + it('should reduce a selection on shift+click', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); - expect(rendered.result.current.isSelected(1)).toBe(false); - expect(rendered.result.current.isSelected(2)).toBe(false); - expect(rendered.result.current.isSelected(3)).toBe(true); - expect(rendered.result.current.isSelected(4)).toBe(true); - expect(rendered.result.current.isSelected(5)).toBe(false); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, true, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(7, true, false)); + expectSelectedLines(rendered, [2, 3, 4, 5, 6, 7]); + act(() => rendered.result.current.setSelection(4, true, false)); + expectSelectedLines(rendered, [2, 3, 4]); + act(() => rendered.result.current.setSelection(2, true, false)); + expectSelectedLines(rendered, [2]); + }); - expect(rendered.result.current.shouldShowButton(1)).toBe(false); - expect(rendered.result.current.shouldShowButton(2)).toBe(false); - expect(rendered.result.current.shouldShowButton(3)).toBe(true); - expect(rendered.result.current.shouldShowButton(4)).toBe(true); - expect(rendered.result.current.shouldShowButton(5)).toBe(false); + it('should add a new selection on cmd/ctrl+click', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, true)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(5, false, true)); + expectSelectedLines(rendered, [2, 5]); + }); + + it('should merge selections on cmd/ctrl+click', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(1, false, true)); + expectSelectedLines(rendered, [1]); + act(() => rendered.result.current.setSelection(3, true, false)); + expectSelectedLines(rendered, [1, 2, 3]); + act(() => rendered.result.current.setSelection(5, false, true)); + expectSelectedLines(rendered, [1, 2, 3, 5]); + act(() => rendered.result.current.setSelection(7, true, false)); + expectSelectedLines(rendered, [1, 2, 3, 5, 6, 7]); + act(() => rendered.result.current.setSelection(4, false, true)); + expectSelectedLines(rendered, [1, 2, 3, 4, 5, 6, 7]); + }); + + it('should split a selection on cmd/ctrl+click', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(1, false, true)); + expectSelectedLines(rendered, [1]); + act(() => rendered.result.current.setSelection(5, true, false)); + expectSelectedLines(rendered, [1, 2, 3, 4, 5]); + act(() => rendered.result.current.setSelection(3, false, true)); + expectSelectedLines(rendered, [1, 2, 4, 5]); + }); + + it('should copy a selected line', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); expect(copyToClipboard).not.toHaveBeenCalled(); - act(() => rendered.result.current.copySelection()); - expect(copyToClipboard).toHaveBeenLastCalledWith('3\n4'); + act(() => rendered.result.current.copySelection(2)); + expect(copyToClipboard).toHaveBeenLastCalledWith('2'); + }); - act(() => rendered.result.current.setSelection(2, true)); - act(() => rendered.result.current.setSelection(4, true)); + it('should copy a selected range', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + rendered.rerender(); - act(() => rendered.result.current.copySelection()); - expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4'); + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(5, true, false)); + expectSelectedLines(rendered, [2, 3, 4, 5]); - act(() => rendered.result.current.setSelection(2, false)); - act(() => rendered.result.current.setSelection(4, false)); - act(() => rendered.result.current.setSelection(4, false)); - act(() => rendered.result.current.setSelection(5, true)); - act(() => rendered.result.current.copySelection()); - expect(copyToClipboard).toHaveBeenCalledWith('5'); + expect(copyToClipboard).not.toHaveBeenCalled(); + act(() => rendered.result.current.copySelection(2)); + expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4\n5'); + }); + + it('should copy the correct selection', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + rendered.rerender(); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(5, true, false)); + expectSelectedLines(rendered, [2, 3, 4, 5]); + + act(() => rendered.result.current.setSelection(7, false, true)); + expectSelectedLines(rendered, [2, 3, 4, 5, 7]); + + expect(copyToClipboard).not.toHaveBeenCalled(); + act(() => rendered.result.current.copySelection(2)); + expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4\n5'); + act(() => rendered.result.current.copySelection(7)); + expect(copyToClipboard).toHaveBeenCalledWith('7'); + }); + + it('should add a single line selection to the hash', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + rendered.rerender(); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); + + expect(rendered.result.current.getHash()).toBe('#lines-2'); + }); + + it('should add a range selection to the hash', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + rendered.rerender(); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(2, false, false)); + expectSelectedLines(rendered, [2]); + act(() => rendered.result.current.setSelection(5, true, false)); + expectSelectedLines(rendered, [2, 3, 4, 5]); + + expect(rendered.result.current.getHash()).toBe('#lines-2-5'); + }); + + it('should add multiple selections to the hash', () => { + const rendered = renderHook(() => useLogViewerSelection(lines), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }); + rendered.rerender(); + + expectSelectedLines(rendered, []); + act(() => rendered.result.current.setSelection(3, false, false)); + expectSelectedLines(rendered, [3]); + act(() => rendered.result.current.setSelection(5, true, false)); + expectSelectedLines(rendered, [3, 4, 5]); + act(() => rendered.result.current.setSelection(1, false, true)); + expectSelectedLines(rendered, [1, 3, 4, 5]); + act(() => rendered.result.current.setSelection(7, false, true)); + expectSelectedLines(rendered, [1, 3, 4, 5, 7]); + + expect(rendered.result.current.getHash()).toBe('#lines-3-5,1,7'); }); }); diff --git a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx index 97fee53c8a..d59a552777 100644 --- a/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx +++ b/packages/core-components/src/components/LogViewer/useLogViewerSelection.tsx @@ -19,11 +19,14 @@ import { useEffect, useState } from 'react'; import useCopyToClipboard from 'react-use/esm/useCopyToClipboard'; import { AnsiLine } from './AnsiProcessor'; +type Selection = { + start: number; + end: number; +}; + export function useLogViewerSelection(lines: AnsiLine[]) { const errorApi = useApi(errorApiRef); - const [sel, setSelection] = useState<{ start: number; end: number }>(); - const start = sel ? Math.min(sel.start, sel.end) : undefined; - const end = sel ? Math.max(sel.start, sel.end) : undefined; + const [selections, setSelections] = useState([]); const [{ error }, copyToClipboard] = useCopyToClipboard(); @@ -33,38 +36,202 @@ export function useLogViewerSelection(lines: AnsiLine[]) { } }, [error, errorApi]); + const findClosestSelection = ( + allSelections: Selection[], + line: number, + ): Selection | undefined => { + if (selections.length === 0) { + return undefined; + } + let minDistance = Number.MAX_SAFE_INTEGER; + let closestSelection: Selection | undefined = undefined; + + allSelections.forEach(s => { + const distance = Math.min( + Math.abs(s.start - line), + Math.abs(s.end - line), + ); + if (distance < minDistance) { + minDistance = distance; + closestSelection = s; + } + }); + + return closestSelection; + }; + + const mergeNeighbouringSelections = ( + allSelections: Selection[], + line: number, + ): Selection[] => { + // Merge selections if they're next to each other + const neighboringSelections = allSelections.filter( + s => s.start - 1 === line || s.end + 1 === line, + ); + if (neighboringSelections.length === 0) { + return allSelections; + } + const newSelection = { + start: Math.min(line, ...neighboringSelections.map(s => s.start)), + end: Math.max(line, ...neighboringSelections.map(s => s.end)), + }; + + return [ + ...allSelections.filter( + s => + !neighboringSelections.includes(s) && + !(s.start === line && s.end === line), + ), + newSelection, + ]; + }; + return { - shouldShowButton(line: number) { - return start === line || end === line; + shouldShowCopyButton(line: number) { + // show copy button at the beginning of each selection + return selections.some(s => s.start === line); }, isSelected(line: number) { - if (!sel) { + if (!selections) { return false; } - return start! <= line && line <= end!; + // check if line is in any selection range + return selections.some( + s => s.start <= line && (s.end ?? s.start) >= line, + ); }, - setSelection(line: number, add: boolean) { - if (add) { - setSelection(s => - s ? { start: s.start, end: line } : { start: line, end: line }, + setSelection(line: number, addRange: boolean, addNewSelection: boolean) { + setSelections(currentSelections => { + const clickedSelection = currentSelections.find( + s => s.start <= line && s.end >= line, ); - } else { - setSelection(s => - s?.start === line && s?.end === line - ? undefined - : { start: line, end: line }, + const otherSelections = currentSelections.filter( + s => s !== clickedSelection, ); - } + + if (!addRange && !addNewSelection) { + // Normal click -> select only this line if nothing or multiple lines are selected + if ( + !clickedSelection || + clickedSelection.start !== clickedSelection.end + ) { + return [{ start: line, end: line }]; + } + // Clear selection if single line is selected + return []; + } + + if (addRange) { + // Shift+click -> extend/reduce selection + if (currentSelections.length === 0) { + // No existing selection -> create new selection + return [{ start: line, end: line }]; + } + + if (clickedSelection) { + // Clicked inside an existing selection -> reduce selection + if (clickedSelection.start === clickedSelection.end) { + // Single line selection -> remove it + return otherSelections; + } + // Reduce selection + return [ + ...otherSelections, + { start: clickedSelection.start, end: line }, + ]; + } + + // Extend the closest selection to the new line + const closestSelection = findClosestSelection( + currentSelections, + line, + ); + if (!closestSelection) { + // Can't actually happen + return currentSelections; + } + if (closestSelection.start < line) { + // Add lines before the selection + return mergeNeighbouringSelections( + [ + ...otherSelections.filter(s => s !== closestSelection), + { start: closestSelection.start, end: line }, + ], + line, + ); + } + // Add lines after the selection + return mergeNeighbouringSelections( + [ + ...otherSelections.filter(s => s !== closestSelection), + { start: line, end: closestSelection!.end }, + ], + line, + ); + } + + if (addNewSelection) { + // Ctrl/Cmd+click -> add new selection + if (!clickedSelection) { + // Just add new selection + return mergeNeighbouringSelections( + [...currentSelections, { start: line, end: line }], + line, + ); + } + if (clickedSelection.start === clickedSelection.end) { + // Single line selection -> remove it + return otherSelections; + } + // Multi line selection -> split it + return [ + ...otherSelections, + ...(clickedSelection.start < line + ? [{ start: clickedSelection.start, end: line - 1 }] + : []), + ...(clickedSelection.end > line + ? [{ start: line + 1, end: clickedSelection.end }] + : []), + ]; + } + + return []; + }); }, - copySelection() { - if (sel) { - const copyText = lines - .slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end)) - .map(l => l.chunks.map(c => c.text).join('')) - .join('\n'); - copyToClipboard(copyText); - setSelection(undefined); + copySelection(line: number) { + const selection = selections.find(s => s.start === line); + if (!selection) { + return; } + const copyText = lines + .slice(selection.start - 1, selection.end) + .map(l => l.chunks.map(c => c.text).join('')) + .join('\n'); + copyToClipboard(copyText); + }, + getHash() { + if (selections.length === 0) { + return ''; + } + const parts = selections.map(s => { + if (s.start === s.end) { + return `${s.start}`; + } + return `${s.start}-${s.end}`; + }); + return `#lines-${parts.join(',')}`; + }, + selectAll(hash: string) { + const match = hash.match(/#lines-([\d,-]+)/); + const s: Selection[] = []; + if (match) { + const ranges = match[1].split(','); + ranges.forEach(r => { + const [start, end] = r.split('-').map(Number); + s.push({ start, end: end ?? start }); + }); + } + setSelections(s); }, }; }