Repository navigation
Expand file tree
/
Copy pathvite-plugin-notebook-editor.ts
More file actions
888 lines (805 loc) · 34.6 KB
/
Copy pathvite-plugin-notebook-editor.ts
File metadata and controls
888 lines (805 loc) · 34.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
/**
* The editor server: dev-only Vite middleware that parses pages with Babel
* and applies structured edits as surgical, format-preserving splices to
* the JSX source. Bytes outside the edited span never change.
*
* GET /__editor/page?slug=<slug> → PagePayload
* POST /__editor/apply {slug, hash, op, defer} → PagePayload (fresh)
* POST /__editor/flush {slug} → releases deferred HMR
*
* Autosave (`defer: true`) writes the file but suppresses its HMR update —
* otherwise every debounced save would re-render the block under the
* user's caret. When the edit session ends the client flushes, and the
* queued HMR update fires once.
*
* Ops are index-based: client block order == AST block order == DOM order
* (the same positional identity React itself uses). The `hash` guards
* against applying ops to a file that changed under the client.
*/
import { promises as fs } from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { parse } from '@babel/parser'
import type * as t from '@babel/types'
import type { Plugin, ViteDevServer } from 'vite'
// ---------------------------------------------------------------------------
// Types shared with the client (kept in sync by hand — small surface)
// ---------------------------------------------------------------------------
export interface Span {
start: number
end: number
}
export interface BlockInfo {
index: number
tag: string
span: Span
/** Children region between the open/close tags; null when self-closing. */
inner: Span | null
/** Spans of JSXElement children, in order — the inline "islands". */
elements: Span[]
/** Whether in-place text editing is supported for this block. */
editable: boolean
/**
* Span of the block's top-level unit: itself, or the <Columns> wrapper it
* lives in. Top-level vertical moves anchor on this, never inside columns.
*/
top: Span
}
export interface PagePayload {
slug: string
file: string
source: string
hash: string
blocks: BlockInfo[]
canUndo: boolean
canRedo: boolean
}
export type BlockKind = 'p' | 'h2' | 'h3' | 'callout' | 'sql'
export type EditOp =
| { type: 'replaceInner'; index: number; text: string }
/**
* Insert after a block, at its own level — inside its column when it lives
* in one (Enter / + in a column stays in the column). With `topLevel`,
* insert after the block's whole unit at the top level instead (click
* below the page → full width, even when the last unit is a Columns).
*/
| { type: 'insert'; afterIndex: number; kind: BlockKind; topLevel?: boolean }
| { type: 'replaceBlock'; index: number; kind: BlockKind }
| { type: 'delete'; index: number }
| { type: 'move'; from: number; before: number | null }
/** Move a block beside another at ITS level — into/within a column. */
| { type: 'moveInto'; from: number; anchor: number; pos: 'before' | 'after' }
/** Set a component prop to a template-literal value (e.g. a Query's sql). */
| { type: 'setProp'; index: number; name: string; value: string }
/** Drop a block beside another → wrap both in a 2-column layout. */
| { type: 'columnize'; from: number; target: number; side: 'left' | 'right' }
/**
* Merge block[index] into block[index-1] (Backspace at start / Delete at
* end). `prevText`/`text` carry unsaved live content; omitted = use disk.
*/
| { type: 'mergeUp'; index: number; text?: string; prevText?: string }
| { type: 'duplicate'; index: number }
// ---------------------------------------------------------------------------
// Page parsing
// ---------------------------------------------------------------------------
const TEXT_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'p', 'blockquote'])
/** Components that render {children} inside a [data-nb-children] container. */
const CONTAINER_COMPONENTS = new Set(['Note', 'Callout'])
/** New blocks are empty; the editor opens them with a placeholder caret. */
const SNIPPETS: Record<BlockKind, string> = {
p: '<p></p>',
h2: '<h2></h2>',
h3: '<h3></h3>',
callout: '<Callout></Callout>',
sql: '<Query sql={`SELECT event, COUNT(*) AS count\nFROM events\nGROUP BY event ORDER BY count DESC`} />',
}
/** Imports each snippet needs, added to the page when the block is created. */
const SNIPPET_IMPORTS: Partial<Record<BlockKind, { name: string; from: string }>> = {
callout: { name: 'Callout', from: '@/components/notebook' },
sql: { name: 'Query', from: '@/components/analytics' },
}
function hashSource(source: string): string {
return crypto.createHash('sha1').update(source).digest('hex').slice(0, 12)
}
function parseAst(source: string): t.File {
return parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript'] })
}
function jsxName(el: t.JSXElement): string {
const name = el.openingElement.name
return name.type === 'JSXIdentifier' ? name.name : ''
}
function findPageElement(ast: t.File): t.JSXElement | null {
for (const node of ast.program.body) {
if (node.type !== 'ExportDefaultDeclaration') continue
const decl = node.declaration
let fnBody: t.BlockStatement | t.Expression | null = null
if (decl.type === 'FunctionDeclaration') fnBody = decl.body
else if (decl.type === 'ArrowFunctionExpression') fnBody = decl.body
if (!fnBody) return null
let returned: t.Node | null = null
if (fnBody.type === 'BlockStatement') {
for (const stmt of fnBody.body) {
if (stmt.type === 'ReturnStatement' && stmt.argument) returned = stmt.argument
}
} else {
returned = fnBody
}
if (returned?.type === 'JSXElement' && jsxName(returned) === 'Page') return returned
}
return null
}
/**
* Blocks are a depth-first flattening: top-level page children, with
* <Columns> wrappers transparent — their <Column> grandchildren's blocks
* take the wrapper's place in the sequence. The flat index stays the only
* identity; the same traversal order is mirrored client-side over the DOM.
*/
/** The flat element list in block order — single source of truth. */
function elementsInBlockOrder(page: t.JSXElement): { el: t.JSXElement; top: t.JSXElement }[] {
const out: { el: t.JSXElement; top: t.JSXElement }[] = []
for (const child of page.children) {
if (child.type !== 'JSXElement') continue
if (jsxName(child) === 'Columns') {
for (const col of child.children) {
if (col.type !== 'JSXElement' || jsxName(col) !== 'Column') continue
for (const inner of col.children) {
if (inner.type === 'JSXElement') out.push({ el: inner, top: child })
}
}
} else {
out.push({ el: child, top: child })
}
}
return out
}
function extractBlocks(source: string): BlockInfo[] {
const page = findPageElement(parseAst(source))
if (!page) return []
const out: BlockInfo[] = []
const push = (el: t.JSXElement, top: t.JSXElement) => {
const tag = jsxName(el)
const inner: Span | null = el.closingElement
? { start: el.openingElement.end!, end: el.closingElement.start! }
: null
const elements = el.children
.filter((c): c is t.JSXElement => c.type === 'JSXElement')
.map((c) => ({ start: c.start!, end: c.end! }))
out.push({
index: out.length,
tag,
span: { start: el.start!, end: el.end! },
inner,
elements,
editable: inner !== null && (TEXT_TAGS.has(tag) || CONTAINER_COMPONENTS.has(tag)),
top: { start: top.start!, end: top.end! },
})
}
for (const { el, top } of elementsInBlockOrder(page)) push(el, top)
return out
}
// ---------------------------------------------------------------------------
// Edits
// ---------------------------------------------------------------------------
function lineStartOf(source: string, pos: number): number {
return source.lastIndexOf('\n', pos - 1) + 1
}
function indentOf(source: string, pos: number): string {
const ls = lineStartOf(source, pos)
const m = /^[ \t]*/.exec(source.slice(ls, pos))
return m ? m[0] : ''
}
/** The block's full lines: [line start, end of last line incl. newline). */
function blockLines(source: string, block: BlockInfo): Span {
const start = lineStartOf(source, block.span.start)
const lineEnd = source.indexOf('\n', block.span.end)
return { start, end: lineEnd === -1 ? source.length : lineEnd + 1 }
}
/** Like blockLines, but for an arbitrary span. */
function spanLines(source: string, span: Span): Span {
const start = lineStartOf(source, span.start)
const lineEnd = source.indexOf('\n', span.end)
return { start, end: lineEnd === -1 ? source.length : lineEnd + 1 }
}
/** Shift a chunk of full lines from one indentation level to another. */
function reindentChunk(chunk: string, oldIndent: string, newIndent: string): string {
if (oldIndent === newIndent) return chunk
return chunk
.split('\n')
.map((line) => {
if (line.trim() === '') return line
if (line.startsWith(oldIndent)) return newIndent + line.slice(oldIndent.length)
return line
})
.join('\n')
}
/** Extend block lines with one adjacent blank line (after, else before). */
function blockLinesWithGap(source: string, block: BlockInfo): Span {
const { start, end } = blockLines(source, block)
const nextLineEnd = source.indexOf('\n', end)
if (nextLineEnd !== -1 && source.slice(end, nextLineEnd).trim() === '') {
return { start, end: nextLineEnd + 1 }
}
const prevLineStart = lineStartOf(source, start - 1)
if (source.slice(prevLineStart, start).trim() === '') return { start: prevLineStart, end }
return { start, end }
}
function applyOp(source: string, blocks: BlockInfo[], op: EditOp): string {
if (op.type === 'replaceInner') {
const block = blocks[op.index]
if (!block?.inner) throw new Error(`block ${op.index} is not text-editable`)
return source.slice(0, block.inner.start) + op.text + source.slice(block.inner.end)
}
if (op.type === 'insert') {
const block = blocks[op.afterIndex]
if (!block) throw new Error(`no block at index ${op.afterIndex}`)
const anchor = op.topLevel ? block.top : block.span
const indent = indentOf(source, anchor.start)
const lineEnd = source.indexOf('\n', anchor.end)
const at = lineEnd === -1 ? anchor.end : lineEnd
let next = source.slice(0, at) + `\n\n${indent}${SNIPPETS[op.kind]}` + source.slice(at)
const imp = SNIPPET_IMPORTS[op.kind]
if (imp) next = ensureNamedImport(next, imp.name, imp.from)
return next
}
if (op.type === 'replaceBlock') {
const block = blocks[op.index]
if (!block) throw new Error(`no block at index ${op.index}`)
let next =
source.slice(0, block.span.start) + SNIPPETS[op.kind] + source.slice(block.span.end)
const imp = SNIPPET_IMPORTS[op.kind]
if (imp) next = ensureNamedImport(next, imp.name, imp.from)
return removeUnusedNamedImports(next)
}
if (op.type === 'setProp') {
// re-find the element's attribute via the AST and replace its value —
// surgical, like every other op. Plain values become string attrs
// (chart="bar"); multiline/awkward ones become template literals.
const page = findPageElement(parseAst(source))
const el = page ? elementsInBlockOrder(page)[op.index]?.el : undefined
if (!el) throw new Error(`no block at index ${op.index}`)
let literal: string
if (/[\n"`\\]|\$\{/.test(op.value)) {
const escaped = op.value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
literal = `{\`${escaped}\`}`
} else {
literal = `"${op.value}"`
}
const attr = el.openingElement.attributes.find(
(a): a is t.JSXAttribute => a.type === 'JSXAttribute' && a.name.name === op.name
)
if (attr?.value) {
return source.slice(0, attr.value.start!) + literal + source.slice(attr.value.end!)
}
const at = el.openingElement.name.end!
return source.slice(0, at) + ` ${op.name}=${literal}` + source.slice(at)
}
if (op.type === 'move') {
const { from, before } = op
const block = blocks[from]
if (!block) throw new Error(`no block at index ${from}`)
if (before === from) return source
const lines = blockLines(source, block)
const fromIndent = indentOf(source, block.span.start)
const cut = blockLinesWithGap(source, block)
// vertical moves land at the top level: anchor on the target's unit
// (its <Columns> wrapper when it lives in one), never inside a column
let at: number
let destIndent: string
if (before === null) {
const lastTop = spanLines(source, blocks[blocks.length - 1].top)
at = lastTop.end
destIndent = indentOf(source, blocks[blocks.length - 1].top.start)
} else {
const target = blocks[before]
if (!target) throw new Error(`no block at index ${before}`)
at = lineStartOf(source, target.top.start)
destIndent = indentOf(source, target.top.start)
}
const chunk = reindentChunk(source.slice(lines.start, lines.end), fromIndent, destIndent)
const text = before === null ? '\n' + chunk : chunk + '\n'
if (at <= cut.start) {
return source.slice(0, at) + text + source.slice(at, cut.start) + source.slice(cut.end)
}
if (at < cut.end) return source // anchor inside the cut — degenerate, no-op
return source.slice(0, cut.start) + source.slice(cut.end, at) + text + source.slice(at)
}
if (op.type === 'moveInto') {
const fromB = blocks[op.from]
const anchorB = blocks[op.anchor]
if (!fromB || !anchorB) throw new Error('bad moveInto indexes')
if (op.from === op.anchor) return source
const lines = blockLines(source, fromB)
const cut = blockLinesWithGap(source, fromB)
const destIndent = indentOf(source, anchorB.span.start)
const chunk = reindentChunk(
source.slice(lines.start, lines.end),
indentOf(source, fromB.span.start),
destIndent
)
const anchorLines = blockLines(source, anchorB)
const at = op.pos === 'before' ? anchorLines.start : anchorLines.end
// anchor point swallowed by the cut → the block is already there; no-op
if (at >= cut.start && at <= cut.end) return source
const text = op.pos === 'before' ? chunk + '\n' : '\n' + chunk
if (at <= cut.start) {
return source.slice(0, at) + text + source.slice(at, cut.start) + source.slice(cut.end)
}
return source.slice(0, cut.start) + source.slice(cut.end, at) + text + source.slice(at)
}
if (op.type === 'columnize') {
const fromB = blocks[op.from]
const targetB = blocks[op.target]
if (!fromB || !targetB) throw new Error('bad columnize indexes')
if (op.from === op.target) return source
if (targetB.top.start !== targetB.span.start) {
throw new Error('columnize target must be a top-level block')
}
const indent = indentOf(source, targetB.span.start)
const colIndent = indent + ' '
const innerIndent = colIndent + ' '
const fromLines = blockLines(source, fromB)
const fromChunk = reindentChunk(
source.slice(fromLines.start, fromLines.end),
indentOf(source, fromB.span.start),
innerIndent
)
const cut = blockLinesWithGap(source, fromB)
const targetLines = blockLines(source, targetB)
const targetChunk = reindentChunk(
source.slice(targetLines.start, targetLines.end),
indent,
innerIndent
)
const [left, right] = op.side === 'left' ? [fromChunk, targetChunk] : [targetChunk, fromChunk]
const wrapper =
`${indent}<Columns>\n` +
`${colIndent}<Column>\n${left}${colIndent}</Column>\n` +
`${colIndent}<Column>\n${right}${colIndent}</Column>\n` +
`${indent}</Columns>\n`
const edits = [
{ start: targetLines.start, end: targetLines.end, text: wrapper },
{ start: cut.start, end: cut.end, text: '' },
].sort((a, b) => b.start - a.start)
let next = source
for (const e of edits) next = next.slice(0, e.start) + e.text + next.slice(e.end)
next = ensureNamedImport(next, 'Columns', '@/components/notebook')
next = ensureNamedImport(next, 'Column', '@/components/notebook')
return next
}
if (op.type === 'duplicate') {
const block = blocks[op.index]
if (!block) throw new Error(`no block at index ${op.index}`)
const lines = blockLines(source, block)
const chunk = source.slice(lines.start, lines.end)
return source.slice(0, lines.end) + '\n' + chunk + source.slice(lines.end)
}
if (op.type === 'mergeUp') {
const block = blocks[op.index]
const prev = blocks[op.index - 1]
if (!block?.inner || !block.editable) throw new Error(`block ${op.index} is not mergeable`)
if (!prev?.inner || !prev.editable) throw new Error(`block ${op.index - 1} is not mergeable`)
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
const curText = op.text ?? norm(source.slice(block.inner.start, block.inner.end))
const prevText = op.prevText ?? norm(source.slice(prev.inner.start, prev.inner.end))
const merged = [prevText, curText].filter((t) => t !== '').join(' ')
const cut = blockLinesWithGap(source, block) // entirely after prev — splice order safe
const removed = source.slice(0, cut.start) + source.slice(cut.end)
return removed.slice(0, prev.inner.start) + merged + removed.slice(prev.inner.end)
}
// delete
const block = blocks[op.index]
if (!block) throw new Error(`no block at index ${op.index}`)
const cut = blockLinesWithGap(source, block)
return removeUnusedNamedImports(source.slice(0, cut.start) + source.slice(cut.end))
}
/**
* Notion-style column hygiene: drop empty <Column>s, unwrap a <Columns>
* that has fewer than two columns left, remove empty wrappers entirely.
* Runs after every structural op; iterates until stable.
*/
function normalizeColumns(source: string): string {
for (let pass = 0; pass < 10; pass++) {
const page = findPageElement(parseAst(source))
if (!page) return source
let edited = false
for (const child of page.children) {
if (child.type !== 'JSXElement' || jsxName(child) !== 'Columns') continue
const cols = child.children.filter(
(c): c is t.JSXElement => c.type === 'JSXElement' && jsxName(c) === 'Column'
)
const wrapperSpan: Span = { start: child.start!, end: child.end! }
const wrapperLines = spanLines(source, wrapperSpan)
const wrapperIndent = indentOf(source, child.start!)
const emptyCol = cols.find(
(c) => !c.children.some((cc) => cc.type === 'JSXElement')
)
if (emptyCol) {
const lines = spanLines(source, { start: emptyCol.start!, end: emptyCol.end! })
source = source.slice(0, lines.start) + source.slice(lines.end)
edited = true
break
}
if (cols.length === 0) {
// only remove a wrapper that is truly empty — never drop stray
// non-Column content an agent may have written inside
if (!child.children.some((c) => c.type === 'JSXElement')) {
source = source.slice(0, wrapperLines.start) + source.slice(wrapperLines.end)
edited = true
break
}
continue
}
if (cols.length === 1) {
// unwrap: the surviving column's blocks return to the top level
const blocksIn = cols[0].children.filter((c): c is t.JSXElement => c.type === 'JSXElement')
const chunks = blocksIn.map((b) => {
const lines = spanLines(source, { start: b.start!, end: b.end! })
return reindentChunk(
source.slice(lines.start, lines.end),
indentOf(source, b.start!),
wrapperIndent
)
})
source = source.slice(0, wrapperLines.start) + chunks.join('\n') + source.slice(wrapperLines.end)
edited = true
break
}
}
if (!edited) return source
}
return source
}
/** Add `name` to an existing named import from `from`, or add a new import. */
function ensureNamedImport(source: string, name: string, from: string): string {
const ast = parseAst(source)
let lastImportEnd = 0
for (const node of ast.program.body) {
if (node.type !== 'ImportDeclaration') continue
lastImportEnd = node.end!
if (node.source.value !== from) continue
const named = node.specifiers.filter((s): s is t.ImportSpecifier => s.type === 'ImportSpecifier')
if (named.some((s) => s.local.name === name)) return source
const last = named[named.length - 1]
if (last) return source.slice(0, last.end!) + `, ${name}` + source.slice(last.end!)
}
return source.slice(0, lastImportEnd) + `\nimport { ${name} } from '${from}'` + source.slice(lastImportEnd)
}
/** Drop named imports from '@/...' whose name no longer appears in the body. */
function removeUnusedNamedImports(source: string): string {
const ast = parseAst(source)
const imports = ast.program.body.filter(
(n): n is t.ImportDeclaration => n.type === 'ImportDeclaration'
)
if (imports.length === 0) return source
const bodyStart = Math.max(...imports.map((n) => n.end!))
const body = source.slice(bodyStart)
const cuts: Span[] = []
for (const imp of imports) {
if (!String(imp.source.value).startsWith('@/')) continue
const named = imp.specifiers.filter((s): s is t.ImportSpecifier => s.type === 'ImportSpecifier')
if (named.length === 0 || named.length !== imp.specifiers.length) continue
const unused = named.filter((s) => !new RegExp(`\\b${s.local.name}\\b`).test(body))
if (unused.length === named.length) {
// whole import is dead — remove its line
const from = lineStartOf(source, imp.start!)
const lineEnd = source.indexOf('\n', imp.end!)
cuts.push({ start: from, end: lineEnd === -1 ? source.length : lineEnd + 1 })
} else {
for (const spec of unused) {
const i = named.indexOf(spec)
cuts.push(
i > 0
? { start: named[i - 1].end!, end: spec.end! }
: { start: spec.start!, end: named[1].start! }
)
}
}
}
let result = source
for (const cut of cuts.sort((a, b) => b.start - a.start)) {
result = result.slice(0, cut.start) + result.slice(cut.end)
}
return result
}
// ---------------------------------------------------------------------------
// Undo history — state lives in the workspace (.notebooks/history/<slug>.json,
// gitignored), the process stays amnesiac: read file, mutate, write, forget.
// Entries are diff-like inverse splices; hash guards make them exact — a
// stale entry declines instead of rebasing.
// ---------------------------------------------------------------------------
export interface UndoEntry {
/** Forward splice: at `at`, `removed` was replaced by `inserted`. */
at: number
removed: string
inserted: string
baseHash: string
resultHash: string
label: string
/** Block to focus after undoing this entry. */
block: number
ts: number
}
interface HistoryFile {
entries: UndoEntry[]
/** entries[0..cursor) are undoable; entries[cursor..) are redoable. */
cursor: number
}
const HISTORY_CAP = 200
/** A pause longer than this starts a new typing-burst undo unit. */
const COALESCE_WINDOW_MS = 5_000
/** Common prefix/suffix trim → one exact splice between two texts. */
function spliceDiff(before: string, after: string): { at: number; removed: string; inserted: string } {
let p = 0
const minLen = Math.min(before.length, after.length)
while (p < minLen && before[p] === after[p]) p++
let endB = before.length
let endA = after.length
while (endB > p && endA > p && before[endB - 1] === after[endA - 1]) {
endB--
endA--
}
return { at: p, removed: before.slice(p, endB), inserted: after.slice(p, endA) }
}
function applySplice(text: string, at: number, removeLen: number, insert: string): string {
return text.slice(0, at) + insert + text.slice(at + removeLen)
}
async function readHistory(histFile: string): Promise<HistoryFile> {
try {
const parsed = JSON.parse(await fs.readFile(histFile, 'utf8')) as HistoryFile
if (Array.isArray(parsed.entries) && typeof parsed.cursor === 'number') return parsed
} catch {
// missing or corrupted — scratch state, start fresh; the page is never at risk
}
return { entries: [], cursor: 0 }
}
async function writeHistory(histFile: string, h: HistoryFile): Promise<void> {
await fs.mkdir(path.dirname(histFile), { recursive: true })
const tmp = histFile + '.tmp'
await fs.writeFile(tmp, JSON.stringify(h), 'utf8')
await fs.rename(tmp, histFile)
}
function focusBlockFor(op: EditOp): number {
switch (op.type) {
case 'insert':
return op.afterIndex
case 'move':
return op.from
case 'moveInto':
return op.anchor
case 'columnize':
return op.target
case 'mergeUp':
return op.index - 1
default:
return op.index
}
}
function recordEntry(h: HistoryFile, before: string, after: string, op: EditOp, defer: boolean): void {
const d = spliceDiff(before, after)
if (d.removed === '' && d.inserted === '') return
h.entries = h.entries.slice(0, h.cursor) // any new edit truncates the redo tail
const last = h.entries[h.entries.length - 1]
const beforeHash = hashSource(before)
const label =
op.type === 'replaceInner'
? 'typing'
: op.type === 'setProp'
? `typing:${op.name}` // sql editing etc. coalesces like text typing
: op.type
if (
defer &&
(op.type === 'replaceInner' || op.type === 'setProp') &&
last?.label === label &&
last.block === op.index &&
last.resultHash === beforeHash && // contiguous: nothing happened in between
Date.now() - last.ts < COALESCE_WINDOW_MS
) {
// same burst — recompute one splice against the burst's original text
const burstBefore = applySplice(before, last.at, last.inserted.length, last.removed)
const d2 = spliceDiff(burstBefore, after)
Object.assign(last, d2, { resultHash: hashSource(after), ts: Date.now() })
} else {
h.entries.push({
...d,
baseHash: beforeHash,
resultHash: hashSource(after),
label,
block: focusBlockFor(op),
ts: Date.now(),
})
}
if (h.entries.length > HISTORY_CAP) h.entries.shift()
h.cursor = h.entries.length
}
/** Which block contains a splice offset — focus target for external entries. */
function blockAtOffset(source: string, at: number): number {
const blocks = extractBlocks(source)
const hit = blocks.find((b) => at >= b.span.start && at <= b.span.end)
return hit?.index ?? 0
}
// ---------------------------------------------------------------------------
// HTTP plumbing
// ---------------------------------------------------------------------------
function payloadFor(slug: string, file: string, source: string, h?: HistoryFile): PagePayload {
return {
slug,
file,
source,
hash: hashSource(source),
blocks: extractBlocks(source),
canUndo: !!h && h.cursor > 0,
canRedo: !!h && h.cursor < h.entries.length,
}
}
async function readBody(req: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(Buffer.from(chunk as Buffer))
return Buffer.concat(chunks).toString('utf8')
}
const SUPPRESS_TTL_MS = 30_000
export function notebookEditor(): Plugin {
/** Files whose HMR is deferred while an edit session autosaves into them. */
const suppressed = new Map<string, number>()
let devServer: ViteDevServer | null = null
return {
name: 'notebook-editor',
apply: 'serve',
handleHotUpdate(ctx) {
const at = suppressed.get(ctx.file)
if (at !== undefined && Date.now() - at < SUPPRESS_TTL_MS) return []
suppressed.delete(ctx.file)
},
configureServer(server: ViteDevServer) {
devServer = server
const pagesDir = path.resolve(server.config.root, 'pages')
const historyDir = path.resolve(server.config.root, '.notebooks/history')
const resolvePage = (slug: unknown): { slug: string; file: string } | null => {
if (typeof slug !== 'string' || !/^[\w-]+(\/[\w-]+)*$/.test(slug)) return null
const file = path.resolve(pagesDir, `${slug}.tsx`)
return file.startsWith(pagesDir + path.sep) ? { slug, file } : null
}
const historyFileFor = (slug: string) => path.resolve(historyDir, `${slug}.json`)
/**
* Last content of each page the plugin has seen (read or written).
* Ephemeral bookkeeping, not state: lets the watcher tell our writes
* apart from foreign ones and gives external diffs their `before`.
*/
const lastSeen = new Map<string, string>()
const writePage = async (file: string, content: string) => {
const tmp = file + '.tmp'
await fs.writeFile(tmp, content, 'utf8')
await fs.rename(tmp, file)
lastSeen.set(file, content)
}
const releaseHmr = async (file: string) => {
if (!suppressed.delete(file) || !devServer) return
for (const mod of devServer.moduleGraph.getModulesByFile(file) ?? []) {
await devServer.reloadModule(mod)
}
}
// Foreign writes (agent, IDE) become undoable 'external' history entries.
// Runs in the local process, so it works even with no tab open.
server.watcher.on('change', (file: string) => {
if (!file.startsWith(pagesDir + path.sep) || !file.endsWith('.tsx')) return
void (async () => {
const content = await fs.readFile(file, 'utf8')
const prev = lastSeen.get(file)
lastSeen.set(file, content)
if (prev === undefined || prev === content) return // our write, or unknown base
const slug = path.relative(pagesDir, file).replace(/\.tsx$/, '')
const histFile = historyFileFor(slug)
const h = await readHistory(histFile)
const d = spliceDiff(prev, content)
if (d.removed === '' && d.inserted === '') return
h.entries = h.entries.slice(0, h.cursor)
h.entries.push({
...d,
baseHash: hashSource(prev),
resultHash: hashSource(content),
label: 'external',
block: blockAtOffset(content, d.at),
ts: Date.now(),
})
if (h.entries.length > HISTORY_CAP) h.entries.shift()
h.cursor = h.entries.length
await writeHistory(histFile, h)
})().catch(() => {})
})
server.middlewares.use('/__editor', (req, res) => {
const respond = (status: number, data: unknown) => {
res.statusCode = status
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'no-store') // stale payloads break hash guards
res.end(JSON.stringify(data))
}
void (async () => {
const url = new URL(req.url ?? '/', 'http://localhost')
if (req.method === 'GET' && url.pathname === '/page') {
const page = resolvePage(url.searchParams.get('slug'))
if (!page) return respond(400, { error: 'bad slug' })
const source = await fs.readFile(page.file, 'utf8')
lastSeen.set(page.file, source)
const h = await readHistory(historyFileFor(page.slug))
return respond(200, payloadFor(page.slug, page.file, source, h))
}
if (req.method === 'POST' && url.pathname === '/apply') {
const body = JSON.parse(await readBody(req)) as {
slug?: string
hash?: string
op?: EditOp
defer?: boolean
}
const page = resolvePage(body.slug)
if (!page || !body.op) return respond(400, { error: 'bad request' })
const source = await fs.readFile(page.file, 'utf8')
if (body.hash !== hashSource(source)) {
lastSeen.set(page.file, source)
const h = await readHistory(historyFileFor(page.slug))
return respond(409, { error: 'stale', payload: payloadFor(page.slug, page.file, source, h) })
}
let next = applyOp(source, extractBlocks(source), body.op)
// structural ops can empty a column — keep the layout tidy
if (body.op.type !== 'replaceInner' && body.op.type !== 'setProp') {
const normalized = normalizeColumns(next)
if (normalized !== next) next = removeUnusedNamedImports(normalized)
}
if (body.defer) suppressed.set(page.file, Date.now())
else suppressed.delete(page.file)
// history rides in the same request as the page write — transactional
const histFile = historyFileFor(page.slug)
const h = await readHistory(histFile)
recordEntry(h, source, next, body.op, !!body.defer)
await writeHistory(histFile, h)
await writePage(page.file, next)
return respond(200, payloadFor(page.slug, page.file, next, h))
}
if (req.method === 'POST' && (url.pathname === '/undo' || url.pathname === '/redo')) {
const isUndo = url.pathname === '/undo'
const body = JSON.parse(await readBody(req)) as { slug?: string }
const page = resolvePage(body.slug)
if (!page) return respond(400, { error: 'bad slug' })
const source = await fs.readFile(page.file, 'utf8')
lastSeen.set(page.file, source)
const histFile = historyFileFor(page.slug)
const h = await readHistory(histFile)
const entry = isUndo ? h.entries[h.cursor - 1] : h.entries[h.cursor]
if (!entry) {
return respond(200, { ok: false, reason: 'empty', payload: payloadFor(page.slug, page.file, source, h) })
}
// an entry only ever applies to the exact text it was recorded for
const expected = isUndo ? entry.resultHash : entry.baseHash
if (hashSource(source) !== expected) {
h.entries = []
h.cursor = 0
await writeHistory(histFile, h)
return respond(200, { ok: false, reason: 'stale', payload: payloadFor(page.slug, page.file, source, h) })
}
const next = isUndo
? applySplice(source, entry.at, entry.inserted.length, entry.removed)
: applySplice(source, entry.at, entry.removed.length, entry.inserted)
h.cursor += isUndo ? -1 : 1
suppressed.delete(page.file) // undo/redo always flush HMR via the watcher
await writeHistory(histFile, h)
await writePage(page.file, next)
return respond(200, {
ok: true,
payload: payloadFor(page.slug, page.file, next, h),
focusBlock: entry.block,
label: entry.label,
})
}
if (req.method === 'POST' && url.pathname === '/flush') {
const body = JSON.parse(await readBody(req)) as { slug?: string }
const page = resolvePage(body.slug)
if (!page) return respond(400, { error: 'bad slug' })
await releaseHmr(page.file)
return respond(200, { ok: true })
}
respond(404, { error: 'not found' })
})().catch((err: unknown) => {
respond(500, { error: err instanceof Error ? err.message : String(err) })
})
})
},
}
}