mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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 <81476595+fhrmrk@users.noreply.github.com>
This commit is contained in:
@@ -412,6 +412,56 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
/// The class at `pos` and the members a class-hierarchy refactoring can move.
|
||||
pub async fn lsp_hierarchy_members(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
) -> IdeResult<HierarchyMembers> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = project.lsp.hierarchy_members(doc, pos, kind, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Checks a class-hierarchy refactoring and computes its edit without applying it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn lsp_hierarchy_refactor(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
target: Option<String>,
|
||||
choices: Vec<HierarchyChoice>,
|
||||
) -> IdeResult<HierarchyRefactored> {
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = project.lsp.hierarchy_refactor(doc, pos, kind, target, choices, cancel).await;
|
||||
host.end_request(request);
|
||||
let mut refactored = result?;
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::OutOfSync,
|
||||
"document changed while computing the refactoring; request it again",
|
||||
));
|
||||
}
|
||||
if let Some(edit) = &mut refactored.edit {
|
||||
project.documents.prepare_workspace_edit(edit)?;
|
||||
}
|
||||
Ok(refactored)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lsp_rename(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -676,6 +726,22 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_rename", |c| async move {
|
||||
lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
});
|
||||
d.add("lsp_hierarchy_members", |c| async move {
|
||||
lsp_hierarchy_members(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await
|
||||
});
|
||||
d.add("lsp_hierarchy_refactor", |c| async move {
|
||||
lsp_hierarchy_refactor(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("doc")?,
|
||||
c.arg("pos")?,
|
||||
c.arg("kind")?,
|
||||
c.arg("target")?,
|
||||
c.arg("choices")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_format", |c| async move {
|
||||
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ const SERVER: &str = "jdtls";
|
||||
|
||||
pub(crate) mod configuration;
|
||||
mod formatter;
|
||||
mod hierarchy;
|
||||
pub(crate) mod reserved_names;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Pull Members Up / Push Members Down through the app-owned JDT extension
|
||||
//! (`plugins/java-usages`). The server checks the move and computes the edit; the UI
|
||||
//! shows its problems and applies the edit itself.
|
||||
use super::COMMAND_TIMEOUT;
|
||||
use crate::types::{HierarchyChoice, HierarchyMembers, HierarchyRefactored, HierarchyRefactoring, RefactoringProblem};
|
||||
use crate::{DocSnap, LspManager, Target, convert};
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, Pos};
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MEMBERS: &str = "openidea.java.hierarchy.members";
|
||||
const REFACTOR: &str = "openidea.java.hierarchy.refactor";
|
||||
|
||||
/// The extension's answer: a result, or why the position can't be refactored.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Answer<T> {
|
||||
Refused { refused: String },
|
||||
Ok(T),
|
||||
}
|
||||
|
||||
impl<T> Answer<T> {
|
||||
fn into_result(self) -> IdeResult<T> {
|
||||
match self {
|
||||
Answer::Refused { refused } => Err(IdeError::invalid(refused)),
|
||||
Answer::Ok(value) => Ok(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Refactored {
|
||||
problems: Vec<RefactoringProblem>,
|
||||
edit: Option<lsp_types::WorkspaceEdit>,
|
||||
}
|
||||
|
||||
fn feature(kind: HierarchyRefactoring) -> &'static str {
|
||||
match kind {
|
||||
HierarchyRefactoring::PullUp => "Pull Members Up",
|
||||
HierarchyRefactoring::PushDown => "Push Members Down",
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
async fn hierarchy_command<R: DeserializeOwned>(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
command: &str,
|
||||
extra: &[Value],
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<(R, DocSnap, Target)> {
|
||||
let (snap, mut targets) =
|
||||
self.doc_targets(doc, feature(kind), |c| c.commands().iter().any(|c| c == command)).await?;
|
||||
let t = targets.swap_remove(0);
|
||||
let at = snap.position(&t, pos);
|
||||
let mut arguments = vec![json!(snap.uri), json!(at.line), json!(at.character), json!(kind)];
|
||||
arguments.extend_from_slice(extra);
|
||||
let params = json!({ "command": command, "arguments": arguments });
|
||||
let answer: Answer<R> =
|
||||
self.call(&t, "workspace/executeCommand", params, Some(cancel), COMMAND_TIMEOUT).await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok((answer.into_result()?, snap, t))
|
||||
}
|
||||
|
||||
/// The class at `pos` and its movable members; an invalid-input error says why there are none.
|
||||
pub async fn hierarchy_members(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<HierarchyMembers> {
|
||||
let (members, _, _) = self.hierarchy_command(doc, pos, kind, MEMBERS, &[], &cancel).await?;
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
/// Checks moving `choices` (to `target`, for pull up) and computes the edit without applying it.
|
||||
pub async fn hierarchy_refactor(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
target: Option<String>,
|
||||
choices: Vec<HierarchyChoice>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<HierarchyRefactored> {
|
||||
let choices: Vec<Value> = choices.iter().map(|c| json!({ "id": c.id, "abstract": c.make_abstract })).collect();
|
||||
let (refactored, _, t): (Refactored, _, _) =
|
||||
self.hierarchy_command(doc, pos, kind, REFACTOR, &[json!(target), json!(choices)], &cancel).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
Ok(HierarchyRefactored {
|
||||
problems: refactored.problems,
|
||||
edit: refactored.edit.map(|e| convert::workspace_edit(&conv, e)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -389,6 +389,87 @@ pub struct Renamed {
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
/// IDEA's class-hierarchy refactorings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HierarchyRefactoring {
|
||||
PullUp,
|
||||
PushDown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyClass {
|
||||
/// Server-owned identity, passed back as the pull-up destination.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub qualified_name: String,
|
||||
pub interface: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HierarchyMemberKind {
|
||||
Field,
|
||||
Method,
|
||||
Type,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyMember {
|
||||
pub id: String,
|
||||
/// `name(Params) : Type`, as the server renders it.
|
||||
pub label: String,
|
||||
pub kind: HierarchyMemberKind,
|
||||
/// The member at the caret.
|
||||
pub selected: bool,
|
||||
/// Can be declared abstract in the destination (pull up) or kept abstract (push down).
|
||||
pub abstractable: bool,
|
||||
}
|
||||
|
||||
/// The class at a position and what can move: pull-up destinations, or the subclasses
|
||||
/// members are pushed into.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyMembers {
|
||||
pub class_name: String,
|
||||
pub targets: Vec<HierarchyClass>,
|
||||
pub members: Vec<HierarchyMember>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyChoice {
|
||||
pub id: String,
|
||||
pub make_abstract: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ProblemSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
/// The refactoring can't run at all; there is no edit.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefactoringProblem {
|
||||
pub severity: ProblemSeverity,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A checked refactoring: what the server found, and the edit unless a problem is fatal.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyRefactored {
|
||||
pub problems: Vec<RefactoringProblem>,
|
||||
pub edit: Option<WorkspaceEdit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallHierarchy {
|
||||
|
||||
@@ -3338,3 +3338,90 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
|
||||
}
|
||||
let _ = std::fs::remove_file(&nul);
|
||||
}
|
||||
|
||||
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_hierarchy_refactorings_use_the_extension_commands() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] =
|
||||
json!({"commands": ["openidea.java.hierarchy.members", "openidea.java.hierarchy.refactor"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
*s.responder.lock() = Some(Arc::new(|_, method, params| {
|
||||
if method != "workspace/executeCommand" {
|
||||
return None;
|
||||
}
|
||||
let args = params["arguments"].as_array().unwrap();
|
||||
let uri = args[0].clone();
|
||||
Some(match (params["command"].as_str().unwrap(), args[1].as_u64().unwrap()) {
|
||||
(_, 9) => json!({"refused": "Type has no super types."}),
|
||||
("openidea.java.hierarchy.members", _) => json!({
|
||||
"className": "Dog",
|
||||
"targets": [{"id": "=p/src<{Animal.java[Animal", "name": "Animal", "qualifiedName": "Animal", "interface": false}],
|
||||
"members": [{"id": "=p/src<{Dog.java[Dog~bark", "label": "bark() : String", "kind": "method", "selected": true, "abstractable": true}],
|
||||
}),
|
||||
_ => json!({
|
||||
"problems": [{"severity": "warning", "message": "The visibility of field 'tail' will be changed to package."}],
|
||||
"edit": {"changes": {uri.as_str().unwrap(): [
|
||||
{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 5}}, "newText": "class"}
|
||||
]}},
|
||||
}),
|
||||
})
|
||||
}));
|
||||
let text =
|
||||
format!("class Dog extends Animal {{ String bark() {{ return \"\"; }} }}{}class Lonely {{}}", "\n".repeat(9));
|
||||
let (doc, path) = h.open("Dog.java", &text);
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let info =
|
||||
h.mgr.hierarchy_members(doc, Pos::new(0, 35), HierarchyRefactoring::PullUp, Default::default()).await.unwrap();
|
||||
assert_eq!(info.class_name, "Dog");
|
||||
assert_eq!(info.targets[0].name, "Animal");
|
||||
assert!(info.members[0].selected && info.members[0].abstractable);
|
||||
assert_eq!(info.members[0].kind, HierarchyMemberKind::Method);
|
||||
let sent = s.requested("workspace/executeCommand");
|
||||
assert_eq!(sent[0]["arguments"], json!([uri_of(&path), 0, 35, "pullUp"]));
|
||||
|
||||
let choices = vec![HierarchyChoice { id: info.members[0].id.clone(), make_abstract: true }];
|
||||
let done = h
|
||||
.mgr
|
||||
.hierarchy_refactor(
|
||||
doc,
|
||||
Pos::new(0, 35),
|
||||
HierarchyRefactoring::PushDown,
|
||||
Some(info.targets[0].id.clone()),
|
||||
choices,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(done.problems[0].severity, ProblemSeverity::Warning);
|
||||
let edit = done.edit.unwrap();
|
||||
assert!(matches!(&edit.files[..], [FileEdit::Edit { path: p, edits, .. }] if p == &path && edits.len() == 1));
|
||||
let sent = s.requested("workspace/executeCommand");
|
||||
assert_eq!(
|
||||
sent[1]["arguments"],
|
||||
json!([uri_of(&path), 0, 35, "pushDown", "=p/src<{Animal.java[Animal",
|
||||
[{"id": "=p/src<{Dog.java[Dog~bark", "abstract": true}]])
|
||||
);
|
||||
|
||||
let refused = h.mgr.hierarchy_members(doc, Pos::new(9, 0), HierarchyRefactoring::PullUp, Default::default()).await;
|
||||
let e = refused.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert_eq!(e.message, "Type has no super types.");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn hierarchy_refactorings_name_the_servers_without_them() {
|
||||
let h = harness(&[("plain", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "class A {}");
|
||||
h.wait_status("plain", ServerStatus::Ready).await;
|
||||
let e = h
|
||||
.mgr
|
||||
.hierarchy_members(doc, Pos::new(0, 6), HierarchyRefactoring::PullUp, Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(e.message, "plain doesn't support Pull Members Up");
|
||||
}
|
||||
|
||||
@@ -1170,7 +1170,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.2.1.jar");
|
||||
let extension = data_dir.join("java-usages-1.3.0.jar");
|
||||
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Pull Members Up / Push Members Down. The language server is stubbed at the IPC layer: the
|
||||
* Java side is covered by plugins/java-usages/tests/hierarchy.py against a real JDT LS.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
const captureDir = process.env.OPENIDEA_ACCEPTANCE_DIR;
|
||||
|
||||
/** Opens the dialog as the action does after the server answered, for the editor's view. */
|
||||
async function openDialog(page: Page, kind: "pullUp" | "pushDown") {
|
||||
await page.evaluate(async (kind) => {
|
||||
const module = "/src/editor/refactor/hierarchy.ts";
|
||||
const { useHierarchyDialog } = await import(/* @vite-ignore */ module);
|
||||
const view = (document.querySelector(".cm-content") as unknown as { cmTile: { view: { state: { doc: unknown } } } })
|
||||
.cmTile.view;
|
||||
const pullUp = kind === "pullUp";
|
||||
useHierarchyDialog.setState({
|
||||
request: {
|
||||
seq: 1,
|
||||
view,
|
||||
kind,
|
||||
doc: 1,
|
||||
pos: { line: 0, col: 0 },
|
||||
text: view.state.doc,
|
||||
info: {
|
||||
className: pullUp ? "Dog" : "Animal",
|
||||
targets: pullUp
|
||||
? [
|
||||
{ id: "animal", name: "Animal", qualifiedName: "zoo.Animal", interface: false },
|
||||
{ id: "speaker", name: "Speaker", qualifiedName: "zoo.Speaker", interface: true },
|
||||
]
|
||||
: [
|
||||
{ id: "dog", name: "Dog", qualifiedName: "zoo.Dog", interface: false },
|
||||
{ id: "cat", name: "Cat", qualifiedName: "zoo.Cat", interface: false },
|
||||
],
|
||||
members: pullUp
|
||||
? [
|
||||
{ id: "tail", label: "tail : int", kind: "field", selected: false, abstractable: false },
|
||||
{ id: "bark", label: "bark() : String", kind: "method", selected: true, abstractable: true },
|
||||
{ id: "wag", label: "wag() : int", kind: "method", selected: false, abstractable: true },
|
||||
]
|
||||
: [
|
||||
{ id: "name", label: "name : String", kind: "field", selected: false, abstractable: false },
|
||||
{ id: "describe", label: "describe() : String", kind: "method", selected: true, abstractable: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}, kind);
|
||||
}
|
||||
|
||||
/** Stubs the server's check and the edit application; returns what reached them. */
|
||||
async function stubServer(page: Page, problems: { severity: string; message: string }[]) {
|
||||
await page.evaluate(async (problems) => {
|
||||
const module = "/src/platform/ipc.ts";
|
||||
const { commands } = await import(/* @vite-ignore */ module);
|
||||
// Only the stubbed commands answer; window lookups by other features keep working.
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: {
|
||||
transformCallback: () => 1,
|
||||
invoke: async () => null,
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
},
|
||||
});
|
||||
const seen = ((window as unknown as { hierarchySeen: unknown[] }).hierarchySeen = [] as unknown[]);
|
||||
commands.lspHierarchyRefactor = async (...args: unknown[]) => {
|
||||
seen.push({ refactor: args.slice(3) });
|
||||
return {
|
||||
status: "ok",
|
||||
data: {
|
||||
problems,
|
||||
edit: {
|
||||
files: [
|
||||
{
|
||||
kind: "edit",
|
||||
path: "/dev/acme-api/README.md",
|
||||
edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
commands.applyWorkspaceEdit = async (edit: unknown) => {
|
||||
seen.push({ apply: edit });
|
||||
return { status: "ok", data: null };
|
||||
};
|
||||
}, problems);
|
||||
}
|
||||
|
||||
const seen = (page: Page) => page.evaluate(() => (window as unknown as { hierarchySeen: unknown[] }).hierarchySeen);
|
||||
|
||||
test("the editor's Refactor menu offers Pull Members Up and Push Members Down", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "README.md");
|
||||
await editor.locator(".cm-content").click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Refactor", exact: true }).hover();
|
||||
await expect(page.getByRole("menuitem", { name: /^Pull Members Up…/ })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: /^Push Members Down…/ })).toBeVisible();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/01-refactor-menu.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Pull Members Up: members, interface destination, conflicts, then the edit", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await openDialog(page, "pullUp");
|
||||
const dialog = page.getByRole("dialog", { name: "Pull Members Up" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("Pull up members of Dog to:")).toBeVisible();
|
||||
const member = (label: string) => dialog.getByRole("row").filter({ hasText: label });
|
||||
// The member at the caret starts checked; fields can't be made abstract.
|
||||
await expect(member("bark() : String").getByRole("checkbox").first()).toBeChecked();
|
||||
await expect(member("wag() : int").getByRole("checkbox").first()).not.toBeChecked();
|
||||
await member("tail : int").getByRole("checkbox").first().check();
|
||||
await expect(member("tail : int").getByRole("checkbox", { name: "Make abstract" })).toBeDisabled();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/02-pull-up-dialog.png` });
|
||||
|
||||
// To an interface, methods are declared abstract there.
|
||||
await dialog.getByRole("button", { name: "Destination class" }).click();
|
||||
await page.getByRole("option", { name: /zoo\.Speaker/ }).click();
|
||||
const abstractBark = member("bark() : String").getByRole("checkbox", { name: "Make abstract" });
|
||||
await expect(abstractBark).toBeChecked();
|
||||
await expect(abstractBark).toBeDisabled();
|
||||
await expect(dialog.getByText("Methods pulled up to an interface are declared abstract there.")).toBeVisible();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/03-pull-up-to-interface.png` });
|
||||
|
||||
await stubServer(page, [
|
||||
{ severity: "error", message: "Field 'zoo.Dog.tail' is not accessible from type 'zoo.Speaker'" },
|
||||
]);
|
||||
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
|
||||
const problems = page.getByRole("alertdialog").or(page.getByRole("dialog", { name: "Problems Detected" }));
|
||||
await expect(problems.getByText(/is not accessible from type/)).toBeVisible();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/04-problems-detected.png` });
|
||||
// Cancel: nothing applied, the dialog stays.
|
||||
await problems.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(await seen(page)).toEqual([
|
||||
{
|
||||
refactor: [
|
||||
"pullUp",
|
||||
"speaker",
|
||||
[
|
||||
{ id: "tail", makeAbstract: false },
|
||||
{ id: "bark", makeAbstract: true },
|
||||
],
|
||||
],
|
||||
},
|
||||
]);
|
||||
// Continue: the edit is applied and the dialog closes.
|
||||
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
|
||||
await problems.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(async () => (await seen(page)).some((s) => "apply" in (s as object))).toBe(true);
|
||||
});
|
||||
|
||||
test("Push Members Down keeps an abstract declaration when asked", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await openDialog(page, "pushDown");
|
||||
const dialog = page.getByRole("dialog", { name: "Push Members Down" });
|
||||
await expect(dialog.getByText("Push members of Animal down to:")).toBeVisible();
|
||||
await expect(dialog.getByText("Dog, Cat")).toBeVisible();
|
||||
const describe = dialog.getByRole("row").filter({ hasText: "describe() : String" });
|
||||
await describe.getByRole("checkbox", { name: "Keep abstract" }).check();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/05-push-down-dialog.png` });
|
||||
await stubServer(page, []);
|
||||
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
const calls = await seen(page);
|
||||
expect(calls[0]).toEqual({ refactor: ["pushDown", null, [{ id: "describe", makeAbstract: true }]] });
|
||||
expect(calls.some((s) => "apply" in (s as object))).toBe(true);
|
||||
});
|
||||
@@ -2,9 +2,10 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.2.1
|
||||
Bundle-Version: 1.3.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson
|
||||
org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson,
|
||||
org.eclipse.ltk.core.refactoring
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.2.1.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.3.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -73,6 +73,8 @@ Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
|
||||
Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of
|
||||
unrelated content and non-Java resource changes; its reproducible SHA-256 is
|
||||
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
|
||||
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
|
||||
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
@@ -101,6 +103,7 @@ not rebuild this jar. A maintainer must:
|
||||
python3 plugins/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
|
||||
python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
OPENIDEA_USAGES_INDEX_STRESS=1 python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
|
||||
python3 plugins/java-usages/tests/hierarchy.py /path/to/jdtls
|
||||
```
|
||||
|
||||
The harnesses select the JDT LS configuration for the current platform. The first
|
||||
@@ -112,6 +115,24 @@ method references, receiver/array reads and a real cancellation response (`-3280
|
||||
Use the actual desktop app to check read/write toggles, mutual toggle behavior,
|
||||
Find transfer, persistence and unfiltered non-Java counts with the same fixture.
|
||||
|
||||
`hierarchy.py` runs Pull Members Up and Push Members Down: member and destination
|
||||
listing, moving, declaring abstract, pulling up to an interface, conflicts reported
|
||||
before any change, and a class without supertypes. Each edit is applied to a copy of
|
||||
the fixture and compiled with javac.
|
||||
|
||||
## Class-hierarchy refactorings
|
||||
|
||||
`openidea.java.hierarchy.members(uri, line, column, kind)` describes the class at the
|
||||
position for `kind` `pullUp` or `pushDown`: its movable members (handle id, label,
|
||||
kind, preselected, can be declared abstract) and the destination supertypes, or the
|
||||
direct subtypes the members are pushed into. A position on a member preselects it.
|
||||
`openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)` runs
|
||||
JDT's `PullUpRefactoringProcessor` / `PushDownRefactoringProcessor` with the chosen
|
||||
`{id, abstract}` members and returns `{problems, edit}`. Nothing is applied by the
|
||||
server; the app shows the problems, then applies the edit as one undoable change.
|
||||
A method pulled up to an interface is always declared there, as in JDT's wizard.
|
||||
Either command answers `{refused: reason}` when the position can't be refactored.
|
||||
|
||||
## Review and manual reproduction
|
||||
|
||||
See [the English reproduction guide](tests/REPRODUCE.md) for a small Java/Lombok
|
||||
@@ -129,6 +150,11 @@ This is an app-owned JDT LS extension, not a standard LSP feature. It uses:
|
||||
objects (`isIncludeAccessors`).
|
||||
- `JDTUtils.resolveTypeRoot`, `findElementAtSelection`.
|
||||
- `org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil`.
|
||||
- `org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor`,
|
||||
`PushDownRefactoringProcessor`, `RefactoringAvailabilityTesterCore`, `JdtFlags`,
|
||||
`JavaElementLabelsCore`, `PreferenceManager.getCodeGenerationSettings`,
|
||||
`JsonRpcHelpers.toOffset`, `JDTUtils.resolveCompilationUnit` and
|
||||
`ChangeUtil.convertToWorkspaceEdit`.
|
||||
- Public JDT model/DOM APIs for exact target identity and source-expression context.
|
||||
|
||||
The fingerprint protects native target discovery, not all binary compatibility.
|
||||
|
||||
Binary file not shown.
@@ -7,5 +7,9 @@
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
</delegateCommandHandler>
|
||||
</extension>
|
||||
</plugin>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package ai.openidea.hierarchy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.internal.core.manipulation.JavaElementLabelsCore;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.RefactoringAvailabilityTesterCore;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor.MemberActionInfo;
|
||||
import org.eclipse.jdt.internal.corext.util.JdtFlags;
|
||||
import org.eclipse.jdt.ls.core.internal.ChangeUtil;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JDTUtils;
|
||||
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
|
||||
import org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager;
|
||||
import org.eclipse.ltk.core.refactoring.Change;
|
||||
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
|
||||
import org.eclipse.ltk.core.refactoring.RefactoringStatusEntry;
|
||||
import org.eclipse.ltk.core.refactoring.participants.ProcessorBasedRefactoring;
|
||||
import org.eclipse.ltk.core.refactoring.participants.RefactoringProcessor;
|
||||
|
||||
/**
|
||||
* Pull Members Up / Push Members Down with JDT's own processors.
|
||||
*
|
||||
* <p>{@code openidea.java.hierarchy.members(uri, line, column, kind)} describes the class at the
|
||||
* position: its movable members and, for pull up, the supertypes in project sources.
|
||||
* {@code openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)} checks the
|
||||
* chosen move and returns its problems and the edit, which is never applied here. {@code kind}
|
||||
* is {@code pullUp} or {@code pushDown}; {@code choices} are {@code {id, abstract}} objects whose
|
||||
* ids are the handle identifiers returned by {@code members}.
|
||||
*/
|
||||
public final class HierarchyCommand implements IDelegateCommandHandler {
|
||||
static final String MEMBERS = "openidea.java.hierarchy.members";
|
||||
static final String REFACTOR = "openidea.java.hierarchy.refactor";
|
||||
|
||||
private static final long LABEL = JavaElementLabelsCore.M_PARAMETER_TYPES | JavaElementLabelsCore.M_APP_RETURNTYPE
|
||||
| JavaElementLabelsCore.F_APP_TYPE_SIGNATURE;
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
String uri = (String) arguments.get(0);
|
||||
int line = ((Number) arguments.get(1)).intValue();
|
||||
int column = ((Number) arguments.get(2)).intValue();
|
||||
boolean pullUp = switch ((String) arguments.get(3)) {
|
||||
case "pullUp" -> true;
|
||||
case "pushDown" -> false;
|
||||
default -> throw new IllegalArgumentException("unknown hierarchy refactoring " + arguments.get(3));
|
||||
};
|
||||
ICompilationUnit unit = JDTUtils.resolveCompilationUnit(uri);
|
||||
if (unit == null || !unit.exists()) return refused("The file is not part of a Java project");
|
||||
IMember start = memberAt(unit, JsonRpcHelpers.toOffset(unit, line, column));
|
||||
IMember[] members = start == null ? null : selection(start, pullUp);
|
||||
if (members == null) {
|
||||
return refused(pullUp ? "Caret should be inside a class that has members to pull up"
|
||||
: "Caret should be inside a class that has members to push down");
|
||||
}
|
||||
if (MEMBERS.equals(command)) return pullUp ? pullUpMembers(unit, members, monitor) : pushDownMembers(members, monitor);
|
||||
if (REFACTOR.equals(command)) {
|
||||
String target = (String) arguments.get(4);
|
||||
List<Choice> choices = new ArrayList<>();
|
||||
for (Object o : (List<?>) arguments.get(5)) {
|
||||
Map<?, ?> map = (Map<?, ?>) o;
|
||||
IJavaElement element = JavaCore.create((String) map.get("id"));
|
||||
if (!(element instanceof IMember member) || !member.exists()) return refused("The class changed; open the dialog again");
|
||||
choices.add(new Choice(member, Boolean.TRUE.equals(map.get("abstract"))));
|
||||
}
|
||||
if (choices.isEmpty()) return refused("No members selected");
|
||||
// Started from the class, the processors know it even when no member is moved
|
||||
// (a method only declared abstract in the destination).
|
||||
IType declaring = members[0] instanceof IType type ? type : members[0].getDeclaringType();
|
||||
IMember[] owner = { declaring };
|
||||
return pullUp ? pullUp(unit, owner, target, choices, monitor) : pushDown(owner, choices, monitor);
|
||||
}
|
||||
throw new IllegalArgumentException("unknown command " + command);
|
||||
}
|
||||
|
||||
private record Choice(IMember member, boolean makeAbstract) {}
|
||||
|
||||
/** The innermost member at the offset, or the type itself for an initializer. */
|
||||
private static IMember memberAt(ICompilationUnit unit, int offset) throws JavaModelException {
|
||||
IJavaElement element = unit.getElementAt(offset);
|
||||
while (element != null && !(element instanceof IMember)) element = element.getParent();
|
||||
if (element instanceof IInitializer initializer) return initializer.getDeclaringType();
|
||||
return (IMember) element;
|
||||
}
|
||||
|
||||
/** The members to start from: the one at the caret when it can move, otherwise its class. */
|
||||
private static IMember[] selection(IMember start, boolean pullUp) throws JavaModelException {
|
||||
for (IMember member = start; member != null; member = member.getDeclaringType()) {
|
||||
if (member.isBinary() || member.isReadOnly()) return null;
|
||||
if (pullUp ? RefactoringAvailabilityTesterCore.isPullUpAvailable(member)
|
||||
: RefactoringAvailabilityTesterCore.isPushDownAvailable(member)) return new IMember[] { member };
|
||||
if (member instanceof IType type && (pullUp ? RefactoringAvailabilityTesterCore.getPullUpMembers(type)
|
||||
: RefactoringAvailabilityTesterCore.getPushDownMembers(type)).length > 0) {
|
||||
return new IMember[] { type };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object pullUpMembers(ICompilationUnit unit, IMember[] members, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
|
||||
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
|
||||
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
|
||||
IType[] candidates = processor.getCandidateTypes(new RefactoringStatus(), monitor);
|
||||
cancelled(monitor);
|
||||
if (candidates.length == 0) {
|
||||
return refused("'" + processor.getDeclaringType().getElementName()
|
||||
+ "' has no superclass or interface in the project sources to pull members up to");
|
||||
}
|
||||
List<Map<String, Object>> targets = new ArrayList<>();
|
||||
for (IType type : candidates) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", type.getHandleIdentifier());
|
||||
row.put("name", type.getElementName());
|
||||
row.put("qualifiedName", type.getFullyQualifiedName('.'));
|
||||
row.put("interface", type.isInterface());
|
||||
targets.add(row);
|
||||
}
|
||||
Set<IMember> selected = Set.of(processor.getMembersToMove());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (IMember member : processor.getPullableMembersOfDeclaringType()) {
|
||||
boolean abstractable = member instanceof IMethod method && !JdtFlags.isStatic(method);
|
||||
rows.add(member(member, selected.contains(member), abstractable));
|
||||
}
|
||||
return info(processor.getDeclaringType(), targets, rows);
|
||||
}
|
||||
|
||||
private static Object pushDownMembers(IMember[] members, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PushDownRefactoringProcessor(members);
|
||||
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
|
||||
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
|
||||
IType declaring = processor.getDeclaringType();
|
||||
List<Map<String, Object>> targets = new ArrayList<>();
|
||||
for (IType type : declaring.newTypeHierarchy(declaring.getJavaProject(), monitor).getSubtypes(declaring)) {
|
||||
if (type.isBinary()) continue;
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", type.getHandleIdentifier());
|
||||
row.put("name", type.getElementName());
|
||||
row.put("qualifiedName", type.getFullyQualifiedName('.'));
|
||||
row.put("interface", type.isInterface());
|
||||
targets.add(row);
|
||||
}
|
||||
cancelled(monitor);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (MemberActionInfo info : processor.getMemberActionInfos()) {
|
||||
boolean abstractable = false;
|
||||
for (int action : info.getAvailableActions()) abstractable |= action == MemberActionInfo.PUSH_ABSTRACT_ACTION;
|
||||
rows.add(member(info.getMember(), info.isToBePushedDown(), abstractable));
|
||||
}
|
||||
return info(declaring, targets, rows);
|
||||
}
|
||||
|
||||
private static Object pullUp(ICompilationUnit unit, IMember[] members, String target, List<Choice> choices,
|
||||
IProgressMonitor monitor) throws Exception {
|
||||
if (!(JavaCore.create(target) instanceof IType destination) || !destination.exists()) {
|
||||
return refused("The destination class no longer exists");
|
||||
}
|
||||
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
|
||||
List<IMember> moved = new ArrayList<>();
|
||||
List<IMethod> declaredAbstract = new ArrayList<>();
|
||||
List<IMethod> deleted = new ArrayList<>();
|
||||
for (Choice choice : choices) {
|
||||
// A method pulled up to an interface is declared there, as JDT's own wizard does.
|
||||
if (choice.member instanceof IMethod method && (choice.makeAbstract || destination.isInterface())) {
|
||||
declaredAbstract.add(method);
|
||||
} else {
|
||||
moved.add(choice.member);
|
||||
if (choice.member instanceof IMethod method) deleted.add(method);
|
||||
}
|
||||
}
|
||||
processor.setMembersToMove(moved.toArray(IMember[]::new));
|
||||
processor.setAbstractMethods(declaredAbstract.toArray(IMethod[]::new));
|
||||
processor.setDeletedMethods(deleted.toArray(IMethod[]::new));
|
||||
processor.setDestinationType(destination);
|
||||
processor.setCreateMethodStubs(true);
|
||||
return run(processor, monitor);
|
||||
}
|
||||
|
||||
private static Object pushDown(IMember[] members, List<Choice> choices, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PushDownRefactoringProcessor(members);
|
||||
var refactoring = new ProcessorBasedRefactoring(processor);
|
||||
RefactoringStatus initial = refactoring.checkInitialConditions(monitor);
|
||||
if (initial.hasFatalError()) return result(initial, null);
|
||||
Map<IMember, Choice> chosen = new LinkedHashMap<>();
|
||||
for (Choice choice : choices) chosen.put(choice.member, choice);
|
||||
for (MemberActionInfo info : processor.getMemberActionInfos()) {
|
||||
Choice choice = chosen.get(info.getMember());
|
||||
if (choice == null) info.setAction(MemberActionInfo.NO_ACTION);
|
||||
else info.setAction(choice.makeAbstract && info.getMember() instanceof IMethod
|
||||
? MemberActionInfo.PUSH_ABSTRACT_ACTION : MemberActionInfo.PUSH_DOWN_ACTION);
|
||||
}
|
||||
RefactoringStatus status = refactoring.checkFinalConditions(monitor);
|
||||
initial.merge(status);
|
||||
return finish(refactoring, initial, monitor);
|
||||
}
|
||||
|
||||
private static Object run(RefactoringProcessor processor, IProgressMonitor monitor) throws Exception {
|
||||
var refactoring = new ProcessorBasedRefactoring(processor);
|
||||
RefactoringStatus status = refactoring.checkAllConditions(monitor);
|
||||
return finish(refactoring, status, monitor);
|
||||
}
|
||||
|
||||
private static Object finish(ProcessorBasedRefactoring refactoring, RefactoringStatus status, IProgressMonitor monitor)
|
||||
throws Exception {
|
||||
cancelled(monitor);
|
||||
if (status.hasFatalError()) return result(status, null);
|
||||
Change change = refactoring.createChange(monitor);
|
||||
cancelled(monitor);
|
||||
return result(status, ChangeUtil.convertToWorkspaceEdit(change));
|
||||
}
|
||||
|
||||
private static Map<String, Object> member(IMember member, boolean selected, boolean abstractable) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", member.getHandleIdentifier());
|
||||
row.put("label", JavaElementLabelsCore.getElementLabel(member, LABEL));
|
||||
row.put("kind", member instanceof IMethod ? "method" : member instanceof IField ? "field" : "type");
|
||||
row.put("selected", selected);
|
||||
row.put("abstractable", abstractable);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Map<String, Object> info(IType type, List<Map<String, Object>> targets, List<Map<String, Object>> members) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("className", type.getElementName());
|
||||
out.put("targets", targets);
|
||||
out.put("members", members);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> result(RefactoringStatus status, Object edit) {
|
||||
List<Map<String, Object>> problems = new ArrayList<>();
|
||||
for (RefactoringStatusEntry entry : status.getEntries()) {
|
||||
String severity = switch (entry.getSeverity()) {
|
||||
case RefactoringStatus.FATAL -> "fatal";
|
||||
case RefactoringStatus.ERROR -> "error";
|
||||
case RefactoringStatus.WARNING -> "warning";
|
||||
default -> "info";
|
||||
};
|
||||
problems.add(Map.of("severity", severity, "message", entry.getMessage()));
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("problems", problems);
|
||||
out.put("edit", edit);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> refused(String reason) {
|
||||
return Map.of("refused", reason);
|
||||
}
|
||||
|
||||
private static void cancelled(IProgressMonitor monitor) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Pull Members Up / Push Members Down against a real JDT LS. Argument: jdtls home.
|
||||
|
||||
Each case computes the edit from the same unchanged sources, applies it to a copy
|
||||
of the project and compiles the copy with javac.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
home = pathlib.Path(sys.argv[1]).resolve()
|
||||
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
|
||||
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-hierarchy-'))
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
system, machine = platform.system(), platform.machine().lower()
|
||||
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
||||
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
||||
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
|
||||
(config / 'config.ini').write_bytes(config_source.read_bytes())
|
||||
project = work / 'project'
|
||||
(project / 'src/zoo').mkdir(parents=True)
|
||||
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
|
||||
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
|
||||
sources = {
|
||||
'Animal.java': '''package zoo;
|
||||
|
||||
public abstract class Animal {
|
||||
protected String name = "animal";
|
||||
|
||||
public String describe() {
|
||||
return "I am " + name;
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Speaker.java': '''package zoo;
|
||||
|
||||
public interface Speaker {
|
||||
}
|
||||
''',
|
||||
'Dog.java': '''package zoo;
|
||||
|
||||
public class Dog extends Animal implements Speaker {
|
||||
private int tail = 1;
|
||||
|
||||
public String bark() {
|
||||
return "Woof";
|
||||
}
|
||||
|
||||
public int wag() {
|
||||
return tail * 2;
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Cat.java': '''package zoo;
|
||||
|
||||
public class Cat extends Animal {
|
||||
public String purr() {
|
||||
return describe() + " purr";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Lonely.java': '''package zoo;
|
||||
|
||||
public class Lonely {
|
||||
public void alone() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Use.java': '''package zoo;
|
||||
|
||||
public class Use {
|
||||
String run(Dog dog, Animal animal) {
|
||||
return dog.bark() + dog.wag() + animal.describe();
|
||||
}
|
||||
}
|
||||
''',
|
||||
}
|
||||
for name, text in sources.items():
|
||||
(project / 'src/zoo' / name).write_text(text)
|
||||
uri = lambda name: (project / 'src/zoo' / name).as_uri()
|
||||
|
||||
log = (work / 'stderr.log').open('w')
|
||||
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
|
||||
messages = queue.Queue()
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def send(message):
|
||||
data = json.dumps(message).encode()
|
||||
with lock:
|
||||
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def read():
|
||||
while True:
|
||||
headers = {}
|
||||
while True:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
return
|
||||
if line == b'\r\n':
|
||||
break
|
||||
k, v = line.decode().split(':', 1)
|
||||
headers[k.lower()] = v.strip()
|
||||
message = json.loads(proc.stdout.read(int(headers['content-length'])))
|
||||
if 'method' in message and 'id' in message:
|
||||
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
|
||||
else:
|
||||
messages.put(message)
|
||||
|
||||
|
||||
threading.Thread(target=read, daemon=True).start()
|
||||
seq = 0
|
||||
|
||||
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
|
||||
if msg.get('id') == seq:
|
||||
if 'error' in msg:
|
||||
raise RuntimeError(msg['error'])
|
||||
return msg.get('result')
|
||||
raise TimeoutError(method)
|
||||
|
||||
|
||||
def position(name, needle):
|
||||
lines = sources[name].splitlines()
|
||||
line = next(i for i, t in enumerate(lines) if needle in t)
|
||||
return line, lines[line].index(needle)
|
||||
|
||||
|
||||
def command(name, *args):
|
||||
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
|
||||
|
||||
|
||||
def members(file, needle, kind):
|
||||
line, col = position(file, needle)
|
||||
return command('openidea.java.hierarchy.members', uri(file), line, col, kind)
|
||||
|
||||
|
||||
def refactor(file, needle, kind, target, choices):
|
||||
line, col = position(file, needle)
|
||||
return command('openidea.java.hierarchy.refactor', uri(file), line, col, kind, target, choices)
|
||||
|
||||
|
||||
def offset(text, pos):
|
||||
lines = text.split('\n')
|
||||
return sum(len(l) + 1 for l in lines[:pos['line']]) + pos['character']
|
||||
|
||||
|
||||
def apply(edit):
|
||||
"""The project after `edit`, compiled; returns the edited sources."""
|
||||
per_file = {}
|
||||
for u, edits in (edit.get('changes') or {}).items():
|
||||
per_file.setdefault(u, []).extend(edits)
|
||||
for change in edit.get('documentChanges') or []:
|
||||
assert 'kind' not in change, change
|
||||
per_file.setdefault(change['textDocument']['uri'], []).extend(change['edits'])
|
||||
out = dict(sources)
|
||||
for u, edits in per_file.items():
|
||||
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(u).path)).name
|
||||
text = out[name]
|
||||
for e in sorted(edits, key=lambda e: offset(text, e['range']['start']), reverse=True):
|
||||
text = text[:offset(text, e['range']['start'])] + e['newText'] + text[offset(text, e['range']['end']):]
|
||||
out[name] = text
|
||||
copy = work / f'compile{seq}'
|
||||
(copy / 'zoo').mkdir(parents=True)
|
||||
for name, text in out.items():
|
||||
(copy / 'zoo' / name).write_text(text)
|
||||
javac = subprocess.run(['javac', '-d', str(copy / 'bin'), *map(str, (copy / 'zoo').glob('*.java'))], capture_output=True, text=True)
|
||||
assert javac.returncode == 0, (javac.stderr, out)
|
||||
return out
|
||||
|
||||
|
||||
def body(text, name):
|
||||
return [l.strip() for l in text.splitlines() if name in l]
|
||||
|
||||
|
||||
try:
|
||||
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {'workspace': {'workspaceEdit': {'documentChanges': True}}}, 'initializationOptions': {'bundles': [str(bundle)], 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
|
||||
commands = init['capabilities']['executeCommandProvider']['commands']
|
||||
assert 'openidea.java.hierarchy.members' in commands and 'openidea.java.hierarchy.refactor' in commands, commands
|
||||
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
|
||||
for name, text in sources.items():
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
|
||||
request('java/buildWorkspace', True)
|
||||
evidence = {}
|
||||
|
||||
# Members of Dog, with bark() preselected; Animal and Speaker are destinations.
|
||||
info = members('Dog.java', 'bark', 'pullUp')
|
||||
evidence['pullUpMembers'] = info
|
||||
assert info['className'] == 'Dog', info
|
||||
assert {t['name'] for t in info['targets']} == {'Animal', 'Speaker'}, info['targets']
|
||||
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
|
||||
assert rows['bark']['selected'] and not rows['wag']['selected'] and not rows['tail']['selected'], rows
|
||||
assert rows['bark']['abstractable'] and rows['bark']['kind'] == 'method' and rows['tail']['kind'] == 'field', rows
|
||||
animal = next(t for t in info['targets'] if t['name'] == 'Animal')
|
||||
speaker = next(t for t in info['targets'] if t['name'] == 'Speaker')
|
||||
|
||||
# Caret on the class name: nothing preselected.
|
||||
on_class = members('Dog.java', 'class Dog', 'pullUp')
|
||||
assert on_class['className'] == 'Dog' and not any(m['selected'] for m in on_class['members']), on_class
|
||||
|
||||
# Pull bark() up to Animal: moved, call sites unchanged, compiles.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': False}])
|
||||
evidence['pullUp'] = result
|
||||
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
|
||||
out = apply(result['edit'])
|
||||
assert 'bark()' in out['Animal.java'] and 'bark()' not in out['Dog.java'], out
|
||||
assert out['Use.java'] == sources['Use.java']
|
||||
|
||||
# Make abstract: declared in Animal, kept (and overridden) in Dog.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': True}])
|
||||
evidence['pullUpAbstract'] = result
|
||||
out = apply(result['edit'])
|
||||
assert any('abstract' in l for l in body(out['Animal.java'], 'bark()')), out['Animal.java']
|
||||
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
|
||||
|
||||
# To an interface, a method is always declared, never moved.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', speaker['id'], [{'id': rows['bark']['id'], 'abstract': False}])
|
||||
evidence['pullUpInterface'] = result
|
||||
out = apply(result['edit'])
|
||||
assert body(out['Speaker.java'], 'bark()') == ['String bark();'] or body(out['Speaker.java'], 'bark()') == ['public String bark();'], out['Speaker.java']
|
||||
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
|
||||
|
||||
# wag() uses Dog's private field: reported before anything changes.
|
||||
result = refactor('Dog.java', 'wag', 'pullUp', animal['id'], [{'id': rows['wag']['id'], 'abstract': False}])
|
||||
evidence['pullUpConflict'] = result
|
||||
assert any(p['severity'] in ('error', 'fatal') and 'tail' in p['message'] for p in result['problems']), result
|
||||
|
||||
# No superclass in the sources.
|
||||
refused = members('Lonely.java', 'alone', 'pullUp')
|
||||
evidence['pullUpRefused'] = refused
|
||||
assert 'refused' in refused, refused
|
||||
|
||||
# Push describe() down: copied into Dog and Cat, removed from Animal.
|
||||
info = members('Animal.java', 'describe', 'pushDown')
|
||||
evidence['pushDownMembers'] = info
|
||||
assert info['className'] == 'Animal' and {t['name'] for t in info['targets']} == {'Dog', 'Cat'}, info
|
||||
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
|
||||
assert rows['describe']['selected'] and rows['describe']['abstractable'] and not rows['name']['abstractable'], rows
|
||||
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': True}])
|
||||
evidence['pushDownAbstract'] = result
|
||||
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
|
||||
out = apply(result['edit'])
|
||||
assert any('abstract' in l for l in body(out['Animal.java'], 'describe()')), out['Animal.java']
|
||||
assert 'I am' in out['Dog.java'] and 'I am' in out['Cat.java'], out
|
||||
|
||||
# Without keeping a declaration, Use's animal.describe() breaks: JDT reports it.
|
||||
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': False}])
|
||||
evidence['pushDownConflict'] = result
|
||||
assert any(p['severity'] in ('error', 'fatal') for p in result['problems']), result
|
||||
|
||||
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
|
||||
print('hierarchy: all cases passed')
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=20)
|
||||
print('Evidence:', work, file=sys.stderr)
|
||||
@@ -240,6 +240,38 @@ pub async fn lsp_rename(
|
||||
ide_host::ops::lsp::lsp_rename(&state.host, window.label(), request, doc, pos, new_name).await
|
||||
}
|
||||
|
||||
/// The class at `pos` and the members Pull Members Up / Push Members Down can move.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_hierarchy_members(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
) -> IdeResult<HierarchyMembers> {
|
||||
ide_host::ops::lsp::lsp_hierarchy_members(&state.host, window.label(), request, doc, pos, kind).await
|
||||
}
|
||||
|
||||
/// Checks Pull Members Up / Push Members Down and computes the edit without applying it.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn lsp_hierarchy_refactor(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
target: Option<String>,
|
||||
choices: Vec<HierarchyChoice>,
|
||||
) -> IdeResult<HierarchyRefactored> {
|
||||
ide_host::ops::lsp::lsp_hierarchy_refactor(&state.host, window.label(), request, doc, pos, kind, target, choices)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_format(
|
||||
|
||||
@@ -221,6 +221,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
lsp::lsp_apply_code_action,
|
||||
lsp::lsp_prepare_rename,
|
||||
lsp::lsp_rename,
|
||||
lsp::lsp_hierarchy_members,
|
||||
lsp::lsp_hierarchy_refactor,
|
||||
lsp::lsp_format,
|
||||
lsp::lsp_inlay_hints,
|
||||
lsp::lsp_semantic_tokens,
|
||||
|
||||
@@ -542,6 +542,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("refactor.extractMethod", "Extract Method…"),
|
||||
act("refactor.inline", "Inline…"),
|
||||
SEP,
|
||||
act("refactor.pullMembersUp", "Pull Members Up…"),
|
||||
act("refactor.pushMembersDown", "Push Members Down…"),
|
||||
SEP,
|
||||
act("refactor.move", "Move…"),
|
||||
act("project.copyElement", "Copy…"),
|
||||
act("refactor.safeDelete", "Safe Delete…"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)!;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "이 항목 리팩터링…",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "파일이 변경되었습니다. 대화상자를 다시 여세요"
|
||||
}
|
||||
|
||||
@@ -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": "文件已更改,请重新打开对话框"
|
||||
}
|
||||
|
||||
@@ -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": "重构此项…",
|
||||
|
||||
Reference in New Issue
Block a user