From 0bf06cbee47e23011b8dfa34dd1378e7017be6cb Mon Sep 17 00:00:00 2001 From: Robert Gaal Date: Mon, 19 Jan 2026 17:08:34 +0100 Subject: [PATCH] feat: Add clickable links support for URLs in cells - URLs (http, https, ftp, mailto) in cells are automatically detected and rendered as clickable links - Ctrl/Cmd+click to open links in external browser - New setting csv.clickableLinks (default: true) to toggle feature - New command 'CSV: Toggle Clickable Links' in command palette - Links don't interfere with cell editing or selection --- README.md | 5 ++++- media/main.js | 44 ++++++++++++++++++++++++++++++++++++++-- package.json | 11 ++++++++-- src/CsvEditorProvider.ts | 37 +++++++++++++++++++++++++++------ src/commands.ts | 3 +++ src/extension.ts | 2 +- 6 files changed, 90 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 45dbd55..9f39307 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: - **Column Sorting:** Right-click a header and choose A–Z or Z–A. - **Custom Font Selection:** Choose a font from a dropdown or inherit VS Code's default. - **Find & Highlight:** Built-in find widget helps you search for text within your CSV with real-time highlighting and navigation through matches. +- **Clickable Links:** URLs in cells are automatically detected and displayed as clickable links. Ctrl/Cmd+click to open them in your browser. - **Preserved CSV Integrity:** All modifications respect CSV formatting—no unwanted extra characters or formatting issues. - **Optimized for Performance:** Designed for medium-sized datasets, ensuring a smooth editing experience without compromising on functionality. - **Large File Support:** Loads big CSVs in chunks so even large datasets open quickly. @@ -85,6 +86,7 @@ Open the Command Palette and search for: - `CSV: Change Font Family` (`csv.changeFontFamily`) - `CSV: Hide First N Rows` (`csv.changeIgnoreRows`) - `CSV: Change File Encoding` (`csv.changeEncoding`) +- `CSV: Toggle Clickable Links` (`csv.toggleClickableLinks`) ## Settings @@ -94,7 +96,8 @@ Global (Settings UI or `settings.json`): - `csv.enabled` (boolean, default `true`): Enable/disable the custom editor. - `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`. - `csv.cellPadding` (number, default `4`): Vertical cell padding in pixels. -- Per-file encoding: use `CSV: Change File Encoding` to set a file’s encoding (e.g., `utf8`, `utf16le`, `windows1250`, `gbk`). The extension will reopen the file using the chosen encoding. +- `csv.clickableLinks` (boolean, default `true`): Make URLs in cells clickable. Ctrl/Cmd+click to open links. +- Per-file encoding: use `CSV: Change File Encoding` to set a file's encoding (e.g., `utf8`, `utf16le`, `windows1250`, `gbk`). The extension will reopen the file using the chosen encoding. Per-file (stored by the extension; set via commands): diff --git a/media/main.js b/media/main.js index 51331a3..c633809 100644 --- a/media/main.js +++ b/media/main.js @@ -283,7 +283,25 @@ const showContextMenu = (x, y, row, col) => { contextMenu.style.display = 'block'; }; -document.addEventListener('click', () => { contextMenu.style.display = 'none'; }); +document.addEventListener('click', (e) => { + contextMenu.style.display = 'none'; + + // Handle clicks on CSV links + if (e.target.classList.contains('csv-link')) { + e.preventDefault(); + e.stopPropagation(); + + // Ctrl/Cmd+click to open link + if (e.ctrlKey || e.metaKey) { + const url = e.target.getAttribute('href'); + if (url) { + vscode.postMessage({ type: 'openLink', url: url }); + } + } + // Regular click just selects the cell (don't start editing) + return; + } +}); /* ──────── UPDATED contextmenu listener ──────── */ table.addEventListener('contextmenu', e => { @@ -300,6 +318,10 @@ table.addEventListener('contextmenu', e => { }); table.addEventListener('mousedown', e => { + // Don't interfere with link clicks + if (e.target.classList.contains('csv-link')) { + return; + } if(e.target.tagName !== 'TD' && e.target.tagName !== 'TH') return; const target = e.target; @@ -886,7 +908,25 @@ const editCell = (cell, event, mode = 'detail') => { event ? setCursorAtPoint(cell, event.clientX, event.clientY) : setCursorToEnd(cell); }; -table.addEventListener('dblclick', e => { const target = e.target; if(target.tagName !== 'TD' && target.tagName !== 'TH') return; clearSelection(); editCell(target, e); }); +table.addEventListener('dblclick', e => { + const target = e.target; + // Don't enter edit mode when double-clicking a link + if (target.classList.contains('csv-link')) { + e.preventDefault(); + e.stopPropagation(); + // Ctrl/Cmd+double-click opens the link + if (e.ctrlKey || e.metaKey) { + const url = target.getAttribute('href'); + if (url) { + vscode.postMessage({ type: 'openLink', url: url }); + } + } + return; + } + if(target.tagName !== 'TD' && target.tagName !== 'TH') return; + clearSelection(); + editCell(target, e); +}); const copySelectionToClipboard = () => { if (currentSelection.length === 0) return; diff --git a/package.json b/package.json index 0f3ba1b..0e95af0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "onCommand:csv.changeSeparator", "onCommand:csv.changeFontFamily", "onCommand:csv.changeIgnoreRows", - "onCommand:csv.changeEncoding" + "onCommand:csv.changeEncoding", + "onCommand:csv.toggleClickableLinks" ], "main": "./out/extension.js", "contributes": { @@ -48,7 +49,8 @@ { "command": "csv.changeSeparator", "title": "CSV: Change CSV Separator" }, { "command": "csv.changeFontFamily", "title": "CSV: Change Font Family" }, { "command": "csv.changeIgnoreRows", "title": "CSV: Hide First N Rows" }, - { "command": "csv.changeEncoding", "title": "CSV: Change File Encoding" } + { "command": "csv.changeEncoding", "title": "CSV: Change File Encoding" }, + { "command": "csv.toggleClickableLinks", "title": "CSV: Toggle Clickable Links" } ], "configuration": { "type": "object", @@ -69,6 +71,11 @@ "type": "number", "default": 4, "description": "Vertical padding in pixels for table cells." + }, + "csv.clickableLinks": { + "type": "boolean", + "default": true, + "description": "Make URLs in cells clickable. Ctrl/Cmd+click to open links." } } }, diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 06c0c6d..c87416e 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -96,6 +96,11 @@ class CsvEditorController { case 'sortColumn': await this.sortColumn(e.index, e.ascending); break; + case 'openLink': + if (e.url) { + vscode.env.openExternal(vscode.Uri.parse(e.url)); + } + break; } }); @@ -562,9 +567,10 @@ class CsvEditorController { const cellPadding = config.get('cellPadding', 4); const data = this.trimTrailingEmptyRows((parsed.data || []) as string[][]); const treatHeader = this.getEffectiveHeader(data, hiddenRows); + const clickableLinks = config.get('clickableLinks', true); const { tableHtml, chunksJson, colorCss } = - this.generateTableAndChunks(data, treatHeader, addSerialIndex, hiddenRows); + this.generateTableAndChunks(data, treatHeader, addSerialIndex, hiddenRows, clickableLinks); const nonce = this.getNonce(); @@ -584,7 +590,8 @@ class CsvEditorController { data: string[][], treatHeader: boolean, addSerialIndex: boolean, - hiddenRows: number + hiddenRows: number, + clickableLinks: boolean ): { tableHtml: string; chunksJson: string; colorCss: string } { let headerFlag = treatHeader; const totalRows = data.length; @@ -625,7 +632,7 @@ class CsvEditorController { const displayIdx = i + localR + 1; // numbering relative to first visible data row let cells = ''; for (let cIdx = 0; cIdx < numColumns; cIdx++) { - const safe = this.escapeHtml(row[cIdx] || ''); + const safe = this.formatCellContent(row[cIdx] || '', clickableLinks); cells += `${safe}`; } @@ -661,7 +668,7 @@ class CsvEditorController { : '' }`; for (let i = 0; i < numColumns; i++) { - const safe = this.escapeHtml(headerRow[i] || ''); + const safe = this.formatCellContent(headerRow[i] || '', clickableLinks); tableHtml += `${safe}`; } tableHtml += ``; @@ -672,7 +679,7 @@ class CsvEditorController { : '' }`; for (let i = 0; i < numColumns; i++) { - const safe = this.escapeHtml(row[i] || ''); + const safe = this.formatCellContent(row[i] || '', clickableLinks); tableHtml += `${safe}`; } tableHtml += ``; @@ -695,7 +702,7 @@ class CsvEditorController { : '' }`; for (let i = 0; i < numColumns; i++) { - const safe = this.escapeHtml(row[i] || ''); + const safe = this.formatCellContent(row[i] || '', clickableLinks); tableHtml += `${safe}`; } tableHtml += ``; @@ -794,6 +801,8 @@ class CsvEditorController { td.editing, th.editing { overflow: visible !important; white-space: normal !important; max-width: none !important; } .highlight { background-color: ${isDark ? '#222222' : '#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'}; } #findWidget { position: fixed; top: 20px; @@ -888,6 +897,22 @@ class CsvEditorController { })[m] as string); } + private linkifyUrls(escapedText: string): string { + // Match URLs in already-escaped text (handles & in query strings) + // Supports http, https, ftp, mailto protocols + const urlPattern = /(?:https?:\/\/|ftp:\/\/|mailto:)[^\s<>&"']+(?:&[^\s<>&"']+)*/gi; + return escapedText.replace(urlPattern, (url) => { + // Decode & back to & for the href attribute + const href = url.replace(/&/g, '&'); + return `${url}`; + }); + } + + private formatCellContent(text: string, linkify: boolean): string { + const escaped = this.escapeHtml(text); + return linkify ? this.linkifyUrls(escaped) : escaped; + } + private escapeCss(text: string): string { // conservative; ok for font-family lists return text.replace(/[\\"]/g, m => (m === '\\' ? '\\\\' : '\\"')); diff --git a/src/commands.ts b/src/commands.ts index b4459e6..a8dc1c8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -16,6 +16,9 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { vscode.commands.registerCommand('csv.toggleExtension', () => toggleBooleanConfig('enabled', true, 'CSV extension') ), + vscode.commands.registerCommand('csv.toggleClickableLinks', () => + toggleBooleanConfig('clickableLinks', true, 'CSV clickable links') + ), vscode.commands.registerCommand('csv.toggleHeader', async () => { const active = CsvEditorProvider.getActiveProvider(); if (!active) { vscode.window.showInformationMessage('Open a CSV/TSV file in the CSV editor.'); return; } diff --git a/src/extension.ts b/src/extension.ts index fef7e59..306f8ac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -86,7 +86,7 @@ export function activate(context: vscode.ExtensionContext) { } } - const keys = ['csv.fontFamily', 'csv.cellPadding']; + const keys = ['csv.fontFamily', 'csv.cellPadding', 'csv.clickableLinks']; const changed = keys.filter(k => e.affectsConfiguration(k)); if (changed.length) { CsvEditorProvider.editors.forEach(ed => ed.refresh());