import './style.css';
// ===== State =====
const state = {
headers: [],
rows: [],
filePath: '',
cursor: { row: 0, col: 0 },
selection: null, // { startRow, startCol, endRow, endCol }
selectedCells: null, // Set of "r,c" strings for non-contiguous selection (Match Cell)
colWidths: [], // pixel widths per column
isSelecting: false,
editingHeader: -1,
formulaBarFocused: false,
formulaBarFromTable: false,
};
// ===== DOM refs =====
const $ = (sel) => document.querySelector(sel);
const tableHead = $('#table-head');
const tableBody = $('#table-body');
const formulaBar = $('#formula-bar');
const cellRef = $('#cell-ref');
const statusText = $('#status-text');
const commandPalette = $('#command-palette');
const commandInput = $('#command-input');
const commandList = $('#command-list');
const overlay = $('#overlay');
const tableContainer = $('#table-container');
const sortModal = $('#sort-modal');
const sortInput = $('#sort-columns-input');
const sortAutocomplete = $('#sort-autocomplete');
const sortConfirmBtn = $('#sort-confirm-btn');
const sortCancelBtn = $('#sort-cancel-btn');
const swModal = $('#set-where-modal');
const swMatchCol = $('#sw-match-col');
const swColAutocomplete = $('#sw-col-autocomplete');
const swMatchValues = $('#sw-match-values');
const swSetValue = $('#sw-set-value');
const swConfirmBtn = $('#sw-confirm-btn');
const swCancelBtn = $('#sw-cancel-btn');
// Default column width
const DEFAULT_COL_WIDTH = 120;
const MIN_COL_WIDTH = 40;
const MAX_BEST_FIT = 150;
// ===== Helpers =====
function colLabel(i) {
// A, B, C ... Z, AA, AB ...
let s = '';
let n = i;
do {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return s;
}
function cellRefStr(r, c) {
return colLabel(c) + (r + 1);
}
function clearMatchSelection() {
state.selectedCells = null;
}
function normalizeSelection() {
if (!state.selection) return null;
const { startRow, startCol, endRow, endCol } = state.selection;
return {
r1: Math.min(startRow, endRow),
c1: Math.min(startCol, endCol),
r2: Math.max(startRow, endRow),
c2: Math.max(startCol, endCol),
};
}
function isCellSelected(r, c) {
if (state.selectedCells) return state.selectedCells.has(`${r},${c}`);
const sel = normalizeSelection();
if (!sel) return r === state.cursor.row && c === state.cursor.col;
return r >= sel.r1 && r <= sel.r2 && c >= sel.c1 && c <= sel.c2;
}
function getCellValue(r, c) {
if (r < 0 || r >= state.rows.length) return '';
if (c < 0 || !state.rows[r] || c >= state.rows[r].length) return '';
return state.rows[r][c] || '';
}
function setCellValue(r, c, val) {
// Ensure row exists
while (state.rows.length <= r) state.rows.push([]);
// Ensure columns exist
while (state.rows[r].length <= c) state.rows[r].push('');
state.rows[r][c] = val;
}
function ensureGridSize() {
const numCols = state.headers.length;
for (let i = 0; i < state.rows.length; i++) {
while (state.rows[i].length < numCols) state.rows[i].push('');
}
}
function setStatus(msg) {
statusText.textContent = msg;
}
// ===== Rendering =====
function render() {
renderHead();
renderBody();
updateFormulaBar();
updateCellRef();
}
function renderHead() {
tableHead.innerHTML = '';
const tr = document.createElement('tr');
// Row number header
const th0 = document.createElement('th');
th0.className = 'row-number-header';
th0.textContent = '';
tr.appendChild(th0);
state.headers.forEach((h, i) => {
const th = document.createElement('th');
th.style.width = (state.colWidths[i] || DEFAULT_COL_WIDTH) + 'px';
th.style.position = 'relative';
th.dataset.col = i;
const content = document.createElement('div');
content.className = 'header-content';
const textSpan = document.createElement('span');
textSpan.className = 'header-text';
textSpan.textContent = h;
content.appendChild(textSpan);
th.appendChild(content);
// Resize handle
const handle = document.createElement('div');
handle.className = 'col-resize-handle';
handle.addEventListener('mousedown', (e) => startColResize(e, i));
handle.addEventListener('dblclick', (e) => {
e.stopPropagation();
bestFitColumn(i);
});
th.appendChild(handle);
// Double click to edit header
th.addEventListener('dblclick', (e) => {
if (e.target.classList.contains('col-resize-handle')) return;
startHeaderEdit(i);
});
tr.appendChild(th);
});
tableHead.appendChild(tr);
}
function renderBody() {
tableBody.innerHTML = '';
const numCols = state.headers.length;
state.rows.forEach((row, r) => {
const tr = document.createElement('tr');
// Row number
const tdNum = document.createElement('td');
tdNum.className = 'row-number';
tdNum.textContent = r + 1;
tr.appendChild(tdNum);
for (let c = 0; c < numCols; c++) {
const td = document.createElement('td');
td.style.width = (state.colWidths[c] || DEFAULT_COL_WIDTH) + 'px';
td.textContent = row[c] || '';
td.dataset.row = r;
td.dataset.col = c;
if (isCellSelected(r, c)) {
td.classList.add('selected');
}
if (r === state.cursor.row && c === state.cursor.col) {
td.classList.add('cursor-cell');
}
tr.appendChild(td);
}
tableBody.appendChild(tr);
});
}
function updateSelectionClasses() {
const tds = tableBody.querySelectorAll('td[data-row]');
tds.forEach(td => {
const r = parseInt(td.dataset.row);
const c = parseInt(td.dataset.col);
td.classList.toggle('selected', isCellSelected(r, c));
td.classList.toggle('cursor-cell', r === state.cursor.row && c === state.cursor.col);
});
updateFormulaBar();
updateCellRef();
}
function updateFormulaBar() {
if (!state.formulaBarFocused) {
formulaBar.value = getCellValue(state.cursor.row, state.cursor.col);
}
}
function updateCellRef() {
if (state.headers.length > 0 && state.rows.length > 0) {
cellRef.textContent = cellRefStr(state.cursor.row, state.cursor.col);
} else {
cellRef.textContent = '';
}
}
function scrollCursorIntoView() {
const td = tableBody.querySelector(
`td[data-row="${state.cursor.row}"][data-col="${state.cursor.col}"]`
);
if (td) {
td.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
}
// ===== Header editing =====
function startHeaderEdit(colIndex) {
state.editingHeader = colIndex;
const ths = tableHead.querySelectorAll('th');
const th = ths[colIndex + 1]; // +1 for row number header
if (!th) return;
const content = th.querySelector('.header-content');
content.innerHTML = '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'header-edit-input';
input.value = state.headers[colIndex];
content.appendChild(input);
input.focus();
input.select();
const finish = () => {
state.headers[colIndex] = input.value;
state.editingHeader = -1;
renderHead();
};
input.addEventListener('blur', finish);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { input.blur(); }
if (e.key === 'Escape') {
input.value = state.headers[colIndex]; // revert
input.blur();
}
e.stopPropagation();
});
}
// ===== Column resizing =====
let resizeState = null;
function startColResize(e, colIndex) {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = state.colWidths[colIndex] || DEFAULT_COL_WIDTH;
resizeState = { colIndex, startX, startWidth };
const handle = e.target;
handle.classList.add('active');
const onMove = (ev) => {
const diff = ev.clientX - startX;
const newWidth = Math.max(MIN_COL_WIDTH, startWidth + diff);
state.colWidths[colIndex] = newWidth;
// Update widths live
const ths = tableHead.querySelectorAll('th');
if (ths[colIndex + 1]) ths[colIndex + 1].style.width = newWidth + 'px';
const allTds = tableBody.querySelectorAll(`td[data-col="${colIndex}"]`);
allTds.forEach(td => td.style.width = newWidth + 'px');
};
const onUp = () => {
handle.classList.remove('active');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
resizeState = null;
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function bestFitColumn(colIndex) {
// Measure the widest content in this column
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
let maxW = ctx.measureText(state.headers[colIndex] || '').width;
for (const row of state.rows) {
const val = row[colIndex] || '';
const w = ctx.measureText(val).width;
if (w > maxW) maxW = w;
}
// Add padding
const fitted = Math.ceil(maxW) + 24; // 12px padding each side
const width = Math.max(fitted, MAX_BEST_FIT);
state.colWidths[colIndex] = width;
render();
}
function bestFitAllColumns() {
for (let i = 0; i < state.headers.length; i++) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
let maxW = ctx.measureText(state.headers[i] || '').width;
for (const row of state.rows) {
const w = ctx.measureText(row[i] || '').width;
if (w > maxW) maxW = w;
}
const fitted = Math.ceil(maxW) + 24;
state.colWidths[i] = Math.max(fitted, MAX_BEST_FIT);
}
render();
}
// ===== Cell click & drag selection =====
tableBody.addEventListener('mousedown', (e) => {
const td = e.target.closest('td[data-row]');
if (!td) return;
const r = parseInt(td.dataset.row);
const c = parseInt(td.dataset.col);
// Commit formula bar if it was focused
if (state.formulaBarFocused) {
commitFormulaBar();
}
clearMatchSelection();
if (e.shiftKey) {
// Extend selection
state.selection = {
startRow: state.cursor.row,
startCol: state.cursor.col,
endRow: r,
endCol: c
};
} else {
state.cursor = { row: r, col: c };
state.selection = { startRow: r, startCol: c, endRow: r, endCol: c };
}
state.isSelecting = true;
updateSelectionClasses();
});
document.addEventListener('mousemove', (e) => {
if (!state.isSelecting) return;
const td = e.target.closest('td[data-row]');
if (!td) return;
const r = parseInt(td.dataset.row);
const c = parseInt(td.dataset.col);
if (state.selection) {
state.selection.endRow = r;
state.selection.endCol = c;
}
updateSelectionClasses();
});
document.addEventListener('mouseup', () => {
state.isSelecting = false;
});
// ===== Keyboard navigation =====
function moveCursor(dr, dc, shift) {
clearMatchSelection();
if (shift) {
if (!state.selection) {
state.selection = {
startRow: state.cursor.row,
startCol: state.cursor.col,
endRow: state.cursor.row,
endCol: state.cursor.col
};
}
const nr = Math.max(0, Math.min(state.rows.length - 1, state.selection.endRow + dr));
const nc = Math.max(0, Math.min(state.headers.length - 1, state.selection.endCol + dc));
state.selection.endRow = nr;
state.selection.endCol = nc;
} else {
const nr = Math.max(0, Math.min(state.rows.length - 1, state.cursor.row + dr));
const nc = Math.max(0, Math.min(state.headers.length - 1, state.cursor.col + dc));
state.cursor = { row: nr, col: nc };
state.selection = null;
}
updateSelectionClasses();
scrollCursorIntoView();
}
document.addEventListener('keydown', (e) => {
// Command palette
if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
e.preventDefault();
openCommandPalette();
return;
}
// If command palette is open, don't handle other keys
if (!commandPalette.classList.contains('hidden')) return;
// If editing a header, don't interfere
if (state.editingHeader >= 0) return;
// If formula bar is focused, handle Enter/Escape
if (state.formulaBarFocused) {
if (e.key === 'Enter') {
e.preventDefault();
commitFormulaBar();
if (state.formulaBarFromTable) {
formulaBar.blur();
tableContainer.focus();
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
cancelFormulaBar();
formulaBar.blur();
tableContainer.focus();
return;
}
return; // Let normal typing happen in formula bar
}
// Shortcuts
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key === 's') {
e.preventDefault();
executeCommand('save');
return;
}
if (meta && e.key === 'c') {
e.preventDefault();
executeCommand('copy');
return;
}
if (meta && e.key === 'x') {
e.preventDefault();
executeCommand('cut');
return;
}
if (meta && e.key === 'v') {
e.preventDefault();
executeCommand('paste');
return;
}
if (meta && e.key === 'o') {
e.preventDefault();
executeCommand('open');
return;
}
if (meta && e.key === 'm') {
e.preventDefault();
executeCommand('match-cell');
return;
}
// Arrow keys
if (e.key === 'ArrowUp') { e.preventDefault(); moveCursor(-1, 0, e.shiftKey); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); moveCursor(1, 0, e.shiftKey); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); moveCursor(0, -1, e.shiftKey); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); moveCursor(0, 1, e.shiftKey); return; }
// Tab
if (e.key === 'Tab') {
e.preventDefault();
moveCursor(0, e.shiftKey ? -1 : 1, false);
return;
}
// Enter moves down
if (e.key === 'Enter') {
e.preventDefault();
moveCursor(1, 0, false);
return;
}
// Cmd+Backspace deletes rows
if (meta && e.key === 'Backspace') {
e.preventDefault();
executeCommand('delete-row');
return;
}
// Delete/Backspace clears selected cells
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
clearSelectedCells();
return;
}
// Escape clears selection
if (e.key === 'Escape') {
state.selection = null;
clearMatchSelection();
updateSelectionClasses();
return;
}
// Typing a printable character: focus formula bar and start editing
if (e.key.length === 1 && !meta && !e.altKey) {
e.preventDefault();
formulaBar.value = '';
formulaBar.focus();
state.formulaBarFocused = true;
state.formulaBarFromTable = true;
// Insert the typed character
formulaBar.value = e.key;
// Move cursor to end
formulaBar.setSelectionRange(formulaBar.value.length, formulaBar.value.length);
return;
}
});
// ===== Formula bar =====
formulaBar.addEventListener('focus', () => {
state.formulaBarFocused = true;
// If not triggered by keyboard, this was a direct click
if (!state.formulaBarFromTable) {
formulaBar.value = getCellValue(state.cursor.row, state.cursor.col);
}
});
formulaBar.addEventListener('blur', () => {
state.formulaBarFocused = false;
state.formulaBarFromTable = false;
});
function commitFormulaBar() {
setCellValue(state.cursor.row, state.cursor.col, formulaBar.value);
state.formulaBarFocused = false;
state.formulaBarFromTable = false;
render();
}
function cancelFormulaBar() {
formulaBar.value = getCellValue(state.cursor.row, state.cursor.col);
state.formulaBarFocused = false;
state.formulaBarFromTable = false;
}
// ===== Selection data extraction =====
function getSelectedData() {
// Non-contiguous match selection: collect bounding box
if (state.selectedCells && state.selectedCells.size > 0) {
let minR = Infinity, maxR = -1, minC = Infinity, maxC = -1;
for (const key of state.selectedCells) {
const [r, c] = key.split(',').map(Number);
if (r < minR) minR = r;
if (r > maxR) maxR = r;
if (c < minC) minC = c;
if (c > maxC) maxC = c;
}
const headers = [];
for (let c = minC; c <= maxC; c++) headers.push(state.headers[c] || '');
const rows = [];
for (let r = minR; r <= maxR; r++) {
const row = [];
for (let c = minC; c <= maxC; c++) {
row.push(state.selectedCells.has(`${r},${c}`) ? getCellValue(r, c) : '');
}
rows.push(row);
}
return { headers, rows, isSingleCol: minC === maxC };
}
const sel = normalizeSelection();
if (!sel) {
return {
headers: [state.headers[state.cursor.col]],
rows: [[getCellValue(state.cursor.row, state.cursor.col)]],
isSingleCol: true
};
}
const headers = [];
for (let c = sel.c1; c <= sel.c2; c++) {
headers.push(state.headers[c] || '');
}
const rows = [];
for (let r = sel.r1; r <= sel.r2; r++) {
const row = [];
for (let c = sel.c1; c <= sel.c2; c++) {
row.push(getCellValue(r, c));
}
rows.push(row);
}
return {
headers,
rows,
isSingleCol: sel.c1 === sel.c2
};
}
function clearSelectedCells() {
if (state.selectedCells && state.selectedCells.size > 0) {
for (const key of state.selectedCells) {
const [r, c] = key.split(',').map(Number);
setCellValue(r, c, '');
}
render();
return;
}
const sel = normalizeSelection();
if (!sel) {
setCellValue(state.cursor.row, state.cursor.col, '');
} else {
for (let r = sel.r1; r <= sel.r2; r++) {
for (let c = sel.c1; c <= sel.c2; c++) {
setCellValue(r, c, '');
}
}
}
render();
}
// ===== Commands =====
let commands = [];
async function loadCommands() {
try {
commands = await window.go.main.CommandRegistry.GetCommands();
} catch (e) {
console.error('Failed to load commands:', e);
// Fallback commands
commands = [
{ ID: 'copy', Name: 'Copy', Shortcut: 'Cmd+C' },
{ ID: 'cut', Name: 'Cut', Shortcut: 'Cmd+X' },
{ ID: 'copy-markdown', Name: 'Copy as Markdown', Shortcut: '' },
{ ID: 'copy-jira', Name: 'Copy as Jira', Shortcut: '' },
{ ID: 'paste', Name: 'Paste', Shortcut: 'Cmd+V' },
{ ID: 'resize-all', Name: 'Resize All Columns', Shortcut: '' },
{ ID: 'open', Name: 'Open File', Shortcut: 'Cmd+O' },
{ ID: 'save', Name: 'Save File', Shortcut: 'Cmd+S' },
{ ID: 'open-up', Name: 'Insert Row Above', Shortcut: '' },
{ ID: 'open-down', Name: 'Insert Row Below', Shortcut: '' },
{ ID: 'open-left', Name: 'Insert Column Left', Shortcut: '' },
{ ID: 'open-right', Name: 'Insert Column Right', Shortcut: '' },
{ ID: 'sort-asc', Name: 'Sort A-Z', Shortcut: '' },
{ ID: 'sort-desc', Name: 'Sort Z-A', Shortcut: '' },
{ ID: 'sort-advanced', Name: 'Sort Advanced', Shortcut: '' },
{ ID: 'match-cell', Name: 'Match Cell', Shortcut: 'Cmd+M' },
{ ID: 'set-where', Name: 'Set Where', Shortcut: '' },
{ ID: 'delete-row', Name: 'Delete Row', Shortcut: 'Cmd+Backspace' },
];
}
}
function openCommandPalette() {
commandPalette.classList.remove('hidden');
overlay.classList.remove('hidden');
commandInput.value = '';
renderCommandList('');
commandInput.focus();
}
function closeCommandPalette() {
commandPalette.classList.add('hidden');
overlay.classList.add('hidden');
commandInput.value = '';
}
let activeCommandIndex = 0;
function renderCommandList(filter) {
commandList.innerHTML = '';
const lower = filter.toLowerCase();
const filtered = commands
.filter(c => c.Name.toLowerCase().includes(lower))
.sort((a, b) => a.Name.toLowerCase().localeCompare(b.Name.toLowerCase()));
activeCommandIndex = 0;
filtered.forEach((cmd, i) => {
const div = document.createElement('div');
div.className = 'command-item' + (i === 0 ? ' active' : '');
div.innerHTML = `${cmd.Name}` +
(cmd.Shortcut ? `${cmd.Shortcut}` : '');
div.addEventListener('click', () => {
closeCommandPalette();
executeCommand(cmd.ID);
});
commandList.appendChild(div);
});
}
commandInput.addEventListener('input', () => {
renderCommandList(commandInput.value);
});
commandInput.addEventListener('keydown', (e) => {
const items = commandList.querySelectorAll('.command-item');
if (e.key === 'Escape') {
e.preventDefault();
closeCommandPalette();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (items.length === 0) return;
items[activeCommandIndex]?.classList.remove('active');
activeCommandIndex = (activeCommandIndex + 1) % items.length;
items[activeCommandIndex]?.classList.add('active');
items[activeCommandIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (items.length === 0) return;
items[activeCommandIndex]?.classList.remove('active');
activeCommandIndex = (activeCommandIndex - 1 + items.length) % items.length;
items[activeCommandIndex]?.classList.add('active');
items[activeCommandIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const items = commandList.querySelectorAll('.command-item');
if (items[activeCommandIndex]) {
items[activeCommandIndex].click();
}
return;
}
e.stopPropagation();
});
overlay.addEventListener('click', () => {
if (!commandPalette.classList.contains('hidden')) closeCommandPalette();
if (!sortModal.classList.contains('hidden')) closeSortAdvanced();
if (!swModal.classList.contains('hidden')) closeSetWhere();
});
// ===== Command execution =====
async function executeCommand(id) {
switch (id) {
case 'copy': await doCopy(); break;
case 'cut': await doCut(); break;
case 'copy-markdown': await doCopyMarkdown(); break;
case 'copy-jira': await doCopyJira(); break;
case 'paste': await doPaste(); break;
case 'resize-all': bestFitAllColumns(); setStatus('All columns resized'); break;
case 'open': await doOpen(); break;
case 'save': await doSave(); break;
case 'open-up': doInsertRowAbove(); break;
case 'open-down': doInsertRowBelow(); break;
case 'open-left': doInsertColLeft(); break;
case 'open-right': doInsertColRight(); break;
case 'sort-asc': doSort(true); break;
case 'sort-desc': doSort(false); break;
case 'sort-advanced': openSortAdvanced(); break;
case 'match-cell': doMatchCell(); break;
case 'set-where': openSetWhere(); break;
case 'delete-row': doDeleteRow(); break;
}
}
async function doCopy() {
const data = getSelectedData();
let text;
if (data.isSingleCol) {
try {
text = await window.go.main.App.FormatAsSingleColumn(data.rows);
} catch {
text = data.rows.map(r => r[0]).join('\n');
}
} else {
try {
text = await window.go.main.App.FormatRowsAsCSV(data.rows);
} catch {
text = data.rows.map(r => r.join(',')).join('\n');
}
}
await navigator.clipboard.writeText(text);
setStatus('Copied to clipboard');
}
async function doCut() {
await doCopy();
clearSelectedCells();
setStatus('Cut to clipboard');
}
async function doCopyMarkdown() {
const data = getSelectedData();
let text;
try {
text = await window.go.main.App.FormatAsMarkdown(data.headers, data.rows);
} catch {
text = '(Markdown format unavailable)';
}
await navigator.clipboard.writeText(text);
setStatus('Copied as Markdown');
}
async function doCopyJira() {
const data = getSelectedData();
let text;
try {
text = await window.go.main.App.FormatAsJira(data.headers, data.rows);
} catch {
text = '(Jira format unavailable)';
}
await navigator.clipboard.writeText(text);
setStatus('Copied as Jira');
}
async function doPaste() {
let text;
try {
text = await navigator.clipboard.readText();
} catch {
setStatus('Failed to read clipboard');
return;
}
if (!text || !text.trim()) {
setStatus('Clipboard is empty');
return;
}
const lines = text.trim().split('\n');
// Check if it looks like CSV: multi-line, consistent comma-separated columns
let isCSV = false;
if (lines.length > 1) {
// Count commas outside quotes for each line
const colCounts = lines.map(line => {
let count = 1;
let inQuote = false;
for (const ch of line) {
if (ch === '"') inQuote = !inQuote;
else if (ch === ',' && !inQuote) count++;
}
return count;
});
// Check if all lines have the same column count and > 1
const first = colCounts[0];
if (first > 1 && colCounts.every(c => c === first)) {
// Also check no spaces before commas (heuristic for CSV-like)
isCSV = true;
}
}
let pasteRows;
if (isCSV) {
try {
const parsed = await window.go.main.App.ParseCSVString(text);
// Combine headers and rows since we're pasting into cells
pasteRows = [parsed.Headers, ...parsed.Rows];
} catch {
// Fallback: split by comma
pasteRows = lines.map(l => l.split(','));
}
} else {
// Each line is a single-column row
pasteRows = lines.map(l => [l]);
}
// Place at cursor position
const startRow = state.cursor.row;
const startCol = state.cursor.col;
for (let r = 0; r < pasteRows.length; r++) {
for (let c = 0; c < pasteRows[r].length; c++) {
const targetRow = startRow + r;
const targetCol = startCol + c;
// Extend grid if needed
while (state.rows.length <= targetRow) state.rows.push(new Array(state.headers.length).fill(''));
while (state.headers.length <= targetCol) {
state.headers.push(colLabel(state.headers.length));
state.colWidths.push(DEFAULT_COL_WIDTH);
for (const row of state.rows) row.push('');
}
setCellValue(targetRow, targetCol, pasteRows[r][c]);
}
}
render();
setStatus(`Pasted ${pasteRows.length} rows`);
}
async function doOpen() {
let filePath;
try {
filePath = await window.go.main.App.OpenFileDialog();
} catch (e) {
setStatus('Open cancelled');
return;
}
if (!filePath) {
setStatus('Open cancelled');
return;
}
await loadFile(filePath);
}
async function doSave() {
if (state.filePath) {
try {
await window.go.main.App.SaveCurrentFile(state.headers, state.rows);
setStatus('Saved: ' + state.filePath);
} catch (e) {
setStatus('Error saving: ' + e);
}
} else {
let filePath;
try {
filePath = await window.go.main.App.SaveFileDialog();
} catch {
setStatus('Save cancelled');
return;
}
if (!filePath) {
setStatus('Save cancelled');
return;
}
try {
await window.go.main.App.SaveCSV(filePath, state.headers, state.rows);
state.filePath = filePath;
setStatus('Saved: ' + filePath);
} catch (e) {
setStatus('Error saving: ' + e);
}
}
}
function doInsertRowAbove() {
const r = state.cursor.row;
const newRow = new Array(state.headers.length).fill('');
state.rows.splice(r, 0, newRow);
render();
setStatus('Inserted row above');
}
function doInsertRowBelow() {
const r = state.cursor.row;
const newRow = new Array(state.headers.length).fill('');
state.rows.splice(r + 1, 0, newRow);
state.cursor.row = r + 1;
render();
setStatus('Inserted row below');
}
function doInsertColLeft() {
const c = state.cursor.col;
state.headers.splice(c, 0, colLabel(state.headers.length));
state.colWidths.splice(c, 0, DEFAULT_COL_WIDTH);
for (const row of state.rows) {
row.splice(c, 0, '');
}
render();
setStatus('Inserted column left');
}
function doInsertColRight() {
const c = state.cursor.col + 1;
state.headers.splice(c, 0, colLabel(state.headers.length));
state.colWidths.splice(c, 0, DEFAULT_COL_WIDTH);
for (const row of state.rows) {
row.splice(c, 0, '');
}
state.cursor.col = c;
render();
setStatus('Inserted column right');
}
// ===== Match & Delete Row =====
function doMatchCell() {
const val = getCellValue(state.cursor.row, state.cursor.col);
const matched = new Set();
for (let r = 0; r < state.rows.length; r++) {
for (let c = 0; c < state.headers.length; c++) {
if ((state.rows[r][c] || '') === val) {
matched.add(`${r},${c}`);
}
}
}
if (matched.size === 0) {
setStatus('No matching cells');
return;
}
state.selection = null;
state.selectedCells = matched;
updateSelectionClasses();
setStatus(`${matched.size} cell(s) matching "${val}"`);
}
function doDeleteRow() {
const sel = normalizeSelection();
let startRow, endRow;
if (sel) {
startRow = sel.r1;
endRow = sel.r2;
} else {
startRow = state.cursor.row;
endRow = state.cursor.row;
}
const count = endRow - startRow + 1;
state.rows.splice(startRow, count);
// Ensure at least one row remains
if (state.rows.length === 0) {
state.rows.push(new Array(state.headers.length).fill(''));
}
// Adjust cursor
state.cursor.row = Math.min(startRow, state.rows.length - 1);
state.selection = null;
render();
setStatus(`Deleted ${count} row(s)`);
}
// ===== Sorting =====
function doSort(ascending) {
const col = state.cursor.col;
state.rows.sort((a, b) => {
const va = (a[col] || '').toLowerCase();
const vb = (b[col] || '').toLowerCase();
return ascending ? va.localeCompare(vb, undefined, { numeric: true })
: vb.localeCompare(va, undefined, { numeric: true });
});
render();
setStatus(`Sorted by ${state.headers[col] || colLabel(col)} ${ascending ? 'A-Z' : 'Z-A'}`);
}
// ===== Sort Advanced Modal =====
let acIndex = -1;
function openSortAdvanced() {
sortModal.classList.remove('hidden');
overlay.classList.remove('hidden');
sortInput.value = '';
hideSortAutocomplete();
sortInput.focus();
}
function closeSortAdvanced() {
sortModal.classList.add('hidden');
overlay.classList.add('hidden');
sortInput.value = '';
hideSortAutocomplete();
}
function hideSortAutocomplete() {
sortAutocomplete.classList.add('hidden');
sortAutocomplete.innerHTML = '';
acIndex = -1;
}
function getCurrentToken() {
const val = sortInput.value;
const cursor = sortInput.selectionStart;
const before = val.substring(0, cursor);
const lastComma = before.lastIndexOf(',');
return {
text: before.substring(lastComma + 1).trim(),
start: lastComma + 1,
cursorPos: cursor,
};
}
function getAlreadyChosen() {
const parts = sortInput.value.split(',');
const cursor = sortInput.selectionStart;
const before = sortInput.value.substring(0, cursor);
const tokenIdx = before.split(',').length - 1;
return parts
.map(p => p.trim().toLowerCase())
.filter((_, i) => i !== tokenIdx)
.filter(p => p.length > 0);
}
function updateSortAutocomplete() {
const { text } = getCurrentToken();
const lower = text.toLowerCase();
const chosen = getAlreadyChosen();
const matches = state.headers
.filter(h => h.toLowerCase().includes(lower))
.filter(h => !chosen.includes(h.toLowerCase()))
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
if (matches.length === 0 || (matches.length === 1 && matches[0].toLowerCase() === lower)) {
hideSortAutocomplete();
return;
}
sortAutocomplete.innerHTML = '';
sortAutocomplete.classList.remove('hidden');
acIndex = -1;
matches.forEach((name, i) => {
const div = document.createElement('div');
div.className = 'ac-item';
div.textContent = name;
div.addEventListener('mousedown', (e) => {
e.preventDefault(); // keep focus on input
applySortCompletion(name);
});
sortAutocomplete.appendChild(div);
});
}
function applySortCompletion(name) {
const val = sortInput.value;
const cursor = sortInput.selectionStart;
const before = val.substring(0, cursor);
const after = val.substring(cursor);
const lastComma = before.lastIndexOf(',');
const prefix = before.substring(0, lastComma + 1);
const spacing = lastComma >= 0 ? ' ' : '';
const newVal = prefix + spacing + name + after;
sortInput.value = newVal;
const newCursor = (prefix + spacing + name).length;
sortInput.setSelectionRange(newCursor, newCursor);
hideSortAutocomplete();
sortInput.focus();
}
function confirmSortAdvanced() {
const names = sortInput.value
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
const colIndices = [];
for (const name of names) {
const idx = state.headers.findIndex(
h => h.toLowerCase() === name.toLowerCase()
);
if (idx === -1) {
setStatus(`Unknown column: "${name}"`);
return;
}
colIndices.push(idx);
}
if (colIndices.length === 0) {
setStatus('No columns specified');
return;
}
state.rows.sort((a, b) => {
for (const col of colIndices) {
const va = (a[col] || '').toLowerCase();
const vb = (b[col] || '').toLowerCase();
const cmp = va.localeCompare(vb, undefined, { numeric: true });
if (cmp !== 0) return cmp;
}
return 0;
});
closeSortAdvanced();
render();
setStatus('Sorted by ' + names.join(', '));
}
sortInput.addEventListener('input', updateSortAutocomplete);
sortInput.addEventListener('focus', updateSortAutocomplete);
sortInput.addEventListener('blur', () => {
// Small delay to allow mousedown on ac-item to fire
setTimeout(hideSortAutocomplete, 150);
});
sortInput.addEventListener('keydown', (e) => {
const items = sortAutocomplete.querySelectorAll('.ac-item');
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (!sortAutocomplete.classList.contains('hidden')) {
hideSortAutocomplete();
} else {
closeSortAdvanced();
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (acIndex >= 0 && items[acIndex]) {
applySortCompletion(items[acIndex].textContent);
} else {
confirmSortAdvanced();
}
return;
}
if (e.key === 'ArrowDown' && items.length > 0) {
e.preventDefault();
if (acIndex >= 0) items[acIndex]?.classList.remove('active');
acIndex = (acIndex + 1) % items.length;
items[acIndex]?.classList.add('active');
items[acIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
if (e.key === 'ArrowUp' && items.length > 0) {
e.preventDefault();
if (acIndex >= 0) items[acIndex]?.classList.remove('active');
acIndex = (acIndex - 1 + items.length) % items.length;
items[acIndex]?.classList.add('active');
items[acIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
e.stopPropagation();
});
sortConfirmBtn.addEventListener('click', confirmSortAdvanced);
sortCancelBtn.addEventListener('click', closeSortAdvanced);
// ===== Set Where Modal =====
let swAcIndex = -1;
function openSetWhere() {
swModal.classList.remove('hidden');
overlay.classList.remove('hidden');
swMatchCol.value = '';
swMatchValues.value = '';
swSetValue.value = '';
hideSwAutocomplete();
swMatchCol.focus();
}
function closeSetWhere() {
swModal.classList.add('hidden');
overlay.classList.add('hidden');
swMatchCol.value = '';
swMatchValues.value = '';
swSetValue.value = '';
hideSwAutocomplete();
}
function hideSwAutocomplete() {
swColAutocomplete.classList.add('hidden');
swColAutocomplete.innerHTML = '';
swAcIndex = -1;
}
function updateSwAutocomplete() {
const text = swMatchCol.value.trim().toLowerCase();
const matches = state.headers
.filter(h => h.toLowerCase().includes(text))
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
if (matches.length === 0 || (matches.length === 1 && matches[0].toLowerCase() === text)) {
hideSwAutocomplete();
return;
}
swColAutocomplete.innerHTML = '';
swColAutocomplete.classList.remove('hidden');
swAcIndex = -1;
matches.forEach((name) => {
const div = document.createElement('div');
div.className = 'ac-item';
div.textContent = name;
div.addEventListener('mousedown', (e) => {
e.preventDefault();
swMatchCol.value = name;
hideSwAutocomplete();
swMatchValues.focus();
});
swColAutocomplete.appendChild(div);
});
}
function confirmSetWhere() {
const colName = swMatchCol.value.trim();
const matchColIdx = state.headers.findIndex(
h => h.toLowerCase() === colName.toLowerCase()
);
if (matchColIdx === -1) {
setStatus(`Unknown column: "${colName}"`);
return;
}
const matchVals = new Set(
swMatchValues.value.split('\n').map(s => s.trim()).filter(s => s.length > 0)
);
if (matchVals.size === 0) {
setStatus('No match values specified');
return;
}
const setValue = swSetValue.value;
const targetCol = state.cursor.col;
let count = 0;
for (let r = 0; r < state.rows.length; r++) {
const cellVal = state.rows[r][matchColIdx] || '';
if (matchVals.has(cellVal)) {
setCellValue(r, targetCol, setValue);
count++;
}
}
closeSetWhere();
render();
const targetColName = state.headers[targetCol] || colLabel(targetCol);
setStatus(`Set ${count} cell(s) in ${targetColName}`);
}
swMatchCol.addEventListener('input', updateSwAutocomplete);
swMatchCol.addEventListener('focus', updateSwAutocomplete);
swMatchCol.addEventListener('blur', () => {
setTimeout(hideSwAutocomplete, 150);
});
swMatchCol.addEventListener('keydown', (e) => {
const items = swColAutocomplete.querySelectorAll('.ac-item');
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (!swColAutocomplete.classList.contains('hidden')) {
hideSwAutocomplete();
} else {
closeSetWhere();
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (swAcIndex >= 0 && items[swAcIndex]) {
swMatchCol.value = items[swAcIndex].textContent;
hideSwAutocomplete();
swMatchValues.focus();
} else {
// Move focus to match values
swMatchValues.focus();
}
return;
}
if (e.key === 'ArrowDown' && items.length > 0) {
e.preventDefault();
if (swAcIndex >= 0) items[swAcIndex]?.classList.remove('active');
swAcIndex = (swAcIndex + 1) % items.length;
items[swAcIndex]?.classList.add('active');
items[swAcIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
if (e.key === 'ArrowUp' && items.length > 0) {
e.preventDefault();
if (swAcIndex >= 0) items[swAcIndex]?.classList.remove('active');
swAcIndex = (swAcIndex - 1 + items.length) % items.length;
items[swAcIndex]?.classList.add('active');
items[swAcIndex]?.scrollIntoView({ block: 'nearest' });
return;
}
if (e.key === 'Tab') {
e.preventDefault();
if (swAcIndex >= 0 && items[swAcIndex]) {
swMatchCol.value = items[swAcIndex].textContent;
hideSwAutocomplete();
}
swMatchValues.focus();
return;
}
e.stopPropagation();
});
swMatchValues.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSetWhere();
return;
}
// Tab to next field
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
swSetValue.focus();
return;
}
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
swMatchCol.focus();
return;
}
e.stopPropagation();
});
swSetValue.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSetWhere();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
confirmSetWhere();
return;
}
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
swMatchValues.focus();
return;
}
e.stopPropagation();
});
swConfirmBtn.addEventListener('click', confirmSetWhere);
swCancelBtn.addEventListener('click', closeSetWhere);
// ===== File loading =====
async function loadFile(filePath) {
try {
const data = await window.go.main.App.LoadCSV(filePath);
state.headers = data.Headers || [];
state.rows = data.Rows || [];
state.filePath = data.FilePath || filePath;
state.cursor = { row: 0, col: 0 };
state.selection = null;
state.colWidths = state.headers.map(() => DEFAULT_COL_WIDTH);
ensureGridSize();
render();
setStatus('Loaded: ' + filePath);
} catch (e) {
setStatus('Error loading: ' + e);
}
}
function loadEmptySheet() {
const cols = 10;
const rows = 30;
state.headers = Array.from({ length: cols }, (_, i) => colLabel(i));
state.rows = Array.from({ length: rows }, () => new Array(cols).fill(''));
state.colWidths = new Array(cols).fill(DEFAULT_COL_WIDTH);
state.cursor = { row: 0, col: 0 };
state.selection = null;
state.filePath = '';
render();
}
// ===== File drop =====
function setupFileDrop() {
try {
window.runtime.EventsOn('file-dropped', (filePath) => {
if (filePath && filePath.toLowerCase().endsWith('.csv')) {
loadFile(filePath);
} else if (filePath) {
// Try to load anyway
loadFile(filePath);
}
});
} catch (e) {
console.warn('File drop events not available:', e);
}
}
// ===== Init =====
async function init() {
await loadCommands();
setupFileDrop();
loadEmptySheet();
}
init();