1import {2 Notice,3 Plugin,4 WorkspaceLeaf,5 normalizePath,6} from "obsidian";7import {8 type SublimatedSettings,9 DEFAULT_SETTINGS,10 SublimatedSettingTab,11} from "./settings";12import { type FileMetadata, readFileMetadata, isMacOS } from "./xattr";13import { VersionView, VERSION_VIEW_TYPE } from "./version-view";14import { WelcomeModal } from "./welcome-modal";15
16export default class SublimatedPlugin extends Plugin {17 settings: SublimatedSettings = DEFAULT_SETTINGS;18 lastMetadata: FileMetadata | null = null;19
20 private statusBarEl: HTMLElement | null = null;21 private modifyRetryTimer: ReturnType<typeof setTimeout> | null = null;22
23 async onload(): Promise<void> {24 await this.loadSettings();25
26 // status bar27 this.statusBarEl = this.addStatusBarItem();28 this.statusBarEl.addClass("sbl-status-bar");29 this.statusBarEl.addEventListener("click", () =>30 this.openCurrentFileOnWeb()31 );32 this.updateStatusBar(null);33
34 // register version history view35 this.registerView(36 VERSION_VIEW_TYPE,37 (leaf) => new VersionView(leaf, this)38 );39
40 // commands41 this.addCommand({42 id: "open-on-web",43 name: "Open on sublimated.com",44 callback: () => this.openCurrentFileOnWeb(),45 });46
47 this.addCommand({48 id: "show-versions",49 name: "Show versions",50 callback: () => this.activateVersionView(),51 });52
53 this.addCommand({54 id: "copy-web-link",55 name: "Copy web link",56 callback: () => this.copyWebLink(),57 });58
59 // ribbon icon for version sidebar60 this.addRibbonIcon("history", "sublimated versions", () => {61 this.activateVersionView();62 });63
64 // URI handler: obsidian://sublimated?action=open&file=path/to/note.md65 this.registerObsidianProtocolHandler(66 "sublimated",67 (params) => this.handleUri(params)68 );69
70 // settings tab71 this.addSettingTab(new SublimatedSettingTab(this.app, this));72
73 // update status bar when active file changes74 this.registerEvent(75 this.app.workspace.on("active-leaf-change", () => {76 this.refreshMetadata();77 })78 );79
80 // refresh metadata after file modifications (xattrs update on sync)81 this.registerEvent(82 this.app.vault.on("modify", (file) => {83 const active = this.app.workspace.getActiveFile();84 if (active && file.path === active.path) {85 this.refreshAfterModify();86 }87 })88 );89
90 // initial metadata read91 this.refreshMetadata();92
93 // welcome modal on first install94 if (!this.settings.hasSeenWelcome) {95 new WelcomeModal(this.app).open();96 this.settings.hasSeenWelcome = true;97 await this.saveSettings();98 }99 }100
101 onunload(): void {102 this.app.workspace.detachLeavesOfType(VERSION_VIEW_TYPE);103 }104
105 async loadSettings(): Promise<void> {106 this.settings = Object.assign(107 {},108 DEFAULT_SETTINGS,109 await this.loadData()110 );111 }112
113 async saveSettings(): Promise<void> {114 await this.saveData(this.settings);115 }116
117 // detect workspace from xattrs on the currently active file.118 // every synced file carries com.sublimated.space#PS, so any file119 // with sublimated metadata identifies the workspace.120 detectWorkspace(): { space: string | null } | null {121 if (!this.lastMetadata) return null;122 if (!this.lastMetadata.space && !this.lastMetadata.id) return null;123 return {124 space: this.lastMetadata.space ?? null,125 };126 }127
128 private refreshAfterModify(attempt = 0): void {129 if (this.modifyRetryTimer) clearTimeout(this.modifyRetryTimer);130 const delays = [200, 800, 2000];131 if (attempt >= delays.length) return;132
133 this.modifyRetryTimer = setTimeout(async () => {134 const prev = this.lastMetadata?.version ?? null;135 await this.refreshMetadata();136 if (this.lastMetadata?.version === prev) {137 // xattrs haven't updated yet, retry138 this.refreshAfterModify(attempt + 1);139 }140 }, delays[attempt]);141 }142
143 private async refreshMetadata(): Promise<void> {144 const file = this.app.workspace.getActiveFile();145 if (!file) {146 this.lastMetadata = null;147 this.updateStatusBar(null);148 return;149 }150
151 if (!isMacOS()) {152 this.lastMetadata = null;153 this.updateStatusBar(null);154 return;155 }156
157 // get absolute path via vault adapter158 const adapter = this.app.vault.adapter;159 const basePath = (adapter as { getBasePath?: () => string })160 .getBasePath?.();161 if (!basePath) {162 this.lastMetadata = null;163 this.updateStatusBar(null);164 return;165 }166
167 const absPath = `${basePath}/${file.path}`;168 this.lastMetadata = await readFileMetadata(absPath);169 this.updateStatusBar(this.lastMetadata);170 }171
172 private updateStatusBar(meta: FileMetadata | null): void {173 if (!this.statusBarEl) return;174
175 // no sublimated xattrs → file isn't tracked by sublimated, hide status bar176 if (!meta || (!meta.space && !meta.id)) {177 this.statusBarEl.setText("");178 return;179 }180
181 // compact format: "✓ synced v21 by anton" (sync + version + author + optional branch/visibility)182 const parts: string[] = [];183
184 if (meta.syncStatus) {185 const icon = syncStatusIcon(meta.syncStatus);186 parts.push(icon ? `${icon} ${meta.syncStatus}` : meta.syncStatus);187 }188
189 if (meta.version) {190 parts.push(`v${meta.version}`);191 }192
193 if (meta.modifiedBy) {194 const prefix =195 meta.modifiedByType === "ai_agent" ? "by AI:" : "by";196 parts.push(`${prefix} ${meta.modifiedBy}`);197 }198
199 if (meta.branch && meta.branch !== "main") {200 parts.push(meta.branch);201 }202
203 if (meta.visibility) {204 parts.push(visibilityIcon(meta.visibility));205 }206
207 this.statusBarEl.setText(parts.join(" "));208
209 // show pointer cursor when URL available210 if (meta.url) {211 this.statusBarEl.addClass("sbl-clickable");212 } else {213 this.statusBarEl.removeClass("sbl-clickable");214 }215 }216
217 private openCurrentFileOnWeb(): void {218 const url = this.lastMetadata?.url;219 if (!url) {220 new Notice("No sublimated URL for this file");221 return;222 }223 window.open(`https://${url}`);224 }225
226 private async copyWebLink(): Promise<void> {227 const url = this.lastMetadata?.url;228 if (!url) {229 new Notice("No sublimated URL for this file");230 return;231 }232 await navigator.clipboard.writeText(`https://${url}`);233 new Notice("Link copied to clipboard");234 }235
236 private async activateVersionView(): Promise<void> {237 const existing =238 this.app.workspace.getLeavesOfType(VERSION_VIEW_TYPE);239 if (existing.length > 0) {240 this.app.workspace.revealLeaf(existing[0]);241 return;242 }243
244 const leaf = this.app.workspace.getRightLeaf(false);245 if (!leaf) return;246 await leaf.setViewState({247 type: VERSION_VIEW_TYPE,248 active: true,249 });250 this.app.workspace.revealLeaf(leaf);251 }252
253 private handleUri(params: Record<string, string>): void {254 const { action, file } = params;255
256 if (action === "open" && file) {257 const normalized = normalizePath(file);258 const existing = this.app.vault.getAbstractFileByPath(normalized);259 if (existing) {260 this.app.workspace.openLinkText(normalized, "", false);261 } else {262 new Notice(`File not found: ${file}`);263 }264 return;265 }266
267 if (action === "auth" && params.token) {268 // future: OAuth flow stores token from redirect269 this.settings.apiToken = params.token;270 this.saveSettings();271 new Notice("sublimated: signed in successfully");272 return;273 }274
275 new Notice(`Unknown sublimated action: ${action}`);276 }277}278
279function syncStatusIcon(status: string): string {280 switch (status) {281 case "synced":282 return "✓";283 case "uploading":284 return "↑";285 case "downloading":286 return "↓";287 case "error":288 return "!";289 case "conflict":290 return "⚠";291 default:292 return "";293 }294}295
296function visibilityIcon(visibility: string): string {297 switch (visibility) {298 case "public":299 return "🌐";300 case "shared":301 return "👥";302 case "private":303 return "🔒";304 default:305 return "";306 }307} v8
commit 94e5c4f
message edit main.ts via macOS
author anton
date Apr 14, 2026
size 7.4 KB