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
@@ -3645,6 +3645,106 @@ def test_sidecar_permission_fix_target():
|
||||
assert c.sidecar_permission_fix_target({"command": "npx", "args": ["other"]}) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Hot-reload — live external-change detection (issue #101)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_sidecar_watch_paths_covers_file_dir_and_doc():
|
||||
data = dict(_SSH)
|
||||
paths = c.sidecar_watch_paths(data, platform="darwin", environ={}, home=_HOME)
|
||||
posix = [p.as_posix() for p in paths]
|
||||
real = c.sidecar_path(c.SERVER_SPECS["ssh-mcp"], platform="darwin", environ={}, home=_HOME)
|
||||
# The resolved sidecar file AND its directory (so create/delete registers).
|
||||
assert real.as_posix() in posix
|
||||
assert real.parent.as_posix() in posix
|
||||
# The README/doc path is distinct on macOS -> also watched.
|
||||
doc = c.sidecar_doc_path(c.SERVER_SPECS["ssh-mcp"], home=_HOME)
|
||||
assert doc.as_posix() in posix
|
||||
# No duplicates.
|
||||
assert len(posix) == len(set(posix))
|
||||
|
||||
|
||||
def test_sidecar_watch_paths_dedupes_on_linux():
|
||||
# On Linux the doc path coincides with the real path, so the file + dir are
|
||||
# each listed once, not twice.
|
||||
data = dict(_SSH)
|
||||
paths = c.sidecar_watch_paths(
|
||||
data, platform="linux", environ={"XDG_CONFIG_HOME": "/cfg"}, home=_HOME
|
||||
)
|
||||
posix = [p.as_posix() for p in paths]
|
||||
assert posix == list(dict.fromkeys(posix)) # order-preserving de-dup is a no-op
|
||||
assert "/cfg/ssh-mcp/config.toml" in posix
|
||||
assert "/cfg/ssh-mcp" in posix
|
||||
|
||||
|
||||
def test_sidecar_watch_paths_empty_for_non_sidecar():
|
||||
assert c.sidecar_watch_paths({"command": "npx", "args": ["other"]}) == []
|
||||
|
||||
|
||||
def test_sidecar_state_fingerprint_reflects_appearance_and_perms():
|
||||
data = dict(_SSH)
|
||||
real = c.sidecar_path(c.SERVER_SPECS["ssh-mcp"], platform="darwin", environ={}, home=_HOME)
|
||||
|
||||
# File absent -> a fingerprint that encodes "not there, no perms".
|
||||
absent = c.sidecar_state_fingerprint(
|
||||
data, platform="darwin", environ={}, home=_HOME, exists=lambda p: False
|
||||
)
|
||||
# File present but world-readable.
|
||||
bad = c.sidecar_state_fingerprint(
|
||||
data,
|
||||
platform="darwin",
|
||||
environ={},
|
||||
home=_HOME,
|
||||
exists=lambda p: Path(p) == real,
|
||||
stat_mode=lambda p: 0o644 if Path(p) == real else 0o700,
|
||||
)
|
||||
# File present and tight.
|
||||
good = c.sidecar_state_fingerprint(
|
||||
data,
|
||||
platform="darwin",
|
||||
environ={},
|
||||
home=_HOME,
|
||||
exists=lambda p: Path(p) == real,
|
||||
stat_mode=lambda p: 0o600 if Path(p) == real else 0o700,
|
||||
)
|
||||
assert absent is not None
|
||||
# Each observable transition changes the fingerprint.
|
||||
assert c.sidecar_state_changed(absent, bad)
|
||||
assert c.sidecar_state_changed(bad, good)
|
||||
assert c.sidecar_state_changed(absent, good)
|
||||
# Stable when nothing changed.
|
||||
assert not c.sidecar_state_changed(good, good)
|
||||
|
||||
|
||||
def test_sidecar_state_fingerprint_none_for_non_sidecar():
|
||||
assert c.sidecar_state_fingerprint({"command": "npx", "args": ["other"]}) is None
|
||||
|
||||
|
||||
def test_sidecar_state_fingerprint_tracks_args_inert_flip():
|
||||
# args_inert only becomes true once the sidecar file exists AND managed args
|
||||
# are present; the fingerprint must capture that precedence flip.
|
||||
data = dict(_SSH) # carries --host/--user (managed args)
|
||||
real = c.sidecar_path(
|
||||
c.SERVER_SPECS["ssh-mcp"], platform="linux", environ={"XDG_CONFIG_HOME": "/cfg"}, home=_HOME
|
||||
)
|
||||
before = c.sidecar_state_fingerprint(
|
||||
data,
|
||||
platform="linux",
|
||||
environ={"XDG_CONFIG_HOME": "/cfg"},
|
||||
home=_HOME,
|
||||
exists=lambda p: False,
|
||||
stat_mode=lambda p: None,
|
||||
)
|
||||
after = c.sidecar_state_fingerprint(
|
||||
data,
|
||||
platform="linux",
|
||||
environ={"XDG_CONFIG_HOME": "/cfg"},
|
||||
home=_HOME,
|
||||
exists=lambda p: Path(p) == real,
|
||||
stat_mode=lambda p: 0o600,
|
||||
)
|
||||
assert c.sidecar_state_changed(before, after)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Move to environment variable (issue #83)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user