feat(ui): IDEA-style message dialogs, project widget popup and Chinese UI text

Message dialogs: `Dialog` gets an `icon` (question / info / warning / error)
that renders IDEA's alert layout: no title bar, a 32px icon, bold title,
text, checkbox under it. Every confirmation helper (prompt, VCS, services,
project tree, keymap, LSP choices, run, database, Replace All) uses it. Open
Project matches IDEA: "This Window" is the default and has focus. The Code
Analysis and TODO commit checks default to Review.

Project widget: the title bar project button opens a popup like IDEA's
ProjectWidget, with Open… / Clone Repository…, the projects open in other
windows (new `window_open_projects` command) and recent projects, filtered by
typing.

Chinese UI: UI text stays written in English and is translated through a
source-text catalog (`locales/text/zh.json`, about 2300 strings). The UI
primitives (buttons, checkboxes, selects, tabs, dialogs, menus, tooltips,
toasts, settings labels and hints) translate the strings they are given.
Action titles and categories are localised in the registry, and static JSX
text uses `<T>`, so text follows a locale switch without a reload. The
Settings window now syncs the locale too. Settings search matches both
languages.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-23 22:18:50 +08:00
1 джерело cd55bc8f7f
коміт 76d33f0306
138 files changed
+5507 -986

No files matched your search

+94 -31
Переглянути файл
@@ -21,6 +21,7 @@ import {
Tabs,
showContextMenu,
type MenuItem,
T,
} from "@/ui";
import {
checksSummary,
@@ -43,7 +44,11 @@ const STATES: Array<{ value: PrStateFilter; label: string }> = [
];
function Unavailable({ availability }: { availability: GhAvailability }) {
const retry = <Button size="small" onClick={() => void refreshAll()}>Retry</Button>;
const retry = (
<Button size="small" onClick={() => void refreshAll()}>
<T>Retry</T>
</Button>
);
switch (availability.kind) {
case "notInstalled":
return (
@@ -51,12 +56,15 @@ function Unavailable({ availability }: { availability: GhAvailability }) {
title="GitHub CLI is not installed"
actions={
<>
<Button size="small" onClick={() => void openUrl("https://cli.github.com")}>Install gh…</Button>
<Button size="small" onClick={() => void openUrl("https://cli.github.com")}>
<T>Install gh…</T>
</Button>
{retry}
</>
}
>
Pull requests use the <code>gh</code> command line tool. Install it, then run <code>gh auth login</code>.
<T>Pull requests use the</T> <code>gh</code> <T>command line tool. Install it, then run</T>{" "}
<code>gh auth login</code>.
</EmptyState>
);
case "notLoggedIn":
@@ -68,21 +76,29 @@ function Unavailable({ availability }: { availability: GhAvailability }) {
<Button
size="small"
variant="primary"
onClick={() => void executeAction("terminal.runCommand", { program: "gh", args: ["auth", "login"], name: "gh auth login" })}
onClick={() =>
void executeAction("terminal.runCommand", {
program: "gh",
args: ["auth", "login"],
name: "gh auth login",
})
}
>
Run gh auth login
<T>Run gh auth login</T>
</Button>
{retry}
</>
}
>
Run <code>gh auth login</code> in a terminal, then retry.
<T>Run</T> <code>gh auth login</code> <T>in a terminal, then retry.</T>
</EmptyState>
);
case "notGitHub":
return (
<EmptyState title="No GitHub repository" actions={retry}>
<div>This project has no remote on GitHub.</div>
<div>
<T>This project has no remote on GitHub.</T>
</div>
<div className={styles.hint}>{availability.message}</div>
</EmptyState>
);
@@ -113,11 +129,21 @@ function Filters() {
}, [search]);
return (
<div className={styles.filters}>
<SearchField value={search} onChange={setSearch} placeholder="Search (GitHub syntax)" aria-label="Search pull requests" className={styles.search} />
<SearchField
value={search}
onChange={setSearch}
placeholder="Search (GitHub syntax)"
aria-label="Search pull requests"
className={styles.search}
/>
<div className={styles.filterRow}>
<Select value={query.state} options={STATES} onChange={(state) => setQuery({ state })} aria-label="State" />
<Checkbox checked={query.authorMe} onChange={(authorMe) => setQuery({ authorMe })} label="Mine" />
<Checkbox checked={query.reviewRequested} onChange={(reviewRequested) => setQuery({ reviewRequested })} label="Review requested" />
<Checkbox
checked={query.reviewRequested}
onChange={(reviewRequested) => setQuery({ reviewRequested })}
label="Review requested"
/>
<span className={styles.flex} />
{loading ? <Spinner size={14} /> : <IconButton icon="refresh" size="small" action="github.refresh" />}
<IconButton icon="plus" size="small" action="github.createPullRequest" />
@@ -184,7 +210,12 @@ function PullRequestList() {
function Markdown({ text }: { text: string }) {
const html = useMarkupHtml({ kind: "markdown", value: text });
if (!text.trim()) return <div className={styles.dim}>No description provided.</div>;
if (!text.trim())
return (
<div className={styles.dim}>
<T>No description provided.</T>
</div>
);
return (
<div
className={styles.markdown}
@@ -225,19 +256,33 @@ function Overview({ pr }: { pr: PullRequestDetails }) {
</span>
{c.url && (
<button type="button" className={styles.link} onClick={() => void openUrl(c.url!)}>
Details
<T>Details</T>
</button>
)}
</div>
))}
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Reviewers</h3>
{pr.reviewers.length === 0 && <div className={styles.dim}>No reviews</div>}
<h3 className={styles.sectionTitle}>
<T>Reviewers</T>
</h3>
{pr.reviewers.length === 0 && (
<div className={styles.dim}>
<T>No reviews</T>
</div>
)}
{pr.reviewers.map((r) => (
<div key={r.login} className={styles.checkRow}>
<Icon
name={r.state === "APPROVED" ? "success" : r.state === "CHANGES_REQUESTED" ? "error" : r.state === "REQUESTED" ? "clock" : "info"}
name={
r.state === "APPROVED"
? "success"
: r.state === "CHANGES_REQUESTED"
? "error"
: r.state === "REQUESTED"
? "clock"
: "info"
}
size={14}
className={styles[`review-${r.state}`]}
/>
@@ -276,7 +321,14 @@ function Files({ pr }: { pr: PullRequestDetails }) {
{pr.files.map((f) => {
const slash = f.path.lastIndexOf("/");
return (
<button key={f.path} type="button" role="listitem" className={styles.fileRow} onClick={(e) => e.detail <= 1 && void openFileDiff(pr, f)} title={f.path}>
<button
key={f.path}
type="button"
role="listitem"
className={styles.fileRow}
onClick={(e) => e.detail <= 1 && void openFileDiff(pr, f)}
title={f.path}
>
<Icon name="diff" size={14} className={styles[`change-${f.changeType}`]} />
<span className={clsx(styles.fileName, styles[`change-${f.changeType}`])}>{f.path.slice(slash + 1)}</span>
<span className={clsx(styles.dim, styles.ellipsis)}>{slash > 0 ? f.path.slice(0, slash) : ""}</span>
@@ -304,7 +356,11 @@ function Commits({ pr }: { pr: PullRequestDetails }) {
e.preventDefault();
showContextMenu({ x: e.clientX, y: e.clientY }, [
{ label: "Copy Revision Number", icon: "copy", run: () => void navigator.clipboard?.writeText(c.oid) },
{ label: "Open in Browser", icon: "external-link", run: () => void openUrl(`${pr.summary.url}/commits/${c.oid}`) },
{
label: "Open in Browser",
icon: "external-link",
run: () => void openUrl(`${pr.summary.url}/commits/${c.oid}`),
},
]);
}}
>
@@ -330,13 +386,19 @@ function Conversation({ pr }: { pr: PullRequestDetails }) {
<article key={i} className={styles.comment}>
<header className={styles.commentHead}>
<strong>{t.author}</strong>
{t.reviewState && <span className={styles[`review-${t.reviewState}`]}>{reviewStateLabel(t.reviewState)}</span>}
{t.reviewState && (
<span className={styles[`review-${t.reviewState}`]}>{reviewStateLabel(t.reviewState)}</span>
)}
<span className={styles.dim}>{relativeTime(t.createdAt)}</span>
</header>
{t.body.trim() && <Markdown text={t.body} />}
</article>
))}
{threads.length > 0 && <h3 className={styles.sectionTitle}>Review comments</h3>}
{threads.length > 0 && (
<h3 className={styles.sectionTitle}>
<T>Review comments</T>
</h3>
)}
{threads.map((t) => (
<article key={t.root.id} className={styles.comment}>
<header className={styles.commentHead}>
@@ -377,7 +439,7 @@ function Conversation({ pr }: { pr: PullRequestDetails }) {
setPosting(false);
}}
>
Comment
<T>Comment</T>
</Button>
</div>
</div>
@@ -396,7 +458,7 @@ function Details() {
if (error) return <EmptyState title="Could not load the pull request">{error}</EmptyState>;
return loading ? (
<div className={styles.loading}>
<Spinner size={14} /> Loading…
<Spinner size={14} /> <T>Loading…</T>
</div>
) : (
<EmptyState title="Select a pull request" />
@@ -423,18 +485,24 @@ function Details() {
<span>
#{s.number} by <strong>{s.author}</strong> · {s.baseRef} ← {s.headRef}
</span>
{reviewDecisionLabel(s.reviewDecision) && <span className={styles.dim}>· {reviewDecisionLabel(s.reviewDecision)}</span>}
{pr.mergeable === "CONFLICTING" && <span className={styles.conflict}>· Conflicts</span>}
{reviewDecisionLabel(s.reviewDecision) && (
<span className={styles.dim}>· {reviewDecisionLabel(s.reviewDecision)}</span>
)}
{pr.mergeable === "CONFLICTING" && (
<span className={styles.conflict}>
<T>· Conflicts</T>
</span>
)}
</div>
<div className={styles.actions}>
<Button size="small" icon="branch" onClick={() => void checkoutPullRequest(s.number)}>
Checkout
<T>Checkout</T>
</Button>
<Button size="small" icon="edit" onClick={reviewMenu}>
Review ▾
<T>Review ▾</T>
</Button>
<Button size="small" icon="external-link" onClick={() => void openUrl(s.url)}>
Open in Browser
<T>Open in Browser</T>
</Button>
<span className={styles.flex} />
<IconButton icon="refresh" size="small" title="Reload" onClick={() => void loadDetails(s.number)} />
@@ -496,11 +564,7 @@ export function PullRequestsView() {
if (!useGithub.getState().availability) void refreshAll();
}, []);
if (!availability)
return (
<div className={styles.loading}>
{checking && <Spinner size={14} />} Connecting to GitHub…
</div>
);
return <div className={styles.loading}>{checking && <Spinner size={14} />} Connecting to GitHub…</div>;
if (availability.kind !== "ready")
return (
<div className={styles.view}>
@@ -529,4 +593,3 @@ export function PullRequestsHeader() {
if (availability?.kind !== "ready") return null;
return <span className={styles.repo}>{availability.repo.nameWithOwner}</span>;
}
+60 -12
Переглянути файл
@@ -3,7 +3,7 @@ import { Fragment, useEffect, useState, type ReactNode } from "react";
import { create } from "zustand";
import type { ReviewEvent } from "@/platform/ipc";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Input, Select } from "@/ui";
import { Checkbox, Dialog, Input, Select, T } from "@/ui";
import styles from "./Github.module.css";
interface Entry {
@@ -45,11 +45,22 @@ export interface CreatePrInput {
}
/** Asks for the new pull request's title, description, base branch and options. */
export function createPullRequestDialog(opts: { head: string; defaultBase: string | null; title: string; needsPush: boolean }) {
export function createPullRequestDialog(opts: {
head: string;
defaultBase: string | null;
title: string;
needsPush: boolean;
}) {
return open<CreatePrInput>((done) => <CreateDialog {...opts} onDone={done} />);
}
function CreateDialog(props: { head: string; defaultBase: string | null; title: string; needsPush: boolean; onDone: (v: CreatePrInput | null) => void }) {
function CreateDialog(props: {
head: string;
defaultBase: string | null;
title: string;
needsPush: boolean;
onDone: (v: CreatePrInput | null) => void;
}) {
const [title, setTitle] = useState(props.title);
const [body, setBody] = useState("");
const [base, setBase] = useState(props.defaultBase ?? "");
@@ -95,11 +106,24 @@ function CreateDialog(props: { head: string; defaultBase: string | null; title:
>
<div className={styles.form}>
<div className={styles.formRow}>
<label className={styles.fieldLabel}>Base:</label>
<Select value={base || null} options={bases.map((b) => ({ value: b, label: b }))} onChange={setBase} aria-label="Base branch" />
<label className={styles.fieldLabel}>
<T>Base:</T>
</label>
<Select
value={base || null}
options={bases.map((b) => ({ value: b, label: b }))}
onChange={setBase}
aria-label="Base branch"
/>
<span className={styles.fieldLabel}>← {props.head}</span>
</div>
<Input data-autofocus aria-label="Title" placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
<Input
data-autofocus
aria-label="Title"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
aria-label="Description"
placeholder="Description (Markdown)"
@@ -117,14 +141,24 @@ function CreateDialog(props: { head: string; defaultBase: string | null; title:
);
}
const REVIEW_LABEL: Record<ReviewEvent, string> = { approve: "Approve", requestChanges: "Request Changes", comment: "Comment" };
const REVIEW_LABEL: Record<ReviewEvent, string> = {
approve: "Approve",
requestChanges: "Request Changes",
comment: "Comment",
};
/** Asks for a review comment; resolves with the chosen event and body. */
export function reviewDialog(number: number, initial: ReviewEvent) {
return open<{ event: ReviewEvent; body: string }>((done) => <ReviewDialog number={number} initial={initial} onDone={done} />);
return open<{ event: ReviewEvent; body: string }>((done) => (
<ReviewDialog number={number} initial={initial} onDone={done} />
));
}
function ReviewDialog(props: { number: number; initial: ReviewEvent; onDone: (v: { event: ReviewEvent; body: string } | null) => void }) {
function ReviewDialog(props: {
number: number;
initial: ReviewEvent;
onDone: (v: { event: ReviewEvent; body: string } | null) => void;
}) {
const [body, setBody] = useState("");
const [event, setEvent] = useState<ReviewEvent>(props.initial);
const needsBody = event !== "approve";
@@ -135,7 +169,12 @@ function ReviewDialog(props: { number: number; initial: ReviewEvent; onDone: (v:
onClose={() => props.onDone(null)}
width={520}
buttons={[
{ label: REVIEW_LABEL[event], role: "default", disabled: needsBody && !body.trim(), onClick: () => props.onDone({ event, body }) },
{
label: REVIEW_LABEL[event],
role: "default",
disabled: needsBody && !body.trim(),
onClick: () => props.onDone({ event, body }),
},
{ label: "Cancel", role: "cancel" },
]}
>
@@ -187,8 +226,17 @@ function NumberDialog(props: { title: string; onDone: (v: number | null) => void
if (ok) props.onDone(n);
}}
>
<label className={styles.fieldLabel}>Pull request number:</label>
<Input data-autofocus mono aria-label="Pull request number" placeholder="#123" value={value} onChange={(e) => setValue(e.target.value)} />
<label className={styles.fieldLabel}>
<T>Pull request number:</T>
</label>
<Input
data-autofocus
mono
aria-label="Pull request number"
placeholder="#123"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</form>
</Dialog>
);