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
| Field | Required | Description |
|---|---|---|
columns | Yes | Array of column header strings |
rows | Yes | Array of row arrays — same shape as table |
editable | No | Map of column name to input spec. Columns not in the map are read-only. |
rowOps | No | { 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
| Argument | Description |
|---|---|
edit | A discriminated union (see below) |
content | The current file content as a string — read fresh from the CRDT |
context | Same 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.