mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-15 08:08:18 +00:00
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
317 lines
10 KiB
TypeScript
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);
|
|
}
|