Skip to content

Commit 58a7809

Browse files
authored
Merge pull request #129 from jonaraphael/codex/word-wrap-toggle
Add native word wrap toggle with cutoff as the default
2 parents b67ca1b + 8b46835 commit 58a7809

16 files changed

Lines changed: 311 additions & 13 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## [1.5.1] - Unreleased
4+
5+
- Add an icon-only word-wrap action beside the editor title bar’s table/text switch. Default each file to single-line cutoff at the automatic width cap, with Enable/Disable Word Wrap shown on hover. Preserve selection, viewport visibility, live drafts and manual sizes; copying and saving retain complete values.
6+
- Keep fractional column borders resizable without widening the grab area.
7+
38
## [1.4.2] - 2026-10-17
49

510
This release combines the editing, navigation, and table tools developed since the public 1.3 release.

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Context menus open toward the available space, flip above or left when needed, a
3939
- **Serial index:** Right-click an index cell or its top-left corner and choose **Hide serial index column**. Restore it with **CSV: Toggle Serial Index Column**. The numbers identify source rows and can have gaps while filtering.
4040
- **Metadata:** **CSV: Hide First N Rows** hides leading records without deleting them. Blank input inherits `csv.defaultIgnoreRows`; an explicit `0` shows them even if the configured default is nonzero.
4141
- **Widths:** Automatic column widths account for the longest line in multiline cells. Drag close to a column boundary to resize, including with headers off. Double-click the boundary to restore automatic sizing. Manual widths are remembered per file; narrow columns retain a selectable center. Drag the bottom edge of a serial-index cell to adjust its row height.
42+
- **Word wrap:** Word wrap starts **off** for each file, showing single-line cutoff with an ellipsis. Use the wrap icon beside the table/text switch in the editor title bar to toggle it; hover for **Enable Word Wrap** or **Disable Word Wrap**. Automatic widths remain capped at 100 characters; manual widths and row heights stay intact. Cutoff lets long rows collapse or be resized shorter. The selected cell/range stays selected and its active cell stays in view. Editing always shows the full value, and copying, searching and saving use the complete text. The display choice is remembered with the table's webview state.
4243
- **Frozen panes:** Right-click to freeze through a visible row or data column, or use **CSV: Freeze Leading Rows/Columns**. Enter `0` to unfreeze an axis. Counts are remembered per file. By default, the header stays frozen; with headers off, no rows freeze until requested. Hidden metadata and the append row do not count. Frozen rows follow measured multiline/zoomed heights and paint their divider seams; the serial index stays alongside frozen data columns.
4344

4445
Header, serial-index, separator, hidden-row, manual-width, and frozen-pane preferences do not change CSV contents and are stored per file URI in VS Code workspace state. Scroll, selection, row heights, and zoom use webview state. Column colors follow the selected datatype palette (`default`, `cool`, or `warm`) or the theme foreground mode. Light, dark, and high-contrast themes retain that choice. CSV's zoom changes the table's text size independently of global editor zoom.
@@ -113,6 +114,7 @@ Run these from the Command Palette. Commands that change a view preference targe
113114
| Command | Purpose |
114115
| --- | --- |
115116
| **CSV: Switch Between Table and Text** (`csv.toggleEditor`) | Switch the active supported table/text view. |
117+
| **CSV: Enable/Disable Word Wrap** (`csv.enableWordWrap` / `csv.disableWordWrap`) | Toggle wrapping in the active editable table; also available beside the table/text switch. |
116118
| **CSV: Save Untitled Document As…** (`csv.saveUntitled`) | Commit and save the active untitled table through the native owner. |
117119
| **CSV: Toggle Extension On/Off** (`csv.toggleExtension`) | Enable/disable CSV and synchronize native editor routing. |
118120
| **CSV: Toggle First Row as Header** (`csv.toggleHeader`) | Change the file's header preference. |

‎images/word-wrap-off.png‎

157 KB
Loading

‎images/word-wrap-on.png‎

191 KB
Loading

‎media/main.js‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ let persistedColumnSizes = {};
6868
try { persistedColumnSizes = JSON.parse(root?.dataset?.columnsizes || '{}'); } catch {}
6969
let columnSizeState = {};
7070
let rowSizeState = {};
71+
let wordWrapEnabled = false;
72+
const applyWordWrapMode = () => {
73+
root?.classList.toggle('csv-cutoff', !wordWrapEnabled);
74+
vscode.postMessage({ type: 'wordWrapChanged', enabled: wordWrapEnabled });
75+
};
7176

7277
const normalizeSizeState = (raw, minSize) => {
7378
const out = {};
@@ -176,6 +181,7 @@ const persistState = () => {
176181
anchorCol: anchor ? anchor.col : undefined,
177182
columnSizes: { ...columnSizeState },
178183
rowSizes: { ...rowSizeState },
184+
wordWrapEnabled,
179185
zoomScale
180186
};
181187
vscode.setState(nextState);
@@ -248,10 +254,21 @@ const scheduleCellReveal = (cell, center = false) => {
248254
});
249255
};
250256

257+
const setWordWrap = enabled => {
258+
const selectedCell = editingCell || rangeEndCell || anchorCell;
259+
wordWrapEnabled = enabled;
260+
applyWordWrapMode();
261+
window.csvFrozenPanes?.layout();
262+
if (selectedCell) scheduleCellReveal(selectedCell);
263+
persistState();
264+
};
265+
251266
const restoreState = () => {
252267
if (document.activeElement?.closest?.('#filterStatus')) return;
253268
try {
254269
const st = vscode.getState() || {};
270+
wordWrapEnabled = st.wordWrapEnabled === true;
271+
applyWordWrapMode();
255272
const restoredZoom = parsePositiveNumber(st.zoomScale);
256273
setZoomScale(restoredZoom ?? 1, false);
257274
columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40);
@@ -413,15 +430,20 @@ if (scrollContainer) {
413430
}
414431

415432
// Persist on blur/visibility change and restore on focus/visibility
433+
const restoreRecreatedView = () => {
434+
// A live DOM already owns its range and caret; a snapshot only has its anchor.
435+
if (anchorCell?.isConnected || editingCell) return;
436+
try { restoreState(); } catch {}
437+
};
416438
window.addEventListener('blur', () => { persistState(); }, { passive: true });
417439
window.addEventListener('focus', () => {
418-
setTimeout(() => { try { restoreState(); } catch {} }, 0);
440+
setTimeout(restoreRecreatedView, 0);
419441
}, { passive: true });
420442
document.addEventListener('visibilitychange', () => {
421443
if (document.visibilityState === 'hidden') {
422444
persistState();
423445
} else if (document.visibilityState === 'visible') {
424-
setTimeout(() => { try { restoreState(); } catch {} }, 0);
446+
setTimeout(restoreRecreatedView, 0);
425447
}
426448
});
427449

@@ -733,7 +755,9 @@ const getResizeEdgeInfo = (target, e) => {
733755
if (rect.width <= 0 || rect.height <= 0 || e.clientY < rect.top - 0.5 || e.clientY > rect.bottom + 0.5 || Math.abs(e.clientX - rect.right) > COLUMN_RESIZE_HANDLE_PX + 0.75) continue;
734756
const sampleY = clamp(e.clientY, rect.top + Math.min(0.5, rect.height / 4), rect.bottom - Math.min(0.5, rect.height / 4));
735757
// A scrolled cell can share the frozen column's x coordinate while covered.
736-
if (paintedCellAt(rect.right - Math.min(0.5, rect.width / 4), sampleY) !== cell) continue;
758+
// Collapsed borders can paint almost one CSS pixel into the next cell
759+
// after fractional width rounding. Probe inside the content edge.
760+
if (paintedCellAt(rect.right - Math.min(1.5, rect.width / 4), sampleY) !== cell) continue;
737761
const neighbor = paintedCellAt(rect.right + 0.5, sampleY);
738762
const next = neighbor && neighbor !== cell && neighbor.parentElement === cell.parentElement ? neighbor : cell.nextElementSibling;
739763
const nextRect = next && getDataCellCoords(next) ? next.getBoundingClientRect() : null;
@@ -2238,7 +2262,9 @@ const copySelectionToClipboard = (cut = false) => {
22382262

22392263
window.addEventListener('message', event => {
22402264
const message = event.data;
2241-
if (message.type === 'prepareViewRefresh') {
2265+
if (message.type === 'setWordWrap') {
2266+
if (typeof message.enabled === 'boolean') setWordWrap(message.enabled);
2267+
} else if (message.type === 'prepareViewRefresh') {
22422268
if (editingCell) { editingCell.blur(); }
22432269
persistState();
22442270
vscode.postMessage({ type: 'viewRefreshReady', requestId: message.requestId, session: filterSession, filterDraft: window.csvColumnFilters?.captureDraft() });

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"type": "git",
99
"url": "https://github.com/jonaraphael/csv.git"
1010
},
11-
"version": "1.4.2",
11+
"version": "1.5.1",
1212
"engines": {
1313
"vscode": "^1.70.0",
1414
"node": ">=14"
@@ -37,7 +37,9 @@
3737
"onCommand:csv.changeEncoding",
3838
"onCommand:csv.toggleClickableLinks",
3939
"onCommand:csv.changeFrozenRows",
40-
"onCommand:csv.changeFrozenColumns"
40+
"onCommand:csv.changeFrozenColumns",
41+
"onCommand:csv.enableWordWrap",
42+
"onCommand:csv.disableWordWrap"
4143
],
4244
"main": "./out/extension.js",
4345
"contributes": {
@@ -81,6 +83,16 @@
8183
"title": "CSV: Switch Between Table and Text",
8284
"icon": "$(arrow-swap)"
8385
},
86+
{
87+
"command": "csv.enableWordWrap",
88+
"title": "CSV: Enable Word Wrap",
89+
"icon": "$(word-wrap)"
90+
},
91+
{
92+
"command": "csv.disableWordWrap",
93+
"title": "CSV: Disable Word Wrap",
94+
"icon": "$(word-wrap)"
95+
},
8496
{
8597
"command": "csv.compareFilesAsTable",
8698
"title": "CSV: Compare Files as Table"
@@ -141,18 +153,36 @@
141153
{
142154
"command": "csv.toggleEditor",
143155
"when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor || (activeEditor == workbench.editors.files.textFileEditor && resourceScheme != untitled && resourceExtname =~ /\\.(csv|tsv|tab|psv)$/i) || (activeEditor == workbench.editors.textResourceEditor && resourceScheme == untitled && editorLangId =~ /^(csv|tsv)$/))",
144-
"group": "navigation"
156+
"group": "navigation@1"
157+
},
158+
{
159+
"command": "csv.enableWordWrap",
160+
"when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && !csv.wordWrapEnabled",
161+
"group": "navigation@2"
162+
},
163+
{
164+
"command": "csv.disableWordWrap",
165+
"when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && csv.wordWrapEnabled",
166+
"group": "navigation@2"
145167
},
146168
{
147169
"command": "csv.saveUntitled",
148170
"when": "activeWebviewPanelId == csv.untitledEditor",
149-
"group": "navigation@1"
171+
"group": "navigation@3"
150172
}
151173
],
152174
"commandPalette": [
153175
{
154176
"command": "csv.saveUntitled",
155177
"when": "activeWebviewPanelId == csv.untitledEditor"
178+
},
179+
{
180+
"command": "csv.enableWordWrap",
181+
"when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && !csv.wordWrapEnabled"
182+
},
183+
{
184+
"command": "csv.disableWordWrap",
185+
"when": "!isInDiffEditor && (activeCustomEditorId == csv.editor || activeWebviewPanelId == csv.untitledEditor) && csv.wordWrapEnabled"
156186
}
157187
],
158188
"explorer/context": [

‎src/CsvEditorProvider.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ class CsvEditorController {
112112
private separatorCache: { version: number; configKey: string; separator: string } | undefined;
113113
private isDiffContext = false;
114114
private currentHeader = false;
115+
private wordWrapEnabled = false;
115116
private chunkRenderState: ChunkRenderState | undefined;
116117
private currentFilterView: FilterRender | undefined;
117118
private latestFilterRevision = 0;
@@ -148,6 +149,7 @@ class CsvEditorController {
148149
this.resolveDraftCommitWaiters(false);
149150
if (CsvEditorProvider.currentActive === this) {
150151
CsvEditorProvider.currentActive = undefined;
152+
void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', false);
151153
}
152154
});
153155
if (_token.isCancellationRequested) { return; }
@@ -180,6 +182,7 @@ class CsvEditorController {
180182

181183
if (webviewPanel.active) {
182184
CsvEditorProvider.currentActive = this;
185+
this.updateWordWrapContext();
183186
}
184187
void this.warnAboutPossibleDecoding().catch(error => {
185188
console.warn('CSV: Encoding warning action failed', error);
@@ -199,12 +202,21 @@ class CsvEditorController {
199202
}
200203
e.webviewPanel.webview.postMessage({ type: 'focus' });
201204
CsvEditorProvider.currentActive = this;
205+
this.updateWordWrapContext();
202206
} else if (CsvEditorProvider.currentActive === this) {
203207
CsvEditorProvider.currentActive = undefined;
208+
void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', false);
204209
}
205210
});
206211

207212
webviewPanel.webview.onDidReceiveMessage(e => {
213+
if (e.type === 'wordWrapChanged') {
214+
if (typeof e.enabled === 'boolean') {
215+
this.wordWrapEnabled = e.enabled;
216+
this.updateWordWrapContext();
217+
}
218+
return;
219+
}
208220
if (e.type === 'filterDraftChanged') {
209221
if (e.session === this.filterSession) { this.draftActivityRevision += 1; }
210222
return;
@@ -1020,6 +1032,16 @@ class CsvEditorController {
10201032
return CsvEditorProvider.getFrozenPanesForUri(this.context, this.document.uri, this.currentHeader);
10211033
}
10221034

1035+
private updateWordWrapContext(): void {
1036+
if (this.isActive()) {
1037+
void vscode.commands.executeCommand('setContext', 'csv.wordWrapEnabled', this.wordWrapEnabled);
1038+
}
1039+
}
1040+
1041+
public setWordWrap(enabled: boolean): void {
1042+
void this.currentWebviewPanel?.webview.postMessage({ type: 'setWordWrap', enabled });
1043+
}
1044+
10231045
public async setFrozenPanes(change: Partial<FrozenPanes>): Promise<void> {
10241046
await CsvEditorProvider.setFrozenPanesForUri(this.context, this.document.uri, change);
10251047
this.currentWebviewPanel?.webview.postMessage({ type: 'frozenPanes', ...this.getCurrentFrozenPanes() });
@@ -2536,6 +2558,7 @@ class CsvEditorController {
25362558
th, td { padding: ${cellPadding}px 8px; border: 1px solid var(--csv-table-border, ${isDark ? '#555' : '#ccc'}); font-size: inherit; }
25372559
th { position: static; background-color: var(--csv-header-background, ${isDark ? '#1e1e1e' : '#ffffff'}); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
25382560
td { overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; }
2561+
#csv-root.csv-cutoff td:not(.editing) { overflow: hidden !important; white-space: nowrap !important; overflow-wrap: normal !important; text-overflow: ellipsis; }
25392562
td.selected, th.selected { background-color: var(--csv-selection-background, ${isDark ? '#333333' : '#cce0ff'}) !important; }
25402563
td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; }
25412564
.highlight { background-color: var(--csv-find-highlight-background, ${isDark ? '#2a2a2a' : '#fefefe'}) !important; }
@@ -2731,6 +2754,17 @@ class CsvEditorController {
27312754
box-sizing: border-box;
27322755
pointer-events: none;
27332756
}
2757+
/* An outline paints the divider outside clipped cell contents. Keep its
2758+
specificity below the high-contrast selection and Find outlines. */
2759+
.csv-cutoff .csv-frozen-row {
2760+
outline: 2px solid var(--csv-contrast-border, var(--csv-table-border, ${isDark ? '#555' : '#ccc'}));
2761+
outline-offset: -1px;
2762+
}
2763+
/* Retain state outline colors while covering the outer seam in HC themes. */
2764+
.csv-cutoff .csv-frozen-row:is(.selected, .highlight, .active-match) {
2765+
outline-width: 2px;
2766+
outline-offset: -1px;
2767+
}
27342768
#csv-root .csv-frozen-row.selected, #csv-root .csv-frozen-column.selected {
27352769
background-image: linear-gradient(var(--vscode-editor-selectionBackground, ${isDark ? '#333333' : '#cce0ff'}), var(--vscode-editor-selectionBackground, ${isDark ? '#333333' : '#cce0ff'}));
27362770
}
@@ -2757,7 +2791,7 @@ class CsvEditorController {
27572791
<link rel="stylesheet" href="${contrastStyleUri}">
27582792
</head>
27592793
<body>
2760-
<div id="csv-root" data-headerenabled="${headerEnabled ? '1' : '0'}" data-roworder="${this.escapeHtml(JSON.stringify(visibleRowIds))}" data-filtered="${filterMetadata.filters.length ? '1' : '0'}" data-filterepoch="0" data-frozenrows="${frozenPanes.rows}" data-frozencolumns="${frozenPanes.columns}" data-firstrow="${firstVisibleRow}" data-realrowcount="${realRowCount}" data-columnsizes="${this.escapeHtml(JSON.stringify(columnSizes))}" class="table-container" data-sepcode="${sepCode}" data-fontsize="${fontSize}" data-wheelzoomenabled="${mouseWheelZoomEnabled ? '1' : '0'}" data-wheelzoominvert="${mouseWheelZoomInvert ? '1' : '0'}" data-nextchunkstart="${nextChunkStart >= 0 ? nextChunkStart : ''}" data-hasmorechunks="${hasRemoteChunks ? '1' : '0'}">
2794+
<div id="csv-root" data-headerenabled="${headerEnabled ? '1' : '0'}" data-roworder="${this.escapeHtml(JSON.stringify(visibleRowIds))}" data-filtered="${filterMetadata.filters.length ? '1' : '0'}" data-filterepoch="0" data-frozenrows="${frozenPanes.rows}" data-frozencolumns="${frozenPanes.columns}" data-firstrow="${firstVisibleRow}" data-realrowcount="${realRowCount}" data-columnsizes="${this.escapeHtml(JSON.stringify(columnSizes))}" class="table-container csv-cutoff" data-sepcode="${sepCode}" data-fontsize="${fontSize}" data-wheelzoomenabled="${mouseWheelZoomEnabled ? '1' : '0'}" data-wheelzoominvert="${mouseWheelZoomInvert ? '1' : '0'}" data-nextchunkstart="${nextChunkStart >= 0 ? nextChunkStart : ''}" data-hasmorechunks="${hasRemoteChunks ? '1' : '0'}">
27612795
<div id="filterStatus" aria-label="Column filters"${filterMetadata.filters.length ? '' : ' hidden'}></div>
27622796
${tableHtml}
27632797
</div>

‎src/commands.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export function registerCsvCommands(context: vscode.ExtensionContext, synchroniz
7272
vscode.commands.registerCommand('csv.changeFrozenColumns', () => changeFrozenPaneCount('columns')),
7373
vscode.commands.registerCommand('csv.openLargeFilePreview', openLargeFilePreview),
7474
vscode.commands.registerCommand('csv.toggleEditor', () => toggleCsvEditor(prepareEditorRouting)),
75+
vscode.commands.registerCommand('csv.enableWordWrap', () => CsvEditorProvider.getActiveProvider()?.setWordWrap(true)),
76+
vscode.commands.registerCommand('csv.disableWordWrap', () => CsvEditorProvider.getActiveProvider()?.setWordWrap(false)),
7577
vscode.commands.registerCommand('csv.compareFilesAsTable', (resource?: vscode.Uri, selected?: vscode.Uri[]) => compareFilesAsTable(context, resource, selected)),
7678
vscode.commands.registerCommand('csv.saveUntitled', saveActiveUntitledCsvEditor),
7779
vscode.commands.registerCommand('csv.toggleExtension', () => {

‎src/test/edit-integrity-harness.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ const vscodeStub = {
6565
return { dispose() {} };
6666
},
6767
executeCommand: async (name: string, ...args: unknown[]) => {
68+
if (name === 'setContext') {
69+
activeHarness.contextUpdates.push(args);
70+
return;
71+
}
6872
activeHarness.executedCommands.push({ name, args });
6973
activeHarness.recordedCommands.push([name, ...args]);
7074
if (activeHarness.commandError) {
@@ -250,6 +254,7 @@ export async function createEditorHarness(options: {
250254
closeResult: true,
251255
commands: new Map<string, (...args: any[]) => unknown>(),
252256
executedCommands: [] as Array<{ name: string; args: unknown[] }>,
257+
contextUpdates: [] as unknown[][],
253258
registerCommands() {
254259
const { registerCsvCommands } = require('../commands') as typeof import('../commands');
255260
registerCsvCommands(context as any);
@@ -335,11 +340,11 @@ export async function createEditorHarness(options: {
335340
return harness;
336341
}
337342

338-
export function createWebviewDom(html: string) {
343+
export function createWebviewDom(html: string, initialState: unknown = {}) {
339344
const dom = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true });
340345
const { window } = dom;
341346
const messages: any[] = [];
342-
let state: unknown = {};
347+
let state: unknown = initialState;
343348
window.acquireVsCodeApi = () => ({
344349
postMessage: (message: unknown) => messages.push(message),
345350
getState: () => state,

0 commit comments

Comments
 (0)