Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions playwright/specs/sheet_excel_chrome.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,48 @@ test.describe('Sheet Excel chrome', () => {
await ctx.close();
});

test('undo and redo revert and reapply an edit', async ({ page }) => {
const padId = `xl-undo-${Date.now()}`;
await openSheet(page, padId);
// Typing into a cell appends to its content, so each value goes into a
// fresh cell — this test is about the history, not about cell editing.
await commitCell(page, 0, 0, 'first'); // A1
await commitCell(page, 1, 0, 'second'); // A2

// Toolbar path: undo the second edit, redo it.
await page.locator('.sheet-toolbar button[title="Undo (Ctrl+Z)"]').click();
await expect(cell(page, 1, 0)).toHaveText('');
await expect(cell(page, 0, 0)).toHaveText('first'); // the older edit stands
await page.locator('.sheet-toolbar button[title="Redo (Ctrl+Y)"]').click();
await expect(cell(page, 1, 0)).toHaveText('second');

// Keyboard path, stepping back through both edits.
await cell(page, 3, 3).click(); // focus a cell outside the edited ones
await page.keyboard.press('Control+z');
await expect(cell(page, 1, 0)).toHaveText('');
await page.keyboard.press('Control+z');
await expect(cell(page, 0, 0)).toHaveText('');
await page.keyboard.press('Control+y');
await expect(cell(page, 0, 0)).toHaveText('first');
});

test('undoing a multi-cell action reverts it in one step', async ({ page }) => {
const padId = `xl-undogroup-${Date.now()}`;
await openSheet(page, padId);
await commitCell(page, 0, 0, 'a'); // A1
await commitCell(page, 1, 0, 'b'); // A2

// Clear Contents writes one op per cell; undo must restore both at once.
await dragSelect(page, 0, 0, 1, 0);
await page.locator('.sheet-toolbar button[title="Clear"]').click();
await page.locator('.sheet-file-menu button', { hasText: 'Clear Contents' }).click();
await expect(cell(page, 0, 0)).toHaveText('');

await page.locator('.sheet-toolbar button[title="Undo (Ctrl+Z)"]').click();
await expect(cell(page, 0, 0)).toHaveText('a');
await expect(cell(page, 1, 0)).toHaveText('b');
});

test('selected cell highlights its row and column headers', async ({ page }) => {
const padId = `xl-headhl-${Date.now()}`;
await openSheet(page, padId);
Expand Down
76 changes: 76 additions & 0 deletions ui/src/js/sheet/sheetCollabClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Op } from './op';
import { transform } from './transform';
import { invertOp } from './undo';
import { WorkbookState, type WorkbookSnapshot } from './workbookState';

// Bounded so a long session cannot grow the history without limit; Excel caps
// its own undo list too.
const MAX_HISTORY = 100;

// CollabTransport is the outbound channel for ops (wraps the socket emit).
export interface CollabTransport {
send(op: Op): void;
Expand All @@ -27,6 +32,15 @@ export class SheetCollabClient {
private committing = false;
private transport: CollabTransport;

// Undo history. Each entry is the op list that reverts one user action; the
// ops are recorded per applyLocal but grouped per tick, so a multi-op action
// (paste, fill, styling a range) undoes in one step. Only local ops are ever
// recorded, so undo never touches another collaborator's work.
private undoStack: Op[][] = [];
private redoStack: Op[][] = [];
private group: Op[] | null = null;
private mode: 'edit' | 'undo' | 'redo' = 'edit';

constructor(snap: WorkbookSnapshot, head: number, transport: CollabTransport) {
this.rev = head;
this.serverWb = new WorkbookState();
Expand All @@ -42,12 +56,69 @@ export class SheetCollabClient {

// applyLocal applies a local edit optimistically and schedules it for sending.
applyLocal(op: Op): void {
// Computed against the pre-op display state — that is what the inverse has
// to restore.
const inverse = invertOp(this.display, op);
this.pending.push(op);
this.display.applyOp(op);
this.record(inverse);
this.onChange();
this.flush();
}

canUndo(): boolean {
return this.undoStack.length > 0;
}

canRedo(): boolean {
return this.redoStack.length > 0;
}

// undo/redo replay a recorded entry as ordinary local ops, which is what makes
// them collaboration-safe: they are transformed, sent and acked like any edit.
undo(): void {
this.replay(this.undoStack.pop(), 'undo');
}

redo(): void {
this.replay(this.redoStack.pop(), 'redo');
}

private replay(entry: Op[] | undefined, mode: 'undo' | 'redo'): void {
if (!entry) return;
this.mode = mode;
for (const op of entry) this.applyLocal({ ...op, baseRev: this.rev });
this.closeGroup(); // the entry is complete now; do not wait for the tick
}

// record collects the inverse ops of one tick into a single history entry.
// Prepending keeps them in reverse application order, so undoing a group
// reverts its last op first.
private record(inverse: Op[]): void {
if (inverse.length === 0) return;
if (this.group === null) {
this.group = [];
queueMicrotask(() => this.closeGroup());
}
this.group.unshift(...inverse);
}

private closeGroup(): void {
const entry = this.group;
this.group = null;
const mode = this.mode;
this.mode = 'edit';
if (!entry || entry.length === 0) return;
if (mode === 'undo') {
this.redoStack.push(entry);
return;
}
this.undoStack.push(entry);
if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift();
// A fresh edit invalidates the redo branch; a redo keeps it.
if (mode === 'edit') this.redoStack.length = 0;
}

private flush(): void {
if (this.committing || this.pending.length === 0) return;
this.committing = true;
Expand Down Expand Up @@ -75,6 +146,11 @@ export class SheetCollabClient {
this.serverWb.applyOp(remoteOp);
this.rev = newRev;
this.pending = this.pending.map((p) => transform(p, remoteOp));
// The history holds ops against the old coordinate space too: a remote row
// insert must move a recorded undo the same way it moves a pending op.
const rebase = (stack: Op[][]): Op[][] => stack.map((entry) => entry.map((o) => transform(o, remoteOp)));
this.undoStack = rebase(this.undoStack);
this.redoStack = rebase(this.redoStack);
Comment on lines +151 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Deleted-row undo clobbers cells 🐞 Bug ≡ Correctness

Rebasing a stored cell inverse through a remote row or column deletion clamps its target to the
deletion index. Undo then writes the old value into the unrelated cell that shifted into that
position.
Agent Prompt
## Issue description
History rebasing uses the normal pending-operation transform, which clamps inverse cell coordinates inside a remotely deleted band and redirects undo onto unrelated shifted cells.

## Issue Context
Introduce history-specific transformation semantics that can remove obsolete point inverses rather than clamping them. Cover both row and column deletion with collaborative regression tests.

## Fix Focus Areas
- ui/src/js/sheet/sheetCollabClient.ts[143-156]
- ui/src/js/sheet/transform.ts[30-69]
- ui/src/js/sheet/undo.test.ts[194-206]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

this.rebuildDisplay();
this.onChange();
}
Expand Down
32 changes: 31 additions & 1 deletion ui/src/js/sheet/sheetEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { DomSheetView } from './sheetView';
import { SheetPresence, effectiveCells, type PresenceFrame } from './sheetPresence';
import { rangeToTSV, rangeToCSV, parseTSV, parseCSV, pasteOps, fillOps } from './sheetClipboard';
import { normalize, selCells, selIsSingle, type Selection } from './sheetSelection';
import { createToolbar, type ToolbarCallbacks } from './sheetToolbar';
import { createToolbar, type ToolbarCallbacks, type ToolbarElement } from './sheetToolbar';
import { createSheetTabs } from './sheetTabs';
import { sortRangeOps, distinctValues, hiddenRowsForFilter } from './sheetSortFilter';
import { createFormulaBar, type FormulaBarHandle } from './sheetFormulaBar';
Expand Down Expand Up @@ -73,6 +73,7 @@ export function startSheetEditor(root: HTMLElement): void {
// Client-local filter state (per active sheet, reset on switch — not collaborative).
let hiddenRows = new Set<number>();
let tabs: { el: HTMLElement; refresh: () => void } | null = null;
let toolbarEl: ToolbarElement | null = null;

const transport = {
send: (op: Op) =>
Expand Down Expand Up @@ -233,6 +234,7 @@ export function startSheetEditor(root: HTMLElement): void {
}
view?.render();
tabs?.refresh();
toolbarEl?.refreshHistory();
if (formulaBar) {
const { r0, c0, r1, c1 } = normalize(selection);
formulaBar.setActive(rangeRefA1(r0, c0, r1, c1), rawValue(selection.focus.row, selection.focus.col));
Expand Down Expand Up @@ -389,6 +391,9 @@ export function startSheetEditor(root: HTMLElement): void {
if (!readOnly) formulaBar?.beginFormula(`=${fn}(`);
},
fill: doFill,
undo: () => doHistory('undo'),
redo: () => doHistory('redo'),
history: () => ({ canUndo: collab?.canUndo() ?? false, canRedo: collab?.canRedo() ?? false }),
clear: (what: 'all' | 'formats' | 'contents') => {
if (readOnly || !collab) return;
blurActiveCell();
Expand Down Expand Up @@ -436,6 +441,7 @@ export function startSheetEditor(root: HTMLElement): void {
},
};
const toolbar = createToolbar(actions);
toolbarEl = toolbar;
formulaBar = createFormulaBar({
readOnly: data.readonly,
getFunctionNames: () => engine.functionNames(),
Expand Down Expand Up @@ -644,6 +650,15 @@ export function startSheetEditor(root: HTMLElement): void {
for (const op of pasteOps(grid, { row: r0, col: c0 }, activeSheetId, collab.rev)) collab.applyLocal(op);
});
};
// Undo/redo this client's own edits. Blur first so a half-typed cell does not
// get committed over the restored value by the blur handler.
const doHistory = (which: 'undo' | 'redo'): void => {
if (readOnly || !collab) return;
blurActiveCell();
if (which === 'undo') collab.undo();
else collab.redo();
Comment on lines +658 to +659

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Undo leaves invalid active sheet 🐞 Bug ≡ Correctness

Undoing an added sheet deletes it without updating activeSheetId. The editor remains pointed at a
nonexistent sheet, producing no active tab and an empty grid until another sheet is selected.
Agent Prompt
## Issue description
History replay can delete the currently active sheet without selecting a surviving sheet, leaving editor state inconsistent.

## Issue Context
After undo or redo, verify that `activeSheetId` still exists and select a valid fallback when it does not. Add coverage for adding and activating a sheet, undoing the addition, and verifying that another sheet becomes active.

## Fix Focus Areas
- ui/src/js/sheet/sheetEditor.ts[508-539]
- ui/src/js/sheet/sheetEditor.ts[653-660]
- ui/src/js/sheet/undo.ts[69-80]
- ui/src/js/sheet/sheetTabs.ts[37-45]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

};

// Fill the selection from its first row (down) or first column (right).
// fillOps adjusts relative references, so formulas fill like in Excel.
const doFill = (dir: 'down' | 'right'): void => {
Expand Down Expand Up @@ -671,6 +686,21 @@ export function startSheetEditor(root: HTMLElement): void {
doPaste();
return;
}
// Ctrl+Z / Ctrl+Y (and Ctrl+Shift+Z) — only outside cell editing, where the
// browser's own text undo still owns the keystroke.
if (mod && !editingNow() && !readOnly) {
const k = e.key.toLowerCase();
Comment on lines +691 to +692

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Formula undo triggers sheet history 🐞 Bug ≡ Correctness

The global undo/redo shortcut only checks whether the grid is editing, not whether the formula-bar
input is focused. Pressing Ctrl/Meta+Z while editing a formula therefore invokes workbook history
instead of the input's native text undo.
Agent Prompt
## Issue description
Workbook undo and redo intercept native text-history shortcuts while the user is typing in the formula bar.

## Issue Context
Guard global history shortcuts when the event target is an input, textarea, or editable element, or expose formula-bar editing state. Add browser coverage proving formula text changes locally without changing workbook history.

## Fix Focus Areas
- ui/src/js/sheet/sheetEditor.ts[689-702]
- ui/src/js/sheet/sheetFormulaBar.ts[71-80]
- ui/src/js/sheet/sheetFormulaBar.ts[119-130]
- playwright/specs/sheet_excel_chrome.spec.ts[162-180]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

if (k === 'z' && !e.shiftKey) {
e.preventDefault();
doHistory('undo');
return;
}
if (k === 'y' || (k === 'z' && e.shiftKey)) {
e.preventDefault();
doHistory('redo');
return;
}
}
// Ctrl+D / Ctrl+R fill the selection from its first row / column, like Excel.
if (mod && !editingNow() && !readOnly && (e.key === 'd' || e.key === 'D' || e.key === 'r' || e.key === 'R')) {
e.preventDefault();
Expand Down
31 changes: 29 additions & 2 deletions ui/src/js/sheet/sheetToolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export interface ToolbarCallbacks {
clear?: (what: 'all' | 'formats' | 'contents') => void;
// Ribbon: View toggles that only affect this client (never sent on the wire).
viewOption?: (opt: 'gridlines' | 'headings' | 'zoom', value: boolean | number) => void;
// Undo/redo of this client's own edits. history() drives the button states;
// the editor calls the returned refresh() whenever the workbook changes.
undo?: () => void;
redo?: () => void;
history?: () => { canUndo: boolean; canRedo: boolean };
// Merge/unmerge the current selection (the editor decides which).
mergeToggle?: () => void;
}
Expand Down Expand Up @@ -100,9 +105,15 @@ const IC = {
fillDown: '<rect x="3.5" y="1.5" width="9" height="3.5"/><path d="M8 6.5V13M5.5 10.5 8 13l2.5-2.5"/>',
fillRight: '<rect x="1.5" y="3.5" width="3.5" height="9"/><path d="M6.5 8H13M10.5 5.5 13 8l-2.5 2.5"/>',
clear: '<path d="M3 13h10"/><path d="m5.5 10.5 6-6a1.5 1.5 0 0 0-2-2l-6 6z"/><path d="M9 3.5 12.5 7"/>',
undo: '<path d="M3 8h7a3.5 3.5 0 0 1 0 7H6"/><path d="M5.5 5 2.5 8l3 3"/>',
redo: '<path d="M13 8H6a3.5 3.5 0 0 0 0 7h4"/><path d="M10.5 5l3 3-3 3"/>',
};

export function createToolbar(cb: ToolbarCallbacks): HTMLElement {
// The returned element carries refreshHistory(): the editor calls it after every
// workbook change so the undo/redo buttons show whether there is anything left.
export type ToolbarElement = HTMLElement & { refreshHistory: () => void };

export function createToolbar(cb: ToolbarCallbacks): ToolbarElement {
if (!document.getElementById('sheet-toolbar-style')) {
const s = document.createElement('style');
s.id = 'sheet-toolbar-style';
Expand Down Expand Up @@ -284,6 +295,22 @@ export function createToolbar(cb: ToolbarCallbacks): HTMLElement {
return b;
};

// --- Home: Undo (Excel keeps these in the quick-access bar; same actions) ---
let refreshHistory = (): void => {};
if (cb.undo && cb.redo) {
const hist = row(group('Home', 'Undo'));
const undoBtn = btn(hist, { icon: IC.undo }, 'Undo (Ctrl+Z)', () => cb.undo?.());
const redoBtn = btn(hist, { icon: IC.redo }, 'Redo (Ctrl+Y)', () => cb.redo?.());
refreshHistory = () => {
const state = cb.history?.() ?? { canUndo: false, canRedo: false };
undoBtn.disabled = !state.canUndo;
redoBtn.disabled = !state.canRedo;
undoBtn.style.opacity = state.canUndo ? '' : '0.4';
redoBtn.style.opacity = state.canRedo ? '' : '0.4';
};
refreshHistory();
}

// --- Home: Clipboard ---
if (cb.clipboardAction) {
const clip = group('Home', 'Clipboard');
Expand Down Expand Up @@ -541,5 +568,5 @@ export function createToolbar(cb: ToolbarCallbacks): HTMLElement {
}

selectTab('Home');
return bar;
return Object.assign(bar, { refreshHistory: () => refreshHistory() });
}
Loading
Loading