diff --git a/README.md b/README.md index b930b51..217a5e4 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,12 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. - **Smart Resizing:** Automatic calculation of column widths for improved readability. - **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings). -- **Sticky Headers:** Keep column titles in view as you scroll through large datasets. +- **Serial Index Column:** Right-click a serial index cell or its top-left corner and choose **Hide serial index column** for that file. Restore it with **CSV: Toggle Serial Index Column** in the Command Palette. +- **Sticky Headers:** Keep column titles in view as you scroll through large datasets. Right-click a cell in the first visible row and choose **Use first row as header** or **Treat first row as data** to toggle the header for that file. With leading metadata rows hidden, this applies to the first visible row; the CSV contents stay unchanged. - **Enhanced Keyboard Navigation:** Navigate cells with arrows and Tab/Shift+Tab; quick edits can commit with arrow keys; `Ctrl/Cmd + A` selects all; `Ctrl/Cmd + C` copies selection. - **Advanced Multi-Cell Selection:** Easily select and copy blocks of data, then paste them elsewhere as properly formatted CSV. - **Add/Delete Columns:** Right-click any cell to add a column left or right, or remove the selected column. -- **Add/Delete Rows:** Insert above/below or remove the selected row via context menu. +- **Add/Delete Rows:** Insert above/below or remove the selected row via context menu. Menus open toward available space and remain scrollable in small editor panes. - **Edit Empty CSVs:** Create or open an empty CSV file and start typing immediately. - **Column Sorting:** Right-click a header and choose A–Z or Z–A. - **Custom Font Controls:** Choose a font family and optional font-size override, or inherit VS Code defaults. diff --git a/images/context-menu-edge.jpg b/images/context-menu-edge.jpg new file mode 100644 index 0000000..3e59353 Binary files /dev/null and b/images/context-menu-edge.jpg differ diff --git a/images/header-menu-later.jpg b/images/header-menu-later.jpg new file mode 100644 index 0000000..b4990f4 Binary files /dev/null and b/images/header-menu-later.jpg differ diff --git a/images/header-menu-off.jpg b/images/header-menu-off.jpg new file mode 100644 index 0000000..b9b2890 Binary files /dev/null and b/images/header-menu-off.jpg differ diff --git a/images/header-menu-on.jpg b/images/header-menu-on.jpg new file mode 100644 index 0000000..c0ffdf4 Binary files /dev/null and b/images/header-menu-on.jpg differ diff --git a/images/serial-index-menu.jpg b/images/serial-index-menu.jpg new file mode 100644 index 0000000..8ebd0c0 Binary files /dev/null and b/images/serial-index-menu.jpg differ diff --git a/media/main.js b/media/main.js index 5f0477a..0fc03be 100644 --- a/media/main.js +++ b/media/main.js @@ -95,6 +95,8 @@ const setZoomScale = (nextScale, persist = true) => { return false; } zoomScale = normalized; + const menu = document.getElementById('contextMenu'); + if (menu) { menu.style.display = 'none'; } document.body.style.fontSize = `${Math.max(1, BASE_FONT_SIZE_PX * zoomScale)}px`; applySizeStateToRenderedCells(); if (persist) { @@ -178,14 +180,14 @@ const restoreState = () => { } } if (typeof st.anchorRow === 'number' && typeof st.anchorCol === 'number') { - const tag = (hasHeader && st.anchorRow === 0 ? 'th' : 'td'); - let sel = table.querySelector(`${tag}[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`); + const selector = `td[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"], th[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`; + let sel = table.querySelector(selector); // If not present yet (due to chunking), load chunks until available or exhausted if (!sel && typeof window.__csvLoadNextChunk === 'function') { let guard = 100; // prevent infinite loops while (!sel && typeof window.__csvLoadNextChunk === 'function' && guard-- > 0) { if (!window.__csvLoadNextChunk()) break; - sel = table.querySelector(`${tag}[data-row="${st.anchorRow}"][data-col="${st.anchorCol}"]`); + sel = table.querySelector(selector); } } applySizeStateToRenderedCells(); @@ -367,6 +369,7 @@ const handleZoomWheel = e => { window.addEventListener('wheel', handleZoomWheel, { passive: false }); const hasHeader = document.querySelector('thead') !== null; +const headerEnabled = root?.dataset?.headerenabled === '1'; const getCellCoords = cell => ({ row: parseInt(cell.getAttribute('data-row')), col: parseInt(cell.getAttribute('data-col')) }); const clearSelection = () => { currentSelection.forEach(c => c.classList.remove('selected')); currentSelection = []; }; const contextMenu = document.getElementById('contextMenu'); @@ -386,6 +389,32 @@ const showContextMenu = (x, y, row, col) => { d.style.margin = '1px 0'; contextMenu.appendChild(d); }; + if (col === -1) { + item('Hide serial index column', () => { + const anchor = anchorCell ? getCellCoords(anchorCell) : null; + if (!anchor || !Number.isFinite(anchor.col) || anchor.col < 0) { + const row = anchor && Number.isFinite(anchor.row) ? anchor.row : Number(root?.dataset?.firstrow || '0'); + const surviving = table.querySelector(`td[data-row="${row}"][data-col="0"], th[data-row="${row}"][data-col="0"]`); + if (surviving) { + clearSelection(); + surviving.classList.add('selected'); + currentSelection.push(surviving); + anchorCell = surviving; + rangeEndCell = surviving; + } + } + persistState(); + vscode.postMessage({ type: 'setSerialIndex', enabled: false }); + }); + divider(); + } + if (row === Number(root?.dataset?.firstrow)) { + item(headerEnabled ? 'Treat first row as data' : 'Use first row as header', () => { + persistState(); + vscode.postMessage({ type: 'setHeader', enabled: !headerEnabled }); + }); + divider(); + } item('Cut', () => copySelectionToClipboard(true)); item('Copy', () => copySelectionToClipboard()); item('Paste', () => pasteFromClipboard()); @@ -460,10 +489,25 @@ const showContextMenu = (x, y, row, col) => { }); } - contextMenu.style.left = x + 'px'; - contextMenu.style.top = y + 'px'; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const marginX = Math.min(8, viewportWidth / 4); + const marginY = Math.min(8, viewportHeight / 4); + contextMenu.style.maxWidth = `${Math.max(0, viewportWidth - 2 * marginX)}px`; + contextMenu.style.maxHeight = `${Math.max(0, viewportHeight - 2 * marginY)}px`; + // Measure all actions at their natural width, bounded by the viewport, before placing. + contextMenu.style.left = `${marginX}px`; + contextMenu.style.top = `${marginY}px`; contextMenu.style.display = 'block'; + contextMenu.scrollTop = 0; + contextMenu.scrollLeft = 0; + const { width, height } = contextMenu.getBoundingClientRect(); + const left = x + width <= viewportWidth - marginX ? x : x - width; + const top = y + height <= viewportHeight - marginY ? y : y - height; + contextMenu.style.left = `${clamp(left, marginX, Math.max(marginX, viewportWidth - marginX - width))}px`; + contextMenu.style.top = `${clamp(top, marginY, Math.max(marginY, viewportHeight - marginY - height))}px`; }; +window.addEventListener('resize', () => { contextMenu.style.display = 'none'; }); const getElementTarget = target => { if (target instanceof Element) return target; @@ -753,12 +797,13 @@ table.addEventListener('contextmenu', e => { if (!target || editingCell) return; const colAttr = target.getAttribute('data-col'); const rowAttr = target.getAttribute('data-row'); - const col = parseInt(colAttr); + const serialCorner = target.hasAttribute('data-serial-index'); + const col = serialCorner ? -1 : parseInt(colAttr); const row = parseInt(rowAttr); - if ((isNaN(col) || col === -1) && (isNaN(row) || row === -1)) return; + if (!serialCorner && (isNaN(col) || col === -1) && (isNaN(row) || row === -1)) return; e.preventDefault(); - lastContextIsHeader = target.tagName === 'TH'; - showContextMenu(e.pageX, e.pageY, row, col); + lastContextIsHeader = target.tagName === 'TH' && !serialCorner; + showContextMenu(e.clientX, e.clientY, row, col); }); table.addEventListener('mousedown', e => { diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index b056d36..0846453 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -148,6 +148,28 @@ class CsvEditorController { case 'save': await this.handleSave(); break; + case 'setHeader': { + if (typeof e.enabled === 'boolean') { + const uri = this.document.uri; + await CsvEditorProvider.setHeaderForUri(this.context, uri, e.enabled); + CsvEditorProvider.editors + .filter(editor => editor.getDocumentUri().toString() === uri.toString()) + .forEach(editor => editor.refresh()); + } + break; + } + case 'setSerialIndex': { + if (typeof e.enabled === 'boolean') { + const uri = this.getDocumentUri(); + if (CsvEditorProvider.getSerialIndexForUri(this.context, uri) !== e.enabled) { + await CsvEditorProvider.setSerialIndexForUri(this.context, uri, e.enabled); + CsvEditorProvider.editors + .filter(editor => editor.getDocumentUri().toString() === uri.toString()) + .forEach(editor => editor.refresh()); + } + } + break; + } case 'copyCells': await this.copyCells(e.matrix, e.startRow, e.startCol, e.cut === true); break; @@ -1451,6 +1473,8 @@ class CsvEditorController { extraColumnColorCss: colorCss, nextChunkStart, hasRemoteChunks, + headerEnabled: treatHeader, + firstVisibleRow: Math.min(Math.max(0, hiddenRows), data.length), mouseWheelZoomEnabled, mouseWheelZoomInvert, realRowCount: data.length @@ -1564,7 +1588,7 @@ class CsvEditorController { if (headerFlag) { tableHtml += `${ addSerialIndex - ? `` + ? `` : '' }`; for (let i = 0; i < numColumns; i++) { @@ -1704,11 +1728,13 @@ class CsvEditorController { extraColumnColorCss: string; nextChunkStart: number; hasRemoteChunks: boolean; + headerEnabled: boolean; + firstVisibleRow: number; mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; realRowCount: number; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, realRowCount } = args; + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, headerEnabled, firstVisibleRow, mouseWheelZoomEnabled, mouseWheelZoomInvert, realRowCount } = args; const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) const scriptUri = webview.asWebviewUri( @@ -1911,7 +1937,7 @@ class CsvEditorController { font-size: inherit; } #findReplaceWidget .fr-overflow-item:hover { background: rgba(255,255,255,0.05); } - #contextMenu { position: absolute; display: none; background: ${isDark ? '#2d2d2d' : '#ffffff'}; border: 1px solid ${isDark ? '#555' : '#ccc'}; z-index: 10000; font-family: ${this.escapeCss(fontFamily)}; font-size: inherit; } + #contextMenu { position: fixed; display: none; width: max-content; box-sizing: border-box; overflow: auto; overflow-wrap: anywhere; background: ${isDark ? '#2d2d2d' : '#ffffff'}; border: 1px solid ${isDark ? '#555' : '#ccc'}; z-index: 10000; font-family: ${this.escapeCss(fontFamily)}; font-size: inherit; } #contextMenu div { padding: 4px 12px; cursor: pointer; } #contextMenu div:hover { background: ${isDark ? '#3d3d3d' : '#eeeeee'}; } @@ -1920,7 +1946,7 @@ class CsvEditorController { -
+
${tableHtml}
diff --git a/src/test/context-menu-positioning.test.ts b/src/test/context-menu-positioning.test.ts new file mode 100644 index 0000000..5ca10c1 --- /dev/null +++ b/src/test/context-menu-positioning.test.ts @@ -0,0 +1,211 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +async function fixture(separator = ',') { + const host = await createEditorHarness({ rows: [['Name', 'Value'], ['one', 'two']], header: true, separator }); + const view = createWebviewDom(host.panel.webview.html); + const { window } = view; + const menu = window.document.getElementById('contextMenu'); + let naturalSize: { width: number; height: number } | undefined; + const measurements: Array<{ left: string; top: string; display: string }> = []; + const baseFontSize = Number.parseFloat(window.getComputedStyle(window.document.body).fontSize); + const natural = () => { + if (naturalSize) { return naturalSize; } + const scale = Number.parseFloat(window.getComputedStyle(window.document.body).fontSize) / baseFontSize; + return { + width: Math.max(...Array.from(menu.children).map((item: any) => item.textContent.length * 8 * scale + 26)), + height: Array.from(menu.children).reduce((sum: number, item: any) => sum + (item.textContent ? 24 * scale : 4), 2) + }; + }; + const size = () => ({ + width: Math.min(natural().width, Number.parseFloat(menu.style.maxWidth)), + height: Math.min(natural().height, Number.parseFloat(menu.style.maxHeight)) + }); + menu.getBoundingClientRect = () => { + measurements.push({ left: menu.style.left, top: menu.style.top, display: menu.style.display }); + const { width, height } = size(); + const left = Number.parseFloat(menu.style.left), top = Number.parseFloat(menu.style.top); + return { left, top, right: left + width, bottom: top + height, width, height }; + }; + Object.defineProperty(menu, 'scrollHeight', { get: () => natural().height }); + Object.defineProperty(menu, 'clientHeight', { get: () => size().height }); + const viewport = (width: number, height: number) => { + Object.defineProperty(window, 'innerWidth', { value: width, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: height, configurable: true }); + }; + viewport(1000, 700); + const open = (x: number, y: number, row = 1, column = 0, pageOffsetX = 0, pageOffsetY = 0) => { + const target = view.cell(row, column); + target.dispatchEvent(new window.MouseEvent('mousedown', { button: 2, bubbles: true, cancelable: true, clientX: x, clientY: y })); + const event = new window.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y }); + Object.defineProperty(event, 'pageX', { value: x + pageOffsetX }); + Object.defineProperty(event, 'pageY', { value: y + pageOffsetY }); + target.dispatchEvent(event); + assert.strictEqual(event.defaultPrevented, true); + return { left: Number.parseFloat(menu.style.left), top: Number.parseFloat(menu.style.top), ...size() }; + }; + return { ...view, menu, measurements, viewport, open, setSize: (width: number, height: number) => { naturalSize = { width, height }; } }; +} + +describe('Context menu viewport positioning', () => { + it('renders the provider popup at natural width with bounded wrapping and scrolling', async () => { + const view = await fixture(); + try { + const css = view.window.getComputedStyle(view.menu); + assert.strictEqual(css.position, 'fixed'); + assert.strictEqual(css.width, 'max-content'); + assert.strictEqual(css.boxSizing, 'border-box'); + assert.strictEqual(css.overflow, 'auto'); + assert.strictEqual(css.overflowWrap, 'anywhere'); + } finally { view.close(); } + }); + + for (const separator of [',', '\t']) { + it(`flips at the measured right-space threshold, including fractional coordinates (${JSON.stringify(separator)})`, async () => { + const view = await fixture(separator); + try { + view.setSize(200, 120); + assert.strictEqual(view.open(792, 100).left, 792, 'exact fit stays on the right, beyond the screen midpoint'); + assert.strictEqual(view.open(792.5, 100).left, 592.5, 'half a pixel beyond the measured threshold opens left'); + assert.ok(view.measurements.every(measurement => measurement.left === '8px' && measurement.top === '8px' && measurement.display === 'block')); + } finally { view.close(); } + }); + } + + it('chooses a side again for differently sized menus at the same pointer position', async () => { + const view = await fixture(); + try { + view.setSize(100, 120); + assert.strictEqual(view.open(650, 100).left, 650); + view.setSize(400, 120); + assert.strictEqual(view.open(650, 100).left, 250); + } finally { view.close(); } + }); + + it('measures the completed header menu, including its extra actions', async () => { + const view = await fixture(); + try { + const body = view.open(100, 100, 1); + const x = 1000 - 8 - body.width; + const y = 700 - 8 - body.height; + const fittedBody = view.open(x, y, 1); + assert.strictEqual(fittedBody.left, x); + assert.strictEqual(fittedBody.top, y); + const header = view.open(x, y, 0); + assert.ok(header.width >= body.width); + assert.ok(header.height > body.height); + assert.strictEqual(header.left, header.width > body.width ? x - header.width : x); + assert.strictEqual(header.top, Math.max(8, y - header.height)); + assert.ok(Array.from(view.menu.children).some((item: any) => item.textContent === 'Treat first row as data')); + assert.ok(Array.from(view.menu.children).some((item: any) => item.textContent === 'Sort: A-Z')); + } finally { view.close(); } + }); + + it('flips upward at the measured bottom-space threshold', async () => { + const view = await fixture(); + try { + view.setSize(200, 120); + assert.strictEqual(view.open(100, 572).top, 572); + assert.strictEqual(view.open(100, 572.5).top, 452.5); + } finally { view.close(); } + }); + + it('clamps all four corners inside the viewport margins', async () => { + const view = await fixture(); + try { + view.setSize(200, 120); + for (const [x, y, left, top] of [[0, 0, 8, 8], [1000, 0, 792, 8], [0, 700, 8, 572], [1000, 700, 792, 572]]) { + const placed = view.open(x, y); + assert.strictEqual(placed.left, left); + assert.strictEqual(placed.top, top); + assert.ok(placed.left + placed.width <= 992); + assert.ok(placed.top + placed.height <= 692); + } + } finally { view.close(); } + }); + + it('uses viewport client coordinates independently of document and table scrolling', async () => { + const view = await fixture(); + try { + view.setSize(200, 120); + Object.defineProperty(view.window, 'scrollX', { value: 5000 }); + Object.defineProperty(view.window, 'scrollY', { value: 7000 }); + const container = view.window.document.querySelector('.table-container'); + container.scrollLeft = 300; + container.scrollTop = 600; + const placed = view.open(100, 200, 1, 0, 5000, 7000); + assert.strictEqual(placed.left, 100); + assert.strictEqual(placed.top, 200); + assert.strictEqual(container.scrollLeft, 300); + assert.strictEqual(container.scrollTop, 600); + } finally { view.close(); } + }); + + it('bounds tiny editor menus and retains reachable actions through vertical scrolling', async () => { + const view = await fixture(); + try { + view.setSize(300, 500); + for (const [width, height] of [[120, 90], [16, 12]]) { + view.viewport(width, height); + const placed = view.open(width - 1, height - 1); + assert.ok(placed.width <= width - 2 * Math.min(8, width / 4)); + assert.ok(placed.height <= height - 2 * Math.min(8, height / 4)); + assert.ok(placed.left >= 0 && placed.left + placed.width <= width); + assert.ok(placed.top >= 0 && placed.top + placed.height <= height); + assert.ok(view.menu.scrollHeight > view.menu.clientHeight); + } + view.menu.scrollTop = view.menu.scrollHeight - view.menu.clientHeight; + const last = view.menu.lastElementChild; + assert.strictEqual(last.textContent, 'Delete COLUMN'); + last.click(); + assert.strictEqual(view.messages.at(-1).type, 'deleteColumn'); + assert.strictEqual(view.messages.at(-1).index, 0); + assert.strictEqual(view.menu.style.display, 'none'); + } finally { view.close(); } + }); + + it('dismisses only when CSV zoom changes and measures the enlarged menu on reopen', async () => { + const view = await fixture(); + try { + const original = view.open(100, 100); + const x = 1000 - 8 - original.width; + assert.strictEqual(view.open(x, 100).left, x); + view.key('0', { ctrlKey: true }); + assert.strictEqual(view.menu.style.display, 'block', 'reset at the existing zoom leaves the menu open'); + const originalFontSize = Number.parseFloat(view.window.getComputedStyle(view.window.document.body).fontSize); + view.key('+', { ctrlKey: true }); + assert.strictEqual(view.menu.style.display, 'none'); + assert.ok(Number.parseFloat(view.window.document.body.style.fontSize) > originalFontSize); + const enlarged = view.open(x, 100); + assert.ok(enlarged.width > original.width); + assert.strictEqual(enlarged.left, x - enlarged.width); + assert.ok(enlarged.left + enlarged.width <= 992); + assert.strictEqual(view.menu.style.display, 'block'); + } finally { view.close(); } + }); + + it('dismisses on viewport resize and remeasures/reset scroll when reopened', async () => { + const view = await fixture(); + try { + view.viewport(120, 90); + view.setSize(300, 500); + view.open(119, 89); + view.menu.scrollTop = 200; + view.menu.scrollLeft = 20; + view.viewport(1000, 700); + view.window.dispatchEvent(new view.window.Event('resize')); + assert.strictEqual(view.menu.style.display, 'none'); + view.setSize(180, 100); + const placed = view.open(100, 100); + assert.strictEqual(placed.left, 100); + assert.strictEqual(placed.top, 100); + assert.strictEqual(placed.width, 180); + assert.strictEqual(placed.height, 100); + assert.strictEqual(view.menu.scrollTop, 0); + assert.strictEqual(view.menu.scrollLeft, 0); + assert.strictEqual(view.menu.style.display, 'block'); + assert.ok(Array.from(view.menu.children).some((item: any) => item.textContent === 'Copy')); + } finally { view.close(); } + }); +}); diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..2293b97 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -48,6 +48,7 @@ export async function createEditorHarness(options: { rows?: string[][]; separator?: string; header?: boolean; + serialIndex?: boolean; remote?: boolean; } = {}) { const separator = options.separator ?? ','; @@ -84,6 +85,7 @@ export async function createEditorHarness(options: { } }; await CsvEditorProvider.setHeaderForUri(context as any, uri as any, options.header ?? true); + await CsvEditorProvider.setSerialIndexForUri(context as any, uri as any, options.serialIndex ?? true); const panel = { active: true, visible: true, @@ -100,7 +102,7 @@ export async function createEditorHarness(options: { onDidDispose() {} }; const harness = { - document, panel, messages, changeListeners, saveListeners, + context, document, panel, messages, changeListeners, saveListeners, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, diff --git a/src/test/header-context-menu.test.ts b/src/test/header-context-menu.test.ts new file mode 100644 index 0000000..dd9bbe7 --- /dev/null +++ b/src/test/header-context-menu.test.ts @@ -0,0 +1,201 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); + +type Host = Awaited>; +type View = ReturnType; +const rows = [['Name', 'Value'], ['one', 'two'], ['three', 'four']]; +const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + +function openMenu(view: View, row: number, column: number) { + const target = view.cell(row, column); + assert.ok(target); + target.dispatchEvent(new view.window.MouseEvent('mousedown', { button: 2, bubbles: true, cancelable: true })); + target.dispatchEvent(new view.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true })); + return Array.from(view.window.document.querySelectorAll('#contextMenu div')) as any[]; +} + +function chooseHeader(view: View, row: number, column: number, enabled: boolean) { + const label = enabled ? 'Use first row as header' : 'Treat first row as data'; + const item = openMenu(view, row, column).find(element => element.textContent === label); + assert.ok(item, label); + item.click(); + const message = view.messages.slice().reverse().find((item: any) => item.type === 'setHeader'); + assert.strictEqual(message.enabled, enabled); + return message; +} + +async function finishRefresh(host: Host, view: View, start = 0) { + const prepare = host.messages.slice(start).reverse().find((message: any) => message.type === 'prepareViewRefresh'); + if (prepare) { + const begin = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: prepare })); + for (const message of view.messages.slice(begin)) { await host.receive(message); } + } + await settle(); +} + +async function applyHeader(host: Host, view: View, message: unknown) { + const start = host.messages.length; + await host.receive(message); + await finishRefresh(host, view, start); +} + +describe('File header context action', () => { + for (const separator of [',', '\t']) { + for (const header of [false, true]) { + it(`toggles the file header from either first-row column (${JSON.stringify(separator)}, header ${header})`, async () => { + for (const column of [0, 1]) { + const host = await createEditorHarness({ rows, separator, header }); + const view = createWebviewDom(host.panel.webview.html); + const source = host.document.getText(); + const version = host.document.version; + try { + await applyHeader(host, view, chooseHeader(view, 0, column, !header)); + const next = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(!!next.window.document.querySelector('thead'), !header); + const reverse = header ? 'Use first row as header' : 'Treat first row as data'; + assert.ok(openMenu(next, 0, column).some(item => item.textContent === reverse)); + } finally { next.close(); } + assert.strictEqual(host.document.getText(), source); + assert.strictEqual(host.document.version, version); + } finally { view.close(); } + } + }); + } + } + + it('uses the effective detected state rather than the stored fallback', async () => { + const host = await createEditorHarness({ rows, header: true }); + const initial = createWebviewDom(host.panel.webview.html); + const controller = CsvEditorProvider.getActiveProvider()!; + const context = (controller as any).context; + try { + await context.workspaceState.update(CsvEditorProvider.headerKey, {}); + controller.refresh(); + await finishRefresh(host, initial); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.window.document.querySelector('thead'), null); + await applyHeader(host, view, chooseHeader(view, 0, 1, true)); + assert.strictEqual(CsvEditorProvider.getHeaderForUri(context, host.document.uri as any), true); + assert.strictEqual(CsvEditorProvider.hasHeaderOverride(context, host.document.uri as any), true); + const next = createWebviewDom(host.panel.webview.html); + try { assert.ok(next.window.document.querySelector('thead')); } finally { next.close(); } + } finally { view.close(); } + } finally { initial.close(); } + }); + + it('targets the owning file even when another CSV editor becomes active', async () => { + const host = await createEditorHarness({ rows, header: true }); + const view = createWebviewDom(host.panel.webview.html); + const other = await createEditorHarness({ rows, separator: '\t', header: true }); + try { + await applyHeader(host, view, chooseHeader(view, 0, 1, false)); + const next = createWebviewDom(host.panel.webview.html); + const untouched = createWebviewDom(other.panel.webview.html); + try { + assert.strictEqual(next.window.document.querySelector('thead'), null); + assert.ok(untouched.window.document.querySelector('thead')); + } finally { next.close(); untouched.close(); } + } finally { view.close(); } + }); + + it('rejects non-boolean header messages without changing the setting or view', async () => { + const host = await createEditorHarness({ rows, header: true }); + const html = host.panel.webview.html; + for (const enabled of [undefined, null, 'false', 0, 1, {}]) { + await host.receive({ type: 'setHeader', enabled }); + } + await settle(); + assert.strictEqual(host.panel.webview.html, html); + assert.deepStrictEqual(host.rows(), rows); + }); + + it('persists the clicked anchor and scroll, including a header after hidden metadata', async () => { + const host = await createEditorHarness({ rows: [['metadata', ''], ...rows], header: false }); + const initial = createWebviewDom(host.panel.webview.html); + const controller = CsvEditorProvider.getActiveProvider()!; + const context = (controller as any).context; + try { + await CsvEditorProvider.setHiddenRowsForUri(context, host.document.uri as any, 1); + controller.refresh(); + await finishRefresh(host, initial); + const view = createWebviewDom(host.panel.webview.html); + try { + const container = view.window.document.querySelector('.table-container'); + container.scrollLeft = 40; + container.scrollTop = 120; + await applyHeader(host, view, chooseHeader(view, 1, 1, true)); + const saved = view.window.acquireVsCodeApi().getState(); + assert.strictEqual(saved.anchorRow, 1); + assert.strictEqual(saved.anchorCol, 1); + assert.strictEqual(saved.scrollX, 40); + assert.strictEqual(saved.scrollY, 120); + const next = createWebviewDom(host.panel.webview.html); + try { + next.window.acquireVsCodeApi().setState(saved); + next.window.dispatchEvent(new next.window.Event('focus')); + await settle(); + assert.strictEqual(next.cell(1, 1).tagName, 'TH'); + assert.ok(next.cell(1, 1).classList.contains('selected')); + assert.strictEqual(next.window.document.querySelector('.table-container').scrollTop, 120); + assert.strictEqual(next.cell(0, 0), null); + } finally { next.close(); } + assert.deepStrictEqual(host.rows(), [['metadata', ''], ...rows]); + } finally { view.close(); } + } finally { initial.close(); } + }); + + it('can switch off an empty file header preference even without a rendered header', async () => { + const host = await createEditorHarness({ rows: [], header: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(view.window.document.querySelector('thead'), null); + await applyHeader(host, view, chooseHeader(view, 0, 0, false)); + const next = createWebviewDom(host.panel.webview.html); + try { + assert.ok(openMenu(next, 0, 0).some(item => item.textContent === 'Use first row as header')); + } finally { next.close(); } + assert.strictEqual(host.document.getText(), ''); + } finally { view.close(); } + }); + + it('keeps the first displayed row actionable when hidden metadata exceeds the file length', async () => { + const host = await createEditorHarness({ rows, header: true }); + const initial = createWebviewDom(host.panel.webview.html); + const controller = CsvEditorProvider.getActiveProvider()!; + const context = (controller as any).context; + try { + await CsvEditorProvider.setHiddenRowsForUri(context, host.document.uri as any, 10); + controller.refresh(); + await finishRefresh(host, initial); + const view = createWebviewDom(host.panel.webview.html); + try { + await applyHeader(host, view, chooseHeader(view, rows.length, 0, false)); + assert.strictEqual(CsvEditorProvider.getHeaderForUri(context, host.document.uri as any), false); + assert.deepStrictEqual(host.rows(), rows); + } finally { view.close(); } + } finally { initial.close(); } + }); + + it('only offers the header action on first-row cells, with headers on or off', async () => { + for (const header of [true, false]) { + const host = await createEditorHarness({ rows, header }); + const view = createWebviewDom(host.panel.webview.html); + try { + const label = header ? 'Treat first row as data' : 'Use first row as header'; + for (const column of [0, 1]) { + assert.ok(openMenu(view, 0, column).some(item => item.textContent === label)); + } + for (const [row, column] of [[1, 0], [1, -1], [2, 1], [3, 1]]) { + const items = openMenu(view, row, column); + assert.ok(!items.some(item => item.textContent === label)); + assert.ok(items.some(item => item.textContent === 'Copy'), 'later cells retain normal context actions'); + } + } finally { view.close(); } + } + }); +}); diff --git a/src/test/serial-index-context-menu.test.ts b/src/test/serial-index-context-menu.test.ts new file mode 100644 index 0000000..e2741e2 --- /dev/null +++ b/src/test/serial-index-context-menu.test.ts @@ -0,0 +1,279 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; +const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import('../CsvEditorProvider'); + +type Host = Awaited>; +type View = ReturnType; +const rows = [['Name', 'Value'], ['one', 'two'], ['three', 'four']]; +const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + +function openMenu(view: View, target: any) { + assert.ok(target); + target.dispatchEvent(new view.window.MouseEvent('mousedown', { button: 2, bubbles: true, cancelable: true, clientX: 100, clientY: 100 })); + const event = new view.window.MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 100 }); + target.dispatchEvent(event); + return { event, items: Array.from(view.window.document.querySelectorAll('#contextMenu div')) as any[] }; +} + +function chooseHide(view: View, target: any) { + const item = openMenu(view, target).items.find(element => element.textContent === 'Hide serial index column'); + assert.ok(item); + item.click(); + const message = view.messages.slice().reverse().find(item => item.type === 'setSerialIndex'); + assert.strictEqual(message.enabled, false); + return message; +} + +async function finishRefresh(host: Host, view: View, start = 0) { + let cursor = start; + for (let guard = 0; guard < 5; guard++) { + const pending = host.messages.slice(cursor).filter((item: any) => item.type === 'prepareViewRefresh'); + cursor = host.messages.length; + if (!pending.length) { break; } + for (const prepare of pending) { + const begin = view.messages.length; + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: prepare })); + for (const item of view.messages.slice(begin)) { await host.receive(item); } + } + } + await settle(); +} + +async function applySerialIndex(host: Host, view: View, message: unknown) { + const start = host.messages.length; + await host.receive(message); + await finishRefresh(host, view, start); +} + +async function mountThrough(view: View, host: Host, row: number) { + let begin = 0; + for (let guard = 0; !view.cell(row, -1) && guard < 10; guard++) { + view.window.__csvLoadNextChunk(); + const pending = view.messages.slice(begin).filter(message => message.type === 'requestChunk'); + begin = view.messages.length; + for (const request of pending) { + await host.receive(request); + const response = host.messages.slice().reverse().find((message: any) => message.type === 'chunkData' && message.requestId === request.requestId); + view.window.dispatchEvent(new view.window.MessageEvent('message', { data: response })); + } + } + assert.ok(view.cell(row, -1), `serial cell in row ${row} should be mounted`); +} + +describe('Serial index context action', () => { + for (const separator of [',', '\t']) { + for (const header of [false, true]) { + it(`hides only the serial column without changing source (${JSON.stringify(separator)}, header ${header})`, async () => { + const host = await createEditorHarness({ rows, separator, header }); + const view = createWebviewDom(host.panel.webview.html); + const source = host.document.getText(), version = host.document.version, disk = host.diskText; + try { + for (const row of [1, 2, 3]) { + assert.ok(openMenu(view, view.cell(row, -1)).items.some(item => item.textContent === 'Hide serial index column')); + } + for (const row of [0, 1, 3]) { + for (const column of [0, 1]) { + assert.ok(!openMenu(view, view.cell(row, column)).items.some(item => item.textContent === 'Hide serial index column')); + } + } + await applySerialIndex(host, view, chooseHide(view, view.cell(1, -1))); + const next = createWebviewDom(host.panel.webview.html); + try { + assert.strictEqual(next.window.document.querySelector('[data-col="-1"], [data-serial-index]'), null); + assert.ok(!openMenu(next, next.cell(1, 0)).items.some(item => item.textContent === 'Hide serial index column')); + } finally { next.close(); } + assert.strictEqual(CsvEditorProvider.getSerialIndexForUri(host.context as any, host.document.uri as any), false); + assert.strictEqual(host.document.getText(), source); + assert.strictEqual(host.document.version, version); + assert.strictEqual(host.diskText, disk); + // The existing command uses this setter and refresh route to restore the column. + await CsvEditorProvider.setSerialIndexForUri(host.context as any, host.document.uri as any, true); + CsvEditorProvider.getActiveProvider()!.refresh(); + await settle(); + const restored = createWebviewDom(host.panel.webview.html); + try { assert.ok(restored.cell(1, -1)); } finally { restored.close(); } + } finally { view.close(); } + }); + } + } + + it('offers the corner action while preserving Select All and excluding invalid data actions', async () => { + const host = await createEditorHarness({ rows }); + const view = createWebviewDom(host.panel.webview.html); + try { + const corner = view.window.document.querySelector('thead th[data-serial-index]'); + assert.ok(corner); + assert.ok(!corner.hasAttribute('data-row') && !corner.hasAttribute('data-col')); + corner.dispatchEvent(new view.window.MouseEvent('mousedown', { button: 0, bubbles: true, cancelable: true })); + assert.strictEqual(view.window.document.querySelectorAll('td.selected, th.selected').length, view.window.document.querySelectorAll('td, th').length); + const menu = openMenu(view, corner); + assert.strictEqual(menu.event.defaultPrevented, true); + assert.ok(menu.items.some(item => item.textContent === 'Hide serial index column')); + assert.ok(!menu.items.some(item => /Sort:|COLUMN:|Delete COLUMN|first row as/.test(item.textContent))); + await applySerialIndex(host, view, chooseHide(view, corner)); + assert.ok(!host.panel.webview.html.includes('data-serial-index')); + assert.deepStrictEqual(host.rows(), rows); + } finally { view.close(); } + }); + + it('hides an empty/header-off serial column and accepts a boolean restore idempotently', async () => { + const host = await createEditorHarness({ rows: [], header: false }); + const view = createWebviewDom(host.panel.webview.html); + try { + await applySerialIndex(host, view, chooseHide(view, view.cell(0, -1))); + assert.ok(!host.panel.webview.html.includes('data-col="-1"')); + await applySerialIndex(host, view, { type: 'setSerialIndex', enabled: true }); + const restored = createWebviewDom(host.panel.webview.html); + try { assert.ok(restored.cell(0, -1)); } finally { restored.close(); } + const html = host.panel.webview.html; + await applySerialIndex(host, view, { type: 'setSerialIndex', enabled: true }); + assert.strictEqual(host.panel.webview.html, html); + assert.strictEqual(host.document.getText(), ''); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); } + }); + + it('targets its owner URI and refreshes every view of that URI, even with another active file', async () => { + const host = await createEditorHarness({ rows }); + const view = createWebviewDom(host.panel.webview.html); + const second = await createEditorHarness({ rows }); + const secondView = createWebviewDom(second.panel.webview.html); + second.context.workspaceState = host.context.workspaceState; + const other = await createEditorHarness({ rows, separator: '\t' }); + const otherHtml = other.panel.webview.html; + try { + await applySerialIndex(host, view, chooseHide(view, view.cell(1, -1))); + assert.ok(!host.panel.webview.html.includes('data-col="-1"')); + await finishRefresh(second, secondView); + assert.ok(!second.panel.webview.html.includes('data-col="-1"')); + assert.strictEqual(other.panel.webview.html, otherHtml); + assert.strictEqual(CsvEditorProvider.getSerialIndexForUri(other.context as any, other.document.uri as any), true); + assert.deepStrictEqual(host.rows(), rows); + assert.deepStrictEqual(second.rows(), rows); + } finally { view.close(); secondView.close(); } + }); + + it('rejects malformed preferences and ignores repeated requests without extra writes or reloads', async () => { + const host = await createEditorHarness({ rows }); + const view = createWebviewDom(host.panel.webview.html); + const update = host.context.workspaceState.update.bind(host.context.workspaceState); + let serialWrites = 0; + host.context.workspaceState.update = async (key: string, value: unknown) => { + if (key === CsvEditorProvider.serialKey) { serialWrites++; } + await update(key, value); + }; + try { + const original = host.panel.webview.html; + for (const enabled of [undefined, null, 'false', 0, 1, {}, []]) { + await applySerialIndex(host, view, { type: 'setSerialIndex', enabled }); + } + assert.strictEqual(host.panel.webview.html, original); + assert.strictEqual(serialWrites, 0); + await applySerialIndex(host, view, chooseHide(view, view.cell(1, -1))); + const hidden = host.panel.webview.html; + await applySerialIndex(host, view, { type: 'setSerialIndex', enabled: false }); + assert.strictEqual(host.panel.webview.html, hidden); + assert.strictEqual(serialWrites, 1); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); } + }); + + for (const corner of [false, true]) { + it(`preserves scroll and a surviving data anchor with hidden metadata (corner ${corner})`, async () => { + const host = await createEditorHarness({ rows: [['metadata', ''], ...rows], header: true }); + await CsvEditorProvider.setHiddenRowsForUri(host.context as any, host.document.uri as any, 1); + CsvEditorProvider.getActiveProvider()!.refresh(); + await settle(); + const view = createWebviewDom(host.panel.webview.html); + try { + const container = view.window.document.querySelector('.table-container'); + container.scrollLeft = 40; + container.scrollTop = 120; + const target = corner ? view.window.document.querySelector('[data-serial-index]') : view.cell(2, -1); + const message = chooseHide(view, target); + view.window.dispatchEvent(new view.window.Event('blur')); + view.window.dispatchEvent(new view.window.Event('focus')); + await settle(); + view.window.dispatchEvent(new view.window.Event('blur')); + await applySerialIndex(host, view, message); + const state = view.window.acquireVsCodeApi().getState(); + assert.strictEqual(state.anchorRow, corner ? 1 : 2); + assert.strictEqual(state.anchorCol, 0); + assert.strictEqual(state.scrollX, 40); + assert.strictEqual(state.scrollY, 120); + const next = createWebviewDom(host.panel.webview.html); + try { + next.window.acquireVsCodeApi().setState(state); + next.window.dispatchEvent(new next.window.Event('focus')); + await settle(); + assert.ok(next.cell(state.anchorRow, 0).classList.contains('selected')); + assert.strictEqual(next.window.document.querySelector('.table-container').scrollLeft, 40); + assert.strictEqual(next.window.document.querySelector('.table-container').scrollTop, 120); + } finally { next.close(); } + } finally { view.close(); } + }); + } + + it('retains an existing data anchor when the selected serial cell opens the menu', async () => { + const host = await createEditorHarness({ rows }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.window.editingTestApi.setSingleSelection(view.cell(1, 1)); + view.key('a', { ctrlKey: true }); + assert.ok(view.cell(1, -1).classList.contains('selected')); + const message = chooseHide(view, view.cell(1, -1)); + view.window.dispatchEvent(new view.window.Event('blur')); + const state = view.window.acquireVsCodeApi().getState(); + assert.strictEqual(state.anchorRow, 1); + assert.strictEqual(state.anchorCol, 1); + await applySerialIndex(host, view, message); + assert.deepStrictEqual(host.rows(), rows); + } finally { view.close(); } + }); + + it('keeps remote later-chunk index cells actionable and preserves the source', async () => { + const data = [['Name', 'Value'], ...Array.from({ length: 1100 }, (_, row) => [`row-${row}`, 'value'])]; + const host = await createEditorHarness({ rows: data, remote: true }); + const view = createWebviewDom(host.panel.webview.html); + try { + await mountThrough(view, host, 1050); + await applySerialIndex(host, view, chooseHide(view, view.cell(1050, -1))); + assert.strictEqual(view.window.acquireVsCodeApi().getState().anchorRow, 1050); + assert.strictEqual(view.window.acquireVsCodeApi().getState().anchorCol, 0); + await host.receive({ type: 'requestChunk', start: 1001, requestId: 99 }); + const response = host.messages.slice().reverse().find((message: any) => message.type === 'chunkData' && message.requestId === 99); + assert.ok(!response.html.includes('data-col="-1"')); + assert.deepStrictEqual(host.rows(), data); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); } + }); + + it('retains native context menus while editing and drains queued multiline edits before refresh', async () => { + const host = await createEditorHarness({ rows }); + const view = createWebviewDom(host.panel.webview.html); + try { + view.start(view.cell(1, 0)); + view.type('draft\nline'); + const menu = openMenu(view, view.cell(1, -1)); + assert.strictEqual(menu.event.defaultPrevented, false); + assert.ok(!menu.items.some(item => item.textContent === 'Hide serial index column')); + view.cell(1, 0).blur(); + const editMessage = view.messages.find(message => message.type === 'editCell'); + assert.ok(editMessage); + host.deferEdits(); + const edit = host.receive(editMessage); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.pendingEdits, 1); + const hide = host.receive({ type: 'setSerialIndex', enabled: false }); + assert.ok(host.panel.webview.html.includes('data-col="-1"')); + host.releaseEdit(); + await edit; + await hide; + await finishRefresh(host, view); + assert.strictEqual(host.rows()[1][0], editMessage.value); + assert.ok(!host.panel.webview.html.includes('data-col="-1"')); + assert.ok(!host.diskText.includes('draft')); + } finally { view.close(); } + }); +}); diff --git a/src/test/webview-clipboard.test.ts b/src/test/webview-clipboard.test.ts index 96eeb09..df9d0d5 100644 --- a/src/test/webview-clipboard.test.ts +++ b/src/test/webview-clipboard.test.ts @@ -46,6 +46,7 @@ function createWebview() { contains(other: Element): boolean { return other === this || this.children.some(child => child.contains(other)); } focus() { document.activeElement = this; } scrollIntoView() {} + getBoundingClientRect() { return { width: 200, height: 300 }; } matches(selector: string): boolean { if (selector.includes(' ')) { const [ancestor, child] = selector.split(' '); @@ -103,6 +104,8 @@ function createWebview() { } const window = new Element('WINDOW') as any; window.getComputedStyle = () => ({ fontSize: '14px' }); + window.innerWidth = 1024; + window.innerHeight = 768; const messages: any[] = []; const context = vm.createContext({ document, window, Element, Node: Element, @@ -192,7 +195,7 @@ describe('Shipped webview clipboard interactions', () => { const second = view.cell(1, 1, 'two'); view.select(1, 0, 1, 1); view.dispatch(second, 'mousedown', { button: 2 }); - view.dispatch(second, 'contextmenu', { pageX: 10, pageY: 20 }); + view.dispatch(second, 'contextmenu', { clientX: 10, clientY: 20 }); const menu = view.byId('contextMenu'); for (const label of ['Copy', 'Cut', 'Paste']) { const item = menu.children.find(child => child.textContent === label)!;