Apr 8, 2026 5.1 KB typescript of v8
1import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
2import type SublimatedPlugin from "./main";
3import { SublimatedApi, type Version } from "./api";
4
5export const VERSION_VIEW_TYPE = "sublimated-versions";
6
7export class VersionView extends ItemView {
8 plugin: SublimatedPlugin;
9
10 constructor(leaf: WorkspaceLeaf, plugin: SublimatedPlugin) {
11 super(leaf);
12 this.plugin = plugin;
13 }
14
15 getViewType(): string {
16 return VERSION_VIEW_TYPE;
17 }
18
19 getDisplayText(): string {
20 return "sublimated versions";
21 }
22
23 getIcon(): string {
24 return "history";
25 }
26
27 async onOpen(): Promise<void> {
28 await this.refresh();
29 // refresh when active file changes
30 this.registerEvent(
31 this.app.workspace.on("active-leaf-change", () => {
32 this.refresh();
33 })
34 );
35 }
36
37 async refresh(): Promise<void> {
38 const { contentEl } = this;
39 contentEl.empty();
40 contentEl.addClass("sbl-version-view");
41
42 const activeFile = this.app.workspace.getActiveFile();
43 if (!activeFile) {
44 contentEl.createEl("p", {
45 text: "No file open.",
46 cls: "sbl-version-empty",
47 });
48 return;
49 }
50
51 if (!this.plugin.settings.apiToken) {
52 contentEl.createEl("p", {
53 text: "API token required for version history.",
54 cls: "sbl-version-empty",
55 });
56 contentEl.createEl("p", {
57 text: "Configure in Settings → sublimated",
58 cls: "sbl-version-hint",
59 });
60 return;
61 }
62
63 // read xattrs to get space + item ID
64 const meta = this.plugin.lastMetadata;
65 if (!meta?.space || !meta?.id) {
66 contentEl.createEl("p", {
67 text: "Not a sublimated file.",
68 cls: "sbl-version-empty",
69 });
70 return;
71 }
72
73 contentEl.createEl("h4", {
74 text: activeFile.name,
75 cls: "sbl-version-filename",
76 });
77
78 const loading = contentEl.createEl("p", {
79 text: "Loading versions...",
80 cls: "sbl-version-loading",
81 });
82
83 try {
84 const api = new SublimatedApi(this.plugin.settings.apiToken);
85 const versions = await api.getVersions(meta.space, meta.id);
86 loading.remove();
87
88 if (versions.length === 0) {
89 contentEl.createEl("p", {
90 text: "No versions found.",
91 cls: "sbl-version-empty",
92 });
93 return;
94 }
95
96 const list = contentEl.createEl("div", {
97 cls: "sbl-version-list",
98 });
99 const groups = groupByDay(versions);
100 for (const group of groups) {
101 list.createEl("div", {
102 text: group.label,
103 cls: "sbl-version-day",
104 });
105 for (const v of group.versions) {
106 this.renderVersion(list, v, meta.url);
107 }
108 }
109 } catch (e) {
110 loading.remove();
111 contentEl.createEl("p", {
112 text: `Error: ${e instanceof Error ? e.message : "Unknown"}`,
113 cls: "sbl-version-error",
114 });
115 }
116 }
117
118 private renderVersion(
119 container: HTMLElement,
120 v: Version,
121 fileUrl: string | null
122 ): void {
123 const item = container.createDiv({ cls: "sbl-version-item" });
124
125 const header = item.createDiv({ cls: "sbl-version-header" });
126 header.createEl("span", {
127 text: `v${v.number}`,
128 cls: "sbl-version-number",
129 });
130 header.createEl("span", {
131 text: v.authorName,
132 cls: "sbl-version-author",
133 });
134
135 const time = item.createDiv({ cls: "sbl-version-time" });
136 time.createEl("span", {
137 text: formatRelativeTime(v.createdAt),
138 attr: { title: new Date(v.createdAt).toLocaleString() },
139 });
140
141 if (v.message) {
142 item.createDiv({
143 text: v.message,
144 cls: "sbl-version-message",
145 });
146 }
147
148 if (fileUrl) {
149 const webUrl = `https://${fileUrl}/~versions/v${v.number}`;
150 const link = item.createEl("a", {
151 text: "view on web",
152 cls: "sbl-version-web-link",
153 href: webUrl,
154 });
155 link.addEventListener("click", (e) => {
156 e.preventDefault();
157 window.open(webUrl);
158 });
159 }
160 }
161
162 async onClose(): Promise<void> {
163 // cleanup handled by obsidian
164 }
165}
166
167interface DayGroup {
168 label: string;
169 versions: Version[];
170}
171
172function groupByDay(versions: Version[]): DayGroup[] {
173 const groups = new Map<string, Version[]>();
174 const order: string[] = [];
175
176 for (const v of versions) {
177 const label = formatDayLabel(v.createdAt);
178 if (!groups.has(label)) {
179 groups.set(label, []);
180 order.push(label);
181 }
182 groups.get(label)!.push(v);
183 }
184
185 return order.map((label) => ({
186 label,
187 versions: groups.get(label)!,
188 }));
189}
190
191function formatDayLabel(iso: string): string {
192 const now = new Date();
193 const date = new Date(iso);
194
195 const nowDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
196 const thenDay = new Date(
197 date.getFullYear(),
198 date.getMonth(),
199 date.getDate()
200 );
201 const days = Math.round(
202 (nowDay.getTime() - thenDay.getTime()) / (1000 * 60 * 60 * 24)
203 );
204
205 if (days === 0) return "Today";
206 if (days === 1) return "Yesterday";
207 if (days < 7) return `${days} days ago`;
208 return date.toLocaleDateString("en-US", {
209 month: "short",
210 day: "numeric",
211 });
212}
213
214function formatRelativeTime(iso: string): string {
215 const now = Date.now();
216 const then = new Date(iso).getTime();
217 const diff = now - then;
218 const seconds = Math.floor(diff / 1000);
219 const minutes = Math.floor(seconds / 60);
220 const hours = Math.floor(minutes / 60);
221 const days = Math.floor(hours / 24);
222
223 if (days > 30) return new Date(iso).toLocaleDateString();
224 if (days > 0) return `${days}d ago`;
225 if (hours > 0) return `${hours}h ago`;
226 if (minutes > 0) return `${minutes}m ago`;
227 return "just now";
228}