diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 99b5fbe7..c767fb63 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -7,6 +7,11 @@ import { useScopeStore } from "@/stores/scope-store"; import { Sidebar } from "./sidebar"; const INTERACTIVE = "a, button, input, select, textarea, [role='button']"; +// A mousedown/dblclick on these must not drive the window: interactive +// controls, the scrollable content (
), the nav rail, and overlay dialogs +// (which can render outside
, e.g. the update dialog — without this they'd +// be undraggable-window chrome where text can't be selected). +const NO_WINDOW_GESTURE = `${INTERACTIVE}, main, nav, [role='dialog']`; export function AppShell() { const mainRef = useRef(null); @@ -62,24 +67,14 @@ export function AppShell() { const onMouseDown = (e: MouseEvent) => { if (e.button !== 0) return; const target = e.target as HTMLElement; - if ( - target.closest(INTERACTIVE) || - target.closest("main") || - target.closest("nav") - ) - return; + if (target.closest(NO_WINDOW_GESTURE)) return; e.preventDefault(); getCurrentWindow().startDragging(); }; const onDblClick = (e: MouseEvent) => { const target = e.target as HTMLElement; - if ( - target.closest(INTERACTIVE) || - target.closest("main") || - target.closest("nav") - ) - return; + if (target.closest(NO_WINDOW_GESTURE)) return; getCurrentWindow().toggleMaximize(); }; diff --git a/src/components/layout/update-dialog.tsx b/src/components/layout/update-dialog.tsx index 525b8136..04f6cb81 100644 --- a/src/components/layout/update-dialog.tsx +++ b/src/components/layout/update-dialog.tsx @@ -1,8 +1,11 @@ import { Download, Loader2, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { localizeChangelog } from "@/lib/i18n/changelog"; import { useUpdateStore } from "@/stores/update-store"; import { ChangelogMarkdown } from "./changelog-markdown"; export function UpdateDialog() { + const { t, i18n } = useTranslation("update"); const available = useUpdateStore((s) => s.available); const showChangelog = useUpdateStore((s) => s.showChangelog); const installing = useUpdateStore((s) => s.installing); @@ -14,18 +17,21 @@ export function UpdateDialog() { return (
- {/* Backdrop */} -
+ {/* Backdrop — left as plain chrome so pressing the dimmed area outside the + dialog drags the window (handled by the app-shell drag listener, since + it's outside
). Dismiss via the header ✕ or "Later". */} +
{/* Dialog */} -
+
{/* Header */}

- Update to v{available.version} + {t("title", { version: available.version })}

{/* Footer */} @@ -46,7 +54,7 @@ export function UpdateDialog() { onClick={dismissUpdate} className="rounded-lg border border-border px-4 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" > - Later + {t("later")}
diff --git a/src/components/layout/web-update-dialog.tsx b/src/components/layout/web-update-dialog.tsx index 47ee4061..3c148a55 100644 --- a/src/components/layout/web-update-dialog.tsx +++ b/src/components/layout/web-update-dialog.tsx @@ -1,10 +1,13 @@ import { ExternalLink, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { localizeChangelog } from "@/lib/i18n/changelog"; import { useWebUpdateStore } from "@/stores/web-update-store"; import { ChangelogMarkdown } from "./changelog-markdown"; const INSTRUCTIONS_URL = "https://github.com/RealZST/HarnessKit#updating"; export function WebUpdateDialog() { + const { t, i18n } = useTranslation("update"); const available = useWebUpdateStore((s) => s.available); const showDialog = useWebUpdateStore((s) => s.showDialog); const dismissDialog = useWebUpdateStore((s) => s.dismissDialog); @@ -14,15 +17,19 @@ export function WebUpdateDialog() { return (
-
+ {/* Backdrop — left as plain chrome so pressing the dimmed area outside the + dialog drags the window (handled by the app-shell drag listener, since + it's outside
). Dismiss via the header ✕ or "Close". */} +
-
+

- Update to v{available.version} + {t("title", { version: available.version })}

- View Update Instructions + {t("viewInstructions")}
diff --git a/src/lib/i18n/__tests__/changelog.test.ts b/src/lib/i18n/__tests__/changelog.test.ts new file mode 100644 index 00000000..86d49acf --- /dev/null +++ b/src/lib/i18n/__tests__/changelog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { localizeChangelog } from "../changelog"; + +const BILINGUAL = ` +## What's new +English line + +## 更新内容 +中文行`; + +describe("localizeChangelog", () => { + it("returns the section matching the language", () => { + const zh = localizeChangelog(BILINGUAL, "zh"); + expect(zh).toContain("中文行"); + expect(zh).not.toContain("English line"); + expect(localizeChangelog(BILINGUAL, "en")).toContain("English line"); + }); + + it("normalizes regional codes like zh-CN to the section language", () => { + expect(localizeChangelog(BILINGUAL, "zh-CN")).toContain("中文行"); + }); + + it("falls back to English when the requested section is missing", () => { + expect(localizeChangelog("\nonly english", "zh")).toBe( + "only english", + ); + }); + + it("returns the whole body unchanged when there are no fences", () => { + expect(localizeChangelog("plain single-language notes", "zh")).toBe( + "plain single-language notes", + ); + }); +}); diff --git a/src/lib/i18n/changelog.ts b/src/lib/i18n/changelog.ts new file mode 100644 index 00000000..b7b4f921 --- /dev/null +++ b/src/lib/i18n/changelog.ts @@ -0,0 +1,40 @@ +import { mapLocaleToSupportedLanguage } from "./index"; + +// Matches a language fence like `` / ``. +const LANG_FENCE = //gi; + +/** + * Pick the section of a changelog body matching `language`. + * + * Release notes can be authored bilingually by fencing each language: + * + * + * ## What's new ... + * + * ## 更新内容 ... + * + * Returns the section for the active language, falling back to English, then to + * the first section present. Notes without any fence are returned unchanged, so + * single-language releases keep working. + */ +export function localizeChangelog(body: string, language: string): string { + const fences = [...body.matchAll(LANG_FENCE)]; + if (fences.length === 0) return body.trim(); + + const sections: Record = {}; + fences.forEach((fence, i) => { + const start = (fence.index ?? 0) + fence[0].length; + const end = + i + 1 < fences.length + ? (fences[i + 1].index ?? body.length) + : body.length; + const key = + mapLocaleToSupportedLanguage(fence[1]) ?? fence[1].toLowerCase(); + sections[key] = body.slice(start, end).trim(); + }); + + const lang = mapLocaleToSupportedLanguage(language) ?? "en"; + return ( + sections[lang] ?? sections.en ?? Object.values(sections)[0] ?? body.trim() + ); +} diff --git a/src/lib/i18n/locales/en/update.json b/src/lib/i18n/locales/en/update.json new file mode 100644 index 00000000..f6ab77cf --- /dev/null +++ b/src/lib/i18n/locales/en/update.json @@ -0,0 +1,8 @@ +{ + "title": "Update to v{{version}}", + "later": "Later", + "updateNow": "Update Now", + "updating": "Updating...", + "close": "Close", + "viewInstructions": "View Update Instructions" +} diff --git a/src/lib/i18n/locales/zh/update.json b/src/lib/i18n/locales/zh/update.json new file mode 100644 index 00000000..2ca1a60a --- /dev/null +++ b/src/lib/i18n/locales/zh/update.json @@ -0,0 +1,8 @@ +{ + "title": "更新到 v{{version}}", + "later": "稍后", + "updateNow": "立即更新", + "updating": "更新中...", + "close": "关闭", + "viewInstructions": "查看更新说明" +} diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 9497abc1..211fcffe 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -11,6 +11,7 @@ import type marketplace from "@/lib/i18n/locales/en/marketplace.json"; import type navigation from "@/lib/i18n/locales/en/navigation.json"; import type overview from "@/lib/i18n/locales/en/overview.json"; import type settings from "@/lib/i18n/locales/en/settings.json"; +import type update from "@/lib/i18n/locales/en/update.json"; declare module "i18next" { interface CustomTypeOptions { @@ -25,6 +26,7 @@ declare module "i18next" { navigation: typeof navigation; overview: typeof overview; settings: typeof settings; + update: typeof update; }; } }