@lapis-notes/history 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/CHANGELOG.md +6 -0
- package/LICENSE.md +8 -0
- package/README.md +20 -0
- package/dist/history-capture.d.ts +7 -0
- package/dist/history-capture.js +21 -0
- package/dist/history-compare-panel.css +126 -0
- package/dist/history-compare-panel.svelte +314 -0
- package/dist/history-compare-panel.svelte.d.ts +10 -0
- package/dist/history-compare-view.d.ts +24 -0
- package/dist/history-compare-view.js +97 -0
- package/dist/history-format.d.ts +3 -0
- package/dist/history-format.js +19 -0
- package/dist/history-panel.css +200 -0
- package/dist/history-panel.svelte +302 -0
- package/dist/history-panel.svelte.d.ts +8 -0
- package/dist/history-plugin.d.ts +88 -0
- package/dist/history-plugin.js +435 -0
- package/dist/history-settings-tab.d.ts +9 -0
- package/dist/history-settings-tab.js +72 -0
- package/dist/history-settings.d.ts +14 -0
- package/dist/history-settings.js +47 -0
- package/dist/history-tracking.d.ts +4 -0
- package/dist/history-tracking.js +49 -0
- package/dist/history-view-type.d.ts +2 -0
- package/dist/history-view-type.js +2 -0
- package/dist/history-view.d.ts +12 -0
- package/dist/history-view.js +36 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/dist/public-components.d.ts +2 -0
- package/dist/public-components.js +2 -0
- package/dist/register-history-settings.d.ts +105 -0
- package/dist/register-history-settings.js +199 -0
- package/dist/styles.css +2 -0
- package/manifest.json +27 -0
- package/package.json +72 -0
- package/styles.css +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { dirname, md5, normalizePath, Plugin, TFile, } from "@lapis-notes/api";
|
|
2
|
+
import { consumeSuppressedHash, shouldReplaceLatestModify, } from "./history-capture";
|
|
3
|
+
import { mergeHistorySettings, patchHistorySettings, } from "./history-settings";
|
|
4
|
+
import { HistorySettingsTab } from "./history-settings-tab";
|
|
5
|
+
import { registerHistorySettings } from "./register-history-settings";
|
|
6
|
+
import { isHistoryTrackedFile } from "./history-tracking";
|
|
7
|
+
import { HistoryCompareView } from "./history-compare-view";
|
|
8
|
+
import { HistoryView } from "./history-view";
|
|
9
|
+
import { HistoryCompareViewType, HistoryViewType, } from "./history-view-type";
|
|
10
|
+
import manifestSpec from "../../manifest.json";
|
|
11
|
+
export const HISTORY_PLUGIN_ID = "history";
|
|
12
|
+
function orderNewestFirst(revisions) {
|
|
13
|
+
return [...revisions].sort((left, right) => right.createdAt - left.createdAt);
|
|
14
|
+
}
|
|
15
|
+
function toPluginHistory(history) {
|
|
16
|
+
if (!history)
|
|
17
|
+
return null;
|
|
18
|
+
return {
|
|
19
|
+
file: {
|
|
20
|
+
id: history.file.fileId,
|
|
21
|
+
path: history.file.currentPath,
|
|
22
|
+
deleted: history.file.deleted,
|
|
23
|
+
},
|
|
24
|
+
revisions: orderNewestFirst(history.revisions.map((revision) => ({
|
|
25
|
+
revisionId: revision.revisionId,
|
|
26
|
+
path: revision.currentPath,
|
|
27
|
+
previousPath: revision.eventType === "rename" ? revision.capturedPath : undefined,
|
|
28
|
+
eventType: revision.eventType,
|
|
29
|
+
createdAt: revision.createdAt,
|
|
30
|
+
sourceMtime: revision.sourceMtime,
|
|
31
|
+
sourceSize: revision.sourceSize,
|
|
32
|
+
contentHash: revision.contentHash,
|
|
33
|
+
content: revision.content,
|
|
34
|
+
}))),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export class HistoryPlugin extends Plugin {
|
|
38
|
+
settings = mergeHistorySettings(null);
|
|
39
|
+
currentFilePath = null;
|
|
40
|
+
compareAnchor = null;
|
|
41
|
+
pendingCaptures = new Map();
|
|
42
|
+
lastModifyStoredAt = new Map();
|
|
43
|
+
suppressedHashes = new Map();
|
|
44
|
+
listeners = new Set();
|
|
45
|
+
constructor(app, manifest = manifestSpec) {
|
|
46
|
+
super(app, manifest);
|
|
47
|
+
}
|
|
48
|
+
async onload() {
|
|
49
|
+
this.settings = mergeHistorySettings(await this.loadData());
|
|
50
|
+
this.addSettingTab(new HistorySettingsTab(this.app, this));
|
|
51
|
+
registerHistorySettings(this);
|
|
52
|
+
this.registerSidebarView(HistoryViewType, (leaf) => new HistoryView(leaf), { side: "right", title: "History", icon: "history" }, {
|
|
53
|
+
kind: "command",
|
|
54
|
+
command: {
|
|
55
|
+
id: "open-file-history",
|
|
56
|
+
name: "Open file history",
|
|
57
|
+
callback: () => void this.openHistoryView(),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
this.registerView(HistoryCompareViewType, (leaf) => new HistoryCompareView(this, leaf), { kind: "internal" });
|
|
61
|
+
this.registerEvent(this.app.workspace.on("file-open", (file) => {
|
|
62
|
+
if (file instanceof TFile) {
|
|
63
|
+
this.setCurrentFilePath(file.path);
|
|
64
|
+
void this.ensureBaselineForFile(file);
|
|
65
|
+
}
|
|
66
|
+
}));
|
|
67
|
+
this.registerEvent(this.app.workspace.on("active-leaf-change", () => {
|
|
68
|
+
const file = this.getFocusedFile();
|
|
69
|
+
if (file)
|
|
70
|
+
this.setCurrentFilePath(file.path);
|
|
71
|
+
}));
|
|
72
|
+
this.registerEvent(this.app.vault.on("create", (file) => {
|
|
73
|
+
if (file instanceof TFile)
|
|
74
|
+
this.scheduleCapture(file, "create");
|
|
75
|
+
}));
|
|
76
|
+
this.registerEvent(this.app.vault.on("modify", (file) => {
|
|
77
|
+
if (file instanceof TFile)
|
|
78
|
+
this.scheduleCapture(file, "modify");
|
|
79
|
+
}));
|
|
80
|
+
this.registerEvent(this.app.vault.on("rename", (file, oldPath) => {
|
|
81
|
+
if (!(file instanceof TFile))
|
|
82
|
+
return;
|
|
83
|
+
if (this.currentFilePath === oldPath)
|
|
84
|
+
this.setCurrentFilePath(file.path);
|
|
85
|
+
if (this.compareAnchor?.filePath === oldPath) {
|
|
86
|
+
this.compareAnchor = {
|
|
87
|
+
filePath: file.path,
|
|
88
|
+
revisionId: this.compareAnchor.revisionId,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
void this.captureRename(file, oldPath);
|
|
92
|
+
}));
|
|
93
|
+
this.registerEvent(this.app.vault.on("delete", (file) => {
|
|
94
|
+
if (file instanceof TFile)
|
|
95
|
+
void this.captureDelete(file);
|
|
96
|
+
}));
|
|
97
|
+
const initialFile = this.getFocusedFile();
|
|
98
|
+
if (initialFile) {
|
|
99
|
+
this.setCurrentFilePath(initialFile.path);
|
|
100
|
+
void this.ensureBaselineForFile(initialFile);
|
|
101
|
+
}
|
|
102
|
+
this.register(() => {
|
|
103
|
+
for (const timer of this.pendingCaptures.values())
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
this.pendingCaptures.clear();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
getSettings() {
|
|
109
|
+
return mergeHistorySettings(this.settings);
|
|
110
|
+
}
|
|
111
|
+
async updateSettings(patch) {
|
|
112
|
+
this.settings = patchHistorySettings(this.settings, patch);
|
|
113
|
+
await this.saveData(this.settings);
|
|
114
|
+
this.notify();
|
|
115
|
+
}
|
|
116
|
+
onHistoryChanged(listener) {
|
|
117
|
+
this.listeners.add(listener);
|
|
118
|
+
return () => this.listeners.delete(listener);
|
|
119
|
+
}
|
|
120
|
+
getCompareAnchor() {
|
|
121
|
+
return this.compareAnchor;
|
|
122
|
+
}
|
|
123
|
+
setCompareAnchor(anchor) {
|
|
124
|
+
this.compareAnchor = anchor;
|
|
125
|
+
this.notify();
|
|
126
|
+
}
|
|
127
|
+
toggleCompareAnchor(filePath, revisionId) {
|
|
128
|
+
if (this.compareAnchor?.filePath === filePath &&
|
|
129
|
+
this.compareAnchor.revisionId === revisionId) {
|
|
130
|
+
this.setCompareAnchor(null);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.setCompareAnchor({ filePath, revisionId });
|
|
134
|
+
}
|
|
135
|
+
async openHistoryView(filePath) {
|
|
136
|
+
const file = filePath
|
|
137
|
+
? this.app.vault.getFileByPath(normalizePath(filePath))
|
|
138
|
+
: this.getFocusedFile();
|
|
139
|
+
if (file instanceof TFile) {
|
|
140
|
+
this.setCurrentFilePath(file.path);
|
|
141
|
+
await this.ensureBaselineForFile(file);
|
|
142
|
+
}
|
|
143
|
+
else if (filePath) {
|
|
144
|
+
this.setCurrentFilePath(filePath);
|
|
145
|
+
}
|
|
146
|
+
const existing = this.app.workspace.getLeavesOfType(HistoryViewType)[0];
|
|
147
|
+
const leaf = existing ?? this.app.workspace.ensureSideLeaf(HistoryViewType, "right");
|
|
148
|
+
await leaf.setViewState({ type: HistoryViewType });
|
|
149
|
+
this.app.workspace.activateLeaf(leaf, {
|
|
150
|
+
focusRootHost: false,
|
|
151
|
+
source: "api",
|
|
152
|
+
operation: "open-file-history",
|
|
153
|
+
});
|
|
154
|
+
await this.app.workspace.revealLeaf(leaf);
|
|
155
|
+
}
|
|
156
|
+
async openHistoryCompareView(state) {
|
|
157
|
+
const leaves = this.app.workspace.getLeavesOfType(HistoryCompareViewType);
|
|
158
|
+
const leaf = leaves[0] ?? this.app.workspace.getLeaf("tab");
|
|
159
|
+
await leaf.setViewState({
|
|
160
|
+
type: HistoryCompareViewType,
|
|
161
|
+
state: {
|
|
162
|
+
...state,
|
|
163
|
+
sourceLeafId: state.sourceLeafId ?? this.app.workspace.activeLeaf?.id ?? undefined,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
this.app.workspace.activateLeaf(leaf, {
|
|
167
|
+
focusRootHost: false,
|
|
168
|
+
source: "api",
|
|
169
|
+
operation: "open-history-compare",
|
|
170
|
+
});
|
|
171
|
+
await this.app.workspace.revealLeaf(leaf);
|
|
172
|
+
}
|
|
173
|
+
closeHistoryCompareView(sourceLeafId) {
|
|
174
|
+
const sourceLeaf = sourceLeafId
|
|
175
|
+
? this.app.workspace.getLeafById(sourceLeafId)
|
|
176
|
+
: null;
|
|
177
|
+
if (sourceLeaf) {
|
|
178
|
+
this.app.workspace.activateLeaf(sourceLeaf, {
|
|
179
|
+
focusRootHost: false,
|
|
180
|
+
source: "api",
|
|
181
|
+
operation: "close-history-compare",
|
|
182
|
+
});
|
|
183
|
+
void this.app.workspace.revealLeaf(sourceLeaf);
|
|
184
|
+
}
|
|
185
|
+
this.app.workspace.getLeavesOfType(HistoryCompareViewType)[0]?.detach();
|
|
186
|
+
}
|
|
187
|
+
async getHistoryViewModel() {
|
|
188
|
+
const file = this.getFocusedFile();
|
|
189
|
+
if (file) {
|
|
190
|
+
this.setCurrentFilePath(file.path);
|
|
191
|
+
await this.ensureBaselineForFile(file);
|
|
192
|
+
const [rawHistory, currentContent] = await Promise.all([
|
|
193
|
+
this.app.appDatabase.getFileHistory(file.path),
|
|
194
|
+
this.app.vault.cachedRead(file).catch(() => ""),
|
|
195
|
+
]);
|
|
196
|
+
return {
|
|
197
|
+
filePath: file.path,
|
|
198
|
+
history: toPluginHistory(rawHistory),
|
|
199
|
+
currentContent,
|
|
200
|
+
fileExists: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (!this.currentFilePath) {
|
|
204
|
+
return {
|
|
205
|
+
filePath: null,
|
|
206
|
+
history: null,
|
|
207
|
+
currentContent: "",
|
|
208
|
+
fileExists: false,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
filePath: this.currentFilePath,
|
|
213
|
+
history: toPluginHistory(await this.app.appDatabase.getFileHistory(this.currentFilePath)),
|
|
214
|
+
currentContent: "",
|
|
215
|
+
fileExists: false,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async getHistoryComparisonModel(state) {
|
|
219
|
+
const filePath = normalizePath(state.filePath);
|
|
220
|
+
const history = toPluginHistory(await this.app.appDatabase.getFileHistory(filePath));
|
|
221
|
+
if (!history)
|
|
222
|
+
throw new Error("No stored history found for this file");
|
|
223
|
+
const selectedRevision = history.revisions.find((revision) => revision.revisionId === state.revisionId);
|
|
224
|
+
if (!selectedRevision) {
|
|
225
|
+
throw new Error("Selected revision is no longer available");
|
|
226
|
+
}
|
|
227
|
+
const selectedIndex = history.revisions.findIndex((revision) => revision.revisionId === selectedRevision.revisionId);
|
|
228
|
+
const previousRevision = selectedIndex >= 0 ? (history.revisions[selectedIndex + 1] ?? null) : null;
|
|
229
|
+
const otherRevision = state.otherRevisionId
|
|
230
|
+
? (history.revisions.find((revision) => revision.revisionId === state.otherRevisionId) ?? null)
|
|
231
|
+
: null;
|
|
232
|
+
const file = this.app.vault.getFileByPath(filePath);
|
|
233
|
+
const fileExists = file instanceof TFile;
|
|
234
|
+
const currentContent = fileExists
|
|
235
|
+
? await this.app.vault.cachedRead(file).catch(() => "")
|
|
236
|
+
: "";
|
|
237
|
+
const compareMode = state.compareMode === "selected" && otherRevision
|
|
238
|
+
? "selected"
|
|
239
|
+
: state.compareMode === "previous" && previousRevision
|
|
240
|
+
? "previous"
|
|
241
|
+
: "current";
|
|
242
|
+
const pair = compareMode === "selected" && otherRevision
|
|
243
|
+
? [selectedRevision, otherRevision].sort((left, right) => left.createdAt - right.createdAt)
|
|
244
|
+
: compareMode === "previous" && previousRevision
|
|
245
|
+
? [previousRevision, selectedRevision]
|
|
246
|
+
: [selectedRevision, { content: currentContent, createdAt: Date.now() }];
|
|
247
|
+
return {
|
|
248
|
+
filePath,
|
|
249
|
+
history,
|
|
250
|
+
currentContent,
|
|
251
|
+
fileExists,
|
|
252
|
+
compareMode,
|
|
253
|
+
selectedRevision,
|
|
254
|
+
previousRevision,
|
|
255
|
+
otherRevision,
|
|
256
|
+
compareBaseText: pair[0]?.content ?? "",
|
|
257
|
+
compareNewText: pair[1]?.content ?? "",
|
|
258
|
+
beforeLabel: compareMode === "current"
|
|
259
|
+
? "Selected revision"
|
|
260
|
+
: formatRevisionLabel(pair[0]?.createdAt),
|
|
261
|
+
afterLabel: compareMode === "current"
|
|
262
|
+
? fileExists
|
|
263
|
+
? "Current file"
|
|
264
|
+
: "Latest"
|
|
265
|
+
: formatRevisionLabel(pair[1]?.createdAt),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async restoreRevision(filePath, revision) {
|
|
269
|
+
const normalizedPath = normalizePath(filePath);
|
|
270
|
+
this.suppressedHashes.set(normalizedPath, revision.contentHash);
|
|
271
|
+
const existingFile = this.app.vault.getFileByPath(normalizedPath);
|
|
272
|
+
if (existingFile instanceof TFile) {
|
|
273
|
+
await this.app.vault.modify(existingFile, revision.content);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
await this.app.vault.mkpath(dirname(normalizedPath));
|
|
277
|
+
await this.app.vault.create(normalizedPath, revision.content);
|
|
278
|
+
}
|
|
279
|
+
const nextFile = this.app.vault.getFileByPath(normalizedPath);
|
|
280
|
+
await this.app.appDatabase.storeFileHistoryRevision({
|
|
281
|
+
path: normalizedPath,
|
|
282
|
+
eventType: "restore",
|
|
283
|
+
createdAt: Date.now(),
|
|
284
|
+
sourceMtime: nextFile instanceof TFile ? nextFile.stat.mtime : undefined,
|
|
285
|
+
sourceSize: nextFile instanceof TFile
|
|
286
|
+
? nextFile.stat.size
|
|
287
|
+
: revision.content.length,
|
|
288
|
+
contentHash: revision.contentHash,
|
|
289
|
+
content: revision.content,
|
|
290
|
+
maxRevisions: this.settings.retentionCount,
|
|
291
|
+
});
|
|
292
|
+
this.setCurrentFilePath(normalizedPath);
|
|
293
|
+
this.notify();
|
|
294
|
+
}
|
|
295
|
+
async saveCurrentFileContent(filePath, content) {
|
|
296
|
+
const normalizedPath = normalizePath(filePath);
|
|
297
|
+
const existingFile = this.app.vault.getFileByPath(normalizedPath);
|
|
298
|
+
if (existingFile instanceof TFile) {
|
|
299
|
+
const current = await this.app.vault.cachedRead(existingFile).catch(() => null);
|
|
300
|
+
if (current === content)
|
|
301
|
+
return;
|
|
302
|
+
await this.app.vault.modify(existingFile, content);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
await this.app.vault.mkpath(dirname(normalizedPath));
|
|
306
|
+
await this.app.vault.create(normalizedPath, content);
|
|
307
|
+
}
|
|
308
|
+
this.setCurrentFilePath(normalizedPath);
|
|
309
|
+
this.notify();
|
|
310
|
+
}
|
|
311
|
+
getFocusedFile() {
|
|
312
|
+
const activeLeafFile = this.app.workspace.activeLeaf?.view?.file;
|
|
313
|
+
if (activeLeafFile instanceof TFile)
|
|
314
|
+
return activeLeafFile;
|
|
315
|
+
const activeFile = this.app.workspace.getActiveFile?.();
|
|
316
|
+
return activeFile instanceof TFile ? activeFile : null;
|
|
317
|
+
}
|
|
318
|
+
setCurrentFilePath(path) {
|
|
319
|
+
const normalizedPath = path ? normalizePath(path) : null;
|
|
320
|
+
if (this.currentFilePath === normalizedPath)
|
|
321
|
+
return;
|
|
322
|
+
this.currentFilePath = normalizedPath;
|
|
323
|
+
if (this.compareAnchor && this.compareAnchor.filePath !== normalizedPath) {
|
|
324
|
+
this.compareAnchor = null;
|
|
325
|
+
}
|
|
326
|
+
this.notify();
|
|
327
|
+
}
|
|
328
|
+
scheduleCapture(file, eventType) {
|
|
329
|
+
if (!isHistoryTrackedFile(file, this.settings))
|
|
330
|
+
return;
|
|
331
|
+
const path = normalizePath(file.path);
|
|
332
|
+
const existing = this.pendingCaptures.get(path);
|
|
333
|
+
if (existing)
|
|
334
|
+
clearTimeout(existing);
|
|
335
|
+
const timer = setTimeout(() => {
|
|
336
|
+
this.pendingCaptures.delete(path);
|
|
337
|
+
void this.captureSnapshot(file, eventType);
|
|
338
|
+
}, this.settings.debounceMs);
|
|
339
|
+
this.pendingCaptures.set(path, timer);
|
|
340
|
+
}
|
|
341
|
+
async ensureBaselineForFile(file) {
|
|
342
|
+
if (!isHistoryTrackedFile(file, this.settings))
|
|
343
|
+
return;
|
|
344
|
+
const existing = await this.app.appDatabase.getFileHistory(file.path);
|
|
345
|
+
if (existing)
|
|
346
|
+
return;
|
|
347
|
+
await this.captureSnapshot(file, "baseline");
|
|
348
|
+
}
|
|
349
|
+
async captureSnapshot(file, eventType) {
|
|
350
|
+
if (!isHistoryTrackedFile(file, this.settings))
|
|
351
|
+
return;
|
|
352
|
+
let content;
|
|
353
|
+
try {
|
|
354
|
+
content = await this.app.vault.cachedRead(file);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const path = normalizePath(file.path);
|
|
360
|
+
const contentHash = md5(content);
|
|
361
|
+
if (consumeSuppressedHash(this.suppressedHashes, path, contentHash)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const existing = await this.app.appDatabase.getFileHistory(path);
|
|
365
|
+
const latest = existing?.revisions.at(-1);
|
|
366
|
+
const replaceLatest = shouldReplaceLatestModify(eventType, latest?.eventType, this.lastModifyStoredAt.get(path), this.settings.mergeWindowMs);
|
|
367
|
+
const result = await this.app.appDatabase.storeFileHistoryRevision({
|
|
368
|
+
path,
|
|
369
|
+
eventType,
|
|
370
|
+
createdAt: Date.now(),
|
|
371
|
+
sourceMtime: file.stat.mtime,
|
|
372
|
+
sourceSize: file.stat.size,
|
|
373
|
+
contentHash,
|
|
374
|
+
content,
|
|
375
|
+
maxRevisions: this.settings.retentionCount,
|
|
376
|
+
replaceLatest,
|
|
377
|
+
});
|
|
378
|
+
if (result.stored) {
|
|
379
|
+
if (eventType === "modify")
|
|
380
|
+
this.lastModifyStoredAt.set(path, Date.now());
|
|
381
|
+
this.notify();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async captureRename(file, oldPath) {
|
|
385
|
+
const hadHistory = await this.app.appDatabase.getFileHistory(oldPath);
|
|
386
|
+
if (!hadHistory && !isHistoryTrackedFile(file, this.settings))
|
|
387
|
+
return;
|
|
388
|
+
let content = hadHistory?.revisions.at(-1)?.content ?? "";
|
|
389
|
+
try {
|
|
390
|
+
if (isHistoryTrackedFile(file, this.settings)) {
|
|
391
|
+
content = await this.app.vault.cachedRead(file);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const result = await this.app.appDatabase.storeFileHistoryRevision({
|
|
398
|
+
path: file.path,
|
|
399
|
+
previousPath: oldPath,
|
|
400
|
+
eventType: "rename",
|
|
401
|
+
createdAt: Date.now(),
|
|
402
|
+
sourceMtime: file.stat.mtime,
|
|
403
|
+
sourceSize: file.stat.size,
|
|
404
|
+
contentHash: content ? md5(content) : hadHistory?.revisions.at(-1)?.contentHash,
|
|
405
|
+
content,
|
|
406
|
+
maxRevisions: this.settings.retentionCount,
|
|
407
|
+
});
|
|
408
|
+
if (result.stored)
|
|
409
|
+
this.notify();
|
|
410
|
+
}
|
|
411
|
+
async captureDelete(file) {
|
|
412
|
+
const path = normalizePath(file.path);
|
|
413
|
+
const existing = await this.app.appDatabase.getFileHistory(path);
|
|
414
|
+
if (!existing)
|
|
415
|
+
return;
|
|
416
|
+
const result = await this.app.appDatabase.storeFileHistoryRevision({
|
|
417
|
+
path,
|
|
418
|
+
eventType: "delete",
|
|
419
|
+
createdAt: Date.now(),
|
|
420
|
+
sourceMtime: file.stat.mtime,
|
|
421
|
+
sourceSize: file.stat.size,
|
|
422
|
+
maxRevisions: this.settings.retentionCount,
|
|
423
|
+
});
|
|
424
|
+
if (result.stored)
|
|
425
|
+
this.notify();
|
|
426
|
+
}
|
|
427
|
+
notify() {
|
|
428
|
+
for (const listener of this.listeners)
|
|
429
|
+
listener();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function formatRevisionLabel(value) {
|
|
433
|
+
return value ? new Date(value).toLocaleString() : "Revision";
|
|
434
|
+
}
|
|
435
|
+
export default HistoryPlugin;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { PluginSettingTab, type App } from "@lapis-notes/api";
|
|
2
|
+
import type { HistoryPlugin } from "./history-plugin";
|
|
3
|
+
export declare class HistorySettingsTab extends PluginSettingTab {
|
|
4
|
+
private readonly historyPlugin;
|
|
5
|
+
constructor(app: App, historyPlugin: HistoryPlugin);
|
|
6
|
+
display(): void;
|
|
7
|
+
private addNumber;
|
|
8
|
+
private update;
|
|
9
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { PluginSettingTab, Setting } from "@lapis-notes/api";
|
|
2
|
+
export class HistorySettingsTab extends PluginSettingTab {
|
|
3
|
+
historyPlugin;
|
|
4
|
+
constructor(app, historyPlugin) {
|
|
5
|
+
super(app, historyPlugin);
|
|
6
|
+
this.historyPlugin = historyPlugin;
|
|
7
|
+
}
|
|
8
|
+
display() {
|
|
9
|
+
const settings = this.historyPlugin.getSettings();
|
|
10
|
+
this.containerEl.empty();
|
|
11
|
+
this.addNumber("Revisions per file", "Maximum stored snapshots for one path.", settings.retentionCount, 1, 200, 1, (retentionCount) => ({ retentionCount }));
|
|
12
|
+
this.addNumber("Maximum file size (KiB)", "Files larger than this are not snapshotted.", Math.round(settings.maxFileSizeBytes / 1024), 1, 2048, 1, (kib) => ({ maxFileSizeBytes: kib * 1024 }));
|
|
13
|
+
this.addNumber("Merge window (seconds)", "Replace the latest modify for the same path when another modify arrives inside this window.", Math.round(settings.mergeWindowMs / 1000), 0, 120, 1, (seconds) => ({ mergeWindowMs: seconds * 1000 }));
|
|
14
|
+
this.addNumber("Capture debounce (ms)", "Delay before create and modify snapshots are stored.", settings.debounceMs, 0, 5000, 50, (debounceMs) => ({ debounceMs }));
|
|
15
|
+
new Setting(this.containerEl)
|
|
16
|
+
.setName("Exclude globs")
|
|
17
|
+
.setDesc("Comma-separated globs that skip capture. Defaults include .obsidian, .lapis, .git, and .jj.")
|
|
18
|
+
.addText((text) => {
|
|
19
|
+
text
|
|
20
|
+
.setPlaceholder(".obsidian/**, .lapis/**")
|
|
21
|
+
.setValue(settings.excludeGlobs.join(", "))
|
|
22
|
+
.onChange((value) => {
|
|
23
|
+
void this.update({
|
|
24
|
+
excludeGlobs: value.split(","),
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
new Setting(this.containerEl)
|
|
29
|
+
.setName("Include globs")
|
|
30
|
+
.setDesc("Optional path allowlist. Leave empty to snapshot remaining paths after excludes.")
|
|
31
|
+
.addText((text) => {
|
|
32
|
+
text
|
|
33
|
+
.setPlaceholder("Notes/**, Projects/**")
|
|
34
|
+
.setValue(settings.includeGlobs.join(", "))
|
|
35
|
+
.onChange((value) => {
|
|
36
|
+
void this.update({
|
|
37
|
+
includeGlobs: value.split(","),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
new Setting(this.containerEl)
|
|
42
|
+
.setName("Tracked extensions")
|
|
43
|
+
.setDesc("Optional allowlist. Leave empty to snapshot remaining UTF-8 text under the size cap.")
|
|
44
|
+
.addText((text) => {
|
|
45
|
+
text
|
|
46
|
+
.setPlaceholder("md, json, txt")
|
|
47
|
+
.setValue(settings.trackedExtensions.join(", "))
|
|
48
|
+
.onChange((value) => {
|
|
49
|
+
void this.update({
|
|
50
|
+
trackedExtensions: value.split(","),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
addNumber(name, description, value, min, max, step, patch) {
|
|
56
|
+
new Setting(this.containerEl)
|
|
57
|
+
.setName(name)
|
|
58
|
+
.setDesc(description)
|
|
59
|
+
.addSlider((slider) => {
|
|
60
|
+
slider
|
|
61
|
+
.setDynamicTooltip()
|
|
62
|
+
.setLimits(min, max, step)
|
|
63
|
+
.setValue(value)
|
|
64
|
+
.onChange((next) => void this.update(patch(Number(next)), false));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async update(patch, redisplay = true) {
|
|
68
|
+
await this.historyPlugin.updateSettings(patch);
|
|
69
|
+
if (redisplay)
|
|
70
|
+
this.display();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface HistoryPluginSettings {
|
|
2
|
+
retentionCount: number;
|
|
3
|
+
debounceMs: number;
|
|
4
|
+
mergeWindowMs: number;
|
|
5
|
+
maxFileSizeBytes: number;
|
|
6
|
+
excludeGlobs: string[];
|
|
7
|
+
includeGlobs: string[];
|
|
8
|
+
trackedExtensions: string[];
|
|
9
|
+
}
|
|
10
|
+
export type HistoryPluginSettingsPatch = Partial<HistoryPluginSettings>;
|
|
11
|
+
export declare const DEFAULT_HISTORY_EXCLUDE_GLOBS: readonly [".obsidian/**", ".lapis/**", "**/.git/**", "**/.jj/**"];
|
|
12
|
+
export declare const DEFAULT_HISTORY_SETTINGS: HistoryPluginSettings;
|
|
13
|
+
export declare function mergeHistorySettings(stored: Partial<HistoryPluginSettings> | null | undefined): HistoryPluginSettings;
|
|
14
|
+
export declare function patchHistorySettings(current: HistoryPluginSettings, patch: HistoryPluginSettingsPatch): HistoryPluginSettings;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const DEFAULT_HISTORY_EXCLUDE_GLOBS = [
|
|
2
|
+
".obsidian/**",
|
|
3
|
+
".lapis/**",
|
|
4
|
+
"**/.git/**",
|
|
5
|
+
"**/.jj/**",
|
|
6
|
+
];
|
|
7
|
+
export const DEFAULT_HISTORY_SETTINGS = {
|
|
8
|
+
retentionCount: 50,
|
|
9
|
+
debounceMs: 500,
|
|
10
|
+
mergeWindowMs: 10_000,
|
|
11
|
+
maxFileSizeBytes: 256 * 1024,
|
|
12
|
+
excludeGlobs: [...DEFAULT_HISTORY_EXCLUDE_GLOBS],
|
|
13
|
+
includeGlobs: [],
|
|
14
|
+
trackedExtensions: [],
|
|
15
|
+
};
|
|
16
|
+
function normalizeStringList(values) {
|
|
17
|
+
return [
|
|
18
|
+
...new Set((values ?? [])
|
|
19
|
+
.flatMap((value) => value.split(","))
|
|
20
|
+
.map((value) => value.trim())
|
|
21
|
+
.filter(Boolean)),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
function normalizeExtensions(values) {
|
|
25
|
+
return [
|
|
26
|
+
...new Set(normalizeStringList(values).map((value) => value.toLowerCase().replace(/^\.+/u, ""))),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
export function mergeHistorySettings(stored) {
|
|
30
|
+
return {
|
|
31
|
+
retentionCount: Math.max(1, Math.round(stored?.retentionCount ?? DEFAULT_HISTORY_SETTINGS.retentionCount)),
|
|
32
|
+
debounceMs: Math.max(0, Math.round(stored?.debounceMs ?? DEFAULT_HISTORY_SETTINGS.debounceMs)),
|
|
33
|
+
mergeWindowMs: Math.max(0, Math.round(stored?.mergeWindowMs ?? DEFAULT_HISTORY_SETTINGS.mergeWindowMs)),
|
|
34
|
+
maxFileSizeBytes: Math.max(1, Math.round(stored?.maxFileSizeBytes ?? DEFAULT_HISTORY_SETTINGS.maxFileSizeBytes)),
|
|
35
|
+
excludeGlobs: normalizeStringList(stored?.excludeGlobs).length > 0
|
|
36
|
+
? normalizeStringList(stored?.excludeGlobs)
|
|
37
|
+
: [...DEFAULT_HISTORY_SETTINGS.excludeGlobs],
|
|
38
|
+
includeGlobs: normalizeStringList(stored?.includeGlobs),
|
|
39
|
+
trackedExtensions: normalizeExtensions(stored?.trackedExtensions),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function patchHistorySettings(current, patch) {
|
|
43
|
+
return mergeHistorySettings({
|
|
44
|
+
...current,
|
|
45
|
+
...patch,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type TFile } from "@lapis-notes/api";
|
|
2
|
+
import type { HistoryPluginSettings } from "./history-settings";
|
|
3
|
+
export declare const HISTORY_BINARY_EXTENSIONS: Set<string>;
|
|
4
|
+
export declare function isHistoryTrackedFile(file: TFile, settings: HistoryPluginSettings): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isLapisInternalPath, matchesEditorAssociationGlob, normalizePath, } from "@lapis-notes/api";
|
|
2
|
+
export const HISTORY_BINARY_EXTENSIONS = new Set([
|
|
3
|
+
"png",
|
|
4
|
+
"jpg",
|
|
5
|
+
"jpeg",
|
|
6
|
+
"gif",
|
|
7
|
+
"webp",
|
|
8
|
+
"avif",
|
|
9
|
+
"bmp",
|
|
10
|
+
"ico",
|
|
11
|
+
"pdf",
|
|
12
|
+
"zip",
|
|
13
|
+
"gz",
|
|
14
|
+
"tgz",
|
|
15
|
+
"7z",
|
|
16
|
+
"mp3",
|
|
17
|
+
"mp4",
|
|
18
|
+
"mov",
|
|
19
|
+
"wav",
|
|
20
|
+
"ogg",
|
|
21
|
+
"woff",
|
|
22
|
+
"woff2",
|
|
23
|
+
"ttf",
|
|
24
|
+
"otf",
|
|
25
|
+
]);
|
|
26
|
+
export function isHistoryTrackedFile(file, settings) {
|
|
27
|
+
const path = normalizePath(file.path);
|
|
28
|
+
if (isLapisInternalPath(path)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (file.stat.size > settings.maxFileSizeBytes) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const extension = file.extension.toLowerCase();
|
|
35
|
+
if (HISTORY_BINARY_EXTENSIONS.has(extension)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (settings.excludeGlobs.some((pattern) => matchesEditorAssociationGlob(pattern, path))) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (settings.includeGlobs.length > 0 &&
|
|
42
|
+
!settings.includeGlobs.some((pattern) => matchesEditorAssociationGlob(pattern, path))) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (settings.trackedExtensions.length === 0) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return settings.trackedExtensions.includes(extension);
|
|
49
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { View, type WorkspaceLeaf } from "@lapis-notes/api";
|
|
2
|
+
export declare class HistoryView extends View {
|
|
3
|
+
private component;
|
|
4
|
+
constructor(leaf: WorkspaceLeaf);
|
|
5
|
+
getViewType(): string;
|
|
6
|
+
getDisplayText(): string;
|
|
7
|
+
getIcon(): string;
|
|
8
|
+
protected onOpen(): Promise<void>;
|
|
9
|
+
protected onClose(): Promise<void>;
|
|
10
|
+
onload(): void;
|
|
11
|
+
onunload(): void;
|
|
12
|
+
}
|