diff --git a/server/watcher.js b/server/watcher.js new file mode 100644 index 0000000..3b3ce80 --- /dev/null +++ b/server/watcher.js @@ -0,0 +1,120 @@ +const chokidar = require("chokidar"); +const path = require("path"); +const fs = require("fs"); + +// Per-vault chokidar watchers +// Map, vaultPath }> +const vaultWatchers = new Map(); + +function startWatching(vaultId, vaultPath) { + if (vaultWatchers.has(vaultId)) { + return vaultWatchers.get(vaultId); + } + + const watcher = chokidar.watch(vaultPath, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 300, + pollInterval: 100, + }, + ignored: [ + /(^|[\/\\])\.git([\/\\]|$)/, // .git directories + ], + }); + + const entry = { watcher, listeners: new Set(), vaultPath }; + + function emit(type, fullPath, stat) { + const rel = path.relative(vaultPath, fullPath).replace(/\\/g, "/"); + + const event = { type, path: rel }; + + if (stat) { + event.stat = { + size: stat.size, + mtime: stat.mtimeMs, + ctime: stat.ctimeMs, + }; + } + + for (const fn of entry.listeners) { + try { + fn(event); + } catch (e) { + console.error("[watcher] Listener error:", e); + } + } + } + + watcher + .on("add", (fullPath) => { + try { + const stat = fs.statSync(fullPath); + emit("created", fullPath, stat); + } catch { + emit("created", fullPath, null); + } + }) + .on("change", (fullPath) => { + try { + const stat = fs.statSync(fullPath); + emit("modified", fullPath, stat); + } catch { + emit("modified", fullPath, null); + } + }) + .on("unlink", (fullPath) => { + emit("deleted", fullPath, null); + }) + .on("addDir", (fullPath) => { + // Skip vault root itself + if (path.resolve(fullPath) === path.resolve(vaultPath)) return; + emit("folder-created", fullPath, null); + }) + .on("unlinkDir", (fullPath) => { + emit("deleted", fullPath, null); + }) + .on("error", (err) => { + console.error(`[watcher] Error on vault "${vaultId}":`, err.message); + }); + + vaultWatchers.set(vaultId, entry); + console.log(`[watcher] Started watching vault: ${vaultId}`); + + return entry; +} + +function stopWatching(vaultId) { + const entry = vaultWatchers.get(vaultId); + + if (entry) { + entry.watcher.close(); + entry.listeners.clear(); + vaultWatchers.delete(vaultId); + console.log(`[watcher] Stopped watching vault: ${vaultId}`); + } +} + +function addListener(vaultId, fn) { + const entry = vaultWatchers.get(vaultId); + + if (entry) { + entry.listeners.add(fn); + } +} + +function removeListener(vaultId, fn) { + const entry = vaultWatchers.get(vaultId); + + if (entry) { + entry.listeners.delete(fn); + + // Stop watching if no listeners remain + if (entry.listeners.size === 0) { + stopWatching(vaultId); + } + } +} + +module.exports = { startWatching, stopWatching, addListener, removeListener }; diff --git a/server/ws.js b/server/ws.js index 6e52cb4..254c978 100644 --- a/server/ws.js +++ b/server/ws.js @@ -1,25 +1,41 @@ const { WebSocketServer } = require("ws"); +const url = require("url"); +const config = require("./config"); +const watcher = require("./watcher"); -//currently unused function setupWebSocket(server) { const wss = new WebSocketServer({ server, path: "/ws" }); - wss.on("connection", (ws) => { - console.log("[ws] Client connected"); + wss.on("connection", (ws, req) => { + const params = new url.URL(req.url, "http://localhost").searchParams; + const vaultId = params.get("vault"); - ws.on("message", (data) => { - // TODO: handle watch/unwatch subscriptions from client - const msg = JSON.parse(data); - console.log("[ws] Received:", msg); - }); + if (!vaultId || !config.getVaultPath(vaultId)) { + ws.close(4001, "Invalid or missing vault ID"); + return; + } + + const vaultPath = config.getVaultPath(vaultId); + console.log(`[ws] Client connected to vault: ${vaultId}`); + + // Start watching this vault (no-op if already watching) + watcher.startWatching(vaultId, vaultPath); + + // Per-client listener that forwards events over WebSocket + const listener = (event) => { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(event)); + } + }; + + watcher.addListener(vaultId, listener); ws.on("close", () => { - console.log("[ws] Client disconnected"); + console.log(`[ws] Client disconnected from vault: ${vaultId}`); + watcher.removeListener(vaultId, listener); }); }); - // TODO: maybe integrate chokidar file watching and broadcast changes - return wss; } diff --git a/src/shims/fs/echo-guard.js b/src/shims/fs/echo-guard.js new file mode 100644 index 0000000..82f3d60 --- /dev/null +++ b/src/shims/fs/echo-guard.js @@ -0,0 +1,31 @@ +// Shared echo suppression for file watcher. +// fs operations mark paths as "locally modified" so the watcher client +// can skip events that originated from this client. + +const ECHO_SUPPRESS_MS = 1500; +const recentOps = new Map(); // normalized path -> timestamp + +function normalize(p) { + return (p || "") + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .replace(/\/+$/, ""); +} + +export function markLocalOp(path) { + recentOps.set(normalize(path), Date.now()); +} + +export function isRecentLocalOp(path) { + const norm = normalize(path); + const ts = recentOps.get(norm); + + if (!ts) return false; + + if (Date.now() - ts < ECHO_SUPPRESS_MS) { + return true; + } + + recentOps.delete(norm); + return false; +} diff --git a/src/shims/fs/index.js b/src/shims/fs/index.js index eb51cc4..dd846a1 100644 --- a/src/shims/fs/index.js +++ b/src/shims/fs/index.js @@ -4,6 +4,7 @@ import { transport } from "./transport.js"; import { createFsPromises } from "./promises.js"; import { createFsSync } from "./sync.js"; import { createFsWatch } from "./watch.js"; +import { createWatcherClient } from "./watcher-client.js"; import { constants } from "./constants.js"; const metadataCache = new MetadataCache(); @@ -12,6 +13,7 @@ const contentCache = new ContentCache(); const fsPromises = createFsPromises(metadataCache, contentCache, transport); const fsSync = createFsSync(metadataCache, contentCache, transport); const fsWatch = createFsWatch(transport); +const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch); export const fsShim = { promises: fsPromises, @@ -29,6 +31,7 @@ export const fsShim = { _metadataCache: metadataCache, _contentCache: contentCache, + _watcherClient: watcherClient, async _init(basePath) { const tree = await transport.fetchTree(basePath); diff --git a/src/shims/fs/promises.js b/src/shims/fs/promises.js index de08f6d..0af3ce9 100644 --- a/src/shims/fs/promises.js +++ b/src/shims/fs/promises.js @@ -1,3 +1,5 @@ +import { markLocalOp } from "./echo-guard.js"; + export function createFsPromises(metadataCache, contentCache, transport) { return { async stat(path) { @@ -85,6 +87,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { encoding = encoding?.encoding; } + markLocalOp(path); contentCache.set(path, data); const size = @@ -110,6 +113,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { }, async appendFile(path, data, encoding) { + markLocalOp(path); contentCache.invalidate(path); await transport.appendFile(path, data); @@ -119,6 +123,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { }, async unlink(path) { + markLocalOp(path); contentCache.delete(path); metadataCache.delete(path); @@ -126,6 +131,8 @@ export function createFsPromises(metadataCache, contentCache, transport) { }, async rename(oldPath, newPath) { + markLocalOp(oldPath); + markLocalOp(newPath); const content = contentCache.get(oldPath); if (content !== null) { @@ -142,12 +149,14 @@ export function createFsPromises(metadataCache, contentCache, transport) { const recursive = typeof options === "object" ? !!options.recursive : !!options; + markLocalOp(path); metadataCache.set(path, { type: "directory" }); await transport.mkdir(path, recursive); }, async rmdir(path) { + markLocalOp(path); metadataCache.delete(path); await transport.rmdir(path); }, @@ -156,6 +165,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { const recursive = typeof options === "object" ? !!options.recursive : false; + markLocalOp(path); metadataCache.delete(path); contentCache.delete(path); @@ -163,6 +173,7 @@ export function createFsPromises(metadataCache, contentCache, transport) { }, async copyFile(src, dest) { + markLocalOp(dest); await transport.copyFile(src, dest); const meta = await transport.stat(dest); diff --git a/src/shims/fs/sync.js b/src/shims/fs/sync.js index 1006ee9..b499924 100644 --- a/src/shims/fs/sync.js +++ b/src/shims/fs/sync.js @@ -1,3 +1,5 @@ +import { markLocalOp } from "./echo-guard.js"; + export function createFsSync(metadataCache, contentCache, transport) { return { existsSync(path) { @@ -64,6 +66,7 @@ export function createFsSync(metadataCache, contentCache, transport) { encoding = encoding?.encoding; } + markLocalOp(path); contentCache.set(path, data); const size = @@ -87,6 +90,7 @@ export function createFsSync(metadataCache, contentCache, transport) { }, unlinkSync(path) { + markLocalOp(path); contentCache.delete(path); metadataCache.delete(path); diff --git a/src/shims/fs/watcher-client.js b/src/shims/fs/watcher-client.js new file mode 100644 index 0000000..fd56008 --- /dev/null +++ b/src/shims/fs/watcher-client.js @@ -0,0 +1,141 @@ +// Client-side WebSocket file watcher. +// Connects to the server's /ws endpoint, receives file change events, +// updates the metadata/content caches, and dispatches to fs.watch listeners +// so Obsidian's vault picks them up automatically. + +import { isRecentLocalOp } from "./echo-guard.js"; + +const RECONNECT_DELAY = 2000; + +export function createWatcherClient(metadataCache, contentCache, fsWatch) { + let ws = null; + let vaultId = null; + let reconnectTimer = null; + + function connect(vault) { + vaultId = vault; + + if (!vaultId) { + console.warn("[watcher] No vault ID, skipping WebSocket connection"); + return; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`; + + try { + ws = new WebSocket(url); + } catch (e) { + console.error("[watcher] Failed to create WebSocket:", e); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log("[watcher] Connected to file watcher"); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + handleEvent(msg); + } catch (e) { + console.error("[watcher] Failed to parse message:", e); + } + }; + + ws.onclose = () => { + console.log("[watcher] Disconnected"); + ws = null; + scheduleReconnect(); + }; + + ws.onerror = (e) => { + console.error("[watcher] WebSocket error:", e); + }; + } + + function scheduleReconnect() { + if (reconnectTimer) return; + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + + if (vaultId) { + console.log("[watcher] Reconnecting..."); + connect(vaultId); + } + }, RECONNECT_DELAY); + } + + function handleEvent(msg) { + const { type, path, stat } = msg; + + if (!type || !path) return; + + // Suppress echo from our own operations + if (isRecentLocalOp(path)) { + return; + } + + switch (type) { + case "created": + if (stat) { + metadataCache.set(path, { + type: "file", + size: stat.size, + mtime: stat.mtime, + ctime: stat.ctime, + }); + } + contentCache.invalidate(path); + fsWatch._dispatch("created", path); + break; + + case "folder-created": + metadataCache.set(path, { type: "directory" }); + fsWatch._dispatch("folder-created", path); + break; + + case "modified": + if (stat) { + metadataCache.set(path, { + type: "file", + size: stat.size, + mtime: stat.mtime, + ctime: stat.ctime, + }); + } + contentCache.invalidate(path); + fsWatch._dispatch("modified", path); + break; + + case "deleted": + metadataCache.delete(path); + contentCache.invalidate(path); + fsWatch._dispatch("deleted", path); + break; + + default: + console.warn("[watcher] Unknown event type:", type); + } + } + + function disconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (ws) { + ws.onclose = null; // prevent reconnect + ws.close(); + ws = null; + } + } + + return { + connect, + disconnect, + }; +} diff --git a/src/shims/loader.js b/src/shims/loader.js index d517d61..79d635a 100644 --- a/src/shims/loader.js +++ b/src/shims/loader.js @@ -259,4 +259,9 @@ window.__currentVaultId = installRequestUrlShim(); +// Connect file watcher WebSocket after everything is initialized +if (window.__currentVaultId) { + fsShim._watcherClient.connect(window.__currentVaultId); +} + console.log("[ignis] Shim loader initialized");