diffden 0.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/README.md +29 -0
- package/dist/cli.js +890 -0
- package/dist/index.js +883 -0
- package/package.json +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
// src/app.ts
|
|
2
|
+
import {
|
|
3
|
+
createCliRenderer,
|
|
4
|
+
BoxRenderable as BoxRenderable2,
|
|
5
|
+
TextRenderable as TextRenderable3
|
|
6
|
+
} from "@opentui/core";
|
|
7
|
+
|
|
8
|
+
// src/config.ts
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
|
|
10
|
+
import { resolve as resolve2, basename as basename2, join as join2 } from "path";
|
|
11
|
+
|
|
12
|
+
// src/utils.ts
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join, basename, dirname, resolve } from "path";
|
|
15
|
+
var DATA_DIR = join(homedir(), ".diffden");
|
|
16
|
+
var CONFIG_PATH = join(DATA_DIR, "config.json");
|
|
17
|
+
var REPOS_DIR = join(DATA_DIR, "repos");
|
|
18
|
+
function relativeTime(date) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const diff = now - date.getTime();
|
|
21
|
+
const seconds = Math.floor(diff / 1e3);
|
|
22
|
+
const minutes = Math.floor(seconds / 60);
|
|
23
|
+
const hours = Math.floor(minutes / 60);
|
|
24
|
+
const days = Math.floor(hours / 24);
|
|
25
|
+
if (seconds < 5) return "just now";
|
|
26
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
27
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
28
|
+
if (hours < 24) return `${hours}h ago`;
|
|
29
|
+
if (days === 1) return "yesterday";
|
|
30
|
+
if (days < 30) return `${days}d ago`;
|
|
31
|
+
return date.toLocaleDateString();
|
|
32
|
+
}
|
|
33
|
+
function projectSlug(dirPath) {
|
|
34
|
+
return basename(resolve(dirPath)).replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
35
|
+
}
|
|
36
|
+
function projectDirFromFile(filePath) {
|
|
37
|
+
return dirname(resolve(filePath));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/config.ts
|
|
41
|
+
function ensureDirs() {
|
|
42
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
43
|
+
mkdirSync(REPOS_DIR, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
function loadConfig() {
|
|
46
|
+
ensureDirs();
|
|
47
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
48
|
+
const config = { projects: [] };
|
|
49
|
+
saveConfig(config);
|
|
50
|
+
return config;
|
|
51
|
+
}
|
|
52
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
function saveConfig(config) {
|
|
56
|
+
ensureDirs();
|
|
57
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
58
|
+
}
|
|
59
|
+
function addFileToConfig(config, filePath) {
|
|
60
|
+
const absPath = resolve2(filePath);
|
|
61
|
+
const dir = projectDirFromFile(absPath);
|
|
62
|
+
const slug = projectSlug(dir);
|
|
63
|
+
const fileName = basename2(absPath);
|
|
64
|
+
let project = config.projects.find((p) => p.slug === slug);
|
|
65
|
+
if (!project) {
|
|
66
|
+
project = { slug, dir, files: [] };
|
|
67
|
+
config.projects.push(project);
|
|
68
|
+
}
|
|
69
|
+
if (!project.files.includes(fileName)) {
|
|
70
|
+
project.files.push(fileName);
|
|
71
|
+
}
|
|
72
|
+
saveConfig(config);
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
function removeFileFromConfig(config, filePath) {
|
|
76
|
+
const absPath = resolve2(filePath);
|
|
77
|
+
const dir = projectDirFromFile(absPath);
|
|
78
|
+
const slug = projectSlug(dir);
|
|
79
|
+
const fileName = basename2(absPath);
|
|
80
|
+
const project = config.projects.find((p) => p.slug === slug);
|
|
81
|
+
if (project) {
|
|
82
|
+
project.files = project.files.filter((f) => f !== fileName);
|
|
83
|
+
if (project.files.length === 0) {
|
|
84
|
+
config.projects = config.projects.filter((p) => p.slug !== slug);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
saveConfig(config);
|
|
88
|
+
return config;
|
|
89
|
+
}
|
|
90
|
+
function getRepoPath(slug) {
|
|
91
|
+
return `${REPOS_DIR}/${slug}`;
|
|
92
|
+
}
|
|
93
|
+
function getFullFilePath(project, fileName) {
|
|
94
|
+
return join2(project.dir, fileName);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/tracker.ts
|
|
98
|
+
import simpleGit from "simple-git";
|
|
99
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, copyFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
100
|
+
import { join as join3, basename as basename3 } from "path";
|
|
101
|
+
function ensureRepoDir(slug) {
|
|
102
|
+
const repoPath = getRepoPath(slug);
|
|
103
|
+
mkdirSync2(repoPath, { recursive: true });
|
|
104
|
+
return repoPath;
|
|
105
|
+
}
|
|
106
|
+
async function getGit(slug) {
|
|
107
|
+
const repoPath = ensureRepoDir(slug);
|
|
108
|
+
const git = simpleGit(repoPath);
|
|
109
|
+
const isRepo = await git.checkIsRepo();
|
|
110
|
+
if (!isRepo) {
|
|
111
|
+
await git.init();
|
|
112
|
+
await git.addConfig("user.name", "diffden");
|
|
113
|
+
await git.addConfig("user.email", "diffden@local");
|
|
114
|
+
}
|
|
115
|
+
return git;
|
|
116
|
+
}
|
|
117
|
+
async function snapshot(slug, sourceFilePath) {
|
|
118
|
+
const git = await getGit(slug);
|
|
119
|
+
const repoPath = getRepoPath(slug);
|
|
120
|
+
const fileName = basename3(sourceFilePath);
|
|
121
|
+
const destPath = join3(repoPath, fileName);
|
|
122
|
+
if (!existsSync2(sourceFilePath)) return null;
|
|
123
|
+
copyFileSync(sourceFilePath, destPath);
|
|
124
|
+
await git.add(fileName);
|
|
125
|
+
const status = await git.status();
|
|
126
|
+
if (status.staged.length === 0) return null;
|
|
127
|
+
const result = await git.commit(`[${fileName}] auto-snapshot`);
|
|
128
|
+
return result.commit || null;
|
|
129
|
+
}
|
|
130
|
+
async function getLog(slug, fileName) {
|
|
131
|
+
const git = await getGit(slug);
|
|
132
|
+
const repoPath = getRepoPath(slug);
|
|
133
|
+
try {
|
|
134
|
+
const args = ["--stat", "--stat-width=200"];
|
|
135
|
+
if (fileName) {
|
|
136
|
+
args.push("--follow", "--", fileName);
|
|
137
|
+
}
|
|
138
|
+
const log = await git.log(args);
|
|
139
|
+
return log.all.map((entry) => {
|
|
140
|
+
const diff = entry.diff;
|
|
141
|
+
let insertions = 0;
|
|
142
|
+
let deletions = 0;
|
|
143
|
+
if (diff) {
|
|
144
|
+
insertions = diff.insertions ?? 0;
|
|
145
|
+
deletions = diff.deletions ?? 0;
|
|
146
|
+
}
|
|
147
|
+
if (insertions === 0 && deletions === 0 && entry.body) {
|
|
148
|
+
const statMatch = entry.body.match(/(\d+) insertion.*?(\d+) deletion/);
|
|
149
|
+
if (statMatch) {
|
|
150
|
+
insertions = parseInt(statMatch[1], 10);
|
|
151
|
+
deletions = parseInt(statMatch[2], 10);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
hash: entry.hash,
|
|
156
|
+
date: new Date(entry.date),
|
|
157
|
+
message: entry.message,
|
|
158
|
+
insertions,
|
|
159
|
+
deletions
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function getDiff(slug, hash, fileName) {
|
|
167
|
+
const git = await getGit(slug);
|
|
168
|
+
try {
|
|
169
|
+
const args = [`${hash}^`, hash];
|
|
170
|
+
if (fileName) args.push("--", fileName);
|
|
171
|
+
const diff = await git.diff(args);
|
|
172
|
+
return diff || "(no diff available)";
|
|
173
|
+
} catch {
|
|
174
|
+
try {
|
|
175
|
+
const diff = await git.diff(["4b825dc642cb6eb9a060e54bf899d8b2da2e7862", hash]);
|
|
176
|
+
return diff || "(initial snapshot)";
|
|
177
|
+
} catch {
|
|
178
|
+
return "(no diff available)";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function getContent(slug, hash, fileName) {
|
|
183
|
+
const git = await getGit(slug);
|
|
184
|
+
try {
|
|
185
|
+
return await git.show([`${hash}:${fileName}`]);
|
|
186
|
+
} catch {
|
|
187
|
+
return "(content not available)";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function restore(slug, hash, fileName, destPath) {
|
|
191
|
+
try {
|
|
192
|
+
const content = await getContent(slug, hash, fileName);
|
|
193
|
+
if (content === "(content not available)") return false;
|
|
194
|
+
writeFileSync2(destPath, content);
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function getSnapshotCount(slug, fileName) {
|
|
201
|
+
const log = await getLog(slug, fileName);
|
|
202
|
+
return log.length;
|
|
203
|
+
}
|
|
204
|
+
async function getLatestSnapshot(slug, fileName) {
|
|
205
|
+
const log = await getLog(slug, fileName);
|
|
206
|
+
return log[0] ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/watcher.ts
|
|
210
|
+
import { watch } from "chokidar";
|
|
211
|
+
import { join as join4 } from "path";
|
|
212
|
+
var DEBOUNCE_MS = 500;
|
|
213
|
+
var watchers = /* @__PURE__ */ new Map();
|
|
214
|
+
var onSnapshotCallback = null;
|
|
215
|
+
function onSnapshot(cb) {
|
|
216
|
+
onSnapshotCallback = cb;
|
|
217
|
+
}
|
|
218
|
+
async function startWatching(project) {
|
|
219
|
+
const slug = project.slug;
|
|
220
|
+
for (const fileName of project.files) {
|
|
221
|
+
const filePath = join4(project.dir, fileName);
|
|
222
|
+
const key = `${slug}:${fileName}`;
|
|
223
|
+
if (watchers.has(key)) continue;
|
|
224
|
+
let debounceTimer = null;
|
|
225
|
+
const watcher = watch(filePath, {
|
|
226
|
+
persistent: true,
|
|
227
|
+
ignoreInitial: true,
|
|
228
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }
|
|
229
|
+
});
|
|
230
|
+
watcher.on("change", () => {
|
|
231
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
232
|
+
debounceTimer = setTimeout(async () => {
|
|
233
|
+
const hash = await snapshot(slug, filePath);
|
|
234
|
+
if (hash && onSnapshotCallback) {
|
|
235
|
+
onSnapshotCallback(slug, fileName);
|
|
236
|
+
}
|
|
237
|
+
}, DEBOUNCE_MS);
|
|
238
|
+
});
|
|
239
|
+
watchers.set(key, { watcher, filePath, slug });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function stopWatching(slug, fileName) {
|
|
243
|
+
for (const [key, entry] of watchers) {
|
|
244
|
+
if (entry.slug === slug && (!fileName || key.endsWith(`:${fileName}`))) {
|
|
245
|
+
await entry.watcher.close();
|
|
246
|
+
watchers.delete(key);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function stopAll() {
|
|
251
|
+
for (const [key, entry] of watchers) {
|
|
252
|
+
await entry.watcher.close();
|
|
253
|
+
}
|
|
254
|
+
watchers.clear();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/editor.ts
|
|
258
|
+
import { spawn } from "child_process";
|
|
259
|
+
function openInEditor(slug, editor) {
|
|
260
|
+
const repoPath = getRepoPath(slug);
|
|
261
|
+
const cmd = editor || process.env.VISUAL || process.env.EDITOR || "code";
|
|
262
|
+
try {
|
|
263
|
+
const child = spawn(cmd, [repoPath], {
|
|
264
|
+
detached: true,
|
|
265
|
+
stdio: "ignore"
|
|
266
|
+
});
|
|
267
|
+
child.unref();
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function getLinkInstructions(slug) {
|
|
274
|
+
const repoPath = getRepoPath(slug);
|
|
275
|
+
return [
|
|
276
|
+
"To link the tracking repo in VS Code's git sidebar:",
|
|
277
|
+
"",
|
|
278
|
+
"Option 1: Add to workspace settings (.vscode/settings.json):",
|
|
279
|
+
` "git.repositories": ["${repoPath}"]`,
|
|
280
|
+
"",
|
|
281
|
+
"Option 2: Add as multi-root workspace folder:",
|
|
282
|
+
` File > Add Folder to Workspace... > ${repoPath}`,
|
|
283
|
+
"",
|
|
284
|
+
"Option 3: Open tracking repo directly:",
|
|
285
|
+
` code ${repoPath}`,
|
|
286
|
+
"",
|
|
287
|
+
"Then use GitLens or the built-in Git sidebar to browse history."
|
|
288
|
+
].join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/app.ts
|
|
292
|
+
import { resolve as resolvePath, basename as basenamePath, dirname as dirnamePath } from "path";
|
|
293
|
+
|
|
294
|
+
// src/ui/project-list.ts
|
|
295
|
+
import { SelectRenderable } from "@opentui/core";
|
|
296
|
+
function formatOption(item) {
|
|
297
|
+
return {
|
|
298
|
+
name: `${item.slug}`,
|
|
299
|
+
description: `${item.fileCount} file${item.fileCount !== 1 ? "s" : ""} \xB7 ${item.dir}`,
|
|
300
|
+
value: item
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function createProjectList(ctx) {
|
|
304
|
+
return new SelectRenderable(ctx, {
|
|
305
|
+
showDescription: true,
|
|
306
|
+
showScrollIndicator: true,
|
|
307
|
+
wrapSelection: true,
|
|
308
|
+
backgroundColor: "#1a1a2e",
|
|
309
|
+
textColor: "#e0e0e0",
|
|
310
|
+
selectedBackgroundColor: "#16213e",
|
|
311
|
+
selectedTextColor: "#00d2ff",
|
|
312
|
+
focusedBackgroundColor: "#1a1a2e",
|
|
313
|
+
focusedTextColor: "#e0e0e0",
|
|
314
|
+
descriptionColor: "#666680",
|
|
315
|
+
selectedDescriptionColor: "#4a90d9"
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
function updateProjectList(select, items) {
|
|
319
|
+
select.options = items.map(formatOption);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/ui/file-list.ts
|
|
323
|
+
import { SelectRenderable as SelectRenderable2 } from "@opentui/core";
|
|
324
|
+
function formatOption2(item) {
|
|
325
|
+
const count = `${item.snapshotCount} snap${item.snapshotCount !== 1 ? "s" : ""}`;
|
|
326
|
+
const time = item.lastChanged ? relativeTime(item.lastChanged) : "no snapshots";
|
|
327
|
+
return {
|
|
328
|
+
name: item.fileName,
|
|
329
|
+
description: `${count} \xB7 ${time}`,
|
|
330
|
+
value: item
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function createFileList(ctx) {
|
|
334
|
+
return new SelectRenderable2(ctx, {
|
|
335
|
+
showDescription: true,
|
|
336
|
+
showScrollIndicator: true,
|
|
337
|
+
wrapSelection: true,
|
|
338
|
+
backgroundColor: "#1a1a2e",
|
|
339
|
+
textColor: "#e0e0e0",
|
|
340
|
+
selectedBackgroundColor: "#16213e",
|
|
341
|
+
selectedTextColor: "#00d2ff",
|
|
342
|
+
focusedBackgroundColor: "#1a1a2e",
|
|
343
|
+
focusedTextColor: "#e0e0e0",
|
|
344
|
+
descriptionColor: "#666680",
|
|
345
|
+
selectedDescriptionColor: "#4a90d9"
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function updateFileList(select, items) {
|
|
349
|
+
select.options = items.map(formatOption2);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/ui/snapshot-list.ts
|
|
353
|
+
import { SelectRenderable as SelectRenderable3 } from "@opentui/core";
|
|
354
|
+
function formatOption3(snap) {
|
|
355
|
+
const time = relativeTime(snap.date);
|
|
356
|
+
const stats = `+${snap.insertions} -${snap.deletions}`;
|
|
357
|
+
return {
|
|
358
|
+
name: time,
|
|
359
|
+
description: stats,
|
|
360
|
+
value: snap
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function createSnapshotList(ctx) {
|
|
364
|
+
return new SelectRenderable3(ctx, {
|
|
365
|
+
showDescription: true,
|
|
366
|
+
showScrollIndicator: true,
|
|
367
|
+
wrapSelection: true,
|
|
368
|
+
backgroundColor: "#1a1a2e",
|
|
369
|
+
textColor: "#e0e0e0",
|
|
370
|
+
selectedBackgroundColor: "#16213e",
|
|
371
|
+
selectedTextColor: "#00d2ff",
|
|
372
|
+
focusedBackgroundColor: "#1a1a2e",
|
|
373
|
+
focusedTextColor: "#e0e0e0",
|
|
374
|
+
descriptionColor: "#666680",
|
|
375
|
+
selectedDescriptionColor: "#4a90d9"
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function updateSnapshotList(select, snapshots) {
|
|
379
|
+
select.options = snapshots.map(formatOption3);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/ui/preview.ts
|
|
383
|
+
import { TextRenderable } from "@opentui/core";
|
|
384
|
+
function createPreview(ctx) {
|
|
385
|
+
return new TextRenderable(ctx, {
|
|
386
|
+
content: "",
|
|
387
|
+
fg: "#c0c0c0",
|
|
388
|
+
bg: "#0d0d1a",
|
|
389
|
+
wrapMode: "char"
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function updatePreview(text, content) {
|
|
393
|
+
text.content = content;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/ui/command-bar.ts
|
|
397
|
+
import { InputRenderable, BoxRenderable, TextRenderable as TextRenderable2 } from "@opentui/core";
|
|
398
|
+
function createCommandBar(ctx) {
|
|
399
|
+
const container = new BoxRenderable(ctx, {
|
|
400
|
+
flexDirection: "row",
|
|
401
|
+
height: 1,
|
|
402
|
+
width: "100%",
|
|
403
|
+
backgroundColor: "#16213e"
|
|
404
|
+
});
|
|
405
|
+
const hints = new TextRenderable2(ctx, {
|
|
406
|
+
content: "[j/k] nav [Enter/l] select [Esc/h] back [Tab] diff/full [r] restore [o] open [/] cmd [q] quit",
|
|
407
|
+
fg: "#666680",
|
|
408
|
+
bg: "#16213e",
|
|
409
|
+
flexGrow: 1,
|
|
410
|
+
height: 1,
|
|
411
|
+
truncate: true
|
|
412
|
+
});
|
|
413
|
+
const status = new TextRenderable2(ctx, {
|
|
414
|
+
content: "",
|
|
415
|
+
fg: "#00d2ff",
|
|
416
|
+
bg: "#16213e",
|
|
417
|
+
height: 1,
|
|
418
|
+
flexShrink: 0
|
|
419
|
+
});
|
|
420
|
+
const input = new InputRenderable(ctx, {
|
|
421
|
+
placeholder: "Type / for commands...",
|
|
422
|
+
width: "100%",
|
|
423
|
+
textColor: "#e0e0e0",
|
|
424
|
+
backgroundColor: "#16213e",
|
|
425
|
+
visible: false
|
|
426
|
+
});
|
|
427
|
+
container.add(hints);
|
|
428
|
+
container.add(status);
|
|
429
|
+
container.add(input);
|
|
430
|
+
return { container, input, hints, status };
|
|
431
|
+
}
|
|
432
|
+
function showInput(bar) {
|
|
433
|
+
bar.hints.visible = false;
|
|
434
|
+
bar.status.visible = false;
|
|
435
|
+
bar.input.visible = true;
|
|
436
|
+
bar.input.value = "/";
|
|
437
|
+
bar.input.focus();
|
|
438
|
+
}
|
|
439
|
+
function hideInput(bar) {
|
|
440
|
+
bar.input.visible = false;
|
|
441
|
+
bar.input.blur();
|
|
442
|
+
bar.input.value = "";
|
|
443
|
+
bar.hints.visible = true;
|
|
444
|
+
bar.status.visible = true;
|
|
445
|
+
}
|
|
446
|
+
function setStatus(bar, msg) {
|
|
447
|
+
bar.status.content = msg ? ` ${msg} ` : "";
|
|
448
|
+
}
|
|
449
|
+
function updateHints(bar, focusedColumn) {
|
|
450
|
+
const base = "[q] quit [/] cmd";
|
|
451
|
+
const nav = "[j/k] nav";
|
|
452
|
+
const select = "[Enter/l] select";
|
|
453
|
+
const back = "[Esc/h] back";
|
|
454
|
+
switch (focusedColumn) {
|
|
455
|
+
case 0:
|
|
456
|
+
bar.hints.content = `${nav} ${select} [o] open ${base}`;
|
|
457
|
+
break;
|
|
458
|
+
case 1:
|
|
459
|
+
bar.hints.content = `${nav} ${select} ${back} [o] open ${base}`;
|
|
460
|
+
break;
|
|
461
|
+
case 2:
|
|
462
|
+
bar.hints.content = `${nav} ${select} ${back} [Tab] diff/full [r] restore ${base}`;
|
|
463
|
+
break;
|
|
464
|
+
case 3:
|
|
465
|
+
bar.hints.content = `${back} [Tab] diff/full [j/k] scroll ${base}`;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/app.ts
|
|
471
|
+
var COL_PROJECTS = 0;
|
|
472
|
+
var COL_FILES = 1;
|
|
473
|
+
var COL_SNAPSHOTS = 2;
|
|
474
|
+
var COL_PREVIEW = 3;
|
|
475
|
+
async function startApp(initialFilePath) {
|
|
476
|
+
const renderer = await createCliRenderer({
|
|
477
|
+
exitOnCtrlC: true,
|
|
478
|
+
useAlternateScreen: true,
|
|
479
|
+
useMouse: true,
|
|
480
|
+
backgroundColor: "#0d0d1a"
|
|
481
|
+
});
|
|
482
|
+
await renderer.setupTerminal();
|
|
483
|
+
const state = {
|
|
484
|
+
config: loadConfig(),
|
|
485
|
+
focusedColumn: 0,
|
|
486
|
+
selectedProject: null,
|
|
487
|
+
selectedFileName: null,
|
|
488
|
+
selectedSnapshot: null,
|
|
489
|
+
snapshots: [],
|
|
490
|
+
previewMode: "diff",
|
|
491
|
+
commandMode: false
|
|
492
|
+
};
|
|
493
|
+
if (initialFilePath) {
|
|
494
|
+
state.config = addFileToConfig(state.config, initialFilePath);
|
|
495
|
+
}
|
|
496
|
+
const rootBox = new BoxRenderable2(renderer.root.ctx, {
|
|
497
|
+
flexDirection: "column",
|
|
498
|
+
width: "100%",
|
|
499
|
+
height: "100%",
|
|
500
|
+
backgroundColor: "#0d0d1a"
|
|
501
|
+
});
|
|
502
|
+
const titleBar = new TextRenderable3(renderer.root.ctx, {
|
|
503
|
+
content: " DiffDen ",
|
|
504
|
+
fg: "#00d2ff",
|
|
505
|
+
bg: "#16213e",
|
|
506
|
+
height: 1,
|
|
507
|
+
width: "100%",
|
|
508
|
+
truncate: true
|
|
509
|
+
});
|
|
510
|
+
const columnsBox = new BoxRenderable2(renderer.root.ctx, {
|
|
511
|
+
flexDirection: "row",
|
|
512
|
+
flexGrow: 1,
|
|
513
|
+
width: "100%"
|
|
514
|
+
});
|
|
515
|
+
const projectBox = new BoxRenderable2(renderer.root.ctx, {
|
|
516
|
+
border: true,
|
|
517
|
+
borderColor: "#00d2ff",
|
|
518
|
+
title: " Projects ",
|
|
519
|
+
backgroundColor: "#1a1a2e",
|
|
520
|
+
flexGrow: 1,
|
|
521
|
+
minWidth: 15
|
|
522
|
+
});
|
|
523
|
+
const fileBox = new BoxRenderable2(renderer.root.ctx, {
|
|
524
|
+
border: true,
|
|
525
|
+
borderColor: "#333355",
|
|
526
|
+
title: " Files ",
|
|
527
|
+
backgroundColor: "#1a1a2e",
|
|
528
|
+
flexGrow: 1,
|
|
529
|
+
minWidth: 20
|
|
530
|
+
});
|
|
531
|
+
const snapshotBox = new BoxRenderable2(renderer.root.ctx, {
|
|
532
|
+
border: true,
|
|
533
|
+
borderColor: "#333355",
|
|
534
|
+
title: " Snapshots ",
|
|
535
|
+
backgroundColor: "#1a1a2e",
|
|
536
|
+
flexGrow: 1,
|
|
537
|
+
minWidth: 22
|
|
538
|
+
});
|
|
539
|
+
const previewBox = new BoxRenderable2(renderer.root.ctx, {
|
|
540
|
+
border: true,
|
|
541
|
+
borderColor: "#333355",
|
|
542
|
+
title: " Preview ",
|
|
543
|
+
backgroundColor: "#0d0d1a",
|
|
544
|
+
flexGrow: 2,
|
|
545
|
+
minWidth: 20
|
|
546
|
+
});
|
|
547
|
+
const projectList = createProjectList(renderer.root.ctx);
|
|
548
|
+
projectList.width = "100%";
|
|
549
|
+
projectList.height = "100%";
|
|
550
|
+
projectBox.add(projectList);
|
|
551
|
+
const fileList = createFileList(renderer.root.ctx);
|
|
552
|
+
fileList.width = "100%";
|
|
553
|
+
fileList.height = "100%";
|
|
554
|
+
fileBox.add(fileList);
|
|
555
|
+
const snapshotList = createSnapshotList(renderer.root.ctx);
|
|
556
|
+
snapshotList.width = "100%";
|
|
557
|
+
snapshotList.height = "100%";
|
|
558
|
+
snapshotBox.add(snapshotList);
|
|
559
|
+
const preview = createPreview(renderer.root.ctx);
|
|
560
|
+
preview.width = "100%";
|
|
561
|
+
preview.height = "100%";
|
|
562
|
+
previewBox.add(preview);
|
|
563
|
+
const commandBar = createCommandBar(renderer.root.ctx);
|
|
564
|
+
columnsBox.add(projectBox);
|
|
565
|
+
columnsBox.add(fileBox);
|
|
566
|
+
columnsBox.add(snapshotBox);
|
|
567
|
+
columnsBox.add(previewBox);
|
|
568
|
+
rootBox.add(titleBar);
|
|
569
|
+
rootBox.add(columnsBox);
|
|
570
|
+
rootBox.add(commandBar.container);
|
|
571
|
+
renderer.root.add(rootBox);
|
|
572
|
+
const columns = [projectList, fileList, snapshotList, preview];
|
|
573
|
+
const columnBoxes = [projectBox, fileBox, snapshotBox, previewBox];
|
|
574
|
+
function updateColumnVisibility() {
|
|
575
|
+
const w = renderer.width;
|
|
576
|
+
const focus = state.focusedColumn;
|
|
577
|
+
if (w >= 120) {
|
|
578
|
+
for (const box of columnBoxes) box.visible = true;
|
|
579
|
+
} else if (w >= 80) {
|
|
580
|
+
if (focus <= 1) {
|
|
581
|
+
projectBox.visible = true;
|
|
582
|
+
fileBox.visible = true;
|
|
583
|
+
snapshotBox.visible = true;
|
|
584
|
+
previewBox.visible = false;
|
|
585
|
+
} else {
|
|
586
|
+
projectBox.visible = false;
|
|
587
|
+
fileBox.visible = true;
|
|
588
|
+
snapshotBox.visible = true;
|
|
589
|
+
previewBox.visible = true;
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
for (const box of columnBoxes) box.visible = false;
|
|
593
|
+
columnBoxes[focus].visible = true;
|
|
594
|
+
if (focus < 3) {
|
|
595
|
+
columnBoxes[focus + 1].visible = true;
|
|
596
|
+
} else {
|
|
597
|
+
columnBoxes[focus - 1].visible = true;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function updateFocusStyles() {
|
|
602
|
+
for (let i = 0; i < columnBoxes.length; i++) {
|
|
603
|
+
columnBoxes[i].borderColor = i === state.focusedColumn ? "#00d2ff" : "#333355";
|
|
604
|
+
}
|
|
605
|
+
updateHints(commandBar, state.focusedColumn);
|
|
606
|
+
updateColumnVisibility();
|
|
607
|
+
}
|
|
608
|
+
function focusColumn(col) {
|
|
609
|
+
if (col < 0 || col > 3) return;
|
|
610
|
+
state.focusedColumn = col;
|
|
611
|
+
const target = columns[col];
|
|
612
|
+
if (target && "focus" in target) {
|
|
613
|
+
target.focus();
|
|
614
|
+
}
|
|
615
|
+
updateFocusStyles();
|
|
616
|
+
renderer.requestRender();
|
|
617
|
+
}
|
|
618
|
+
async function refreshProjects() {
|
|
619
|
+
state.config = loadConfig();
|
|
620
|
+
const items = [];
|
|
621
|
+
for (const project of state.config.projects) {
|
|
622
|
+
const fileCount = project.files.length;
|
|
623
|
+
items.push({ slug: project.slug, dir: project.dir, fileCount });
|
|
624
|
+
}
|
|
625
|
+
updateProjectList(projectList, items);
|
|
626
|
+
if (items.length === 0) {
|
|
627
|
+
titleBar.content = " DiffDen \u2014 use /watch <path> to add a file ";
|
|
628
|
+
} else {
|
|
629
|
+
titleBar.content = " DiffDen ";
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function refreshFiles() {
|
|
633
|
+
if (!state.selectedProject) {
|
|
634
|
+
updateFileList(fileList, []);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const project = state.selectedProject;
|
|
638
|
+
const items = [];
|
|
639
|
+
for (const fileName of project.files) {
|
|
640
|
+
const count = await getSnapshotCount(project.slug, fileName);
|
|
641
|
+
const latest = await getLatestSnapshot(project.slug, fileName);
|
|
642
|
+
items.push({
|
|
643
|
+
fileName,
|
|
644
|
+
snapshotCount: count,
|
|
645
|
+
lastChanged: latest?.date ?? null
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
updateFileList(fileList, items);
|
|
649
|
+
fileBox.title = ` Files \u2014 ${project.slug} `;
|
|
650
|
+
}
|
|
651
|
+
async function refreshSnapshots() {
|
|
652
|
+
if (!state.selectedProject || !state.selectedFileName) {
|
|
653
|
+
updateSnapshotList(snapshotList, []);
|
|
654
|
+
state.snapshots = [];
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const snaps = await getLog(state.selectedProject.slug, state.selectedFileName);
|
|
658
|
+
state.snapshots = snaps;
|
|
659
|
+
updateSnapshotList(snapshotList, snaps);
|
|
660
|
+
snapshotBox.title = ` Snapshots \u2014 ${state.selectedFileName} `;
|
|
661
|
+
}
|
|
662
|
+
async function refreshPreview() {
|
|
663
|
+
if (!state.selectedProject || !state.selectedFileName || !state.selectedSnapshot) {
|
|
664
|
+
updatePreview(preview, "");
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const snap = state.selectedSnapshot;
|
|
668
|
+
if (state.previewMode === "diff") {
|
|
669
|
+
const diff = await getDiff(state.selectedProject.slug, snap.hash, state.selectedFileName);
|
|
670
|
+
updatePreview(preview, diff);
|
|
671
|
+
previewBox.title = " Diff ";
|
|
672
|
+
} else {
|
|
673
|
+
const content = await getContent(state.selectedProject.slug, snap.hash, state.selectedFileName);
|
|
674
|
+
updatePreview(preview, content);
|
|
675
|
+
previewBox.title = " Full Content ";
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
projectList.on("selectionChanged", async (_index, option) => {
|
|
679
|
+
if (!option?.value) return;
|
|
680
|
+
const item = option.value;
|
|
681
|
+
state.selectedProject = state.config.projects.find((p) => p.slug === item.slug) ?? null;
|
|
682
|
+
state.selectedFileName = null;
|
|
683
|
+
state.selectedSnapshot = null;
|
|
684
|
+
await refreshFiles();
|
|
685
|
+
updateSnapshotList(snapshotList, []);
|
|
686
|
+
updatePreview(preview, "");
|
|
687
|
+
});
|
|
688
|
+
projectList.on("itemSelected", async (_index, option) => {
|
|
689
|
+
if (!option?.value) return;
|
|
690
|
+
const item = option.value;
|
|
691
|
+
state.selectedProject = state.config.projects.find((p) => p.slug === item.slug) ?? null;
|
|
692
|
+
await refreshFiles();
|
|
693
|
+
focusColumn(COL_FILES);
|
|
694
|
+
});
|
|
695
|
+
fileList.on("selectionChanged", async (_index, option) => {
|
|
696
|
+
if (!option?.value) return;
|
|
697
|
+
const item = option.value;
|
|
698
|
+
state.selectedFileName = item.fileName;
|
|
699
|
+
state.selectedSnapshot = null;
|
|
700
|
+
await refreshSnapshots();
|
|
701
|
+
updatePreview(preview, "");
|
|
702
|
+
});
|
|
703
|
+
fileList.on("itemSelected", async (_index, option) => {
|
|
704
|
+
if (!option?.value) return;
|
|
705
|
+
const item = option.value;
|
|
706
|
+
state.selectedFileName = item.fileName;
|
|
707
|
+
await refreshSnapshots();
|
|
708
|
+
focusColumn(COL_SNAPSHOTS);
|
|
709
|
+
});
|
|
710
|
+
snapshotList.on("selectionChanged", async (_index, option) => {
|
|
711
|
+
if (!option?.value) return;
|
|
712
|
+
state.selectedSnapshot = option.value;
|
|
713
|
+
await refreshPreview();
|
|
714
|
+
});
|
|
715
|
+
snapshotList.on("itemSelected", async () => {
|
|
716
|
+
focusColumn(COL_PREVIEW);
|
|
717
|
+
});
|
|
718
|
+
commandBar.input.on("enter", async (value) => {
|
|
719
|
+
hideInput(commandBar);
|
|
720
|
+
state.commandMode = false;
|
|
721
|
+
focusColumn(state.focusedColumn);
|
|
722
|
+
await handleCommand(value.trim());
|
|
723
|
+
});
|
|
724
|
+
async function handleCommand(cmd) {
|
|
725
|
+
if (!cmd.startsWith("/")) return;
|
|
726
|
+
const parts = cmd.slice(1).split(/\s+/);
|
|
727
|
+
const command = parts[0];
|
|
728
|
+
const arg = parts.slice(1).join(" ");
|
|
729
|
+
switch (command) {
|
|
730
|
+
case "watch": {
|
|
731
|
+
if (!arg) {
|
|
732
|
+
setStatus(commandBar, "Usage: /watch <file-path>");
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
state.config = addFileToConfig(state.config, arg);
|
|
736
|
+
const watchAbsPath = resolvePath(arg);
|
|
737
|
+
const watchSlug = projectSlug(dirnamePath(watchAbsPath));
|
|
738
|
+
await snapshot(watchSlug, watchAbsPath);
|
|
739
|
+
const watchProject = state.config.projects.find((p) => p.slug === watchSlug);
|
|
740
|
+
if (watchProject) await startWatching(watchProject);
|
|
741
|
+
await refreshProjects();
|
|
742
|
+
setStatus(commandBar, `Watching: ${basenamePath(watchAbsPath)}`);
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
case "unwatch": {
|
|
746
|
+
if (!arg) {
|
|
747
|
+
setStatus(commandBar, "Usage: /unwatch <file-path>");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const unwatchAbsPath = resolvePath(arg);
|
|
751
|
+
const unwatchSlug = projectSlug(dirnamePath(unwatchAbsPath));
|
|
752
|
+
await stopWatching(unwatchSlug, basenamePath(unwatchAbsPath));
|
|
753
|
+
state.config = removeFileFromConfig(state.config, arg);
|
|
754
|
+
await refreshProjects();
|
|
755
|
+
setStatus(commandBar, `Unwatched: ${basenamePath(unwatchAbsPath)}`);
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
case "restore": {
|
|
759
|
+
if (state.selectedProject && state.selectedFileName && state.selectedSnapshot) {
|
|
760
|
+
const destPath = getFullFilePath(state.selectedProject, state.selectedFileName);
|
|
761
|
+
const ok = await restore(state.selectedProject.slug, state.selectedSnapshot.hash, state.selectedFileName, destPath);
|
|
762
|
+
setStatus(commandBar, ok ? "Restored!" : "Restore failed");
|
|
763
|
+
} else {
|
|
764
|
+
setStatus(commandBar, "Select a snapshot first");
|
|
765
|
+
}
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
case "open": {
|
|
769
|
+
const slug = state.selectedProject?.slug;
|
|
770
|
+
if (slug) {
|
|
771
|
+
openInEditor(slug, state.config.editor);
|
|
772
|
+
setStatus(commandBar, "Opened in editor");
|
|
773
|
+
} else {
|
|
774
|
+
setStatus(commandBar, "Select a project first");
|
|
775
|
+
}
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
case "link": {
|
|
779
|
+
const slug = state.selectedProject?.slug;
|
|
780
|
+
if (slug) {
|
|
781
|
+
const instructions = getLinkInstructions(slug);
|
|
782
|
+
updatePreview(preview, instructions);
|
|
783
|
+
previewBox.title = " Link Instructions ";
|
|
784
|
+
focusColumn(COL_PREVIEW);
|
|
785
|
+
} else {
|
|
786
|
+
setStatus(commandBar, "Select a project first");
|
|
787
|
+
}
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
default:
|
|
791
|
+
setStatus(commandBar, `Unknown command: /${command}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
renderer.keyInput.on("keypress", async (key) => {
|
|
795
|
+
if (state.commandMode) {
|
|
796
|
+
if (key.name === "escape") {
|
|
797
|
+
hideInput(commandBar);
|
|
798
|
+
state.commandMode = false;
|
|
799
|
+
focusColumn(state.focusedColumn);
|
|
800
|
+
}
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
switch (key.name) {
|
|
804
|
+
case "q":
|
|
805
|
+
await stopAll();
|
|
806
|
+
renderer.destroy();
|
|
807
|
+
process.exit(0);
|
|
808
|
+
break;
|
|
809
|
+
case "/":
|
|
810
|
+
state.commandMode = true;
|
|
811
|
+
showInput(commandBar);
|
|
812
|
+
break;
|
|
813
|
+
case "l":
|
|
814
|
+
case "right": {
|
|
815
|
+
const col = state.focusedColumn;
|
|
816
|
+
if (col < 3) {
|
|
817
|
+
const current = columns[col];
|
|
818
|
+
if (current && "selectCurrent" in current) {
|
|
819
|
+
current.selectCurrent();
|
|
820
|
+
}
|
|
821
|
+
focusColumn(col + 1);
|
|
822
|
+
}
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
case "h":
|
|
826
|
+
case "left":
|
|
827
|
+
case "escape":
|
|
828
|
+
if (state.focusedColumn > 0) {
|
|
829
|
+
focusColumn(state.focusedColumn - 1);
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
case "tab": {
|
|
833
|
+
if (state.focusedColumn >= 2 && state.selectedSnapshot) {
|
|
834
|
+
state.previewMode = state.previewMode === "diff" ? "full" : "diff";
|
|
835
|
+
await refreshPreview();
|
|
836
|
+
}
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
case "r": {
|
|
840
|
+
if (state.selectedProject && state.selectedFileName && state.selectedSnapshot) {
|
|
841
|
+
const destPath = getFullFilePath(state.selectedProject, state.selectedFileName);
|
|
842
|
+
const ok = await restore(state.selectedProject.slug, state.selectedSnapshot.hash, state.selectedFileName, destPath);
|
|
843
|
+
setStatus(commandBar, ok ? "Restored!" : "Restore failed");
|
|
844
|
+
}
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
case "o": {
|
|
848
|
+
const slug = state.selectedProject?.slug;
|
|
849
|
+
if (slug) {
|
|
850
|
+
openInEditor(slug, state.config.editor);
|
|
851
|
+
setStatus(commandBar, "Opened in editor");
|
|
852
|
+
}
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
onSnapshot(async (slug, fileName) => {
|
|
858
|
+
if (state.selectedProject?.slug === slug) {
|
|
859
|
+
await refreshFiles();
|
|
860
|
+
if (state.selectedFileName === fileName) {
|
|
861
|
+
await refreshSnapshots();
|
|
862
|
+
if (state.selectedSnapshot) {
|
|
863
|
+
await refreshPreview();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
setStatus(commandBar, `Snapshot: ${fileName}`);
|
|
868
|
+
setTimeout(() => setStatus(commandBar, ""), 3e3);
|
|
869
|
+
});
|
|
870
|
+
for (const project of state.config.projects) {
|
|
871
|
+
await startWatching(project);
|
|
872
|
+
}
|
|
873
|
+
await refreshProjects();
|
|
874
|
+
focusColumn(COL_PROJECTS);
|
|
875
|
+
renderer.root.on("resized", () => {
|
|
876
|
+
updateColumnVisibility();
|
|
877
|
+
renderer.requestRender();
|
|
878
|
+
});
|
|
879
|
+
renderer.start();
|
|
880
|
+
}
|
|
881
|
+
export {
|
|
882
|
+
startApp
|
|
883
|
+
};
|