feat(ui): add i18n and index picker UX

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-06-28 21:21:32 +02:00
parent d9d70656b4
commit d5414111b3
21 changed files with 717 additions and 97 deletions
+15 -12
View File
@@ -3,16 +3,17 @@ import { GraphTab } from "./components/GraphTab";
import { StatsTab } from "./components/StatsTab";
import { ControlTab } from "./components/ControlTab";
import type { TabId } from "./lib/types";
const TABS: { id: TabId; label: string }[] = [
{ id: "graph", label: "Graph" },
{ id: "stats", label: "Projects" },
{ id: "control", label: "Control" },
];
import { useUiMessages } from "./lib/i18n";
export function App() {
const t = useUiMessages();
const [activeTab, setActiveTab] = useState<TabId>("stats");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const tabs: { id: TabId; label: string }[] = [
{ id: "graph", label: t.tabs.graph },
{ id: "stats", label: t.tabs.projects },
{ id: "control", label: t.tabs.control },
];
return (
<div className="h-screen flex flex-col bg-background text-foreground">
@@ -28,17 +29,17 @@ export function App() {
{/* Tabs inline in header */}
<nav className="flex items-center gap-0.5">
{TABS.map((t) => (
{tabs.map((tab) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
activeTab === t.id
activeTab === tab.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
}`}
>
{t.label}
{tab.label}
</button>
))}
</nav>
@@ -46,7 +47,9 @@ export function App() {
{selectedProject && (
<div className="flex items-center gap-2 px-3 py-1 rounded-lg bg-white/[0.04] border border-border/30">
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">Graph</span>
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">
{t.graph.selectedLabel}
</span>
<span className="text-[11px] text-primary font-mono truncate max-w-[300px]">
{selectedProject}
</span>
+19 -15
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ProcessInfo } from "../lib/types";
import { useUiMessages } from "../lib/i18n";
/* ── Gauge component ────────────────────────────────────── */
@@ -30,6 +31,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
proc: ProcessInfo; selected: boolean;
onSelect: () => void; onKill: () => void;
}) {
const t = useUiMessages();
return (
<button
onClick={onSelect}
@@ -46,7 +48,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
PID {proc.pid}
</span>
{proc.is_self && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">THIS</span>
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">{t.control.thisProcess}</span>
)}
</div>
{!proc.is_self && (
@@ -54,7 +56,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
onClick={(e) => { e.stopPropagation(); onKill(); }}
className="px-2 py-1 rounded-lg text-[10px] text-foreground/20 hover:text-destructive hover:bg-destructive/10 transition-all"
>
Kill
{t.control.kill}
</button>
)}
</div>
@@ -69,7 +71,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.rss_mb.toFixed(0)} MB</p>
</div>
<div>
<p className="text-[9px] text-foreground/20 uppercase">Uptime</p>
<p className="text-[9px] text-foreground/20 uppercase">{t.control.uptime}</p>
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.elapsed}</p>
</div>
</div>
@@ -82,6 +84,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
/* ── Log viewer ─────────────────────────────────────────── */
function LogViewer() {
const t = useUiMessages();
const [lines, setLines] = useState<string[]>([]);
useEffect(() => {
@@ -100,13 +103,13 @@ function LogViewer() {
return (
<div className="rounded-xl border border-border/30 bg-black/30 overflow-hidden">
<div className="px-4 py-2 border-b border-border/20">
<span className="text-[11px] font-medium text-foreground/40">Process Logs</span>
<span className="text-[11px] font-medium text-foreground/40">{t.control.processLogs}</span>
<span className="text-[10px] text-foreground/15 ml-2">{lines.length} lines</span>
</div>
<ScrollArea className="h-[400px]">
<div className="p-3 font-mono text-[10px] leading-relaxed">
{lines.length === 0 ? (
<p className="text-foreground/15 text-center py-8">No logs yet</p>
<p className="text-foreground/15 text-center py-8">{t.control.noLogs}</p>
) : (
lines.map((line, i) => {
const isErr = line.includes("level=error");
@@ -132,6 +135,7 @@ function LogViewer() {
/* ── Main Control Tab ───────────────────────────────────── */
export function ControlTab() {
const t = useUiMessages();
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [selfMetrics, setSelfMetrics] = useState({ rss_mb: 0, user_cpu: 0, sys_cpu: 0 });
const [selectedPid, setSelectedPid] = useState<number | null>(null);
@@ -156,7 +160,7 @@ export function ControlTab() {
}, [fetchProcesses]);
const killProcess = useCallback(async (pid: number) => {
if (!confirm(`Kill process ${pid}?`)) return;
if (!confirm(t.control.killConfirm(pid))) return;
try {
await fetch("/api/process-kill", {
method: "POST",
@@ -165,7 +169,7 @@ export function ControlTab() {
});
setTimeout(fetchProcesses, 1000);
} catch { /* ignore */ }
}, [fetchProcesses]);
}, [fetchProcesses, t.control]);
/* Aggregates */
const totalCpu = processes.reduce((s, p) => s + p.cpu, 0);
@@ -174,32 +178,32 @@ export function ControlTab() {
return (
<ScrollArea className="h-full">
<div className="p-8 max-w-4xl mx-auto">
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">Control Panel</h2>
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">{t.control.panel}</h2>
{/* Aggregate gauges */}
<div className="flex gap-4 mb-8">
<Gauge label="Total CPU" value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
<Gauge label="Total RAM" value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
<Gauge label="Processes" value={processes.length} max={10} unit="" color="text-primary" />
<Gauge label="Self RAM" value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
<Gauge label={t.control.totalCpu} value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
<Gauge label={t.control.totalRam} value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
<Gauge label={t.control.processes} value={processes.length} max={10} unit="" color="text-primary" />
<Gauge label={t.control.selfRam} value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
</div>
{/* Process grid */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-medium text-foreground/50">
Active Processes
{t.control.activeProcesses}
</h3>
<button
onClick={fetchProcesses}
className="text-[11px] text-primary/60 hover:text-primary transition-colors"
>
Refresh
{t.common.refresh}
</button>
</div>
{processes.length === 0 ? (
<p className="text-foreground/20 text-[12px] text-center py-8">No processes found</p>
<p className="text-foreground/20 text-[12px] text-center py-8">{t.control.noProcesses}</p>
) : (
<div className="grid grid-cols-2 gap-3">
{processes.map((p) => (
+7 -3
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { GraphNode } from "../lib/types";
import { useUiMessages } from "../lib/i18n";
interface SidebarProps {
nodes: GraphNode[];
@@ -105,6 +106,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
}
export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
const t = useUiMessages();
const [search, setSearch] = useState("");
const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]);
@@ -122,7 +124,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
<div className="relative">
<input
type="text"
placeholder="Search..."
placeholder={t.graph.search}
value={search}
onChange={(e) => 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"
@@ -134,7 +136,9 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
<div className="py-1">
{filtered ? (
filtered.length === 0 ? (
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">No matches</p>
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">
{t.common.noMatches}
</p>
) : (
filtered.map((n) => (
<button
@@ -160,7 +164,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
onClick={() => onSelectPath("", new Set())}
className="w-full px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/40 font-medium transition-all"
>
Clear selection
{t.graph.clearSelection}
</button>
</div>
)}
+95
View File
@@ -0,0 +1,95 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StatsTab } from "./StatsTab";
function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response | undefined) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const overridden = extra?.(url, init);
if (overridden) return overridden;
if (url === "/rpc") {
return new Response(JSON.stringify({
result: { content: [{ text: JSON.stringify({ projects: [] }) }] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/ui-config")) {
return new Response(JSON.stringify({ lang: "en" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.startsWith("/api/browse")) {
return new Response(JSON.stringify({
path: "/home/dev",
parent: "/home",
dirs: ["alpha", "beta"],
roots: ["/", "D:/"],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/index") {
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("StatsTab index modal", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("submits a custom path and project name", async () => {
let submitted: unknown = null;
mockProjectsFetch((url, init) => {
if (url === "/api/index") {
submitted = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
return undefined;
});
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
fireEvent.change(await screen.findByLabelText("Repository path"), {
target: { value: "D:\\work\\信租风控通后端" },
});
fireEvent.change(screen.getByLabelText("Project name"), {
target: { value: "信租风控通后端" },
});
fireEvent.click(screen.getByRole("button", { name: "Index This Folder" }));
await waitFor(() => {
expect(submitted).toEqual({
root_path: "D:\\work\\信租风控通后端",
project_name: "信租风控通后端",
});
});
});
it("filters picker rows and exposes quick row indexing", async () => {
mockProjectsFetch();
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
fireEvent.change(await screen.findByPlaceholderText("Filter folders"), {
target: { value: "bet" },
});
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Index beta" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument();
});
});
+151 -44
View File
@@ -1,7 +1,8 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useProjects } from "../hooks/useProjects";
import { colorForLabel } from "../lib/colors";
import { useUiMessages } from "../lib/i18n";
interface StatsTabProps {
onSelectProject: (project: string) => void;
@@ -10,6 +11,7 @@ interface StatsTabProps {
/* ── Glowy health dot ───────────────────────────────────── */
function HealthDot({ name }: { name: string }) {
const t = useUiMessages();
const [status, setStatus] = useState<"loading" | "healthy" | "corrupt" | "missing">("loading");
const [info, setInfo] = useState("");
@@ -34,9 +36,9 @@ function HealthDot({ name }: { name: string }) {
status === "corrupt" ? "#f87171" : "#555";
const label =
status === "healthy" ? "Database healthy" :
status === "missing" ? "Database missing" :
status === "corrupt" ? "Database unhealthy" : "Checking...";
status === "healthy" ? t.projects.healthHealthy :
status === "missing" ? t.projects.healthMissing :
status === "corrupt" ? t.projects.healthCorrupt : t.projects.healthChecking;
return (
<div className="group relative inline-flex items-center">
@@ -64,6 +66,7 @@ function HealthDot({ name }: { name: string }) {
/* ── ADR button + modal ─────────────────────────────────── */
function AdrButton({ project }: { project: string }) {
const t = useUiMessages();
const [hasAdr, setHasAdr] = useState<boolean | null>(null);
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
@@ -117,13 +120,13 @@ function AdrButton({ project }: { project: string }) {
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl p-6 w-full max-w-2xl shadow-2xl max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-[15px] font-semibold text-foreground/90">Architecture Decision Record</h3>
<h3 className="text-[15px] font-semibold text-foreground/90">{t.adr.title}</h3>
<p className="text-[11px] text-foreground/30 font-mono mt-0.5">{project}</p>
</div>
<button onClick={() => setOpen(false)} className="text-foreground/20 hover:text-foreground/50 text-[16px] p-1">×</button>
</div>
{updatedAt && (
<p className="text-[10px] text-foreground/20 mb-3">Last updated: {updatedAt}</p>
<p className="text-[10px] text-foreground/20 mb-3">{t.adr.lastUpdated}: {updatedAt}</p>
)}
<textarea
value={content}
@@ -139,12 +142,12 @@ function AdrButton({ project }: { project: string }) {
}}
className="px-3 py-2 rounded-lg text-[12px] text-destructive/60 hover:text-destructive hover:bg-destructive/10 font-medium transition-all"
>
Delete
{t.common.delete}
</button>
)}
<button onClick={() => setOpen(false)} className="px-4 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">Cancel</button>
<button onClick={() => setOpen(false)} className="px-4 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
<button onClick={save} disabled={saving} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{saving ? "Saving..." : "Save"}
{saving ? t.common.saving : t.common.save}
</button>
</div>
</div>
@@ -156,16 +159,30 @@ function AdrButton({ project }: { project: string }) {
/* ── Create Index Modal ─────────────────────────────────── */
function joinPath(base: string, dir: string): string {
if (!base || base === "/") return `/${dir}`;
if (/^[A-Za-z]:[\\/]?$/.test(base)) return `${base[0]}:/${dir}`;
const slash = base.includes("\\") && !base.includes("/") ? "\\" : "/";
return `${base.replace(/[\\/]+$/, "")}${slash}${dir}`;
}
function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const t = useUiMessages();
const [currentPath, setCurrentPath] = useState("");
const [dirs, setDirs] = useState<string[]>([]);
const [roots, setRoots] = useState<string[]>(["/"]);
const [parentPath, setParentPath] = useState("");
const [projectName, setProjectName] = useState("");
const [filter, setFilter] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const filterRef = useRef<HTMLInputElement>(null);
const browse = useCallback(async (path?: string) => {
setLoading(true);
setError(null);
try {
const q = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/browse${q}`);
@@ -173,18 +190,30 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
if (data.error) throw new Error(data.error);
setCurrentPath(data.path ?? "");
setDirs((data.dirs ?? []).sort());
setRoots(data.roots ?? ["/"]);
setParentPath(data.parent ?? "/");
} catch (e) { setError(e instanceof Error ? e.message : "Browse failed"); }
finally { setLoading(false); }
}, []);
useEffect(() => { browse(); }, [browse]);
useEffect(() => { filterRef.current?.focus(); }, []);
const submit = async () => {
if (!currentPath) return;
const filteredDirs = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return dirs;
return dirs.filter((d) => d.toLowerCase().includes(q));
}, [dirs, filter]);
useEffect(() => { setActiveIndex(0); }, [filter, currentPath]);
const submit = async (path = currentPath) => {
if (!path) return;
setSubmitting(true); setError(null);
try {
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root_path: currentPath }) });
const body: { root_path: string; project_name?: string } = { root_path: path };
if (projectName.trim()) body.project_name = projectName.trim();
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed");
onCreated(); onClose();
@@ -192,17 +221,78 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
finally { setSubmitting(false); }
};
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, Math.max(filteredDirs.length - 1, 0)));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && filteredDirs.length > 0) {
e.preventDefault();
const dir = filteredDirs.length === 1 ? filteredDirs[0] : filteredDirs[activeIndex];
if (filteredDirs.length === 1) void submit(joinPath(currentPath, dir));
else void browse(joinPath(currentPath, dir));
}
};
/* Breadcrumb segments */
const segments = currentPath.split("/").filter(Boolean);
const displayPath = currentPath.replace(/\\/g, "/");
const segments = displayPath.split("/").filter(Boolean);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl w-full max-w-lg shadow-2xl flex flex-col overflow-hidden" style={{ height: "min(70vh, 550px)" }} onClick={(e) => e.stopPropagation()}>
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl w-full max-w-2xl shadow-2xl flex flex-col overflow-hidden" style={{ height: "min(82vh, 680px)" }} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="px-5 pt-5 pb-3 shrink-0">
<h3 className="text-[15px] font-semibold text-foreground/90 mb-1">Select Repository Folder</h3>
<p className="text-[12px] text-foreground/30">Navigate to the project root and click "Index This Folder".</p>
<h3 className="text-[15px] font-semibold text-foreground/90 mb-1">{t.index.selectRepositoryFolder}</h3>
<p className="text-[12px] text-foreground/30">{t.index.instructions}</p>
</div>
<div className="px-5 pb-3 grid grid-cols-[1fr_220px] gap-3 shrink-0">
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.repositoryPath}</span>
<input
aria-label={t.index.repositoryPath}
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground font-mono outline-none focus:border-primary/40"
/>
</label>
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.projectName}</span>
<input
aria-label={t.index.projectName}
value={projectName}
placeholder={t.index.projectNamePlaceholder}
onChange={(e) => setProjectName(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
</label>
</div>
<div className="px-5 pb-3 flex items-center gap-2 shrink-0">
<input
ref={filterRef}
value={filter}
placeholder={t.index.filterFolders}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={onFilterKeyDown}
className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
<div className="flex items-center gap-1">
{roots.map((root) => (
<button
key={root}
aria-label={t.index.browseRoot(root)}
onClick={() => browse(root)}
className="px-2.5 py-2 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/45 font-mono transition-all"
>
{root}
</button>
))}
</div>
</div>
{/* Breadcrumb */}
@@ -235,19 +325,34 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
</button>
)}
{loading ? (
<p className="text-foreground/20 text-[12px] text-center py-8">Loading...</p>
) : dirs.length === 0 ? (
<p className="text-foreground/15 text-[12px] text-center py-8">No subdirectories</p>
<p className="text-foreground/20 text-[12px] text-center py-8">{t.common.loading}</p>
) : filteredDirs.length === 0 ? (
<p className="text-foreground/15 text-[12px] text-center py-8">{t.index.noSubdirectories}</p>
) : (
dirs.map((d) => (
<button
filteredDirs.map((d, i) => (
<div
key={d}
onClick={() => browse(`${currentPath}/${d}`)}
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-lg hover:bg-white/[0.04] text-[12px] text-foreground/60 transition-colors group"
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-[12px] transition-colors group ${
i === activeIndex ? "bg-white/[0.05]" : "hover:bg-white/[0.04]"
}`}
>
<span className="text-foreground/20 group-hover:text-foreground/40">📁</span>
<span className="truncate">{d}</span>
</button>
<button
aria-label={t.index.browseRoot(d)}
onClick={() => browse(joinPath(currentPath, d))}
className="flex min-w-0 flex-1 items-center gap-2 text-left text-foreground/60"
>
<span className="text-foreground/20 group-hover:text-foreground/40">/</span>
<span className="truncate">{d}</span>
</button>
<button
aria-label={t.index.indexDirectory(d)}
onClick={() => submit(joinPath(currentPath, d))}
disabled={submitting}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 px-2 py-1 rounded-md bg-primary/15 hover:bg-primary/25 text-primary text-[10px] font-medium transition-all disabled:opacity-30"
>
{t.index.indexThisFolder}
</button>
</div>
))
)}
</div>
@@ -259,9 +364,9 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
<div className="flex items-center justify-between">
<p className="text-[11px] text-foreground/25 font-mono truncate max-w-[250px]">{currentPath}</p>
<div className="flex gap-2 shrink-0">
<button onClick={onClose} className="px-3 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">Cancel</button>
<button onClick={submit} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{submitting ? "Starting..." : "Index This Folder"}
<button onClick={onClose} className="px-3 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
<button onClick={() => submit()} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{submitting ? t.index.starting : t.index.indexThisFolder}
</button>
</div>
</div>
@@ -274,6 +379,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
/* ── Index Progress ─────────────────────────────────────── */
function IndexProgress({ onDone }: { onDone: () => void }) {
const t = useUiMessages();
const [jobs, setJobs] = useState<{ slot: number; status: string; path: string }[]>([]);
useEffect(() => {
const poll = setInterval(async () => {
@@ -293,7 +399,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
<div key={j.slot} className="flex items-center gap-3">
<div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin shrink-0" />
<div>
<p className="text-[12px] text-primary font-medium">Indexing in progress</p>
<p className="text-[12px] text-primary font-medium">{t.projects.indexingInProgress}</p>
<p className="text-[11px] text-foreground/30 font-mono">{j.path}</p>
</div>
</div>
@@ -305,6 +411,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
/* ── Main Stats Tab ─────────────────────────────────────── */
export function StatsTab({ onSelectProject }: StatsTabProps) {
const t = useUiMessages();
const { projects, loading, error, refresh } = useProjects();
const [showModal, setShowModal] = useState(false);
const [indexing, setIndexing] = useState(false);
@@ -319,9 +426,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
}, [projects]);
const deleteProject = useCallback(async (name: string) => {
if (!confirm(`Delete index for "${name}"?`)) return;
if (!confirm(t.projects.deleteConfirm(name))) return;
try { await fetch(`/api/project?name=${encodeURIComponent(name)}`, { method: "DELETE" }); refresh(); } catch { /* */ }
}, [refresh]);
}, [refresh, t.projects]);
return (
<ScrollArea className="h-full">
@@ -329,9 +436,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{projects.length > 0 && (
<div className="flex gap-4 mb-8">
{[
{ label: "Projects", value: aggregate.projects, color: "text-primary" },
{ label: "Nodes", value: aggregate.nodes, color: "text-foreground/80" },
{ label: "Edges", value: aggregate.edges, color: "text-foreground/80" },
{ label: t.tabs.projects, value: aggregate.projects, color: "text-primary" },
{ label: t.projects.nodes, value: aggregate.nodes, color: "text-foreground/80" },
{ label: t.projects.edges, value: aggregate.edges, color: "text-foreground/80" },
].map((s) => (
<div key={s.label} className="flex-1 rounded-xl border border-border/30 bg-white/[0.02] p-4">
<p className="text-[10px] text-foreground/25 uppercase tracking-widest mb-1">{s.label}</p>
@@ -344,10 +451,10 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{indexing && <IndexProgress onDone={() => { setIndexing(false); refresh(); }} />}
<div className="flex items-center justify-between mb-6">
<h2 className="text-[15px] font-semibold text-foreground/80">Indexed Projects</h2>
<h2 className="text-[15px] font-semibold text-foreground/80">{t.projects.indexedProjects}</h2>
<div className="flex items-center gap-2">
<button onClick={() => setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ New Index</button>
<button onClick={refresh} disabled={loading} className="px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[12px] text-foreground/40 font-medium transition-all disabled:opacity-30">{loading ? "..." : "Refresh"}</button>
<button onClick={() => setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ {t.index.newIndex}</button>
<button onClick={refresh} disabled={loading} className="px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[12px] text-foreground/40 font-medium transition-all disabled:opacity-30">{loading ? "..." : t.common.refresh}</button>
</div>
</div>
@@ -355,8 +462,8 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{!loading && projects.length === 0 && !error && (
<div className="text-center py-20">
<p className="text-foreground/25 text-[13px] mb-2">No indexed projects</p>
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">Index your first repository</button>
<p className="text-foreground/25 text-[13px] mb-2">{t.projects.noIndexedProjects}</p>
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.indexFirstRepository}</button>
</div>
)}
@@ -376,15 +483,15 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
</div>
<div className="flex items-center gap-1.5 shrink-0">
<AdrButton project={p.project.name} />
<button onClick={() => onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">View Graph</button>
<button onClick={() => deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title="Delete index"></button>
<button onClick={() => onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.viewGraph}</button>
<button onClick={() => deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title={t.projects.deleteTitle}></button>
</div>
</div>
{p.schema && (
<>
<div className="flex gap-6 text-[12px] text-foreground/30 mb-3">
<span><strong className="text-foreground/55 tabular-nums">{totalNodes.toLocaleString()}</strong> nodes</span>
<span><strong className="text-foreground/55 tabular-nums">{totalEdges.toLocaleString()}</strong> edges</span>
<span><strong className="text-foreground/55 tabular-nums">{totalNodes.toLocaleString()}</strong> {t.projects.nodes}</span>
<span><strong className="text-foreground/55 tabular-nums">{totalEdges.toLocaleString()}</strong> {t.projects.edges}</span>
</div>
<div className="flex flex-wrap gap-1">
{p.schema.node_labels?.map((l) => (
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { detectLanguage, messages } from "./i18n";
describe("i18n", () => {
it("detects Chinese from Accept-Language and falls back to English", () => {
expect(detectLanguage("zh-CN,zh;q=0.9,en;q=0.8")).toBe("zh");
expect(detectLanguage("de-DE,de;q=0.9")).toBe("en");
});
it("keeps UI chrome messages in the catalog", () => {
expect(messages.zh.tabs.projects).toBe("项目");
expect(messages.zh.index.newIndex).toBe("新建索引");
expect(messages.en.index.repositoryPath).toBe("Repository path");
});
});
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
export type UiLanguage = "en" | "zh";
export const messages = {
en: {
tabs: {
graph: "Graph",
projects: "Projects",
control: "Control",
},
common: {
cancel: "Cancel",
refresh: "Refresh",
loading: "Loading...",
save: "Save",
saving: "Saving...",
delete: "Delete",
noMatches: "No matches",
},
graph: {
selectedLabel: "Graph",
search: "Search...",
clearSelection: "Clear selection",
},
projects: {
indexedProjects: "Indexed Projects",
noIndexedProjects: "No indexed projects",
indexFirstRepository: "Index your first repository",
viewGraph: "View Graph",
nodes: "nodes",
edges: "edges",
deleteTitle: "Delete index",
deleteConfirm: (name: string) => `Delete index for "${name}"?`,
healthHealthy: "Database healthy",
healthMissing: "Database missing",
healthCorrupt: "Database unhealthy",
healthChecking: "Checking...",
indexingInProgress: "Indexing in progress",
},
index: {
newIndex: "New Index",
selectRepositoryFolder: "Select Repository Folder",
instructions: "Navigate to the project root and click \"Index This Folder\".",
repositoryPath: "Repository path",
projectName: "Project name",
projectNamePlaceholder: "Optional display name",
filterFolders: "Filter folders",
noSubdirectories: "No subdirectories",
indexThisFolder: "Index This Folder",
starting: "Starting...",
browseRoot: (path: string) => `Browse ${path}`,
indexDirectory: (name: string) => `Index ${name}`,
},
adr: {
title: "Architecture Decision Record",
lastUpdated: "Last updated",
},
control: {
panel: "Control Panel",
totalCpu: "Total CPU",
totalRam: "Total RAM",
processes: "Processes",
selfRam: "Self RAM",
activeProcesses: "Active Processes",
processLogs: "Process Logs",
noProcesses: "No processes found",
noLogs: "No logs yet",
kill: "Kill",
thisProcess: "THIS",
uptime: "Uptime",
killConfirm: (pid: number) => `Kill process ${pid}?`,
},
},
zh: {
tabs: {
graph: "图谱",
projects: "项目",
control: "控制",
},
common: {
cancel: "取消",
refresh: "刷新",
loading: "加载中...",
save: "保存",
saving: "保存中...",
delete: "删除",
noMatches: "无匹配结果",
},
graph: {
selectedLabel: "图谱",
search: "搜索...",
clearSelection: "清除选择",
},
projects: {
indexedProjects: "已索引项目",
noIndexedProjects: "暂无已索引项目",
indexFirstRepository: "索引第一个仓库",
viewGraph: "查看图谱",
nodes: "节点",
edges: "边",
deleteTitle: "删除索引",
deleteConfirm: (name: string) => `删除 "${name}" 的索引?`,
healthHealthy: "数据库正常",
healthMissing: "数据库缺失",
healthCorrupt: "数据库异常",
healthChecking: "检查中...",
indexingInProgress: "正在索引",
},
index: {
newIndex: "新建索引",
selectRepositoryFolder: "选择仓库目录",
instructions: "导航到项目根目录,然后点击“索引此目录”。",
repositoryPath: "仓库路径",
projectName: "项目名称",
projectNamePlaceholder: "可选显示名称",
filterFolders: "筛选目录",
noSubdirectories: "没有子目录",
indexThisFolder: "索引此目录",
starting: "启动中...",
browseRoot: (path: string) => `浏览 ${path}`,
indexDirectory: (name: string) => `索引 ${name}`,
},
adr: {
title: "架构决策记录",
lastUpdated: "最后更新",
},
control: {
panel: "控制面板",
totalCpu: "总 CPU",
totalRam: "总内存",
processes: "进程",
selfRam: "自身内存",
activeProcesses: "活动进程",
processLogs: "进程日志",
noProcesses: "未找到进程",
noLogs: "暂无日志",
kill: "结束",
thisProcess: "本进程",
uptime: "运行时间",
killConfirm: (pid: number) => `结束进程 ${pid}`,
},
},
} as const;
export type UiMessages = (typeof messages)[UiLanguage];
export function detectLanguage(acceptLanguage?: string | null, override?: string | null): UiLanguage {
if (override === "zh" || override === "en") return override;
if (!acceptLanguage) return "en";
const normalized = acceptLanguage.toLowerCase();
return normalized.includes("zh-cn") || normalized.includes("zh") ? "zh" : "en";
}
let cachedLanguage: UiLanguage = "en";
let languageLoaded = false;
let languageRequest: Promise<UiLanguage> | null = null;
const languageListeners = new Set<(lang: UiLanguage) => void>();
function loadUiLanguage(): Promise<UiLanguage> {
if (languageLoaded) return Promise.resolve(cachedLanguage);
if (languageRequest) return languageRequest;
languageRequest = fetch("/api/ui-config")
.then((r) => r.json())
.then((data) => detectLanguage(null, data?.lang))
.catch(() => detectLanguage(navigator.language))
.then((lang) => {
cachedLanguage = lang;
languageLoaded = true;
for (const listener of languageListeners) listener(lang);
return lang;
})
.finally(() => {
languageRequest = null;
});
return languageRequest;
}
export function useUiMessages(): UiMessages {
const [lang, setLang] = useState<UiLanguage>(cachedLanguage);
useEffect(() => {
let cancelled = false;
languageListeners.add(setLang);
void loadUiLanguage().then((nextLang) => {
if (!cancelled) setLang(nextLang);
});
return () => {
cancelled = true;
languageListeners.delete(setLang);
};
}, []);
return messages[lang];
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
+4
View File
@@ -2625,6 +2625,8 @@ int cbm_cmd_config(int argc, char **argv) {
"Enable auto-indexing on MCP session start");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_INDEX_LIMIT, "50000",
"Max files for auto-indexing new projects");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto",
"Pin graph UI language: en, zh, or auto");
return 0;
}
@@ -2650,6 +2652,8 @@ int cbm_cmd_config(int argc, char **argv) {
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX, "false"));
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_INDEX_LIMIT,
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000"));
printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG,
cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto"));
} else if (strcmp(argv[0], "get") == 0) {
if (argc < MIN_ARGC_GET) {
(void)fprintf(stderr, "Usage: config get <key>\n");
+1
View File
@@ -264,6 +264,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
/* Well-known config keys */
#define CBM_CONFIG_AUTO_INDEX "auto_index"
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
#define CBM_CONFIG_UI_LANG "ui-lang"
/* ── Subcommands (wired from main.c) ─────────────────────────── */
+8 -3
View File
@@ -283,10 +283,15 @@ bool cbm_validate_project_name(const char *name) {
/* Reject leading dot (hidden files / relative refs) */
if (name[0] == '.')
return false;
/* Allow only alphanumeric, dash, underscore, dot */
/* Allow alphanumeric, dash, underscore, dot, and UTF-8 bytes. Reject
* ASCII controls and punctuation that can affect paths or shells. */
for (const char *p = name; *p; p++) {
if (!(((*p >= 'a') && (*p <= 'z')) || ((*p >= 'A') && (*p <= 'Z')) ||
((*p >= '0') && (*p <= '9')) || *p == '-' || *p == '_' || *p == '.')) {
unsigned char c = (unsigned char)*p;
if (c >= 0x80) {
continue;
}
if (!(((c >= 'a') && (c <= 'z')) || ((c >= 'A') && (c <= 'Z')) ||
((c >= '0') && (c <= '9')) || c == '-' || c == '_' || c == '.')) {
return false;
}
}
+13
View File
@@ -325,6 +325,8 @@ static const tool_def_t TOOLS[] = {
"\"target_projects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},"
"\"description\":\"Projects to search for cross-repo links (cross-repo-intelligence mode). "
"Use [\\\"*\\\"] for all indexed projects. Run list_projects to see available projects.\"},"
"\"name\":{\"type\":\"string\",\"description\":"
"\"Override the derived project name. Unicode is preserved and unsafe path characters are normalized.\"},"
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
"\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. "
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
@@ -2919,15 +2921,18 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *
static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
char *repo_path = cbm_mcp_get_string_arg(args, "repo_path");
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
char *name_override = cbm_mcp_get_string_arg(args, "name");
cbm_normalize_path_sep(repo_path);
if (!repo_path) {
free(mode_str);
free(name_override);
return cbm_mcp_text_result("repo_path is required", true);
}
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
free(mode_str);
free(name_override);
char *result = handle_cross_repo_mode(repo_path, args);
free(repo_path);
return result;
@@ -2945,9 +2950,17 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
cbm_pipeline_t *p = cbm_pipeline_new(repo_path, NULL, mode);
if (!p) {
free(name_override);
free(repo_path);
return cbm_mcp_text_result("failed to create pipeline", true);
}
if (name_override && name_override[0] && !cbm_pipeline_set_project_name(p, name_override)) {
cbm_pipeline_free(p);
free(name_override);
free(repo_path);
return cbm_mcp_text_result("invalid project name", true);
}
free(name_override);
cbm_pipeline_set_persistence(p, persistence);
char *project_name = heap_strdup(cbm_pipeline_project_name(p));
+2 -2
View File
@@ -340,8 +340,8 @@ char *cbm_project_name_from_path(const char *abs_path) {
* the space and reports project-not-found (#349). */
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)path[i];
bool safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
bool safe = (c >= 0x80) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
if (!safe) {
path[i] = '-';
}
+22
View File
@@ -27,6 +27,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P
#include "foundation/platform.h"
#include "foundation/compat_fs.h"
#include "foundation/log.h"
#include "foundation/str_util.h"
#include "foundation/hash_table.h"
#include "foundation/compat.h"
#include "foundation/compat_thread.h"
@@ -175,6 +176,27 @@ void cbm_pipeline_set_persistence(cbm_pipeline_t *p, bool enabled) {
}
}
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name) {
if (!p || !name || !name[0]) {
return false;
}
char *normalized = cbm_project_name_from_path(name);
if (!normalized) {
return false;
}
if (!cbm_validate_project_name(normalized)) {
free(normalized);
return false;
}
free(p->project_name);
p->project_name = normalized;
free(p->branch_qn);
p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx);
return true;
}
void cbm_pipeline_free(cbm_pipeline_t *p) {
if (!p) {
return;
+3
View File
@@ -63,6 +63,9 @@ void cbm_pipeline_cancel(cbm_pipeline_t *p);
* owned by the pipeline. Valid until cbm_pipeline_free(). */
const char *cbm_pipeline_project_name(const cbm_pipeline_t *p);
/* Override the derived project name with a sanitized user-provided label. */
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name);
/* Get the index mode (CBM_MODE_FULL, CBM_MODE_MODERATE, CBM_MODE_FAST). */
int cbm_pipeline_get_mode(const cbm_pipeline_t *p);
+72 -15
View File
@@ -19,6 +19,7 @@
#include "ui/layout3d.h"
#include "mcp/mcp.h"
#include "store/store.h"
#include "cli/cli.h"
/* pipeline.h no longer needed — indexing runs as subprocess */
#include "foundation/log.h"
#include "foundation/platform.h"
@@ -81,6 +82,33 @@ static void update_cors(const cbm_http_req_t *req) {
snprintf(g_cors_json, sizeof(g_cors_json), "%sContent-Type: application/json\r\n", g_cors);
}
static const char *detect_ui_lang(const char *accept_language) {
if (accept_language && (strstr(accept_language, "zh-CN") || strstr(accept_language, "zh"))) {
return "zh";
}
return "en";
}
static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
const char *lang = NULL;
char cache_dir[1024];
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
cbm_config_t *cfg = cbm_config_open(cache_dir);
if (cfg) {
const char *pinned = cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto");
if (strcmp(pinned, "zh") == 0 || strcmp(pinned, "en") == 0) {
lang = pinned;
}
}
char lang_buf[8];
snprintf(lang_buf, sizeof(lang_buf), "%s", lang ? lang : detect_ui_lang(req->accept_language));
if (cfg) {
cbm_config_close(cfg);
}
cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\"}", lang_buf);
}
/* ── Server state ─────────────────────────────────────────────── */
struct cbm_http_server {
@@ -398,6 +426,26 @@ static void handle_process_kill(cbm_http_conn_t *c, const cbm_http_req_t *req) {
#include <dirent.h>
static void append_roots_json(char *buf, size_t bufsz, int *pos) {
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, ",\"roots\":[");
#ifdef _WIN32
DWORD drives = GetLogicalDrives();
int count = 0;
for (int i = 0; i < 26; i++) {
if (!(drives & (1u << i))) {
continue;
}
if (count++ > 0) {
buf[(*pos)++] = ',';
}
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"%c:/\"", 'A' + i);
}
#else
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"/\"");
#endif
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "]");
}
/* GET /api/browse?path=/some/dir — list subdirectories for file picker */
static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char path[1024] = {0};
@@ -469,7 +517,9 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
{
char esc_parent[2048];
cbm_json_escape(esc_parent, (int)sizeof(esc_parent), parent);
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"}", esc_parent);
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"", esc_parent);
append_roots_json(buf, sizeof(buf), &pos);
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "}");
}
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
}
@@ -703,21 +753,18 @@ static void *index_thread_fn(void *arg) {
char log_file[256];
/* JSON-escape root_path to prevent injection via double-quotes or backslashes */
/* JSON-escape root_path and optional project name. */
char escaped_path[2048];
{
const char *s = job->root_path;
size_t j = 0;
for (; *s && j < sizeof(escaped_path) - 2; s++) {
if (*s == '"' || *s == '\\') {
escaped_path[j++] = '\\';
}
escaped_path[j++] = *s;
}
escaped_path[j] = '\0';
}
cbm_json_escape(escaped_path, (int)sizeof(escaped_path), job->root_path);
char escaped_name[512];
cbm_json_escape(escaped_name, (int)sizeof(escaped_name), job->project_name);
char json_arg[4096];
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
if (job->project_name[0]) {
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\",\"name\":\"%s\"}", escaped_path,
escaped_name);
} else {
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
}
#ifdef _WIN32
snprintf(log_file, sizeof(log_file), "%s\\cbm_index_%d.log",
@@ -843,7 +890,7 @@ static void *index_thread_fn(void *arg) {
return NULL;
}
/* POST /api/index — body: {"root_path": "/abs/path"} → starts background indexing */
/* POST /api/index — body: {"root_path": "/abs/path", "project_name": "..."} */
static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
if (req->body_len == 0 || req->body_len > 4096) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
@@ -863,6 +910,9 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
return;
}
const char *rpath = yyjson_get_str(v_path);
yyjson_val *v_project_name = yyjson_obj_get(root, "project_name");
const char *project_name =
yyjson_is_str(v_project_name) ? yyjson_get_str(v_project_name) : "";
/* Check path exists */
if (!cbm_is_dir(rpath)) {
@@ -888,6 +938,7 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
index_job_t *job = &g_index_jobs[slot];
snprintf(job->root_path, sizeof(job->root_path), "%s", rpath);
snprintf(job->project_name, sizeof(job->project_name), "%s", project_name);
job->error_msg[0] = '\0';
atomic_store(&job->status, 1);
yyjson_doc_free(doc);
@@ -1343,6 +1394,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
return;
}
/* GET /api/ui-config → language and local UI preferences */
if (is_get && cbm_http_path_match(req->path, "/api/ui-config")) {
handle_ui_config(c, req);
return;
}
/* DELETE /api/project → delete a project's .db file */
if (is_delete && cbm_http_path_match(req->path, "/api/project*")) {
handle_delete_project(c, req);
+3
View File
@@ -342,6 +342,9 @@ int cbm_http_parse_head(const char *data, size_t len, cbm_http_req_t *req, size_
if (header_name_is(p, nlen, "origin")) {
copy_header_value(colon + 1, eol, req->origin, sizeof(req->origin));
} else if (header_name_is(p, nlen, "accept-language")) {
copy_header_value(colon + 1, eol, req->accept_language,
sizeof(req->accept_language));
} else if (header_name_is(p, nlen, "transfer-encoding")) {
/* Chunked (or any transfer coding) is not supported. */
return 411;
+3 -2
View File
@@ -42,13 +42,14 @@ typedef struct cbm_httpd cbm_httpd_t; /* listener */
typedef struct cbm_http_conn cbm_http_conn_t; /* accepted connection */
/* A parsed request. `path` and `query` are raw (NOT percent-decoded).
* `origin` is the Origin header value ("" when absent) the only header
* the routing layer consumes. `body` is heap-allocated, NUL-terminated. */
* `origin` and `accept_language` are the header values consumed by the
* routing layer ("" when absent). `body` is heap-allocated, NUL-terminated. */
typedef struct {
char method[16];
char path[2048];
char query[2048];
char origin[256];
char accept_language[256];
char *body;
size_t body_len;
} cbm_http_req_t;
+16
View File
@@ -478,6 +478,21 @@ TEST(project_name_always_validator_safe_issue349) {
PASS();
}
TEST(project_name_preserves_unicode_segments_issue571) {
char *got = cbm_project_name_from_path(
"/Users/yunxin/Desktop/\xe5\xbc\x80\xe5\x8f\x91/"
"\xe5\x90\x8e\xe7\xab\xaf/"
"\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
ASSERT_NOT_NULL(got);
ASSERT_STR_EQ(got,
"Users-yunxin-Desktop-\xe5\xbc\x80\xe5\x8f\x91-"
"\xe5\x90\x8e\xe7\xab\xaf-"
"\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
ASSERT_TRUE(cbm_validate_project_name(got));
free(got);
PASS();
}
/* ================================================================
* Suite
* ================================================================ */
@@ -579,6 +594,7 @@ SUITE(fqn) {
RUN_TEST(project_name_already_dashed);
RUN_TEST(project_name_deep_path);
RUN_TEST(project_name_always_validator_safe_issue349);
RUN_TEST(project_name_preserves_unicode_segments_issue571);
RUN_TEST(project_name_colon_only);
RUN_TEST(project_name_backslash_only);
RUN_TEST(project_name_consecutive_colons);
+59
View File
@@ -14,6 +14,8 @@
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/log.h"
#include "../src/foundation/platform.h"
#include "../src/cli/cli.h"
#include "../src/ui/http_server.h"
#include "test_framework.h"
#include "test_helpers.h"
@@ -560,6 +562,61 @@ TEST(ui_server_browse_traversal_probe) {
PASS();
}
TEST(ui_server_ui_config_detects_zh_accept_language) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /api/ui-config HTTP/1.1\r\n"
"Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n"
"\r\n",
resp, sizeof(resp));
ASSERT_TRUE(n > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_ui_config_prefers_config_lang) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX");
char *td = cbm_mkdtemp(tmpdir);
ASSERT_NOT_NULL(td);
char *old_home = getenv("HOME") ? strdup(getenv("HOME")) : NULL;
cbm_setenv("HOME", td, 1);
char cache_dir[1024];
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
cbm_config_t *cfg = cbm_config_open(cache_dir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_UI_LANG, "zh"), 0);
cbm_config_close(cfg);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /api/ui-config HTTP/1.1\r\n"
"Accept-Language: en-US,en;q=0.9\r\n"
"\r\n",
resp, sizeof(resp));
ASSERT_TRUE(n > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
th_server_stop(&ts);
if (old_home) {
cbm_setenv("HOME", old_home, 1);
free(old_home);
}
PASS();
}
TEST(ui_server_slow_request_hits_deadline) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
@@ -660,6 +717,8 @@ SUITE(httpd) {
RUN_TEST(ui_server_encoded_slash_not_routed);
RUN_TEST(ui_server_nul_in_target_rejected);
RUN_TEST(ui_server_browse_traversal_probe);
RUN_TEST(ui_server_ui_config_detects_zh_accept_language);
RUN_TEST(ui_server_ui_config_prefers_config_lang);
RUN_TEST(ui_server_slow_request_hits_deadline);
RUN_TEST(ui_server_access_log_redacts_query);
RUN_TEST(ui_server_stop_joins_cleanly);
+11
View File
@@ -207,6 +207,16 @@ TEST(mcp_tools_list_latest_metadata) {
PASS();
}
TEST(mcp_index_repository_declares_name_override_issue571) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"index_repository\""));
ASSERT_NOT_NULL(strstr(json, "\"name\":{\"type\":\"string\""));
ASSERT_NOT_NULL(strstr(json, "Override the derived project name"));
free(json);
PASS();
}
TEST(mcp_tools_array_schemas_have_items) {
/* VS Code 1.112+ rejects array schemas without "items" (see
* https://github.com/microsoft/vscode/issues/248810).
@@ -2417,6 +2427,7 @@ SUITE(mcp) {
RUN_TEST(mcp_initialize_response);
RUN_TEST(mcp_tools_list);
RUN_TEST(mcp_tools_list_latest_metadata);
RUN_TEST(mcp_index_repository_declares_name_override_issue571);
RUN_TEST(mcp_tools_array_schemas_have_items);
RUN_TEST(mcp_text_result);
RUN_TEST(mcp_text_result_skips_structured_content_for_plain_text);