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 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 ID64 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 | null122 ): 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 obsidian164 }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} v4 of v8
commit b119d2a
message edit version-view.ts via macOS
author anton
date Apr 8, 2026
size 5.1 KB