feat(refactor): Java Pull Members Up / Push Members Down (#1183)

Refactor | Pull Members Up… and Push Members Down… for Java, from the Refactor menu, the editor context menu and Refactor This. A dialog picks the destination and the members (Make abstract / Keep abstract); problems are confirmed before anything changes; the edit is applied as one change or previewed first.

JDT LS offers neither as a code action, so the app-owned JDT LS bundle (plugins/java-usages 1.3.0) adds two delegate commands that run JDT's PullUpRefactoringProcessor / PushDownRefactoringProcessor and return the problems and the edit without applying it, exposed as lsp_hierarchy_members / lsp_hierarchy_refactor (also over the remote host dispatcher). The bundled jar was rebuilt from source with JDT LS 1.63.0 and matches the recorded SHA-256.

Co-authored-by: fhrmrk <[email protected]>
Цей коміт міститься в:
Park Jong Hyun authored and GitHub committed 2026-11-04 14:55:42 +08:00
1 джерело 5b46844f88
коміт ed145e3046
39 files changed
+2008 -14

No files matched your search

+58
Переглянути файл
@@ -418,6 +418,10 @@ export const commands = {
} | null, IdeError>(__TAURI_INVOKE("lsp_prepare_rename", { doc, pos })),
/** Computes the rename edit without applying it, so the UI can preview conflicts. */
lspRename: (request: RequestId, doc: DocId, pos: Pos, newName: string) => typedError<Renamed, IdeError>(__TAURI_INVOKE("lsp_rename", { request, doc, pos, newName })),
/** The class at `pos` and the members Pull Members Up / Push Members Down can move. */
lspHierarchyMembers: (request: RequestId, doc: DocId, pos: Pos, kind: HierarchyRefactoring) => typedError<HierarchyMembers, IdeError>(__TAURI_INVOKE("lsp_hierarchy_members", { request, doc, pos, kind })),
/** Checks Pull Members Up / Push Members Down and computes the edit without applying it. */
lspHierarchyRefactor: (request: RequestId, doc: DocId, pos: Pos, kind: HierarchyRefactoring, target: string | null, choices: HierarchyChoice[]) => typedError<HierarchyRefactored, IdeError>(__TAURI_INVOKE("lsp_hierarchy_refactor", { request, doc, pos, kind, target, choices })),
lspFormat: (doc: DocId, range: {
start: Pos,
end: Pos,
@@ -3822,6 +3826,19 @@ export type GradleTask = {
description: string | null,
};
export type HierarchyChoice = {
id: string,
makeAbstract: boolean,
};
export type HierarchyClass = {
/** Server-owned identity, passed back as the pull-up destination. */
id: string,
name: string,
qualifiedName: string,
interface: boolean,
};
export type HierarchyDirection =
/** Callers / supertypes. */
"incoming" |
@@ -3842,6 +3859,38 @@ export type HierarchyItem = {
fromRanges: Range[],
};
export type HierarchyMember = {
id: string,
/** `name(Params) : Type`, as the server renders it. */
label: string,
kind: HierarchyMemberKind,
/** The member at the caret. */
selected: boolean,
/** Can be declared abstract in the destination (pull up) or kept abstract (push down). */
abstractable: boolean,
};
export type HierarchyMemberKind = "field" | "method" | "type";
/**
* The class at a position and what can move: pull-up destinations, or the subclasses
* members are pushed into.
*/
export type HierarchyMembers = {
className: string,
targets: HierarchyClass[],
members: HierarchyMember[],
};
/** A checked refactoring: what the server found, and the edit unless a problem is fatal. */
export type HierarchyRefactored = {
problems: RefactoringProblem[],
edit: WorkspaceEdit | null,
};
/** IDEA's class-hierarchy refactorings. */
export type HierarchyRefactoring = "pullUp" | "pushDown";
export type Highlight = {
range: Range,
kind: HighlightKind,
@@ -5368,6 +5417,10 @@ export type PreparedRequest = {
body: string | null,
};
export type ProblemSeverity = "info" | "warning" | "error" |
/** The refactoring can't run at all; there is no edit. */
"fatal";
/** Memory of one row of the breakdown. */
export type ProcessMemory = {
category: MemoryCategory,
@@ -5624,6 +5677,11 @@ export type RecentProject = {
branch: string | null,
};
export type RefactoringProblem = {
severity: ProblemSeverity,
message: string,
};
export type ReferenceAccess = "read" | "write" | "readWrite" | "unknown";
export type ReferenceKind = "getter" | "setter" | "field" | "unknown";
+2
Переглянути файл
@@ -716,6 +716,8 @@ export class EditorController implements SlotHost {
{ kind: "action", action: "refactor.extractConstant" },
{ kind: "action", action: "refactor.extractMethod" },
{ kind: "action", action: "refactor.inline" },
{ kind: "action", action: "refactor.pullMembersUp" },
{ kind: "action", action: "refactor.pushMembersDown" },
{ kind: "separator" },
{ kind: "action", action: "refactor.safeDelete" },
],
+13 -1
Переглянути файл
@@ -19,7 +19,13 @@ import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSe
import { startRename } from "./rename";
import { formatDocument, reformatDialog } from "./format";
import { showHint } from "./hint";
import { refactorThis, runRefactoring, safeDelete, showNothingHere } from "@/editor/refactor";
import {
refactorThis,
runRefactoring,
safeDelete,
showNothingHere,
startHierarchyRefactoring,
} from "@/editor/refactor";
import { evaluateWhen } from "@/platform/context";
/** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */
@@ -314,6 +320,12 @@ export function registerLspActions(): () => void {
{ keyWhen: GENERATE_KEY_WHEN },
),
editorAction("refactor.inline", "Inline…", "Refactor", (v) => runRefactoring(v, "inline")),
editorAction("refactor.pullMembersUp", "Pull Members Up…", "Refactor", (v) =>
startHierarchyRefactoring(v, "pullUp"),
),
editorAction("refactor.pushMembersDown", "Push Members Down…", "Refactor", (v) =>
startHierarchyRefactoring(v, "pushDown"),
),
// Implement / Override Methods: the server's code actions for them (jdtls "Override/Implement
// Methods…", rust-analyzer "Implement missing members", TS "Implement interface"…).
editorAction("code.implementMethods", "Implement Methods…", "Code", (v) =>
+2
Переглянути файл
@@ -39,6 +39,7 @@ import { DOCUMENTATION_WINDOW } from "./docs";
import { prefetchMarkdown } from "./markdown";
import { outsideProjectBanner } from "./outsideProject";
import { implementationGutter, registerNavigationActions } from "@/editor/navigation";
import { HierarchyDialog } from "@/editor/refactor";
export function lspExtensions(ctx: EditorDocContext): Extension[] {
return [
@@ -74,6 +75,7 @@ export function activate(): () => void {
// After the templates (order 90): live template / postfix expansion keeps ⇥ first.
registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }),
registerOverlay({ id: "editor-lsp.popups", component: LspPopups }),
registerOverlay({ id: "editor-lsp.hierarchyDialog", component: HierarchyDialog }),
registerEditorBanner(outsideProjectBanner),
registerStatusItem({ id: "editor-lsp.usagesStatus", align: "left", priority: 10, component: UsagesStatus }),
// IDEA's Documentation tool window: right side, listed once F1 moved documentation there.
+80
Переглянути файл
@@ -0,0 +1,80 @@
.body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
font-size: var(--fs-ui);
color: var(--fg);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.targets {
color: var(--fg-muted);
overflow-wrap: anywhere;
}
.table {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 4px;
min-height: 0;
}
.head,
.row {
display: grid;
grid-template-columns: 1fr 110px;
align-items: center;
gap: 8px;
padding: 0 8px;
min-height: var(--h-list-row);
}
.head {
color: var(--fg-muted);
border-bottom: 1px solid var(--border);
}
.rows {
max-height: 280px;
overflow: auto;
}
.row:hover {
background: var(--bg-hover);
}
.member {
min-width: 0;
}
.label {
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.error {
color: var(--danger);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.busy {
display: flex;
align-items: center;
gap: 6px;
color: var(--fg-muted);
}
+143
Переглянути файл
@@ -0,0 +1,143 @@
/** IDEA's Pull Members Up and Push Members Down dialogs (`hierarchy.ts`). */
import { useState } from "react";
import { Checkbox, Dialog, Select, Spinner, T } from "@/ui";
import { useTx } from "@/platform/i18n";
import {
abstractForced,
choicesOf,
initialRows,
refactorHierarchy,
titleOf,
useHierarchyDialog,
type HierarchyRequest,
type MemberRow,
} from "./hierarchy";
import styles from "./HierarchyDialog.module.css";
export function HierarchyDialog() {
const request = useHierarchyDialog((s) => s.request);
if (!request) return null;
return <HierarchyDialogInner key={request.seq} request={request} />;
}
function HierarchyDialogInner({ request }: { request: HierarchyRequest }) {
const tx = useTx();
const { kind, info } = request;
const pullUp = kind === "pullUp";
const [target, setTarget] = useState<string | null>(info.targets[0]?.id ?? null);
const [rows, setRows] = useState<MemberRow[]>(() => initialRows(info));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const toInterface = pullUp && !!info.targets.find((t) => t.id === target)?.interface;
const ready = rows.some((r) => r.checked) && (!pullUp || !!target) && !busy;
const close = () => {
useHierarchyDialog.setState({ request: null });
request.view.focus();
};
const update = (id: string, change: Partial<MemberRow>) =>
setRows((all) => all.map((r) => (r.id === id ? { ...r, ...change } : r)));
const submit = async (preview: boolean) => {
if (!ready) return;
setBusy(true);
setError(null);
const outcome = await refactorHierarchy(
request,
pullUp ? target : null,
choicesOf(rows, kind, toInterface),
preview,
);
if (useHierarchyDialog.getState().request !== request) return;
setBusy(false);
if (outcome.status === "done") close();
else if (outcome.error) setError(outcome.error);
};
return (
<Dialog
open
onClose={close}
title={titleOf(kind)}
width={560}
buttons={[
{ label: "Preview", role: "extra", disabled: !ready, onClick: () => void submit(true) },
{ label: "Refactor", role: "default", disabled: !ready, onClick: () => void submit(false) },
{ label: "Cancel", role: "cancel" },
]}
footerStart={
busy ? (
<span className={styles.busy}>
<Spinner size={12} />
<T>Checking for conflicts…</T>
</span>
) : undefined
}
>
<div className={styles.body}>
{pullUp ? (
<label className={styles.field}>
<span>{tx`Pull up members of ${info.className} to:`}</span>
<Select
aria-label="Destination class"
value={target}
options={info.targets.map((t) => ({
value: t.id,
label: t.qualifiedName,
icon: t.interface ? "interface" : "class",
}))}
onChange={setTarget}
/>
</label>
) : (
<div className={styles.field}>
<span>{tx`Push members of ${info.className} down to:`}</span>
<span className={styles.targets}>{info.targets.map((t) => t.name).join(", ")}</span>
</div>
)}
<div
className={styles.table}
role="table"
aria-label={pullUp ? tx("Members to be pulled up") : tx("Members to be pushed down")}
>
<div className={styles.head} role="row">
<span role="columnheader">
<T>Member</T>
</span>
<span role="columnheader">{pullUp ? <T>Make abstract</T> : <T>Keep abstract</T>}</span>
</div>
<div className={styles.rows}>
{rows.map((row) => {
const forced = abstractForced(row, kind, toInterface);
return (
<div key={row.id} className={styles.row} role="row">
<Checkbox
className={styles.member}
checked={row.checked}
onChange={(checked) => update(row.id, { checked })}
label={<span className={styles.label}>{row.label}</span>}
/>
<Checkbox
aria-label={pullUp ? "Make abstract" : "Keep abstract"}
checked={row.checked && (forced || (row.abstractable && row.makeAbstract))}
disabled={!row.checked || forced || !row.abstractable}
onChange={(makeAbstract) => update(row.id, { makeAbstract })}
/>
</div>
);
})}
</div>
</div>
{toInterface && (
<span className={styles.hint}>
<T>Methods pulled up to an interface are declared abstract there.</T>
</span>
)}
{error && (
<span className={styles.error} role="alert">
{tx(error)}
</span>
)}
</div>
</Dialog>
);
}
+21 -1
Переглянути файл
@@ -2,6 +2,7 @@
* IDEA's refactorings and how language-server code actions map onto them:
* `refactor.extract.*` → Extract Method / Introduce Variable…, `refactor.inline*`
* → Inline, `refactor.move*` → Move, signature rewrites → Change Signature.
* Pull Members Up / Push Members Down come from the Java extension, not code actions.
* Pure; used by Refactor This (⌃T) and the Refactor menu actions.
*/
import type { CodeAction } from "@/bindings";
@@ -16,7 +17,9 @@ export type RefactoringId =
| "extractConstant"
| "extractField"
| "extractParameter"
| "inline";
| "inline"
| "pullMembersUp"
| "pushMembersDown";
export interface Refactoring {
id: RefactoringId;
@@ -94,8 +97,25 @@ export const REFACTORINGS: readonly Refactoring[] = [
sel ? "Selected block should represent an expression" : "Caret should be inside an expression in a function",
},
{ id: "inline", action: "refactor.inline", label: "Inline…", kind: "refactor.inline", reason: () => AT_NAME },
{
id: "pullMembersUp",
action: "refactor.pullMembersUp",
label: "Pull Members Up…",
kind: null,
reason: () => "Caret should be inside a class that has members to pull up",
},
{
id: "pushMembersDown",
action: "refactor.pushMembersDown",
label: "Push Members Down…",
kind: null,
reason: () => "Caret should be inside a class that has members to push down",
},
];
/** Refactorings of the class hierarchy, run by the app-owned Java extension (`hierarchy.ts`). */
export const HIERARCHY_REFACTORINGS: ReadonlySet<RefactoringId> = new Set(["pullMembersUp", "pushMembersDown"]);
export function refactoring(id: RefactoringId): Refactoring {
return REFACTORINGS.find((r) => r.id === id)!;
}
+237
Переглянути файл
@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { HierarchyMembers, HierarchyRefactored, WorkspaceEdit } from "@/bindings";
type Result = { status: "ok"; data: unknown } | { status: "error"; error: { kind: string; message: string } };
const ok = (data: unknown): Result => ({ status: "ok", data });
const refused = (message: string): Result => ({ status: "error", error: { kind: "invalidInput", message } });
let members: Result = ok(null);
let refactored: Result = ok(null);
const lspHierarchyMembers = vi.fn(async () => members);
const lspHierarchyRefactor = vi.fn(async () => refactored);
const applyWorkspaceEdit = vi.fn(async () => ok(null));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: {
...actual.commands,
lspHierarchyMembers,
lspHierarchyRefactor,
applyWorkspaceEdit,
cancelRequest: vi.fn(async () => undefined),
},
};
});
const showHint = vi.fn();
vi.mock("@/editor/lsp/hint", () => ({ showHint }));
let answer = "continue";
const ask = vi.fn(async () => ({ button: answer }));
vi.mock("@/workbench/messages", () => ({ ask }));
const {
abstractForced,
choicesOf,
conflicts,
fatalProblem,
initialRows,
refactorHierarchy,
startHierarchyRefactoring,
useHierarchyDialog,
} = await import("./hierarchy");
const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore");
const { lspDoc } = await import("@/editor/lsp/lsp");
const INFO: HierarchyMembers = {
className: "Dog",
targets: [
{ id: "animal", name: "Animal", qualifiedName: "zoo.Animal", interface: false },
{ id: "speaker", name: "Speaker", qualifiedName: "zoo.Speaker", interface: true },
],
members: [
{ id: "tail", label: "tail : int", kind: "field", selected: false, abstractable: false },
{ id: "bark", label: "bark() : String", kind: "method", selected: true, abstractable: true },
],
};
const at = (line: number) => ({ start: { line, col: 0 }, end: { line, col: 0 } });
const EDIT = {
files: [
{ kind: "edit", path: "/p/Dog.java", edits: [{ range: at(1), new_text: "" }] },
{ kind: "edit", path: "/p/Animal.java", edits: [{ range: at(2), new_text: "String bark() {}" }] },
],
} as WorkspaceEdit;
const done = (problems: HierarchyRefactored["problems"], edit: WorkspaceEdit | null = EDIT): Result =>
ok({ problems, edit } satisfies HierarchyRefactored);
let view: EditorView | null = null;
function editor(readOnly = false) {
view = new EditorView({
state: EditorState.create({
doc: 'class Dog extends Animal { String bark() { return ""; } }',
selection: { anchor: 30 },
extensions: [lspDoc.of({ doc: 7, path: "/p/Dog.java", language: "java", uiMode: null, readOnly })],
}),
});
return view;
}
afterEach(() => {
view?.destroy();
view = null;
closePopup();
useHierarchyDialog.setState({ request: null });
answer = "continue";
vi.clearAllMocks();
});
describe("member choices", () => {
it("starts from the member at the caret and sends what is checked", () => {
const rows = initialRows(INFO);
expect(rows.map((r) => [r.id, r.checked])).toEqual([
["tail", false],
["bark", true],
]);
rows[0]!.checked = true;
rows[1]!.makeAbstract = true;
expect(choicesOf(rows, "pullUp", false)).toEqual([
{ id: "tail", makeAbstract: false },
{ id: "bark", makeAbstract: true },
]);
});
it("declares methods abstract in an interface, and never abstracts a field", () => {
const rows = initialRows(INFO).map((r) => ({ ...r, checked: true, makeAbstract: true }));
const [field, method] = rows;
expect(abstractForced(method!, "pullUp", true)).toBe(true);
expect(abstractForced(method!, "pushDown", true)).toBe(false);
expect(abstractForced(field!, "pullUp", true)).toBe(false);
rows[1]!.makeAbstract = false;
expect(choicesOf(rows, "pullUp", true)).toEqual([
{ id: "tail", makeAbstract: false },
{ id: "bark", makeAbstract: true },
]);
expect(choicesOf(rows, "pushDown", false)[1]).toEqual({ id: "bark", makeAbstract: false });
});
it("separates fatal problems from the ones the user may accept", () => {
const problems = [
{ severity: "info", message: "i" },
{ severity: "warning", message: "w" },
{ severity: "error", message: "e" },
] as const;
expect(fatalProblem([...problems])).toBeNull();
expect(conflicts([...problems])).toEqual(["w", "e"]);
expect(fatalProblem([...problems, { severity: "fatal", message: "f" }])).toBe("f");
});
});
describe("Pull Members Up / Push Members Down", () => {
it("opens the dialog with the server's members, at the caret", async () => {
members = ok(INFO);
const v = editor();
await startHierarchyRefactoring(v, "pullUp");
expect(lspHierarchyMembers).toHaveBeenCalledWith(expect.any(Number), 7, { line: 0, col: 30 }, "pullUp");
const request = useHierarchyDialog.getState().request;
expect(request?.info.className).toBe("Dog");
expect(request?.kind).toBe("pullUp");
});
it("says why when the class can't be refactored, or the file is read-only", async () => {
members = refused("Type has no super types.");
await startHierarchyRefactoring(editor(), "pullUp");
expect(showHint).toHaveBeenCalledWith(expect.anything(), "Cannot perform refactoring. Type has no super types");
expect(useHierarchyDialog.getState().request).toBeNull();
view!.destroy();
await startHierarchyRefactoring(editor(true), "pushDown");
expect(showHint).toHaveBeenLastCalledWith(expect.anything(), "Cannot perform refactoring. The file is read-only");
expect(lspHierarchyMembers).toHaveBeenCalledOnce();
});
it("applies a clean edit at once", async () => {
members = ok(INFO);
refactored = done([{ severity: "info", message: "fine" }]);
await startHierarchyRefactoring(editor(), "pullUp");
const request = useHierarchyDialog.getState().request!;
const choices = [{ id: "bark", makeAbstract: false }];
expect(await refactorHierarchy(request, "animal", choices, false)).toEqual({ status: "done" });
expect(lspHierarchyRefactor).toHaveBeenCalledWith(
expect.any(Number),
7,
{ line: 0, col: 30 },
"pullUp",
"animal",
choices,
);
expect(ask).not.toHaveBeenCalled();
expect(applyWorkspaceEdit).toHaveBeenCalledWith(EDIT);
});
it("asks about conflicts first; Cancel changes nothing and keeps the dialog", async () => {
members = ok(INFO);
refactored = done([{ severity: "error", message: "Field 'tail' is not accessible from type 'Animal'" }]);
await startHierarchyRefactoring(editor(), "pullUp");
const request = useHierarchyDialog.getState().request!;
answer = "cancel";
expect(await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], false)).toEqual({
status: "stay",
});
expect(ask).toHaveBeenCalledWith(expect.objectContaining({ title: "Problems Detected" }));
expect(applyWorkspaceEdit).not.toHaveBeenCalled();
answer = "continue";
expect((await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], false)).status).toBe(
"done",
);
expect(applyWorkspaceEdit).toHaveBeenCalledOnce();
});
it("keeps the dialog with the reason when nothing can be done", async () => {
members = ok(INFO);
await startHierarchyRefactoring(editor(), "pushDown");
const request = useHierarchyDialog.getState().request!;
refactored = done([{ severity: "fatal", message: "No subclasses" }], null);
expect(await refactorHierarchy(request, null, [{ id: "bark", makeAbstract: true }], false)).toEqual({
status: "stay",
error: "No subclasses",
});
refactored = refused("The class changed; open the dialog again");
expect(await refactorHierarchy(request, null, [{ id: "bark", makeAbstract: true }], false)).toEqual({
status: "stay",
error: "The class changed; open the dialog again",
});
expect(applyWorkspaceEdit).not.toHaveBeenCalled();
});
it("refuses a request whose position an edit has moved", async () => {
members = ok(INFO);
const v = editor();
await startHierarchyRefactoring(v, "pullUp");
v.dispatch({ changes: { from: 0, insert: "// moved\n" } });
const request = useHierarchyDialog.getState().request!;
expect(await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], false)).toEqual({
status: "stay",
error: "The file changed; open the dialog again",
});
expect(lspHierarchyRefactor).not.toHaveBeenCalled();
});
it("previews the edit before applying it when asked", async () => {
members = ok(INFO);
refactored = done([]);
await startHierarchyRefactoring(editor(), "pullUp");
const request = useHierarchyDialog.getState().request!;
expect((await refactorHierarchy(request, "animal", [{ id: "bark", makeAbstract: false }], true)).status).toBe(
"done",
);
const popup = useLspPopup.getState().popup;
expect(popup?.kind).toBe("preview");
if (popup?.kind !== "preview") return;
expect(popup.title).toBe("Pull Members Up from 'Dog'");
expect(popup.summary.files).toBe(2);
expect(applyWorkspaceEdit).not.toHaveBeenCalled();
popup.onConfirm();
await vi.waitFor(() => expect(applyWorkspaceEdit).toHaveBeenCalledWith(EDIT));
});
});
+207
Переглянути файл
@@ -0,0 +1,207 @@
/**
* Pull Members Up / Push Members Down, IDEA's class-hierarchy refactorings. The
* language server (the app-owned JDT extension) lists the class's members and
* destinations, checks the chosen move and computes the edit; the dialog
* (`HierarchyDialog.tsx`) picks the members, problems are confirmed like IDEA's
* "Problems Detected", and the edit is applied as one change (or previewed first).
*/
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { create } from "zustand";
import type {
HierarchyChoice,
HierarchyMemberKind,
HierarchyMembers,
HierarchyRefactoring,
Pos,
RefactoringProblem,
WorkspaceEdit,
} from "@/bindings";
import { commands, isCancelled, nextRequestId, unavailableText } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { whenAllSynced, whenSynced } from "@/editor/api";
import { ask } from "@/workbench/messages";
import { docOf, isUnavailable, lspRequest, reportLspError } from "@/editor/lsp/lsp";
import { offsetToPos } from "@/editor/lsp/positions";
import { showHint } from "@/editor/lsp/hint";
import { openPopup } from "@/editor/lsp/popupStore";
import { summarizeWorkspaceEdit } from "@/editor/lsp/workspaceEdit";
import { cannotPerform } from "./catalog";
export interface MemberRow {
id: string;
label: string;
kind: HierarchyMemberKind;
checked: boolean;
/** Pull up: declare abstract in the destination. Push down: keep an abstract declaration. */
makeAbstract: boolean;
abstractable: boolean;
}
export function initialRows(info: HierarchyMembers): MemberRow[] {
return info.members.map((m) => ({
id: m.id,
label: m.label,
kind: m.kind,
checked: m.selected,
makeAbstract: false,
abstractable: m.abstractable,
}));
}
/** A method pulled up to an interface is only declared there (JDT, like IDEA, never moves its body). */
export function abstractForced(row: MemberRow, kind: HierarchyRefactoring, toInterface: boolean): boolean {
return kind === "pullUp" && toInterface && row.kind === "method";
}
export function choicesOf(
rows: readonly MemberRow[],
kind: HierarchyRefactoring,
toInterface: boolean,
): HierarchyChoice[] {
return rows
.filter((r) => r.checked)
.map((r) => ({
id: r.id,
makeAbstract: abstractForced(r, kind, toInterface) || (r.abstractable && r.makeAbstract),
}));
}
/** The first fatal problem: nothing can be done. */
export function fatalProblem(problems: readonly RefactoringProblem[]): string | null {
return problems.find((p) => p.severity === "fatal")?.message ?? null;
}
/** Problems the user confirms before the change is made (IDEA lists errors and warnings). */
export function conflicts(problems: readonly RefactoringProblem[]): string[] {
return problems.filter((p) => p.severity === "error" || p.severity === "warning").map((p) => p.message);
}
export function titleOf(kind: HierarchyRefactoring): string {
return kind === "pullUp" ? "Pull Members Up" : "Push Members Down";
}
export interface HierarchyRequest {
seq: number;
view: EditorView;
kind: HierarchyRefactoring;
doc: number;
pos: Pos;
/** The document `pos` refers to. */
text: Text;
info: HierarchyMembers;
}
export const useHierarchyDialog = create<{ request: HierarchyRequest | null }>(() => ({ request: null }));
let seq = 0;
let running: AbortController | null = null;
function errorText(e: unknown): string {
if (isUnavailable(e)) return unavailableText(e, "No language server available");
return e instanceof Error ? e.message : String(e);
}
/** Pull Members Up… / Push Members Down…: asks the server what can move, then opens the dialog. */
export async function startHierarchyRefactoring(view: EditorView, kind: HierarchyRefactoring): Promise<void> {
const ctx = docOf(view);
if (!ctx) return;
if (ctx.readOnly) {
showHint(view, cannotPerform("The file is read-only"));
return;
}
running?.abort();
const run = (running = new AbortController());
await whenSynced(view);
const state = view.state;
const pos = offsetToPos(state.doc, state.selection.main.head);
const request = nextRequestId();
const abort = () => void commands.cancelRequest(request);
run.signal.addEventListener("abort", abort);
try {
const info = await lspRequest(() => commands.lspHierarchyMembers(request, ctx.doc, pos, kind));
if (run.signal.aborted || view.state.doc !== state.doc) return;
useHierarchyDialog.setState({ request: { seq: ++seq, view, kind, doc: ctx.doc, pos, text: state.doc, info } });
} catch (e) {
if (isCancelled(e) || run.signal.aborted) return;
showHint(view, cannotPerform(errorText(e)));
} finally {
run.signal.removeEventListener("abort", abort);
if (running === run) running = null;
}
}
async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
try {
await whenAllSynced();
await lspRequest(() => commands.applyWorkspaceEdit(edit));
} catch (e) {
reportLspError(view, e, true);
}
}
/** IDEA's "Problems Detected": true to go on anyway. */
async function confirmConflicts(messages: readonly string[]): Promise<boolean> {
const { button } = await ask({
title: "Problems Detected",
icon: "warning",
body: messages.join("\n"),
buttons: [
{ id: "continue", label: "Continue", primary: true },
{ id: "cancel", label: "Cancel" },
],
});
return button === "continue";
}
export type RefactorOutcome =
/** The edit was applied or handed to the preview: close the dialog. */
| { status: "done" }
/** Stay in the dialog (the user cancelled at the problems); `error` says why nothing can be done. */
| { status: "stay"; error?: string };
/**
* Checks the chosen move, confirms its problems and applies the edit, or opens the
* preview with `preview`. The dialog stays open until the change is made.
*/
export async function refactorHierarchy(
request: HierarchyRequest,
target: string | null,
choices: HierarchyChoice[],
preview: boolean,
): Promise<RefactorOutcome> {
const { view, kind } = request;
// The position names the class: after an edit it may name another one.
if (view.state.doc !== request.text) return { status: "stay", error: "The file changed; open the dialog again" };
let refactored;
try {
await whenSynced(view);
refactored = await lspRequest(() =>
commands.lspHierarchyRefactor(nextRequestId(), request.doc, request.pos, kind, target, choices),
);
} catch (e) {
if (isCancelled(e)) return { status: "stay" };
return { status: "stay", error: errorText(e) };
}
const fatal = fatalProblem(refactored.problems);
if (fatal || !refactored.edit) return { status: "stay", error: fatal ?? "Nothing to change" };
const found = conflicts(refactored.problems);
if (found.length && !(await confirmConflicts(found))) return { status: "stay" };
const edit = refactored.edit;
const summary = summarizeWorkspaceEdit(edit);
if (!summary.files) return { status: "stay", error: "Nothing to change" };
if (preview) {
openPopup({
kind: "preview",
title:
kind === "pullUp"
? tx`Pull Members Up from '${request.info.className}'`
: tx`Push Members Down from '${request.info.className}'`,
summary,
confirmLabel: "Do Refactor",
onConfirm: () => void applyEdit(view, edit),
});
} else {
await applyEdit(view, edit);
}
return { status: "done" };
}
+2
Переглянути файл
@@ -1,2 +1,4 @@
export { refactorThis, runRefactoring, showNothingHere } from "./refactorThis";
export { askUsagesDetected, fileUsages, safeDelete, viewUsages } from "./safeDelete";
export { startHierarchyRefactoring } from "./hierarchy";
export { HierarchyDialog } from "./HierarchyDialog";
+28 -2
Переглянути файл
@@ -64,6 +64,8 @@ vi.mock("./introduce", () => ({
}));
const safeDelete = vi.fn();
vi.mock("./safeDelete", () => ({ safeDelete }));
const startHierarchyRefactoring = vi.fn();
vi.mock("./hierarchy", () => ({ startHierarchyRefactoring }));
const { classifyAction, kindAdvertised, cannotPerform } = await import("./catalog");
const { refactorThis, runRefactoring } = await import("./refactorThis");
@@ -88,13 +90,19 @@ const answer =
ok({ actions, advertisedKinds } satisfies CodeActionQuery);
let view: EditorView | null = null;
function editor(text = "fn main() { let total = price * 2; }") {
function editor(text = "fn main() { let total = price * 2; }", language = "rust") {
view = new EditorView({
state: EditorState.create({
doc: text,
selection: { anchor: text.indexOf("price") + 1 },
extensions: [
lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false }),
lspDoc.of({
doc: 1,
path: `/p/a.${language === "java" ? "java" : "rs"}`,
language,
uiMode: null,
readOnly: false,
}),
inplaceRenameExtension(),
],
}),
@@ -179,6 +187,24 @@ describe("Refactor This", () => {
expect(lspCodeActionQuery.mock.calls[0]![3]).toEqual(["refactor"]);
});
it("offers Pull Members Up / Push Members Down in Java files, after IDEA's other refactorings", async () => {
query = answer([action("Convert to switch expression", "refactor.rewrite")]);
const v = editor("class Dog extends Animal { int price; }", "java");
await refactorThis(v, vi.fn());
expect(labels()).toEqual(["Pull Members Up…", "Push Members Down…", "Convert to switch expression"]);
const items = (useLspPopup.getState().popup as { items: { label: string; run: () => void }[] }).items;
items.find((i) => i.label === "Push Members Down…")!.run();
expect(startHierarchyRefactoring).toHaveBeenCalledWith(v, "pushDown");
closePopup();
v.destroy();
// Other languages have no server for them; nor does Java while its server is down.
await refactorThis(editor(), vi.fn());
expect(labels()).not.toContain("Pull Members Up…");
query = async () => unavailable;
await refactorThis(editor("class Dog {}", "java"), vi.fn());
expect(labels()).toEqual(["Nothing here"]);
});
it("runs the mapped server action, Rename and Safe Delete", async () => {
prepare = ok({ start: { line: 0, col: 0 }, end: { line: 0, col: 1 } });
query = answer([action("Inline variable", "refactor.inline")]);
+20 -3
Переглянути файл
@@ -20,6 +20,7 @@ import { caretAnchor, openPopup, type ListPopupItem } from "@/editor/lsp/popupSt
import { applyCodeAction } from "@/editor/lsp/intentions";
import { planLocalRename } from "@/editor/lsp/syntaxFallback";
import {
HIERARCHY_REFACTORINGS,
REFACTORINGS,
cannotPerform,
classifyAction,
@@ -38,6 +39,10 @@ import {
introducesName,
} from "./serverIntroduce";
import { safeDelete } from "./safeDelete";
import { startHierarchyRefactoring } from "./hierarchy";
/** Languages whose server runs the class-hierarchy refactorings (the app-owned JDT extension). */
const HIERARCHY_LANGUAGES = new Set(["java"]);
/** Outcome of the server query: its answer, or why there is none. */
export type ServerAnswer =
@@ -214,8 +219,12 @@ async function expressionAtCaret(view: EditorView, signal: AbortSignal): Promise
export function refactorThisItems(
view: EditorView,
answer: ServerAnswer,
facts: { symbol: boolean; expression: boolean },
run: { rename: (view: EditorView) => unknown; safeDelete: (view: EditorView) => unknown },
facts: { symbol: boolean; expression: boolean; hierarchy?: boolean },
run: {
rename: (view: EditorView) => unknown;
safeDelete: (view: EditorView) => unknown;
hierarchy?: (view: EditorView, kind: "pullUp" | "pushDown") => unknown;
},
): ListPopupItem[] {
const { mapped, other } = groupActions(answer.status === "ok" ? answer.query.actions : []);
const serverUsable = answer.status === "ok";
@@ -231,6 +240,8 @@ export function refactorThisItems(
else if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions, r.id);
else if (r.id === "rename" && facts.symbol) go = () => run.rename(view);
else if (r.id === "safeDelete" && facts.symbol && !docOf(view)?.readOnly) go = () => run.safeDelete(view);
else if (HIERARCHY_REFACTORINGS.has(r.id) && facts.hierarchy && serverUsable && run.hierarchy)
go = () => run.hierarchy!(view, r.id === "pullMembersUp" ? "pullUp" : "pushDown");
else if (INTRODUCE[r.id] && facts.expression && !serverUsable)
go = () => introduceFallback(view, INTRODUCE[r.id]!, answer.status === "notReady" ? "notReady" : "error");
if (!go) continue;
@@ -266,7 +277,13 @@ export async function refactorThis(view: EditorView, rename: (view: EditorView)
symbolAtCaret(view, answer.status !== "notReady"),
expressionAtCaret(view, run.signal),
]);
items = refactorThisItems(view, answer, { symbol, expression }, { rename, safeDelete });
const hierarchy = HIERARCHY_LANGUAGES.has(ctx.language) && !ctx.readOnly;
items = refactorThisItems(
view,
answer,
{ symbol, expression, hierarchy },
{ rename, safeDelete, hierarchy: startHierarchyRefactoring },
);
} catch (e) {
if (isCancelled(e)) return;
throw e;
+2
Переглянути файл
@@ -228,6 +228,8 @@ export const IDEA_ACTIONS: Readonly<Record<string, string>> = {
Inline: "refactor.inline",
Move: "refactor.move",
SafeDelete: "refactor.safeDelete",
MembersPullUp: "refactor.pullMembersUp",
MemberPushDown: "refactor.pushMembersDown",
Run: "run.run",
Debug: "run.debug",
RunClass: "run.runContext",
+2
Переглянути файл
@@ -170,6 +170,8 @@ export const intellijMacos: Record<string, string[]> = {
"refactor.inline": ["Meta+Alt+N"],
"refactor.move": ["F6"],
"refactor.safeDelete": ["Meta+Delete"],
"refactor.pullMembersUp": [],
"refactor.pushMembersDown": [],
// File / editor tabs
"file.save": ["Meta+S"],
+2
Переглянути файл
@@ -171,6 +171,8 @@ export const intellijWindows: Record<string, string[]> = {
"refactor.inline": ["Ctrl+Alt+N"],
"refactor.move": ["F6"],
"refactor.safeDelete": ["Alt+Delete"],
"refactor.pullMembersUp": [],
"refactor.pushMembersDown": [],
// File / editor tabs
"file.save": ["Ctrl+S"],
+2
Переглянути файл
@@ -172,6 +172,8 @@ export const vscodeMacos: Record<string, string[]> = {
"refactor.inline": [],
"refactor.move": [],
"refactor.safeDelete": [],
"refactor.pullMembersUp": [],
"refactor.pushMembersDown": [],
// File / editor tabs
"file.save": ["Meta+S"],
+2
Переглянути файл
@@ -242,6 +242,8 @@
"menu.refactor.extractVariable": "Introduce Variable…",
"menu.refactor.inline": "Inline…",
"menu.refactor.move": "Move…",
"menu.refactor.pullMembersUp": "Pull Members Up…",
"menu.refactor.pushMembersDown": "Push Members Down…",
"menu.refactor.rename": "Rename…",
"menu.refactor.safeDelete": "Safe Delete…",
"menu.refactor.thisMenu": "Refactor This…",
+2
Переглянути файл
@@ -241,6 +241,8 @@
"menu.refactor.extractVariable": "Introduire une variable…",
"menu.refactor.inline": "Intégrer…",
"menu.refactor.move": "Déplacer…",
"menu.refactor.pullMembersUp": "Remonter les membres…",
"menu.refactor.pushMembersDown": "Descendre les membres…",
"menu.refactor.rename": "Renommer…",
"menu.refactor.safeDelete": "Suppression sûre…",
"menu.refactor.thisMenu": "Refactoriser ceci…",
+2
Переглянути файл
@@ -242,6 +242,8 @@
"menu.refactor.extractVariable": "변수 도입…",
"menu.refactor.inline": "인라인…",
"menu.refactor.move": "이동…",
"menu.refactor.pullMembersUp": "멤버 끌어올리기…",
"menu.refactor.pushMembersDown": "멤버 끌어내리기…",
"menu.refactor.rename": "이름 변경…",
"menu.refactor.safeDelete": "안전 삭제…",
"menu.refactor.thisMenu": "이 항목 리팩터링…",
+22 -1
Переглянути файл
@@ -5784,5 +5784,26 @@
"Weekly Sonnet limit": "Limite hebdomadaire Sonnet",
"Marketplace index URL (https://…/index.json or a folder path)": "URL de l'index de la marketplace (https://…/index.json ou un chemin de dossier)",
"Editor Providers": "Fournisseurs d'éditeurs",
"Syntax errors": "Erreurs de syntaxe"
"Syntax errors": "Erreurs de syntaxe",
"Pull Members Up…": "Remonter les membres…",
"Push Members Down…": "Descendre les membres…",
"Pull Members Up": "Remonter les membres",
"Push Members Down": "Descendre les membres",
"Caret should be inside a class that has members to pull up": "Le curseur doit se trouver dans une classe ayant des membres à remonter",
"Caret should be inside a class that has members to push down": "Le curseur doit se trouver dans une classe ayant des membres à descendre",
"Problems Detected": "Problèmes détectés",
"Nothing to change": "Rien à modifier",
"Pull Members Up from '{name}'": "Remonter les membres de '{name}'",
"Push Members Down from '{name}'": "Descendre les membres de '{name}'",
"Pull up members of {name} to:": "Remonter les membres de {name} vers :",
"Push members of {name} down to:": "Descendre les membres de {name} vers :",
"Checking for conflicts…": "Recherche de conflits…",
"Destination class": "Classe de destination",
"Members to be pulled up": "Membres à remonter",
"Members to be pushed down": "Membres à descendre",
"Member": "Membre",
"Make abstract": "Rendre abstrait",
"Keep abstract": "Garder abstrait",
"Methods pulled up to an interface are declared abstract there.": "Les méthodes remontées dans une interface y sont déclarées abstraites.",
"The file changed; open the dialog again": "Le fichier a changé ; rouvrez la boîte de dialogue"
}
+22 -1
Переглянути файл
@@ -5830,5 +5830,26 @@
"Could not start Compose project {project}": "Compose 프로젝트 {project}을(를) 시작할 수 없습니다",
"Could not stop Compose project {project}": "Compose 프로젝트 {project}을(를) 중지할 수 없습니다",
"Could not restart Compose project {project}": "Compose 프로젝트 {project}을(를) 다시 시작할 수 없습니다",
"Could not take down Compose project {project}": "Compose 프로젝트 {project}을(를) 중지 및 제거할 수 없습니다"
"Could not take down Compose project {project}": "Compose 프로젝트 {project}을(를) 중지 및 제거할 수 없습니다",
"Pull Members Up…": "멤버 끌어올리기…",
"Push Members Down…": "멤버 끌어내리기…",
"Pull Members Up": "멤버 끌어올리기",
"Push Members Down": "멤버 끌어내리기",
"Caret should be inside a class that has members to pull up": "캐럿은 끌어올릴 멤버가 있는 클래스 안에 있어야 합니다",
"Caret should be inside a class that has members to push down": "캐럿은 끌어내릴 멤버가 있는 클래스 안에 있어야 합니다",
"Problems Detected": "문제 감지됨",
"Nothing to change": "변경할 내용이 없습니다",
"Pull Members Up from '{name}'": "'{name}'에서 멤버 끌어올리기",
"Push Members Down from '{name}'": "'{name}'에서 멤버 끌어내리기",
"Pull up members of {name} to:": "{name}의 멤버를 끌어올릴 위치:",
"Push members of {name} down to:": "{name}의 멤버를 끌어내릴 위치:",
"Checking for conflicts…": "충돌 확인 중…",
"Destination class": "대상 클래스",
"Members to be pulled up": "끌어올릴 멤버",
"Members to be pushed down": "끌어내릴 멤버",
"Member": "멤버",
"Make abstract": "추상으로 만들기",
"Keep abstract": "추상 선언 유지",
"Methods pulled up to an interface are declared abstract there.": "인터페이스로 끌어올린 메서드는 그곳에 추상 메서드로 선언됩니다.",
"The file changed; open the dialog again": "파일이 변경되었습니다. 대화상자를 다시 여세요"
}
+22 -1
Переглянути файл
@@ -6025,5 +6025,26 @@
"Back to Changed Files": "返回更改文件列表",
"Default (the module's JDK)": "默认(模块的 JDK)",
"Use classpath of module": "使用模块的类路径",
"Find by main class": "按主类查找"
"Find by main class": "按主类查找",
"Pull Members Up…": "上拉成员…",
"Push Members Down…": "下推成员…",
"Pull Members Up": "上拉成员",
"Push Members Down": "下推成员",
"Caret should be inside a class that has members to pull up": "光标应位于具有可上拉成员的类中",
"Caret should be inside a class that has members to push down": "光标应位于具有可下推成员的类中",
"Problems Detected": "检测到问题",
"Nothing to change": "没有需要更改的内容",
"Pull Members Up from '{name}'": "从 '{name}' 上拉成员",
"Push Members Down from '{name}'": "从 '{name}' 下推成员",
"Pull up members of {name} to:": "将 {name} 的成员上拉到:",
"Push members of {name} down to:": "将 {name} 的成员下推到:",
"Checking for conflicts…": "正在检查冲突…",
"Destination class": "目标类",
"Members to be pulled up": "要上拉的成员",
"Members to be pushed down": "要下推的成员",
"Member": "成员",
"Make abstract": "设为抽象",
"Keep abstract": "保留抽象声明",
"Methods pulled up to an interface are declared abstract there.": "上拉到接口的方法将在接口中声明为抽象方法。",
"The file changed; open the dialog again": "文件已更改,请重新打开对话框"
}
+2
Переглянути файл
@@ -242,6 +242,8 @@
"menu.refactor.extractVariable": "引入变量…",
"menu.refactor.inline": "内联…",
"menu.refactor.move": "移动…",
"menu.refactor.pullMembersUp": "上拉成员…",
"menu.refactor.pushMembersDown": "下推成员…",
"menu.refactor.rename": "重命名…",
"menu.refactor.safeDelete": "安全删除…",
"menu.refactor.thisMenu": "重构此项…",