From 3d760497213fcb6d983ff6d3fea28c5a90c69f2f Mon Sep 17 00:00:00 2001 From: drknowhow Date: Fri, 24 Jul 2026 08:01:07 -0400 Subject: [PATCH] release: v2.57.0 - Hub sub-project management overhaul Manual parent/child control becomes a first-class Hub surface: - Fix first-child bootstrap gap: Designate on every non-child card (previously invisible until a child already existed via CLI) - Passive link health: /api/projects annotates subproject_issues + per-child link_status (cached, single-flight); red badges on cards - New Sub-projects drill tab: per-child validate/promote, inline reconcile with repair, cascade launcher (include-parent, cancel) - "Make sub-project of..." reverse designate; staged "Change parent..." wizard; clear-mode De-initialize behind typed-name confirm - FolderPicker: subtree fence, adopt/re-link messaging, IDE picker - Federation toggles (hybrid.subprojects) in the config editor with strict server-side schema; Ctrl-K hierarchy breadcrumbs + scope - Cascade totals exclude skipped children; cancelled state surfaced - Guide (workflow.html) + README sub-projects documentation Tests: 1281 passed, 1 skipped (10 new). Claude-Session: https://claude.ai/code/session_01P3p4Kiem63Va7kazUJFY7s --- CHANGELOG.md | 88 ++++ CLAUDE.md | 10 +- README.md | 8 + cli/c3.py | 2 +- cli/guide/workflow.html | 87 ++++ cli/hub_server.py | 167 ++++++- cli/hub_ui/app.js | 2 +- cli/hub_ui/components/config_editor.js | 128 ++++- cli/hub_ui/components/drill_panel.js | 41 +- cli/hub_ui/components/drill_subprojects.js | 514 +++++++++++++++++++++ cli/hub_ui/components/drill_views.js | 52 ++- cli/hub_ui/components/global_search.js | 71 ++- cli/hub_ui/components/modals.js | 489 +++++++++++++++++++- cli/hub_ui/components/project_card.js | 194 +++++++- cli/hub_ui/components/toasts.js | 11 +- cli/hub_ui/state.js | 4 + cli/ui/api.js | 8 + pyproject.toml | 4 +- tests/test_hub_inspect_api.py | 24 + tests/test_hub_subproject_links.py | 223 +++++++++ 20 files changed, 2053 insertions(+), 74 deletions(-) create mode 100644 cli/hub_ui/components/drill_subprojects.js create mode 100644 tests/test_hub_subproject_links.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 570fe34..d5608ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,94 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.57.0] - 2026-07-24 + +### Added — Hub sub-project management overhaul (UI/UX) + +Manual parent/child control becomes a first-class Hub surface. + +- **First-child bootstrap fix**: "Designate sub-project…" now renders on every + non-child project card — previously it only appeared once a project already + HAD registered children, so the first child could only be created via + `c3 sub add` (the UI feature was invisible until the CLI had been used). +- **Passive link health**: `GET /api/projects` now annotates parent rows with + `subproject_issues` (count of broken three-way links + registry orphans) + and child rows with `link_status` (`ok | backlink_broken | unregistered | + missing_folder | missing_c3 | orphan`) via a new + `_annotate_subproject_links` helper — config + filesystem + raw-registry + reads only, no port probing. Cards surface red "N link issues" badges on + parents and amber/red status badges on children without anyone having to + run Reconcile manually. +- **Sub-projects drill tab** (`drill_subprojects.js`, new): dedicated + management surface — per-child rows (status, facts, alerts) with inline + Validate and Promote actions; Designate button (fixes bootstrap in the + drill too, with an educational empty state); inline Reconcile with + per-issue detail and planned repair actions; Cascade launcher with op + picker, include-parent toggle, affected-children preview, live progress, + Cancel, and 409 attach-to-running handling. +- **"Make sub-project of…"** on eligible top-level cards: reverse designate — + lists only registered parents whose folder physically contains the project + (containment is mandatory), validates via the existing endpoint, links with + adopt semantics. +- **Clear-mode de-init**: the child kebab's duplicate "Unlink" (same server op + as Promote) is replaced by "De-initialize…" — the previously CLI-only + `remove mode='clear'` (deletes `.c3`, uninstalls MCP config, removes + instruction docs, deregisters) behind a typed-name confirm dialog. +- **Cascade UX**: progress toasts gained a cancel affordance (the + `/cascade/cancel` endpoint existed with zero UI); the card submenu gained + an "Include parent" toggle (`include_parent` was accepted server-side but + never sent). +- **Hierarchy visibility**: child cards show an "↳ parent" chip (annotated in + `buildProjectTree`, path-derived fallback when the parent row is filtered + out or unregistered); the drill header shows an "under parent" chip; grid + view child rows — previously inert — now carry kebab menus and link-status + badges; removing a parent pre-warns in the confirm dialog that its N + children become top-level (was a post-hoc toast). +- **Ctrl-K hierarchy** (`global_search.js`): child result groups render a + "parent › child" breadcrumb; a scope chip row (All / Top-level only / + per-parent "+ children") drives real server-side filtering through the + endpoint's existing `projects` param. +- **FolderPicker polish** (`modals.js`): navigation fenced to the parent + subtree (breadcrumb segments above the parent render inert instead of + post-hoc validation failures); explicit adopt/re-link messaging for + `has_c3` / already-registered folders; "Instruction docs / IDE" picker + (`auto | claude | vscode | cursor | codex | antigravity`) wired to the + endpoint's existing `ide` param; adopt-vs-initialize expectation shown + before designating. +- **Inheritance toggles**: the config editor gains a "Sub-projects" section on + parent projects — "Parent memory recall includes child facts" + (`memory_rollup`), "Search scope 'all' fans out to children" + (`search_fanout`), and "Max children per query", persisted as a partial + deep-merge under `hybrid.subprojects`. The config PUT now permits that + federation dict (the top-level child-links `subprojects[]` array stays + refused — hierarchy still changes only through the dedicated actions). +- **Staged re-parent wizard**: "Change parent…" on child cards — an honest + multi-step flow, never a fake one-click move (physical containment is + mandatory). Ready-now targets relink immediately; otherwise the wizard + unlinks first (deliberate: the child back-link can only be cleared while + the folder is still at its old path), offers Undo until the user moves the + folder, gates the new link behind validation, and cleans up the stale + registry row — with a per-step checklist that reports the exact state on + any failure. +- **Config editor honesty**: `subprojects` / `parent` keys — previously + silently hidden — render as read-only rows with a "managed via sub-project + actions" hint; drill Overview status colors now branch on the real status + vocabulary (the old `'missing'`/`'error'` branches were dead code, so real + errors rendered as warnings). + +### Added — Hub UI: Reconcile action for sub-project links + +- Parent project cards' kebab menu gains **"Reconcile links"** (wrench icon): + runs the existing `/api/projects/subprojects/reconcile` consistency check + as a dry-run, reports "all N links consistent" when clean, and otherwise + shows a confirm dialog summarizing the damage (broken back-links, + unregistered children, registry orphans, missing folders) before repairing + with `fix=true`. `prune=true` is sent only when missing-folder entries were + detected, so pruning is always surfaced to the user before it happens. + `missing_c3` children are called out as not repairable by reconcile + (re-designate or Cascade update instead). The endpoint was previously + CLI/API-only. + ## [2.56.1] - 2026-07-23 ### Fixed — `c3 --version` / `c3 upgrade` reported 2.55.0 after upgrading to 2.56.0 diff --git a/CLAUDE.md b/CLAUDE.md index 624425f..31e5145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ claude-companion - v2/ THIRD_PARTY_LICENSES.md c3.bat install.bat - ... +4 more + ... +5 more .agents/ .github/ FUNDING.yml @@ -126,7 +126,7 @@ claude-companion - v2/ compressor.py context_snapshot.py conversation_store.py - ... +52 more + ... +53 more bench/ (1 files) tests/ test_activity_reporter.py @@ -144,7 +144,7 @@ claude-companion - v2/ test_circuit_breaker.py test_claude_md_merge.py test_cli_smoke.py - ... +77 more + ... +81 more tui/ __init__.py backend.py @@ -170,8 +170,8 @@ Python (Modern) ## Key Facts (use c3_memory for more) -- Session summary (20260701): Decision: Deep 4-agent evaluation of C3 completed (tool surface, hooks, services, claims-vs- - Session summary (20260701): Files (modified): README.md, services/subprojects.py, services/project_manager.py, services/ - [validate] U:\1. Projects\Claude Code Companion (C3)\claude-companion - v2\cli\hub_ui\components\toasts.js has syntax er - Sub-projects (v2.44.0): three-way link — parent config subprojects[] (POSIX rel_path = source of truth), child config pa -- Hub v2 (v2.44.0): cli/hub_ui.html + cli/hub_ui/ 22-file concat bundle served at / (hub.html frozen at /legacy until v2.4 \ No newline at end of file +- Hub v2 (v2.44.0): cli/hub_ui.html + cli/hub_ui/ 22-file concat bundle served at / (hub.html frozen at /legacy until v2.4 +- Session summary (20260702): Files (modified): services/subprojects.py, .github/workflows/release.yml, pyproject.toml, cl \ No newline at end of file diff --git a/README.md b/README.md index 456fd55..615f7a3 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,14 @@ Every C3-initialized project on your machine appears here automatically — `c3 Per-card actions cover the full lifecycle: launch the IDE, open the per-project UI, edit name / tags / notes, transfer the registration to a new path, **merge** another project's memory + conversation history + edit ledger into this one (with optional source cleanup), or remove it from the registry. +**Sub-projects — nested repos as first-class children.** Designate any sub-folder (a service in a monorepo, a vendored tool) as a linked child project with its own `.c3`: the parent's index excludes the child's subtree, and `c3_search` / `c3_memory` fan out across children on demand (`scope='all'` or one child by name). The whole hierarchy is manageable from the hub: + +- **Designate** from any project card or the drill-in **Sub-projects** tab — folder picker with upfront validation, adopt-vs-initialize preview, and IDE choice. An existing top-level project that physically sits inside a parent can be re-linked via **"Make sub-project of…"**. +- **Link health is passive** — parent cards show a red "N link issues" badge and children show their link status automatically; **Reconcile** shows exactly what's broken and repairs it on confirm. +- **Cascade** update / reindex / health across all children (cancellable, optionally including the parent), **promote** a child back to top-level, or **de-initialize** it entirely behind a typed-name confirm. +- **Change parent…** moves a child between parents honestly: folders must physically nest, so the wizard stages the move, validates every step, and never leaves a broken state silently. +- Federation behavior per parent — memory roll-up, search fan-out, children per query — is editable in the project's config editor. + **Open in your IDE of choice** — C3 auto-detects which CLIs you have installed and gives you one-click launchers:

diff --git a/cli/c3.py b/cli/c3.py index c85ae49..f41af82 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -92,7 +92,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.56.1" +__version__ = "2.57.0" def _command_deps() -> CommandDeps: diff --git a/cli/guide/workflow.html b/cli/guide/workflow.html index 917d286..66e917f 100644 --- a/cli/guide/workflow.html +++ b/cli/guide/workflow.html @@ -249,6 +249,7 @@ 📐 Plan Mode 🔄 Session Restart ⚡ /terse Skill + 🌳 Sub-projects ⚠️ Native Fallback @@ -355,6 +356,12 @@

Workflow

c3_project(action='list') v2.31.0 — discover and operate on OTHER c3-installed projects; reads run free, writes need allow_write=true. Tool card → + + + + Sub-projects + c3 sub add <folder> + v2.44.0 — link child projects living inside a parent; federated search/memory + Hub management. Section → + @@ -897,6 +904,86 @@

Terse Advisor — automatic nudge


+ +
+
🌳 Sub-projects
+ +

Since v2.44.0, a c3 project can link folders that physically live inside it as sub-projects. Each child gets its own full .c3 (index, memory, config), the parent's index excludes the child's subtree, and the link is recorded in three places at once: the parent config's subprojects[] array, a back-link in the child's config, and the hub registry. Links are depth-1 — a child cannot have children of its own.

+ +
+ 💡 +
+ Use sub-projects for monorepos and nested apps: each child stays independently searchable with its own memory, while the parent can fan queries out across the whole family. +
+
+ +

CLI — c3 sub

+ + + + + + + + + + + + + + + + + + + + + + + + +
CommandEffect
c3 sub add <folder> [--name --ide]Link a folder inside the current project. Adopts an existing .c3, otherwise runs a full init. --no-init links without initializing; --parent <path> targets a different parent.
c3 sub listTree of linked children with status (the default when no sub-command is given).
c3 sub remove <ref> [--clear]Unlink a child and promote it to a standalone top-level project (its .c3 is kept). --clear also wipes .c3, uninstalls the MCP config and instruction docs, and deregisters it.
c3 sub run <update|reindex|health> [--include-parent]Cascade an operation across every child. --mcp also reinstalls the MCP config during update.
c3 sub check [--fix --prune]Reconcile the three-way links. --fix repairs from the parent config; adding --prune drops entries whose folder is gone.
+ +

Agent surface — the scope parameter

+

c3_search and c3_memory accept a scope parameter on parent projects:

+ + + + + + + +
ValueMeaning
''Current project only (default)
'all'Fan out across the parent and all linked children
'<child>'One specific sub-project by name
+ +
c3_search(query='auth middleware', scope='all')
+c3_memory(action='recall', query='payment gotchas', scope='billing-service')
+ +

With hybrid.subprojects.memory_rollup enabled, a parent-side recall also unions in the children's facts automatically.

+ +

Hub UI management (v2.57.0)

+

Everything the CLI does — plus the operations that need multi-step safety — is available in the Hub:

+ + +
+ ⚠️ +
+ Managed keys + The raw subprojects / parent config keys render read-only in the Hub config editor by design — hierarchy changes only go through the dedicated actions above (CLI or Hub), never by hand-editing the link records. +
+
+
+ +
+
⚠️ When Native Tools Are Permitted
diff --git a/cli/hub_server.py b/cli/hub_server.py index c83ca3b..9fd3870 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -333,6 +333,7 @@ def _win_find_ide(cmd: str) -> str: "hub_ui/components/session_drawer.js", "hub_ui/components/drill_panel.js", "hub_ui/components/drill_views.js", + "hub_ui/components/drill_subprojects.js", "hub_ui/components/drill_health.js", "hub_ui/components/drill_tasks.js", "hub_ui/components/drill_artifacts.js", @@ -477,6 +478,129 @@ def _notification_count(project_path: str) -> int: return count +_sub_links_cache = {"fp": None, "ts": 0.0, "data": None, "computing": False} +_sub_links_lock = threading.Lock() +_SUB_LINKS_TTL = 15.0 + + +def _invalidate_subproject_links(): + """Drop the cached link-health snapshot (call after any hierarchy mutation).""" + with _sub_links_lock: + _sub_links_cache.update({"fp": None, "ts": 0.0, "data": None}) + + +def _sub_federation_errors(sub): + """Schema check for the hybrid.subprojects federation dict (config PUT).""" + allowed = {"memory_rollup", "search_fanout", "max_children_per_query"} + unknown = sorted(set(sub) - allowed) + if unknown: + return f"unknown keys {unknown}" + for k in ("memory_rollup", "search_fanout"): + if k in sub and not isinstance(sub[k], bool): + return f"'{k}' must be a boolean" + v = sub.get("max_children_per_query") + if v is not None and (isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 256): + return "'max_children_per_query' must be an integer between 1 and 256" + return "" + + +def _norm_realpath(s): + """resolve()+normcase, matching services.subprojects._norm — plain + normcase alone would flag symlinked/8.3/trailing-slash registry rows + as false orphans that Reconcile then denies.""" + try: + return os.path.normcase(str(Path(s).resolve())) + except Exception: + return os.path.normcase(str(s or "")) + + +def _compute_subproject_links(projects): + """Statuses only — deliberately NOT SubprojectManager.list(), which + also parses facts.json and notifications.jsonl per child; those counts + were computed and thrown away on every poll recompute.""" + from services.subprojects import SubprojectManager, get_subprojects + parents_out, children_out = {}, {} + registry = None + for parent in projects: + if not (parent.get("is_parent") or parent.get("subproject_count")): + continue + try: + mgr = SubprojectManager(parent.get("path", "")) + entries = get_subprojects(mgr.parent_path) + if registry is None: + registry = mgr.pm._read_projects() + except Exception: + continue + issues = 0 + config_paths = set() + for entry in entries: + try: + key = _norm_realpath(mgr._abs_child(entry.get("rel_path", ""))) + status = mgr._entry_status(entry, registry) + except Exception: + continue + config_paths.add(key) + if status != "ok": + issues += 1 + children_out[key] = status + parent_key = _norm_realpath(parent.get("path", "")) + for row in projects: + rp = row.get("parent_path") + if (rp and _norm_realpath(rp) == parent_key + and _norm_realpath(row.get("path", "")) not in config_paths): + issues += 1 + children_out[_norm_realpath(row.get("path", ""))] = "orphan" + if issues: + parents_out[parent_key] = issues + return {"parents": parents_out, "children": children_out} + + +def _annotate_subproject_links(projects): + """Per-parent link-health rollup + per-child link_status for the cards. + + SubprojectManager.list() is config + filesystem + raw-registry reads + only (no child runtimes, no port probing). Registry orphans (rows + claiming a parent that has no matching config entry) count as issues + too — same rule as reconcile. The snapshot is cached _SUB_LINKS_TTL + seconds and computed single-flight, so overlapping UI polls never + stack behind a slow disk (e.g. a disconnected network drive); the + fingerprint ties the cache to the registry rows, and mutating + sub-project endpoints call _invalidate_subproject_links(). + """ + fp = tuple(sorted((os.path.normcase(p.get("path", "")), + os.path.normcase(p.get("parent_path") or "")) + for p in projects)) + now = time.time() + do_compute = False + with _sub_links_lock: + snap = _sub_links_cache["data"] + fresh = (snap is not None and _sub_links_cache["fp"] == fp + and now - _sub_links_cache["ts"] < _SUB_LINKS_TTL) + if not fresh: + if _sub_links_cache["computing"]: + if _sub_links_cache["fp"] != fp: + snap = None # stale snapshot of a different registry — skip + else: + _sub_links_cache["computing"] = True + do_compute = True + if do_compute: + try: + snap = _compute_subproject_links(projects) + with _sub_links_lock: + _sub_links_cache.update({"fp": fp, "ts": time.time(), "data": snap}) + finally: + with _sub_links_lock: + _sub_links_cache["computing"] = False + if not snap: + return + for p in projects: + key = _norm_realpath(p.get("path", "")) + if key in snap["parents"]: + p["subproject_issues"] = snap["parents"][key] + if key in snap["children"]: + p["link_status"] = snap["children"][key] + + @app.route("/api/projects", methods=["GET"]) def api_projects_list(): try: @@ -487,6 +611,7 @@ def api_projects_list(): p["notification_count"] = _notification_count(p.get("path", "")) p["is_parent"] = os.path.normcase(p.get("path", "")) in parent_paths p["open_task_count"] = open_task_count(p.get("path", "")) + _annotate_subproject_links(projects) return jsonify(projects) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -519,6 +644,8 @@ def api_projects_remove(): orphans = [p.get("path") for p in _pm().list_projects() if p.get("parent_path") and os.path.normcase(p["parent_path"]) == resolved] result = {"removed": _pm().remove_project(path)} + if result["removed"]: + _invalidate_subproject_links() if result["removed"] and orphans: result["orphaned_children"] = orphans return jsonify(result) @@ -1226,6 +1353,8 @@ def api_subprojects_add(): res = _run_c3(args, timeout=300) payload = _parse_json_tail(res.get("output", "")) ok = res.get("success") and bool((payload or {}).get("added")) + if ok: + _invalidate_subproject_links() body = {"success": ok, "result": payload, "output": res.get("output", "")} return jsonify(body), (201 if ok else 500) @@ -1247,6 +1376,8 @@ def api_subprojects_remove(): res = _run_c3(args, timeout=300) payload = _parse_json_tail(res.get("output", "")) ok = res.get("success") and bool((payload or {}).get("removed")) + if ok: + _invalidate_subproject_links() body = {"success": ok, "result": payload, "output": res.get("output", "")} return jsonify(body), (200 if ok else 500) @@ -1259,8 +1390,11 @@ def api_subprojects_reconcile(): if not parent: return jsonify({"error": "parent is required"}), 400 try: - return jsonify(_sub_manager(parent).reconcile( - fix=bool(data.get("fix")), prune=bool(data.get("prune")))) + result = _sub_manager(parent).reconcile( + fix=bool(data.get("fix")), prune=bool(data.get("prune"))) + if data.get("fix"): + _invalidate_subproject_links() + return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 404 except Exception as e: @@ -1321,6 +1455,7 @@ def _sub_cascade_worker(op: str, targets: list): _sub_cascade_state["running"] = False _sub_cascade_state["done"] = True _sub_cascade_state["current"] = None + _invalidate_subproject_links() @app.route("/api/projects/subprojects/cascade", methods=["POST"]) @@ -1339,11 +1474,20 @@ def api_subprojects_cascade(): return jsonify({"error": "op must be update|reindex|health"}), 400 try: sm = _sub_manager(parent) - targets = sm.list() + listed = sm.list() + # Skipped children (missing folder; missing .c3 unless the op can + # create it) are excluded up front so total and the success + # denominator reflect real work — previously they inflated total + # and reported as failures. + skipped = [t for t in listed + if t.get("status") == "missing_folder" + or (t.get("status") == "missing_c3" and op != "update")] + targets = [t for t in listed if t not in skipped] if data.get("include_parent"): targets.append({"name": "(parent)", "path": sm.parent_path, "status": "ok"}) if not targets: - return jsonify({"error": "No sub-projects designated"}), 400 + return jsonify({"error": "No cascade-eligible sub-projects" + + (f" ({len(skipped)} skipped)" if skipped else "")}), 400 with _sub_cascade_lock: _sub_cascade_state = { "running": True, "cancelled": False, "results": [], @@ -1352,7 +1496,8 @@ def api_subprojects_cascade(): } t = threading.Thread(target=_sub_cascade_worker, args=(op, targets), daemon=True) t.start() - return jsonify({"started": True, "total": len(targets), "op": op}) + return jsonify({"started": True, "total": len(targets), "op": op, + "skipped": len(skipped)}) except ValueError as e: return jsonify({"error": str(e)}), 404 except Exception as e: @@ -1643,6 +1788,18 @@ def api_projects_config_put(): return jsonify({"error": "values must be an object"}), 400 for refused in _CONFIG_REFUSED_KEYS: if refused in values: + # 'subprojects' under the hybrid section is the federation + # settings DICT (memory_rollup / search_fanout / …), not the + # top-level child-links ARRAY — the array stays refused (and is + # unreachable through the section whitelist anyway). Schema-check + # the dict: exactly these keys, exactly these types — the generic + # deep-merge below would happily persist anything. + if (section == "hybrid" and refused == "subprojects" + and isinstance(values["subprojects"], dict)): + bad = _sub_federation_errors(values["subprojects"]) + if bad: + return jsonify({"error": f"hybrid.subprojects: {bad}"}), 400 + continue return jsonify({"error": f"'{refused}' cannot be edited here"}), 400 try: resolved = _resolve_project_path(path) diff --git a/cli/hub_ui/app.js b/cli/hub_ui/app.js index 04afc48..71b961c 100644 --- a/cli/hub_ui/app.js +++ b/cli/hub_ui/app.js @@ -128,7 +128,7 @@ function App() { {drawerProject && setDrawerProject(null)} />} {drill && - setDrill(null)} onChanged={loadProjects} onOpenModal={openModal} />} setSearchOpen(false)} projects={projects} onOpenProject={(p, tab) => { setSearchOpen(false); openDrill(p, tab || 'overview'); }} /> diff --git a/cli/hub_ui/components/config_editor.js b/cli/hub_ui/components/config_editor.js index 2e96618..07bdf34 100644 --- a/cli/hub_ui/components/config_editor.js +++ b/cli/hub_ui/components/config_editor.js @@ -6,6 +6,15 @@ const CFG_ED_SECTIONS = ['hybrid', 'agents', 'delegate', 'proxy', 'mcp', 'meta', 'memory_llm', 'bitbucket']; const CFG_ED_READONLY = ['bitbucket']; const CFG_ED_REFUSED = ['version', 'project_path', 'permission_tier', 'subprojects', 'parent', 'api_key']; +// Refused keys worth surfacing read-only when present, rather than hiding — +// still never editable here (server refuses the write regardless). +const CFG_ED_MANAGED = ['subprojects', 'parent']; +const CFG_ED_MANAGED_HINT = 'Managed via sub-project actions — use the project card menu or drill-in panel.'; +// hybrid.subprojects federation settings get a friendly "Sub-projects" +// section on parent projects. The hub API accepts dict-valued 'subprojects' +// under section=hybrid (schema-checked server-side); the read-only fallback +// below only engages if this flag is ever flipped off again. +const CFG_ED_SUBPROJECTS_WRITABLE = true; function ConfigEditor({ project }) { const [section, setSection] = useState('hybrid'); @@ -36,11 +45,43 @@ function ConfigEditor({ project }) { }; useEffect(() => { setCfg(null); load(); }, [project.path, section]); + // ── Sub-projects (hybrid.subprojects): friendly federation controls ───── + // Shown for parent projects on the hybrid section. Edits nest under one + // 'subprojects' key holding only the changed sub-keys, so the server's + // deep-merge preserves untouched siblings (e.g. parent_memory_visible). + const isParent = !!project.is_parent || (project.subproject_count || 0) > 0; + const showSubprojects = section === 'hybrid' && !!cfg + && (isParent || cfg.subprojects !== undefined); + const subCfg = (cfg && cfg.subprojects && typeof cfg.subprojects === 'object') ? cfg.subprojects : {}; + const subDefs = (defs.subprojects && typeof defs.subprojects === 'object') ? defs.subprojects : {}; + const subEdits = (edits.subprojects && typeof edits.subprojects === 'object') ? edits.subprojects : {}; + const subVal = (k) => Object.prototype.hasOwnProperty.call(subEdits, k) ? subEdits[k] + : (subCfg[k] !== undefined ? subCfg[k] : subDefs[k]); + const subEdited = (k) => Object.prototype.hasOwnProperty.call(subEdits, k); + const subDot = (k) => subEdited(k) + ? + : null; + const setSubEdit = (k, v) => { + if (!CFG_ED_SUBPROJECTS_WRITABLE) return; + setEdits(e => { + const cur = Object.assign({}, (e.subprojects && typeof e.subprojects === 'object') ? e.subprojects : {}); + cur[k] = v; + return Object.assign({}, e, { subprojects: cur }); + }); + }; + const keys = []; + const managedKeys = []; if (cfg) { - Object.keys(defs).forEach(k => { if (CFG_ED_REFUSED.indexOf(k) < 0) keys.push(k); }); + Object.keys(defs).forEach(k => { + if (CFG_ED_REFUSED.indexOf(k) < 0) keys.push(k); + else if (CFG_ED_MANAGED.indexOf(k) >= 0 && managedKeys.indexOf(k) < 0 + && !(k === 'subprojects' && showSubprojects)) managedKeys.push(k); + }); Object.keys(cfg).forEach(k => { - if (CFG_ED_REFUSED.indexOf(k) < 0 && keys.indexOf(k) < 0) keys.push(k); + if (CFG_ED_REFUSED.indexOf(k) < 0) { if (keys.indexOf(k) < 0) keys.push(k); } + else if (CFG_ED_MANAGED.indexOf(k) >= 0 && managedKeys.indexOf(k) < 0 + && !(k === 'subprojects' && showSubprojects)) managedKeys.push(k); }); } const currentVal = (k) => Object.prototype.hasOwnProperty.call(edits, k) ? edits[k] @@ -66,6 +107,14 @@ function ConfigEditor({ project }) { v = Number(v); if (!isFinite(v)) throw new Error(`'${k}' must be a number`); } + if (k === 'subprojects' && v && typeof v === 'object' + && Object.prototype.hasOwnProperty.call(v, 'max_children_per_query')) { + const n = Number(v.max_children_per_query); + if (!isFinite(n) || n < 1 || Math.floor(n) !== n) { + throw new Error("'max_children_per_query' must be a positive integer"); + } + v = Object.assign({}, v, { max_children_per_query: n }); + } values[k] = v; }); const d = await api.put('/api/projects/config', { path: project.path, section, values }); @@ -83,6 +132,13 @@ function ConfigEditor({ project }) { Object.keys(defs).forEach(k => { if (CFG_ED_REFUSED.indexOf(k) < 0) next[k] = JSON.parse(JSON.stringify(defs[k] === undefined ? null : defs[k])); }); + if (showSubprojects && CFG_ED_SUBPROJECTS_WRITABLE) { + next.subprojects = { + memory_rollup: subDefs.memory_rollup !== false, + search_fanout: subDefs.search_fanout !== false, + max_children_per_query: subDefs.max_children_per_query !== undefined ? subDefs.max_children_per_query : 8, + }; + } setEdits(next); setJsonText({}); setJsonBad({}); notify(`Prefilled ${section} defaults — review, then Save section`, 'warn'); @@ -91,6 +147,18 @@ function ConfigEditor({ project }) { const dirty = Object.keys(edits).length > 0; const invalid = Object.keys(jsonBad).some(k => jsonBad[k]); + const renderManagedField = (k) => { + const val = cfg && cfg[k] !== undefined ? cfg[k] : defs[k]; + const text = (val !== null && typeof val === 'object') + ? JSON.stringify(val) : String(val === undefined ? '' : val); + return ( +
+ {k} + {text} +
+ ); + }; + const renderField = (k) => { const kind = kindOf(k); const val = currentVal(k); @@ -215,6 +283,62 @@ function ConfigEditor({ project }) {
{keys.map(renderField)}
+ {showSubprojects && ( +
+
Sub-projects
+
+ How this parent federates with its linked children (hybrid.subprojects). +
+ {!CFG_ED_SUBPROJECTS_WRITABLE && ( +
+ Read-only for now — the hub API refuses subprojects config writes. + Edit .c3/config.json directly to change these. +
+ )} +
+
+
+ {renderBoolToggle('Parent memory recall includes child facts', !!subVal('memory_rollup'), + () => setSubEdit('memory_rollup', !subVal('memory_rollup')), + `memory_rollup — default: ${subDefs.memory_rollup === false ? 'off' : 'on'}`)} +
+ {subDot('memory_rollup')} +
+
+
+ {renderBoolToggle("Search scope 'all' fans out to children", !!subVal('search_fanout'), + () => setSubEdit('search_fanout', !subVal('search_fanout')), + `search_fanout — default: ${subDefs.search_fanout === false ? 'off' : 'on'}`)} +
+ {subDot('search_fanout')} +
+ +
+
+ )} + {managedKeys.length > 0 && ( +
+
{CFG_ED_MANAGED_HINT}
+
+ {managedKeys.map(renderManagedField)} +
+
+ )} {!readOnly && (
id !== 'subprojects' || !isChild); + // "under " chip: friendly name from the loaded project list when + // available (optional prop), else the last path segment. + const parentName = (() => { + if (!isChild) return null; + const norm = (s) => String(s || '').replace(/[\\/]+$/, '').toLowerCase(); + const row = (projects || []).find(pr => norm(pr.path) === norm(project.parent_path)); + if (row && row.name) return row.name; + const segs = String(project.parent_path).split(/[\\/]/).filter(Boolean); + return segs[segs.length - 1] || project.parent_path; + })(); const renderTab = () => { switch (tab) { + case 'subprojects': + return ; case 'tasks': return ; case 'artifacts': return ; case 'memory': return ; @@ -132,10 +148,23 @@ function DrillPanel({ project, tab, setTab, onClose, onChanged, onOpenModal }) {
-
{project.name || project.path}
+
+
{project.name || project.path}
+ {parentName && ( + + + under {parentName} + + )} +
- {DRILL_PANEL_TABS.map(([id, label]) => ( + {tabs.map(([id, label]) => ( +
+ + {d.ok && rec.phase !== 'fixed' && ( +
+ + All {children.length} sub-project link{children.length === 1 ? '' : 's'} consistent · no registry orphans. +
+ )} + + {issues.map(c => ( +
+ {subStatusMeta(c.status).label} + {c.name || c.rel_path} + + {rec.phase === 'fixed' ? 'still ' + subStatusMeta(c.status).label.toLowerCase() + : SUB_REPAIR_ACTIONS[c.status] || ''} + +
+ ))} + {orphans.map(p => ( +
+ Registry orphan + {p} + parent link cleared on repair +
+ ))} + + {rec.phase === 'fixed' && ( +
+ {(d.fixed || []).length} repaired + {(d.pruned || []).length ? ` · ${(d.pruned || []).length} pruned` : ''} + {d.ok ? ' — all links consistent now.' : ' — issues remain (see above).'} +
+ )} + + {rec.phase !== 'fixed' && !d.ok && ( +
+
+ Repair treats the parent config as source of truth: broken back-links are rewritten, + unregistered children re-registered, registry orphans get their parent link cleared + {prune ? ', and entries whose folder is gone are pruned' : ''}. + {issues.some(c => c.status === 'missing_c3') && + ' Missing .c3 is not repairable by reconcile — re-designate the folder or run Cascade update.'} +
+
+ + {busy ? 'Repairing…' : (prune ? 'Repair + prune' : 'Repair')} + +
+
+ )} +
+ ); +} + +// ── Sub-projects tab ─────────────────────────────────────────── +function DrillSubprojects({ project, onChanged, onOpenModal }) { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const [rec, setRec] = useState(null); // {result, phase: 'dry'|'fixed'} + const [recBusy, setRecBusy] = useState(false); + const [cascOpen, setCascOpen] = useState(false); // launcher visible + const [cascOp, setCascOp] = useState('update'); + const [includeParent, setIncludeParent] = useState(false); + const [cascBusy, setCascBusy] = useState(false); + const [cascStatus, setCascStatus] = useState(null); + const aliveRef = useRef(true); + + useEffect(() => { + aliveRef.current = true; + return () => { aliveRef.current = false; }; + }, []); + + const load = useCallback(async () => { + setErr(null); + try { + setData(await api.get('/api/projects/subprojects?parent=' + encodeURIComponent(project.path))); + } catch (e) { setErr(e.message); } + }, [project.path]); + + useEffect(() => { setData(null); setRec(null); load(); }, [load]); + usePoll(load, 10000); // picks up designations made via the modal + external changes + + const mutated = () => { load(); if (onChanged) onChanged(); }; + + // ── Reconcile: dry-run inline, explicit Repair after ───────── + const runReconcile = async () => { + if (recBusy) return; + setRecBusy(true); + try { + const d = await api.post('/api/projects/subprojects/reconcile', { parent: project.path }); + setRec({ result: d, phase: 'dry' }); + } catch (e) { notify('Reconcile: ' + e.message, 'err'); } + setRecBusy(false); + }; + + const repair = async () => { + if (recBusy || !rec) return; + const prune = (rec.result.children || []).some(c => c.status === 'missing_folder'); + setRecBusy(true); + try { + const d = await api.post('/api/projects/subprojects/reconcile', + { parent: project.path, fix: true, prune }); + setRec({ result: d, phase: 'fixed' }); + const fixed = (d.fixed || []).length; + const pruned = (d.pruned || []).length; + notify(`Reconcile: ${fixed} repaired${pruned ? `, ${pruned} pruned` : ''}${d.ok ? '' : ' — issues remain'}`, + d.ok ? 'ok' : 'warn'); + mutated(); + } catch (e) { notify('Reconcile: ' + e.message, 'err'); } + setRecBusy(false); + }; + + // ── Cascade: launch + inline status poll (hub-global singleton) ── + const pollCascade = async (tries) => { + if (!aliveRef.current) return; + let st; + try { st = await api.get('/api/projects/subprojects/cascade/status'); } + catch (e) { + if (aliveRef.current) { setCascStatus(null); notify('Cascade: ' + e.message, 'err'); } + return; + } + if (!aliveRef.current) return; + setCascStatus(st); + if (st.done) { + const results = st.results || []; + const ok = results.filter(r => r.success).length; + notify(`Cascade ${st.op || ''}: ${ok}/${results.length} succeeded`, + ok === results.length ? 'ok' : 'warn'); + mutated(); + return; + } + if (!st.running) { setCascStatus(null); return; } // idle state — nothing to watch + if (tries > 600) { notify('Cascade: timed out waiting for status', 'err'); return; } + setTimeout(() => pollCascade(tries + 1), 1500); + }; + + // A cascade may already be running (started from a card or another tab). + useEffect(() => { + api.get('/api/projects/subprojects/cascade/status') + .then(st => { + if (st.running && aliveRef.current) { + setCascStatus(st); + setTimeout(() => pollCascade(0), 1500); + } + }) + .catch(() => {}); + }, [project.path]); + + const startCascade = async (affectedTotal) => { + if (cascBusy) return; + setCascBusy(true); + try { + const resp = await api.post('/api/projects/subprojects/cascade', + { parent: project.path, op: cascOp, include_parent: includeParent }); + setCascOpen(false); + setCascStatus({ + running: true, op: cascOp, parent: project.path, results: [], + total: (resp && resp.total) || affectedTotal, + }); + setTimeout(() => pollCascade(0), 800); + } catch (e) { + if (e.status === 409) { + notify('Another cascade is already running (one at a time, hub-wide) — showing its progress', 'warn'); + setCascOpen(false); + setTimeout(() => pollCascade(0), 200); + } else { + notify('Cascade: ' + e.message, 'err'); + } + } + setCascBusy(false); + }; + + const cancelCascade = async () => { + try { + const d = await api.post('/api/projects/subprojects/cascade/cancel', {}); + notify(d.cancelled ? 'Cascade cancelled — current child finishes first' + : (d.message || 'No cascade in progress'), d.cancelled ? 'warn' : 'ok'); + } catch (e) { notify('Cancel: ' + e.message, 'err'); } + }; + + // ── Render ─────────────────────────────────────────────────── + if (project.parent_path) { + return ; + } + if (err) return ; + if (!data) return ; + + const children = data.children || []; + const parentName = (data.parent && data.parent.name) || project.name || project.path; + const rollup = data.rollup; + const designate = () => onOpenModal('folderPick', project); + + if (children.length === 0) { + return ( + + +
No sub-projects yet
+
+ A sub-project is a folder inside this project designated as a linked child + workspace: it gets its own .c3 index and memory, + is excluded from the parent's index, and rolls its alerts and status up here. + Links are depth-1 — children cannot have children of their own. +
+ + Designate sub-project… + +
+ ); + } + + const cascActive = cascStatus && !cascStatus.done; + const cascResults = (cascStatus && cascStatus.results) || []; + const cascForeign = cascStatus && cascStatus.parent && project.path + && String(cascStatus.parent).toLowerCase() !== String(project.path).toLowerCase(); + const affected = children.filter(c => + c.status !== 'missing_folder' && (cascOp === 'update' || c.status !== 'missing_c3')); + const skipped = children.length - affected.length; + const affectedTotal = affected.length + (includeParent ? 1 : 0); + + return ( +
+
+ + Designate sub-project… + + + {recBusy && !rec ? 'Checking…' : 'Reconcile'} + + setCascOpen(o => !o)} disabled={!!cascActive} + style={{ padding: '7px 14px' }}> + Cascade… + +
+ + {cascOpen && !cascActive && ( +
+
Cascade across sub-projects
+ + {renderBoolToggle('Include parent', includeParent, () => setIncludeParent(v => !v), + 'Run the same operation on this parent project after the children')} +
+ Affects {affectedTotal} project{affectedTotal === 1 ? '' : 's'}: +
+
+ {affected.map(c => {c.name})} + {includeParent && {parentName} (parent)} +
+ {skipped > 0 && ( +
+ {skipped} child{skipped === 1 ? '' : 'ren'} skipped (missing folder + {cascOp !== 'update' ? ' or missing .c3' : ''}). +
+ )} +
+ setCascOpen(false)} style={{ padding: '6px 14px' }}>Close + startCascade(affectedTotal)} disabled={cascBusy || affectedTotal === 0} + style={{ padding: '6px 14px' }}> + {cascBusy ? 'Starting…' : 'Start cascade'} + +
+
+ )} + + {cascStatus && ( +
+
+ + + Cascade — {subCascadeOpLabel(cascStatus.op)} + {cascForeign ? ` (running for ${cascStatus.parent})` : ''} + + {!cascStatus.done && ( + + Cancel + + )} + {cascStatus.done && ( + + )} +
+
+ + + {cascResults.length}/{cascStatus.total || '?'} + +
+
+ {cascStatus.done + ? (cascStatus.cancelled ? 'Cancelled.' : 'Done.') + : cascStatus.current ? `Running ${cascStatus.current}…` : 'Starting…'} +
+ {cascResults.map(r => ( +
+ + {r.name || r.path} + {String(r.output || '').slice(0, 120)} + {r.elapsed_ms != null && + + {(r.elapsed_ms / 1000).toFixed(1)}s + } +
+ ))} +
+ )} + + {rec && ( + setRec(null)} /> + )} + + + {children.map(c => ( + + ))} + {rollup && ( +
+ {rollup.children} sub-project{rollup.children === 1 ? '' : 's'} + {' · '}{rollup.notifications} alert{rollup.notifications === 1 ? '' : 's'} + {' · '}{rollup.issues} issue{rollup.issues === 1 ? '' : 's'} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/cli/hub_ui/components/drill_views.js b/cli/hub_ui/components/drill_views.js index b7e57b5..32b7281 100644 --- a/cli/hub_ui/components/drill_views.js +++ b/cli/hub_ui/components/drill_views.js @@ -16,6 +16,18 @@ function drillFactText(f) { return f.fact || f.text || f.content || JSON.stringify(f); } +// Sub-project status vocabulary (services/subprojects.py _entry_status): +// 'ok' | 'backlink_broken' | 'unregistered' | 'missing_folder' | 'missing_c3'. +function drillSubStatusMeta(status) { + const s = String(status || ''); + if (s === 'ok') return { color: T.accent, label: 'ok' }; + if (s === 'backlink_broken') return { color: T.warn, label: 'broken back-link' }; + if (s === 'unregistered') return { color: T.warn, label: 'unregistered' }; + if (s === 'missing_folder') return { color: T.error, label: 'missing folder' }; + if (s === 'missing_c3') return { color: T.error, label: 'missing .c3' }; + return { color: T.warn, label: s || '?' }; +} + // ── Overview ─────────────────────────────────────────────────── function DrillOverview({ project, onChanged, setTab }) { const [data, setData] = useState(null); @@ -96,27 +108,27 @@ function DrillOverview({ project, onChanged, setTab }) { {!subs ? : ( {(subs.children || []).length === 0 && } - {(subs.children || []).map(c => ( -
-
-
{c.name || c.rel_path}
-
{c.rel_path || c.path}
+ {(subs.children || []).map(c => { + const statusMeta = drillSubStatusMeta(c.status); + return ( +
+
+
{c.name || c.rel_path}
+
{c.rel_path || c.path}
+
+ {c.facts_count != null && + {c.facts_count} facts} + {c.notification_count > 0 && {c.notification_count} alerts} + {statusMeta.label}
- {c.facts_count != null && - {c.facts_count} facts} - {c.notification_count > 0 && {c.notification_count} alerts} - - {c.status || '?'} - -
- ))} + ); + })} {rollup && (
{rollup.children} sub-project{rollup.children === 1 ? '' : 's'} diff --git a/cli/hub_ui/components/global_search.js b/cli/hub_ui/components/global_search.js index 42562c8..048073a 100644 --- a/cli/hub_ui/components/global_search.js +++ b/cli/hub_ui/components/global_search.js @@ -1,10 +1,13 @@ // ─── Global cross-project search (Ctrl-K overlay) ────────────── -// POST /api/search/global {query, kind} → results grouped per project. +// POST /api/search/global {query, kind, projects?} → results grouped per +// project. Scope chips narrow which projects are searched (hierarchy-aware); +// child-project result groups render a "parent › child" breadcrumb. // Esc close is handled by app.js; backdrop click + the X button close too. function GlobalSearch({ open, onClose, projects, onOpenProject }) { const [query, setQuery] = useState(''); const [kind, setKind] = useState('both'); + const [scope, setScope] = useState('all'); // 'all' | 'top' | const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -16,6 +19,18 @@ function GlobalSearch({ open, onClose, projects, onOpenProject }) { if (open && inputRef.current) inputRef.current.focus(); }, [open]); + const lc = s => (s || '').toLowerCase(); + const lastSeg = p => (p || '').split(/[\\/]/).filter(Boolean).pop() || ''; + + // Paths to search for the current scope; null → server default (all). + const scopePaths = () => { + if (scope === 'all') return null; + if (scope === 'top') return (projects || []).filter(p => !p.parent_path).map(p => p.path); + return (projects || []) + .filter(p => lc(p.path) === lc(scope) || lc(p.parent_path) === lc(scope)) + .map(p => p.path); + }; + // Debounced search: 400ms after typing stops, min 2 chars. useEffect(() => { if (!open) return; @@ -25,7 +40,17 @@ function GlobalSearch({ open, onClose, projects, onOpenProject }) { const seq = ++seqRef.current; const t = setTimeout(async () => { try { - const d = await api.post('/api/search/global', { query: q, kind }); + const body = { query: q, kind }; + const paths = scopePaths(); + if (paths) { + if (!paths.length) { + // Scoped target vanished (removed/renamed) — reset visibly + // instead of silently widening the search to everything. + setScope('all'); setLoading(false); return; + } + body.projects = paths; + } + const d = await api.post('/api/search/global', body); if (seq !== seqRef.current) return; // superseded by a newer search setData(d); setError(null); setLoading(false); } catch (e) { @@ -34,7 +59,7 @@ function GlobalSearch({ open, onClose, projects, onOpenProject }) { } }, 400); return () => clearTimeout(t); - }, [query, kind, open]); + }, [query, kind, scope, open]); if (!open) return null; @@ -47,12 +72,25 @@ function GlobalSearch({ open, onClose, projects, onOpenProject }) { const fmtLines = (lines) => Array.isArray(lines) ? lines.join('-') : (lines || ''); - const chip = (id, label) => ( - ); @@ -88,9 +126,22 @@ function GlobalSearch({ open, onClose, projects, onOpenProject }) { {/* Kind chips */}
- {chip('both', 'Both')}{chip('code', 'Code')}{chip('memory', 'Memory')} + {chip('both', 'Both', kind, setKind)}{chip('code', 'Code', kind, setKind)}{chip('memory', 'Memory', kind, setKind)}
+ {/* Scope chips — which projects get searched (only when a hierarchy exists) */} + {hasTree && ( +
+ SCOPE + {chip('all', 'All projects', scope, setScope)} + {chip('top', 'Top-level only', scope, setScope)} + {parents.map(p => chip(p.path, `${p.name || lastSeg(p.path)} + children`, scope, setScope))} +
+ )} + {/* Results */}
{loading && ( @@ -116,11 +167,13 @@ function GlobalSearch({ open, onClose, projects, onOpenProject }) { const memHits = row.memory || []; if (!codeHits.length && !memHits.length && !row.error) return null; const proj = row.project || {}; + const parentLabel = parentNameOf(findProject(proj)); return (
{/* Project header */}
+ {parentLabel && {parentLabel} › } {proj.name || proj.path} ; case 'ide': return ; case 'folderPick': return ; + case 'makeSub': return ; + case 'reparent': return ; case 'batch': return ; case 'settings': return ; default: return null; } } +// ── Make sub-project of… ─────────────────────────────────────── +// Reverse designate: pick a registered parent that physically +// contains this project (containment is mandatory server-side) and +// link it as a child via the same validate/add endpoints. +function MakeSubprojectModal({ project, projects, onClose, onChanged }) { + const norm = (s) => (s || '').replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); + const self = norm(project.path); + const candidates = (projects || []).filter(c => { + const cp = norm(c.path); + return cp && cp !== self && !c.parent_path && self.startsWith(cp + '\\'); + }); + const [selected, setSelected] = useState(candidates.length === 1 ? candidates[0].path : ''); + const [validation, setValidation] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!selected) { setValidation(null); return; } + let live = true; + setValidation({ pending: true }); + api.post('/api/projects/subprojects/validate', { parent: selected, folder: project.path }) + .then(v => { if (live) setValidation(v); }) + .catch(e => { if (live) setValidation({ ok: false, warnings: [e.message] }); }); + return () => { live = false; }; + }, [selected]); + + const designate = async () => { + setBusy(true); + try { + const d = await api.post('/api/projects/subprojects/add', + { parent: selected, folder: project.path, name: project.name }); + const res = d.result || d; + const parentName = (candidates.find(c => c.path === selected) || {}).name || selected; + notify(`${res.adopted ? 'Adopted' : 'Linked'} ${project.name} under ${parentName}`); + onChanged(); onClose(); + } catch (e) { notify('Designate: ' + e.message, 'err'); } + setBusy(false); + }; + + return ( + + {project.path} + {candidates.length === 0 ? ( +
+ No registered project contains this folder. A sub-project must live + physically inside its parent — move the folder under the intended + parent (then designate from the parent's card), or register the + containing folder as a project first. +
+ ) : ( + + Parent project + + {validation && !validation.pending && ( +
+ {(validation.warnings || []).map((w, i) => ( +
⚠ {w}
+ ))} + {validation.ok && ( +
✓ Valid — existing .c3 and hub registration are kept and re-linked.
+ )} +
+ )} +
+ )} + + Cancel + + {busy ? 'Linking…' : 'Make sub-project'} + + +
+ ); +} + +// ── Change parent (re-parent) ────────────────────────────────── +// Staged wizard. A sub-project's folder MUST physically live inside +// its parent (services/subprojects.py validate() enforces strict +// containment), so re-parenting can never be one click. Order is +// unlink → [manual move] → verify → link → cleanup, with the unlink +// FIRST while the folder is still at its registered location: `sub +// remove` clears the child's .c3 back-link only when the folder +// exists there, and a stale back-link makes the new-parent add fail +// with "already a sub-project of " — which no hub endpoint can +// repair (config PUT refuses the `parent` section). The intermediate +// state is valid and reversible: top-level, still registered, .c3 +// intact; Undo re-links under the old parent until the folder moves. +// Unlink: POST /api/projects/subprojects/remove {parent, ref, mode:'unlink'} +// Verify: POST /api/projects/subprojects/validate {parent, folder} +// Link: POST /api/projects/subprojects/add {parent, folder, name} +// Cleanup: POST /api/projects/remove {path} — after a physical move, +// add_project appends a NEW registry row for the new path (rows are +// keyed by exact path, never migrated), so the old row must go. +function ReparentModal({ project, projects, onClose, onChanged }) { + const norm = (s) => String(s || '').replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); + const self = norm(project.path); + const oldParentPath = project.parent_path || ''; + const oldParentName = ((projects || []).find(c => norm(c.path) === norm(oldParentPath)) || {}).name || oldParentPath; + const folderName = (project.path || '').split(/[\\/]/).filter(Boolean).pop() || project.name; + + // Candidate new parents: registered, not this project, not the current + // parent, not themselves children (nesting depth > 1 is unsupported), + // not inside this project. `ready` = the folder is already physically + // inside the candidate, so no move is needed. + const candidates = (projects || []).filter(c => { + const cp = norm(c.path); + return cp && cp !== self && cp !== norm(oldParentPath) && !c.parent_path + && !cp.startsWith(self + '\\'); + }).map(c => { + const ready = self.startsWith(norm(c.path) + '\\'); + const sep = c.path.includes('\\') ? '\\' : '/'; + return { ...c, ready, destination: ready ? project.path : c.path.replace(/[\\/]+$/, '') + sep + folderName }; + }); + + const [target, setTarget] = useState(null); // chosen candidate (+ ready/destination) + const [phase, setPhase] = useState('choose'); // choose | run + const [steps, setSteps] = useState([]); // [{id, label, status, detail}] + const [outcome, setOutcome] = useState(null); // {kind: done|warn|stopped, message} + const [busy, setBusy] = useState(false); // a request is in flight + const mutatedRef = useRef(false); + + const setStep = (id, patch) => setSteps(s => s.map(st => (st.id === id ? { ...st, ...patch } : st))); + const stepById = (id) => steps.find(st => st.id === id); + // apiErr (cli/ui/api.js) digs result.error out of 500 bodies. + const finish = (kind, message) => { + setOutcome({ kind, message }); + if (mutatedRef.current) onChanged(); + }; + + const doUnlink = async () => { + setBusy(true); + setStep('unlink', { status: 'running', detail: '' }); + try { + const d = await api.post('/api/projects/subprojects/remove', + { parent: oldParentPath, ref: project.path, mode: 'unlink' }); + const res = (d && d.result) || {}; + if (!d.success || !res.removed) throw new Error(res.error || 'unlink failed'); + mutatedRef.current = true; + setStep('unlink', { status: 'ok', detail: 'Now top-level: still registered, .c3 intact.' }); + setBusy(false); + return true; + } catch (e) { + setStep('unlink', { status: 'fail', detail: apiErr(e) }); + finish('stopped', `Stopped at unlink. If nothing was removed, ${project.name} is still linked under ` + + `${oldParentName} exactly as before. If the failure looks partial, run "Reconcile links" on ` + + `${oldParentName}'s card to check and repair.`); + setBusy(false); + return false; + } + }; + + const doLink = async (folder) => { + setBusy(true); + setStep('link', { status: 'running', detail: 'Adopting the existing .c3 (no re-init)…' }); + try { + const d = await api.post('/api/projects/subprojects/add', + { parent: target.path, folder, name: project.name }); + const res = (d && d.result) || {}; + if (!d.success || !res.added) throw new Error(res.error || 'link failed'); + mutatedRef.current = true; + setStep('link', { status: 'ok', detail: `${res.adopted ? 'Adopted' : 'Linked'} under ${target.name}.` }); + setBusy(false); + return true; + } catch (e) { + setStep('link', { status: 'fail', detail: apiErr(e) }); + finish('stopped', target.ready + ? `Current state: ${project.name} is a TOP-LEVEL project — still registered, .c3 intact, not linked ` + + `under any parent. Nothing is lost. Retry the link below, or Undo to re-link under ${oldParentName}.` + : `Current state: the folder lives at ${target.destination} with its .c3 intact, but is NOT linked ` + + `under ${target.name}, and the hub registry still has the old-path row. Nothing is lost — retry the ` + + `link below, or finish later via "Designate sub-project…" on ${target.name}'s card.`); + setBusy(false); + return false; + } + }; + + // Old registry row: add_project never migrates it, so remove explicitly. + const doCleanup = async () => { + setBusy(true); + setStep('cleanup', { status: 'running', detail: '' }); + let ok = true; + try { + const d = await api.post('/api/projects/remove', { path: project.path }); + setStep('cleanup', { status: 'ok', detail: d.removed + ? `Removed the stale registry row for ${project.path}.` + : 'No stale row found — registry already clean.' }); + } catch (e) { + ok = false; + setStep('cleanup', { status: 'fail', detail: apiErr(e) }); + } + setBusy(false); + return ok; + }; + + const linkAndCleanup = async () => { + setOutcome(null); + if (!await doLink(target.destination)) return; + if (await doCleanup()) { + finish('done', `${project.name} is now a sub-project of ${target.name} at its new location.`); + } else { + finish('warn', `Re-parent complete, but the stale registry row for the old path (${project.path}) ` + + `could not be removed — remove that card from the hub manually.`); + } + }; + + const runReadyLink = async () => { + setOutcome(null); + if (await doLink(project.path)) { + finish('done', `${project.name} is now a sub-project of ${target.name}. No files moved.`); + } + }; + + // Verify the manual move — read-only until it passes; nothing further + // is touched while the folder is not confirmed at the destination. + const recheck = async () => { + setBusy(true); + setStep('move', { status: 'waiting', detail: 'Checking…' }); + try { + const v = await api.post('/api/projects/subprojects/validate', + { parent: target.path, folder: target.destination }); + if (v.ok) { + setStep('move', { status: 'ok', detail: `Verified — folder present and linkable at ${target.destination}.` }); + setBusy(false); + await linkAndCleanup(); + return; + } + if (!v.is_dir) { + setStep('move', { status: 'waiting', detail: 'Not there yet — no folder at the destination. Move it, then Re-check.' }); + } else if (v.already_child_of) { + setStep('move', { status: 'waiting', detail: + `The folder at the destination still back-links ${v.already_child_of} in its .c3/config.json ` + + `(it was moved before the unlink step could clear the link). Delete the "parent" key from that ` + + `file by hand, then Re-check. Nothing has been changed at the destination.` }); + } else { + setStep('move', { status: 'waiting', detail: + 'Validation failed: ' + ((v.warnings || []).join('; ') || 'unknown reason') + '. Fix and Re-check.' }); + } + } catch (e) { + setStep('move', { status: 'waiting', detail: 'Re-check failed: ' + apiErr(e) + '. Try again.' }); + } + setBusy(false); + }; + + // Undo — valid only while the folder is still at its old path. + const undoRelink = async () => { + setBusy(true); + try { + const d = await api.post('/api/projects/subprojects/add', + { parent: oldParentPath, folder: project.path, name: project.name }); + const res = (d && d.result) || {}; + if (!d.success || !res.added) throw new Error(res.error || 'relink failed'); + if (stepById('move')) setStep('move', { status: 'skip', detail: 'Cancelled — the folder was not moved.' }); + setStep('link', { status: 'skip', detail: '' }); + finish('stopped', `Undone: ${project.name} is linked under ${oldParentName} again, exactly as before.`); + } catch (e) { + notify('Undo failed: ' + apiErr(e), 'err'); + } + setBusy(false); + }; + + const start = async () => { + if (!target) return; + mutatedRef.current = false; + setOutcome(null); + setSteps([ + { id: 'unlink', label: `Unlink from ${oldParentName}`, status: 'pending', detail: '' }, + ...(target.ready ? [] : [{ id: 'move', label: 'Move the folder (manual)', status: 'pending', detail: '' }]), + { id: 'link', label: `Link under ${target.name}`, status: 'pending', detail: '' }, + ...(target.ready ? [] : [{ id: 'cleanup', label: 'Remove stale registry row (old path)', status: 'pending', detail: '' }]), + ]); + setPhase('run'); + if (!await doUnlink()) return; + if (target.ready) { + await runReadyLink(); + } else { + onChanged(); // hub now truthfully shows it as top-level while we wait + setStep('move', { status: 'waiting', detail: '' }); + } + }; + + const safeClose = () => { if (!busy) onClose(); }; + const linkFailed = (stepById('link') || {}).status === 'fail'; + const waitingMove = (stepById('move') || {}).status === 'waiting'; + const stStatusColor = (st) => + st === 'ok' ? T.accent : st === 'fail' ? T.error : st === 'running' || st === 'waiting' ? T.warn : T.textDim; + + return ( + + {project.path} +
+ Current parent: {oldParentName || '—'} +
+ + {!oldParentPath && ( +
+ This project has no parent — use "Make sub-project of…" to link it under one. +
+ )} + + {oldParentPath && phase === 'choose' && (candidates.length === 0 ? ( +
+ No eligible new parent. Candidates must be registered, top-level (not themselves + sub-projects), and not this project or its current parent. +
+ ) : ( + + New parent +
+ {candidates.map(c => { + const sel = target && target.path === c.path; + return ( +
setTarget(c)} style={{ + padding: '8px 10px', cursor: 'pointer', borderBottom: `1px solid ${T.border}40`, + background: sel ? T.accentDim : 'transparent', + }}> +
+ + + {c.name} + + {c.ready ? 'ready now' : 'move needed'} +
+
+ {c.ready ? 'already inside — no move required' : `requires: ${c.destination}`} +
+
+ ); + })} +
+ {target && ( +
+
Plan
+ {target.ready ? ( + +
1. Unlink from {oldParentName}   2. Link under {target.name}
+
The folder is already inside {target.name} — no files move.
+
+ ) : ( + +
1. Unlink from {oldParentName} (runs immediately)
+
2. You move the folder to the required path — the hub never moves files
+
3. Re-check verifies the destination, then links under {target.name}
+
4. Remove the stale registry row for the old path
+
+ The unlink must run before the move: clearing the child's back-link is only possible + while the folder is at its current path (a stale back-link would block the new link). + Between steps, {project.name} is simply a top-level registered project — nothing + breaks, and Undo can re-link it under {oldParentName} until the folder is moved. +
+
+ )} +
+ )} +
+ ))} + + {phase === 'run' && ( +
+ {steps.map(st => ( +
+
+ {st.status === 'ok' && } + {st.status === 'fail' && } + {st.status === 'waiting' && } + {st.status === 'running' && } + {(st.status === 'pending' || st.status === 'skip') && } + {st.label} + + {st.status === 'running' ? 'running…' : st.status === 'waiting' ? 'action needed' : st.status === 'skip' ? 'skipped' : st.status} + +
+ {st.detail && ( +
+ {st.detail} +
+ )} + {st.id === 'move' && st.status === 'waiting' && ( +
+
{project.path}
+
→ {target.destination}
+
+ Move the folder yourself (File Explorer / terminal — the hub does not move files), + then click Re-check. Until the folder is verified at the destination, nothing further + is touched — {project.name} stays a valid top-level project. You can also close this + wizard and finish later via "Designate sub-project…" on {target.name}'s card. +
+
+ {busy ? 'Checking…' : 'Re-check'} + Undo — re-link under {oldParentName} +
+
+ )} +
+ ))} +
+ )} + + {outcome && ( +
+ {outcome.message} +
+ )} + + + {phase === 'choose' && Cancel} + {phase === 'choose' && ( + Start re-parent + )} + {phase === 'run' && linkFailed && !waitingMove && ( + Retry link + )} + {phase === 'run' && linkFailed && target && target.ready && ( + Undo — re-link under {oldParentName} + )} + {phase === 'run' && Close} + +
+ ); +} + // ── IDE picker ───────────────────────────────────────────────── // POST /api/projects/launch-ide {path, ide, custom_cmd?} function IdePickerModal({ project, onClose, onChanged }) { @@ -313,7 +742,8 @@ function MergeModal({ project, projects, onClose, onChanged }) { // ── Folder picker (designate a sub-project) ──────────────────── // Browse: POST /api/projects/browse {path} // Validate: POST /api/projects/subprojects/validate {parent, folder} -// Designate: POST /api/projects/subprojects/add {parent, folder, name?} +// Designate: POST /api/projects/subprojects/add {parent, folder, name?, ide?} +// Navigation is fenced to the parent subtree (crumbs above it are inert). const _pathCrumbs = (full) => { if (!full) return []; const sep = full.includes('\\') ? '\\' : '/'; @@ -326,6 +756,16 @@ const _pathCrumbs = (full) => { }); }; +// Legal `c3 sub add --ide` values (cli/commands/parser.py); 'auto' = CLI default. +const _SUB_IDE_OPTIONS = [ + { id: 'auto', label: 'Auto-detect' }, + { id: 'claude', label: 'Claude Code' }, + { id: 'vscode', label: 'VS Code' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'codex', label: 'Codex' }, + { id: 'antigravity', label: 'Antigravity' }, +]; + function FolderPickerModal({ project, onClose, onChanged }) { const parentPath = (project && project.path) || ''; const [listing, setListing] = useState(null); @@ -334,9 +774,18 @@ function FolderPickerModal({ project, onClose, onChanged }) { const [validation, setValidation] = useState(null); const [validating, setValidating] = useState(false); const [name, setName] = useState(''); + const [ide, setIde] = useState('auto'); const [busy, setBusy] = useState(false); + // Fence: only the parent project's subtree is browsable. + const _norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + const inParent = (p) => { + const a = _norm(p), b = _norm(parentPath); + return !!b && (a === b || a.startsWith(b + '/')); + }; + const loadDir = async (path) => { + if (!inParent(path)) path = parentPath; // clamp any escape to the parent root setBrowsing(true); setSelected(null); setValidation(null); setName(''); try { @@ -370,6 +819,7 @@ function FolderPickerModal({ project, onClose, onChanged }) { const body = { parent: parentPath, folder: selected.path }; const nm = name.trim(); if (nm) body.name = nm; + if (ide && ide !== 'auto') body.ide = ide; const d = await api.post('/api/projects/subprojects/add', body); // long-running (full init) const res = (d && d.result) || {}; if (!d.success) throw new Error(res.error || 'Designation failed'); @@ -393,10 +843,14 @@ function FolderPickerModal({ project, onClose, onChanged }) { {crumbs.map((c, i) => ( {i > 0 && /} - loadDir(c.path)} style={{ - color: i === crumbs.length - 1 ? T.text : T.textMuted, - cursor: 'pointer', padding: '1px 3px', borderRadius: 4, - }}>{c.label} + {inParent(c.path) ? ( + loadDir(c.path)} style={{ + color: i === crumbs.length - 1 ? T.text : T.textMuted, + cursor: 'pointer', padding: '1px 3px', borderRadius: 4, + }}>{c.label} + ) : ( + {c.label} + )} ))}
@@ -442,22 +896,45 @@ function FolderPickerModal({ project, onClose, onChanged }) { {validating && (
Validating…
)} - {!validating && validation && (validation.warnings || []).map((w, i) => ( + {!validating && validation && (validation.warnings || []) + .filter(w => !(validation.ok && validation.has_c3 && w.includes('adopted'))) // superseded by the explicit adopt line below + .map((w, i) => (
{w}
))} + {!validating && validation && validation.ok && validation.has_c3 && ( +
+ + Existing .c3 will be adopted as-is (no re-init). +
+ )} + {!validating && validation && validation.ok && validation.registered && ( +
+ + Already registered in the hub — will be re-linked under this parent. +
+ )} {!validating && validation && validation.ok && (
Name (optional) setName(e.target.value)} placeholder={selected.name} style={mdlInputStyle()} /> + Instruction docs / IDE +
)}
)} + {!validating && validation && validation.ok && ( + + {validation.has_c3 ? 'Will adopt existing .c3' : 'Will initialize a new .c3'} + + )} Cancel {busy ? 'Designating…' : 'Designate'} diff --git a/cli/hub_ui/components/project_card.js b/cli/hub_ui/components/project_card.js index 16db263..95dd666 100644 --- a/cli/hub_ui/components/project_card.js +++ b/cli/hub_ui/components/project_card.js @@ -45,10 +45,50 @@ function CardMenuDivider() { } // Kebab menu: absolutely positioned dropdown, closes on outside click. -function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer }) { +// Typed-name confirm for the destructive clear-mode removal: unlink +// AND delete the child's .c3 / MCP config / instruction docs. +function DeinitDialog({ p, onConfirm, onCancel }) { + const [typed, setTyped] = useState(''); + const match = !!p.name && typed.trim() === p.name; + return ( +
+
e.stopPropagation()} className="fade-up" style={{ + background: T.surface, border: `1px solid ${T.error}60`, borderRadius: 10, + padding: 24, width: 440, maxWidth: '90vw', + }}> +
De-initialize Sub-project
+
+ Unlinks {p.name} from its parent AND deletes its + .c3 state, uninstalls its MCP config, removes its + instruction docs, and deregisters it from the hub. Source code is not touched. + This cannot be undone. +
+ setTyped(e.target.value)} + placeholder={`Type "${p.name}" to confirm`} + style={{ + width: '100%', boxSizing: 'border-box', padding: '8px 10px', borderRadius: 8, + border: `1px solid ${T.border}`, background: T.surfaceAlt, color: T.text, + fontSize: 13, outline: 'none', fontFamily: 'inherit', + }} /> +
+ Cancel + De-initialize +
+
+
+ ); +} + +function CardKebab({ p, isParent, isChild, rollup, onChanged, onOpenModal, onOpenDrawer }) { const [open, setOpen] = useState(false); const [cascadeOpen, setCascadeOpen] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false); + const [confirmDeinit, setConfirmDeinit] = useState(false); + const [cascadeParent, setCascadeParent] = useState(false); + const [reconcileInfo, setReconcileInfo] = useState(null); const ref = useRef(null); useEffect(() => { @@ -87,7 +127,7 @@ function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer { parent: p.parent_path, ref: p.path, mode: 'unlink' }); notify(d.success ? `${label}: ${p.name}` : `${label} failed`, d.success ? 'ok' : 'err'); if (d.success) onChanged(); - } catch (e) { notify(`${label}: ${e.message}`, 'err'); } + } catch (e) { notify(`${label}: ${apiErr(e)}`, 'err'); } }; const runCascade = async (op) => { @@ -95,10 +135,14 @@ function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer const label = op === 'update' ? 'Cascade update' : op === 'reindex' ? 'Cascade reindex' : 'Cascade health check'; let started; - try { started = await api.post('/api/projects/subprojects/cascade', { parent: p.path, op }); } + try { started = await api.post('/api/projects/subprojects/cascade', { parent: p.path, op, include_parent: cascadeParent }); } catch (e) { notify('Cascade: ' + e.message, 'err'); return; } const total = started.total || 0; - notifyProgress('cascade', { label, current: 0, total }); + const cancelCascade = async () => { + try { await api.post('/api/projects/subprojects/cascade/cancel'); notify('Cascade: cancelling…', 'warn'); } + catch (e) { notify('Cancel: ' + e.message, 'err'); } + }; + notifyProgress('cascade', { label, current: 0, total, onCancel: cancelCascade }); const poll = async (tries) => { let st; try { st = await api.get('/api/projects/subprojects/cascade/status'); } @@ -111,7 +155,8 @@ function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer notifyProgress('cascade', { label: st.current ? `${label} — ${st.current}` : label, current: results.length, total: st.total || total, - done: !!st.done, error: !!st.error, + done: !!st.done, error: !!st.error, cancelled: !!st.cancelled, + onCancel: st.done ? undefined : cancelCascade, }); if (st.done) { const ok = results.filter(r => r.success).length; @@ -125,6 +170,69 @@ function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer setTimeout(() => poll(0), 800); }; + // Reconcile: dry-run check first; repair only after an explicit confirm. + const runReconcile = async () => { + close(); + let d; + try { d = await api.post('/api/projects/subprojects/reconcile', { parent: p.path }); } + catch (e) { notify('Reconcile: ' + e.message, 'err'); return; } + const children = d.children || []; + const issues = children.filter(c => c.status !== 'ok'); + const orphans = d.orphans || []; + if (d.ok) { + notify(`Reconcile: all ${children.length} sub-project link${children.length === 1 ? '' : 's'} consistent`); + return; + } + setReconcileInfo({ issues, orphans }); + }; + + const reconcileMessage = (info) => { + const label = { + backlink_broken: 'broken back-link', + unregistered: 'unregistered', + missing_folder: 'missing folder (removed on repair)', + missing_c3: 'missing .c3 (not repairable here — re-designate or Cascade update)', + }; + const counts = {}; + info.issues.forEach(c => { counts[c.status] = (counts[c.status] || 0) + 1; }); + const parts = Object.entries(counts).map(([s, n]) => `${n} ${label[s] || s}`); + if (info.orphans.length) { + parts.push(`${info.orphans.length} registry orphan${info.orphans.length === 1 ? '' : 's'} (parent link cleared on repair)`); + } + return `Found ${parts.join('; ')}. Repair from the parent config (parent wins)?`; + }; + + const doReconcileFix = async () => { + const info = reconcileInfo; + setReconcileInfo(null); + const prune = info.issues.some(c => c.status === 'missing_folder'); + try { + const d = await api.post('/api/projects/subprojects/reconcile', + { parent: p.path, fix: true, prune }); + const fixed = (d.fixed || []).length; + const pruned = (d.pruned || []).length; + notify(`Reconcile: ${fixed} repaired${pruned ? `, ${pruned} pruned` : ''}${d.ok ? '' : ' — issues remain'}`, + d.ok ? 'ok' : 'warn'); + onChanged(); + } catch (e) { notify('Reconcile: ' + e.message, 'err'); } + }; + + const doDeinit = async () => { + setConfirmDeinit(false); + try { + const d = await api.post('/api/projects/subprojects/remove', + { parent: p.parent_path, ref: p.path, mode: 'clear' }); + const warns = ((d.result || {}).warnings) || []; + if (d.success && warns.length) { + notify(`Unlinked ${p.name}, but cleanup was partial: ${warns.join('; ')}`, 'warn'); + } else { + notify(d.success ? `De-initialized ${p.name} — .c3 removed, project deregistered` : 'De-initialize failed', + d.success ? 'ok' : 'err'); + } + if (d.success) onChanged(); + } catch (e) { notify('De-initialize: ' + apiErr(e), 'err'); } + }; + const doRemove = async () => { setConfirmRemove(false); try { @@ -165,18 +273,30 @@ function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer onClick={() => { close(); onOpenModal('transfer', p); }} /> { close(); onOpenModal('merge', p); }} /> - {isParent && ( + {!isChild && ( { close(); onOpenModal('folderPick', p); }} /> - setCascadeOpen(o => !o)} /> - {cascadeOpen && ( + {!isParent && ( + { close(); onOpenModal('makeSub', p); }} /> + )} + {isParent && ( - runCascade('update')} /> - runCascade('reindex')} /> - runCascade('health')} /> + setCascadeOpen(o => !o)} /> + {cascadeOpen && ( + + runCascade('update')} /> + runCascade('reindex')} /> + runCascade('health')} /> + setCascadeParent(o => !o)} /> + + )} + )} @@ -184,9 +304,12 @@ function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer {isChild && ( + { close(); onOpenModal('reparent', p); }} /> unlink('Promoted')} /> - unlink('Unlinked')} /> + { close(); setConfirmDeinit(true); }} /> )} @@ -196,14 +319,31 @@ function CardKebab({ p, isParent, isChild, onChanged, onOpenModal, onOpenDrawer )} {confirmRemove && ( 0 + ? ` Its ${rollup.count} sub-project${rollup.count === 1 ? '' : 's'} will become top-level.` + : '')} confirmLabel="Remove" danger onConfirm={doRemove} onCancel={() => setConfirmRemove(false)} /> )} + {reconcileInfo && ( + setReconcileInfo(null)} /> + )} + {confirmDeinit && ( + setConfirmDeinit(false)} /> + )}
); } +const SUB_LINK_LABEL = { + backlink_broken: 'broken back-link', unregistered: 'unregistered', + missing_folder: 'missing folder', missing_c3: 'missing .c3', orphan: 'orphan link', +}; + function ProjectCard({ p, isChild, rollup, expanded, onToggleExpand, onChanged, onOpenDrill, onOpenModal, onOpenDrawer, view, hubVersion, childRows }) { const [updating, setUpdating] = useState(false); const [starting, setStarting] = useState(false); @@ -321,6 +461,23 @@ function ProjectCard({ p, isChild, rollup, expanded, onToggleExpand, onChanged, 0 ? T.warn : T.textMuted}>{rollup.alerts} alerts )} + {(p.subproject_issues || 0) > 0 && ( + + {p.subproject_issues} link issue{p.subproject_issues === 1 ? '' : 's'} + + )} + {isChild && p.parent_name && ( + + ↳ {p.parent_name} + + )} + {isChild && p.link_status && p.link_status !== 'ok' && ( + + {SUB_LINK_LABEL[p.link_status] || p.link_status} + + )} ); @@ -347,7 +504,7 @@ function ProjectCard({ p, isChild, rollup, expanded, onToggleExpand, onChanged, )} - ); @@ -386,9 +543,16 @@ function ProjectCard({ p, isChild, rollup, expanded, onToggleExpand, onChanged, }}>{c.name} {(c.notification_count || 0) > 0 && 🔔 {c.notification_count}} {(c.open_task_count || 0) > 0 && ☑ {c.open_task_count}} + {c.link_status && c.link_status !== 'ok' && ( + + {SUB_LINK_LABEL[c.link_status] || c.link_status} + + )} {c.last_session ? timeAgo(c.last_session) : ''} +
))}
diff --git a/cli/hub_ui/components/toasts.js b/cli/hub_ui/components/toasts.js index 88372a9..83ece4e 100644 --- a/cli/hub_ui/components/toasts.js +++ b/cli/hub_ui/components/toasts.js @@ -8,7 +8,7 @@ const notify = (msg, kind = 'ok') => { }; // Progress toast: call repeatedly with the same id to update; done:true removes -// after a grace period. spec: {label, current, total, done, error} +// after a grace period. spec: {label, current, total, done, error, cancelled, onCancel} const notifyProgress = (id, spec) => { if (_toastDispatch) _toastDispatch({ type: 'progress', id, spec }); }; @@ -51,9 +51,16 @@ function ToastHost() {
{t.progress.label} - {t.progress.done ? (t.progress.error ? 'failed' : 'done') + {t.progress.done + ? (t.progress.error ? 'failed' : (t.progress.cancelled ? 'cancelled' : 'done')) : `${t.progress.current || 0}/${t.progress.total || '?'}`} + {!t.progress.done && t.progress.onCancel && ( + cancel + )}
diff --git a/cli/hub_ui/state.js b/cli/hub_ui/state.js index c1d03e8..f06cc3c 100644 --- a/cli/hub_ui/state.js +++ b/cli/hub_ui/state.js @@ -90,8 +90,12 @@ const buildProjectTree = (projects) => { projects.forEach(p => { const parentKey = (p.parent_path || '').toLowerCase(); if (parentKey && byPath[parentKey]) { + p.parent_name = byPath[parentKey].name || ''; (childrenOf[parentKey] = childrenOf[parentKey] || []).push(p); } else { + // Child rendered as a root (parent filtered out or unregistered): + // keep parent context visible on the card via a path-derived name. + if (parentKey) p.parent_name = (p.parent_path || '').split(/[\\/]/).filter(Boolean).pop() || ''; roots.push(p); } }); diff --git a/cli/ui/api.js b/cli/ui/api.js index 0143b17..0540edd 100644 --- a/cli/ui/api.js +++ b/cli/ui/api.js @@ -22,6 +22,14 @@ const parseApiResponse = async (r) => { if (json !== null) return json; return {}; }; +// Digs the real failure reason out of hub error bodies: sub-project +// endpoints fail as 500 {success, result:{error}, output} with no +// top-level `error`, so err.message alone is just "HTTP 500". +const apiErr = (e) => { + const p = (e && e.payload) || {}; + const r = p.result || {}; + return r.error || p.error || (e && e.message) || 'unknown error'; +}; const api = { get: async (path) => parseApiResponse(await fetch(`${API}${path}`)), post: async (path, body) => parseApiResponse(await fetch(`${API}${path}`, { diff --git a/pyproject.toml b/pyproject.toml index 091ddd6..5da8e1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.56.1" -description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Copilot, Cursor, Antigravity). Retrieve less, read less, edit safer — and version the configs that shape your agent (CLAUDE.md, skills, hooks, MCP)." +version = "2.57.0" +description = "Local code-intelligence layer for AI coding tools (Claude Code, Codex, Copilot, Cursor, Antigravity). Retrieve less, read less, edit safer — version the configs that shape your agent (CLAUDE.md, skills, hooks, MCP), and manage multi-project + sub-project hierarchies from a local hub." readme = "README.md" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/tests/test_hub_inspect_api.py b/tests/test_hub_inspect_api.py index 20fa071..1837461 100644 --- a/tests/test_hub_inspect_api.py +++ b/tests/test_hub_inspect_api.py @@ -198,6 +198,30 @@ def test_put_refuses_protected_keys_and_sections(self): "path": str(self.proj), "section": "bitbucket", "values": {"x": 1}}) self.assertEqual(resp.status_code, 400) + def test_put_federation_dict_allowed(self): + resp = self.client.put("/api/projects/config", json={ + "path": str(self.proj), "section": "hybrid", + "values": {"subprojects": {"memory_rollup": False, + "max_children_per_query": 4}}, + }) + self.assertEqual(resp.status_code, 200, resp.get_json()) + cfg = json.loads((self.proj / ".c3" / "config.json").read_text()) + self.assertIs(cfg["hybrid"]["subprojects"]["memory_rollup"], False) + self.assertEqual(cfg["hybrid"]["subprojects"]["max_children_per_query"], 4) + + def test_put_federation_dict_schema_enforced(self): + bad = ( + {"subprojects": {"unknown_key": 1}}, + {"subprojects": {"memory_rollup": "yes"}}, + {"subprojects": {"max_children_per_query": 0}}, + {"subprojects": {"max_children_per_query": True}}, + {"subprojects": {"parent": {"path": "x"}}}, + ) + for values in bad: + resp = self.client.put("/api/projects/config", json={ + "path": str(self.proj), "section": "hybrid", "values": values}) + self.assertEqual(resp.status_code, 400, values) + class TestHubConfigPrefs(unittest.TestCase): def test_new_keys_persist(self): diff --git a/tests/test_hub_subproject_links.py b/tests/test_hub_subproject_links.py new file mode 100644 index 0000000..bdad3f4 --- /dev/null +++ b/tests/test_hub_subproject_links.py @@ -0,0 +1,223 @@ +"""Hub /api/projects sub-project link annotation — _annotate_subproject_links. + +Covers the per-parent link-health rollup added to cli/hub_server.py: +- healthy three-way link -> no subproject_issues, child link_status == 'ok' +- broken/missing child back-link -> backlink_broken counted +- registry rows claiming a parent with no config entry -> orphan counted +- deleted child folder -> missing_folder counted +- endpoint-level: fields flow through GET /api/projects (Flask test client) + +Fixture conventions follow tests/test_subprojects.py (temp dirs, .c3 config +writing, pm_mod._PROJECTS_FILE/_REGISTRY_FILE patching) and +tests/test_hub_inspect_api.py (hub_server.app.test_client()). +""" +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from cli import hub_server # noqa: E402 +from services import project_manager as pm_mod # noqa: E402 + + +def _write_json(path: Path, data) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +class LinkBase(unittest.TestCase): + """Temp parent project + patched global registry, per the three-way link: + + 1. parent .c3/config.json -> subprojects[] entries (POSIX rel_path) + 2. child .c3/config.json -> parent{} back-link + 3. ~/.c3/projects.json row -> parent_path + """ + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.base = Path(self._tmp.name).resolve() + self.parent = self.base / "parent" + (self.parent / ".c3").mkdir(parents=True) + self._write_parent_cfg([]) + self.reg_file = self.base / "projects.json" + _write_json(self.reg_file, {"projects": []}) + self._patches = [ + mock.patch.object(pm_mod, "_PROJECTS_FILE", self.reg_file), + mock.patch.object(pm_mod, "_REGISTRY_FILE", self.base / "registry.json"), + ] + for p in self._patches: + p.start() + + def tearDown(self): + for p in self._patches: + p.stop() + self._tmp.cleanup() + + # ── fixture helpers ──────────────────────────────────────────── + + def _write_parent_cfg(self, entries: list) -> None: + _write_json(self.parent / ".c3" / "config.json", + {"meta": {"name": "parent"}, "subprojects": entries}) + + def _entry(self, rel: str = "child") -> dict: + return {"name": rel, "rel_path": rel, "added_at": "2026-07-24T00:00:00Z"} + + def _make_child(self, rel: str = "child", backlink: bool = True, + backlink_path: str = None) -> Path: + """Create the child folder + .c3 config, optionally with the back-link.""" + d = self.parent / rel + (d / ".c3").mkdir(parents=True) + cfg = {"meta": {"name": rel}} + if backlink: + cfg["parent"] = {"path": backlink_path or str(self.parent), + "name": "parent"} + _write_json(d / ".c3" / "config.json", cfg) + return d + + def _register(self, *rows: dict) -> None: + _write_json(self.reg_file, {"projects": list(rows)}) + + def _parent_reg_row(self) -> dict: + return {"name": "parent", "path": str(self.parent), "ide": "claude-code"} + + def _child_reg_row(self, rel: str = "child") -> dict: + return {"name": rel, "path": str(self.parent / rel), "ide": "claude-code", + "parent_path": str(self.parent)} + + +class TestAnnotateHelper(LinkBase): + """Direct calls to hub_server._annotate_subproject_links().""" + + def _rows_for_helper(self, *child_rels: str) -> tuple: + """(projects_list, parent_row, {rel: child_row}) mimicking list_projects rows.""" + parent_row = {"name": "parent", "path": str(self.parent), "is_parent": True} + child_rows = {} + projects = [parent_row] + for rel in child_rels: + row = {"name": rel, "path": str(self.parent / rel), + "parent_path": str(self.parent)} + child_rows[rel] = row + projects.append(row) + return projects, parent_row, child_rows + + def test_healthy_link_no_issues_child_ok(self): + self._write_parent_cfg([self._entry("child")]) + self._make_child("child") + self._register(self._parent_reg_row(), self._child_reg_row("child")) + projects, parent_row, child_rows = self._rows_for_helper("child") + + hub_server._annotate_subproject_links(projects) + + self.assertNotIn("subproject_issues", parent_row) + self.assertEqual(child_rows["child"]["link_status"], "ok") + + def test_backlink_missing_counts_issue(self): + self._write_parent_cfg([self._entry("child")]) + self._make_child("child", backlink=False) + self._register(self._parent_reg_row(), self._child_reg_row("child")) + projects, parent_row, child_rows = self._rows_for_helper("child") + + hub_server._annotate_subproject_links(projects) + + self.assertEqual(parent_row["subproject_issues"], 1) + self.assertEqual(child_rows["child"]["link_status"], "backlink_broken") + + def test_backlink_pointing_elsewhere_counts_issue(self): + self._write_parent_cfg([self._entry("child")]) + elsewhere = self.base / "not-the-parent" + elsewhere.mkdir() + self._make_child("child", backlink_path=str(elsewhere)) + self._register(self._parent_reg_row(), self._child_reg_row("child")) + projects, parent_row, child_rows = self._rows_for_helper("child") + + hub_server._annotate_subproject_links(projects) + + self.assertEqual(parent_row["subproject_issues"], 1) + self.assertEqual(child_rows["child"]["link_status"], "backlink_broken") + + def test_registry_orphan_counts_issue(self): + # Healthy config child + a registry row claiming this parent with NO + # matching parent-config entry -> orphan, same rule as reconcile. + self._write_parent_cfg([self._entry("child")]) + self._make_child("child") + self._register(self._parent_reg_row(), self._child_reg_row("child"), + self._child_reg_row("stray")) + projects, parent_row, child_rows = self._rows_for_helper("child", "stray") + + hub_server._annotate_subproject_links(projects) + + self.assertEqual(parent_row["subproject_issues"], 1) + self.assertEqual(child_rows["stray"]["link_status"], "orphan") + self.assertEqual(child_rows["child"]["link_status"], "ok") + + def test_missing_folder_counts_issue(self): + # Config entry exists but the child folder was deleted. + self._write_parent_cfg([self._entry("ghost")]) + self._register(self._parent_reg_row(), self._child_reg_row("ghost")) + projects, parent_row, child_rows = self._rows_for_helper("ghost") + + hub_server._annotate_subproject_links(projects) + + self.assertEqual(parent_row["subproject_issues"], 1) + self.assertEqual(child_rows["ghost"]["link_status"], "missing_folder") + + def test_non_parent_rows_untouched(self): + # Rows without is_parent/subproject_count are skipped entirely. + self._write_parent_cfg([self._entry("child")]) + self._make_child("child", backlink=False) + self._register(self._parent_reg_row(), self._child_reg_row("child")) + plain = {"name": "parent", "path": str(self.parent)} # no is_parent + projects = [plain] + + hub_server._annotate_subproject_links(projects) + + self.assertNotIn("subproject_issues", plain) + + +class TestProjectsEndpoint(LinkBase): + """GET /api/projects surfaces link_status + subproject_issues.""" + + def setUp(self): + super().setUp() + self.client = hub_server.app.test_client() + + def _get_rows(self) -> dict: + resp = self.client.get("/api/projects") + self.assertEqual(resp.status_code, 200) + rows = resp.get_json() + self.assertIsInstance(rows, list) + return {r["name"]: r for r in rows} + + def test_healthy_link_through_endpoint(self): + self._write_parent_cfg([self._entry("child")]) + self._make_child("child") + self._register(self._parent_reg_row(), self._child_reg_row("child")) + + by_name = self._get_rows() + + parent = by_name["parent"] + child = by_name["child"] + self.assertTrue(parent["is_parent"]) + self.assertNotIn("subproject_issues", parent) + self.assertEqual(child["link_status"], "ok") + self.assertEqual(Path(child["parent_path"]).resolve(), + self.parent.resolve()) + + def test_broken_backlink_through_endpoint(self): + self._write_parent_cfg([self._entry("child")]) + self._make_child("child", backlink=False) + self._register(self._parent_reg_row(), self._child_reg_row("child")) + + by_name = self._get_rows() + + self.assertEqual(by_name["parent"]["subproject_issues"], 1) + self.assertEqual(by_name["child"]["link_status"], "backlink_broken") + + +if __name__ == "__main__": + unittest.main()