Merge #502: read-only XLSX spreadsheet preview in the file-preview overlay

# Conflicts:
#	CLAUDE.md
This commit is contained in:
Codeman maintainer
2026-10-09 05:48:05 +02:00
30 changed files with 5844 additions and 19 deletions
+9 -3
View File
@@ -53,15 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
*/
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
/**
* Document types an attachment card previews. Also the list `codeman attach`'s
* error text names, so the help cannot drift from what is accepted. `xlsx` is
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
*/
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'pdf',
'docx',
'pptx',
...DOCUMENT_ATTACHMENT_EXTENSIONS,
'md',
'txt',
...VIDEO_ATTACHMENT_EXTENSIONS,
@@ -154,6 +159,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation';
if (normalized === 'xlsx') return 'spreadsheet';
if (normalized === 'md') return 'markdown';
// Everything else in the text family reads as text, including code and
// config: the card and the preview both treat it as a plain-text file.
+6 -2
View File
@@ -23,7 +23,7 @@ import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
import { getStore } from './state-store.js';
import { getErrorMessage } from './types.js';
import { isSupportedAttachmentExtension } from './attachment-registry.js';
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
import { installService, serviceStatus, uninstallService } from './service-installer.js';
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
@@ -111,7 +111,11 @@ program
.action(async (filePath, options) => {
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
console.error(
palette.err(
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
)
);
process.exit(1);
}
+1
View File
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
| 'pdf'
| 'document'
| 'presentation'
| 'spreadsheet'
| 'markdown'
| 'text';
+2 -2
View File
@@ -1830,7 +1830,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -1848,7 +1848,7 @@ function absoluteFilePathPattern() {
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
+1
View File
@@ -4039,5 +4039,6 @@
<script defer src="ultracode-windows.js"></script>
<script defer src="session-lineage.js"></script>
<script defer src="image-input.js"></script>
<script defer src="spreadsheet-preview.js"></script>
</body>
</html>
+21
View File
@@ -4037,3 +4037,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller,
touch-scrollable grid on phones. */
@media (max-width: 700px) {
.spreadsheet-sheet-tabs {
padding-inline: 4px;
scroll-snap-type: x proximity;
}
.spreadsheet-sheet-tab {
min-width: 96px;
min-height: 40px;
scroll-snap-align: start;
}
.spreadsheet-grid {
min-height: 55vh;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
}
+45 -1
View File
@@ -4178,6 +4178,8 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'docx' || ext === 'pptx') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'xlsx') {
this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize);
} else {
try {
// Bounded like the workspace text preview: a Range for the first
@@ -4274,6 +4276,9 @@ Object.assign(CodemanApp.prototype, {
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'spreadsheet') {
this._openSpreadsheetPreview(bodyEl, CodemanBase.url(data.url), data.size);
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
@@ -4347,6 +4352,9 @@ Object.assign(CodemanApp.prototype, {
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
*/
_stopFilePreviewMedia() {
// A spreadsheet preview owns a fetch and a Web Worker; emptying the body
// leaves both running, so tear them down with the rest of the media.
this._disposeSpreadsheetPreview();
const bodyEl = this.$('filePreviewBody');
if (!bodyEl) return;
for (const media of bodyEl.querySelectorAll('video, audio')) {
@@ -4361,6 +4369,42 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = '';
},
/**
* Render an XLSX into the preview body via spreadsheet-preview.js, which
* parses it in a Web Worker (the ExcelJS bundle loads there, on demand, and
* never on page load). `url` is a raw route; the renderer adds `?preview=true`
* so the server applies its preview size cap. Superseded by the next
* _stopFilePreviewMedia(), which runs on every open and on close.
*/
_openSpreadsheetPreview(bodyEl, url, size) {
this._disposeSpreadsheetPreview();
const renderer = window.CodemanSpreadsheetPreview;
if (!renderer?.open) {
bodyEl.innerHTML = '<div class="binary-message">Spreadsheet preview is unavailable.</div>';
return;
}
bodyEl.textContent = '';
const token = {};
this._spreadsheetPreviewToken = token;
this._spreadsheetPreview = renderer.open({
container: bodyEl,
url,
size,
isCurrent: () => this._spreadsheetPreviewToken === token,
});
},
_disposeSpreadsheetPreview() {
const handle = this._spreadsheetPreview;
this._spreadsheetPreview = null;
this._spreadsheetPreviewToken = null;
try {
handle?.dispose();
} catch (err) {
console.warn('Failed to dispose spreadsheet preview:', err);
}
},
// ═══════════════════════════════════════════════════════════════
// File Viewer text view: rendered markdown, line numbers, wrap
// ═══════════════════════════════════════════════════════════════
@@ -5056,7 +5100,7 @@ Object.assign(CodemanApp.prototype, {
<div class="attachment-history-empty-title">No attachments yet</div>
<div>Show a file here by running:</div>
<code>codeman attach /absolute/path/to/file.pptx</code>
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
<div>Supports .pptx, .docx, .xlsx, .pdf, .png, .md, and .txt.</div>
</div>
`;
return;
@@ -0,0 +1,290 @@
/**
* @fileoverview Same-origin XLSX parsing worker for the file-preview overlay.
*
* Runs off the main thread and is the ONLY place the spreadsheet vendor bundles
* load: fflate + the pure core at worker start, ExcelJS only after the ZIP has
* passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). ExcelJS is then
* given a STORE-only archive rebuilt from the entries admission inflated, never
* the fetched bytes, so it can only parse what admission counted. The page never
* loads either vendor file. Cell values are sent back as plain strings; the
* renderer writes them with `textContent`. Formulas are never evaluated (the
* cached result is shown, else the formula text), and nothing here fetches:
* external links, images and drawings are reported as unsupported features.
*
* Script URLs are RELATIVE so they resolve against this worker's own URL, which
* keeps a reverse-proxy `--base-url` mount working.
*/
'use strict';
const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev';
const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`;
importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`);
const core = self.CodemanSpreadsheetXlsxCore;
let workbook = null;
let sheetsById = new Map();
// Per sheet: its populated rows in order, each with its populated cells in
// column order, built once at load from the keys that exist (`populatedRowIndex`).
let populatedRowsById = new Map();
// Merges read once at load: `sheet.model` rebuilds every row and cell model,
// which is far too much to pay on every tile.
let mergesById = new Map();
let normalizedStyles = [];
let styleIds = new Map();
let themePalette = core.DEFAULT_THEME_PALETTE;
function postError(error) {
self.postMessage({
type: 'error',
code: error?.code || 'parse-failed',
message: error?.message || 'Spreadsheet preview failed',
});
}
// Maximum cells in one tile reply; the renderer draws at most this many too.
const MAX_TILE_CELLS = 2500;
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the admitted
// entry is the fallback and the default Office palette is the last resort.
function readThemeXml(loadedWorkbook, admittedEntries) {
const stashed = loadedWorkbook?._themes?.theme1;
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
const theme = admittedEntries?.['xl/theme/theme1.xml'];
return theme ? new TextDecoder().decode(theme) : '';
}
function normalizeStyle(cell) {
// Colours are resolved and contrast-checked as a PAIR. Emitting a
// font colour without its background lets workbook text land on the skin's
// `var(--bg-primary)` and disappear.
const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette);
const style = {
font: {
bold: Boolean(cell.font?.bold),
italic: Boolean(cell.font?.italic),
color: colors.foreground,
},
fill: colors.background,
alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined,
wrapText: Boolean(cell.alignment?.wrapText),
};
const key = JSON.stringify(style);
if (styleIds.has(key)) return styleIds.get(key);
if (normalizedStyles.length >= core.LIMITS.maxStyles) {
throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit');
}
const id = normalizedStyles.length;
normalizedStyles.push(style);
styleIds.set(key, id);
return id;
}
// Ascending numeric own keys of a sparse array. ExcelJS keeps rows at
// `_rows[r - 1]` and a row's cells at `_cells[col - 1]`, and one far index puts
// the array in dictionary mode, where its own `eachRow`, `eachCell` and
// `hasValues` (forEach/some) visit every index up to the largest: a single XFD
// cell per row costs 16,384 steps a row. Walking the keys that exist does not.
function presentIndices(sparse) {
const indices = [];
for (const key of Object.keys(sparse || [])) {
const index = Number(key);
if (Number.isInteger(index) && index >= 0) indices.push(index);
}
return indices.sort((a, b) => a - b);
}
// The rows and cells `sheet.eachRow({ includeEmpty: false })` and
// `row.eachCell({ includeEmpty: false })` would visit, in the same order: a
// cell counts when it exists and its type is not `ValueType.Null`, and a row
// counts when it holds at least one such cell (ExcelJS's `row.hasValues`).
function populatedRowIndex(sheet) {
const nullType = self.ExcelJS.ValueType.Null;
const rows = [];
for (const rowIndex of presentIndices(sheet._rows)) {
const row = sheet._rows[rowIndex];
if (!row) continue;
const cells = [];
for (const cellIndex of presentIndices(row._cells)) {
const cell = row._cells[cellIndex];
if (cell && cell.type !== nullType) cells.push(cell);
}
if (cells.length > 0) rows.push({ number: row.number, row, cells });
}
return rows;
}
function worksheetMetadata(sheet) {
const cellRefs = [];
const rowOverrides = [];
const populatedRows = populatedRowIndex(sheet);
for (const { number, row, cells } of populatedRows) {
for (const cell of cells) {
cellRefs.push(cell.address);
normalizeStyle(cell);
}
if (row.hidden) rowOverrides.push([number, 0]);
else if (row.height) rowOverrides.push([number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
}
// `sheet.model` rebuilds every row and cell model, so merges come straight
// from ExcelJS's own merge map, in the order the model getter would list them.
const merges = Object.values(sheet._merges || {}).map((merge) => merge.range);
const extent = core.deriveExtent(cellRefs, merges);
const columnOverrides = [];
for (let col = 1; col <= extent.cols; col += 1) {
const column = sheet.getColumn(col);
if (column.hidden) columnOverrides.push([col, 0]);
else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]);
}
return {
populatedRows,
merges,
metadata: {
id: String(sheet.id),
name: sheet.name,
rows: extent.rows,
cols: extent.cols,
defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))),
defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)),
rowOverrides,
columnOverrides,
merges,
},
};
}
function cellDisplay(cell, date1904, warnings) {
const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904);
if (formatted.warning) warnings.add(formatted.warning);
return formatted.text;
}
async function loadWorkbook(bytes) {
const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate);
const admitted = core.buildAdmittedArchive(admission, self.fflate);
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
const nextWorkbook = new self.ExcelJS.Workbook();
// ExcelJS's DefinedNames model setter expands every range into one object per
// cell (a whole-sheet name exhausts the heap), and admission reads only the
// `<sheet>` ids in xl/workbook.xml, never defined names. The preview never shows defined names, so they are not
// stored at all; print areas and titles are split off before this setter runs.
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
// than silently expanding again.
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
// object (a whole-column dropdown is a million), and the preview never shows
// validations, so they are not parsed at all. `maxRows` is a per-sheet
// backstop behind admission's row count, which also caps the workbook total.
await nextWorkbook.xlsx.load(admitted, {
ignoreNodes: ['dataValidations'],
maxRows: core.LIMITS.maxRowsPerSheet,
});
const nextSheets = new Map();
const nextRows = new Map();
const nextMerges = new Map();
normalizedStyles = [];
styleIds = new Map();
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
const sheets = [];
for (const sheet of nextWorkbook.worksheets) {
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
const sheetResult = worksheetMetadata(sheet);
const metadata = sheetResult.metadata;
nextSheets.set(metadata.id, sheet);
nextRows.set(metadata.id, sheetResult.populatedRows);
nextMerges.set(metadata.id, sheetResult.merges);
sheets.push(metadata);
}
workbook = nextWorkbook;
sheetsById = nextSheets;
populatedRowsById = nextRows;
mergesById = nextMerges;
self.postMessage({
type: 'metadata',
sheets,
styles: normalizedStyles,
date1904: Boolean(workbook.properties?.date1904),
empty: sheets.length === 0,
warnings: admission.features,
});
}
function sendTile(message) {
if (!workbook) throw new Error('Workbook is not loaded');
const sheet = sheetsById.get(String(message.sheetId));
if (!sheet) throw new Error('Worksheet is unavailable');
const range = message.range;
const warnings = new Set();
const cells = [];
const seenCells = new Set();
let truncated = false;
// Hidden rows and columns are 0 px, so a viewport can span thousands of them
// (a filtered sheet); they are never drawn, so never sent.
const hiddenColumns = new Map();
const columnHidden = (col) => {
if (!hiddenColumns.has(col)) hiddenColumns.set(col, Boolean(sheet.getColumn(col).hidden));
return hiddenColumns.get(col);
};
const addCell = (cell) => {
const key = `${cell.row}:${cell.col}`;
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
if (sheet.getRow(cell.row).hidden || columnHidden(cell.col)) return;
if (cells.length >= MAX_TILE_CELLS) {
truncated = true;
return;
}
seenCells.add(key);
cells.push({
row: cell.row,
col: cell.col,
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
styleId: normalizeStyle(cell),
});
};
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
for (const populated of populatedRows) {
if (truncated) break;
if (populated.number < range.r1) continue;
if (populated.number > range.r2) break;
if (populated.row.hidden) continue;
for (const cell of populated.cells) {
if (cell.col < range.c1) continue;
if (cell.col > range.c2) break;
addCell(cell);
}
}
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
for (const merge of merges) {
const anchor = core.parseRange(merge);
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
}
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
self.postMessage({
type: 'tile',
requestId: message.requestId,
sheetId: String(message.sheetId),
cells,
merges,
// One counted entry for many unsupported number formats keeps the notice bar short.
warnings: core.foldWarnings(Array.from(warnings)),
});
}
self.onmessage = async (event) => {
try {
const message = event.data || {};
if (message.type === 'load') await loadWorkbook(message.bytes);
else if (message.type === 'tile') sendTile(message);
else if (message.type === 'dispose') {
workbook = null;
sheetsById = new Map();
populatedRowsById = new Map();
mergesById = new Map();
themePalette = core.DEFAULT_THEME_PALETTE;
}
} catch (error) {
postError(error);
}
};
self.postMessage({ type: 'ready' });
+476
View File
@@ -0,0 +1,476 @@
/**
* @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay.
*
* `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook
* bytes (same-origin only, `?preview=true` so the server applies its 10 MB
* preview cap), hands them to spreadsheet-preview-worker.js, and renders only
* the visible tile of cells. Parsing happens entirely in the browser worker; the
* server just streams the file through its existing confined raw routes.
*
* Every workbook string (cell text, sheet names) is written with `textContent`,
* never markup. The per-style `<style>` block only emits validated `#rrggbb`
* colours and a fixed set of keywords. `dispose()` aborts the fetch and
* terminates the worker; panels-ui.js calls it whenever the overlay is reused
* or closed.
*
* @dependency constants.js (CodemanBase.url for the worker URL under --base-url)
* @loadorder 16.5 (after image-input.js; only defines a global, used on demand)
*/
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
const ROW_HEADING_WIDTH = 36;
const COLUMN_HEADING_HEIGHT = 20;
const assets = Object.freeze({
version: SPREADSHEET_ASSET_VERSION,
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
});
function message(container, text, kind) {
container.textContent = '';
const state = document.createElement('div');
state.className = `spreadsheet-preview-message ${kind || ''}`.trim();
state.textContent = text;
container.appendChild(state);
}
function safePreviewUrl(candidate) {
const url = new URL(candidate, global.location.href);
if (url.origin !== global.location.origin) throw new Error('Spreadsheet preview must use a same-origin URL');
url.searchParams.set('preview', 'true');
return `${url.pathname}${url.search}${url.hash}`;
}
function open(options) {
const container = options.container;
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
let disposed = false;
let worker = null;
let controller = null;
let timer = null;
let metadata = null;
let activeSheetId = null;
let latestRequestId = 0;
let grid = null;
let spacer = null;
let cellsLayer = null;
let headingsLayer = null;
let emptySheetState = null;
let resizeObserver = null;
let latestRange = null;
let latestAxes = null;
let scrollFrame = null;
const current = () => !disposed && isCurrent();
const clearTimer = () => {
if (timer !== null) global.clearTimeout(timer);
timer = null;
};
const fail = (text) => {
if (!current()) return;
clearTimer();
message(container, text || 'Spreadsheet preview failed', 'error');
};
function sheetMetadata() {
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
}
// Prefix sums per override list, built once per sheet's axis: renderTile
// asks for several offsets per cell, so a linear walk over every override
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
const axisDeltas = new WeakMap();
function overrideDeltas(overrides, defaultSize) {
let entry = axisDeltas.get(overrides);
if (!entry || entry.defaultSize !== defaultSize) {
const deltas = [];
let delta = 0;
for (const [, size] of overrides) {
delta += size - defaultSize;
deltas.push(delta);
}
entry = { defaultSize, deltas };
axisDeltas.set(overrides, entry);
}
return entry.deltas;
}
function axisOffset(count, defaultSize, overrides, index) {
const bounded = Math.max(1, Math.min(count + 1, index));
const list = overrides || [];
let low = 0;
let high = list.length;
while (low < high) {
const mid = (low + high) >> 1;
if (list[mid][0] < bounded) low = mid + 1;
else high = mid;
}
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
}
function axisIndex(count, defaultSize, overrides, offset) {
let low = 1;
let high = Math.max(1, count);
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (axisOffset(count, defaultSize, overrides, mid) <= offset) low = mid;
else high = mid - 1;
}
return low;
}
// Past MAX_SCROLL_PX the spacer is shorter than the sheet, so only the
// scroll POSITION is scaled (the scroll range maps onto the sheet's whole
// range, so the last row stays reachable) and the tile is laid out at real
// sizes from there. `shift` is the logical offset minus the scroll offset,
// 0 when the sheet fits; `end` is the bottom (or right) of the spacer.
function scrollAxis(logical, scroll, viewport, heading) {
const shown = Math.min(MAX_SCROLL_PX, logical);
const scrollRange = Math.max(0, heading + shown - viewport);
const logicalRange = Math.max(0, heading + logical - viewport);
const virtual =
logical > shown && scrollRange > 0 ? Math.min(logicalRange, (scroll / scrollRange) * logicalRange) : scroll;
return { virtual, shift: virtual - scroll, end: heading + shown };
}
function requestTile() {
if (!current() || !worker || !grid) return;
const sheet = sheetMetadata();
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
const viewHeight = grid.clientHeight || 500;
const viewWidth = grid.clientWidth || 800;
const y = scrollAxis(
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
grid.scrollTop,
viewHeight,
COLUMN_HEADING_HEIGHT
);
const x = scrollAxis(
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
grid.scrollLeft,
viewWidth,
ROW_HEADING_WIDTH
);
const r1 = Math.max(
1,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
) - 2
);
const c1 = Math.max(
1,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
) - 2
);
const r2 = Math.min(
sheet.rows,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
) + 2
);
const c2 = Math.min(
sheet.cols,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
) + 2
);
latestRequestId += 1;
latestRange = { r1, c1, r2, c2 };
latestAxes = { y, x };
worker.postMessage({
type: 'tile',
requestId: latestRequestId,
sheetId: String(activeSheetId),
range: { r1, c1, r2, c2 },
});
}
function renderWarnings(tileWarnings) {
const notice = container.querySelector('.spreadsheet-preview-notice');
if (!notice) return;
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
notice.hidden = warnings.length === 0;
const warningLabel =
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
}
function pinHeadings() {
if (!grid || !headingsLayer) return;
headingsLayer.querySelectorAll('.spreadsheet-row-heading').forEach((heading) => {
heading.style.left = `${grid.scrollLeft}px`;
});
headingsLayer.querySelectorAll('.spreadsheet-column-heading').forEach((heading) => {
heading.style.top = `${grid.scrollTop}px`;
});
}
function renderTile(tile) {
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
const sheet = sheetMetadata();
if (!sheet || !cellsLayer || !headingsLayer || !latestRange || !latestAxes) return;
const { y, x } = latestAxes;
// Sizes are real; a span (a tall merge) is clipped at the spacer's edge so
// it never grows the scroll area.
const rowTop = (row) =>
COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) - y.shift;
const colLeft = (col) =>
ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) - x.shift;
const rowSpan = (from, to) => Math.max(0, Math.min(rowTop(to + 1), y.end) - rowTop(from));
const colSpan = (from, to) => Math.max(0, Math.min(colLeft(to + 1), x.end) - colLeft(from));
cellsLayer.textContent = '';
headingsLayer.textContent = '';
const mergeByAnchor = new Map();
for (const merge of tile.merges || []) {
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(merge);
if (!match) continue;
const column = (letters) =>
[...letters.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
mergeByAnchor.set(`${Number(match[2])}:${column(match[1])}`, {
r2: Number(match[4]),
c2: column(match[3]),
});
}
for (const cell of tile.cells.slice(0, 2500)) {
const element = document.createElement('div');
element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`;
element.dataset.row = String(cell.row);
element.dataset.col = String(cell.col);
element.textContent = String(cell.text ?? '');
element.style.top = `${rowTop(cell.row)}px`;
element.style.left = `${colLeft(cell.col)}px`;
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
element.style.height = `${rowSpan(cell.row, merge?.r2 || cell.row)}px`;
element.style.width = `${colSpan(cell.col, merge?.c2 || cell.col)}px`;
cellsLayer.appendChild(element);
}
// Headings take their size from the same axis math as the cells, so custom
// widths/heights line up; hidden (0 px) rows and columns get no heading and
// do not count against the heading caps.
let rowHeadings = 0;
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
const height = rowSpan(row, row);
if (height <= 0) continue;
rowHeadings += 1;
const heading = document.createElement('div');
heading.className = 'spreadsheet-row-heading';
heading.textContent = String(row);
heading.style.top = `${rowTop(row)}px`;
heading.style.height = `${height}px`;
heading.style.left = `${grid.scrollLeft}px`;
headingsLayer.appendChild(heading);
}
let columnHeadings = 0;
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
const width = colSpan(col, col);
if (width <= 0) continue;
columnHeadings += 1;
const heading = document.createElement('div');
heading.className = 'spreadsheet-column-heading';
let label = '';
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
heading.textContent = label;
heading.style.left = `${colLeft(col)}px`;
heading.style.width = `${width}px`;
heading.style.top = `${grid.scrollTop}px`;
headingsLayer.appendChild(heading);
}
renderWarnings(tile.warnings);
}
function selectSheet(sheetId) {
if (!current() || !metadata?.sheets.some((sheet) => String(sheet.id) === String(sheetId))) return;
activeSheetId = String(sheetId);
latestRequestId += 1;
latestRange = null;
latestAxes = null;
if (cellsLayer) cellsLayer.textContent = '';
if (headingsLayer) headingsLayer.textContent = '';
container.querySelectorAll('[role="tab"]').forEach((tab) => {
const selected = tab.dataset.sheetId === activeSheetId;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
});
if (grid) {
grid.scrollTop = 0;
grid.scrollLeft = 0;
}
const sheet = sheetMetadata();
if (sheet && spacer) {
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
}
if (emptySheetState) emptySheetState.hidden = Boolean(sheet?.rows && sheet?.cols);
renderWarnings([]);
requestTile();
}
function renderMetadata(nextMetadata) {
if (!current()) return;
metadata = nextMetadata;
container.textContent = '';
if (!metadata.sheets?.length) {
message(container, 'This workbook has no visible worksheets.', 'empty');
return;
}
const shell = document.createElement('div');
shell.className = 'spreadsheet-preview-shell';
const styleSheet = document.createElement('style');
styleSheet.textContent = (metadata.styles || [])
.map((style, id) => {
const declarations = [];
if (style.font?.bold) declarations.push('font-weight:700');
if (style.font?.italic) declarations.push('font-style:italic');
// Colour and background are emitted together or not at all.
// The worker already contrast-checked them as a pair; contributing
// one half would drop the cell back onto the skin's own background.
if (/^#[a-f0-9]{6}$/i.test(style.font?.color || '') && /^#[a-f0-9]{6}$/i.test(style.fill || '')) {
declarations.push(`color:${style.font.color}`, `background-color:${style.fill}`);
}
if (['left', 'center', 'right'].includes(style.alignment)) declarations.push(`text-align:${style.alignment}`);
if (style.wrapText) declarations.push('white-space:normal');
return `.spreadsheet-style-${id}{${declarations.join(';')}}`;
})
.join('');
const tabs = document.createElement('div');
tabs.className = 'spreadsheet-sheet-tabs';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('data-i18n-skip', '');
for (const sheet of metadata.sheets) {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'spreadsheet-sheet-tab';
tab.setAttribute('role', 'tab');
tab.dataset.sheetId = String(sheet.id);
tab.textContent = sheet.name;
tab.addEventListener('click', () => selectSheet(sheet.id));
tabs.appendChild(tab);
}
const notice = document.createElement('div');
notice.className = 'spreadsheet-preview-notice';
notice.hidden = true;
emptySheetState = document.createElement('div');
emptySheetState.className = 'spreadsheet-empty-sheet';
emptySheetState.textContent = 'This worksheet is empty.';
emptySheetState.hidden = true;
grid = document.createElement('div');
grid.className = 'spreadsheet-grid';
grid.setAttribute('data-i18n-skip', '');
spacer = document.createElement('div');
spacer.className = 'spreadsheet-grid-spacer';
cellsLayer = document.createElement('div');
cellsLayer.className = 'spreadsheet-cells';
headingsLayer = document.createElement('div');
headingsLayer.className = 'spreadsheet-headings';
grid.append(spacer, cellsLayer, headingsLayer);
grid.addEventListener(
'scroll',
() => {
pinHeadings();
if (scrollFrame !== null) return;
scrollFrame = global.requestAnimationFrame(() => {
scrollFrame = null;
requestTile();
});
},
{ passive: true }
);
shell.append(styleSheet, tabs, notice, emptySheetState, grid);
container.appendChild(shell);
resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(requestTile) : null;
resizeObserver?.observe(grid);
selectSheet(metadata.sheets[0].id);
}
function dispose() {
if (disposed) return;
disposed = true;
clearTimer();
if (scrollFrame !== null) global.cancelAnimationFrame(scrollFrame);
controller?.abort();
resizeObserver?.disconnect();
try {
worker?.postMessage({ type: 'dispose' });
worker?.terminate();
} catch {
// A worker that failed during startup may already be unavailable.
}
worker = null;
}
async function start() {
if (Number(options.size) > MAX_PREVIEW_BYTES) {
fail('This workbook is too large to preview (10 MB limit).');
return;
}
message(container, 'Loading spreadsheet…', 'loading');
let previewUrl;
try {
previewUrl = safePreviewUrl(options.url);
controller = new AbortController();
// Root-absolute paths ignore <base href>; route through the mount prefix.
worker = new Worker(global.CodemanBase?.url ? global.CodemanBase.url(assets.workerUrl) : assets.workerUrl);
const ready = new Promise((resolve, reject) => {
worker.onerror = () => reject(new Error('Spreadsheet parser failed to start'));
worker.onmessageerror = () => reject(new Error('Spreadsheet parser message failed'));
worker.onmessage = (event) => {
if (event.data?.type === 'ready') resolve();
};
});
const responsePromise = fetch(previewUrl, { signal: controller.signal });
const [response] = await Promise.all([responsePromise, ready]);
if (!current()) return;
if (response.status === 413) throw new Error('This workbook is too large to preview (10 MB limit).');
if (!response.ok) throw new Error(`Spreadsheet preview failed (${response.status})`);
const bytes = await response.arrayBuffer();
if (!current()) return;
worker.onmessage = (event) => {
if (!current()) return;
const payload = event.data || {};
if (payload.type === 'metadata') {
clearTimer();
renderMetadata(payload);
} else if (payload.type === 'tile') renderTile(payload);
else if (payload.type === 'error') fail(payload.message);
};
worker.onerror = () => fail('Spreadsheet parser failed.');
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
timer = global.setTimeout(() => {
worker?.terminate();
fail('Spreadsheet preview timed out.');
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
worker.postMessage({ type: 'load', bytes }, [bytes]);
} catch (error) {
if (!disposed && error?.name !== 'AbortError') fail(error?.message);
}
}
void start();
return Object.freeze({ dispose, selectSheet, resize: requestTile });
}
global.CodemanSpreadsheetPreviewAssets = assets;
global.CodemanSpreadsheetPreview = Object.freeze({ open, MAX_PREVIEW_BYTES });
})(window);
File diff suppressed because it is too large Load Diff
+132
View File
@@ -20188,6 +20188,138 @@ body.tile-grid-resizing--row * {
color: var(--text-muted);
}
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
.spreadsheet-preview-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
background: var(--bg-primary);
}
.spreadsheet-sheet-tabs {
display: flex;
flex: 0 0 auto;
gap: 2px;
overflow-x: auto;
padding: 6px 8px 0;
border-bottom: 1px solid var(--border);
}
.spreadsheet-sheet-tab {
flex: 0 0 auto;
min-width: 80px;
max-width: 220px;
padding: 6px 12px;
overflow: hidden;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 6px 6px 0 0;
background: transparent;
}
.spreadsheet-sheet-tab[aria-selected='true'] {
color: var(--text-primary);
border-color: var(--border);
background: var(--bg-secondary);
}
.spreadsheet-preview-notice {
flex: 0 0 auto;
/* A long warning list scrolls inside the bar instead of pushing the grid out of view. */
max-height: 4.5em;
overflow-y: auto;
overflow-wrap: anywhere;
padding: 5px 10px;
color: var(--warning, #f59e0b);
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.spreadsheet-grid {
position: relative;
flex: 1 1 auto;
min-height: 220px;
overflow: auto;
overscroll-behavior: contain;
}
.spreadsheet-grid-spacer,
.spreadsheet-cells,
.spreadsheet-headings {
position: absolute;
top: 0;
left: 0;
}
.spreadsheet-row-heading,
.spreadsheet-column-heading {
position: absolute;
z-index: 2;
color: var(--text-secondary);
font-size: 11px;
line-height: 18px;
text-align: center;
background: var(--bg-secondary);
border: 1px solid var(--border);
pointer-events: none;
}
/* Only the fixed gutter dimension lives here; the per-row height and per-column
width come inline from spreadsheet-preview.js's axis math. */
.spreadsheet-row-heading {
left: 0;
width: 36px;
}
.spreadsheet-column-heading {
top: 0;
height: 20px;
}
.spreadsheet-empty-sheet {
position: sticky;
top: 20px;
left: 36px;
z-index: 3;
width: max-content;
padding: 24px;
color: var(--text-secondary);
background: var(--bg-primary);
}
.spreadsheet-cell {
position: absolute;
min-width: 0;
padding: 2px 5px;
overflow: hidden;
color: var(--text-primary);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--bg-primary);
}
.spreadsheet-preview-message {
display: grid;
min-height: 220px;
place-items: center;
padding: 24px;
color: var(--text-secondary);
text-align: center;
}
.spreadsheet-preview-message.error {
color: var(--danger, #ef4444);
}
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
+44 -4
View File
@@ -73,6 +73,35 @@ import {
isEditableFileName,
} from '../../config/file-editing.js';
/**
* Upper bound on an XLSX the browser preview will fetch (`?preview=true`).
* Parsing happens client-side in spreadsheet-preview-worker.js, so this caps
* what a single preview can hand the worker; the renderer refuses the same size
* before fetching. An explicit download is unaffected (global download cap).
*/
export const MAX_XLSX_BROWSER_PREVIEW_BYTES = 10 * 1024 * 1024;
/** Whether a raw request is an XLSX browser preview over the preview cap. */
function exceedsXlsxPreviewLimit(extension: string, query: { preview?: string; download?: string }, size: number) {
return (
extension === 'xlsx' &&
query.preview === 'true' &&
query.download !== 'true' &&
size > MAX_XLSX_BROWSER_PREVIEW_BYTES
);
}
function sendXlsxPreviewTooLarge(reply: FastifyReply, size: number): void {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large to preview (${Math.ceil(size / 1024 / 1024)}MB > ${MAX_XLSX_BROWSER_PREVIEW_BYTES / 1024 / 1024}MB limit)`
)
);
}
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -100,6 +129,7 @@ const MIME_TYPES: Record<string, string> = {
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
json: 'application/json',
md: 'text/markdown',
txt: 'text/plain',
@@ -1778,13 +1808,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
// Return metadata for media/binary files (no text body)
// Return metadata for media/binary files (no text body). XLSX is still a
// binary here (no text body); `spreadsheet` tells the overlay it can parse
// it client-side from `url`. xls/ods stay plain binary (download only).
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: mediaType ?? 'binary',
type: mediaType ?? (ext === 'xlsx' ? 'spreadsheet' : 'binary'),
extension: ext,
url: fileRawUrl,
},
@@ -2009,7 +2041,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath, download } = req.query as { path?: string; download?: string };
const { path: filePath, download, preview } = req.query as { path?: string; download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
if (!filePath) {
@@ -2039,6 +2071,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (exceedsXlsxPreviewLimit(ext, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -2217,7 +2253,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string };
const { download, preview } = req.query as { download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
@@ -2237,6 +2273,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(
reply,
target,