diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 18392667c..8f5b1e814 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -3,7 +3,16 @@ import { GraphTab } from "./components/GraphTab"; import { StatsTab } from "./components/StatsTab"; import { ControlTab } from "./components/ControlTab"; import type { TabId } from "./lib/types"; -import { useUiMessages } from "./lib/i18n"; +import { + useUiMessages, + useUiLanguage, + getCachedLangPref, + getUiLangPref, + langOptionLabels, + setUiLanguage, + messages, + type UiLangPref, +} from "./lib/i18n"; const TAB_IDS: TabId[] = ["graph", "stats", "control"]; @@ -30,6 +39,42 @@ function routeUrl(tab: TabId, project: string | null): string { return `${window.location.pathname}?${params.toString()}${window.location.hash}`; } +function LanguageSwitcher() { + const lang = useUiLanguage(); + const [pref, setPref] = useState(getCachedLangPref()); + const labels = langOptionLabels(lang); + + useEffect(() => { + let cancelled = false; + void getUiLangPref().then((p) => { + if (!cancelled) setPref(p); + }); + return () => { + cancelled = true; + }; + }, []); + + const onChange = (e: React.ChangeEvent) => { + const next = e.target.value as UiLangPref; + setPref(next); + void setUiLanguage(next); + }; + + return ( + + ); +} + export function App() { const t = useUiMessages(); const [route, setRoute] = useState(readRoute); @@ -100,8 +145,11 @@ export function App() { - {selectedProject && ( -
+
+ + + {selectedProject && ( +
{t.graph.selectedLabel} @@ -114,8 +162,9 @@ export function App() { > × -
- )} +
+ )} +
{/* Content */} diff --git a/graph-ui/src/lib/i18n.test.ts b/graph-ui/src/lib/i18n.test.ts index 44c923c26..0269532ff 100644 --- a/graph-ui/src/lib/i18n.test.ts +++ b/graph-ui/src/lib/i18n.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { detectLanguage, messages } from "./i18n"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { detectLanguage, langOptionLabels, messages, setUiLanguage } from "./i18n"; describe("i18n", () => { it("detects Chinese from Accept-Language and falls back to English", () => { @@ -38,3 +38,45 @@ describe("i18n", () => { expect(messages.en.index.repositoryPath).toBe("Repository path"); }); }); + +describe("langOptionLabels", () => { + it("labels the three options in the active UI language", () => { + expect(langOptionLabels("en")).toEqual({ + zh: "Chinese", + en: "English", + auto: "Follow browser", + }); + expect(langOptionLabels("zh")).toEqual({ + zh: "中文", + en: "English", + auto: "跟随浏览器", + }); + }); +}); + +describe("setUiLanguage", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("POSTs the chosen preference to /api/ui-config", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response); + vi.stubGlobal("fetch", fetchMock); + + await setUiLanguage("zh"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/ui-config"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ lang: "zh" }); + }); + + it("resolves without throwing when the POST fails", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("network")); + vi.stubGlobal("fetch", fetchMock); + + await expect(setUiLanguage("en")).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/graph-ui/src/lib/i18n.ts b/graph-ui/src/lib/i18n.ts index 1f50ab927..d6a84a6e1 100644 --- a/graph-ui/src/lib/i18n.ts +++ b/graph-ui/src/lib/i18n.ts @@ -2,6 +2,9 @@ import { useEffect, useState } from "react"; export type UiLanguage = "en" | "zh"; +/** The persisted language preference. "auto" follows the browser via Accept-Language. */ +export type UiLangPref = "en" | "zh" | "auto"; + export const messages = { en: { tabs: { @@ -73,6 +76,9 @@ export const messages = { thisProcess: "THIS", uptime: "Uptime", }, + language: { + label: "Language", + }, }, zh: { tabs: { @@ -144,6 +150,9 @@ export const messages = { thisProcess: "本进程", uptime: "运行时间", }, + language: { + label: "语言", + }, }, } as const; @@ -171,6 +180,7 @@ export function detectLanguage(acceptLanguage?: string | null, override?: string } let cachedLanguage: UiLanguage = "en"; +let cachedLangPref: UiLangPref = "auto"; let languageLoaded = false; let languageRequest: Promise | null = null; const languageListeners = new Set<(lang: UiLanguage) => void>(); @@ -181,7 +191,12 @@ function loadUiLanguage(): Promise { languageRequest = fetch("/api/ui-config") .then((r) => r.json()) - .then((data) => detectLanguage(null, data?.lang)) + .then((data) => { + if (data?.lang_pref === "en" || data?.lang_pref === "zh" || data?.lang_pref === "auto") { + cachedLangPref = data.lang_pref; + } + return detectLanguage(null, data?.lang); + }) .catch(() => detectLanguage(navigator.language)) .then((lang) => { cachedLanguage = lang; @@ -213,3 +228,72 @@ export function useUiMessages(): UiMessages { return messages[lang]; } + +/** Read the persisted language preference without forcing a load. */ +export function getCachedLangPref(): UiLangPref { + return cachedLangPref; +} + +/** Fetch the persisted language preference from the server. */ +export async function getUiLangPref(): Promise { + try { + const data = await fetch("/api/ui-config").then((r) => r.json()); + const pref = data?.lang_pref; + if (pref === "en" || pref === "zh" || pref === "auto") return pref; + } catch { + // The server is unreachable; fall back to the in-memory default. + } + return "auto"; +} + +/** Subscribe to the current effective UI language and re-render on change. */ +export function useUiLanguage(): UiLanguage { + const [lang, setLang] = useState(cachedLanguage); + + useEffect(() => { + let cancelled = false; + languageListeners.add(setLang); + void loadUiLanguage().then((nextLang) => { + if (!cancelled) setLang(nextLang); + }); + return () => { + cancelled = true; + languageListeners.delete(setLang); + }; + }, []); + + return lang; +} + +/** + * Labels for the three language options, rendered in the currently active UI + * language so the dropdown reads naturally regardless of the chosen preference. + */ +export function langOptionLabels(lang: UiLanguage): Record { + if (lang === "zh") { + return { zh: "中文", en: "English", auto: "跟随浏览器" }; + } + return { zh: "Chinese", en: "English", auto: "Follow browser" }; +} + +/** + * Persist a new language preference and immediately reflect it in the UI. + * "auto" recomputes the effective language from the browser at switch time and + * stores "auto" so future loads re-derive it from Accept-Language. + */ +export async function setUiLanguage(pref: UiLangPref): Promise { + cachedLangPref = pref; + const effective: UiLanguage = pref === "auto" ? detectLanguage(navigator.language) : pref; + cachedLanguage = effective; + for (const listener of languageListeners) listener(effective); + + try { + await fetch("/api/ui-config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lang: pref }), + }); + } catch { + // If persistence fails, the in-memory language still applies for this session. + } +} diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 066eafe42..1f3bb700b 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -122,13 +122,18 @@ static const char *detect_ui_lang(const char *accept_language) { static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) { const char *lang = NULL; + char lang_pref[8]; + snprintf(lang_pref, sizeof(lang_pref), "auto"); 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; + if (pinned) { + snprintf(lang_pref, sizeof(lang_pref), "%s", pinned); + } + if (strcmp(lang_pref, "zh") == 0 || strcmp(lang_pref, "en") == 0) { + lang = lang_pref; } } @@ -141,9 +146,60 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) { * edge-case reports. Served from the backend on purpose — the UI security * audit forbids hardcoded external URLs in graph-ui source (external * targets must come from an auditable backend response, same pattern as - * the /api/repo-info deep-links). */ - cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\",\"upstream_issues_url\":\"%s\"}", - lang_buf, "https://github.com/DeusData/codebase-memory-mcp/issues/new"); + * the /api/repo-info deep-links). lang_pref echoes the stored preference + * (en | zh | auto) so the in-UI language switcher can reflect state. */ + cbm_http_replyf(c, 200, g_cors_json, + "{\"lang\":\"%s\",\"lang_pref\":\"%s\",\"upstream_issues_url\":\"%s\"}", + lang_buf, lang_pref, + "https://github.com/DeusData/codebase-memory-mcp/issues/new"); +} + +/* POST /api/ui-config → persist the pinned UI language (en | zh | auto) and + * return the resolved config. The in-UI language switcher calls this so the + * choice survives restarts without forcing a language on other users. */ +static void handle_ui_config_update(cbm_http_conn_t *c, const cbm_http_req_t *req) { + if (req->body_len == 0 || req->body_len > 64) { + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}"); + return; + } + + yyjson_doc *doc = yyjson_read(req->body, req->body_len, 0); + if (!doc) { + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid json\"}"); + return; + } + + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *v_lang = root ? yyjson_obj_get(root, "lang") : NULL; + if (!yyjson_is_str(v_lang)) { + yyjson_doc_free(doc); + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing lang\"}"); + return; + } + + const char *lang = yyjson_get_str(v_lang); + if (strcmp(lang, "en") != 0 && strcmp(lang, "zh") != 0 && strcmp(lang, "auto") != 0) { + yyjson_doc_free(doc); + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid lang\"}"); + return; + } + + 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) { + yyjson_doc_free(doc); + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"config unavailable\"}"); + return; + } + cbm_config_set(cfg, CBM_CONFIG_UI_LANG, lang); + cbm_config_close(cfg); + yyjson_doc_free(doc); + + const char *effective = (strcmp(lang, "auto") == 0) ? detect_ui_lang(req->accept_language) : lang; + cbm_http_replyf(c, 200, g_cors_json, + "{\"lang\":\"%s\",\"lang_pref\":\"%s\",\"upstream_issues_url\":\"%s\"}", + effective, lang, "https://github.com/DeusData/codebase-memory-mcp/issues/new"); } /* ── Server state ─────────────────────────────────────────────── */ @@ -1862,9 +1918,13 @@ 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); + /* /api/ui-config → language and local UI preferences (GET reads, POST writes) */ + if (cbm_http_path_match(req->path, "/api/ui-config")) { + if (is_post) { + handle_ui_config_update(c, req); + } else { + handle_ui_config(c, req); + } return; } diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 0d2f82402..6b417e18b 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1527,6 +1527,52 @@ TEST(ui_server_ui_config_prefers_config_lang) { PASS(); } +TEST(ui_server_ui_config_post_persists_lang) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_post_XXXXXX"); + char *td = cbm_mkdtemp(tmpdir); + ASSERT_NOT_NULL(td); + + char *old_home = getenv("HOME") ? strdup(getenv("HOME")) : NULL; + cbm_setenv("HOME", td, 1); + + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + + /* POST zh; expect the preference echoed and the effective lang resolved. */ + char resp[4096]; + int n = th_http(cbm_http_server_port(ts.srv), + "POST /api/ui-config HTTP/1.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 14\r\n" + "\r\n" + "{\"lang\":\"zh\"}", + resp, sizeof(resp)); + ASSERT_TRUE(n > 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "\"lang_pref\":\"zh\"")); + ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\"")); + + /* A subsequent GET reflects the persisted preference. */ + char resp2[4096]; + int n2 = 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", + resp2, sizeof(resp2)); + ASSERT_TRUE(n2 > 0); + ASSERT_EQ(th_status(resp2), 200); + ASSERT_NOT_NULL(strstr(resp2, "\"lang_pref\":\"zh\"")); + ASSERT_NOT_NULL(strstr(resp2, "\"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);