Skip to content
Merged
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
19 changes: 7 additions & 12 deletions src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<main>), the nav rail, and overlay dialogs
// (which can render outside <main>, 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<HTMLElement>(null);
Expand Down Expand Up @@ -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();
};

Expand Down
28 changes: 18 additions & 10 deletions src/components/layout/update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -14,18 +17,21 @@ export function UpdateDialog() {

return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={dismissDialog}
/>
{/* 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 <main>). Dismiss via the header ✕ or "Later". */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />

{/* Dialog */}
<div className="relative w-[420px] max-h-[70vh] flex flex-col rounded-xl border border-border bg-background shadow-xl">
<div
role="dialog"
aria-modal="true"
className="relative w-[420px] max-h-[70vh] flex flex-col rounded-xl border border-border bg-background shadow-xl"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h3 className="text-base font-semibold">
Update to v{available.version}
{t("title", { version: available.version })}
</h3>
<button
onClick={dismissDialog}
Expand All @@ -37,7 +43,9 @@ export function UpdateDialog() {

{/* Changelog */}
<div className="flex-1 overflow-y-auto px-5 py-4">
<ChangelogMarkdown body={available.body} />
<ChangelogMarkdown
body={localizeChangelog(available.body, i18n.language)}
/>
</div>

{/* Footer */}
Expand All @@ -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")}
</button>
<button
onClick={confirmUpdate}
Expand All @@ -58,7 +66,7 @@ export function UpdateDialog() {
) : (
<Download size={12} />
)}
{installing ? "Updating..." : "Update Now"}
{installing ? t("updating") : t("updateNow")}
</button>
</div>
</div>
Expand Down
27 changes: 18 additions & 9 deletions src/components/layout/web-update-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -14,15 +17,19 @@ export function WebUpdateDialog() {

return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={dismissDialog}
/>
{/* 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 <main>). Dismiss via the header ✕ or "Close". */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />

<div className="relative w-[420px] max-h-[70vh] flex flex-col rounded-xl border border-border bg-background shadow-xl">
<div
role="dialog"
aria-modal="true"
className="relative w-[420px] max-h-[70vh] flex flex-col rounded-xl border border-border bg-background shadow-xl"
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h3 className="text-base font-semibold">
Update to v{available.version}
{t("title", { version: available.version })}
</h3>
<button
onClick={dismissDialog}
Expand All @@ -33,15 +40,17 @@ export function WebUpdateDialog() {
</div>

<div className="flex-1 overflow-y-auto px-5 py-4">
<ChangelogMarkdown body={available.body} />
<ChangelogMarkdown
body={localizeChangelog(available.body, i18n.language)}
/>
</div>

<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
<button
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"
>
Close
{t("close")}
</button>
<a
href={INSTRUCTIONS_URL}
Expand All @@ -50,7 +59,7 @@ export function WebUpdateDialog() {
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
<ExternalLink size={12} />
View Update Instructions
{t("viewInstructions")}
</a>
</div>
</div>
Expand Down
34 changes: 34 additions & 0 deletions src/lib/i18n/__tests__/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { localizeChangelog } from "../changelog";

const BILINGUAL = `<!-- lang:en -->
## What's new
English line
<!-- lang:zh -->
## 更新内容
中文行`;

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("<!-- lang:en -->\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",
);
});
});
40 changes: 40 additions & 0 deletions src/lib/i18n/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { mapLocaleToSupportedLanguage } from "./index";

// Matches a language fence like `<!-- lang:en -->` / `<!-- lang:zh -->`.
const LANG_FENCE = /<!--\s*lang:([a-z-]+)\s*-->/gi;

/**
* Pick the section of a changelog body matching `language`.
*
* Release notes can be authored bilingually by fencing each language:
*
* <!-- lang:en -->
* ## What's new ...
* <!-- lang:zh -->
* ## 更新内容 ...
*
* 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<string, string> = {};
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()
);
}
8 changes: 8 additions & 0 deletions src/lib/i18n/locales/en/update.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"title": "Update to v{{version}}",
"later": "Later",
"updateNow": "Update Now",
"updating": "Updating...",
"close": "Close",
"viewInstructions": "View Update Instructions"
}
8 changes: 8 additions & 0 deletions src/lib/i18n/locales/zh/update.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"title": "更新到 v{{version}}",
"later": "稍后",
"updateNow": "立即更新",
"updating": "更新中...",
"close": "关闭",
"viewInstructions": "查看更新说明"
}
2 changes: 2 additions & 0 deletions src/types/i18next.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -25,6 +26,7 @@ declare module "i18next" {
navigation: typeof navigation;
overview: typeof overview;
settings: typeof settings;
update: typeof update;
};
}
}
Loading