version-history-widget 0.1.3 → 0.1.6
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/README.md +31 -0
- package/middleware.js +9 -2
- package/package.json +1 -1
- package/skill/SKILL.md +51 -0
- package/vh.js +167 -17
- package/widget.js +53 -3
package/README.md
CHANGED
|
@@ -21,6 +21,27 @@ Or use without installing:
|
|
|
21
21
|
npx version-history-widget init
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
## Auto-recording (so nothing is ever missed)
|
|
25
|
+
|
|
26
|
+
A version only exists if something records it. Rather than relying on you
|
|
27
|
+
(or your AI assistant) to remember, let it record on its own:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx vh watch # auto-saves every change as a new version, debounced
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Run that alongside whatever dev server you use — Vite, Next, Astro, plain
|
|
34
|
+
HTML, anything. A burst of edits becomes one version, not twenty.
|
|
35
|
+
|
|
36
|
+
You usually don't even need to run it: **`.versions/middleware.js` starts the
|
|
37
|
+
same watcher automatically whenever it's mounted**, so `node .versions/serve.js`
|
|
38
|
+
or a dev server with the middleware mounted records changes on its own. For
|
|
39
|
+
Vite projects, `vh init` writes a `vite.config.js` that mounts it for you (an
|
|
40
|
+
existing config is never modified — it just prints the snippet to add).
|
|
41
|
+
|
|
42
|
+
Explicitly running `vh record "Some title"` is still worth it when you want a
|
|
43
|
+
meaningful title instead of an auto-generated "Updated src/main.js".
|
|
44
|
+
|
|
24
45
|
## Project mode
|
|
25
46
|
|
|
26
47
|
```bash
|
|
@@ -28,8 +49,14 @@ npx vh init # sets up .versions/, injects the widget, saves vers
|
|
|
28
49
|
npx vh record "Title" -d "what changed and why"
|
|
29
50
|
npx vh restore 4 # safety-snapshots current state, then restores version 4
|
|
30
51
|
npx vh list [query] # list versions, optionally filtered
|
|
52
|
+
npx vh watch # auto-save every change as a new version
|
|
53
|
+
npx vh update # refresh .versions/*.js from the currently installed package
|
|
31
54
|
```
|
|
32
55
|
|
|
56
|
+
`vh init` only copies `widget.js` (and friends) into `.versions/` once. If
|
|
57
|
+
you upgrade the package later and want an already-initialized site to pick
|
|
58
|
+
up widget changes (new styling, bug fixes, etc.), run `vh update`.
|
|
59
|
+
|
|
33
60
|
Serve it so the restore button works:
|
|
34
61
|
|
|
35
62
|
```bash
|
|
@@ -86,6 +113,10 @@ command needs to run this once on their own machine too.
|
|
|
86
113
|
are exact and history is human-readable.
|
|
87
114
|
- The widget (`widget.js`) is vanilla JS + inline CSS, scoped under `vh-`
|
|
88
115
|
class names so it never collides with your site's styles.
|
|
116
|
+
- The "Versions" pill is draggable — click and drag it anywhere on screen
|
|
117
|
+
(works with touch too), and its position is remembered per-browser via
|
|
118
|
+
`localStorage`. The panel it opens repositions itself next to wherever
|
|
119
|
+
the pill currently is.
|
|
89
120
|
|
|
90
121
|
## Customizing the widget UI
|
|
91
122
|
|
package/middleware.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
/* Connect/Express/Vite-compatible middleware: serves /.versions
|
|
1
|
+
/* Connect/Express/Vite-compatible middleware: serves /.versions/*, handles POST /__vh/restore/:id,
|
|
2
|
+
and auto-records every change as a new version while the dev server is running.
|
|
2
3
|
Express: app.use(require('./.versions/middleware.js')())
|
|
3
4
|
Vite: plugins:[{ name:'vh', configureServer(s){ s.middlewares.use(require('./.versions/middleware.js')()) } }]
|
|
4
|
-
Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id)
|
|
5
|
+
Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id)
|
|
6
|
+
Pass { watch: false } to opt out of auto-recording. */
|
|
5
7
|
const fs = require('fs'), path = require('path');
|
|
6
8
|
module.exports = function (opts = {}) {
|
|
7
9
|
const root = path.resolve(opts.root || process.env.VH_ROOT || process.cwd()), vdir = path.join(root, '.versions');
|
|
8
10
|
const types = { '.js': 'text/javascript', '.json': 'application/json', '.diff': 'text/plain' };
|
|
11
|
+
if (opts.watch !== false && fs.existsSync(vdir)) {
|
|
12
|
+
process.env.VH_ROOT = root; // vh.js resolves its root from this at require time
|
|
13
|
+
try { require(path.join(vdir, 'vh.js')).watch({ keepAlive: false }); }
|
|
14
|
+
catch (e) { console.error('[vh] auto-record unavailable: ' + e.message); }
|
|
15
|
+
}
|
|
9
16
|
return function (req, res, next) {
|
|
10
17
|
const url = (req.url || '').split('?')[0];
|
|
11
18
|
if (req.method === 'POST' && url.startsWith('/__vh/restore/')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "version-history-widget",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Zero-dependency version history for web projects: snapshots every change and adds a floating widget to browse, search, and restore any version.",
|
|
5
5
|
"keywords": ["version history", "undo", "snapshot", "restore", "dev-tool", "widget"],
|
|
6
6
|
"license": "MIT",
|
package/skill/SKILL.md
CHANGED
|
@@ -14,6 +14,23 @@ Two modes — pick based on what the user is working on:
|
|
|
14
14
|
- **Project mode**: a normal multi-file site/app. History lives in `.versions/`.
|
|
15
15
|
- **Single-file mode**: one standalone `.html` file. History lives inside that file.
|
|
16
16
|
|
|
17
|
+
## Record after you change things
|
|
18
|
+
|
|
19
|
+
**If a project has a `.versions/` directory, it has version history enabled,
|
|
20
|
+
and every meaningful change you make should end with a recorded version.**
|
|
21
|
+
Don't leave the user's history stuck on an old snapshot — after editing
|
|
22
|
+
files, run `record` with a short human title describing what you changed:
|
|
23
|
+
|
|
24
|
+
`npx version-history-widget record "Tweak icon animation timing" -d "what changed and why"`
|
|
25
|
+
|
|
26
|
+
If the project is set up for auto-recording (`vh watch`, or the middleware
|
|
27
|
+
mounted in their dev server), versions are captured on their own and you
|
|
28
|
+
don't need to record manually — but recording explicitly is still better,
|
|
29
|
+
since it gives the version a meaningful title instead of "Updated src/main.js".
|
|
30
|
+
|
|
31
|
+
To set a project up so changes are never missed again, see "Auto-recording"
|
|
32
|
+
below.
|
|
33
|
+
|
|
17
34
|
## Project mode
|
|
18
35
|
|
|
19
36
|
Run these with Bash from the project root (`npx` needs no local install):
|
|
@@ -35,6 +52,40 @@ Run these with Bash from the project root (`npx` needs no local install):
|
|
|
35
52
|
|
|
36
53
|
If `.versions/` doesn't exist yet in the project, run `init` first.
|
|
37
54
|
|
|
55
|
+
## Auto-recording
|
|
56
|
+
|
|
57
|
+
So that changes are never silently lost, a project can record versions on
|
|
58
|
+
its own. Two ways, both framework-agnostic:
|
|
59
|
+
|
|
60
|
+
- Run a watcher alongside whatever dev server they use:
|
|
61
|
+
`npx version-history-widget watch`
|
|
62
|
+
It debounces, so a burst of edits becomes one version.
|
|
63
|
+
- Or mount the middleware in their dev server, which starts the same
|
|
64
|
+
watcher automatically whenever the server runs. For Vite, a
|
|
65
|
+
`vite.config.js` containing:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { createRequire } from 'module';
|
|
69
|
+
const require = createRequire(import.meta.url);
|
|
70
|
+
|
|
71
|
+
export default {
|
|
72
|
+
plugins: [{
|
|
73
|
+
name: 'version-history',
|
|
74
|
+
configureServer(server) {
|
|
75
|
+
server.middlewares.use(require('./.versions/middleware.js')());
|
|
76
|
+
},
|
|
77
|
+
}],
|
|
78
|
+
};
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
(`createRequire` is needed because Vite configs are ES modules and the
|
|
82
|
+
vendored `.versions/` files are CommonJS.)
|
|
83
|
+
|
|
84
|
+
If the user reports that a change they made isn't showing up in the widget,
|
|
85
|
+
it is almost always because nothing recorded it — check
|
|
86
|
+
`.versions/manifest.json` against the current files, record the missing
|
|
87
|
+
version, then set up one of the two options above.
|
|
88
|
+
|
|
38
89
|
## Single-file mode
|
|
39
90
|
|
|
40
91
|
Use when the user names one `.html` file rather than a project:
|
package/vh.js
CHANGED
|
@@ -4,13 +4,18 @@
|
|
|
4
4
|
node vh.js record "Title" [-d "details" | -f details.txt] — snapshot changed files as a new version
|
|
5
5
|
node vh.js restore <id> — snapshot current state, then restore version <id>
|
|
6
6
|
node vh.js list [query] — list versions (optionally filtered by title/details)
|
|
7
|
+
node vh.js watch — auto-record every change as a new version (run alongside
|
|
8
|
+
your dev server; also runs inside serve.js/middleware.js)
|
|
9
|
+
node vh.js update — refresh the vendored widget.js/vh.js/serve.js/middleware.js
|
|
10
|
+
in .versions/ from the installed package (run after upgrading)
|
|
7
11
|
node vh.js skill — install the Claude Code skill (~/.claude/skills) so
|
|
8
12
|
/version-history-widget works in Claude Code sessions
|
|
9
13
|
Env: VH_ROOT (project root, default cwd) */
|
|
10
14
|
const fs = require('fs'), path = require('path'), os = require('os');
|
|
11
15
|
const ROOT = path.resolve(process.env.VH_ROOT || process.cwd());
|
|
12
16
|
const VDIR = path.join(ROOT, '.versions'), SNAP = path.join(VDIR, 'snapshots'), MAN = path.join(VDIR, 'manifest.json');
|
|
13
|
-
const
|
|
17
|
+
const LOCK = path.join(VDIR, '.restoring');
|
|
18
|
+
const IGNORE = new Set(['node_modules', '.git', '.versions', 'dist', 'build', '.next', '.nuxt', 'coverage', '.cache', '.DS_Store', '.claude']);
|
|
14
19
|
const MAX = 2 * 1024 * 1024;
|
|
15
20
|
|
|
16
21
|
const readMan = () => fs.existsSync(MAN) ? JSON.parse(fs.readFileSync(MAN, 'utf8')) : [];
|
|
@@ -84,14 +89,146 @@ function injectWidget() {
|
|
|
84
89
|
return null;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
const VITE_CONFIG = `import { createRequire } from 'module';
|
|
93
|
+
const require = createRequire(import.meta.url);
|
|
94
|
+
|
|
95
|
+
export default {
|
|
96
|
+
plugins: [{
|
|
97
|
+
name: 'version-history',
|
|
98
|
+
configureServer(server) {
|
|
99
|
+
server.middlewares.use(require('./.versions/middleware.js')());
|
|
100
|
+
},
|
|
101
|
+
}],
|
|
102
|
+
};
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
function usesVite() {
|
|
106
|
+
const pj = path.join(ROOT, 'package.json');
|
|
107
|
+
if (!fs.existsSync(pj)) return false;
|
|
108
|
+
try {
|
|
109
|
+
const p = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
110
|
+
if ((p.devDependencies && p.devDependencies.vite) || (p.dependencies && p.dependencies.vite)) return true;
|
|
111
|
+
return /\bvite\b/.test(Object.values(p.scripts || {}).join(' '));
|
|
112
|
+
} catch (e) { return false; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function viteConfigPath() {
|
|
116
|
+
for (const f of ['vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cjs'])
|
|
117
|
+
if (fs.existsSync(path.join(ROOT, f))) return path.join(ROOT, f);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Wire the dev server up so changes keep getting auto-recorded. Only ever
|
|
122
|
+
creates a config when there isn't one — an existing config is never edited,
|
|
123
|
+
since that's the user's file. Re-run after a restore so restoring to a
|
|
124
|
+
version that predates the setup doesn't silently switch auto-saving off. */
|
|
125
|
+
function ensureDevIntegration(quiet) {
|
|
126
|
+
if (!usesVite()) return null;
|
|
127
|
+
const existing = viteConfigPath();
|
|
128
|
+
if (existing) {
|
|
129
|
+
if (!quiet && !fs.readFileSync(existing, 'utf8').includes('.versions/middleware.js'))
|
|
130
|
+
console.log('Note: ' + path.basename(existing) + ' does not mount .versions/middleware.js, so changes are only auto-recorded while `vh watch` is running. See the README for the snippet to add.');
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const p = path.join(ROOT, 'vite.config.js');
|
|
134
|
+
fs.writeFileSync(p, VITE_CONFIG);
|
|
135
|
+
if (!quiet) console.log('Created vite.config.js mounting .versions/middleware.js — restart your dev server and every change is auto-recorded.');
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Files that differ from the newest recorded version. */
|
|
140
|
+
function pendingChanges() {
|
|
141
|
+
const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
|
|
142
|
+
const cur = walk(ROOT), out = [];
|
|
143
|
+
for (const f of Object.keys(cur)) { const old = prev[f]; if (!old || !old.equals(cur[f])) out.push(f); }
|
|
144
|
+
for (const f of Object.keys(prev)) if (!cur[f]) out.push(f);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Record a version in-process. Returns null when nothing changed. */
|
|
149
|
+
function recordNow(title, details) {
|
|
150
|
+
const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
|
|
151
|
+
const r = snapshot(title, details, prev);
|
|
152
|
+
if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); return null; }
|
|
153
|
+
return r;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function autoTitle(files) {
|
|
157
|
+
if (files.length === 1) return 'Updated ' + files[0];
|
|
158
|
+
return 'Updated ' + files[0] + ' and ' + (files.length - 1) + ' more file' + (files.length > 2 ? 's' : '');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ignoredPath(rel) {
|
|
162
|
+
if (!rel) return false;
|
|
163
|
+
for (const seg of String(rel).split(/[\\/]/)) if (IGNORE.has(seg) || seg.startsWith('.env')) return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Auto-record changes as they happen. Debounced so a burst of edits (an editor
|
|
168
|
+
or an agent writing several files) becomes one version, not twenty. */
|
|
169
|
+
function startWatch(opts = {}) {
|
|
170
|
+
const wait = opts.debounce || 1500, log = opts.log !== false;
|
|
171
|
+
let timer = null, busy = false;
|
|
172
|
+
function fire() {
|
|
173
|
+
timer = null;
|
|
174
|
+
if (busy || fs.existsSync(LOCK)) return schedule(); // mid-restore: check again later
|
|
175
|
+
busy = true;
|
|
176
|
+
try {
|
|
177
|
+
const files = pendingChanges();
|
|
178
|
+
if (files.length) {
|
|
179
|
+
const title = autoTitle(files);
|
|
180
|
+
const r = recordNow(title, 'Auto-saved.\n\nChanged files:\n' + files.map(f => ' ' + f).join('\n'));
|
|
181
|
+
if (r && log) console.log('[vh] saved version ' + r.id + ': ' + title);
|
|
182
|
+
}
|
|
183
|
+
} catch (e) { if (log) console.error('[vh] auto-save failed: ' + e.message); }
|
|
184
|
+
finally { busy = false; }
|
|
185
|
+
}
|
|
186
|
+
function schedule() { clearTimeout(timer); timer = setTimeout(fire, wait); }
|
|
187
|
+
|
|
188
|
+
let watcher = null;
|
|
189
|
+
try { watcher = fs.watch(ROOT, { recursive: true }, (evt, name) => { if (!ignoredPath(name)) schedule(); }); }
|
|
190
|
+
catch (e) { watcher = null; } // recursive watch unsupported (older Linux)
|
|
191
|
+
let poller = null;
|
|
192
|
+
if (!watcher) {
|
|
193
|
+
let seen = 0;
|
|
194
|
+
poller = setInterval(() => {
|
|
195
|
+
let newest = 0;
|
|
196
|
+
(function scan(dir) {
|
|
197
|
+
let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
if (IGNORE.has(e.name) || e.name.startsWith('.env')) continue;
|
|
200
|
+
const p = path.join(dir, e.name);
|
|
201
|
+
if (e.isDirectory()) scan(p);
|
|
202
|
+
else { try { const m = fs.statSync(p).mtimeMs; if (m > newest) newest = m; } catch (e2) { } }
|
|
203
|
+
}
|
|
204
|
+
})(ROOT);
|
|
205
|
+
if (newest > seen) { seen = newest; schedule(); }
|
|
206
|
+
}, opts.poll || 2000);
|
|
207
|
+
}
|
|
208
|
+
if (opts.keepAlive === false) { if (watcher && watcher.unref) watcher.unref(); if (poller && poller.unref) poller.unref(); }
|
|
209
|
+
schedule(); // catch up on anything changed while nothing was watching
|
|
210
|
+
return { stop() { clearTimeout(timer); if (watcher) watcher.close(); if (poller) clearInterval(poller); } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function vendorFiles() {
|
|
214
|
+
fs.mkdirSync(VDIR, { recursive: true });
|
|
90
215
|
for (const f of ['widget.js', 'vh.js', 'serve.js', 'middleware.js']) { const src = path.join(__dirname, f); if (fs.existsSync(src) && src !== path.join(VDIR, f)) fs.copyFileSync(src, path.join(VDIR, f)); }
|
|
216
|
+
// Force CommonJS for everything under .versions/, regardless of the host
|
|
217
|
+
// project's own package.json — otherwise a host with "type":"module" makes
|
|
218
|
+
// Node treat these vendored .js files as ESM and `require` disappears.
|
|
219
|
+
fs.writeFileSync(path.join(VDIR, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2) + '\n');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const cmd = require.main === module ? process.argv[2] : null, args = process.argv.slice(3);
|
|
223
|
+
if (cmd === null) { // required as a library (serve.js / middleware.js)
|
|
224
|
+
} else if (cmd === 'init') {
|
|
225
|
+
fs.mkdirSync(SNAP, { recursive: true });
|
|
226
|
+
vendorFiles();
|
|
91
227
|
if (!fs.existsSync(MAN)) writeMan([]);
|
|
92
228
|
const gi = path.join(ROOT, '.gitignore'); const line = '.versions/snapshots/';
|
|
93
229
|
if (!fs.existsSync(gi) || !fs.readFileSync(gi, 'utf8').includes(line)) fs.appendFileSync(gi, '\n' + line + '\n');
|
|
94
230
|
const where = injectWidget();
|
|
231
|
+
ensureDevIntegration(false);
|
|
95
232
|
const r = readMan().length ? null : snapshot('Initial version', 'State of the project when version history was enabled.', {});
|
|
96
233
|
console.log('version-history ready.' + (where ? ' Widget injected into ' + path.relative(ROOT, where) + '.' : ' Could not find an entry HTML — add <script src="/.versions/widget.js"></script> manually.') + (r ? ' Saved version 1.' : ''));
|
|
97
234
|
console.log('Serve with restore endpoint: node .versions/serve.js (or mount .versions/middleware.js in your dev server)');
|
|
@@ -99,28 +236,41 @@ if (cmd === 'init') {
|
|
|
99
236
|
const title = args[0]; if (!title) { console.error('usage: vh record "Title" [-d "details" | -f file]'); process.exit(1); }
|
|
100
237
|
const di = args.indexOf('-d'), fi = args.indexOf('-f');
|
|
101
238
|
const details = di >= 0 ? args[di + 1] : fi >= 0 ? fs.readFileSync(args[fi + 1], 'utf8') : '';
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
console.log(r.changed ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
|
|
105
|
-
if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); }
|
|
239
|
+
const r = recordNow(title, details);
|
|
240
|
+
console.log(r ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
|
|
106
241
|
} else if (cmd === 'restore') {
|
|
107
242
|
const id = Number(args[0]), man = readMan(), target = man.find(v => v.id === id);
|
|
108
243
|
if (!target) { console.error('No version ' + args[0]); process.exit(1); }
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
244
|
+
fs.mkdirSync(VDIR, { recursive: true }); fs.writeFileSync(LOCK, String(process.pid)); // pause any watcher
|
|
245
|
+
try {
|
|
246
|
+
const cur = man[man.length - 1], prev = stateAt(cur.id, man);
|
|
247
|
+
snapshot('Snapshot before restoring "' + target.title + '"', 'Automatic safety snapshot taken before restore.', prev);
|
|
248
|
+
const want = stateAt(id, readMan()), have = walk(ROOT);
|
|
249
|
+
for (const f of Object.keys(have)) if (!want[f]) fs.rmSync(path.join(ROOT, f));
|
|
250
|
+
for (const f of Object.keys(want)) { const p = path.join(ROOT, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, want[f]); }
|
|
251
|
+
injectWidget();
|
|
252
|
+
ensureDevIntegration(true);
|
|
253
|
+
const r = snapshot('Restored "' + target.title + '"', 'Restored the state saved as version ' + id + '.', stateAt(readMan().slice(-1)[0].id, readMan()));
|
|
254
|
+
console.log('Restored version ' + id + ' (recorded as version ' + r.id + ').');
|
|
255
|
+
} finally { fs.rmSync(LOCK, { force: true }); }
|
|
256
|
+
} else if (cmd === 'watch') {
|
|
257
|
+
if (!fs.existsSync(VDIR)) { console.error('No .versions/ here — run `vh init` first.'); process.exit(1); }
|
|
258
|
+
startWatch({});
|
|
259
|
+
console.log('Watching ' + ROOT + ' — every change is auto-saved as a new version. Ctrl-C to stop.');
|
|
117
260
|
} else if (cmd === 'list') {
|
|
118
261
|
const q = (args[0] || '').toLowerCase();
|
|
119
262
|
for (const v of readMan()) if (!q || v.title.toLowerCase().includes(q) || (v.details || '').toLowerCase().includes(q)) console.log(String(v.id).padStart(3) + ' ' + v.time.slice(0, 16).replace('T', ' ') + ' ' + v.title);
|
|
263
|
+
} else if (cmd === 'update') {
|
|
264
|
+
if (!fs.existsSync(VDIR)) { console.error('No .versions/ here — run `vh init` first.'); process.exit(1); }
|
|
265
|
+
vendorFiles();
|
|
266
|
+
console.log('Updated .versions/{widget.js,vh.js,serve.js,middleware.js} from version-history-widget@' + require('./package.json').version + '. Reload the site to pick up widget changes.');
|
|
120
267
|
} else if (cmd === 'skill') {
|
|
121
268
|
const dest = path.join(os.homedir(), '.claude', 'skills', 'version-history-widget');
|
|
122
269
|
fs.mkdirSync(dest, { recursive: true });
|
|
123
270
|
fs.copyFileSync(path.join(__dirname, 'skill', 'SKILL.md'), path.join(dest, 'SKILL.md'));
|
|
124
271
|
console.log('Installed Claude Code skill to ' + dest + '. Restart Claude Code (or start a new session) and try /version-history-widget.');
|
|
125
272
|
} else { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].split('\n').slice(1).join('\n')); }
|
|
126
|
-
module.exports = {
|
|
273
|
+
module.exports = {
|
|
274
|
+
restore: id => { const { execFileSync } = require('child_process'); return execFileSync(process.execPath, [__filename, 'restore', String(id)], { cwd: ROOT, env: process.env }).toString(); },
|
|
275
|
+
watch: startWatch, record: recordNow, pending: pendingChanges, root: ROOT,
|
|
276
|
+
};
|
package/widget.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
if (window.__vhLoaded) return; window.__vhLoaded = true;
|
|
4
4
|
var Z = 2147483647, MODE = document.getElementById('vh-store') ? 'single' : 'project';
|
|
5
5
|
var css = '\
|
|
6
|
-
.vh-pill{position:fixed;right:16px;top:16px;z-index:' + Z + ';font:13px/1 system-ui,sans-serif;background:#111;color:#fff;border:0;border-radius:999px;padding:10px 14px;cursor:
|
|
6
|
+
.vh-pill{position:fixed;right:16px;top:16px;z-index:' + Z + ';font:13px/1 system-ui,sans-serif;background:#111;color:#fff;border:0;border-radius:999px;padding:10px 14px;cursor:grab;box-shadow:0 6px 20px rgba(0,0,0,.35);touch-action:none;user-select:none}\
|
|
7
|
+
.vh-pill.vh-dragging{cursor:grabbing}\
|
|
7
8
|
.vh-panel{position:fixed;right:16px;top:56px;width:440px;max-width:calc(100vw - 32px);max-height:70vh;z-index:' + Z + ';background:#fff;color:#111;border:1px solid #ddd;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.35);font:13px/1.4 system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden}\
|
|
8
9
|
.vh-head{display:flex;gap:8px;padding:10px;border-bottom:1px solid #eee;align-items:center}\
|
|
9
10
|
.vh-search{flex:1;padding:8px 10px;border:1px solid #ccc;border-radius:8px;font:inherit;outline:none}.vh-search:focus{border-color:#111}\
|
|
@@ -69,16 +70,65 @@
|
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
var POS_KEY = 'vh-pill-pos';
|
|
74
|
+
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
|
75
|
+
function loadPos() { try { var p = JSON.parse(localStorage.getItem(POS_KEY)); if (p && isFinite(p.left) && isFinite(p.top)) return p; } catch (e) { } return null; }
|
|
76
|
+
function savePos(p) { try { localStorage.setItem(POS_KEY, JSON.stringify(p)); } catch (e) { } }
|
|
77
|
+
function placePill(left, top) {
|
|
78
|
+
var r = pill.getBoundingClientRect();
|
|
79
|
+
left = clamp(left, 8, window.innerWidth - r.width - 8);
|
|
80
|
+
top = clamp(top, 8, window.innerHeight - r.height - 8);
|
|
81
|
+
pill.style.left = left + 'px'; pill.style.top = top + 'px'; pill.style.right = 'auto'; pill.style.bottom = 'auto';
|
|
82
|
+
return { left: left, top: top };
|
|
83
|
+
}
|
|
84
|
+
function positionPanel() {
|
|
85
|
+
var r = pill.getBoundingClientRect(), vw = window.innerWidth, vh = window.innerHeight;
|
|
86
|
+
var w = Math.min(440, vw - 16), h = Math.min(vh * 0.7, panel.scrollHeight || vh * 0.7);
|
|
87
|
+
var top = (vh - r.bottom >= h + 8 || vh - r.bottom >= r.top) ? r.bottom + 8 : r.top - h - 8;
|
|
88
|
+
top = clamp(top, 8, vh - h - 8);
|
|
89
|
+
var left = clamp(r.right - w, 8, vw - w - 8);
|
|
90
|
+
panel.style.top = top + 'px'; panel.style.left = left + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.width = w + 'px';
|
|
91
|
+
}
|
|
92
|
+
function initDrag() {
|
|
93
|
+
var saved = loadPos(); if (saved) placePill(saved.left, saved.top);
|
|
94
|
+
var dragging = false, moved = false, startX, startY, baseLeft, baseTop;
|
|
95
|
+
function start(x, y) {
|
|
96
|
+
var r = pill.getBoundingClientRect();
|
|
97
|
+
dragging = true; moved = false; startX = x; startY = y; baseLeft = r.left; baseTop = r.top;
|
|
98
|
+
pill.classList.add('vh-dragging');
|
|
99
|
+
}
|
|
100
|
+
function moveTo(x, y) {
|
|
101
|
+
if (!dragging) return;
|
|
102
|
+
if (Math.abs(x - startX) > 3 || Math.abs(y - startY) > 3) moved = true;
|
|
103
|
+
if (!moved) return;
|
|
104
|
+
placePill(baseLeft + (x - startX), baseTop + (y - startY));
|
|
105
|
+
if (open) positionPanel();
|
|
106
|
+
}
|
|
107
|
+
function end() {
|
|
108
|
+
if (!dragging) return;
|
|
109
|
+
dragging = false; pill.classList.remove('vh-dragging');
|
|
110
|
+
if (moved) { var r = pill.getBoundingClientRect(); savePos({ left: r.left, top: r.top }); }
|
|
111
|
+
}
|
|
112
|
+
pill.addEventListener('mousedown', function (e) { start(e.clientX, e.clientY); e.preventDefault(); });
|
|
113
|
+
document.addEventListener('mousemove', function (e) { moveTo(e.clientX, e.clientY); });
|
|
114
|
+
document.addEventListener('mouseup', end);
|
|
115
|
+
pill.addEventListener('touchstart', function (e) { var t = e.touches[0]; start(t.clientX, t.clientY); }, { passive: true });
|
|
116
|
+
document.addEventListener('touchmove', function (e) { if (!dragging) return; var t = e.touches[0]; moveTo(t.clientX, t.clientY); }, { passive: true });
|
|
117
|
+
document.addEventListener('touchend', end);
|
|
118
|
+
pill.onclick = function () { if (moved) { moved = false; return; } toggle(); };
|
|
119
|
+
window.addEventListener('resize', function () { var r = pill.getBoundingClientRect(); placePill(r.left, r.top); if (open) positionPanel(); });
|
|
120
|
+
}
|
|
72
121
|
function build() {
|
|
73
|
-
pill = document.createElement('button'); pill.className = 'vh-pill'; pill.textContent = 'Versions';
|
|
122
|
+
pill = document.createElement('button'); pill.className = 'vh-pill'; pill.textContent = 'Versions'; document.body.appendChild(pill);
|
|
74
123
|
panel = document.createElement('div'); panel.className = 'vh-panel'; panel.style.display = 'none';
|
|
75
124
|
panel.innerHTML = '<div class="vh-head"><input class="vh-search" placeholder="Search versions\u2026"><button class="vh-clear" title="Clear">\u00D7</button></div><div class="vh-list"></div><div class="vh-note"></div>';
|
|
76
125
|
var inp = panel.querySelector('.vh-search'); inp.oninput = function () { query = inp.value; render(); };
|
|
77
126
|
panel.querySelector('.vh-clear').onclick = function () { inp.value = ''; query = ''; render(); inp.focus(); };
|
|
78
127
|
document.addEventListener('keydown', function (e) { if (e.key !== 'Escape' || !open) return; if (query) { inp.value = ''; query = ''; render(); } else toggle(); });
|
|
79
128
|
document.body.appendChild(panel);
|
|
129
|
+
initDrag();
|
|
80
130
|
}
|
|
81
|
-
function toggle() { open = !open; if (open) load(function () { panel.style.display = 'flex'; render(); panel.querySelector('.vh-search').focus(); }); else panel.style.display = 'none'; }
|
|
131
|
+
function toggle() { open = !open; if (open) load(function () { panel.style.display = 'flex'; positionPanel(); render(); panel.querySelector('.vh-search').focus(); }); else panel.style.display = 'none'; }
|
|
82
132
|
function init() { build(); load(render); }
|
|
83
133
|
if (document.body) init(); else document.addEventListener('DOMContentLoaded', init);
|
|
84
134
|
})();
|