Skip to content

Commit 1b01549

Browse files
committed
add selective hot-reloading
1 parent 5964275 commit 1b01549

File tree

3 files changed

+210
-54
lines changed

3 files changed

+210
-54
lines changed

app.js

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ function getStartPage() {
157157
}
158158

159159
// --- SSE Hot Reload ---
160-
const clients = [];
160+
// Node.js Beispiel
161+
const clients = new Map();
162+
161163
app.get('/hot-reload', (req, res) => {
162164
res.set({
163165
'Content-Type': 'text/event-stream',
@@ -166,19 +168,56 @@ app.get('/hot-reload', (req, res) => {
166168
});
167169
res.flushHeaders();
168170
res.write('\n');
169-
clients.push(res);
170171

171-
req.on('close', () => {
172-
const idx = clients.indexOf(res);
173-
if (idx !== -1) clients.splice(idx, 1);
174-
});
172+
const id = Date.now() + Math.random();
173+
const context = req.query.context ? JSON.parse(req.query.context) : {};
174+
clients.set(id, { res, context });
175+
176+
req.on('close', () => clients.delete(id));
175177
});
176178

177-
function broadcastReloadSSE() {
178-
console.log("Broadcasting reload to", clients.length, "clients");
179-
for (const client of clients) {
180-
client.write('event: reload\ndata: now\n\n');
179+
function broadcastReloadSSE(filesChanged = null) {
180+
const isNavChange = filesChanged === null;
181+
const total = clients.size || clients.length;
182+
console.log(
183+
`Broadcasting ${isNavChange ? "full" : "selective"} reload to ${total} clients`
184+
);
185+
186+
let count = 0;
187+
for (const [id, { res, context }] of clients) {
188+
const currentFile = context?.currentFile || "";
189+
let shouldReload = false;
190+
191+
if (isNavChange) {
192+
// Full reload for everyone
193+
shouldReload = true;
194+
} else {
195+
// Reveal presentation: only reload if the open file was changed
196+
shouldReload = filesChanged.some(changed =>
197+
currentFile.endsWith(changed) || currentFile.includes(changed)
198+
);
199+
}
200+
201+
// console.log(`Client ${id} (currentFile: ${currentFile}) - shouldReload: ${shouldReload}`);
202+
if (!shouldReload) continue;
203+
204+
const payload = isNavChange
205+
? { type: "nav" }
206+
: { type: "page", files: filesChanged };
207+
208+
try {
209+
res.write(`event: reload\ndata: ${JSON.stringify(payload)}\n\n`);
210+
count++;
211+
} catch (err) {
212+
console.warn(`Client ${id} disconnected, removing from pool.`);
213+
try {
214+
res.end();
215+
} catch (_) {}
216+
clients.delete(id);
217+
}
181218
}
219+
console.log(`Reload sent to ${count} clients.`);
220+
console.log(`Active clients after broadcast: ${clients.size || clients.length}`);
182221
}
183222

184223
const basePath = process.env.NEXT_PUBLIC_IS_APP_FOLDER ? '/app/' : '.';
@@ -189,10 +228,15 @@ console.log(`AutoScan is set to ${isAutoScan}`);
189228
if (isAutoScan) {
190229
const mdPath = path.join(basePath, "md");
191230
const watcher = chokidar.watch(mdPath, { ignoreInitial: true, persistent: true, depth: 99 });
192-
watcher.on("all", (event, pathChanged) => {
231+
watcher.on("all", async (event, pathChanged) => {
193232
console.log(`[Watcher] Detected ${event} in ${pathChanged}. Triggering scanFiles...`);
194-
scanFiles("md/", mdPath);
233+
const diff = await scanFiles("md/", path.join(basePath, "md"));
234+
console.log("File changes:", diff);
235+
if (diff.added.length || diff.removed.length) {
195236
broadcastReloadSSE();
237+
} else if (diff.modified.length) {
238+
broadcastReloadSSE(diff.modified);
239+
}
196240
});
197241
}
198242

md/test-presentation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
A file with this name exists twice in the project. Only with a different path.
33
So the wiki-links feature will add a path to it.
44

5-
[[md/presentations/test-presentation|test-presentation]]
5+
[[md/presentations/test-presentation|test-presentation|test-presentation]]

obsidian.js

Lines changed: 153 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,66 @@ export async function scanFonts(dir, root = dir) {
228228
/**
229229
* If it's the root dir, dirPrefix should be an empty string.
230230
*/
231+
// Persistent snapshot across scans
232+
let lastFileSnapshot = new Map();
233+
234+
/**
235+
* Scans all markdown files recursively and detects added/removed/modified files.
236+
* Returns { added, removed, modified } with full paths like "md/subdir/file.md".
237+
*/
231238
export async function scanFiles(prefix, dir, root = dir) {
232-
// Reset global data structures before scanning
239+
const previousSnapshot = new Map(lastFileSnapshot);
240+
const newSnapshot = new Map();
241+
242+
// Recursively collect file mtimes
243+
function snapshotDir(d) {
244+
const files = fs.readdirSync(d);
245+
for (const f of files) {
246+
const filePath = path.join(d, f);
247+
const stat = fs.statSync(filePath);
248+
if (stat.isDirectory()) {
249+
// Skip excluded folders
250+
const nmods = process.env.NEXT_PUBLIC_IS_APP_FOLDER ? "/app/node_modules" : "node_modules";
251+
const slides = process.env.NEXT_PUBLIC_IS_APP_FOLDER ? "/app/slides" : "slides";
252+
if (
253+
filePath.startsWith(".") ||
254+
filePath.startsWith(nmods) ||
255+
filePath.startsWith(slides)
256+
)
257+
continue;
258+
snapshotDir(filePath);
259+
} else if (path.extname(f) === ".md") {
260+
const rel = prefix + path.relative(root, filePath).replace(/\\/g, "/");
261+
newSnapshot.set(rel, stat.mtimeMs);
262+
}
263+
}
264+
}
265+
266+
snapshotDir(dir);
267+
268+
// Diff detection
269+
const added = [];
270+
const removed = [];
271+
const modified = [];
272+
273+
for (const [file, mtime] of newSnapshot) {
274+
if (!previousSnapshot.has(file)) {
275+
added.push(file);
276+
} else if (previousSnapshot.get(file) !== mtime) {
277+
modified.push(file);
278+
}
279+
}
280+
281+
for (const [file] of previousSnapshot) {
282+
if (!newSnapshot.has(file)) {
283+
removed.push(file);
284+
}
285+
}
286+
287+
// Save new snapshot
288+
lastFileSnapshot = newSnapshot;
289+
290+
// Reset old global data (compatibility)
233291
dirPrefix = prefix;
234292
Object.keys(mdFilesMap).forEach(key => delete mdFilesMap[key]);
235293
Object.keys(filesMap).forEach(key => delete filesMap[key]);
@@ -241,8 +299,11 @@ export async function scanFiles(prefix, dir, root = dir) {
241299
navFontsArray.length = 0;
242300
contentMap = {};
243301
mdFilesDirStructure = {};
244-
302+
303+
// Rebuild maps
245304
scanFilesInternal(dir, root);
305+
306+
// Build file metadata
246307
let mdFiles = await Promise.all(
247308
Object.keys(mdFilesDir).map(async (file) => {
248309
const pwe = mdFilesDir[file];
@@ -251,63 +312,55 @@ export async function scanFiles(prefix, dir, root = dir) {
251312
if (folderArray.length === 1 && folderArray[0] === "") {
252313
folderArray.pop();
253314
}
315+
const absPath = path.join(dir, file);
316+
const relFullPath = prefix + file;
317+
const mtime = fs.existsSync(absPath) ? fs.statSync(absPath).mtimeMs : 0;
254318
return {
255319
[file]: {
256320
path: file,
321+
fullPath: relFullPath, // <-- new: full md/... path
257322
pathWithoutExt: pwe,
258-
folders: folders,
259-
folderArray: folderArray,
323+
folders,
324+
folderArray,
260325
depth: folders === "" ? 0 : folders.split("/").length,
261326
fileName: file.split("/").pop(),
262327
fileNameWithoutExtension: pwe.split("/").pop().split(".")[0],
263328
lastFolder: pwe.split("/").slice(-2, -1)[0] || "",
264329
cssName: makeSafeForCSS(folders),
265330
permissions: await getPermissionsFor(dirPrefix + file),
331+
mtime,
266332
},
267333
};
268334
})
269335
);
270336

337+
// Flatten
271338
mdFiles = mdFiles.reduce((acc, file) => {
272339
const key = Object.keys(file)[0];
273340
acc[key] = file[key];
274341
return acc;
275342
}, {});
276343

277-
// Convert to array, sort, and convert back to object
344+
// Sort like before
278345
mdFiles = Object.entries(mdFiles)
279346
.sort(([keyA, valueA], [keyB, valueB]) => {
280-
// Iterate over the folderArray
281-
for (
282-
let i = 0;
283-
i < Math.min(valueA.folderArray.length, valueB.folderArray.length);
284-
i++
285-
) {
286-
const comp = valueA.folderArray[i].localeCompare(
287-
valueB.folderArray[i],
288-
undefined,
289-
{ sensitivity: "base" }
290-
);
291-
if (comp !== 0) {
292-
// If a difference is found, return the comparison result
293-
return comp;
294-
}
347+
for (let i = 0; i < Math.min(valueA.folderArray.length, valueB.folderArray.length); i++) {
348+
const comp = valueA.folderArray[i].localeCompare(valueB.folderArray[i], undefined, { sensitivity: "base" });
349+
if (comp !== 0) return comp;
295350
}
296-
// If all elements are equal, compare the lengths of the arrays
297351
if (valueA.folderArray.length !== valueB.folderArray.length) {
298352
return valueB.folderArray.length - valueA.folderArray.length;
299353
}
300-
// If the folderArrays are equal, compare the name
301-
return valueA.fileName.localeCompare(valueB.fileName, undefined, {
302-
sensitivity: "base",
303-
});
354+
return valueA.fileName.localeCompare(valueB.fileName, undefined, { sensitivity: "base" });
304355
})
305356
.reduce((acc, [key, value]) => {
306357
acc[key] = value;
307358
return acc;
308359
}, {});
309360

310361
mdFilesDirStructure = mdFiles;
362+
363+
return { added, removed, modified };
311364
}
312365

313366
function scanFilesInternal(dir, root = dir) {
@@ -601,7 +654,7 @@ function preReplaceObsidianFileLinks(html, req) {
601654
}
602655
f = f.split(0, -3);
603656
}
604-
f = encodeURIComponent(f);
657+
//f = encodeURIComponent(f);
605658
const serverUrl = `${req.protocol}://${req.get("host")}`;
606659
return `[${alt ? alt : fileName}](${serverUrl}/${dirPrefix + f})`;
607660
} else {
@@ -1357,19 +1410,81 @@ function getMermaidScriptEntry() {
13571410

13581411
function getAutoReloadScript() {
13591412
return `<script>
1360-
(function() {
1361-
const es = new EventSource('/hot-reload');
1413+
(function connectSSE() {
1414+
function normalizeToMdPath(raw) {
1415+
try {
1416+
// strip domain if present
1417+
raw = raw.replace(/^https?:\\/\\/[^/]+/, "");
1418+
// strip query + hash
1419+
raw = raw.split("?")[0].split("#")[0];
1420+
// strip leading slashes
1421+
raw = raw.replace(/^\\/+/, "");
1422+
// If already md/... keep it
1423+
if (raw.startsWith("md/")) return raw;
1424+
// If it's /md/... after stripping, also ok
1425+
if (raw.startsWith("/md/")) return raw.slice(1);
1426+
// If it's a .md at root or elsewhere, coerce to md/<filename>.md
1427+
if (raw.endsWith(".md")) {
1428+
const parts = raw.split("/");
1429+
const fname = parts[parts.length - 1];
1430+
return "md/" + fname;
1431+
}
1432+
return raw; // fallback (non-md pages)
1433+
} catch {
1434+
return "md/unknown.md";
1435+
}
1436+
}
13621437
1363-
es.addEventListener('reload', function() {
1364-
if (window.Reveal && Reveal.getIndices) {
1365-
const slideIndices = Reveal.getIndices();
1366-
sessionStorage.setItem("revealSlide", JSON.stringify(slideIndices));
1367-
} else {
1368-
sessionStorage.setItem("scrollY", window.scrollY);
1438+
// Prefer Reveal's configured URL (if present), otherwise use pathname
1439+
const revealUrl = (window.Reveal && typeof Reveal.getConfig === "function" && Reveal.getConfig()?.url) || null;
1440+
const currentFile = normalizeToMdPath(revealUrl || location.pathname);
1441+
1442+
const context = {
1443+
type: window.Reveal ? 'reveal' : 'page',
1444+
currentFile: decodeURIComponent(location.pathname.replace(/^\\/+/, ""))
1445+
};
1446+
1447+
const es = new EventSource('/hot-reload?context=' +
1448+
encodeURIComponent(JSON.stringify(context))
1449+
);
1450+
1451+
1452+
es.addEventListener('reload', function(event) {
1453+
try {
1454+
const payload = JSON.parse(event.data || '{}');
1455+
console.log('[SSE] Reload event:', payload);
1456+
1457+
// Save position before reload
1458+
if (window.Reveal && Reveal.getIndices) {
1459+
const slideIndices = Reveal.getIndices();
1460+
sessionStorage.setItem("revealSlide", JSON.stringify(slideIndices));
1461+
} else {
1462+
sessionStorage.setItem("scrollY", window.scrollY);
1463+
}
1464+
1465+
if (payload.type === 'nav' && !window.Reveal) {
1466+
location.reload();
1467+
} else if (payload.type === 'page') {
1468+
const current = location.pathname.replace(/^\\/+/, "");
1469+
if (payload.files && payload.files.some(f => current.endsWith(f))) {
1470+
location.reload();
1471+
} else {
1472+
console.log('[SSE] Skipping reload: not affected', { current, files: payload.files });
1473+
}
1474+
} else {
1475+
console.log('[SSE] Skipping reload: not affected', { current, files: payload.files });
1476+
}
1477+
} catch (err) {
1478+
console.error('[SSE] Error parsing reload payload:', err);
13691479
}
1370-
location.reload();
13711480
});
13721481
1482+
es.onerror = function(err) {
1483+
console.warn('[SSE] Connection lost. Reconnecting in 3s...', err);
1484+
es.close();
1485+
setTimeout(connectSSE, 3000);
1486+
};
1487+
13731488
window.addEventListener("DOMContentLoaded", function() {
13741489
document.body.style.display = "none";
13751490
@@ -1385,18 +1500,15 @@ function getAutoReloadScript() {
13851500
13861501
const restore = () => {
13871502
Reveal.slide(h, v, f);
1388-
Reveal.layout(); // neu berechnen
1503+
Reveal.layout();
13891504
document.body.style.display = "";
13901505
sessionStorage.removeItem("revealSlide");
13911506
};
13921507
1393-
// Wenn Reveal schon ready ist, sofort
1394-
if (Reveal.isReady()) {
1508+
if (Reveal.isReady && Reveal.isReady()) {
13951509
restore();
13961510
} else {
1397-
// Sonst auf ready warten
1398-
Reveal.on('ready', restore, { once: true });
1399-
// Fallback, falls ready nicht feuert
1511+
Reveal.on && Reveal.on('ready', restore, { once: true });
14001512
setTimeout(() => {
14011513
document.body.style.display = "";
14021514
}, 1000);

0 commit comments

Comments
 (0)