Merge pull request #31694 from KatharinaSick/bugfix/logviewer-line-selection
fix: fix logviewer selection behavior
This commit is contained in:
@@ -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
|
||||
@@ -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 (
|
||||
<AutoSizer>
|
||||
{({ 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,
|
||||
};
|
||||
<>
|
||||
<AutoSizer>
|
||||
{({ 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 (
|
||||
<Box
|
||||
style={{ ...style }}
|
||||
className={classnames(classes.line, {
|
||||
[classes.lineSelected]: selection.isSelected(lineNumber),
|
||||
})}
|
||||
>
|
||||
{selection.shouldShowButton(lineNumber) && (
|
||||
<IconButton
|
||||
data-testid="copy-button"
|
||||
size="small"
|
||||
className={classes.lineCopyButton}
|
||||
onClick={() => selection.copySelection()}
|
||||
>
|
||||
<CopyIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
)}
|
||||
<a
|
||||
role="row"
|
||||
target="_self"
|
||||
href={`#line-${lineNumber}`}
|
||||
className={classes.lineNumber}
|
||||
onClick={event => 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 (
|
||||
<Box
|
||||
style={{ ...style }}
|
||||
className={classnames(classes.line, {
|
||||
[classes.lineSelected]: selection.isSelected(lineNumber),
|
||||
})}
|
||||
>
|
||||
{lineNumber}
|
||||
</a>
|
||||
<LogLine
|
||||
setRowHeight={shouldTextWrap ? setRowHeight : undefined}
|
||||
line={line}
|
||||
classes={classes}
|
||||
searchText={search.searchText}
|
||||
highlightResultIndex={
|
||||
search.resultLine === lineNumber
|
||||
? search.resultLineIndex
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{selection.shouldShowCopyButton(lineNumber) && (
|
||||
<IconButton
|
||||
data-testid="copy-button"
|
||||
size="small"
|
||||
className={classes.lineCopyButton}
|
||||
onClick={() => handleCopySelection(lineNumber)}
|
||||
>
|
||||
<CopyIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
)}
|
||||
<a
|
||||
role="row"
|
||||
target="_self"
|
||||
href={`#line-${lineNumber}`}
|
||||
className={classes.lineNumber}
|
||||
onClick={event => handleSelectLine(lineNumber, event)}
|
||||
onKeyPress={event => handleSelectLine(lineNumber, event)}
|
||||
>
|
||||
{lineNumber}
|
||||
</a>
|
||||
<LogLine
|
||||
setRowHeight={shouldTextWrap ? setRowHeight : undefined}
|
||||
line={line}
|
||||
classes={classes}
|
||||
searchText={search.searchText}
|
||||
highlightResultIndex={
|
||||
search.resultLine === lineNumber
|
||||
? search.resultLineIndex
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ width, height }} className={classes.root}>
|
||||
<Box className={classes.header}>
|
||||
<LogViewerControls {...search} />
|
||||
</Box>
|
||||
{shouldTextWrap ? (
|
||||
<VariableSizeList<AnsiLine[]>
|
||||
{...commonProps}
|
||||
itemSize={getRowHeight}
|
||||
>
|
||||
{renderItem}
|
||||
</VariableSizeList>
|
||||
) : (
|
||||
<FixedSizeList<AnsiLine[]> {...commonProps} itemSize={20}>
|
||||
{renderItem}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ width, height }} className={classes.root}>
|
||||
<Box className={classes.header}>
|
||||
<LogViewerControls {...search} />
|
||||
</Box>
|
||||
{shouldTextWrap ? (
|
||||
<VariableSizeList<AnsiLine[]>
|
||||
{...commonProps}
|
||||
itemSize={getRowHeight}
|
||||
>
|
||||
{renderItem}
|
||||
</VariableSizeList>
|
||||
) : (
|
||||
<FixedSizeList<AnsiLine[]> {...commonProps} itemSize={20}>
|
||||
{renderItem}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
}}
|
||||
</AutoSizer>
|
||||
<Snackbar
|
||||
open={showCopyInfo}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setShowCopyInfo(false)}
|
||||
message="Lines copied to clipboard"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
@@ -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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
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<{}>) => (
|
||||
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Selection[]>([]);
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user