Skip to content

Commit d4fcb88

Browse files
feat: Add clickable links support for URLs in cells (#72)
- 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 Co-authored-by: jonaraphael <jona.raphael@gmail.com>
1 parent 46ac9bc commit d4fcb88

File tree

6 files changed

+92
-11
lines changed

6 files changed

+92
-11
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get:
3838
- **Column Sorting:** Right-click a header and choose A–Z or Z–A.
3939
- **Custom Font Selection:** Choose a font from a dropdown or inherit VS Code's default.
4040
- **Find & Highlight:** Built-in find widget helps you search for text within your CSV with real-time highlighting and navigation through matches.
41+
- **Clickable Links:** URLs in cells are automatically detected and displayed as clickable links. Ctrl/Cmd+click to open them in your browser.
4142
- **Preserved CSV Integrity:** All modifications respect CSV formatting—no unwanted extra characters or formatting issues.
4243
- **Optimized for Performance:** Designed for medium-sized datasets, ensuring a smooth editing experience without compromising on functionality.
4344
- **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:
8586
- `CSV: Change Font Family` (`csv.changeFontFamily`)
8687
- `CSV: Hide First N Rows` (`csv.changeIgnoreRows`)
8788
- `CSV: Change File Encoding` (`csv.changeEncoding`)
89+
- `CSV: Toggle Clickable Links` (`csv.toggleClickableLinks`)
8890

8991

9092
## Settings
@@ -94,7 +96,8 @@ Global (Settings UI or `settings.json`):
9496
- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor.
9597
- `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`.
9698
- `csv.cellPadding` (number, default `4`): Vertical cell padding in pixels.
97-
- 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.
99+
- `csv.clickableLinks` (boolean, default `true`): Make URLs in cells clickable. Ctrl/Cmd+click to open links.
100+
- 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.
98101

99102
Per-file (stored by the extension; set via commands):
100103

media/main.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,25 @@ const showContextMenu = (x, y, row, col) => {
283283
contextMenu.style.display = 'block';
284284
};
285285

286-
document.addEventListener('click', () => { contextMenu.style.display = 'none'; });
286+
document.addEventListener('click', (e) => {
287+
contextMenu.style.display = 'none';
288+
289+
// Handle clicks on CSV links
290+
if (e.target.classList.contains('csv-link')) {
291+
e.preventDefault();
292+
e.stopPropagation();
293+
294+
// Ctrl/Cmd+click to open link
295+
if (e.ctrlKey || e.metaKey) {
296+
const url = e.target.getAttribute('href');
297+
if (url) {
298+
vscode.postMessage({ type: 'openLink', url: url });
299+
}
300+
}
301+
// Regular click just selects the cell (don't start editing)
302+
return;
303+
}
304+
});
287305

288306
/* ──────── UPDATED contextmenu listener ──────── */
289307
table.addEventListener('contextmenu', e => {
@@ -300,6 +318,10 @@ table.addEventListener('contextmenu', e => {
300318
});
301319

302320
table.addEventListener('mousedown', e => {
321+
// Don't interfere with link clicks
322+
if (e.target.classList.contains('csv-link')) {
323+
return;
324+
}
303325
if(e.target.tagName !== 'TD' && e.target.tagName !== 'TH') return;
304326
const target = e.target;
305327

@@ -886,7 +908,25 @@ const editCell = (cell, event, mode = 'detail') => {
886908
event ? setCursorAtPoint(cell, event.clientX, event.clientY) : setCursorToEnd(cell);
887909
};
888910

889-
table.addEventListener('dblclick', e => { const target = e.target; if(target.tagName !== 'TD' && target.tagName !== 'TH') return; clearSelection(); editCell(target, e); });
911+
table.addEventListener('dblclick', e => {
912+
const target = e.target;
913+
// Don't enter edit mode when double-clicking a link
914+
if (target.classList.contains('csv-link')) {
915+
e.preventDefault();
916+
e.stopPropagation();
917+
// Ctrl/Cmd+double-click opens the link
918+
if (e.ctrlKey || e.metaKey) {
919+
const url = target.getAttribute('href');
920+
if (url) {
921+
vscode.postMessage({ type: 'openLink', url: url });
922+
}
923+
}
924+
return;
925+
}
926+
if(target.tagName !== 'TD' && target.tagName !== 'TH') return;
927+
clearSelection();
928+
editCell(target, e);
929+
});
890930

891931
const copySelectionToClipboard = () => {
892932
if (currentSelection.length === 0) return;

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"onCommand:csv.changeSeparator",
2828
"onCommand:csv.changeFontFamily",
2929
"onCommand:csv.changeIgnoreRows",
30-
"onCommand:csv.changeEncoding"
30+
"onCommand:csv.changeEncoding",
31+
"onCommand:csv.toggleClickableLinks"
3132
],
3233
"main": "./out/extension.js",
3334
"contributes": {
@@ -83,6 +84,10 @@
8384
{
8485
"command": "csv.changeEncoding",
8586
"title": "CSV: Change File Encoding"
87+
},
88+
{
89+
"command": "csv.toggleClickableLinks",
90+
"title": "CSV: Toggle Clickable Links"
8691
}
8792
],
8893
"configuration": {
@@ -104,6 +109,11 @@
104109
"type": "number",
105110
"default": 4,
106111
"description": "Vertical padding in pixels for table cells."
112+
},
113+
"csv.clickableLinks": {
114+
"type": "boolean",
115+
"default": true,
116+
"description": "Make URLs in cells clickable. Ctrl/Cmd+click to open links."
107117
}
108118
}
109119
},

src/CsvEditorProvider.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ class CsvEditorController {
9696
case 'sortColumn':
9797
await this.sortColumn(e.index, e.ascending);
9898
break;
99+
case 'openLink':
100+
if (e.url) {
101+
vscode.env.openExternal(vscode.Uri.parse(e.url));
102+
}
103+
break;
99104
}
100105
});
101106

@@ -562,9 +567,10 @@ class CsvEditorController {
562567
const cellPadding = config.get<number>('cellPadding', 4);
563568
const data = this.trimTrailingEmptyRows((parsed.data || []) as string[][]);
564569
const treatHeader = this.getEffectiveHeader(data, hiddenRows);
570+
const clickableLinks = config.get<boolean>('clickableLinks', true);
565571

566572
const { tableHtml, chunksJson, colorCss } =
567-
this.generateTableAndChunks(data, treatHeader, addSerialIndex, hiddenRows);
573+
this.generateTableAndChunks(data, treatHeader, addSerialIndex, hiddenRows, clickableLinks);
568574

569575
const nonce = this.getNonce();
570576

@@ -584,7 +590,8 @@ class CsvEditorController {
584590
data: string[][],
585591
treatHeader: boolean,
586592
addSerialIndex: boolean,
587-
hiddenRows: number
593+
hiddenRows: number,
594+
clickableLinks: boolean
588595
): { tableHtml: string; chunksJson: string; colorCss: string } {
589596
let headerFlag = treatHeader;
590597
const totalRows = data.length;
@@ -625,7 +632,7 @@ class CsvEditorController {
625632
const displayIdx = i + localR + 1; // numbering relative to first visible data row
626633
let cells = '';
627634
for (let cIdx = 0; cIdx < numColumns; cIdx++) {
628-
const safe = this.escapeHtml(row[cIdx] || '');
635+
const safe = this.formatCellContent(row[cIdx] || '', clickableLinks);
629636
cells += `<td tabindex="0" style="min-width:${Math.min(columnWidths[cIdx]||0,100)}ch;max-width:100ch;border:1px solid ${isDark?'#555':'#ccc'};color:${columnColors[cIdx]};overflow:hidden;white-space:nowrap;text-overflow:ellipsis;" data-row="${absRow}" data-col="${cIdx}">${safe}</td>`;
630637
}
631638

@@ -661,7 +668,7 @@ class CsvEditorController {
661668
: ''
662669
}`;
663670
for (let i = 0; i < numColumns; i++) {
664-
const safe = this.escapeHtml(headerRow[i] || '');
671+
const safe = this.formatCellContent(headerRow[i] || '', clickableLinks);
665672
tableHtml += `<th tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; color: ${columnColors[i]}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;" data-row="${offset}" data-col="${i}">${safe}</th>`;
666673
}
667674
tableHtml += `</tr></thead><tbody>`;
@@ -672,7 +679,7 @@ class CsvEditorController {
672679
: ''
673680
}`;
674681
for (let i = 0; i < numColumns; i++) {
675-
const safe = this.escapeHtml(row[i] || '');
682+
const safe = this.formatCellContent(row[i] || '', clickableLinks);
676683
tableHtml += `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;" data-row="${offset + 1 + r}" data-col="${i}">${safe}</td>`;
677684
}
678685
tableHtml += `</tr>`;
@@ -695,7 +702,7 @@ class CsvEditorController {
695702
: ''
696703
}`;
697704
for (let i = 0; i < numColumns; i++) {
698-
const safe = this.escapeHtml(row[i] || '');
705+
const safe = this.formatCellContent(row[i] || '', clickableLinks);
699706
tableHtml += `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;" data-row="${offset + r}" data-col="${i}">${safe}</td>`;
700707
}
701708
tableHtml += `</tr>`;
@@ -790,6 +797,8 @@ class CsvEditorController {
790797
td.editing, th.editing { overflow: visible !important; white-space: normal !important; max-width: none !important; }
791798
.highlight { background-color: ${isDark ? '#222222' : '#fefefe'} !important; }
792799
.active-match { background-color: ${isDark ? '#444444' : '#ffffcc'} !important; }
800+
.csv-link { color: ${isDark ? '#6cb6ff' : '#0066cc'}; text-decoration: underline; cursor: pointer; }
801+
.csv-link:hover { color: ${isDark ? '#8ecfff' : '#0044aa'}; }
793802
#findWidget {
794803
position: fixed;
795804
top: 20px;
@@ -884,6 +893,22 @@ class CsvEditorController {
884893
})[m] as string);
885894
}
886895

896+
private linkifyUrls(escapedText: string): string {
897+
// Match URLs in already-escaped text (handles &amp; in query strings)
898+
// Supports http, https, ftp, mailto protocols
899+
const urlPattern = /(?:https?:\/\/|ftp:\/\/|mailto:)[^\s<>&"']+(?:&amp;[^\s<>&"']+)*/gi;
900+
return escapedText.replace(urlPattern, (url) => {
901+
// Decode &amp; back to & for the href attribute
902+
const href = url.replace(/&amp;/g, '&');
903+
return `<a href="${href}" class="csv-link" title="Ctrl/Cmd+click to open">${url}</a>`;
904+
});
905+
}
906+
907+
private formatCellContent(text: string, linkify: boolean): string {
908+
const escaped = this.escapeHtml(text);
909+
return linkify ? this.linkifyUrls(escaped) : escaped;
910+
}
911+
887912
private escapeCss(text: string): string {
888913
// conservative; ok for font-family lists
889914
return text.replace(/[\\"]/g, m => (m === '\\' ? '\\\\' : '\\"'));

src/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export function registerCsvCommands(context: vscode.ExtensionContext) {
1616
vscode.commands.registerCommand('csv.toggleExtension', () =>
1717
toggleBooleanConfig('enabled', true, 'CSV extension')
1818
),
19+
vscode.commands.registerCommand('csv.toggleClickableLinks', () =>
20+
toggleBooleanConfig('clickableLinks', true, 'CSV clickable links')
21+
),
1922
vscode.commands.registerCommand('csv.toggleHeader', async () => {
2023
const active = CsvEditorProvider.getActiveProvider();
2124
if (!active) { vscode.window.showInformationMessage('Open a CSV/TSV file in the CSV editor.'); return; }

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function activate(context: vscode.ExtensionContext) {
8686
}
8787
}
8888

89-
const keys = ['csv.fontFamily', 'csv.cellPadding'];
89+
const keys = ['csv.fontFamily', 'csv.cellPadding', 'csv.clickableLinks'];
9090
const changed = keys.filter(k => e.affectsConfiguration(k));
9191
if (changed.length) {
9292
CsvEditorProvider.editors.forEach(ed => ed.refresh());

0 commit comments

Comments
 (0)