Apr 14, 2026 6.9 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 this.refreshAfterModify();
85 }
86 })
87 );
88
89 // initial metadata read
90 this.refreshMetadata();
91
92 // welcome modal on first install
93 if (!this.settings.hasSeenWelcome) {
94 new WelcomeModal(this.app).open();
95 this.settings.hasSeenWelcome = true;
96 await this.saveSettings();
97 }
98 }
99
100 onunload(): void {
101 this.app.workspace.detachLeavesOfType(VERSION_VIEW_TYPE);
102 }
103
104 async loadSettings(): Promise<void> {
105 this.settings = Object.assign(
106 {},
107 DEFAULT_SETTINGS,
108 await this.loadData()
109 );
110 }
111
112 async saveSettings(): Promise<void> {
113 await this.saveData(this.settings);
114 }
115
116 // detect workspace from xattrs on the currently active file.
117 // every synced file carries com.sublimated.space#PS, so any file
118 // with sublimated metadata identifies the workspace.
119 detectWorkspace(): { space: string | null } | null {
120 if (!this.lastMetadata) return null;
121 if (!this.lastMetadata.space && !this.lastMetadata.id) return null;
122 return {
123 space: this.lastMetadata.space ?? null,
124 };
125 }
126
127 private async refreshMetadata(): Promise<void> {
128 const file = this.app.workspace.getActiveFile();
129 if (!file) {
130 this.lastMetadata = null;
131 this.updateStatusBar(null);
132 return;
133 }
134
135 if (!isMacOS()) {
136 this.lastMetadata = null;
137 this.updateStatusBar(null);
138 return;
139 }
140
141 // get absolute path via vault adapter
142 const adapter = this.app.vault.adapter;
143 const basePath = (adapter as { getBasePath?: () => string })
144 .getBasePath?.();
145 if (!basePath) {
146 this.lastMetadata = null;
147 this.updateStatusBar(null);
148 return;
149 }
150
151 const absPath = `${basePath}/${file.path}`;
152 this.lastMetadata = await readFileMetadata(absPath);
153 this.updateStatusBar(this.lastMetadata);
154 }
155
156 private updateStatusBar(meta: FileMetadata | null): void {
157 if (!this.statusBarEl) return;
158
159 // no sublimated xattrs → file isn't tracked by sublimated, hide status bar
160 if (!meta || (!meta.space && !meta.id)) {
161 this.statusBarEl.setText("");
162 return;
163 }
164
165 // compact format: "✓ synced v21 by anton" (sync + version + author + optional branch/visibility)
166 const parts: string[] = [];
167
168 if (meta.syncStatus) {
169 const icon = syncStatusIcon(meta.syncStatus);
170 parts.push(icon ? `${icon} ${meta.syncStatus}` : meta.syncStatus);
171 }
172
173 if (meta.version) {
174 parts.push(`v${meta.version}`);
175 }
176
177 if (meta.modifiedBy) {
178 const prefix =
179 meta.modifiedByType === "ai_agent" ? "by AI:" : "by";
180 parts.push(`${prefix} ${meta.modifiedBy}`);
181 }
182
183 if (meta.branch && meta.branch !== "main") {
184 parts.push(meta.branch);
185 }
186
187 if (meta.visibility) {
188 parts.push(visibilityIcon(meta.visibility));
189 }
190
191 this.statusBarEl.setText(parts.join(" "));
192
193 // show pointer cursor when URL available
194 if (meta.url) {
195 this.statusBarEl.addClass("sbl-clickable");
196 } else {
197 this.statusBarEl.removeClass("sbl-clickable");
198 }
199 }
200
201 private openCurrentFileOnWeb(): void {
202 const url = this.lastMetadata?.url;
203 if (!url) {
204 new Notice("No sublimated URL for this file");
205 return;
206 }
207 window.open(`https://${url}`);
208 }
209
210 private async copyWebLink(): Promise<void> {
211 const url = this.lastMetadata?.url;
212 if (!url) {
213 new Notice("No sublimated URL for this file");
214 return;
215 }
216 await navigator.clipboard.writeText(`https://${url}`);
217 new Notice("Link copied to clipboard");
218 }
219
220 private async activateVersionView(): Promise<void> {
221 const existing =
222 this.app.workspace.getLeavesOfType(VERSION_VIEW_TYPE);
223 if (existing.length > 0) {
224 this.app.workspace.revealLeaf(existing[0]);
225 return;
226 }
227
228 const leaf = this.app.workspace.getRightLeaf(false);
229 if (!leaf) return;
230 await leaf.setViewState({
231 type: VERSION_VIEW_TYPE,
232 active: true,
233 });
234 this.app.workspace.revealLeaf(leaf);
235 }
236
237 private handleUri(params: Record<string, string>): void {
238 const { action, file } = params;
239
240 if (action === "open" && file) {
241 const normalized = normalizePath(file);
242 const existing = this.app.vault.getAbstractFileByPath(normalized);
243 if (existing) {
244 this.app.workspace.openLinkText(normalized, "", false);
245 } else {
246 new Notice(`File not found: ${file}`);
247 }
248 return;
249 }
250
251 if (action === "auth" && params.token) {
252 // future: OAuth flow stores token from redirect
253 this.settings.apiToken = params.token;
254 this.saveSettings();
255 new Notice("sublimated: signed in successfully");
256 return;
257 }
258
259 new Notice(`Unknown sublimated action: ${action}`);
260 }
261}
262
263function syncStatusIcon(status: string): string {
264 switch (status) {
265 case "synced":
266 return "✓";
267 case "uploading":
268 return "↑";
269 case "downloading":
270 return "↓";
271 case "error":
272 return "!";
273 case "conflict":
274 return "⚠";
275 default:
276 return "";
277 }
278}
279
280function visibilityIcon(visibility: string): string {
281 switch (visibility) {
282 case "public":
283 return "🌐";
284 case "shared":
285 return "👥";
286 case "private":
287 return "🔒";
288 default:
289 return "";
290 }
291}