Apr 8, 2026 6.7 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 private isSublimatedVault = false;
22
23 async onload(): Promise<void> {
24 await this.loadSettings();
25
26 // detect if this vault is a sublimated workspace
27 this.isSublimatedVault = await this.checkSublimatedVault();
28
29 // status bar
30 this.statusBarEl = this.addStatusBarItem();
31 this.statusBarEl.addClass("sbl-status-bar");
32 this.statusBarEl.addEventListener("click", () =>
33 this.openCurrentFileOnWeb()
34 );
35 this.updateStatusBar(null);
36
37 // register version history view
38 this.registerView(
39 VERSION_VIEW_TYPE,
40 (leaf) => new VersionView(leaf, this)
41 );
42
43 // commands
44 this.addCommand({
45 id: "open-on-web",
46 name: "Open on sublimated.com",
47 callback: () => this.openCurrentFileOnWeb(),
48 });
49
50 this.addCommand({
51 id: "show-versions",
52 name: "Show versions",
53 callback: () => this.activateVersionView(),
54 });
55
56 this.addCommand({
57 id: "copy-web-link",
58 name: "Copy web link",
59 callback: () => this.copyWebLink(),
60 });
61
62 // ribbon icon for version sidebar
63 this.addRibbonIcon("history", "sublimated versions", () => {
64 this.activateVersionView();
65 });
66
67 // URI handler: obsidian://sublimated?action=open&file=path/to/note.md
68 this.registerObsidianProtocolHandler(
69 "sublimated",
70 (params) => this.handleUri(params)
71 );
72
73 // settings tab
74 this.addSettingTab(new SublimatedSettingTab(this.app, this));
75
76 // update status bar when active file changes
77 this.registerEvent(
78 this.app.workspace.on("active-leaf-change", () => {
79 this.refreshMetadata();
80 })
81 );
82
83 // initial metadata read
84 this.refreshMetadata();
85
86 // welcome modal on first install
87 if (!this.settings.hasSeenWelcome) {
88 new WelcomeModal(this.app).open();
89 this.settings.hasSeenWelcome = true;
90 await this.saveSettings();
91 }
92 }
93
94 onunload(): void {
95 this.app.workspace.detachLeavesOfType(VERSION_VIEW_TYPE);
96 }
97
98 async loadSettings(): Promise<void> {
99 this.settings = Object.assign(
100 {},
101 DEFAULT_SETTINGS,
102 await this.loadData()
103 );
104 }
105
106 async saveSettings(): Promise<void> {
107 await this.saveData(this.settings);
108 }
109
110 // check for .sbl/ folder in vault root
111 private async checkSublimatedVault(): Promise<boolean> {
112 const adapter = this.app.vault.adapter;
113 return adapter.exists(normalizePath(".sbl"));
114 }
115
116 detectWorkspace(): { space: string | null } | null {
117 if (!this.isSublimatedVault) return null;
118 return {
119 space: this.lastMetadata?.space ?? null,
120 };
121 }
122
123 private async refreshMetadata(): Promise<void> {
124 const file = this.app.workspace.getActiveFile();
125 if (!file) {
126 this.lastMetadata = null;
127 this.updateStatusBar(null);
128 return;
129 }
130
131 if (!isMacOS()) {
132 this.lastMetadata = null;
133 this.updateStatusBar(null);
134 return;
135 }
136
137 // get absolute path via vault adapter
138 const adapter = this.app.vault.adapter;
139 const basePath = (adapter as { getBasePath?: () => string })
140 .getBasePath?.();
141 if (!basePath) {
142 this.lastMetadata = null;
143 this.updateStatusBar(null);
144 return;
145 }
146
147 const absPath = `${basePath}/${file.path}`;
148 this.lastMetadata = await readFileMetadata(absPath);
149 this.updateStatusBar(this.lastMetadata);
150 }
151
152 private updateStatusBar(meta: FileMetadata | null): void {
153 if (!this.statusBarEl) return;
154
155 if (!this.isSublimatedVault) {
156 this.statusBarEl.setText("");
157 return;
158 }
159
160 if (!meta) {
161 this.statusBarEl.setText("sublimated");
162 return;
163 }
164
165 const parts: string[] = [];
166
167 if (meta.version) {
168 parts.push(`v${meta.version}`);
169 }
170
171 if (meta.syncStatus) {
172 const icon = syncStatusIcon(meta.syncStatus);
173 parts.push(`${icon} ${meta.syncStatus}`);
174 }
175
176 if (meta.modifiedBy) {
177 const prefix =
178 meta.modifiedByType === "ai_agent" ? "by AI:" : "by";
179 parts.push(`${prefix} ${meta.modifiedBy}`);
180 }
181
182 if (meta.branch && meta.branch !== "main") {
183 parts.push(meta.branch);
184 }
185
186 if (meta.visibility) {
187 parts.push(visibilityIcon(meta.visibility));
188 }
189
190 if (parts.length > 0) {
191 this.statusBarEl.setText(`sublimated: ${parts.join(" · ")}`);
192 } else {
193 this.statusBarEl.setText("sublimated");
194 }
195
196 // show pointer cursor when URL available
197 if (meta.url) {
198 this.statusBarEl.addClass("sbl-clickable");
199 } else {
200 this.statusBarEl.removeClass("sbl-clickable");
201 }
202 }
203
204 private openCurrentFileOnWeb(): void {
205 const url = this.lastMetadata?.url;
206 if (!url) {
207 new Notice("No sublimated URL for this file");
208 return;
209 }
210 window.open(`https://${url}`);
211 }
212
213 private async copyWebLink(): Promise<void> {
214 const url = this.lastMetadata?.url;
215 if (!url) {
216 new Notice("No sublimated URL for this file");
217 return;
218 }
219 await navigator.clipboard.writeText(`https://${url}`);
220 new Notice("Link copied to clipboard");
221 }
222
223 private async activateVersionView(): Promise<void> {
224 const existing =
225 this.app.workspace.getLeavesOfType(VERSION_VIEW_TYPE);
226 if (existing.length > 0) {
227 this.app.workspace.revealLeaf(existing[0]);
228 return;
229 }
230
231 const leaf = this.app.workspace.getRightLeaf(false);
232 if (!leaf) return;
233 await leaf.setViewState({
234 type: VERSION_VIEW_TYPE,
235 active: true,
236 });
237 this.app.workspace.revealLeaf(leaf);
238 }
239
240 private handleUri(params: Record<string, string>): void {
241 const { action, file } = params;
242
243 if (action === "open" && file) {
244 const normalized = normalizePath(file);
245 const existing = this.app.vault.getAbstractFileByPath(normalized);
246 if (existing) {
247 this.app.workspace.openLinkText(normalized, "", false);
248 } else {
249 new Notice(`File not found: ${file}`);
250 }
251 return;
252 }
253
254 if (action === "auth" && params.token) {
255 // future: OAuth flow stores token from redirect
256 this.settings.apiToken = params.token;
257 this.saveSettings();
258 new Notice("sublimated: signed in successfully");
259 return;
260 }
261
262 new Notice(`Unknown sublimated action: ${action}`);
263 }
264}
265
266function syncStatusIcon(status: string): string {
267 switch (status) {
268 case "synced":
269 return "✓";
270 case "uploading":
271 return "↑";
272 case "downloading":
273 return "↓";
274 case "error":
275 return "!";
276 case "conflict":
277 return "⚠";
278 default:
279 return "";
280 }
281}
282
283function visibilityIcon(visibility: string): string {
284 switch (visibility) {
285 case "public":
286 return "🌐";
287 case "shared":
288 return "👥";
289 case "private":
290 return "🔒";
291 default:
292 return "";
293 }
294}