Skip to content

Commit 91e9bf2

Browse files
committed
fix(results): stop a hidden tab's grid from answering copy and select-all
The grid listens on document for copy and select-all, and answers for a selection whose endpoints are inside its own container — bypassing the active-pane check on purpose (#330). A kept-alive tab stays mounted with its selection intact, so Ctrl+C in the tab the user switched to could copy rows from the tab they left, unseen. Both listeners are installed only while the tab is on screen.
1 parent 6b09d8e commit 91e9bf2

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

src/components/DataGrid.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { Badge } from '@/components/ui/badge';
3434
import { useThemeOptional } from '@/hooks/use-theme';
3535
import { getScaledRowHeight } from '@/lib/themes/ui-scale';
3636
import { cn } from '@/lib/utils';
37+
import { useTabVisible } from '@/workspace/tabVisibility';
3738
import type { SpacingDensity } from '@/lib/themes/schema';
3839

3940
interface DataGridProps {
@@ -1160,6 +1161,13 @@ export const DataGrid: React.FC<DataGridProps> = ({
11601161
// still mounted, and the copy is rebuilt from the line data rather than from
11611162
// the DOM.
11621163
const jsonViewRef = React.useRef<HTMLDivElement | null>(null);
1164+
// These grids listen on `document` for copy and select-all, and answer for a
1165+
// selection whose endpoints are inside their own container — which bypasses
1166+
// the active-pane check on purpose (#330). A kept-alive tab (#240) stays
1167+
// mounted with its selection intact, so a hidden grid would go on answering
1168+
// Ctrl+C in the tab the user switched to, copying data they cannot see. Only
1169+
// the grid on screen listens.
1170+
const tabVisible = useTabVisible();
11631171
// The two ends of the selection, each remembered independently at the last
11641172
// row it was seen on. Modelling the ends rather than a min/max span is what
11651173
// lets the range CONTRACT: during a drag the anchor is fixed and only the
@@ -1404,7 +1412,7 @@ export const DataGrid: React.FC<DataGridProps> = ({
14041412
// meaning of the shortcut too: in a results pane, select-all is about the
14051413
// results, not about the whole application around them.
14061414
useEffect(() => {
1407-
if (viewMode !== 'json') return;
1415+
if (viewMode !== 'json' || !tabVisible) return;
14081416
const onKeyDown = (e: KeyboardEvent) => {
14091417
if (e.defaultPrevented) return;
14101418
if (e.key !== 'a' && e.key !== 'A') return;
@@ -1432,20 +1440,20 @@ export const DataGrid: React.FC<DataGridProps> = ({
14321440
// any window-level handler that would otherwise claim the key first.
14331441
document.addEventListener('keydown', onKeyDown, true);
14341442
return () => document.removeEventListener('keydown', onKeyDown, true);
1435-
}, [viewMode]);
1443+
}, [viewMode, tabVisible]);
14361444

14371445
const jsonCopyRef = React.useRef(handleJsonCopy);
14381446
useEffect(() => {
14391447
jsonCopyRef.current = handleJsonCopy;
14401448
});
14411449
useEffect(() => {
1442-
if (viewMode !== 'json') return;
1450+
if (viewMode !== 'json' || !tabVisible) return;
14431451
const onCopy = (e: ClipboardEvent) => jsonCopyRef.current(e);
14441452
document.addEventListener('copy', onCopy);
14451453
// Nothing to unwind: ownership is the pane registry's, and a pane
14461454
// unregisters itself there when it goes.
14471455
return () => document.removeEventListener('copy', onCopy);
1448-
}, [viewMode]);
1456+
}, [viewMode, tabVisible]);
14491457

14501458
const toggleFold = (id: number) => {
14511459
setCollapsedFolds((prev) => {

src/components/__tests__/DataGrid.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ vi.mock('@/hooks/use-theme', () => ({
3636
}));
3737

3838
import { DataGrid, getExplainTree } from '../DataGrid';
39+
import { TabVisibleContext } from '../../workspace/tabVisibility';
3940
import { resetResultsFindShortcutForTests } from '@/lib/resultsFindShortcut';
4041

4142
// Collect every node name in the tree (depth-first) for assertions.
@@ -2186,3 +2187,46 @@ describe('DataGrid — tab guards survive StrictMode replay (#325 review)', () =
21862187
expect(onActiveTabChange).toHaveBeenCalledWith('explain');
21872188
});
21882189
});
2190+
2191+
describe('DataGrid — a hidden tab does not answer the clipboard (#240)', () => {
2192+
const view = (visible: boolean) => (
2193+
<TabVisibleContext.Provider value={visible}>
2194+
<DataGrid documents={mockDocuments} />
2195+
</TabVisibleContext.Provider>
2196+
);
2197+
2198+
const pressSelectAll = (target: EventTarget) => {
2199+
const event = new KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true, cancelable: true });
2200+
target.dispatchEvent(event);
2201+
return event;
2202+
};
2203+
2204+
it('leaves select-all alone once its tab is hidden', () => {
2205+
// The grid stays mounted (#240), so without this it would still claim the
2206+
// key pressed in the tab the user switched to.
2207+
const { rerender } = render(view(true));
2208+
const json = screen.getByTestId('json-view');
2209+
document.getSelection()?.removeAllRanges();
2210+
expect(pressSelectAll(json).defaultPrevented).toBe(true);
2211+
2212+
rerender(view(false));
2213+
expect(pressSelectAll(json).defaultPrevented).toBe(false);
2214+
});
2215+
2216+
it('leaves a copy alone once its tab is hidden', () => {
2217+
const { rerender } = render(view(true));
2218+
const json = screen.getByTestId('json-view');
2219+
document.getSelection()?.removeAllRanges();
2220+
pressSelectAll(json);
2221+
document.dispatchEvent(new Event('selectionchange'));
2222+
2223+
const shown = vi.fn();
2224+
fireEvent.copy(json, { clipboardData: { setData: shown, getData: () => '' } });
2225+
expect(shown).toHaveBeenCalled();
2226+
2227+
rerender(view(false));
2228+
const hidden = vi.fn();
2229+
fireEvent.copy(json, { clipboardData: { setData: hidden, getData: () => '' } });
2230+
expect(hidden).not.toHaveBeenCalled();
2231+
});
2232+
});

0 commit comments

Comments
 (0)