Editable Tables

Build renderer plugins with editable table cells, add/delete row controls, and write-back to the source file.

editableTable is a table output type whose cells the user can edit. When a user makes a change, the host calls the plugin's onEdit(edit, content, context) hook and writes the returned string back to the file in the CRDT.

The plugin owns the file format. The host has no idea how to serialize CSV, TSV, INI, etc. — your onEdit is what turns a structural edit back into bytes.

Fields

FieldRequiredDescription
columnsYesArray of column header strings
rowsYesArray of row arrays — same shape as table
editableNoMap of column name to input spec. Columns not in the map are read-only.
rowOpsNo{ add?: boolean, delete?: boolean } — toggles add-row and delete-row controls

Each editable column spec is one of:

{ type: 'text' }
{ type: 'number' }
{ type: 'select', options: ['low', 'medium', 'high'] }

onEdit protocol

onEdit(edit, content, context) is called once per user action (cell blur, add-row click, delete-row click).

Arguments

ArgumentDescription
editA discriminated union (see below)
contentThe current file content as a string — read fresh from the CRDT
contextSame shape as render()'s context: { filePath, fileContent }

Edit types

// Cell edit
{ kind: 'cell', rowIndex: number, columnIndex: number, columnName: string, newValue: string }

// Add row
{ kind: 'addRow' }

// Delete row
{ kind: 'deleteRow', rowIndex: number }

Return value

Return { content: 'new file bytes' } to commit the change.

Return { content: '...', error: 'message' } to surface a validation message in the UI without writing.

Full example

// @stoa-plugin
// @name CSV Editor
// @type renderer
// @icon Table
// @filetype *.csv,*.tsv

module.exports = {
  render(content, context) {
    const delimiter = context.filePath.endsWith('.tsv') ? '\t' : ','
    const lines = content.split('\n').filter(l => l.trim())
    if (lines.length === 0) {
      return { type: 'empty', message: 'Empty file' }
    }

    const headers = lines[0].split(delimiter)
    const rows = lines.slice(1).map(l => l.split(delimiter))

    return {
      type: 'editableTable',
      columns: headers,
      rows,
      editable: Object.fromEntries(headers.map(h => [h, { type: 'text' }])),
      rowOps: { add: true, delete: true },
    }
  },

  onEdit(edit, content, context) {
    const delimiter = context.filePath.endsWith('.tsv') ? '\t' : ','
    const lines = content.split('\n').filter(l => l.trim())
    const headers = lines[0].split(delimiter)
    const rows = lines.slice(1).map(l => l.split(delimiter))

    if (edit.kind === 'cell') {
      rows[edit.rowIndex][edit.columnIndex] = edit.newValue
    } else if (edit.kind === 'addRow') {
      rows.push(headers.map(() => ''))
    } else if (edit.kind === 'deleteRow') {
      rows.splice(edit.rowIndex, 1)
    }

    return {
      content: [headers.join(delimiter), ...rows.map(r => r.join(delimiter))].join('\n')
    }
  }
}

Concurrency

Edits are fire-and-forget. The host writes the returned content to the CRDT and the next render pass picks up the canonical bytes. If two users edit at once, Automerge merges the resulting text changes. For concurrent editing of the same cell, the last write wins.