Files
openidea/src/features/project-tree/FileRefactorDialogs.tsx
T
l0ng-ai 76d33f0306 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
2026-10-23 22:18:50 +08:00

317 lines
10 KiB
TypeScript

/**
* IDEA's Rename (⇧F6) and Move (F6, drag & drop) dialogs for files and
* folders, with "Search for references" and "Search in comments and strings".
*/
import { useEffect, useRef, useState } from "react";
import { create } from "zustand";
import type { FileMove } from "@/bindings";
import { Checkbox, Dialog, Input, Spinner, T } from "@/ui";
import { runFileRefactoring, type FileRefactorOptions } from "./fileRefactor";
import { isSameOrInside, joinPath, nameOf, parentOf, relativeTo, splitExtension, validateName } from "./paths";
import { useProjectTree } from "./store";
import styles from "./FileRefactor.module.css";
export type FileRefactorRequest =
| {
kind: "rename";
path: string;
dir: boolean;
siblings: string[];
onDone?: (moves: FileMove[]) => void;
}
| {
kind: "move";
paths: string[];
/** Initial target folder. */
target: string;
/** The single moved item is a folder. */
dir?: boolean;
/** Called after the files moved (tree refresh / reveal). */
onDone?: (moves: FileMove[]) => void;
};
const useRequest = create<{ request: (FileRefactorRequest & { seq: number }) | null }>(() => ({ request: null }));
let seq = 0;
export function openFileRefactorDialog(request: FileRefactorRequest) {
useRequest.setState({ request: { ...request, seq: ++seq } });
}
export function closeFileRefactorDialog() {
useRequest.setState({ request: null });
}
/** Test hook: the open dialog's request. */
export function pendingFileRefactorDialog(): FileRefactorRequest | null {
return useRequest.getState().request;
}
// ---------------------------------------------------------------------------
// Remembered options (IDEA keeps the last choice per dialog)
const OPTIONS_KEY = "openidea.projectTree.refactorOptions";
interface StoredOptions extends FileRefactorOptions {
openInEditor: boolean;
}
const DEFAULT_OPTIONS: StoredOptions = { references: true, textOccurrences: true, openInEditor: false };
function loadOptions(kind: string): StoredOptions {
try {
const raw = localStorage.getItem(`${OPTIONS_KEY}.${kind}`);
return raw ? { ...DEFAULT_OPTIONS, ...(JSON.parse(raw) as Partial<StoredOptions>) } : DEFAULT_OPTIONS;
} catch {
return DEFAULT_OPTIONS;
}
}
function saveOptions(kind: string, o: StoredOptions) {
try {
localStorage.setItem(`${OPTIONS_KEY}.${kind}`, JSON.stringify(o));
} catch {
/* storage unavailable */
}
}
const caseInsensitive = typeof navigator !== "undefined" && /Mac|Win/i.test(navigator.userAgent);
// ---------------------------------------------------------------------------
function useSearch() {
const [busy, setBusy] = useState(false);
const abort = useRef<AbortController | null>(null);
useEffect(() => () => abort.current?.abort(), []);
const run = async (moves: FileMove[], options: FileRefactorOptions, label: "Move" | "Rename") => {
abort.current?.abort();
const ctrl = new AbortController();
abort.current = ctrl;
setBusy(true);
try {
return await runFileRefactoring(moves, options, label, {
signal: ctrl.signal,
onSearched: () => {
// The search is over: closing the dialog must not cancel the refactoring.
abort.current = null;
closeFileRefactorDialog();
},
});
} finally {
setBusy(false);
if (abort.current === ctrl) abort.current = null;
}
};
const cancel = () => {
abort.current?.abort();
closeFileRefactorDialog();
};
return { busy, run, cancel };
}
function Options({
value,
onChange,
move,
disabled,
}: {
value: StoredOptions;
onChange: (o: StoredOptions) => void;
move: boolean;
disabled: boolean;
}) {
return (
<div className={styles.options}>
<Checkbox
label="Search for references"
checked={value.references}
disabled={disabled}
onChange={(references) => onChange({ ...value, references })}
/>
<Checkbox
label="Search in comments and strings"
checked={value.textOccurrences}
disabled={disabled}
onChange={(textOccurrences) => onChange({ ...value, textOccurrences })}
/>
{move && (
<Checkbox
label="Open moved files in editor"
checked={value.openInEditor}
disabled={disabled}
onChange={(openInEditor) => onChange({ ...value, openInEditor })}
/>
)}
</div>
);
}
function Busy() {
return (
<div className={styles.busy} role="status">
<Spinner size={14} />
<span>
<T>Looking for references…</T>
</span>
</div>
);
}
function RenameDialog({ request }: { request: Extract<FileRefactorRequest, { kind: "rename" }> }) {
const current = nameOf(request.path);
const [name, setName] = useState(current);
const [options, setOptions] = useState(() => loadOptions("rename"));
const input = useRef<HTMLInputElement>(null);
const { busy, run, cancel } = useSearch();
useEffect(() => {
const el = input.current;
if (!el) return;
el.focus();
// IDEA selects the name without the extension.
const stem = request.dir ? current : splitExtension(current)[0];
el.setSelectionRange(0, stem.length || current.length);
}, [current, request.dir]);
const check = validateName(name, { siblings: request.siblings, current, caseInsensitive });
const error = name === current ? null : check.error;
const submit = () => {
if (busy || error) return;
if (name === current) {
closeFileRefactorDialog();
return;
}
saveOptions("rename", options);
const moves = [{ from: request.path, to: joinPath(parentOf(request.path), name) }];
void run(moves, options, "Rename").then((status) => status === "done" && request.onDone?.(moves));
};
return (
<Dialog
open
title="Rename"
onClose={cancel}
width={460}
footerStart={busy ? <Busy /> : undefined}
buttons={[
{ label: "Refactor", role: "default", disabled: busy || !!error, onClick: submit },
{ label: "Cancel", role: "cancel", onClick: cancel },
]}
>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label className={styles.fieldLabel} htmlFor="file-rename-name">
Rename {request.dir ? "directory" : "file"} '{current}' and its usages to:
</label>
<Input
id="file-rename-name"
ref={input}
value={name}
disabled={busy}
invalid={!!error}
aria-label="New name"
onChange={(e) => setName(e.target.value)}
/>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<Options value={options} onChange={setOptions} move={false} disabled={busy} />
</form>
</Dialog>
);
}
/** Folder `typed` (absolute, or relative to the project root). */
function resolveTarget(typed: string, root: string): string {
const t = typed.trim().replace(/\/+$/, "");
if (!t) return root;
return t.startsWith("/") || /^[A-Za-z]:[\\/]/.test(t) ? t : joinPath(root, t);
}
function MoveDialog({ request }: { request: Extract<FileRefactorRequest, { kind: "move" }> }) {
const root = useProjectTree((s) => s.root) ?? "";
const [target, setTarget] = useState(request.target);
const [options, setOptions] = useState(() => loadOptions("move"));
const { busy, run, cancel } = useSearch();
const dest = resolveTarget(target, root);
const moves: FileMove[] = request.paths
.filter((p) => parentOf(p) !== dest)
.map((p) => ({ from: p, to: joinPath(dest, nameOf(p)) }));
const error = request.paths.some((p) => isSameOrInside(dest, p))
? "Cannot move a folder into itself"
: !moves.length
? "Already in this directory"
: null;
const subject =
request.paths.length === 1
? `Move ${request.dir || useProjectTree.getState().listings.has(request.paths[0]!) ? "directory" : "file"} '${nameOf(request.paths[0]!)}'`
: `Move ${request.paths.length} items`;
const submit = async () => {
if (busy || error) return;
saveOptions("move", options);
const status = await run(moves, options, "Move");
if (status !== "done") return;
request.onDone?.(moves);
};
return (
<Dialog
open
title="Move"
onClose={cancel}
width={520}
footerStart={busy ? <Busy /> : undefined}
buttons={[
{ label: "Refactor", role: "default", disabled: busy || !!error, onClick: () => void submit() },
{ label: "Cancel", role: "cancel", onClick: cancel },
]}
>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<div className={styles.subject}>{subject}</div>
<label className={styles.fieldLabel} htmlFor="file-move-target">
<T>To directory:</T>
</label>
<Input
id="file-move-target"
autoFocus
mono
value={target}
disabled={busy}
invalid={!!error}
aria-label="Target directory"
onChange={(e) => setTarget(e.target.value)}
/>
<div className={styles.hint}>{relativeTo(root, dest) || (dest === root ? "Project root" : dest)}</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<Options value={options} onChange={setOptions} move disabled={busy} />
</form>
</Dialog>
);
}
/** Overlay contribution rendering the open Rename / Move dialog. */
export function FileRefactorDialogHost() {
const request = useRequest((s) => s.request);
if (!request) return null;
return request.kind === "rename" ? (
<RenameDialog key={request.seq} request={request} />
) : (
<MoveDialog key={request.seq} request={request} />
);
}
/** The options last used in the Rename / Move dialog (for moves without a dialog: cut & paste). */
export function rememberedOptions(kind: "rename" | "move"): StoredOptions {
return loadOptions(kind);
}