Apr 14, 2026 7.0 KB typescript of 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
22 async onload(): Promise<void> {
23 await this.loadSettings();
24
25 // status bar
26 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 view
34 this.registerView(
35 VERSION_VIEW_TYPE,
36 (leaf) => new VersionView(leaf, this)
37 );
38
39 // commands
40 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 sidebar
59 this.addRibbonIcon("history", "sublimated versions", () => {
60 this.activateVersionView();
61 });
62
63 // URI handler: obsidian://sublimated?action=open&file=path/to/note.md
64 this.registerObsidianProtocolHandler(
65 "sublimated",
66 (params) => this.handleUri(params)
67 );
68
69 // settings tab
70 this.addSettingTab(new SublimatedSettingTab(this.app, this));
71
72 // update status bar when active file changes
73 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 save
85 setTimeout(() => this.refreshMetadata(), 1000);
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 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 adapter
143 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 bar
161 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 available
195 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 redirect
254 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}