From ecad257587ebda1846005bc6c7e1978b163b2d28 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Mon, 30 Mar 2026 15:33:53 +0200 Subject: [PATCH] add status bar indicator for headless sync --- .../plugins/headless-sync/plugin/src/main.js | 14 +- .../headless-sync/plugin/src/settings-tab.js | 73 +++-- .../plugin/src/sync-status-bar.js | 274 ++++++++++++++++++ .../plugins/headless-sync/plugin/styles.css | 94 ++++++ server/plugins/headless-sync/routes.js | 4 +- server/plugins/headless-sync/sync-manager.js | 31 +- 6 files changed, 461 insertions(+), 29 deletions(-) create mode 100644 server/plugins/headless-sync/plugin/src/sync-status-bar.js diff --git a/server/plugins/headless-sync/plugin/src/main.js b/server/plugins/headless-sync/plugin/src/main.js index 2fc29f1..5d7f8e3 100644 --- a/server/plugins/headless-sync/plugin/src/main.js +++ b/server/plugins/headless-sync/plugin/src/main.js @@ -1,6 +1,7 @@ const { Plugin } = require("obsidian"); const { HeadlessSyncSettingTab } = require("./settings-tab"); const { WsListener } = require("./ws-listener"); +const { initSyncStatusBar } = require("./sync-status-bar"); const api = require("./api"); class IgnisHeadlessSyncPlugin extends Plugin { @@ -8,11 +9,7 @@ class IgnisHeadlessSyncPlugin extends Plugin { this.wsListener = new WsListener(); this.wsListener.start(); - this.wsListener.on("sync-status", (payload) => { - if (payload.vaultId === this.app.vault.getName()) { - console.log("[ignis-headless-sync] Status update:", payload.status); - } - }); + this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener); this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); @@ -53,12 +50,15 @@ class IgnisHeadlessSyncPlugin extends Plugin { } onunload() { + if (this._syncStatusBarCleanup) { + this._syncStatusBarCleanup(); + this._syncStatusBarCleanup = null; + } + if (this.wsListener) { this.wsListener.stop(); this.wsListener = null; } - - console.log("[ignis-headless-sync] Unloaded"); } } diff --git a/server/plugins/headless-sync/plugin/src/settings-tab.js b/server/plugins/headless-sync/plugin/src/settings-tab.js index 0ec8357..fbcbed8 100644 --- a/server/plugins/headless-sync/plugin/src/settings-tab.js +++ b/server/plugins/headless-sync/plugin/src/settings-tab.js @@ -234,41 +234,73 @@ class HeadlessSyncSettingTab extends PluginSettingTab { } }); - // Log viewer + // Log viewer (collapsible) await this.renderLogs(containerEl, vaultId); } async renderLogs(containerEl, vaultId) { - containerEl.createEl("h3", { text: "Recent logs" }); + const details = containerEl.createEl("details", { + cls: "ignis-log-details", + }); + + details.createEl("summary", { text: "Sync logs" }); + + const logBox = details.createEl("pre", { cls: "ignis-log-terminal" }); + const codeEl = logBox.createEl("code"); let logsData; try { logsData = await api.getLogs(vaultId, 50); } catch (e) { - containerEl.createEl("p", { - text: `Failed to load logs: ${e.message}`, - cls: "mod-warning", - }); + codeEl.textContent = `Failed to load logs: ${e.message}`; return; } - const logContainer = containerEl.createDiv("ignis-log-viewer"); - if (logsData.logs.length === 0) { - logContainer.createEl("p", { - text: "No log entries yet.", - cls: "setting-item-description", - }); + codeEl.textContent = "No log entries yet."; } else { - for (const entry of logsData.logs) { + const lines = logsData.logs.map((entry) => { const time = new Date(entry.timestamp).toLocaleTimeString(); - logContainer.createEl("div", { - text: `[${time}] ${entry.line}`, - cls: "ignis-log-entry", - }); - } + return `[${time}] ${entry.line}`; + }); + + codeEl.textContent = lines.join("\n"); } + + logBox.scrollTop = logBox.scrollHeight; + + // Live updates via WebSocket + const wsListener = this.plugin.wsListener; + + if (!wsListener) { + return; + } + + const onLog = (payload) => { + if (payload.vaultId !== vaultId) { + return; + } + + const time = new Date().toLocaleTimeString(); + const line = `[${time}] ${payload.line}`; + + if (codeEl.textContent === "No log entries yet.") { + codeEl.textContent = line; + } else { + codeEl.textContent += "\n" + line; + } + + const isNearBottom = + logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 50; + + if (isNearBottom) { + logBox.scrollTop = logBox.scrollHeight; + } + }; + + wsListener.on("sync-log", onLog); + this._logCleanup = () => wsListener.off("sync-log", onLog); } hide() { @@ -277,6 +309,11 @@ class HeadlessSyncSettingTab extends PluginSettingTab { this._cancelWait = null; } + if (this._logCleanup) { + this._logCleanup(); + this._logCleanup = null; + } + super.hide(); } } diff --git a/server/plugins/headless-sync/plugin/src/sync-status-bar.js b/server/plugins/headless-sync/plugin/src/sync-status-bar.js new file mode 100644 index 0000000..ba97a8c --- /dev/null +++ b/server/plugins/headless-sync/plugin/src/sync-status-bar.js @@ -0,0 +1,274 @@ +const { setIcon } = require("obsidian"); +const api = require("./api"); + +const TOOLTIP_MAP = { + running: "Syncing...", + synced: "Synced", + stopped: "Sync stopped", + error: "Sync error", +}; + +function initSyncStatusBar(plugin, wsListener) { + const vaultId = plugin.app.vault.getName(); + const item = plugin.addStatusBarItem(); + item.addClass("ignis-sync-statusbar"); + item.style.display = "none"; + + const iconEl = item.createEl("span", { cls: "ignis-sync-icon" }); + setIcon(iconEl, "refresh-cw"); + + let popoverEl = null; + let popoverOpen = false; + let currentStatus = "stopped"; + let outsideClickHandler = null; + + function updateState(status, error) { + currentStatus = status; + + iconEl.className = "ignis-sync-icon"; + + if (status === "running") { + iconEl.addClass("ignis-sync-syncing"); + iconEl.addClass("ignis-sync-spinning"); + } else if (status === "error") { + iconEl.addClass("ignis-sync-error"); + } else if (status === "stopped") { + iconEl.addClass("ignis-sync-stopped"); + } else { + iconEl.addClass("ignis-sync-synced"); + } + + const tooltip = error || TOOLTIP_MAP[status] || status; + item.setAttribute("aria-label", tooltip); + item.setAttribute("data-tooltip-position", "top"); + } + + function showPopover(text) { + if (popoverEl) { + const span = popoverEl.querySelector(".ignis-sync-popover-filename"); + + if (span) { + span.textContent = text; + } + + return; + } + + popoverEl = item.createEl("div", { cls: "ignis-sync-popover" }); + popoverEl.createEl("span", { + text: text, + cls: "ignis-sync-popover-filename", + }); + + popoverOpen = true; + + outsideClickHandler = (e) => { + if (!item.contains(e.target)) { + hidePopover(); + } + }; + + setTimeout(() => { + document.addEventListener("click", outsideClickHandler, true); + }, 0); + } + + function hidePopover() { + if (popoverEl) { + popoverEl.remove(); + popoverEl = null; + } + + if (outsideClickHandler) { + document.removeEventListener("click", outsideClickHandler, true); + outsideClickHandler = null; + } + + popoverOpen = false; + } + + function truncatePath(path, maxLen) { + if (path.length <= maxLen) { + return path; + } + + return "\u2026" + path.slice(-(maxLen - 1)); + } + + function formatPopoverText(prefix, path) { + return `${prefix}: ${truncatePath(path, 46 - prefix.length)}`; + } + + function updatePopoverText(text) { + if (!popoverOpen) { + return; + } + + const span = popoverEl?.querySelector(".ignis-sync-popover-filename"); + + if (span) { + span.textContent = text; + } + } + + function extractFileActivity(line) { + // Downloading/Downloaded path + let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/); + + if (match) { + return { prefix: "Syncing", path: match[1].trim() }; + } + + // Deleting path + match = line.match(/^Deleting\s+(.+)$/); + + if (match) { + return { prefix: "Deleting", path: match[1].trim() }; + } + + // Push: path (updated) + match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/); + + if (match) { + return { prefix: "Syncing", path: match[1].trim() }; + } + + // Push: path (deleted) + match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/); + + if (match) { + return { prefix: "Deleting", path: match[1].trim() }; + } + + return null; + } + + function isFullySynced(line) { + return /Fully synced/i.test(line); + } + + // Click toggles popover + item.addEventListener("click", () => { + if (popoverOpen) { + hidePopover(); + } else { + showPopover(TOOLTIP_MAP[currentStatus] || currentStatus); + } + }); + + // Listen for status updates + const onStatus = (payload) => { + if (payload.vaultId !== vaultId) { + return; + } + + item.style.display = ""; + + // "running" from server means the process is alive, but we refine + // the visual state based on log activity. + if (payload.status === "running") { + updateState("synced"); + } else { + updateState(payload.status, payload.error); + } + }; + + wsListener.on("sync-status", onStatus); + + // Debounce the transition to "synced" state to avoid flickering + // during rapid delete cycles (Fully synced -> Deleting -> Fully synced). + let syncedTimer = null; + + function deferSynced() { + if (syncedTimer) { + clearTimeout(syncedTimer); + } + + syncedTimer = setTimeout(() => { + syncedTimer = null; + updateState("synced"); + updatePopoverText("Synced"); + }, 2000); + } + + function cancelDeferredSynced() { + if (syncedTimer) { + clearTimeout(syncedTimer); + syncedTimer = null; + } + } + + // Listen for log lines + const onLog = (payload) => { + if (payload.vaultId !== vaultId) { + return; + } + + if (isFullySynced(payload.line)) { + deferSynced(); + return; + } + + const activity = extractFileActivity(payload.line); + + if (activity) { + cancelDeferredSynced(); + updateState("running"); + updatePopoverText(formatPopoverText(activity.prefix, activity.path)); + } + }; + + wsListener.on("sync-log", onLog); + + // Fetch initial state + api + .getVaults() + .then((data) => { + const vaults = data.vaults || []; + const vault = vaults.find((v) => v.vaultId === vaultId); + + if (vault) { + item.style.display = ""; + updateState(vault.status, vault.error); + } + }) + .catch(() => {}); + + // Poll WebSocket state to detect server disconnect/reconnect + let wasDisconnected = false; + + const wsCheckInterval = setInterval(() => { + const ws = window.__ignisWs; + const disconnected = !ws || ws.readyState !== WebSocket.OPEN; + + if (disconnected && currentStatus === "running") { + updateState("error", "Server connection lost"); + wasDisconnected = true; + } else if (!disconnected && wasDisconnected) { + wasDisconnected = false; + + api + .getVaults() + .then((data) => { + const vaults = data.vaults || []; + const vault = vaults.find((v) => v.vaultId === vaultId); + + if (vault) { + updateState(vault.status, vault.error); + } + }) + .catch(() => {}); + } + }, 3000); + + // Return cleanup function + return () => { + clearInterval(wsCheckInterval); + cancelDeferredSynced(); + wsListener.off("sync-status", onStatus); + wsListener.off("sync-log", onLog); + hidePopover(); + }; +} + +module.exports = { initSyncStatusBar }; diff --git a/server/plugins/headless-sync/plugin/styles.css b/server/plugins/headless-sync/plugin/styles.css index 09a6da5..f718b16 100644 --- a/server/plugins/headless-sync/plugin/styles.css +++ b/server/plugins/headless-sync/plugin/styles.css @@ -38,3 +38,97 @@ .ignis-vault-connect-options .setting-item { border-top: none; } + +.ignis-sync-statusbar { + display: flex; + align-items: center; + cursor: pointer; + position: relative; +} + +.ignis-sync-icon svg { + width: 14px; + height: 14px; +} + +.ignis-sync-spinning svg { + animation: ignis-spin 1s linear infinite; +} + +@keyframes ignis-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.ignis-sync-synced svg { + color: var(--color-green); +} + +.ignis-sync-syncing svg { + color: var(--interactive-accent); +} + +.ignis-sync-error svg { + color: var(--color-red); +} + +.ignis-sync-stopped svg { + color: var(--text-muted); +} + +.ignis-sync-popover { + position: absolute; + bottom: 100%; + right: 0; + margin-bottom: 4px; + background: var(--background-modifier-message); + color: var(--text-normal); + font-size: var(--font-ui-smaller); + padding: 4px 8px; + border-radius: var(--radius-s); + white-space: nowrap; + box-shadow: var(--shadow-s); +} + +.ignis-log-details { + margin-top: 16px; +} + +.ignis-log-details summary { + cursor: pointer; + color: var(--text-muted); + font-size: var(--font-ui-small); + padding: 4px 0; + user-select: none; +} + +.ignis-log-details summary:hover { + color: var(--text-normal); +} + +.ignis-log-terminal { + background: #1a1a1a; + color: #d4d4d4; + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.5; + padding: 12px; + border-radius: var(--radius-s); + max-height: 250px; + overflow-y: auto; + overflow-x: hidden; + margin-top: 8px; + white-space: pre-wrap; + word-break: break-all; +} + +.ignis-log-terminal code { + background: none; + color: inherit; + font-size: inherit; + padding: 0; +} diff --git a/server/plugins/headless-sync/routes.js b/server/plugins/headless-sync/routes.js index 2bc9d84..a6831c8 100644 --- a/server/plugins/headless-sync/routes.js +++ b/server/plugins/headless-sync/routes.js @@ -116,7 +116,7 @@ function mountRoutes(router, plugin) { } }); - router.post("/unlink", (req, res) => { + router.post("/unlink", async (req, res) => { const ctx = plugin.getCtx(); const syncManager = plugin.getSyncManager(); const { vaultId } = req.body; @@ -126,7 +126,7 @@ function mountRoutes(router, plugin) { } try { - syncManager.unlinkVault(vaultId); + await syncManager.unlinkVault(vaultId); res.json({ success: true }); } catch (e) { ctx.log(`Failed to unlink vault: ${e.message}`); diff --git a/server/plugins/headless-sync/sync-manager.js b/server/plugins/headless-sync/sync-manager.js index 258002e..836da66 100644 --- a/server/plugins/headless-sync/sync-manager.js +++ b/server/plugins/headless-sync/sync-manager.js @@ -1,6 +1,6 @@ const fs = require("fs"); const path = require("path"); -const { spawnOb } = require("./ob-cli"); +const { spawnOb, runCommand } = require("./ob-cli"); const MAX_LOG_ENTRIES = 200; @@ -142,6 +142,7 @@ class SyncManager { if (line.trim()) { this.addLog(state, line.trim()); state.lastActivity = new Date().toISOString(); + this.broadcastLog(vaultId, line.trim()); } } }); @@ -213,7 +214,7 @@ class SyncManager { return this.getState(vaultId); } - unlinkVault(vaultId) { + async unlinkVault(vaultId) { const state = this.states.get(vaultId); if (!state) { @@ -224,6 +225,14 @@ class SyncManager { state._process.kill("SIGTERM"); } + // Tell ob to disconnect from the remote vault and clear its stored config + try { + await runCommand(["sync-unlink", "--path", state.vaultPath]); + this.ctx.log(`ob sync-unlink completed for ${vaultId}`); + } catch (e) { + this.ctx.log(`ob sync-unlink failed for ${vaultId}: ${e.message}`); + } + this.states.delete(vaultId); this.saveStates(); this.ctx.log(`Unlinked vault ${vaultId}`); @@ -280,6 +289,24 @@ class SyncManager { } } + broadcastLog(vaultId, line) { + if (!this.ctx.wss || !this.ctx.wss.clients) { + return; + } + + const message = JSON.stringify({ + channel: "plugin:headless-sync", + type: "sync-log", + payload: { vaultId, line }, + }); + + for (const client of this.ctx.wss.clients) { + if (client.readyState === 1) { + client.send(message); + } + } + } + broadcastStatus(vaultId) { const state = this.getState(vaultId);