Merge PR #86: disambiguate project profiles with the same basename (#74)
CI / Lint (ruff) (push) Successful in 12s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 18s
CI / Tests (py3.12 / windows-latest) (push) Successful in 35s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 18s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 18s
CI / Catalog signature (push) Successful in 14s
CI / Lint (ruff) (push) Successful in 12s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 18s
CI / Tests (py3.12 / windows-latest) (push) Successful in 35s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 18s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 18s
CI / Catalog signature (push) Successful in 14s
Two repos both named "app" no longer both render as "Project: app": disambiguate_project_labels widens colliding labels toward the root (Project: work/app vs personal/app), discover_project_configs now requires a .mcp.json that parses to a dict (skipping arrays/garbage that only failed on open), and the full path goes in the combo tooltip. Pure core + 6 tests; the only GUI change is one setItemData line. 463 passed, ruff clean, CI green incl. Windows. Closes #74
This commit was merged in pull request #86.
This commit is contained in:
@@ -2043,6 +2043,11 @@ class MainWindow(QMainWindow):
|
|||||||
for p in self.profiles:
|
for p in self.profiles:
|
||||||
tag = "" if p.config_exists else " (no config yet)"
|
tag = "" if p.config_exists else " (no config yet)"
|
||||||
self.profile_combo.addItem(f"{p.label}{tag}")
|
self.profile_combo.addItem(f"{p.label}{tag}")
|
||||||
|
# Full path in the tooltip so a profile is always verifiable even
|
||||||
|
# when two labels look alike (e.g. two repos both named "app").
|
||||||
|
self.profile_combo.setItemData(
|
||||||
|
self.profile_combo.count() - 1, str(p.path), Qt.ItemDataRole.ToolTipRole
|
||||||
|
)
|
||||||
self.profile_combo.blockSignals(False)
|
self.profile_combo.blockSignals(False)
|
||||||
if self.profiles:
|
if self.profiles:
|
||||||
self.profile_combo.setCurrentIndex(0)
|
self.profile_combo.setCurrentIndex(0)
|
||||||
|
|||||||
+69
-10
@@ -648,16 +648,67 @@ def msix_warning_text(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_name_parts(directory: Path) -> list[str]:
|
||||||
|
"""The path components of `directory` with the filesystem anchor dropped.
|
||||||
|
|
||||||
|
`/home/me/work/app` -> ['home', 'me', 'work', 'app']; `C:\\src\\app` ->
|
||||||
|
['src', 'app']. Falls back to the raw name for an anchor-only path so a
|
||||||
|
label is never empty.
|
||||||
|
"""
|
||||||
|
parts = [p for p in directory.parts if p != directory.anchor]
|
||||||
|
return parts or [directory.name or str(directory)]
|
||||||
|
|
||||||
|
|
||||||
|
def disambiguate_project_labels(dirs: list[Path]) -> dict[Path, str]:
|
||||||
|
"""Map each project directory to a unique `Project: <suffix>` label.
|
||||||
|
|
||||||
|
The suffix is the directory's basename, widened toward the root one
|
||||||
|
component at a time ONLY for directories whose current suffix collides with
|
||||||
|
another in the set -- so the common case stays `Project: app`, and two repos
|
||||||
|
both named `app` become `Project: work/app` and `Project: personal/app`
|
||||||
|
(widening further if even that collides). Deterministic; expects `dirs` to
|
||||||
|
already be de-duplicated by path (identical paths can't be told apart).
|
||||||
|
"""
|
||||||
|
depth = {d: 1 for d in dirs}
|
||||||
|
max_depth = {d: len(_dir_name_parts(d)) for d in dirs}
|
||||||
|
|
||||||
|
def suffix(d: Path) -> str:
|
||||||
|
return "/".join(_dir_name_parts(d)[-depth[d] :])
|
||||||
|
|
||||||
|
# Widen colliding groups until every label is unique or no group can widen.
|
||||||
|
while True:
|
||||||
|
groups: dict[str, list[Path]] = {}
|
||||||
|
for d in dirs:
|
||||||
|
groups.setdefault(suffix(d), []).append(d)
|
||||||
|
widened = False
|
||||||
|
for members in groups.values():
|
||||||
|
if len(members) > 1:
|
||||||
|
for d in members:
|
||||||
|
if depth[d] < max_depth[d]:
|
||||||
|
depth[d] += 1
|
||||||
|
widened = True
|
||||||
|
if not widened:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {d: f"Project: {suffix(d)}" for d in dirs}
|
||||||
|
|
||||||
|
|
||||||
def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
|
def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
|
||||||
"""
|
"""
|
||||||
Find project-scope `.mcp.json` configs known to Claude Code.
|
Find project-scope `.mcp.json` configs known to Claude Code.
|
||||||
|
|
||||||
`~/.claude.json` keeps a `projects` map keyed by absolute project
|
`~/.claude.json` keeps a `projects` map keyed by absolute project
|
||||||
directory path (that's what the CLI writes as it's used in each repo).
|
directory path (that's what the CLI writes as it's used in each repo).
|
||||||
Any project whose directory has a `.mcp.json` file next to it -- a
|
Any project whose directory has a `.mcp.json` file that parses to a JSON
|
||||||
standalone file with a top-level `mcpServers` object, same shape BCC
|
object -- the same shape BCC already edits -- is surfaced here as its own
|
||||||
already edits -- is surfaced here as its own profile so it can be opened
|
profile so it can be opened via 'Add config...' without hunting for the
|
||||||
via 'Add config...' without hunting for the path by hand.
|
path by hand. A `.mcp.json` that is missing, unreadable, or not a JSON
|
||||||
|
object (an array, a string, garbage) is skipped rather than surfaced as a
|
||||||
|
profile that only fails when opened.
|
||||||
|
|
||||||
|
Labels are disambiguated so two repos with the same basename don't both
|
||||||
|
read as `Project: app` and let the user edit the wrong one (see
|
||||||
|
`disambiguate_project_labels`).
|
||||||
|
|
||||||
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a
|
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a
|
||||||
`projects` value that isn't a dict, just yields an empty list rather than
|
`projects` value that isn't a dict, just yields an empty list rather than
|
||||||
@@ -670,14 +721,22 @@ def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profil
|
|||||||
projects = cfg.get("projects")
|
projects = cfg.get("projects")
|
||||||
if not isinstance(projects, dict):
|
if not isinstance(projects, dict):
|
||||||
return []
|
return []
|
||||||
out: list[Profile] = []
|
|
||||||
|
valid_dirs: list[Path] = []
|
||||||
for key in sorted(k for k in projects if isinstance(k, str)):
|
for key in sorted(k for k in projects if isinstance(k, str)):
|
||||||
mcp_path = Path(key) / ".mcp.json"
|
mcp_path = Path(key) / ".mcp.json"
|
||||||
if mcp_path.is_file():
|
if not mcp_path.is_file():
|
||||||
out.append(
|
continue
|
||||||
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True)
|
try:
|
||||||
)
|
data = load_config(mcp_path) # strict; raises on bad JSON / non-object
|
||||||
return out
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
valid_dirs.append(Path(key))
|
||||||
|
|
||||||
|
labels = disambiguate_project_labels(valid_dirs)
|
||||||
|
return [Profile(label=labels[d], path=d / ".mcp.json", config_exists=True) for d in valid_dirs]
|
||||||
|
|
||||||
|
|
||||||
def discover_profiles() -> list[Profile]:
|
def discover_profiles() -> list[Profile]:
|
||||||
|
|||||||
@@ -2991,3 +2991,71 @@ def test_server_sections_and_change_summary_follow_a_custom_key(tmp_path):
|
|||||||
changed_keys, diff = c.external_change_summary(loaded, p, _VSCODE)
|
changed_keys, diff = c.external_change_summary(loaded, p, _VSCODE)
|
||||||
assert "servers" in changed_keys
|
assert "servers" in changed_keys
|
||||||
assert diff # a server-section change under the custom key is diffed
|
assert diff # a server-section change under the custom key is diffed
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Project profile label disambiguation (issue #74)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_disambiguate_labels_no_collision_uses_basename():
|
||||||
|
dirs = [Path("/home/me/work/api"), Path("/home/me/work/web")]
|
||||||
|
labels = c.disambiguate_project_labels(dirs)
|
||||||
|
assert labels[Path("/home/me/work/api")] == "Project: api"
|
||||||
|
assert labels[Path("/home/me/work/web")] == "Project: web"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disambiguate_labels_widens_only_colliding_basenames():
|
||||||
|
dirs = [
|
||||||
|
Path("/home/me/work/app"),
|
||||||
|
Path("/home/me/personal/app"),
|
||||||
|
Path("/home/me/notes"),
|
||||||
|
]
|
||||||
|
labels = c.disambiguate_project_labels(dirs)
|
||||||
|
# the two "app"s widen by one parent; the unique "notes" stays plain
|
||||||
|
assert labels[Path("/home/me/work/app")] == "Project: work/app"
|
||||||
|
assert labels[Path("/home/me/personal/app")] == "Project: personal/app"
|
||||||
|
assert labels[Path("/home/me/notes")] == "Project: notes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disambiguate_labels_widens_further_when_parent_also_collides():
|
||||||
|
dirs = [Path("/a/x/app"), Path("/b/x/app")]
|
||||||
|
labels = c.disambiguate_project_labels(dirs)
|
||||||
|
assert labels[Path("/a/x/app")] == "Project: a/x/app"
|
||||||
|
assert labels[Path("/b/x/app")] == "Project: b/x/app"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_project(tmp_path, name, mcp_content):
|
||||||
|
d = tmp_path / name
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
if mcp_content is not None:
|
||||||
|
(d / ".mcp.json").write_text(mcp_content, encoding="utf-8")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_json_with_projects(tmp_path, dirs):
|
||||||
|
cj = tmp_path / ".claude.json"
|
||||||
|
cj.write_text(json.dumps({"projects": {str(d): {} for d in dirs}}), encoding="utf-8")
|
||||||
|
return cj
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_project_configs_disambiguates_same_basename(tmp_path):
|
||||||
|
d1 = _write_project(tmp_path / "work", "app", '{"mcpServers": {}}')
|
||||||
|
d2 = _write_project(tmp_path / "personal", "app", '{"mcpServers": {}}')
|
||||||
|
cj = _claude_json_with_projects(tmp_path, [d1, d2])
|
||||||
|
profiles = c.discover_project_configs(cj)
|
||||||
|
labels = sorted(p.label for p in profiles)
|
||||||
|
assert labels == ["Project: personal/app", "Project: work/app"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_project_configs_skips_non_object_and_garbage(tmp_path):
|
||||||
|
good = _write_project(tmp_path, "good", '{"mcpServers": {}}')
|
||||||
|
array = _write_project(tmp_path, "arr", "[1, 2, 3]")
|
||||||
|
garbage = _write_project(tmp_path, "junk", "not json at all")
|
||||||
|
missing = tmp_path / "nofile"
|
||||||
|
missing.mkdir()
|
||||||
|
cj = _claude_json_with_projects(tmp_path, [good, array, garbage, missing])
|
||||||
|
profiles = c.discover_project_configs(cj)
|
||||||
|
paths = {str(p.path) for p in profiles}
|
||||||
|
assert str(good / ".mcp.json") in paths
|
||||||
|
assert str(array / ".mcp.json") not in paths
|
||||||
|
assert str(garbage / ".mcp.json") not in paths
|
||||||
|
assert str(missing / ".mcp.json") not in paths
|
||||||
|
|||||||
Reference in New Issue
Block a user