basic filewatcher using websocket

This commit is contained in:
Nystik
2026-03-22 14:56:05 +01:00
parent be0792dab7
commit c5f5bec324
8 changed files with 342 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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");