add status bar indicator for headless sync

This commit is contained in:
Nystik
2026-03-30 15:33:53 +02:00
parent cfe0f7f1b9
commit ecad257587
6 changed files with 461 additions and 29 deletions

View File

@@ -1,6 +1,7 @@
const { Plugin } = require("obsidian"); const { Plugin } = require("obsidian");
const { HeadlessSyncSettingTab } = require("./settings-tab"); const { HeadlessSyncSettingTab } = require("./settings-tab");
const { WsListener } = require("./ws-listener"); const { WsListener } = require("./ws-listener");
const { initSyncStatusBar } = require("./sync-status-bar");
const api = require("./api"); const api = require("./api");
class IgnisHeadlessSyncPlugin extends Plugin { class IgnisHeadlessSyncPlugin extends Plugin {
@@ -8,11 +9,7 @@ class IgnisHeadlessSyncPlugin extends Plugin {
this.wsListener = new WsListener(); this.wsListener = new WsListener();
this.wsListener.start(); this.wsListener.start();
this.wsListener.on("sync-status", (payload) => { this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
if (payload.vaultId === this.app.vault.getName()) {
console.log("[ignis-headless-sync] Status update:", payload.status);
}
});
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
@@ -53,12 +50,15 @@ class IgnisHeadlessSyncPlugin extends Plugin {
} }
onunload() { onunload() {
if (this._syncStatusBarCleanup) {
this._syncStatusBarCleanup();
this._syncStatusBarCleanup = null;
}
if (this.wsListener) { if (this.wsListener) {
this.wsListener.stop(); this.wsListener.stop();
this.wsListener = null; this.wsListener = null;
} }
console.log("[ignis-headless-sync] Unloaded");
} }
} }

View File

@@ -234,41 +234,73 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
} }
}); });
// Log viewer // Log viewer (collapsible)
await this.renderLogs(containerEl, vaultId); await this.renderLogs(containerEl, vaultId);
} }
async 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; let logsData;
try { try {
logsData = await api.getLogs(vaultId, 50); logsData = await api.getLogs(vaultId, 50);
} catch (e) { } catch (e) {
containerEl.createEl("p", { codeEl.textContent = `Failed to load logs: ${e.message}`;
text: `Failed to load logs: ${e.message}`,
cls: "mod-warning",
});
return; return;
} }
const logContainer = containerEl.createDiv("ignis-log-viewer");
if (logsData.logs.length === 0) { if (logsData.logs.length === 0) {
logContainer.createEl("p", { codeEl.textContent = "No log entries yet.";
text: "No log entries yet.",
cls: "setting-item-description",
});
} else { } else {
for (const entry of logsData.logs) { const lines = logsData.logs.map((entry) => {
const time = new Date(entry.timestamp).toLocaleTimeString(); const time = new Date(entry.timestamp).toLocaleTimeString();
logContainer.createEl("div", { return `[${time}] ${entry.line}`;
text: `[${time}] ${entry.line}`, });
cls: "ignis-log-entry",
}); 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() { hide() {
@@ -277,6 +309,11 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
this._cancelWait = null; this._cancelWait = null;
} }
if (this._logCleanup) {
this._logCleanup();
this._logCleanup = null;
}
super.hide(); super.hide();
} }
} }

View File

@@ -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 };

View File

@@ -38,3 +38,97 @@
.ignis-vault-connect-options .setting-item { .ignis-vault-connect-options .setting-item {
border-top: none; 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;
}

View File

@@ -116,7 +116,7 @@ function mountRoutes(router, plugin) {
} }
}); });
router.post("/unlink", (req, res) => { router.post("/unlink", async (req, res) => {
const ctx = plugin.getCtx(); const ctx = plugin.getCtx();
const syncManager = plugin.getSyncManager(); const syncManager = plugin.getSyncManager();
const { vaultId } = req.body; const { vaultId } = req.body;
@@ -126,7 +126,7 @@ function mountRoutes(router, plugin) {
} }
try { try {
syncManager.unlinkVault(vaultId); await syncManager.unlinkVault(vaultId);
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) {
ctx.log(`Failed to unlink vault: ${e.message}`); ctx.log(`Failed to unlink vault: ${e.message}`);

View File

@@ -1,6 +1,6 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawnOb } = require("./ob-cli"); const { spawnOb, runCommand } = require("./ob-cli");
const MAX_LOG_ENTRIES = 200; const MAX_LOG_ENTRIES = 200;
@@ -142,6 +142,7 @@ class SyncManager {
if (line.trim()) { if (line.trim()) {
this.addLog(state, line.trim()); this.addLog(state, line.trim());
state.lastActivity = new Date().toISOString(); state.lastActivity = new Date().toISOString();
this.broadcastLog(vaultId, line.trim());
} }
} }
}); });
@@ -213,7 +214,7 @@ class SyncManager {
return this.getState(vaultId); return this.getState(vaultId);
} }
unlinkVault(vaultId) { async unlinkVault(vaultId) {
const state = this.states.get(vaultId); const state = this.states.get(vaultId);
if (!state) { if (!state) {
@@ -224,6 +225,14 @@ class SyncManager {
state._process.kill("SIGTERM"); 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.states.delete(vaultId);
this.saveStates(); this.saveStates();
this.ctx.log(`Unlinked vault ${vaultId}`); 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) { broadcastStatus(vaultId) {
const state = this.getState(vaultId); const state = this.getState(vaultId);