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
56 changes: 56 additions & 0 deletions docs/superpowers/plans/2026-07-02-excel-m4-structural.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# M4 — Structural / grid polish (multiple sheets, resize, freeze, sort/filter)

Implements the M4 section of `docs/superpowers/specs/2026-07-01-excel-spreadsheet-design.md`.
Branch `feat/excel-m4-structural`, stacked on `fix/sheet-typing-ws` (PR #324).

## Ops (Go `lib/sheet` + TS mirror)

- **Sheet list**: `addSheet` (sheet=new id, name, index), `renameSheet`, `deleteSheet`,
`moveSheet` (toIndex). Convergence rules: duplicate add = first-wins no-op, deleting
the last sheet = no-op, and **any op on a missing sheet is a silent no-op** (a late op
after a concurrent `deleteSheet` must not poison the ordered-log replay — this changed
`Apply`'s old unknown-sheet error).
- **`setDimension`** (axis `col`/`row`, index, sizePx 1..4096): sparse
`ColWidths`/`RowHeights` maps on `Sheet`; structural insert/delete shifts the maps
(in-band deletes drop overrides); `Transform` shifts `setDimension.Index` on the
matching axis.
- **`setFreeze`** (frozenRows/frozenCols, 0|1): per-sheet metadata; arbitrary freeze is
the upgrade path.
- Snapshot round-trips all new metadata (`colWidths`/`rowHeights` JSON objects with
stringified indices, `frozenRows`/`frozenCols`).

## View / UI

- Grid grown to **200×52** (`ponytail:` DOM-node-per-cell; virtualization is the upgrade
path, comment in `sheetView.ts`).
- **Resize**: drag grips on the header cells (`.sheet-resizer-col/-row`), live preview,
one `setDimension` op on mouseup. Column overrides also relax the per-cell
`min-width: 80px` default.
- **Freeze**: `border-collapse: separate` (sticky drops collapsed borders) with
right/bottom-only 1px borders; `.sheet-frozen-r/-c` classes + `--fr-top`/`--fc-left`
CSS vars measured at render.
- **Tabs bar** (`sheetTabs.ts`): click switch, dblclick rename (native prompt),
right-click delete (native confirm, disabled for the last sheet), HTML5 drag reorder,
`+` add. The client-local filter resets on sheet switch.
- **Sort** (`sheetSortFilter.ts`): A→Z / Z→A toolbar buttons sort the selected range by
the focused column as a batch of `setCell` ops; moved formulas shift row refs via the
fill heuristic (`adjustFormula`). Numbers sort numerically, empties always last.
- **Filter**: toolbar dropdown of the focused column's distinct values; hides
non-matching rows client-side (blank rows stay visible). Not collaborative in v1 —
collaborative filter is the upgrade path.

## Testing

- Go: `lib/sheet/structural_test.go` (apply/validate/transform/convergence/snapshot,
dim shifting). `TestApplyUnknownSheet` now asserts the no-op semantics.
- TS: `structural.test.ts` (op mirror), `sheetSortFilter.test.ts` (sort batch, formula
shift, distinct/hide predicates).
- E2E: `playwright/specs/sheet_structural.spec.ts` — tabs add/switch/rename with
per-sheet data isolation, column resize persisting across reload, sticky frozen row,
A→Z sort, filter hide/clear. Ran live 5/5 green (plus the 10 existing sheet specs).

## Known ceilings (deliberate)

- Filter: one active column filter, client-local.
- Freeze: first row / first col only.
- No virtualization; 200×52 is the practical grid bound for the DOM view.
58 changes: 56 additions & 2 deletions lib/sheet/apply.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package sheet

import "fmt"
import (
"fmt"
"slices"
)

// Apply mutates the workbook by op. The op is assumed already rebased to the
// current revision (see reconcile.go). Cell ops are last-writer-wins; the
Expand All @@ -9,9 +12,47 @@ func (w *Workbook) Apply(op Op) error {
if err := op.Validate(); err != nil {
return err
}

// Sheet-list ops manage w.Sheets itself and never need an existing sheet.
switch op.Type {
case OpAddSheet:
if w.SheetByID(op.Sheet) != nil {
return nil // concurrent duplicate add: first wins
}
w.Sheets = slices.Insert(w.Sheets, min(op.Index, len(w.Sheets)), NewSheet(op.Sheet, op.Name))
return nil
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
case OpDeleteSheet:
if len(w.Sheets) <= 1 {
return nil // never delete the last sheet
}
for i, s := range w.Sheets {
if s.Id == op.Sheet {
w.Sheets = slices.Delete(w.Sheets, i, i+1)
return nil
}
}
return nil
case OpRenameSheet:
if s := w.SheetByID(op.Sheet); s != nil {
s.Name = op.Name
}
return nil
case OpMoveSheet:
for i, s := range w.Sheets {
if s.Id == op.Sheet {
rest := slices.Delete(slices.Clone(w.Sheets), i, i+1)
w.Sheets = slices.Insert(rest, min(op.ToIndex, len(rest)), s)
return nil
}
}
return nil
}

s := w.SheetByID(op.Sheet)
if s == nil {
return fmt.Errorf("apply: unknown sheet %q", op.Sheet)
// The sheet was deleted by an op ordered earlier; late ops targeting it
// converge as no-ops instead of poisoning the ordered-log replay.
return nil
}
switch op.Type {
case OpSetCell:
Expand Down Expand Up @@ -47,13 +88,23 @@ func (w *Workbook) Apply(op Op) error {
delete(s.Cells, ref)
}
}
case OpSetDimension:
if op.Axis == "col" {
s.ColWidths[op.Index] = op.Size
} else {
s.RowHeights[op.Index] = op.Size
}
case OpSetFreeze:
s.FrozenRows = op.FrozenRows
s.FrozenCols = op.FrozenCols
case OpInsertRows:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Row >= op.Index {
return CellRef{r.Row + op.Count, r.Col}, true
}
return r, true
})
s.RowHeights = shiftDims(s.RowHeights, op.Index, op.Count)
case OpDeleteRows:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Row >= op.Index && r.Row < op.Index+op.Count {
Expand All @@ -64,13 +115,15 @@ func (w *Workbook) Apply(op Op) error {
}
return r, true
})
s.RowHeights = shiftDims(s.RowHeights, op.Index, -op.Count)
case OpInsertCols:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Col >= op.Index {
return CellRef{r.Row, r.Col + op.Count}, true
}
return r, true
})
s.ColWidths = shiftDims(s.ColWidths, op.Index, op.Count)
case OpDeleteCols:
s.remap(func(r CellRef) (CellRef, bool) {
if r.Col >= op.Index && r.Col < op.Index+op.Count {
Expand All @@ -81,6 +134,7 @@ func (w *Workbook) Apply(op Op) error {
}
return r, true
})
s.ColWidths = shiftDims(s.ColWidths, op.Index, -op.Count)
default:
return fmt.Errorf("apply: unhandled op type %q", op.Type)
}
Expand Down
6 changes: 4 additions & 2 deletions lib/sheet/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ func TestApplyInsertColsShiftsCells(t *testing.T) {
}

func TestApplyUnknownSheet(t *testing.T) {
// No-op, not an error: after a deleteSheet, late ops targeting the gone
// sheet must not poison the ordered-log replay (M4 convergence rule).
w := mkWB(t)
if err := w.Apply(Op{Type: OpSetCell, Sheet: "nope", Row: 0, Col: 0, Raw: ptr("x")}); err == nil {
t.Fatal("apply to unknown sheet must error")
if err := w.Apply(Op{Type: OpSetCell, Sheet: "nope", Row: 0, Col: 0, Raw: ptr("x")}); err != nil {
t.Fatalf("apply to unknown sheet must be a silent no-op, got %v", err)
}
}

Expand Down
53 changes: 52 additions & 1 deletion lib/sheet/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ const (
OpDeleteRows OpType = "deleteRows"
OpInsertCols OpType = "insertCols"
OpDeleteCols OpType = "deleteCols"
// Sheet-list ops: Op.Sheet names the target sheet id.
OpAddSheet OpType = "addSheet"
OpRenameSheet OpType = "renameSheet"
OpDeleteSheet OpType = "deleteSheet"
OpMoveSheet OpType = "moveSheet"
// Grid metadata ops.
OpSetDimension OpType = "setDimension"
OpSetFreeze OpType = "setFreeze"
)

// Op is one cell-based operation. BaseRev is the workbook revision the client
Expand Down Expand Up @@ -42,9 +50,22 @@ type Op struct {
// When present, Apply interns them and sets the cell's StyleId to the result.
Props map[string]string `json:"props,omitempty"`

// Structural ops (insert/delete rows/cols).
// Structural ops (insert/delete rows/cols). Index doubles as the insertion
// position for addSheet.
Index int `json:"index,omitempty"`
Count int `json:"count,omitempty"`

// Sheet-list ops.
Name string `json:"name,omitempty"` // addSheet, renameSheet
ToIndex int `json:"toIndex,omitempty"` // moveSheet

// setDimension.
Axis string `json:"axis,omitempty"` // "col" or "row"
Size int `json:"size,omitempty"` // px

// setFreeze. 0 or 1 each (freeze first row / first col only for now).
FrozenRows int `json:"frozenRows,omitempty"`
FrozenCols int `json:"frozenCols,omitempty"`
}

func (o Op) isStructural() bool {
Expand Down Expand Up @@ -92,6 +113,36 @@ func (o Op) Validate() error {
if o.Count <= 0 {
return fmt.Errorf("%s count must be > 0", o.Type)
}
case OpAddSheet, OpRenameSheet:
if o.Name == "" {
return fmt.Errorf("%s needs a name", o.Type)
}
if len(o.Name) > 128 {
return fmt.Errorf("%s name too long", o.Type)
}
if o.Index < 0 {
return fmt.Errorf("%s negative index", o.Type)
}
case OpDeleteSheet:
// Last-sheet protection is stateful and enforced in Apply.
case OpMoveSheet:
if o.ToIndex < 0 {
return fmt.Errorf("moveSheet negative toIndex")
}
case OpSetDimension:
if o.Axis != "col" && o.Axis != "row" {
return fmt.Errorf("setDimension axis must be col or row")
}
if o.Index < 0 {
return fmt.Errorf("setDimension negative index")
}
if o.Size <= 0 || o.Size > 4096 {
return fmt.Errorf("setDimension size out of range")
}
case OpSetFreeze:
if o.FrozenRows < 0 || o.FrozenRows > 1 || o.FrozenCols < 0 || o.FrozenCols > 1 {
return fmt.Errorf("setFreeze supports only 0 or 1 frozen rows/cols")
}
default:
return fmt.Errorf("unknown op type %q", o.Type)
}
Expand Down
35 changes: 31 additions & 4 deletions lib/sheet/sheet.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
package sheet

import "maps"

// Sheet is a single tab: sparse cells plus structural metadata.
type Sheet struct {
Id string `json:"id"`
Name string `json:"name"`
Cells map[CellRef]Cell `json:"-"` // sparse; JSON handled by the snapshot/persistence layer
// Sparse per-index pixel overrides; unset = view default.
ColWidths map[int]int `json:"-"`
RowHeights map[int]int `json:"-"`
// 0 or 1 each: freeze the first row / first col (position: sticky in the view).
FrozenRows int `json:"-"`
FrozenCols int `json:"-"`
}

func NewSheet(id, name string) *Sheet {
return &Sheet{Id: id, Name: name, Cells: map[CellRef]Cell{}}
return &Sheet{Id: id, Name: name, Cells: map[CellRef]Cell{}, ColWidths: map[int]int{}, RowHeights: map[int]int{}}
}

// SetCell stores a cell, dropping it from storage if empty (keeps it sparse).
Expand All @@ -26,13 +34,32 @@ func (s *Sheet) GetCell(ref CellRef) Cell {
}

func (s *Sheet) clone() *Sheet {
cp := &Sheet{Id: s.Id, Name: s.Name, Cells: make(map[CellRef]Cell, len(s.Cells))}
for k, v := range s.Cells {
cp.Cells[k] = v
cp := &Sheet{
Id: s.Id, Name: s.Name, Cells: make(map[CellRef]Cell, len(s.Cells)),
ColWidths: maps.Clone(s.ColWidths), RowHeights: maps.Clone(s.RowHeights),
FrozenRows: s.FrozenRows, FrozenCols: s.FrozenCols,
}
maps.Copy(cp.Cells, s.Cells)
return cp
}

// shiftDims rebuilds a sparse dimension map after an insert/delete at index.
// delta > 0 inserts (indices at/after move up); delta < 0 deletes a band of
// -delta indices (entries inside the band are dropped).
func shiftDims(m map[int]int, index, delta int) map[int]int {
if len(m) == 0 {
return m
}
next := make(map[int]int, len(m))
for i, v := range m {
if delta < 0 && i >= index && i < index-delta {
continue // deleted band
}
next[shiftCoord(i, index, delta)] = v
}
return next
}

// remap rebuilds the sparse cell map by transforming each ref. The fn returns
// the new ref and whether to keep the cell. Used by structural row/col ops.
func (s *Sheet) remap(fn func(CellRef) (CellRef, bool)) {
Expand Down
24 changes: 22 additions & 2 deletions lib/sheet/snapshot.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package sheet

import "sort"
import (
"maps"
"sort"
)

// CellSnapshot is the serializable form of one populated cell (map keys can't
// be JSON-encoded, so cells become a flat slice).
Expand All @@ -17,6 +20,11 @@ type SheetSnapshot struct {
Id string `json:"id"`
Name string `json:"name"`
Cells []CellSnapshot `json:"cells"`
// Sparse dimension overrides; JSON object keys are stringified indices.
ColWidths map[int]int `json:"colWidths,omitempty"`
RowHeights map[int]int `json:"rowHeights,omitempty"`
FrozenRows int `json:"frozenRows,omitempty"`
FrozenCols int `json:"frozenCols,omitempty"`
}

// WorkbookSnapshot is the JSON-serializable form of a Workbook for persistence.
Expand All @@ -40,7 +48,16 @@ func (w *Workbook) Snapshot() WorkbookSnapshot {
}
return cells[a].Col < cells[b].Col
})
out.Sheets[i] = SheetSnapshot{Id: s.Id, Name: s.Name, Cells: cells}
ss := SheetSnapshot{Id: s.Id, Name: s.Name, Cells: cells, FrozenRows: s.FrozenRows, FrozenCols: s.FrozenCols}
// Clone: snapshots are consumed after the document lock is released
// (export), so aliasing the live maps would race with Apply().
if len(s.ColWidths) > 0 {
ss.ColWidths = maps.Clone(s.ColWidths)
}
if len(s.RowHeights) > 0 {
ss.RowHeights = maps.Clone(s.RowHeights)
}
Comment on lines +51 to +59

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

2. Snapshot shares mutable maps 🐞 Bug ☼ Reliability

Workbook.Snapshot() stores ColWidths/RowHeights map references directly in the snapshot, so
the snapshot can be mutated after sheetdoc.Manager.Snapshot() unlocks but before sendSheetVars
marshals, creating an inconsistent snapshot relative to the returned head revision.
Agent Prompt
### Issue description
`Workbook.Snapshot()` assigns `s.ColWidths`/`s.RowHeights` directly into the returned snapshot. Because these are reference types, callers can observe later mutations through the snapshot, and there is a real window where the snapshot can become inconsistent with the `head` revision returned by `sheetdoc.Manager.Snapshot()`.

### Issue Context
`sheetdoc.Manager.Snapshot()` returns the snapshot and head under a mutex, but `sendSheetVars()` marshals the snapshot after the mutex is released. If a concurrent op mutates a sheet’s dimension maps between return and marshal, JSON encoding may see a newer map state while still advertising the older `head`.

### Fix Focus Areas
- lib/sheet/snapshot.go[38-60]
- lib/sheetdoc/manager.go[138-148]
- lib/ws/SheetHandler.go[233-243]

### Suggested fix
- In `Workbook.Snapshot()`, deep-copy the new maps before assigning:
  - `ss.ColWidths = maps.Clone(s.ColWidths)`
  - `ss.RowHeights = maps.Clone(s.RowHeights)`
- (Optional) Consider similarly deep-copying any other mutable reference fields that can be mutated concurrently with snapshot marshalling, or ensure the snapshot is fully materialized into immutable data before releasing locks.

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

out.Sheets[i] = ss
}
return out
}
Expand All @@ -66,6 +83,9 @@ func WorkbookFromSnapshot(snap WorkbookSnapshot) *Workbook {
for _, c := range ss.Cells {
sh.Cells[CellRef{c.Row, c.Col}] = Cell{Raw: c.Raw, Value: c.Value, ValueType: c.ValueType, StyleId: c.StyleId}
}
maps.Copy(sh.ColWidths, ss.ColWidths)
maps.Copy(sh.RowHeights, ss.RowHeights)
sh.FrozenRows, sh.FrozenCols = ss.FrozenRows, ss.FrozenCols
w.Sheets[i] = sh
}
return w
Expand Down
Loading
Loading