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
22 async onload(): Promise<void> {23 await this.loadSettings();24
25 // status bar26 this.statusBarEl = this.addStatusBarItem();27 this.statusBarEl.addClass("sbl-status-bar");28 this.statusBarEl.addEventListener("click", () =>29 this.openCurrentFileOnWeb()30 );31 this.updateStatusBar(null);32
33 // register version history view34 this.registerView(35 VERSION_VIEW_TYPE,36 (leaf) => new VersionView(leaf, this)37 );38
39 // commands40 this.addCommand({41 id: "open-on-web",42 name: "Open on sublimated.com",43 callback: () => this.openCurrentFileOnWeb(),44 });45
46 this.addCommand({47 id: "show-versions",48 name: "Show versions",49 callback: () => this.activateVersionView(),50 });51
52 this.addCommand({53 id: "copy-web-link",54 name: "Copy web link",55 callback: () => this.copyWebLink(),56 });57
58 // ribbon icon for version sidebar59 this.addRibbonIcon("history", "sublimated versions", () => {60 this.activateVersionView();61 });62
63 // URI handler: obsidian://sublimated?action=open&file=path/to/note.md64 this.registerObsidianProtocolHandler(65 "sublimated",66 (params) => this.handleUri(params)67 );68
69 // settings tab70 this.addSettingTab(new SublimatedSettingTab(this.app, this));71
72 // update status bar when active file changes73 this.registerEvent(74 this.app.workspace.on("active-leaf-change", () => {75 this.refreshMetadata();76 })77 );78
79 // refresh metadata after file modifications (xattrs update on sync)80 this.registerEvent(81 this.app.vault.on("modify", (file) => {82 const active = this.app.workspace.getActiveFile();83 if (active && file.path === active.path) {84 // delay to let File Provider update xattrs after save85 setTimeout(() => this.refreshMetadata(), 1000);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 async refreshMetadata(): Promise<void> {129 const file = this.app.workspace.getActiveFile();130 if (!file) {131 this.lastMetadata = null;132 this.updateStatusBar(null);133 return;134 }135
136 if (!isMacOS()) {137 this.lastMetadata = null;138 this.updateStatusBar(null);139 return;140 }141
142 // get absolute path via vault adapter143 const adapter = this.app.vault.adapter;144 const basePath = (adapter as { getBasePath?: () => string })145 .getBasePath?.();146 if (!basePath) {147 this.lastMetadata = null;148 this.updateStatusBar(null);149 return;150 }151
152 const absPath = `${basePath}/${file.path}`;153 this.lastMetadata = await readFileMetadata(absPath);154 this.updateStatusBar(this.lastMetadata);155 }156
157 private updateStatusBar(meta: FileMetadata | null): void {158 if (!this.statusBarEl) return;159
160 // no sublimated xattrs → file isn't tracked by sublimated, hide status bar161 if (!meta || (!meta.space && !meta.id)) {162 this.statusBarEl.setText("");163 return;164 }165
166 // compact format: "✓ synced v21 by anton" (sync + version + author + optional branch/visibility)167 const parts: string[] = [];168
169 if (meta.syncStatus) {170 const icon = syncStatusIcon(meta.syncStatus);171 parts.push(icon ? `${icon} ${meta.syncStatus}` : meta.syncStatus);172 }173
174 if (meta.version) {175 parts.push(`v${meta.version}`);176 }177
178 if (meta.modifiedBy) {179 const prefix =180 meta.modifiedByType === "ai_agent" ? "by AI:" : "by";181 parts.push(`${prefix} ${meta.modifiedBy}`);182 }183
184 if (meta.branch && meta.branch !== "main") {185 parts.push(meta.branch);186 }187
188 if (meta.visibility) {189 parts.push(visibilityIcon(meta.visibility));190 }191
192 this.statusBarEl.setText(parts.join(" "));193
194 // show pointer cursor when URL available195 if (meta.url) {196 this.statusBarEl.addClass("sbl-clickable");197 } else {198 this.statusBarEl.removeClass("sbl-clickable");199 }200 }201
202 private openCurrentFileOnWeb(): void {203 const url = this.lastMetadata?.url;204 if (!url) {205 new Notice("No sublimated URL for this file");206 return;207 }208 window.open(`https://${url}`);209 }210
211 private async copyWebLink(): Promise<void> {212 const url = this.lastMetadata?.url;213 if (!url) {214 new Notice("No sublimated URL for this file");215 return;216 }217 await navigator.clipboard.writeText(`https://${url}`);218 new Notice("Link copied to clipboard");219 }220
221 private async activateVersionView(): Promise<void> {222 const existing =223 this.app.workspace.getLeavesOfType(VERSION_VIEW_TYPE);224 if (existing.length > 0) {225 this.app.workspace.revealLeaf(existing[0]);226 return;227 }228
229 const leaf = this.app.workspace.getRightLeaf(false);230 if (!leaf) return;231 await leaf.setViewState({232 type: VERSION_VIEW_TYPE,233 active: true,234 });235 this.app.workspace.revealLeaf(leaf);236 }237
238 private handleUri(params: Record<string, string>): void {239 const { action, file } = params;240
241 if (action === "open" && file) {242 const normalized = normalizePath(file);243 const existing = this.app.vault.getAbstractFileByPath(normalized);244 if (existing) {245 this.app.workspace.openLinkText(normalized, "", false);246 } else {247 new Notice(`File not found: ${file}`);248 }249 return;250 }251
252 if (action === "auth" && params.token) {253 // future: OAuth flow stores token from redirect254 this.settings.apiToken = params.token;255 this.saveSettings();256 new Notice("sublimated: signed in successfully");257 return;258 }259
260 new Notice(`Unknown sublimated action: ${action}`);261 }262}263
264function syncStatusIcon(status: string): string {265 switch (status) {266 case "synced":267 return "✓";268 case "uploading":269 return "↑";270 case "downloading":271 return "↓";272 case "error":273 return "!";274 case "conflict":275 return "⚠";276 default:277 return "";278 }279}280
281function visibilityIcon(visibility: string): string {282 switch (visibility) {283 case "public":284 return "🌐";285 case "shared":286 return "👥";287 case "private":288 return "🔒";289 default:290 return "";291 }292} v5 of v8
commit c923ef0
message edit main.ts via macOS
author anton
date Apr 14, 2026
size 7.0 KB