setSearch(e.target.value)}
- className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all"
+ className={`w-full bg-white/[0.04] border rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:bg-white/[0.06] transition-all ${
+ invalidRegex ? "border-amber-500/40" : "border-white/[0.06] focus:border-primary/40"
+ }`}
/>
+ {invalidRegex && (
+
+ invalid regex — matching as literal text
+
+ )}
-
+
{filtered ? (
filtered.length === 0 ? (
@@ -140,23 +178,30 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
{t.common.noMatches}
) : (
- filtered.map((n) => (
-
- ))
+ <>
+ {filtered.map((n) => (
+
+ ))}
+ {truncated && (
+
+ showing first {SEARCH_CAP} matches — refine the search
+
+ )}
+ >
)
) : (
topLevel.map((c) =>
)
)}
-
+
{selectedPath && (
diff --git a/graph-ui/src/hooks/useGraphData.ts b/graph-ui/src/hooks/useGraphData.ts
index 5705555fa..aa5f14fcc 100644
--- a/graph-ui/src/hooks/useGraphData.ts
+++ b/graph-ui/src/hooks/useGraphData.ts
@@ -9,7 +9,7 @@ interface UseGraphDataResult {
fetchDetail: (project: string, centerNode: string) => void;
}
-export const GRAPH_RENDER_NODE_LIMIT = 2000;
+export const GRAPH_RENDER_NODE_LIMIT = 50000;
export async function fetchLayout(
project: string,
diff --git a/graph-ui/src/lib/colors.ts b/graph-ui/src/lib/colors.ts
index 4fb3e8bcb..fb32c8639 100644
--- a/graph-ui/src/lib/colors.ts
+++ b/graph-ui/src/lib/colors.ts
@@ -20,6 +20,37 @@ export function colorForLabel(label: string): string {
return LABEL_COLORS[label] ?? DEFAULT_COLOR;
}
+/* Dead-code status → color (matches layout3d.c status strings).
+ * dead 🔴 zero callers + zero usages, not entry/test/exported
+ * single 🟠 exactly one caller
+ * entry 🔵 entry points / routes
+ * test 🟣 test code
+ * normal 🟢 healthy (>=2 callers)
+ * exported/structural → dimmed grey (not dead-code candidates) */
+const STATUS_COLORS: Record = {
+ dead: "#ef4444",
+ single: "#f97316",
+ entry: "#3b82f6",
+ test: "#a855f7",
+ normal: "#22c55e",
+ exported: "#475569",
+ structural: "#334155",
+};
+
+const STATUS_DEFAULT = "#334155";
+
+export function colorForStatus(status?: string): string {
+ return status ? STATUS_COLORS[status] ?? STATUS_DEFAULT : STATUS_DEFAULT;
+}
+
+export const STATUS_LEGEND: { status: string; label: string; color: string }[] = [
+ { status: "dead", label: "Dead (0 callers)", color: STATUS_COLORS.dead },
+ { status: "single", label: "One caller", color: STATUS_COLORS.single },
+ { status: "entry", label: "Entry / route", color: STATUS_COLORS.entry },
+ { status: "test", label: "Test", color: STATUS_COLORS.test },
+ { status: "normal", label: "Normal", color: STATUS_COLORS.normal },
+];
+
/* Stellar spectral type legend (for the graph view) */
export const STELLAR_LEGEND = [
{ type: "O (Blue Giant)", color: "#80a0ff", description: "50+ connections" },
diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts
index 0286a35b5..45c2ed592 100644
--- a/graph-ui/src/lib/types.ts
+++ b/graph-ui/src/lib/types.ts
@@ -8,10 +8,34 @@ export interface GraphNode {
label: string;
name: string;
file_path?: string;
+ qualified_name?: string;
+ start_line?: number;
+ end_line?: number;
size: number;
color: string;
+ /* Dead-code classification from the backend layout (layout3d.c). */
+ status?: NodeStatus;
+ in_calls?: number;
}
+/* Git remote metadata for building GitHub deep-links (/api/repo-info). */
+export interface RepoInfo {
+ root_path: string;
+ branch: string;
+ remote_url: string;
+ web_base: string; /* https://github.com/org/repo */
+ blob_base: string; /* https://github.com/org/repo/blob/ */
+}
+
+export type NodeStatus =
+ | "dead"
+ | "single"
+ | "entry"
+ | "test"
+ | "exported"
+ | "normal"
+ | "structural";
+
export interface GraphEdge {
source: number;
target: number;
diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c
index af4fda31e..843072b67 100644
--- a/internal/cbm/cbm.c
+++ b/internal/cbm/cbm.c
@@ -20,7 +20,8 @@
#if defined(CBM_BIND_TS_ALLOCATOR) && CBM_BIND_TS_ALLOCATOR
#include "sqlite3.h" // sqlite3_mem_methods, sqlite3_config, SQLITE_CONFIG_MALLOC — bind sqlite to mimalloc
#if defined(HAVE_LIBGIT2)
-#include // git_allocator, git_libgit2_opts, GIT_OPT_SET_ALLOCATOR — bind libgit2 to mimalloc
+#include // git_libgit2_opts, GIT_OPT_SET_ALLOCATOR — bind libgit2 to mimalloc
+#include // git_allocator (not pulled by the git2.h umbrella header)
#endif
#endif
#include // uint32_t, uint64_t, int64_t
diff --git a/src/ui/http_server.c b/src/ui/http_server.c
index c12685ef9..494de69aa 100644
--- a/src/ui/http_server.c
+++ b/src/ui/http_server.c
@@ -20,6 +20,11 @@
#include "mcp/mcp.h"
#include "store/store.h"
#include "cli/cli.h"
+#include "git/git_context.h"
+
+#if defined(HAVE_LIBGIT2)
+#include
+#endif
/* pipeline.h no longer needed — indexing runs as subprocess */
#include "foundation/log.h"
#include "foundation/platform.h"
@@ -109,6 +114,137 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\"}", lang_buf);
}
+/* Defined further below (after the server-state section). */
+static void db_path_for_project(const char *project, char *buf, size_t bufsz);
+
+/* Normalize a git remote URL (scp-style, ssh://, https://) to a canonical
+ * "https://host/org/repo" web base with any trailing ".git" removed. Returns a
+ * malloc'd string or NULL if the shape isn't recognized. Caller frees. */
+static char *normalize_git_remote_https(const char *url) {
+ if (!url || !url[0])
+ return NULL;
+ char host_path[1024] = {0}; /* "host/org/repo" */
+ if (strncmp(url, "git@", 4) == 0) {
+ const char *at = url + 4;
+ const char *colon = strchr(at, ':');
+ if (!colon)
+ return NULL;
+ snprintf(host_path, sizeof(host_path), "%.*s/%s", (int)(colon - at), at, colon + 1);
+ } else {
+ const char *p = strstr(url, "://");
+ if (!p)
+ return NULL;
+ p += 3;
+ const char *at = strchr(p, '@'); /* strip any embedded credentials */
+ if (at)
+ p = at + 1;
+ snprintf(host_path, sizeof(host_path), "%s", p);
+ }
+ size_t l = strlen(host_path);
+ if (l > 4 && strcmp(host_path + l - 4, ".git") == 0)
+ host_path[l - 4] = '\0';
+ l = strlen(host_path);
+ if (l > 0 && host_path[l - 1] == '/')
+ host_path[l - 1] = '\0';
+ char *out = malloc(strlen(host_path) + 9);
+ if (!out)
+ return NULL;
+ sprintf(out, "https://%s", host_path);
+ return out;
+}
+
+/* Read the "origin" remote URL for the repo at root_path. malloc'd or NULL. */
+static char *git_origin_remote_url(const char *root_path) {
+#if defined(HAVE_LIBGIT2)
+ git_libgit2_init();
+ git_repository *repo = NULL;
+ char *out = NULL;
+ if (git_repository_open(&repo, root_path) == 0) {
+ git_remote *rem = NULL;
+ if (git_remote_lookup(&rem, repo, "origin") == 0) {
+ const char *u = git_remote_url(rem);
+ if (u)
+ out = strdup(u);
+ git_remote_free(rem);
+ }
+ git_repository_free(repo);
+ }
+ git_libgit2_shutdown();
+ return out;
+#else
+ (void)root_path;
+ return NULL;
+#endif
+}
+
+/* GET /api/repo-info?project=NAME → { root_path, branch, remote_url, web_base,
+ * blob_base }. blob_base is "/blob/" ready for the frontend to
+ * append "/#L-L". Fields are empty strings when unknown
+ * (e.g. no git remote). */
+static void handle_repo_info(cbm_http_conn_t *c, const cbm_http_req_t *req) {
+ char project[256] = {0};
+ if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) ||
+ project[0] == '\0') {
+ cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}");
+ return;
+ }
+
+ char db_path[1024];
+ db_path_for_project(project, db_path, sizeof(db_path));
+ if (!cbm_file_exists(db_path)) {
+ cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
+ return;
+ }
+ cbm_store_t *store = cbm_store_open_path(db_path);
+ if (!store) {
+ cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
+ return;
+ }
+
+ char root_path[1024] = {0};
+ cbm_project_t proj;
+ memset(&proj, 0, sizeof(proj));
+ if (cbm_store_get_project(store, project, &proj) == CBM_STORE_OK && proj.root_path) {
+ snprintf(root_path, sizeof(root_path), "%s", proj.root_path);
+ }
+ cbm_project_free_fields(&proj);
+ cbm_store_close(store);
+
+ char branch[256] = {0};
+ if (root_path[0]) {
+ cbm_git_context_t gctx;
+ memset(&gctx, 0, sizeof(gctx));
+ if (cbm_git_context_resolve(root_path, &gctx) == 0 && gctx.branch) {
+ snprintf(branch, sizeof(branch), "%s", gctx.branch);
+ }
+ cbm_git_context_free(&gctx);
+ }
+
+ char *remote = root_path[0] ? git_origin_remote_url(root_path) : NULL;
+ char *web_base = normalize_git_remote_https(remote);
+
+ char blob_base[1152] = {0};
+ if (web_base && web_base[0] && branch[0]) {
+ snprintf(blob_base, sizeof(blob_base), "%s/blob/%s", web_base, branch);
+ }
+
+ /* JSON-escape the free-form fields. */
+ char esc_root[2048], esc_branch[512], esc_remote[2048], esc_web[2048], esc_blob[2304];
+ cbm_json_escape(esc_root, (int)sizeof(esc_root), root_path);
+ cbm_json_escape(esc_branch, (int)sizeof(esc_branch), branch);
+ cbm_json_escape(esc_remote, (int)sizeof(esc_remote), remote ? remote : "");
+ cbm_json_escape(esc_web, (int)sizeof(esc_web), web_base ? web_base : "");
+ cbm_json_escape(esc_blob, (int)sizeof(esc_blob), blob_base);
+
+ cbm_http_replyf(c, 200, g_cors_json,
+ "{\"root_path\":\"%s\",\"branch\":\"%s\",\"remote_url\":\"%s\","
+ "\"web_base\":\"%s\",\"blob_base\":\"%s\"}",
+ esc_root, esc_branch, esc_remote, esc_web, esc_blob);
+
+ free(remote);
+ free(web_base);
+}
+
/* ── Server state ─────────────────────────────────────────────── */
struct cbm_http_server {
@@ -1395,6 +1531,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
return;
}
+ /* GET /api/repo-info → git remote / branch for GitHub deep-links */
+ if (is_get && cbm_http_path_match(req->path, "/api/repo-info*")) {
+ handle_repo_info(c, req);
+ return;
+ }
+
/* POST /api/index → start background indexing */
if (is_post && cbm_http_path_match(req->path, "/api/index")) {
handle_index_start(c, req);
diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c
index 8e54f48a7..1d1fda7aa 100644
--- a/src/ui/layout3d.c
+++ b/src/ui/layout3d.c
@@ -24,8 +24,8 @@
/* ── Constants ────────────────────────────────────────────────── */
-#define DEFAULT_MAX_NODES 2000
-#define HARD_MAX_NODES 10000
+#define DEFAULT_MAX_NODES 50000
+#define HARD_MAX_NODES 50000
#define BH_THETA 1.2f
/* Local optimization: gentle, preserves structure */
@@ -35,6 +35,63 @@
#define LOCAL_ITERATIONS 40
#define Z_DEPTH_SPACING 50.0f /* gentle z-layering per call depth */
+/* cbm_store_batch_count_degrees builds a bound "?,?,..." IN clause into a fixed
+ * 4KB buffer (~2045 placeholders max) but binds every id passed — so calling it
+ * with more ids than fit silently drops the tail (their degree stays 0, which
+ * here would masquerade as dead code). Feed it in safe-sized chunks. */
+#define DEAD_DEGREE_CHUNK 500
+
+/* ── Dead-code node-flag parsing ──────────────────────────────── */
+
+typedef struct {
+ bool is_entry;
+ bool is_test;
+ bool is_exported;
+ bool is_route;
+} node_flags_t;
+
+/* Truthy across the representations properties_json may use (JSON bool, the
+ * integer 1 sqlite/json_extract emits, or a "true"/"1" string). */
+static bool json_truthy(yyjson_val *v) {
+ if (!v)
+ return false;
+ if (yyjson_is_bool(v))
+ return yyjson_get_bool(v);
+ if (yyjson_is_int(v))
+ return yyjson_get_int(v) != 0;
+ if (yyjson_is_uint(v))
+ return yyjson_get_uint(v) != 0;
+ if (yyjson_is_real(v))
+ return yyjson_get_real(v) != 0.0;
+ if (yyjson_is_str(v)) {
+ const char *s = yyjson_get_str(v);
+ return s && s[0] && strcmp(s, "0") != 0 && strcmp(s, "false") != 0;
+ }
+ return false;
+}
+
+static node_flags_t parse_node_flags(const char *props_json) {
+ node_flags_t f = {false, false, false, false};
+ if (!props_json || !props_json[0])
+ return f;
+ yyjson_doc *d = yyjson_read(props_json, strlen(props_json), 0);
+ if (!d)
+ return f;
+ yyjson_val *root = yyjson_doc_get_root(d);
+ if (root && yyjson_is_obj(root)) {
+ f.is_entry = json_truthy(yyjson_obj_get(root, "is_entry_point"));
+ f.is_test = json_truthy(yyjson_obj_get(root, "is_test"));
+ f.is_exported = json_truthy(yyjson_obj_get(root, "is_exported"));
+ yyjson_val *rp = yyjson_obj_get(root, "route_path");
+ if (rp && yyjson_is_str(rp)) {
+ const char *s = yyjson_get_str(rp);
+ f.is_route = s && s[0];
+ }
+ }
+ yyjson_doc_free(d);
+ return f;
+}
+
/* ── Node colors/sizes ────────────────────────────────────────── */
/* Stellar spectral type colors — maps node degree to star color.
@@ -549,6 +606,27 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
result->node_count = n;
result->total_nodes = total_count;
+ /* True full-graph incoming degree for dead-code classification. This MUST
+ * come from the store, not the sampled `mapped` edges built above: that set
+ * drops any edge whose other endpoint falls outside the rendered
+ * <=max_nodes window, which would falsely mark a sampled-in function as
+ * having zero callers. */
+ int64_t *node_ids = malloc((size_t)n * sizeof(int64_t));
+ int *in_calls = calloc((size_t)n, sizeof(int));
+ int *in_usage = calloc((size_t)n, sizeof(int));
+ int *deg_dummy = calloc((size_t)n, sizeof(int));
+ if (node_ids && in_calls && in_usage && deg_dummy) {
+ for (int i = 0; i < n; i++)
+ node_ids[i] = search_out.results[i].node.id;
+ for (int off = 0; off < n; off += DEAD_DEGREE_CHUNK) {
+ int cnt = (n - off < DEAD_DEGREE_CHUNK) ? (n - off) : DEAD_DEGREE_CHUNK;
+ cbm_store_batch_count_degrees(store, node_ids + off, cnt, "CALLS", in_calls + off,
+ deg_dummy + off);
+ cbm_store_batch_count_degrees(store, node_ids + off, cnt, "USAGE", in_usage + off,
+ deg_dummy + off);
+ }
+ }
+
for (int i = 0; i < n; i++) {
const cbm_node_t *sn = &search_out.results[i].node;
const char *fp = sn->file_path ? sn->file_path : "";
@@ -591,11 +669,40 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
result->nodes[i].name = sn->name ? strdup(sn->name) : NULL;
result->nodes[i].qualified_name = sn->qualified_name ? strdup(sn->qualified_name) : NULL;
result->nodes[i].file_path = sn->file_path ? strdup(sn->file_path) : NULL;
+ result->nodes[i].start_line = sn->start_line;
+ result->nodes[i].end_line = sn->end_line;
result->nodes[i].color = stellar_color(deg[i]);
/* Size: base from label + boost from degree (hubs are bigger stars) */
float base_size = size_for_label(sn->label);
float deg_boost = (deg[i] > 5) ? fminf((float)deg[i] * 0.3f, 10.0f) : 0;
result->nodes[i].size = base_size + deg_boost;
+
+ /* Dead-code classification. Only Function/Method are candidates; other
+ * labels are structural. Default to non-dead (1) if the batch degree
+ * query failed, so a query error never masquerades as dead code. */
+ node_flags_t nf = parse_node_flags(sn->properties_json);
+ bool is_fn =
+ sn->label && (strcmp(sn->label, "Function") == 0 || strcmp(sn->label, "Method") == 0);
+ bool testish = nf.is_test || (sn->file_path && cbm_is_test_file_path(sn->file_path));
+ int ic = in_calls ? in_calls[i] : 1;
+ int iu = in_usage ? in_usage[i] : 1;
+ const char *status;
+ if (!is_fn)
+ status = "structural";
+ else if (testish)
+ status = "test";
+ else if (nf.is_entry || nf.is_route)
+ status = "entry";
+ else if (nf.is_exported)
+ status = "exported";
+ else if (ic == 0 && iu == 0)
+ status = "dead";
+ else if (ic == 1)
+ status = "single";
+ else
+ status = "normal";
+ result->nodes[i].in_calls = ic;
+ result->nodes[i].status = status;
}
/* 6. Gentle local optimization (anchor-preserving) */
@@ -624,6 +731,10 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
free(es);
free(ed);
free(cdepth);
+ free(node_ids);
+ free(in_calls);
+ free(in_usage);
+ free(deg_dummy);
free_edge_array(all_edges, mapped);
cbm_store_search_free(&search_out);
return result;
@@ -668,11 +779,20 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) {
yyjson_mut_obj_add_str(doc, nd, "name", r->nodes[i].name);
if (r->nodes[i].file_path)
yyjson_mut_obj_add_str(doc, nd, "file_path", r->nodes[i].file_path);
+ if (r->nodes[i].qualified_name)
+ yyjson_mut_obj_add_str(doc, nd, "qualified_name", r->nodes[i].qualified_name);
+ if (r->nodes[i].start_line > 0)
+ yyjson_mut_obj_add_int(doc, nd, "start_line", r->nodes[i].start_line);
+ if (r->nodes[i].end_line > 0)
+ yyjson_mut_obj_add_int(doc, nd, "end_line", r->nodes[i].end_line);
double nsz = isfinite(r->nodes[i].size) ? (double)r->nodes[i].size : 1.0;
yyjson_mut_obj_add_real(doc, nd, "size", nsz);
char hex[CBM_SZ_8];
snprintf(hex, sizeof(hex), "#%06x", r->nodes[i].color);
yyjson_mut_obj_add_strcpy(doc, nd, "color", hex);
+ yyjson_mut_obj_add_int(doc, nd, "in_calls", r->nodes[i].in_calls);
+ if (r->nodes[i].status)
+ yyjson_mut_obj_add_str(doc, nd, "status", r->nodes[i].status);
yyjson_mut_arr_append(na, nd);
}
yyjson_mut_obj_add_val(doc, root, "nodes", na);
diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h
index a939d6996..1c2e1a83a 100644
--- a/src/ui/layout3d.h
+++ b/src/ui/layout3d.h
@@ -22,8 +22,14 @@ typedef struct {
const char *name; /* display name */
const char *qualified_name;
const char *file_path; /* relative file path for tree reconstruction */
+ int start_line; /* 1-based source range (for code snippet / GitHub link) */
+ int end_line;
float size; /* visual size */
uint32_t color; /* 0xRRGGBB */
+ int in_calls; /* incoming CALLS-family degree (full graph, not sampled) */
+ /* Dead-code classification (string literal, NOT freed):
+ * "dead"|"single"|"entry"|"test"|"exported"|"normal"|"structural". */
+ const char *status;
} cbm_layout_node_t;
/* ── Layout edge (output) ─────────────────────────────────────── */