Disambiguate project profiles with the same basename (#74) #86

Merged
the_og merged 1 commits from fix/74-project-label-collision into main 2026-08-03 23:42:19 -04:00
3 changed files with 142 additions and 10 deletions
+5
View File
@@ -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
View File
@@ -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]:
+68
View File
@@ -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