feat(ui): add i18n and index picker UX
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
+15
-12
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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");
|
||||
|
||||
@@ -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) ─────────────────────────── */
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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] = '-';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user