Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions graph-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand All @@ -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<UiLangPref>(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<HTMLSelectElement>) => {
const next = e.target.value as UiLangPref;
setPref(next);
void setUiLanguage(next);
};

return (
<select
value={pref}
onChange={onChange}
aria-label={messages[lang].language.label}
title={messages[lang].language.label}
className="bg-white/[0.04] border border-border/30 rounded-md px-2 py-1 text-[12px] text-foreground/90 cursor-pointer focus:outline-none focus:ring-1 focus:ring-primary/40 hover:bg-white/[0.08] transition-colors"
>
<option value="zh">{labels.zh}</option>
<option value="en">{labels.en}</option>
<option value="auto">{labels.auto}</option>
</select>
);
}

export function App() {
const t = useUiMessages();
const [route, setRoute] = useState<RouteState>(readRoute);
Expand Down Expand Up @@ -100,8 +145,11 @@ export function App() {
</nav>
</div>

{selectedProject && (
<div className="flex items-center gap-2 px-3 py-1 rounded-lg bg-white/[0.04] border border-border/30">
<div className="flex items-center gap-3">
<LanguageSwitcher />

{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">
{t.graph.selectedLabel}
</span>
Expand All @@ -114,8 +162,9 @@ export function App() {
>
×
</button>
</div>
)}
</div>
)}
</div>
</header>

{/* Content */}
Expand Down
46 changes: 44 additions & 2 deletions graph-ui/src/lib/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
86 changes: 85 additions & 1 deletion graph-ui/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -73,6 +76,9 @@ export const messages = {
thisProcess: "THIS",
uptime: "Uptime",
},
language: {
label: "Language",
},
},
zh: {
tabs: {
Expand Down Expand Up @@ -144,6 +150,9 @@ export const messages = {
thisProcess: "本进程",
uptime: "运行时间",
},
language: {
label: "语言",
},
},
} as const;

Expand Down Expand Up @@ -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<UiLanguage> | null = null;
const languageListeners = new Set<(lang: UiLanguage) => void>();
Expand All @@ -181,7 +191,12 @@ function loadUiLanguage(): Promise<UiLanguage> {

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;
Expand Down Expand Up @@ -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<UiLangPref> {
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<UiLanguage>(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<UiLangPref, string> {
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<void> {
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.
}
}
76 changes: 68 additions & 8 deletions src/ui/http_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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 ─────────────────────────────────────────────── */
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading