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 238361f9a9..83fb3a84ad 100644
--- a/packages/core-components/src/components/LogViewer/styles.ts
+++ b/packages/core-components/src/components/LogViewer/styles.ts
@@ -89,15 +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,
- userSelect: 'none',
+ 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);
},
};
}