feat(#101): live hot-reload of external sidecar/config changes
CI / Lint (ruff) (pull_request) Successful in 13s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 25s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s
CI / Lint (ruff) (pull_request) Successful in 13s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 25s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s
Detection (#91/#93) previously ran only at profile load / server selection, so a config.toml created or chmod-ed while BCC was running stayed invisible until a restart. Make the view react on its own. Core (pure, unit-tested — CI has no PySide6): - sidecar_watch_paths(): the external paths worth watching for a server — the resolved sidecar file, its directory (so create/delete and atomic-rename replaces register), and the wrong-path/doc file — order- stable and de-duplicated. - sidecar_state_fingerprint(): a hashable snapshot folding the #91 sidecar status and #93 permission status, so the GUI can tell whether the *observable* state actually changed and skip a redundant refresh. - sidecar_state_changed(): explicit, named equality for that decision. GUI (thin wiring, smoke-tested headlessly): - QFileSystemWatcher over the selection's sidecar path(s) + BCC's own loaded config; debounced (300 ms) so a burst of writes doesn't thrash. - Re-arm on every event: an atomic-rename replace drops the inode from the watcher, so wanted paths are re-added before the next check. - Focus-in fallback via changeEvent(ActivationChange) — always works where watchers miss (atomic replaces, not-yet-created files). - recheck_advisories() only recomputes warning labels from the form + filesystem; it never touches field values, so a live reload cannot clobber unsaved edits. - An external edit to BCC's own config surfaces a non-destructive Reload banner (never a silent overwrite); confirm-on-dirty reuses the existing load path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cdda60da1b
commit
ded1eef2dd
+92
@@ -3100,6 +3100,98 @@ def sidecar_permission_fix_target(
|
||||
return sidecar_path(spec, platform=platform, environ=environ, home=home)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Hot-reload — live external-change detection (issue #101, epic #94)
|
||||
#
|
||||
# #91/#93 compute the sidecar and permission advisories only at profile load /
|
||||
# server selection, so a config.toml created (or chmod-ed) WHILE BCC runs stays
|
||||
# invisible until a restart. The OS file-watcher, the focus-in re-check and the
|
||||
# debounce are GUI wiring (bcc.py). The testable core is two pure functions:
|
||||
# * which external paths to WATCH for a given server, and
|
||||
# * a comparable SNAPSHOT of the externally-observable state, so the GUI can
|
||||
# cheaply decide "did anything the user can see actually change?" and skip a
|
||||
# redundant (flicker-y) refresh on an unrelated write in the watched dir.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def sidecar_watch_paths(
|
||||
data: dict,
|
||||
*,
|
||||
platform: str | None = None,
|
||||
environ: dict | None = None,
|
||||
home: str | os.PathLike | None = None,
|
||||
) -> list[Path]:
|
||||
"""External filesystem paths whose changes affect a server's advisories.
|
||||
|
||||
Returns the resolved sidecar path, its containing directory (so the file
|
||||
appearing or being deleted — which a file-only watch on a not-yet-existent
|
||||
path would miss — is still observed), and, when distinct, the README/doc
|
||||
path (the #91 wrong-path trap: a file a user placed where the docs say but
|
||||
the server never reads). Empty when the server has no sidecar. Order is
|
||||
stable and de-duplicated so the GUI can hand it straight to a watcher.
|
||||
"""
|
||||
spec = resolve_server_spec(data)
|
||||
if spec is None:
|
||||
return []
|
||||
out: list[Path] = []
|
||||
p = sidecar_path(spec, platform=platform, environ=environ, home=home)
|
||||
if p is not None:
|
||||
out.append(p)
|
||||
out.append(p.parent)
|
||||
doc = sidecar_doc_path(spec, home=home)
|
||||
if doc is not None and doc != p:
|
||||
out.append(doc)
|
||||
out.append(doc.parent)
|
||||
# De-dupe while preserving order (the sidecar file's own dir may equal the
|
||||
# doc dir, or on Linux the doc path coincides with the real one).
|
||||
seen: set[Path] = set()
|
||||
uniq: list[Path] = []
|
||||
for path in out:
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
uniq.append(path)
|
||||
return uniq
|
||||
|
||||
|
||||
def sidecar_state_fingerprint(
|
||||
data: dict,
|
||||
*,
|
||||
platform: str | None = None,
|
||||
environ: dict | None = None,
|
||||
home: str | os.PathLike | None = None,
|
||||
exists=None,
|
||||
stat_mode=None,
|
||||
) -> tuple | None:
|
||||
"""A comparable snapshot of a server's externally-observable sidecar state.
|
||||
|
||||
Folds the #91 sidecar status (existence, precedence, wrong-path) and the #93
|
||||
permission status (mode + ok) into one hashable tuple. The GUI takes a
|
||||
fingerprint before and after a filesystem event and refreshes the advisories
|
||||
only when it changed — so an unrelated write in the watched directory doesn't
|
||||
thrash the UI. Returns None when the server has no sidecar (nothing to
|
||||
watch). All inputs are injectable so tests drive a virtual filesystem.
|
||||
"""
|
||||
status = sidecar_status(data, platform=platform, environ=environ, home=home, exists=exists)
|
||||
if status is None:
|
||||
return None
|
||||
perm = permission_status(status["path"], platform=platform, stat_mode=stat_mode)
|
||||
return (
|
||||
status["exists"],
|
||||
status["doc_exists"],
|
||||
status["args_inert"],
|
||||
status["wrong_path"],
|
||||
None if perm is None else perm["ok"],
|
||||
None if perm is None else perm["mode"],
|
||||
)
|
||||
|
||||
|
||||
def sidecar_state_changed(before: tuple | None, after: tuple | None) -> bool:
|
||||
"""True when two ``sidecar_state_fingerprint`` snapshots differ.
|
||||
|
||||
A thin, explicitly-named equality so the GUI's watcher/focus-in handlers read
|
||||
intentionally and the "should I refresh?" decision stays covered by tests.
|
||||
"""
|
||||
return before != after
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user