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