@ape-egg/codecane 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/build.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { cp, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
2
3
  import { dirname, join } from 'node:path'
3
4
 
@@ -12,8 +13,10 @@ const listed = async dir => (await readdir(dir, { recursive: true, withFileTypes
12
13
  .map(entry => join(entry.parentPath, entry.name).slice(dir.length + 1))
13
14
 
14
15
  export const written = async (out, files) => Promise.all(Object.entries(files).map(async ([path, body]) => {
15
- await mkdir(dirname(join(out, path)), { recursive: true })
16
- await writeFile(join(out, path), body)
16
+ const file = join(out, path)
17
+ if (existsSync(file) && (await readFile(file)).equals(Buffer.from(body))) return
18
+ await mkdir(dirname(file), { recursive: true })
19
+ await writeFile(file, body)
17
20
  }))
18
21
 
19
22
  const viewerFiles = async ({ dir }, ids) => {
package/core/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
- import { readFile } from 'node:fs/promises'
2
+ import { watch } from 'node:fs'
3
+ import { readFile, rm } from 'node:fs/promises'
3
4
  import { join, resolve } from 'node:path'
4
5
  import { parseArgs } from 'node:util'
5
6
 
@@ -17,6 +18,7 @@ codecane dev [project] [--port n] serve the viewer and every stacked st
17
18
  codecane build [project] [--out dir] stack every step into dist/, write its data and the viewer, and check that every step runs
18
19
  codecane build [project] --bare the same without the viewer
19
20
  codecane stack <address> [project] --out dir write one step on one timeline, <forks-step>@<fork-step>@<step>
21
+ codecane stack <address> [project] --out dir --watch the same, and write it again as steps/ changes, touching only the files that changed
20
22
  codecane agents print the codecane workflow for a project's AGENTS.md`
21
23
 
22
24
  const root = resolve(import.meta.dirname, '..')
@@ -25,7 +27,7 @@ const viewer = { dir: join(root, 'ui'), modules: Object.keys(dependencies) }
25
27
 
26
28
  const { positionals: [command, ...rest], values } = parseArgs({
27
29
  allowPositionals: true,
28
- options: { port: { type: 'string' }, out: { type: 'string' }, bare: { type: 'boolean' }, project: { type: 'string' } },
30
+ options: { port: { type: 'string' }, out: { type: 'string' }, bare: { type: 'boolean' }, watch: { type: 'boolean' }, project: { type: 'string' } },
29
31
  })
30
32
 
31
33
  const stamped = async (project = '.') => {
@@ -75,10 +77,32 @@ const commands = {
75
77
  process.exitCode = Object.keys(failures).length ? 1 : 0
76
78
  },
77
79
  stack: async ([address, project = '.']) => {
78
- const steps = await stamped(project)
79
- const { id, through } = locate(steps, address)
80
- await written(resolve(values.out), stack(steps, id, through).files)
81
- console.info(`step ${address} stacked into ${resolve(values.out)}`)
80
+ const out = resolve(values.out)
81
+ const stackedFiles = async () => {
82
+ const steps = await stamped(project)
83
+ const { id, through } = locate(steps, address)
84
+ return stack(steps, id, through).files
85
+ }
86
+ let files = await stackedFiles()
87
+ await written(out, files)
88
+ console.info(`step ${address} stacked into ${out}`)
89
+ if (!values.watch) return
90
+ const restacked = async () => {
91
+ const next = await stackedFiles()
92
+ const changed = Object.keys(next).filter(path => !(path in files) || !Buffer.from(files[path]).equals(Buffer.from(next[path])))
93
+ const gone = Object.keys(files).filter(path => !(path in next))
94
+ await written(out, Object.fromEntries(changed.map(path => [path, next[path]])))
95
+ await Promise.all(gone.map(path => rm(join(out, path))))
96
+ files = next
97
+ if (changed.length || gone.length) console.info(`step ${address} stacked into ${out} again: ${[...changed, ...gone].join(', ')}`)
98
+ }
99
+ let settling
100
+ let queue = Promise.resolve()
101
+ watch(resolve(project), { recursive: true }, (_, path) => {
102
+ if (!/^(steps|parts)\//.test(path ?? '')) return
103
+ clearTimeout(settling)
104
+ settling = setTimeout(() => queue = queue.then(restacked).catch(error => console.error(error.message)), 300)
105
+ })
82
106
  },
83
107
  agents: async () => console.info(await readFile(join(root, 'workflow.md'), 'utf8')),
84
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/codecane",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Build a project up step by step: add-only step folders stacked into runnable steps, with a timeline viewer",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -24,7 +24,7 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@ape-egg/codie": "^0.1.18",
27
+ "@ape-egg/codie": "^0.2.0",
28
28
  "@ape-egg/vibe": "^4.3.17"
29
29
  },
30
30
  "publishConfig": {
package/ui/codecane.css CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  body {
18
18
  display: grid;
19
- grid-template-rows: auto minmax(0, 1fr);
19
+ grid-template: auto minmax(0, 1fr) / minmax(0, 1fr);
20
20
  height: 100dvh;
21
21
  margin: 0;
22
22
  overflow: hidden;
@@ -113,10 +113,18 @@ header ol {
113
113
  min-width: 0;
114
114
  margin: 0 1.5rem;
115
115
  padding: 0.75rem 0;
116
- overflow-x: auto;
116
+ overflow: auto hidden;
117
117
  list-style: none;
118
118
  }
119
119
 
120
+ header[stylecheat] ol::-webkit-scrollbar {
121
+ height: var(--unit);
122
+ }
123
+
124
+ header[stylecheat] ol::-webkit-scrollbar-thumb {
125
+ background-color: var(--dim);
126
+ }
127
+
120
128
  header li {
121
129
  display: flex;
122
130
  align-self: center;
@@ -248,11 +256,28 @@ story-panel {
248
256
  border-right: 1px solid var(--line);
249
257
  }
250
258
 
259
+ story-panel [stylecheat] {
260
+ background: none;
261
+ }
262
+
263
+ panel-tabs {
264
+ padding: 1.25rem 1.25rem 0;
265
+ }
266
+
251
267
  article,
252
- file-list {
268
+ file-list,
269
+ footer {
253
270
  padding: 1.25rem;
254
271
  }
255
272
 
273
+ footer {
274
+ position: sticky;
275
+ bottom: 0;
276
+ margin-top: auto;
277
+ border-top: 1px solid var(--line);
278
+ background: inherit;
279
+ }
280
+
256
281
  article small {
257
282
  color: var(--dim);
258
283
  }
@@ -308,7 +333,6 @@ step-links {
308
333
  display: flex;
309
334
  justify-content: space-between;
310
335
  gap: 1rem;
311
- margin-top: 1.25rem;
312
336
  color: var(--dim);
313
337
  }
314
338
 
@@ -326,7 +350,6 @@ step-links a[kind="seam"] {
326
350
 
327
351
  file-list {
328
352
  flex: 1;
329
- border-top: 1px solid var(--line);
330
353
  }
331
354
 
332
355
  file {
@@ -481,11 +504,8 @@ changes-list {
481
504
  border-left: 1px solid var(--line);
482
505
  }
483
506
 
484
- :is(file-list, changes-list) > h2 {
485
- font-size: 0.875rem;
486
- }
487
-
488
507
  changes-list > h2 {
508
+ font-size: 0.875rem;
489
509
  padding: 1.25rem 1.25rem 0.75rem;
490
510
  }
491
511
 
@@ -516,7 +536,7 @@ change-header code {
516
536
  text-overflow: ellipsis;
517
537
  }
518
538
 
519
- change-diff codie {
539
+ change-diff codie[dark] {
520
540
  font-size: 0.75rem;
521
541
  --codie-bg: transparent;
522
542
  }
@@ -1,6 +1,20 @@
1
1
  <story-panel ontransitionend="event.target === this && codecane.returned()">
2
+ <panel-tabs stylecheat dark>
3
+ <tabs primary>
4
+ <tab active="@[tab === 'about']" onclick="$.tab = 'about'">About</tab>
5
+ <tab active="@[tab === 'files']" onclick="$.tab = 'files'">Files</tab>
6
+ </tabs>
7
+ </panel-tabs>
8
+ <!-- if tab === 'about' -->
2
9
  <article>
3
- <small>Step @[timeline.indexOf(page.id) + 1] of @[timeline.length]@[page.untested ? ' · Untested' : '']</small>
10
+ <step-status stylecheat dark>
11
+ <crow justify-between>
12
+ <small>Step @[timeline.indexOf(page.id) + 1] of @[timeline.length]</small>
13
+ <!-- if page.untested -->
14
+ <badge bg-slate text-slate>Untested</badge>
15
+ <!-- /if -->
16
+ </crow>
17
+ </step-status>
4
18
  <!-- if page.convergence && page.convergence.kind === 'seam' -->
5
19
  <story><h1>@[page.title]</h1></story>
6
20
  <!-- else -->
@@ -43,19 +57,9 @@
43
57
  <!-- /each -->
44
58
  </alternatives>
45
59
  <!-- /if -->
46
- <step-links>
47
- <!-- if previous -->
48
- <a href="/@[previous.address]">← @[previous.title]</a>
49
- <!-- /if -->
50
- <!-- if next -->
51
- <a href="/@[next.address]" next kind="@[next.kind]">@[next.title] →</a>
52
- <!-- else -->
53
- <span>Final build</span>
54
- <!-- /if -->
55
- </step-links>
56
60
  </article>
61
+ <!-- else -->
57
62
  <file-list stylecheat dark>
58
- <h2>Files</h2>
59
63
  <!-- each [...new Set(page.files.map(file => file.folder))] as folder (page.id + folder) -->
60
64
  <!-- if folder -->
61
65
  <folder>@[folder]</folder>
@@ -82,4 +86,20 @@
82
86
  <!-- /each -->
83
87
  <!-- /if -->
84
88
  </file-list>
89
+ <!-- /if -->
90
+ <footer>
91
+ <!-- if page.ahead -->
92
+ <story>@[$.unsafe(page.ahead)]</story>
93
+ <!-- /if -->
94
+ <step-links>
95
+ <!-- if previous -->
96
+ <a href="/@[previous.address]" kind="@[previous.kind]">← @[previous.title]</a>
97
+ <!-- /if -->
98
+ <!-- if next -->
99
+ <a href="/@[next.address]" next kind="@[next.kind]">@[next.title] →</a>
100
+ <!-- else -->
101
+ <span>Final build</span>
102
+ <!-- /if -->
103
+ </step-links>
104
+ </footer>
85
105
  </story-panel>
package/ui/viewer.js CHANGED
@@ -34,7 +34,8 @@ const rootFirst = (a, b) => a.path.includes('/') - b.path.includes('/')
34
34
  const opened = async stacked => {
35
35
  const address = new URL(steps()[idOf(stacked)].data[stacked], source)
36
36
  const step = await (loaded[stacked] ??= fetched(address))
37
- return { ...step, files: step.files.map(file => ({ ...file, folder: folderOf(file.path) })).sort(rootFirst), base: new URL(step.folder, address).href, texts: Object.fromEntries(step.changes.map(change => [change.path, change.after])) }
37
+ const [story, ahead = ''] = step.story.split(/<hr\s*\/?>(?![\s\S]*<hr)/)
38
+ return { ...step, story, ahead, files: step.files.map(file => ({ ...file, folder: folderOf(file.path) })).sort(rootFirst), base: new URL(step.folder, address).href, texts: Object.fromEntries(step.changes.map(change => [change.path, change.after])) }
38
39
  }
39
40
 
40
41
  const running = page => page.files.some(file => file.path === 'index.html') ? `${page.base}index.html?revision=${digest(page.files.map(file => file.hash).join())}` : ''
@@ -144,7 +145,7 @@ const reports = {
144
145
  addEventListener('message', ({ source, data }) => source === document.querySelector('stage iframe')?.contentWindow && reports[data.codecane]?.(data))
145
146
 
146
147
  const first = placed(addressAt(location.pathname))
147
- window.$ = vibe({ name: index.name, version: index.version, editable: index.editable, kinds: { merge: 'Merge', seam: 'Seam' }, through: null, panel: 50, dragging: false, closing: false, fullscreen: false, returning: false, root: atRoot(location.pathname), ...shown(await opened(first.address), first.lane) })
148
+ window.$ = vibe({ name: index.name, version: index.version, editable: index.editable, kinds: { merge: 'Merge', seam: 'Seam' }, tab: 'about', through: null, panel: 50, dragging: false, closing: false, fullscreen: false, returning: false, root: atRoot(location.pathname), ...shown(await opened(first.address), first.lane) })
148
149
  const titled = address => document.title = `${index.name} · ${steps()[idOf(address)].title}`
149
150
  titled(first.address)
150
151
 
package/workflow.md CHANGED
@@ -78,4 +78,4 @@ A step's kind is the result of its last replay:
78
78
  - `codecane patch <id> [path…]`: turns a step's copies of earlier files into patches.
79
79
  - `codecane dev`: the viewer at `/`, every stacked step at `/steps/<id>/`, live from `steps/`. It watches `steps/`, turns copies into patches, rebases later patches and replays from each changed step forward, and the open viewer updates in place without reloading. Saving in the viewer writes to the step that last changed the file.
80
80
  - `codecane build`: writes `dist/` (every stacked step, its JSON data and the viewer) and checks that every step runs. `--bare` leaves out the viewer.
81
- - `codecane stack <address> --out <dir>`: writes one step on one timeline, such as `moving-the-center/05adc6a`; `codecane stack moving-the-center` writes it with the default forks.
81
+ - `codecane stack <address> --out <dir>`: writes one step on one timeline, such as `moving-the-center/05adc6a`; `codecane stack moving-the-center` writes it with the default forks. `--watch` writes it again whenever `steps/` or `parts/` changes, touching only the files whose stacked text changed, so a watcher such as `bun --watch` on the stacked folder restarts only when the stacked code really changed.