1import { ItemView, Notice, WorkspaceLeaf } from "obsidian";2import type SublimatedPlugin from "./main";3import { SublimatedApi, AuthRequiredError, 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 changes30 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 // read xattrs to get space + item ID52 const meta = this.plugin.lastMetadata;53 if (!meta?.space || !meta?.id) {54 contentEl.createEl("p", {55 text: "Not a sublimated file.",56 cls: "sbl-version-empty",57 });58 return;59 }60
61 contentEl.createEl("h4", {62 text: activeFile.name,63 cls: "sbl-version-filename",64 });65
66 const loading = contentEl.createEl("p", {67 text: "Loading versions...",68 cls: "sbl-version-loading",69 });70
71 try {72 const api = new SublimatedApi(this.plugin.settings.apiToken);73 const versions = await api.getVersions(meta.space, meta.id);74 loading.remove();75
76 if (versions.length === 0) {77 contentEl.createEl("p", {78 text: "No versions found.",79 cls: "sbl-version-empty",80 });81 return;82 }83
84 const list = contentEl.createEl("div", {85 cls: "sbl-version-list",86 });87 const groups = groupByDay(versions);88 for (const group of groups) {89 list.createEl("div", {90 text: group.label,91 cls: "sbl-version-day",92 });93 for (const v of group.versions) {94 this.renderVersion(list, v, meta.url);95 }96 }97 } catch (e) {98 loading.remove();99 if (e instanceof AuthRequiredError) {100 contentEl.createEl("p", {101 text: "This file is private — add an API token to view version history.",102 cls: "sbl-version-empty",103 });104 contentEl.createEl("p", {105 text: "Configure in Settings → sublimated",106 cls: "sbl-version-hint",107 });108 return;109 }110 contentEl.createEl("p", {111 text: `Error: ${e instanceof Error ? e.message : "Unknown"}`,112 cls: "sbl-version-error",113 });114 }115 }116
117 private renderVersion(118 container: HTMLElement,119 v: Version,120 fileUrl: string | null121 ): void {122 const item = container.createDiv({ cls: "sbl-version-item" });123
124 const header = item.createDiv({ cls: "sbl-version-header" });125 header.createEl("span", {126 text: `v${v.number}`,127 cls: "sbl-version-number",128 });129 header.createEl("span", {130 text: v.authorName,131 cls: "sbl-version-author",132 });133
134 const time = item.createDiv({ cls: "sbl-version-time" });135 time.createEl("span", {136 text: formatRelativeTime(v.createdAt),137 attr: { title: new Date(v.createdAt).toLocaleString() },138 });139
140 if (v.message) {141 item.createDiv({142 text: v.message,143 cls: "sbl-version-message",144 });145 }146
147 if (fileUrl) {148 const webUrl = `https://${fileUrl}/~versions/v${v.number}`;149 const link = item.createEl("a", {150 text: "view on web",151 cls: "sbl-version-web-link",152 href: webUrl,153 });154 link.addEventListener("click", (e) => {155 e.preventDefault();156 window.open(webUrl);157 });158 }159 }160
161 async onClose(): Promise<void> {162 // cleanup handled by obsidian163 }164}165
166interface DayGroup {167 label: string;168 versions: Version[];169}170
171function groupByDay(versions: Version[]): DayGroup[] {172 const groups = new Map<string, Version[]>();173 const order: string[] = [];174
175 for (const v of versions) {176 const label = formatDayLabel(v.createdAt);177 if (!groups.has(label)) {178 groups.set(label, []);179 order.push(label);180 }181 groups.get(label)!.push(v);182 }183
184 return order.map((label) => ({185 label,186 versions: groups.get(label)!,187 }));188}189
190function formatDayLabel(iso: string): string {191 const now = new Date();192 const date = new Date(iso);193
194 const nowDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());195 const thenDay = new Date(196 date.getFullYear(),197 date.getMonth(),198 date.getDate()199 );200 const days = Math.round(201 (nowDay.getTime() - thenDay.getTime()) / (1000 * 60 * 60 * 24)202 );203
204 if (days === 0) return "Today";205 if (days === 1) return "Yesterday";206 if (days < 7) return `${days} days ago`;207 return date.toLocaleDateString("en-US", {208 month: "short",209 day: "numeric",210 });211}212
213function formatRelativeTime(iso: string): string {214 const now = Date.now();215 const then = new Date(iso).getTime();216 const diff = now - then;217 const seconds = Math.floor(diff / 1000);218 const minutes = Math.floor(seconds / 60);219 const hours = Math.floor(minutes / 60);220 const days = Math.floor(hours / 24);221
222 if (days > 30) return new Date(iso).toLocaleDateString();223 if (days > 0) return `${days}d ago`;224 if (hours > 0) return `${hours}h ago`;225 if (minutes > 0) return `${minutes}m ago`;226 return "just now";227} v8
commit 3ff5c69
message edit version-view.ts via macOS
author anton
date Apr 11, 2026
size 5.2 KB