Apr 14, 2026 7.4 KB typescript v8
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 bar
27 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 view
35 this.registerView(
36 VERSION_VIEW_TYPE,
37 (leaf) => new VersionView(leaf, this)
38 );
39
40 // commands
41 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 sidebar
60 this.addRibbonIcon("history", "sublimated versions", () => {
61 this.activateVersionView();
62 });
63
64 // URI handler: obsidian://sublimated?action=open&file=path/to/note.md
65 this.registerObsidianProtocolHandler(
66 "sublimated",
67 (params) => this.handleUri(params)
68 );
69
70 // settings tab
71 this.addSettingTab(new SublimatedSettingTab(this.app, this));
72
73 // update status bar when active file changes
74 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 read
91 this.refreshMetadata();
92
93 // welcome modal on first install
94 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 file
119 // 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, retry
138 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 adapter
158 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 bar
176 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 available
210 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 redirect
269 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}