diff --git a/README.md b/README.md index 7dec9d6..191acf6 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Global (Settings UI or `settings.json`): - `csv.mouseWheelZoom` (boolean, default `true`): Enable `Ctrl/Cmd + Mouse Wheel` zooming in the CSV editor. - `csv.mouseWheelZoomInvert` (boolean, default `false`): Invert the `Ctrl/Cmd + Mouse Wheel` zoom direction. - `csv.cellPadding` (number, default `4`): Vertical cell padding in pixels. +- `csv.maxCellHeightLines` (number, default `3`): Maximum number of text lines displayed per cell. Set to `0` to disable line height limits (up to 1,000 characters per cell). - `csv.columnColorMode` (string, default `type`): `type` keeps CSV’s type-based column colors; `theme` uses your theme foreground color for all columns. - `csv.columnColorPalette` (string, default `default`): Type-color palette when `csv.columnColorMode` is `type`. `cool` biases colors toward greens/blues; `warm` biases colors toward oranges/reds. - `csv.diffUseThemeForeground` (boolean, default `true`): In compare/diff views, use theme foreground color so diff highlighting remains readable. diff --git a/media/main.js b/media/main.js index 58079a4..9c54ab1 100644 --- a/media/main.js +++ b/media/main.js @@ -18,6 +18,7 @@ const computedFontSizePx = parsePositiveNumber(window.getComputedStyle(document. const BASE_FONT_SIZE_PX = configuredFontSizePx ?? computedFontSizePx ?? 14; const MOUSE_WHEEL_ZOOM_ENABLED = root?.dataset?.wheelzoomenabled !== '0'; const MOUSE_WHEEL_ZOOM_INVERTED = root?.dataset?.wheelzoominvert === '1'; +const MAX_CELL_HEIGHT_LINES = parseInt(root?.dataset?.maxcelllines || '3', 10); const ZOOM_STEP = 0.1; const ZOOM_MIN = 0.5; const ZOOM_MAX = 3.0; @@ -49,6 +50,184 @@ document.body.appendChild(dragIndicator); let columnSizeState = {}; let rowSizeState = {}; +// Cell content truncation modal elements +const cellExpandBtn = document.getElementById('cellExpandBtn'); +const cellModalOverlay = document.getElementById('cellModalOverlay'); +const cellModalHeaderTitle = document.getElementById('cellModalHeaderTitle'); +const cellModalTextArea = document.getElementById('cellModalTextArea'); +const cellModalCopyBtn = document.getElementById('cellModalCopyBtn'); +const cellModalCopyLabel = document.getElementById('cellModalCopyLabel'); +const cellModalCloseBtn = document.getElementById('cellModalCloseBtn'); +const cellModalSaveBtn = document.getElementById('cellModalSaveBtn'); + +let activeTruncatedCell = null; +let modalTargetCoords = { row: null, col: null }; +let initialModalText = ''; + +const isCellTruncated = cell => { + if (!cell || cell.tagName !== 'TD' || cell.classList.contains('editing')) return false; + if (cell.getAttribute('data-domtruncated') === '1') return true; + if (MAX_CELL_HEIGHT_LINES === 0) return false; + const inner = cell.querySelector('.cell-content') || cell; + return inner.scrollHeight > (inner.clientHeight + 2); +}; + +const updateExpandBtnForCell = cell => { + if (!cellExpandBtn) return; + if (cellModalOverlay && cellModalOverlay.classList.contains('open')) { + cellExpandBtn.style.display = 'none'; + return; + } + const target = cell || (currentSelection.length === 1 ? currentSelection[0] : null); + if (isCellTruncated(target)) { + activeTruncatedCell = target; + const rect = target.getBoundingClientRect(); + cellExpandBtn.style.top = Math.max(0, rect.top + 2) + 'px'; + cellExpandBtn.style.left = Math.max(0, rect.right - 68) + 'px'; + cellExpandBtn.style.display = 'inline-flex'; + } else { + activeTruncatedCell = null; + cellExpandBtn.style.display = 'none'; + } +}; + +const openCellModal = (row, col) => { + let targetCell = null; + if (typeof row === 'number' && typeof col === 'number') { + targetCell = table.querySelector(`td[data-row="${row}"][data-col="${col}"]`); + } else if (activeTruncatedCell) { + targetCell = activeTruncatedCell; + } else if (currentSelection.length > 0) { + targetCell = currentSelection[0]; + } + if (!targetCell) return; + + const coords = getCellCoords(targetCell); + const r = !isNaN(coords.row) ? coords.row : row; + const c = !isNaN(coords.col) ? coords.col : col; + + let colName = ''; + if (hasHeader && !isNaN(c) && c >= 0) { + const th = table.querySelector(`th[data-col="${c}"]`); + if (th) colName = th.innerText || th.textContent || ''; + } + + const headerTitleText = colName + ? `Column: ${colName} (Row ${(r !== undefined ? r : 0) + 1})` + : `Cell Content (Row ${(r !== undefined ? r : 0) + 1}, Col ${(c !== undefined ? c : 0) + 1})`; + + if (cellModalHeaderTitle) cellModalHeaderTitle.textContent = headerTitleText; + + modalTargetCoords = { row: r, col: c }; + + const isDomTruncated = targetCell.getAttribute('data-domtruncated') === '1'; + if (isDomTruncated) { + initialModalText = 'Loading full content...'; + if (cellModalTextArea) cellModalTextArea.value = initialModalText; + vscode.postMessage({ type: 'getCellContent', row: r, col: c }); + } else { + initialModalText = targetCell.innerText || targetCell.textContent || ''; + if (cellModalTextArea) cellModalTextArea.value = initialModalText; + } + + if (cellModalOverlay) cellModalOverlay.classList.add('open'); + if (cellExpandBtn) cellExpandBtn.style.display = 'none'; + + if (cellModalTextArea) cellModalTextArea.focus(); +}; + +const saveModalContentIfChanged = () => { + if (!cellModalTextArea || modalTargetCoords.row === null || modalTargetCoords.col === null) return; + const currentText = cellModalTextArea.value; + if (currentText !== initialModalText && initialModalText !== 'Loading full content...') { + vscode.postMessage({ + type: 'editCell', + row: modalTargetCoords.row, + col: modalTargetCoords.col, + value: currentText + }); + initialModalText = currentText; + } +}; + +const closeCellModal = (shouldSave = false) => { + if (!cellModalOverlay) return; + if (shouldSave) { + try { + saveModalContentIfChanged(); + } catch (e) { + console.error('CSV: failed saving modal content', e); + } + } + cellModalOverlay.classList.remove('open'); + modalTargetCoords = { row: null, col: null }; + initialModalText = ''; + if (cellModalCopyLabel) cellModalCopyLabel.textContent = 'Copy'; + if (anchorCell) { + try { anchorCell.focus({ preventScroll: true }); } catch { try { anchorCell.focus(); } catch {} } + } +}; + +const copyCellModalContent = () => { + if (!cellModalTextArea) return; + const text = cellModalTextArea.value; + vscode.postMessage({ type: 'copyToClipboard', text: text }); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch(() => {}); + } + if (cellModalCopyLabel) { + cellModalCopyLabel.textContent = 'Copied!'; + setTimeout(() => { + if (cellModalCopyLabel) cellModalCopyLabel.textContent = 'Copy'; + }, 1500); + } +}; + +if (cellExpandBtn) { + cellExpandBtn.addEventListener('click', e => { + e.stopPropagation(); + openCellModal(); + }); +} +if (cellModalCopyBtn) { + cellModalCopyBtn.addEventListener('click', e => { + e.stopPropagation(); + copyCellModalContent(); + }); +} +if (cellModalSaveBtn) { + cellModalSaveBtn.addEventListener('click', e => { + e.stopPropagation(); + closeCellModal(true); + }); +} +if (cellModalCloseBtn) { + cellModalCloseBtn.addEventListener('click', e => { + e.stopPropagation(); + closeCellModal(false); + }); +} +if (cellModalOverlay) { + cellModalOverlay.addEventListener('click', e => { + if (e.target === cellModalOverlay) { + closeCellModal(false); + } + }); +} + +if (scrollContainer) { + scrollContainer.addEventListener('scroll', () => updateExpandBtnForCell(), { passive: true }); +} +window.addEventListener('resize', () => updateExpandBtnForCell(), { passive: true }); +if (table) { + table.addEventListener('mouseover', e => { + const cell = getCellTarget(e.target); + if (cell && cell.tagName === 'TD') { + updateExpandBtnForCell(cell); + } + }); +} + const normalizeSizeState = (raw, minSize) => { const out = {}; if (!raw || typeof raw !== 'object') return out; @@ -398,6 +577,13 @@ const showContextMenu = (x, y, row, col) => { let addedRowItems = false; + /* View Full Cell Content section */ + if (!isNaN(row) && row >= 0 && !isNaN(col) && col >= 0) { + item('View Full Cell Content', () => { + openCellModal(row, col); + }); + } + /* Header-only: SORT functionality */ if (lastContextIsHeader) { item('Sort: A-Z', () => @@ -836,7 +1022,15 @@ table.addEventListener('mousedown', e => { } } - if(editingCell){ if(e.target !== editingCell) editingCell.blur(); else return; } else clearSelection(); + if (editingCell) { + if (!editingCell.contains(e.target)) { + editingCell.blur(); + } else { + return; + } + } else { + clearSelection(); + } /* ──────── NEW: select-all via top-left header cell ──────── */ if ( @@ -1493,6 +1687,16 @@ document.addEventListener('keydown', e => { }, true); document.addEventListener('keydown', e => { + if (cellModalOverlay && cellModalOverlay.classList.contains('open')) { + if (e.key === 'Escape') { + e.preventDefault(); + closeCellModal(false); + } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + closeCellModal(true); + } + return; + } if (maybeHandleZoomShortcut(e)) { return; } @@ -1892,6 +2096,7 @@ const setSingleSelection = cell => { currentSelection.push(cell); anchorCell = cell; rangeEndCell = cell; + updateExpandBtnForCell(cell); persistState(); try { cell.focus({ preventScroll: true }); } catch { try { cell.focus(); } catch {} } cell.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); @@ -1964,6 +2169,12 @@ const insertNewlineAtCaret = cell => { }; const editCell = (cell, event, mode = 'detail') => { + if (!cell || cell.tagName !== 'TD') return; + if (cell.getAttribute('data-domtruncated') === '1') { + const coords = getCellCoords(cell); + openCellModal(coords.row, coords.col); + return; + } if(editingCell === cell) return; if(editingCell) editingCell.blur(); cell.classList.remove('selected'); @@ -2013,6 +2224,13 @@ table.addEventListener('dblclick', e => { } const target = getCellTarget(e.target); if (!target) return; + if (target.getAttribute('data-domtruncated') === '1') { + e.preventDefault(); + e.stopPropagation(); + const coords = getCellCoords(target); + openCellModal(coords.row, coords.col); + return; + } clearSelection(); editCell(target, e); }); @@ -2079,15 +2297,33 @@ window.addEventListener('message', event => { } else if (csvChunks.length && nearBottom()) { loadNextChunk(); } - } else if(message.type === 'updateCell'){ + } else if (message.type === 'fullCellContent') { + const { value } = message; + initialModalText = value || ''; + if (cellModalOverlay && cellModalOverlay.classList.contains('open')) { + if (cellModalTextArea) cellModalTextArea.value = initialModalText; + } + } else if (message.type === 'updateCell') { isUpdating = true; - const { row, col, value, rendered } = message; + const { row, col, value, rendered, isDomTruncated } = message; const cell = table.querySelector('td[data-row="'+row+'"][data-col="'+col+'"], th[data-row="'+row+'"][data-col="'+col+'"]'); if (cell) { + let inner = cell.querySelector('.cell-content'); + if (!inner) { + inner = document.createElement('div'); + inner.className = 'cell-content'; + cell.innerHTML = ''; + cell.appendChild(inner); + } if (typeof rendered === 'string') { - cell.innerHTML = rendered; + inner.innerHTML = rendered; + } else { + inner.textContent = value; + } + if (isDomTruncated) { + cell.setAttribute('data-domtruncated', '1'); } else { - cell.textContent = value; + cell.removeAttribute('data-domtruncated'); } } isUpdating = false; diff --git a/package.json b/package.json index 31d2bda..e06b3fd 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,12 @@ "default": 4, "description": "Vertical padding in pixels for table cells." }, + "csv.maxCellHeightLines": { + "type": "number", + "default": 3, + "minimum": 0, + "description": "Maximum number of text lines displayed per cell. Set to 0 to disable line height limits (up to 1,000 characters per cell)." + }, "csv.columnColorMode": { "type": "string", "enum": [ @@ -236,7 +242,7 @@ "vscode:prepublish": "npm run compile", "lint": "eslint '**/*.ts'", "package": "vsce package", - "test": "npm run compile && node --test out/test" + "test": "npm run compile && node --test \"out/test/**/*.test.js\"" }, "dependencies": { "font-list": "^1.5.1", diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 9135ca8..55d5924 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -63,6 +63,7 @@ class CsvEditorController { private static readonly LARGE_FILE_IGNORE_FOREVER = 'Ignore Forever'; private isUpdatingDocument = false; + private lastInternalEditTime = 0; private isSaving = false; private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; @@ -145,6 +146,9 @@ class CsvEditorController { await vscode.env.clipboard.writeText(e.text); console.log('CSV: Copied to clipboard'); break; + case 'getCellContent': + this.handleGetCellContent(e.row, e.col, e.requestId); + break; case 'insertColumn': await this.insertColumn(e.index); break; @@ -184,13 +188,22 @@ class CsvEditorController { } }); + let pendingDocumentChangeTimeout: NodeJS.Timeout | undefined; const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { if ( e.document.uri.toString() === document.uri.toString() && !this.isUpdatingDocument && - !this.isSaving + !this.isSaving && + Date.now() - this.lastInternalEditTime > 1000 ) { - setTimeout(() => this.updateWebviewContent(), 250); + if (pendingDocumentChangeTimeout) { + clearTimeout(pendingDocumentChangeTimeout); + } + pendingDocumentChangeTimeout = setTimeout(() => { + if (!this.isUpdatingDocument && !this.isSaving && Date.now() - this.lastInternalEditTime > 1000) { + this.updateWebviewContent(); + } + }, 250); } }); @@ -398,6 +411,7 @@ class CsvEditorController { private async updateDocument(row: number, col: number, value: string) { this.isUpdatingDocument = true; + this.lastInternalEditTime = Date.now(); let structuralChange = false; let applied = false; try { @@ -455,8 +469,8 @@ class CsvEditorController { console.log(`CSV: Updated row ${row + 1}, column ${col + 1} to "${value}"`); const config = vscode.workspace.getConfiguration('csv', this.document.uri); const clickableLinks = config.get('clickableLinks', true); - const rendered = this.formatCellContent(value ?? '', clickableLinks); - this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value, rendered }); + const { safe: rendered, isDomTruncated } = this.formatCellForTable(value ?? '', clickableLinks); + this.currentWebviewPanel?.webview.postMessage({ type: 'updateCell', row, col, value, rendered, isDomTruncated }); // Trigger a full re-render if structure may have changed (new row/col created) if (structuralChange) { @@ -469,6 +483,7 @@ class CsvEditorController { return; } this.isUpdatingDocument = true; + this.lastInternalEditTime = Date.now(); try { const separator = this.getSeparator(); const oldText = this.document.getText(); @@ -705,6 +720,7 @@ class CsvEditorController { const selection = CsvEditorController.parsePasteSelectionBounds(rawSelection); this.isUpdatingDocument = true; + this.lastInternalEditTime = Date.now(); try { const separator = this.getSeparator(); const oldText = this.document.getText(); @@ -762,9 +778,10 @@ class CsvEditorController { let cells = ''; for (let cIdx = 0; cIdx < state.numColumns; cIdx++) { const rawValue = row[cIdx] || ''; - const safe = this.formatCellContent(rawValue, state.clickableLinks); + const { safe, isDomTruncated } = this.formatCellForTable(rawValue, state.clickableLinks); + const truncAttr = isDomTruncated ? ' data-domtruncated="1"' : ''; const titleAttr = this.getMultilineCellTitleAttr(rawValue); - cells += `${safe}`; + cells += `
${safe}
`; } const idxCell = state.addSerialIndex ? `${displayIdx}` @@ -787,7 +804,7 @@ class CsvEditorController { const idxCell = state.addSerialIndex ? `${displayIdx}` : ''; - const dataCells = Array.from({ length: state.numColumns }, (_, i) => ``).join(''); + const dataCells = Array.from({ length: state.numColumns }, (_, i) => `
`).join(''); return { html: `${idxCell}${dataCells}`, nextStart: -1, done: true }; } @@ -1384,6 +1401,7 @@ class CsvEditorController { ); const cellPadding = config.get('cellPadding', 4); + const maxCellHeightLines = config.get('maxCellHeightLines', 3); const data = this.trimTrailingEmptyRows((parsed.data || []) as string[][]); const treatHeader = this.getEffectiveHeader(data, hiddenRows); const clickableLinks = config.get('clickableLinks', true); @@ -1428,7 +1446,8 @@ class CsvEditorController { nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, - mouseWheelZoomInvert + mouseWheelZoomInvert, + maxCellHeightLines }); } @@ -1516,9 +1535,10 @@ class CsvEditorController { let cells = ''; for (let cIdx = 0; cIdx < numColumns; cIdx++) { const rawValue = row[cIdx] || ''; - const safe = this.formatCellContent(rawValue, clickableLinks); + const { safe, isDomTruncated } = this.formatCellForTable(rawValue, clickableLinks); + const truncAttr = isDomTruncated ? ' data-domtruncated="1"' : ''; const titleAttr = this.getMultilineCellTitleAttr(rawValue); - cells += `${safe}`; + cells += `
${safe}
`; } return `${ @@ -1556,16 +1576,17 @@ class CsvEditorController { }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; - const safe = this.formatCellContent(rawValue, clickableLinks); + const { safe, isDomTruncated } = this.formatCellForTable(rawValue, clickableLinks); + const truncAttr = isDomTruncated ? ' data-domtruncated="1"' : ''; const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += `${safe}`; + tableHtml += `
${safe}
`; } tableHtml += ``; }); if (!chunked && includeTrailingEmptyRow) { const virtualAbs = offset + 1 + initialBodyRows.length; const idxCell = addSerialIndex ? `${initialBodyRows.length + 1}` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const dataCells = Array.from({ length: numColumns }, (_, i) => `
`).join(''); tableHtml += `${idxCell}${dataCells}`; } tableHtml += ``; @@ -1580,9 +1601,10 @@ class CsvEditorController { }`; for (let i = 0; i < numColumns; i++) { const rawValue = row[i] || ''; - const safe = this.formatCellContent(rawValue, clickableLinks); + const { safe, isDomTruncated } = this.formatCellForTable(rawValue, clickableLinks); + const truncAttr = isDomTruncated ? ' data-domtruncated="1"' : ''; const titleAttr = this.getMultilineCellTitleAttr(rawValue); - tableHtml += `${safe}`; + tableHtml += `
${safe}
`; } tableHtml += ``; }); @@ -1590,7 +1612,7 @@ class CsvEditorController { const virtualAbs = offset + nonHeaderRows.length; const displayIdx = nonHeaderRows.length + 1; const idxCell = addSerialIndex ? `${displayIdx}` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const dataCells = Array.from({ length: numColumns }, (_, i) => `
`).join(''); tableHtml += `${idxCell}${dataCells}`; } tableHtml += ``; @@ -1603,7 +1625,7 @@ class CsvEditorController { const virtualAbs = startAbs + allRowsCount; const displayIdx = allRowsCount + 1; const idxCell = addSerialIndex ? `${displayIdx}` : ''; - const dataCells = Array.from({ length: numColumns }, (_, i) => ``).join(''); + const dataCells = Array.from({ length: numColumns }, (_, i) => `
`).join(''); const vrow = `${idxCell}${dataCells}`; chunks.push(vrow); } else if (nextChunkStart === -1) { @@ -1681,8 +1703,9 @@ class CsvEditorController { hasRemoteChunks: boolean; mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; + maxCellHeightLines: number; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert } = args; + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, maxCellHeightLines } = 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( @@ -1692,6 +1715,10 @@ class CsvEditorController { // Safe separator transport (assumes single character; see assumptions) const sepCode = (separator && separator.length > 0) ? separator.codePointAt(0)! : ','.codePointAt(0)!; + const maxLinesCss = maxCellHeightLines > 0 + ? `max-height: ${(maxCellHeightLines * 1.5).toFixed(1)}em;\n overflow: hidden;\n display: -webkit-box;\n -webkit-line-clamp: ${maxCellHeightLines};\n -webkit-box-orient: vertical;` + : `max-height: none;\n overflow: visible;\n display: block;\n -webkit-line-clamp: none;`; + return ` @@ -1706,13 +1733,160 @@ class CsvEditorController { table { border-collapse: collapse; width: max-content; } th, td { padding: ${cellPadding}px 8px; border: 1px solid ${isDark ? '#555' : '#ccc'}; font-size: inherit; } th { position: sticky; top: 0; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } - td { overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; } + td { overflow: hidden; position: relative; } + .cell-content { + ${maxLinesCss} + word-break: break-all; + overflow-wrap: anywhere; + white-space: pre-wrap; + } td.selected, th.selected { background-color: ${isDark ? '#333333' : '#cce0ff'} !important; } - td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; } + td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; max-height: none !important; } + td.editing .cell-content { + max-height: none !important; + overflow: visible !important; + display: block !important; + -webkit-line-clamp: none !important; + } .highlight { background-color: ${isDark ? '#2a2a2a' : '#fefefe'} !important; } .active-match { background-color: ${isDark ? '#444444' : '#ffffcc'} !important; } .csv-link { color: ${isDark ? '#6cb6ff' : '#0066cc'}; text-decoration: underline; cursor: pointer; } .csv-link:hover { color: ${isDark ? '#8ecfff' : '#0044aa'}; } + + /* Cell Expand Button & Modal Overlay */ + #cellExpandBtn { + position: fixed; + display: none; + align-items: center; + justify-content: center; + background: ${isDark ? '#252526' : '#ffffff'}; + border: 1px solid ${isDark ? '#454545' : '#007acc'}; + color: ${isDark ? '#cccccc' : '#007acc'}; + border-radius: 3px; + padding: 2px 5px; + font-size: 11px; + font-family: inherit; + cursor: pointer; + z-index: 1000; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); + user-select: none; + } + #cellExpandBtn:hover { + background: ${isDark ? '#007acc' : '#005fb8'}; + color: #ffffff; + border-color: ${isDark ? '#007acc' : '#005fb8'}; + } + + #cellModalOverlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.55); + z-index: 20000; + display: none; + align-items: center; + justify-content: center; + } + #cellModalOverlay.open { display: flex; } + #cellModal { + width: 620px; + max-width: 85vw; + max-height: 80vh; + background: ${isDark ? '#1e1e1e' : '#ffffff'}; + border: 1px solid ${isDark ? '#454545' : '#cccccc'}; + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + color: ${isDark ? '#d4d4d4' : '#333333'}; + font-family: ${this.escapeCss(fontFamily)}; + font-size: inherit; + overflow: hidden; + } + #cellModalHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid ${isDark ? '#333333' : '#e5e5e5'}; + background: ${isDark ? '#252526' : '#f8f8f8'}; + font-weight: 600; + } + #cellModalHeaderTitle { + font-size: 0.95em; + color: ${isDark ? '#cccccc' : '#333333'}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + #cellModalHeaderActions { + display: flex; + align-items: center; + gap: 8px; + } + .cm-btn { + background: ${isDark ? '#3c3c3c' : '#e1e1e1'}; + border: 1px solid ${isDark ? '#555555' : '#cccccc'}; + color: ${isDark ? '#cccccc' : '#333333'}; + border-radius: 4px; + padding: 4px 10px; + font-size: 0.85em; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + } + .cm-btn:hover { + background: ${isDark ? '#007acc' : '#005fb8'}; + color: #ffffff; + border-color: ${isDark ? '#007acc' : '#005fb8'}; + } + .cm-close-btn { + background: transparent; + border: none; + font-size: 1.2em; + padding: 2px 6px; + cursor: pointer; + color: ${isDark ? '#aaaaaa' : '#666666'}; + } + .cm-close-btn:hover { color: ${isDark ? '#ffffff' : '#000000'}; } + #cellModalBody { + padding: 14px; + overflow-y: auto; + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + #cellModalTextArea { + width: 100%; + height: 280px; + min-height: 180px; + max-height: 55vh; + box-sizing: border-box; + border: 1px solid ${isDark ? '#333333' : '#cccccc'}; + border-radius: 4px; + background: ${isDark ? '#141414' : '#fafafa'}; + color: inherit; + font-family: inherit; + font-size: inherit; + padding: 8px 10px; + resize: vertical; + white-space: pre-wrap; + word-break: break-all; + } + .cm-btn-primary { + background: ${isDark ? '#0e639c' : '#007acc'}; + border-color: ${isDark ? '#1177bb' : '#005fb8'}; + color: #ffffff; + font-weight: 500; + } + .cm-btn-primary:hover { + background: ${isDark ? '#1177bb' : '#005fb8'}; + border-color: ${isDark ? '#0e639c' : '#007acc'}; + color: #ffffff; + } #findReplaceWidget { position: fixed; top: 12px; @@ -1894,12 +2068,39 @@ class CsvEditorController { -
+
${tableHtml}
+ + + +
@@ -2061,6 +2262,38 @@ class CsvEditorController { return linkify ? this.linkifyUrls(escaped) : escaped; } + private formatCellForTable(rawValue: string, clickableLinks: boolean): { safe: string; isDomTruncated: boolean } { + const MAX_DOM_CELL_CHARS = 1000; + let textToFormat = rawValue || ''; + let isDomTruncated = false; + if (textToFormat.length > MAX_DOM_CELL_CHARS) { + textToFormat = textToFormat.slice(0, MAX_DOM_CELL_CHARS) + '...'; + isDomTruncated = true; + } + const safe = this.formatCellContent(textToFormat, clickableLinks); + return { safe, isDomTruncated }; + } + + private handleGetCellContent(row: number, col: number, requestId?: string) { + let value = ''; + try { + const separator = this.getSeparator(); + const text = this.document.getText(); + const parsed = Papa.parse(text, { dynamicTyping: false, delimiter: separator }); + const data = (parsed.data || []) as string[][]; + if (row >= 0 && row < data.length) { + value = data[row]?.[col] ?? ''; + } + } catch {} + this.currentWebviewPanel?.webview.postMessage({ + type: 'fullCellContent', + row, + col, + requestId, + value + }); + } + private getMultilineCellTitleAttr(text: string): string { if (!text || (text.indexOf('\n') === -1 && text.indexOf('\r') === -1)) { return ''; diff --git a/src/extension.ts b/src/extension.ts index 50ec42b..78b2577 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -89,6 +89,7 @@ export function activate(context: vscode.ExtensionContext) { const keys = [ 'csv.fontFamily', 'csv.cellPadding', + 'csv.maxCellHeightLines', 'csv.columnColorMode', 'csv.columnColorPalette', 'csv.diffUseThemeForeground', diff --git a/src/test/cell-truncation.test.ts b/src/test/cell-truncation.test.ts new file mode 100644 index 0000000..5e62976 --- /dev/null +++ b/src/test/cell-truncation.test.ts @@ -0,0 +1,91 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import fs from 'fs'; +import path from 'path'; + +describe('Cell truncation and full content modal', () => { + const providerSource = fs.readFileSync(path.join(process.cwd(), 'src', 'CsvEditorProvider.ts'), 'utf8'); + const webviewSource = fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'); + const packageSource = fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'); + + it('declares csv.maxCellHeightLines setting in package.json', () => { + assert.ok(packageSource.includes('"csv.maxCellHeightLines"')); + assert.ok(packageSource.includes('"default": 3')); + }); + + it('includes cell-content wrapper and dynamic line clamping CSS in provider', () => { + assert.ok(providerSource.includes("config.get('maxCellHeightLines', 3)")); + assert.ok(providerSource.includes('.cell-content {')); + assert.ok(providerSource.includes('-webkit-line-clamp:')); + assert.ok(providerSource.includes('
')); + assert.ok(providerSource.includes('td.editing .cell-content {')); + assert.ok(providerSource.includes('data-maxcelllines=')); + }); + + it('supports DOM cell text truncation (>1000 chars) and getCellContent IPC handler', () => { + assert.ok(providerSource.includes('MAX_DOM_CELL_CHARS = 1000')); + assert.ok(providerSource.includes('data-domtruncated="1"')); + assert.ok(providerSource.includes("case 'getCellContent':")); + assert.ok(providerSource.includes('handleGetCellContent')); + assert.ok(webviewSource.includes("getAttribute('data-domtruncated') === '1'")); + assert.ok(webviewSource.includes("type: 'getCellContent'")); + assert.ok(webviewSource.includes("message.type === 'fullCellContent'")); + }); + + it('provides an editable modal textarea with header Save support', () => { + assert.ok(!providerSource.includes('