@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.
@@ -0,0 +1,200 @@
1
+ .history-panel {
2
+ --history-marker-col: 1.25rem;
3
+ --history-row-gap: 0.375rem;
4
+ --history-row-pad-y: 0.35rem;
5
+ --history-row-pad-x: 0.45rem;
6
+ --history-timeline-pad-x: 0.75rem;
7
+ --history-timeline-pad-top: 0.5rem;
8
+ --history-timeline-pad-bottom: 0.75rem;
9
+ display: flex;
10
+ box-sizing: border-box;
11
+ width: 100%;
12
+ height: 100%;
13
+ min-width: 0;
14
+ min-height: 0;
15
+ flex-direction: column;
16
+ overflow: hidden;
17
+ color: var(--ui-workspace-view-foreground, inherit);
18
+ background: var(--ui-workspace-view-background, transparent);
19
+ font: inherit;
20
+ }
21
+
22
+ .history-panel__chrome {
23
+ display: flex;
24
+ flex: 0 0 auto;
25
+ align-items: center;
26
+ justify-content: center;
27
+ padding: 0.25rem;
28
+ border-bottom: 1px solid
29
+ var(--ui-workspace-border, color-mix(in srgb, currentColor 14%, transparent));
30
+ background: var(--ui-workspace-view-background, transparent);
31
+ }
32
+
33
+ .history-panel__toolbar {
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ gap: 0.125rem;
38
+ }
39
+
40
+ .history-panel__toolbar
41
+ button[data-ui-component="button"].history-panel__toolbar-action {
42
+ color: inherit;
43
+ background: transparent;
44
+ }
45
+
46
+ .history-panel__toolbar
47
+ button[data-ui-component="button"].history-panel__toolbar-action:hover,
48
+ .history-panel__toolbar
49
+ button[data-ui-component="button"].history-panel__toolbar-action:focus-visible {
50
+ color: var(
51
+ --ui-workspace-panel-action-hover-foreground,
52
+ var(--sidebar-accent-foreground, inherit)
53
+ );
54
+ background: var(
55
+ --ui-workspace-panel-action-hover-background,
56
+ var(--sidebar-accent, #e3e3e3)
57
+ );
58
+ outline: none;
59
+ }
60
+
61
+ .history-panel__toolbar
62
+ button[data-ui-component="button"].history-panel__toolbar-action[aria-pressed="true"],
63
+ .history-panel__toolbar
64
+ button[data-ui-component="button"].history-panel__toolbar-action[aria-pressed="true"]:hover {
65
+ color: var(--ui-workspace-accent, #7c3aed);
66
+ background: var(
67
+ --ui-workspace-selection,
68
+ color-mix(in srgb, var(--ui-workspace-accent, #7c3aed) 14%, transparent)
69
+ );
70
+ }
71
+
72
+ .history-panel__empty {
73
+ margin: 0;
74
+ padding: 0.75rem;
75
+ color: var(--ui-workspace-muted-foreground, var(--muted-foreground));
76
+ font-size: 0.8125rem;
77
+ }
78
+
79
+ .history-panel__scroll {
80
+ min-height: 0;
81
+ flex: 1 1 auto;
82
+ }
83
+
84
+ .history-panel__timeline {
85
+ position: relative;
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: var(--history-row-gap);
89
+ margin: 0;
90
+ padding: var(--history-timeline-pad-top) var(--history-timeline-pad-x)
91
+ var(--history-timeline-pad-bottom);
92
+ list-style: none;
93
+ }
94
+
95
+ .history-panel__timeline::before {
96
+ position: absolute;
97
+ top: calc(
98
+ var(--history-timeline-pad-top) + var(--history-row-pad-y) +
99
+ var(--history-marker-col) / 2
100
+ );
101
+ bottom: calc(
102
+ var(--history-timeline-pad-bottom) + var(--history-row-pad-y) +
103
+ var(--history-marker-col) / 2
104
+ );
105
+ left: calc(
106
+ var(--history-timeline-pad-x) + var(--history-row-pad-x) +
107
+ var(--history-marker-col) / 2
108
+ );
109
+ width: 1px;
110
+ background: var(
111
+ --ui-workspace-border,
112
+ color-mix(in srgb, currentColor 14%, transparent)
113
+ );
114
+ transform: translateX(-50%);
115
+ content: "";
116
+ }
117
+
118
+ .history-panel__row {
119
+ display: flex;
120
+ width: 100%;
121
+ min-width: 0;
122
+ align-items: center;
123
+ gap: 0.65rem;
124
+ padding: var(--history-row-pad-y) var(--history-row-pad-x);
125
+ border: 0;
126
+ border-radius: 0.4rem;
127
+ color: inherit;
128
+ background: transparent;
129
+ font: inherit;
130
+ text-align: start;
131
+ cursor: pointer;
132
+ }
133
+
134
+ .history-panel__row:hover,
135
+ .history-panel__row:focus-visible {
136
+ background: var(
137
+ --ui-workspace-selection,
138
+ color-mix(in srgb, var(--ui-workspace-accent, #7c3aed) 14%, transparent)
139
+ );
140
+ outline: none;
141
+ }
142
+
143
+ .history-panel__row[aria-current="true"] {
144
+ background: var(
145
+ --ui-workspace-selection,
146
+ color-mix(in srgb, var(--ui-workspace-accent, #7c3aed) 18%, transparent)
147
+ );
148
+ }
149
+
150
+ .history-panel__row[data-compare-anchor="true"] {
151
+ box-shadow: inset 0 0 0 1px
152
+ color-mix(in srgb, var(--ui-workspace-accent, #7c3aed) 45%, transparent);
153
+ }
154
+
155
+ .history-panel__marker {
156
+ position: relative;
157
+ z-index: 1;
158
+ flex: 0 0 var(--history-marker-col);
159
+ width: var(--history-marker-col);
160
+ height: var(--history-marker-col);
161
+ color: var(--ui-workspace-muted-foreground, currentColor);
162
+ }
163
+
164
+ .history-panel__marker path {
165
+ opacity: 0;
166
+ }
167
+
168
+ .history-panel__row[aria-current="true"] .history-panel__marker,
169
+ .history-panel__row[data-compare-anchor="true"] .history-panel__marker {
170
+ color: var(--ui-workspace-accent, #7c3aed);
171
+ }
172
+
173
+ .history-panel__row[aria-current="true"] .history-panel__marker circle {
174
+ fill: currentColor;
175
+ }
176
+
177
+ .history-panel__meta {
178
+ display: flex;
179
+ min-width: 0;
180
+ flex: 1 1 auto;
181
+ flex-direction: column;
182
+ gap: 0.1rem;
183
+ }
184
+
185
+ .history-panel__event {
186
+ font-size: 0.8125rem;
187
+ font-weight: 500;
188
+ }
189
+
190
+ .history-panel__time {
191
+ color: var(--ui-workspace-muted-foreground, var(--muted-foreground));
192
+ font-size: 0.75rem;
193
+ }
194
+
195
+ .history-panel__pin {
196
+ flex: 0 0 auto;
197
+ width: 0.9rem;
198
+ height: 0.9rem;
199
+ color: var(--ui-workspace-accent, #7c3aed);
200
+ }
@@ -0,0 +1,302 @@
1
+ <script lang="ts">
2
+ import "./history-panel.css";
3
+ import { Menu, type App } from "@lapis-notes/api";
4
+ import { Button } from "@lapismd/design-core/shadcn/button";
5
+ import { ScrollArea } from "@lapismd/design-core/shadcn/scroll-area";
6
+ import * as Tooltip from "@lapismd/design-core/shadcn/tooltip";
7
+ import ArchiveRestore from "@lucide/svelte/icons/archive-restore";
8
+ import GitCommitVertical from "@lucide/svelte/icons/git-commit-vertical";
9
+ import GitCompare from "@lucide/svelte/icons/git-compare";
10
+ import GitCompareArrows from "@lucide/svelte/icons/git-compare-arrows";
11
+ import { onMount, untrack } from "svelte";
12
+ import {
13
+ formatHistoryEvent,
14
+ formatHistoryTimestamp,
15
+ } from "./history-format";
16
+ import type {
17
+ HistoryCompareAnchor,
18
+ HistoryCompareViewState,
19
+ HistoryRevision,
20
+ HistoryViewModel,
21
+ } from "./history-plugin";
22
+ import { HistoryCompareViewType } from "./history-view-type";
23
+
24
+ type HistoryPanelPlugin = {
25
+ getHistoryViewModel(): Promise<HistoryViewModel>;
26
+ getCompareAnchor(): HistoryCompareAnchor | null;
27
+ toggleCompareAnchor(filePath: string, revisionId: string): void;
28
+ openHistoryCompareView(state: HistoryCompareViewState): Promise<void>;
29
+ restoreRevision(filePath: string, revision: HistoryRevision): Promise<void>;
30
+ onHistoryChanged(listener: () => void): () => void;
31
+ };
32
+
33
+ let { app }: { app: App } = $props();
34
+
35
+ const plugin = untrack(() => {
36
+ const registered = app.plugins.plugins.get("history") as
37
+ | HistoryPanelPlugin
38
+ | undefined;
39
+ if (!registered) throw new Error("History plugin is not registered");
40
+ return registered;
41
+ });
42
+
43
+ let model = $state<HistoryViewModel>({
44
+ filePath: null,
45
+ history: null,
46
+ currentContent: "",
47
+ fileExists: false,
48
+ });
49
+ let anchor = $state<HistoryCompareAnchor | null>(null);
50
+ let openRevisionId = $state<string | null>(null);
51
+ let openCompareMode = $state<HistoryCompareViewState["compareMode"] | null>(
52
+ null,
53
+ );
54
+ let selectedRevisionId = $state<string | null>(null);
55
+
56
+ const selectedRevision = $derived(
57
+ model.history?.revisions.find(
58
+ (revision) => revision.revisionId === selectedRevisionId,
59
+ ) ?? null,
60
+ );
61
+ const previousRevision = $derived.by(() => {
62
+ if (!selectedRevision || !model.history) return undefined;
63
+ const index = model.history.revisions.findIndex(
64
+ (entry) => entry.revisionId === selectedRevision.revisionId,
65
+ );
66
+ return index >= 0 ? model.history.revisions[index + 1] : undefined;
67
+ });
68
+
69
+ async function refresh(): Promise<void> {
70
+ model = await plugin.getHistoryViewModel();
71
+ anchor = plugin.getCompareAnchor();
72
+ const leaf = app.workspace.getLeavesOfType(HistoryCompareViewType)[0];
73
+ const state = leaf?.view.getState() ?? {};
74
+ openRevisionId =
75
+ typeof state["revisionId"] === "string" ? state["revisionId"] : null;
76
+ const mode = state["compareMode"];
77
+ openCompareMode =
78
+ mode === "current" || mode === "previous" || mode === "selected"
79
+ ? mode
80
+ : null;
81
+ const revisionIds = new Set(
82
+ model.history?.revisions.map((revision) => revision.revisionId) ?? [],
83
+ );
84
+ if (openRevisionId && revisionIds.has(openRevisionId)) {
85
+ selectedRevisionId = openRevisionId;
86
+ } else if (!selectedRevisionId || !revisionIds.has(selectedRevisionId)) {
87
+ selectedRevisionId = model.history?.revisions[0]?.revisionId ?? null;
88
+ }
89
+ }
90
+
91
+ function openCompare(
92
+ revision: HistoryRevision,
93
+ compareMode: "current" | "previous" | "selected",
94
+ otherRevisionId?: string,
95
+ ): void {
96
+ if (!model.filePath) return;
97
+ selectedRevisionId = revision.revisionId;
98
+ void plugin.openHistoryCompareView({
99
+ filePath: model.filePath,
100
+ revisionId: revision.revisionId,
101
+ compareMode,
102
+ otherRevisionId,
103
+ sourceLeafId: app.workspace.activeLeaf?.id,
104
+ });
105
+ }
106
+
107
+ function showRevisionMenu(event: MouseEvent, revision: HistoryRevision): void {
108
+ event.preventDefault();
109
+ const previous = model.history?.revisions[
110
+ model.history.revisions.findIndex(
111
+ (entry) => entry.revisionId === revision.revisionId,
112
+ ) + 1
113
+ ];
114
+ const canCompareSelected = Boolean(
115
+ anchor &&
116
+ anchor.filePath === model.filePath &&
117
+ anchor.revisionId !== revision.revisionId,
118
+ );
119
+ new Menu()
120
+ .addItem((item) =>
121
+ item.setTitle("Compare with current").onClick(() => {
122
+ openCompare(revision, "current");
123
+ }),
124
+ )
125
+ .addItem((item) =>
126
+ item
127
+ .setTitle("Compare with previous")
128
+ .setDisabled(!previous)
129
+ .onClick(() => {
130
+ if (previous) openCompare(revision, "previous");
131
+ }),
132
+ )
133
+ .addSeparator()
134
+ .addItem((item) =>
135
+ item.setTitle("Select for compare").onClick(() => {
136
+ if (!model.filePath) return;
137
+ plugin.toggleCompareAnchor(model.filePath, revision.revisionId);
138
+ }),
139
+ )
140
+ .addItem((item) =>
141
+ item
142
+ .setTitle("Compare with selected")
143
+ .setDisabled(!canCompareSelected)
144
+ .onClick(() => {
145
+ if (!anchor) return;
146
+ openCompare(revision, "selected", anchor.revisionId);
147
+ }),
148
+ )
149
+ .addSeparator()
150
+ .addItem((item) =>
151
+ item.setTitle("Restore this revision").onClick(() => {
152
+ if (!model.filePath) return;
153
+ void plugin.restoreRevision(model.filePath, revision);
154
+ }),
155
+ )
156
+ .showAtMouseEvent(event);
157
+ }
158
+
159
+ onMount(() => {
160
+ const unsubscribe = plugin.onHistoryChanged(() => void refresh());
161
+ const leafChange = app.workspace.on("active-leaf-change", () => {
162
+ void refresh();
163
+ });
164
+ const layoutChange = app.workspace.on("layout-change", () => {
165
+ void refresh();
166
+ });
167
+ void refresh();
168
+ return () => {
169
+ unsubscribe();
170
+ app.workspace.offref(leafChange);
171
+ app.workspace.offref(layoutChange);
172
+ };
173
+ });
174
+ </script>
175
+
176
+ <div
177
+ class="history-panel"
178
+ data-ui-component="history-panel"
179
+ data-testid="history-panel"
180
+ >
181
+ <div class="history-panel__chrome" data-ui-part="chrome">
182
+ <Tooltip.Provider delayDuration={0}>
183
+ <div class="history-panel__toolbar" data-ui-part="toolbar">
184
+ <Tooltip.Root>
185
+ <Tooltip.Trigger>
186
+ {#snippet child({ props })}
187
+ <Button
188
+ {...props}
189
+ variant="ghost"
190
+ size="icon-sm"
191
+ class="history-panel__toolbar-action"
192
+ aria-label="Compare with current"
193
+ aria-pressed={openCompareMode === "current"}
194
+ disabled={!selectedRevision}
195
+ onclick={() => {
196
+ if (selectedRevision) openCompare(selectedRevision, "current");
197
+ }}
198
+ >
199
+ <GitCompare aria-hidden="true" />
200
+ </Button>
201
+ {/snippet}
202
+ </Tooltip.Trigger>
203
+ <Tooltip.Content side="bottom">Compare with current</Tooltip.Content>
204
+ </Tooltip.Root>
205
+ <Tooltip.Root>
206
+ <Tooltip.Trigger>
207
+ {#snippet child({ props })}
208
+ <Button
209
+ {...props}
210
+ variant="ghost"
211
+ size="icon-sm"
212
+ class="history-panel__toolbar-action"
213
+ aria-label="Compare with previous"
214
+ aria-pressed={openCompareMode === "previous"}
215
+ disabled={!previousRevision}
216
+ onclick={() => {
217
+ if (selectedRevision && previousRevision) {
218
+ openCompare(selectedRevision, "previous");
219
+ }
220
+ }}
221
+ >
222
+ <GitCompareArrows aria-hidden="true" />
223
+ </Button>
224
+ {/snippet}
225
+ </Tooltip.Trigger>
226
+ <Tooltip.Content side="bottom">Compare with previous</Tooltip.Content>
227
+ </Tooltip.Root>
228
+ <Tooltip.Root>
229
+ <Tooltip.Trigger>
230
+ {#snippet child({ props })}
231
+ <Button
232
+ {...props}
233
+ variant="ghost"
234
+ size="icon-sm"
235
+ class="history-panel__toolbar-action"
236
+ aria-label="Restore this revision"
237
+ disabled={!selectedRevision || !model.filePath}
238
+ onclick={() => {
239
+ if (!model.filePath || !selectedRevision) return;
240
+ void plugin.restoreRevision(model.filePath, selectedRevision);
241
+ }}
242
+ >
243
+ <ArchiveRestore aria-hidden="true" />
244
+ </Button>
245
+ {/snippet}
246
+ </Tooltip.Trigger>
247
+ <Tooltip.Content side="bottom">Restore this revision</Tooltip.Content>
248
+ </Tooltip.Root>
249
+ </div>
250
+ </Tooltip.Provider>
251
+ </div>
252
+ {#if !model.filePath}
253
+ <p class="history-panel__empty">Open a file to see its history.</p>
254
+ {:else if !model.history?.revisions.length}
255
+ <p class="history-panel__empty">No revisions stored for this file yet.</p>
256
+ {:else}
257
+ <ScrollArea class="history-panel__scroll">
258
+ <ol class="history-panel__timeline" aria-label="File history">
259
+ {#each model.history.revisions as revision (revision.revisionId)}
260
+ <li>
261
+ <button
262
+ type="button"
263
+ class="history-panel__row"
264
+ data-revision-id={revision.revisionId}
265
+ data-compare-anchor={anchor?.revisionId === revision.revisionId
266
+ ? "true"
267
+ : undefined}
268
+ aria-current={selectedRevisionId === revision.revisionId
269
+ ? "true"
270
+ : undefined}
271
+ aria-label={`${formatHistoryEvent(revision.eventType)} ${formatHistoryTimestamp(revision.createdAt)}`}
272
+ onclick={() => openCompare(revision, "current")}
273
+ oncontextmenu={(event) => showRevisionMenu(event, revision)}
274
+ >
275
+ <GitCommitVertical
276
+ class="history-panel__marker"
277
+ aria-hidden="true"
278
+ />
279
+ <span class="history-panel__meta">
280
+ <span class="history-panel__event"
281
+ >{formatHistoryEvent(revision.eventType)}</span
282
+ >
283
+ <time
284
+ class="history-panel__time"
285
+ datetime={new Date(revision.createdAt).toISOString()}
286
+ >{formatHistoryTimestamp(revision.createdAt)}</time
287
+ >
288
+ </span>
289
+ {#if anchor?.revisionId === revision.revisionId}
290
+ <GitCompare
291
+ class="history-panel__pin"
292
+ data-testid="history-compare-anchor"
293
+ aria-hidden="true"
294
+ />
295
+ {/if}
296
+ </button>
297
+ </li>
298
+ {/each}
299
+ </ol>
300
+ </ScrollArea>
301
+ {/if}
302
+ </div>
@@ -0,0 +1,8 @@
1
+ import "./history-panel.css";
2
+ import { type App } from "@lapis-notes/api";
3
+ type $$ComponentProps = {
4
+ app: App;
5
+ };
6
+ declare const HistoryPanel: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type HistoryPanel = ReturnType<typeof HistoryPanel>;
8
+ export default HistoryPanel;
@@ -0,0 +1,88 @@
1
+ import { Plugin, TFile, type App, type PluginManifest } from "@lapis-notes/api";
2
+ import { type HistoryPluginSettings, type HistoryPluginSettingsPatch } from "./history-settings";
3
+ export declare const HISTORY_PLUGIN_ID = "history";
4
+ export type HistoryCaptureEventType = "baseline" | "create" | "modify" | "rename" | "delete" | "restore";
5
+ export type HistoryCompareMode = "current" | "previous" | "selected";
6
+ export interface HistoryRevision {
7
+ revisionId: string;
8
+ path: string;
9
+ previousPath?: string;
10
+ eventType: HistoryCaptureEventType;
11
+ createdAt: number;
12
+ sourceMtime?: number;
13
+ sourceSize?: number;
14
+ contentHash: string;
15
+ content: string;
16
+ }
17
+ export interface HistoryFileHistory {
18
+ file: {
19
+ id: string;
20
+ path: string;
21
+ deleted: boolean;
22
+ };
23
+ revisions: HistoryRevision[];
24
+ }
25
+ export interface HistoryViewModel {
26
+ filePath: string | null;
27
+ history: HistoryFileHistory | null;
28
+ currentContent: string;
29
+ fileExists: boolean;
30
+ }
31
+ export interface HistoryCompareViewState {
32
+ filePath: string;
33
+ revisionId: string;
34
+ compareMode: HistoryCompareMode;
35
+ otherRevisionId?: string;
36
+ sourceLeafId?: string;
37
+ }
38
+ export interface HistoryComparisonModel {
39
+ filePath: string;
40
+ history: HistoryFileHistory;
41
+ currentContent: string;
42
+ fileExists: boolean;
43
+ compareMode: HistoryCompareMode;
44
+ selectedRevision: HistoryRevision;
45
+ previousRevision: HistoryRevision | null;
46
+ otherRevision: HistoryRevision | null;
47
+ compareBaseText: string;
48
+ compareNewText: string;
49
+ beforeLabel: string;
50
+ afterLabel: string;
51
+ }
52
+ export interface HistoryCompareAnchor {
53
+ filePath: string;
54
+ revisionId: string;
55
+ }
56
+ export declare class HistoryPlugin extends Plugin {
57
+ private settings;
58
+ private currentFilePath;
59
+ private compareAnchor;
60
+ private readonly pendingCaptures;
61
+ private readonly lastModifyStoredAt;
62
+ private readonly suppressedHashes;
63
+ private readonly listeners;
64
+ constructor(app: App, manifest?: PluginManifest);
65
+ onload(): Promise<void>;
66
+ getSettings(): HistoryPluginSettings;
67
+ updateSettings(patch: HistoryPluginSettingsPatch): Promise<void>;
68
+ onHistoryChanged(listener: () => void): () => void;
69
+ getCompareAnchor(): HistoryCompareAnchor | null;
70
+ setCompareAnchor(anchor: HistoryCompareAnchor | null): void;
71
+ toggleCompareAnchor(filePath: string, revisionId: string): void;
72
+ openHistoryView(filePath?: string): Promise<void>;
73
+ openHistoryCompareView(state: HistoryCompareViewState): Promise<void>;
74
+ closeHistoryCompareView(sourceLeafId?: string): void;
75
+ getHistoryViewModel(): Promise<HistoryViewModel>;
76
+ getHistoryComparisonModel(state: HistoryCompareViewState): Promise<HistoryComparisonModel>;
77
+ restoreRevision(filePath: string, revision: HistoryRevision): Promise<void>;
78
+ saveCurrentFileContent(filePath: string, content: string): Promise<void>;
79
+ getFocusedFile(): TFile | null;
80
+ private setCurrentFilePath;
81
+ private scheduleCapture;
82
+ private ensureBaselineForFile;
83
+ private captureSnapshot;
84
+ private captureRename;
85
+ private captureDelete;
86
+ private notify;
87
+ }
88
+ export default HistoryPlugin;