From ec0c7ee253dda0d2fe9f5aae8fb4e69931d14082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Fri, 2 Jan 2026 18:23:29 +0100 Subject: [PATCH] Live reload for dev mode --- bookmarks/static/live-reload.js | 44 +++++++++++++++++++ bookmarks/templates/shared/head.html | 1 + bookmarks/urls.py | 6 +++ bookmarks/views/__init__.py | 1 + bookmarks/views/reload.py | 63 ++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+) create mode 100644 bookmarks/static/live-reload.js create mode 100644 bookmarks/views/reload.py diff --git a/bookmarks/static/live-reload.js b/bookmarks/static/live-reload.js new file mode 100644 index 0000000..32f41b7 --- /dev/null +++ b/bookmarks/static/live-reload.js @@ -0,0 +1,44 @@ +const RELOAD_URL = "/live_reload"; + +let eventSource = null; +let serverId = null; + +function connect() { + console.debug("[live-reload] Connecting to", RELOAD_URL); + + eventSource = new EventSource(RELOAD_URL); + + eventSource.addEventListener("connected", (event) => { + const data = JSON.parse(event.data); + + if (serverId && serverId !== data.server_id) { + console.log("[live-reload] Server restarted, reloading page"); + window.location.reload(); + return; + } + + console.debug("[live-reload] Connected, server ID:", data.server_id); + serverId = data.server_id; + }); + + eventSource.addEventListener("file_change", (event) => { + const data = JSON.parse(event.data); + console.log("[live-reload] File changed:", data); + + if (data.file_path.endsWith(".css") || data.file_path.endsWith(".js")) { + console.log("[live-reload] Asset changed, reloading page"); + window.location.reload(); + } + }); + + eventSource.onerror = (error) => { + console.debug("[live-reload] Disconnected", error); + eventSource.close(); + eventSource = null; + + // Reconnect after a delay + setTimeout(connect, 1000); + }; +} + +connect(); diff --git a/bookmarks/templates/shared/head.html b/bookmarks/templates/shared/head.html index 75c9204..f7505ca 100644 --- a/bookmarks/templates/shared/head.html +++ b/bookmarks/templates/shared/head.html @@ -61,4 +61,5 @@ {% if not request.global_settings.enable_link_prefetch %}{% endif %} {% if rss_feed_url %}{% endif %} + {% if debug %}{% endif %} diff --git a/bookmarks/urls.py b/bookmarks/urls.py index c614c46..3ec6ad4 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -106,6 +106,12 @@ urlpatterns = [ path("opensearch.xml", views.opensearch, name="opensearch"), ] +# Live reload (debug only) +if settings.DEBUG: + from bookmarks.views import reload + + urlpatterns.append(path("live_reload", reload.live_reload, name="live_reload")) + # Put all linkding URLs into a linkding namespace urlpatterns = [path("", include((urlpatterns, "linkding")))] diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py index 7c61067..a779358 100644 --- a/bookmarks/views/__init__.py +++ b/bookmarks/views/__init__.py @@ -10,3 +10,4 @@ from .manifest import manifest from .custom_css import custom_css from .root import root from .opensearch import opensearch + diff --git a/bookmarks/views/reload.py b/bookmarks/views/reload.py new file mode 100644 index 0000000..a12836a --- /dev/null +++ b/bookmarks/views/reload.py @@ -0,0 +1,63 @@ +import json +import threading +import uuid +from pathlib import Path +from queue import Empty, Queue + +from django.dispatch import receiver +from django.http import StreamingHttpResponse +from django.utils.autoreload import autoreload_started, file_changed + +_styles_dir = Path(__file__).resolve().parent.parent / "styles" +_static_dir = Path(__file__).resolve().parent.parent / "static" + +_server_id = str(uuid.uuid4()) + +_active_connections = set() +_connections_lock = threading.Lock() + + +def _event_stream(): + client_queue = Queue() + + with _connections_lock: + _active_connections.add(client_queue) + + try: + data = json.dumps({"server_id": _server_id}) + yield f"event: connected\ndata: {data}\n\n" + + while True: + try: + data = client_queue.get(timeout=30) + yield f"event: file_change\ndata: {data}\n\n" + except Empty: + yield ": keepalive\n\n" + finally: + with _connections_lock: + _active_connections.discard(client_queue) + + +def live_reload(request): + response = StreamingHttpResponse(_event_stream(), content_type="text/event-stream") + response["Cache-Control"] = "no-cache" + return response + + +@receiver(autoreload_started) +def handle_auto_reload(sender, **kwargs): + sender.watch_dir(_styles_dir, "**/*.css") + sender.watch_dir(_static_dir, "bundle.js") + + +@receiver(file_changed) +def handle_file_changed(sender, file_path, **kwargs): + print(f"File changed: {file_path}") + data = json.dumps({"file_path": str(file_path)}) + with _connections_lock: + for queue in _active_connections: + queue.put(data) + + # Return True for CSS/JS files to prevent Django server restart + if file_path.suffix in (".css", ".js"): + return True