mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e83d103cc | ||
|
|
6f83f8cad0 | ||
|
|
b971c8b391 | ||
|
|
c333990404 | ||
|
|
70b953c77a |
No files matched your search
@@ -77,6 +77,8 @@ pub enum AutoUpload {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server groups: Upload to… on a group uploads to each of its servers.
|
||||
pub groups: Vec<DeploymentGroup>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
@@ -92,6 +94,7 @@ impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
@@ -103,6 +106,16 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named set of deployment servers that files are uploaded to together.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`DeploymentServer::id`]s, in upload order.
|
||||
pub servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
|
||||
@@ -15,6 +15,7 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
@@ -330,21 +331,61 @@ pub async fn build_sync(
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let lsp = project.lsp.clone();
|
||||
let documents = project.documents.clone();
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
let first = updates.first().cloned();
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
}
|
||||
let changed = {
|
||||
let lsp = lsp.clone();
|
||||
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
|
||||
};
|
||||
if let Some(file) = first
|
||||
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
|
||||
{
|
||||
tracing::debug!("openidea.java.refreshArchives: {e}");
|
||||
}
|
||||
refresh_library_classes(&lsp, &documents).await;
|
||||
});
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
|
||||
/// again keeps its path) show the new contents, like a reopened one.
|
||||
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
|
||||
let changed = {
|
||||
let (lsp, documents) = (lsp.clone(), documents.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
documents
|
||||
.open_documents()
|
||||
.into_iter()
|
||||
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
|
||||
.filter(|(_, uri)| lsp.library_class_changed(uri))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for (doc, uri) in changed {
|
||||
match lsp.virtual_document(&uri).await {
|
||||
Ok(vd) => {
|
||||
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
|
||||
tracing::debug!("refreshing {uri}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
@@ -1051,7 +1092,6 @@ mod maven_completion_tests {
|
||||
use ide_build::maven::completion::PomCompletionKind;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_workspace::dependencies::DependencyVersionSource;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Cache answers the test plants before completing, so the op's Central
|
||||
/// lookups hit the session cache and the network is never reached.
|
||||
|
||||
@@ -809,6 +809,21 @@ impl LspManager {
|
||||
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
|
||||
}
|
||||
|
||||
/// Asks jdtls to re-read library archives replaced in place (`openidea.java.refreshArchives`):
|
||||
/// a SNAPSHOT installed again keeps its path, and JDT would keep showing the classes it read
|
||||
/// from the old archive. Best-effort: skipped while jdtls is not already running.
|
||||
pub async fn java_refresh_archives(&self, path: &Path, archives: &[PathBuf]) -> IdeResult<()> {
|
||||
let java = LanguageId::new("java");
|
||||
let Some(server) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) else {
|
||||
return Ok(());
|
||||
};
|
||||
if server.running().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let archives = archives.iter().map(|a| Value::String(a.to_string_lossy().into_owned())).collect();
|
||||
self.jdtls_command(path, "openidea.java.refreshArchives", archives).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Pause autobuild before resolving the launch classpath. That query takes the
|
||||
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
|
||||
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
|
||||
|
||||
@@ -241,6 +241,9 @@ pub struct LspManager {
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
/// Modification time of the archive behind a cached library class, so a jar
|
||||
/// replaced in place (a SNAPSHOT installed again) is read again.
|
||||
virtual_stamps: Mutex<HashMap<String, std::time::SystemTime>>,
|
||||
injections: injections::State,
|
||||
/// Open notebooks by path. Locked before `docs` when both are.
|
||||
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
|
||||
@@ -508,6 +511,7 @@ impl LspManager {
|
||||
go_mod_checked: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
virtual_stamps: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
notebooks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
@@ -3202,14 +3206,44 @@ impl LspManager {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether the archive behind a cached library class changed since it was read.
|
||||
/// Stats the archive: call from a blocking worker.
|
||||
pub fn library_class_changed(&self, uri: &str) -> bool {
|
||||
let Some(stamp) = self.virtual_stamps.lock().get(uri).copied() else { return false };
|
||||
virtual_doc::archive_modified(uri) != Some(stamp)
|
||||
}
|
||||
|
||||
/// Archives behind cached library classes that changed since they were read.
|
||||
/// Stats each archive: call from a blocking worker.
|
||||
pub fn changed_library_archives(&self) -> Vec<PathBuf> {
|
||||
let uris: Vec<String> = self.virtual_stamps.lock().keys().cloned().collect();
|
||||
let mut archives: Vec<PathBuf> = uris
|
||||
.iter()
|
||||
.filter(|uri| self.library_class_changed(uri))
|
||||
.filter_map(|uri| virtual_doc::archive(uri))
|
||||
.collect();
|
||||
archives.sort();
|
||||
archives.dedup();
|
||||
archives
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
/// `workspace/textDocumentContent` (LSP 3.18) when the server serves the
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
let cached = self.virtual_docs.lock().get(uri).cloned();
|
||||
if let Some(d) = cached {
|
||||
let stamp = self.virtual_stamps.lock().get(uri).copied();
|
||||
let current = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || virtual_doc::archive_modified(&uri)).await.ok().flatten()
|
||||
};
|
||||
if stamp.is_none() || stamp == current {
|
||||
return Ok(d);
|
||||
}
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class"));
|
||||
@@ -3353,11 +3387,18 @@ impl LspManager {
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
}
|
||||
let stamp = virtual_doc::archive_modified(uri);
|
||||
let mut cache = self.virtual_docs.lock();
|
||||
let mut stamps = self.virtual_stamps.lock();
|
||||
if cache.len() >= VIRTUAL_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
stamps.clear();
|
||||
}
|
||||
cache.insert(uri.to_owned(), doc.clone());
|
||||
match stamp {
|
||||
Some(stamp) => stamps.insert(uri.to_owned(), stamp),
|
||||
None => stamps.remove(uri),
|
||||
};
|
||||
doc
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,20 @@ pub fn description(uri: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive(uri: &str) -> Option<std::path::PathBuf> {
|
||||
match scheme(uri) {
|
||||
"jar" => Some(crate::library_source::parse(uri)?.jar),
|
||||
"jdt" => description(uri).filter(|p| p.ends_with(".jar") || p.ends_with(".jmod")).map(Into::into),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modification time of the archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive_modified(uri: &str) -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(archive(uri)?).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
/// Check both the package and actual archive, so duplicate type names never pick another jar.
|
||||
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
|
||||
let segs = segments(uri);
|
||||
@@ -304,6 +318,29 @@ mod tests {
|
||||
assert_eq!(description("foo://x/y"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_archive_behind_a_library_class() {
|
||||
assert_eq!(archive(JDT), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar".into()));
|
||||
assert_eq!(
|
||||
archive("jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=x"),
|
||||
None
|
||||
);
|
||||
assert_eq!(archive("jdt://contents/a.jar/p/C.class"), None);
|
||||
assert_eq!(archive("file:///a/C.java"), None);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let jar = dir.path().join("lib-1.0-SNAPSHOT.jar");
|
||||
std::fs::write(&jar, b"PK").unwrap();
|
||||
let uri = format!("jar:file://{}!/p/C.class", jar.display());
|
||||
assert_eq!(archive(&uri), Some(jar.clone()));
|
||||
let before = archive_modified(&uri).unwrap();
|
||||
let file = std::fs::File::options().write(true).open(&jar).unwrap();
|
||||
file.set_modified(before + std::time::Duration::from_secs(5)).unwrap();
|
||||
assert_ne!(archive_modified(&uri), Some(before));
|
||||
std::fs::remove_file(&jar).unwrap();
|
||||
assert_eq!(archive_modified(&uri), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_generated_code() {
|
||||
assert!(is_decompiled(
|
||||
|
||||
@@ -1547,7 +1547,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
install_bundle(
|
||||
data_dir,
|
||||
"java-usages",
|
||||
"1.13.0",
|
||||
"1.14.0",
|
||||
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.13.0
|
||||
Bundle-Version: 1.14.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,
|
||||
|
||||
Binary file not shown.
@@ -17,6 +17,9 @@
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.archives.RefreshArchivesCommand">
|
||||
<command id="openidea.java.refreshArchives"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ai.openidea.archives;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.IPath;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.core.runtime.Path;
|
||||
import org.eclipse.jdt.core.IJavaModel;
|
||||
import org.eclipse.jdt.core.IJavaProject;
|
||||
import org.eclipse.jdt.core.IPackageFragmentRoot;
|
||||
import org.eclipse.jdt.core.JavaCore;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
|
||||
|
||||
/**
|
||||
* Re-reads library archives replaced in place (a SNAPSHOT installed again keeps its path). The
|
||||
* arguments are the archives' file system paths. Their package fragment roots are closed, which
|
||||
* drops the class files read from the old archive, and jdtls' decompiled sources (cached by class
|
||||
* handle, which a replaced archive keeps) are dropped, so the classes are read again.
|
||||
*/
|
||||
public final class RefreshArchivesCommand implements IDelegateCommandHandler {
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
IJavaModel model = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot());
|
||||
model.refreshExternalArchives(null, monitor);
|
||||
Set<IPath> archives = new HashSet<>();
|
||||
for (Object argument : arguments) {
|
||||
if (argument instanceof String path) archives.add(Path.fromOSString(path));
|
||||
}
|
||||
int closed = 0;
|
||||
if (archives.isEmpty()) return closed;
|
||||
clearDecompilerCache();
|
||||
for (IJavaProject project : model.getJavaProjects()) {
|
||||
for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
if (root.isArchive() && root.isExternal() && archives.contains(root.getPath()) && root.isOpen()) {
|
||||
root.close();
|
||||
closed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** jdtls keeps decompiled sources in a static per-decompiler LRU with no API to drop them. */
|
||||
private static void clearDecompilerCache() {
|
||||
try {
|
||||
Class<?> impl = IDelegateCommandHandler.class.getClassLoader()
|
||||
.loadClass("org.eclipse.jdt.ls.core.internal.decompiler.DecompilerImpl");
|
||||
Field field = impl.getDeclaredField("decompilerCache");
|
||||
field.setAccessible(true);
|
||||
if (field.get(null) instanceof Map<?, ?> caches) {
|
||||
synchronized (caches) {
|
||||
for (Object cache : caches.values()) {
|
||||
if (cache instanceof Map<?, ?> map) map.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
JavaLanguageServerPlugin.logException("Clearing decompiled sources", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4090,6 +4090,14 @@ export type DeploymentCoverage = {
|
||||
skipped: TransferError[],
|
||||
};
|
||||
|
||||
/** Named set of deployment servers that files are uploaded to together. */
|
||||
export type DeploymentGroup = {
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`DeploymentServer::id`]s, in upload order. */
|
||||
servers?: string[],
|
||||
};
|
||||
|
||||
/** Local files to compare with a deployment server (Sync with Deployed). */
|
||||
export type DeploymentLaunch = {
|
||||
/** Deployment server id. */
|
||||
@@ -4133,6 +4141,8 @@ export type DeploymentServer = {
|
||||
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
||||
export type DeploymentSettings = {
|
||||
servers?: DeploymentServer[],
|
||||
/** Server groups: Upload to… on a group uploads to each of its servers. */
|
||||
groups?: DeploymentGroup[],
|
||||
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
||||
defaultServer?: string,
|
||||
autoUpload?: AutoUpload,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { call, commands, isCancelled, nextRequestId, type RequestId } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, nextRequestId, onIdeEvent, type RequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { invalidateVirtualDocument } from "@/platform/virtualDocs";
|
||||
import { invalidateVirtualDocument, invalidateVirtualDocuments } from "@/platform/virtualDocs";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
|
||||
interface SourceState {
|
||||
@@ -82,6 +82,9 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
const offProject = useProject.subscribe((state, previous) => {
|
||||
if (state.project !== previous.project) clear();
|
||||
});
|
||||
// A reload may have replaced a library jar in place (a SNAPSHOT installed again): reopened
|
||||
// classes fetch their contents again (the backend refreshes the open ones).
|
||||
const offRoots = onIdeEvent("projectRootsChanged", () => invalidateVirtualDocuments());
|
||||
const offActions = registerActions([
|
||||
{
|
||||
id: "editor.downloadSources",
|
||||
@@ -118,6 +121,7 @@ export function registerLibrarySourcesActions(): () => void {
|
||||
return () => {
|
||||
offActions();
|
||||
offProject();
|
||||
offRoots();
|
||||
clear();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
expect(await screen.findByText("Collecting data…")).toBeTruthy();
|
||||
adapterData.library = "com.example.library.Library@1";
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
const value = await screen.findByText("com.example.library.Library@1");
|
||||
const value = await screen.findByText("{Library@1}");
|
||||
expect(screen.queryByText("Collecting data…")).toBeNull();
|
||||
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
|
||||
});
|
||||
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
await screen.findByText("Collecting data…");
|
||||
adapterData.library = "Library@2";
|
||||
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
|
||||
expect(await screen.findByText("Library@2")).toBeTruthy();
|
||||
expect(await screen.findByText("{Library@2}")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does nothing for an invalidation while the program runs", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { selectionExpression } from "../editor/valueTooltip";
|
||||
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
|
||||
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
|
||||
import { inlineValuesFor } from "../editor/execution";
|
||||
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
|
||||
import type { StackFrame } from "@/platform/ipc";
|
||||
@@ -150,3 +150,25 @@ describe("selectionExpression", () => {
|
||||
expect(selectionExpression(multi)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shown values", () => {
|
||||
it.each([
|
||||
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
|
||||
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
|
||||
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
|
||||
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
|
||||
["Db@21", "Db", { label: "{Db@21}", text: "" }],
|
||||
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
|
||||
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
|
||||
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
|
||||
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
|
||||
['"hello"', "String", { label: null, text: '"hello"' }],
|
||||
["42", "int", { label: "{int}", text: "42" }],
|
||||
// Other debuggers' values keep their text and type.
|
||||
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
|
||||
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
|
||||
["0x1000", null, { label: null, text: "0x1000" }],
|
||||
])("%s {%s}", (value, type, shown) => {
|
||||
expect(shownValue(value, type)).toEqual(shown);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { activeSession, useExecutionPoint } from "../state";
|
||||
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { identifiersIn, inlineValue } from "../values";
|
||||
import { identifiersIn, inlineValue, shownValue } from "../values";
|
||||
import { valueHint } from "./valueTooltip";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
@@ -113,11 +113,13 @@ export function inlineValuesFor(
|
||||
evaluateName: id,
|
||||
})
|
||||
: raw;
|
||||
const shown = shownValue(variable.value, variable.typeName);
|
||||
const value = shown.text || shown.label || variable.value;
|
||||
parts.push({
|
||||
name: id,
|
||||
text: `${id}: ${inlineValue(variable.value, 40)}`,
|
||||
text: `${id}: ${inlineValue(value, 40)}`,
|
||||
variable,
|
||||
truncated: variable.value.length > 40,
|
||||
truncated: value.length > 40,
|
||||
});
|
||||
if (parts.length >= MAX_PER_LINE) break;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { navigationModifierHeld } from "@/platform/navigationModifier";
|
||||
import { activeSession, evaluate } from "../state";
|
||||
import { inlineValue } from "../values";
|
||||
import { inlineValue, shownValue } from "../values";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { iconMarkup, selectableText } from "@/ui";
|
||||
import { mountValueTree } from "../ui/ValueHintTree";
|
||||
@@ -70,13 +70,19 @@ export function valueHint(
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.valueName!;
|
||||
name.textContent = `${expression} = `;
|
||||
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
|
||||
if (value.typeName) {
|
||||
const type = document.createElement("span");
|
||||
type.className = styles.valueType!;
|
||||
type.textContent = ` {${value.typeName}}`;
|
||||
dom.append(type);
|
||||
}
|
||||
const shown = shownValue(value.value, value.typeName);
|
||||
// A JVM object's `{Type@id}` leads its value; other types follow it.
|
||||
const leading = shown.label?.includes("@") ?? false;
|
||||
const type = (text: string) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = styles.valueType!;
|
||||
span.textContent = text;
|
||||
return span;
|
||||
};
|
||||
dom.append(name);
|
||||
if (leading) dom.append(type(`${shown.label} `));
|
||||
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
|
||||
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
|
||||
return { dom, destroy: () => tree?.unmount() };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
|
||||
import { methodAt } from "../breakpoints/symbols";
|
||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import { classifyValue, oneLine, sortVariables } from "../values";
|
||||
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
|
||||
import { valueText } from "../valueText";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import styles from "./VariablesView.module.css";
|
||||
@@ -486,20 +486,25 @@ function VarRow({
|
||||
</span>
|
||||
);
|
||||
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
||||
const shown = shownValue(node.value, node.typeName);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
{!shown.entry && (
|
||||
<>
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
</>
|
||||
)}
|
||||
{node.error ? (
|
||||
<span className={styles.error}>{oneLine(node.error)}</span>
|
||||
) : (
|
||||
<>
|
||||
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
|
||||
{shown.label && <span className={styles.type}>{shown.label}</span>}
|
||||
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
|
||||
{oneLine(node.value)}
|
||||
{oneLine(shown.text)}
|
||||
</span>
|
||||
{overflow && (
|
||||
<button
|
||||
|
||||
@@ -69,3 +69,35 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
|
||||
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
|
||||
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
|
||||
export interface ShownValue {
|
||||
label: string | null;
|
||||
text: string;
|
||||
/** A map entry: the row shows `key -> value` alone, without its index. */
|
||||
entry?: true;
|
||||
}
|
||||
|
||||
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
|
||||
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
|
||||
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
|
||||
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
|
||||
|
||||
/**
|
||||
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
|
||||
* a type. Values in other forms keep the adapter's text under `{type}`.
|
||||
*/
|
||||
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
|
||||
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
|
||||
if (!typeName) return plain;
|
||||
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
|
||||
const object = JVM_OBJECT.exec(value);
|
||||
if (!object) return plain;
|
||||
const [, head, id, rest = ""] = object;
|
||||
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
|
||||
if (simple(head!) !== simple(typeName)) return plain;
|
||||
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
|
||||
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
|
||||
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
|
||||
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { SyncEntry } from "@/platform/ipc";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
|
||||
servers: [] as DeploymentServerValid[],
|
||||
groups: [] as { id: string; name: string; servers: string[] }[],
|
||||
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
|
||||
choose: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
@@ -37,7 +38,7 @@ vi.mock("@/platform/project", () => ({
|
||||
useProject: { getState: () => ({ project: { root: "/project" } }) },
|
||||
}));
|
||||
vi.mock("@/platform/workflowSettings", () => ({
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
notifications: () => ({ notify: mocks.notify }),
|
||||
@@ -72,6 +73,7 @@ async function run(id: string, paths = ["/project/a", "/project/b"]) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
|
||||
mocks.groups = [];
|
||||
mocks.coverage = [
|
||||
{
|
||||
server: "one",
|
||||
@@ -91,6 +93,34 @@ beforeEach(() => {
|
||||
useDeployment.setState({ sync: null });
|
||||
registerDeploymentActions();
|
||||
});
|
||||
describe("server groups", () => {
|
||||
it("offers a group with a covering server and uploads to each of its servers, past a failure", async () => {
|
||||
mocks.groups = [
|
||||
{ id: "g", name: "Production", servers: ["one", "two"] },
|
||||
{ id: "none", name: "Elsewhere", servers: ["two"] },
|
||||
];
|
||||
mocks.coverage[1]!.paths = [];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
mocks.upload.mockRejectedValueOnce(new Error("connection refused"));
|
||||
await run("deployment.uploadTo");
|
||||
expect(mocks.choose).toHaveBeenCalledWith({ title: "Upload to", items: ["one", "Production (group)"] });
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "No selected paths can be deployed to two" }),
|
||||
),
|
||||
);
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot upload to one" }));
|
||||
});
|
||||
it("uploads to every covered server of the group in order", async () => {
|
||||
mocks.groups = [{ id: "g", name: "Production", servers: ["two", "one"] }];
|
||||
mocks.coverage[1]!.paths = ["/project/b"];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
await run("deployment.uploadTo");
|
||||
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.upload.mock.calls.map((c) => c[1])).toEqual(["two", "one"]);
|
||||
});
|
||||
});
|
||||
describe("deployment eligibility", () => {
|
||||
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
|
||||
mocks.servers = [mocks.servers[0]!];
|
||||
|
||||
@@ -89,13 +89,13 @@ function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferRepo
|
||||
: "No deployment server covers the selected paths",
|
||||
body: skipped?.length ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : undefined,
|
||||
group: GROUP,
|
||||
key: "deployment.noCoverage",
|
||||
key: server ? `deployment.noCoverage.${server.id}` : "deployment.noCoverage",
|
||||
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep the chooser visible even when only one server covers the selection. */
|
||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
/** The servers covering `paths`, or null (after saying why) when none does. */
|
||||
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
const configured = deployment().servers;
|
||||
if (!configured.length) {
|
||||
notifyNoServers();
|
||||
@@ -119,11 +119,36 @@ async function pickServer(title: string, paths: string[]): Promise<DeploymentSer
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
/** Keep the chooser visible even when only one server covers the selection. */
|
||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const labels = servers.map(serverLabel);
|
||||
const picked = await chooseItem({ title, items: labels });
|
||||
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
|
||||
}
|
||||
|
||||
/** Upload to…: a server covering the selection, or a group with such a server (all its servers). */
|
||||
async function pickUploadTargets(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const { servers: configured, groups } = deployment();
|
||||
const choices = new Map<string, DeploymentServerValid[]>();
|
||||
for (const server of servers) if (!choices.has(serverLabel(server))) choices.set(serverLabel(server), [server]);
|
||||
for (const group of groups) {
|
||||
const members = group.servers.flatMap((id) => configured.find((s) => s.id === id) ?? []);
|
||||
if (!members.some((m) => servers.includes(m))) continue;
|
||||
const name = group.name.trim() || group.id;
|
||||
const label = tx`${name} (group)`;
|
||||
if (!choices.has(label)) choices.set(label, members);
|
||||
}
|
||||
const picked = await chooseItem({ title: "Upload to", items: [...choices.keys()] });
|
||||
return picked === null ? null : (choices.get(picked) ?? null);
|
||||
}
|
||||
|
||||
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
return resolveServer(deployment(), null) ?? pickServer(title, paths);
|
||||
}
|
||||
@@ -160,7 +185,8 @@ export function showReport(
|
||||
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
|
||||
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
|
||||
group: GROUP,
|
||||
key: `deployment.${verb}`,
|
||||
// One per server, so each server of a group reports on its own.
|
||||
key: `deployment.${verb}.${server.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,7 +197,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
|
||||
title: title.replace("{server}", serverLabel(server)),
|
||||
body: errorText(e),
|
||||
group: GROUP,
|
||||
key: "deployment.failure",
|
||||
key: `deployment.failure.${server.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -451,7 +477,10 @@ export function registerDeploymentActions(): () => void {
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "upload", "remote"],
|
||||
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
|
||||
run: withTargets(async (paths) => {
|
||||
// A group uploads to each server in turn; each reports on its own, and a failure doesn't stop the rest.
|
||||
for (const server of (await pickUploadTargets(paths)) ?? []) await transfer("upload", server, paths);
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "deployment.downloadRemoteInto",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
initProjectTree,
|
||||
leadSelection,
|
||||
reloadAll,
|
||||
reloadArchiveDirs,
|
||||
reloadLoadedDirs,
|
||||
setChangedFiles,
|
||||
setOpenFiles,
|
||||
@@ -253,6 +254,7 @@ export function activate(): () => void {
|
||||
initProjectTypes(),
|
||||
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
|
||||
onIdeEvent("excludedDirsChanged", (e) => void reloadLoadedDirs(e.dirs)),
|
||||
onIdeEvent("projectRootsChanged", () => void reloadArchiveDirs()),
|
||||
];
|
||||
return () => {
|
||||
disposers.reverse().forEach((d) => d());
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MemoryFs, setProjectFsForTests } from "./fs";
|
||||
import { visibleInfo } from "./model";
|
||||
import {
|
||||
applyFsChange,
|
||||
reloadArchiveDirs,
|
||||
reloadLoadedDirs,
|
||||
currentModel,
|
||||
expandAll,
|
||||
@@ -114,6 +115,28 @@ describe("refresh of excluded folders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh of library archives", () => {
|
||||
it("re-reads the loaded folders inside archives only", async () => {
|
||||
await fs.createFile("/m2/lib.jar!/p/A.class");
|
||||
await loadDir(ROOT);
|
||||
await loadDir("/m2/lib.jar!");
|
||||
await loadDir("/m2/lib.jar!/p");
|
||||
await fs.createFile("/m2/lib.jar!/p/B.class");
|
||||
const spy = vi.spyOn(fs, "readDir");
|
||||
|
||||
await reloadArchiveDirs();
|
||||
|
||||
expect(spy.mock.calls.map(([p]) => p).sort()).toEqual(["/m2/lib.jar!", "/m2/lib.jar!/p"]);
|
||||
expect(
|
||||
useProjectTree
|
||||
.getState()
|
||||
.listings.get("/m2/lib.jar!/p")
|
||||
?.map((e) => e.name),
|
||||
).toEqual(["A.class", "B.class"]);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh on ignore-rule changes", () => {
|
||||
it("refreshes loaded descendants once, preserving selection and unrelated folders", async () => {
|
||||
await fs.createFile("/p/docs/sub/note.md");
|
||||
|
||||
@@ -565,6 +565,16 @@ export async function reloadLoadedDirs(dirs: readonly string[]) {
|
||||
await Promise.all(dirs.filter((p) => listings.has(p)).map((p) => loadDir(p, true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the loaded folders inside archives (`<archive>!…`): a build tool sync may have replaced
|
||||
* a library jar in place (a SNAPSHOT installed again), which no file watcher reports.
|
||||
*/
|
||||
export async function reloadArchiveDirs() {
|
||||
const { listings } = get();
|
||||
const inArchive = (p: string) => p.endsWith("!") || p.includes("!/") || p.includes("!\\");
|
||||
await Promise.all([...listings.keys()].filter(inArchive).map((p) => loadDir(p, true)));
|
||||
}
|
||||
|
||||
/** Re-reads every loaded folder (View › Reload from Disk). */
|
||||
export async function reloadAll() {
|
||||
const { listings } = get();
|
||||
|
||||
@@ -194,3 +194,13 @@
|
||||
padding: 16px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.groupMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.groupTitle {
|
||||
color: var(--fg-subtle);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
@@ -13,10 +13,24 @@ import {
|
||||
deploymentConnectable,
|
||||
deploymentConnection,
|
||||
deploymentSecretKey,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentServerValid,
|
||||
type FtpConnectionValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
showContextMenu,
|
||||
Spinner,
|
||||
T,
|
||||
Tabs,
|
||||
} from "@/ui";
|
||||
import { GlobListEditor, Row } from "../components/Form";
|
||||
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
|
||||
import { useSetting } from "../components/context";
|
||||
@@ -25,10 +39,13 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
import { choosePath } from "../languages/api";
|
||||
@@ -611,44 +628,118 @@ function ExcludedPathsTab({
|
||||
|
||||
type ServerTab = "connection" | "mappings" | "excluded";
|
||||
|
||||
function GroupPanel({
|
||||
group,
|
||||
servers,
|
||||
update,
|
||||
}: {
|
||||
group: DeploymentGroupValid;
|
||||
servers: readonly DeploymentServerValid[];
|
||||
update: (patch: Partial<DeploymentGroupValid>) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<TextField label="Name" value={group.name} onChange={(name) => update({ name })} />
|
||||
<div className={styles.groupMembers} role="group" aria-label={tx("Servers in group")}>
|
||||
<div className={styles.groupTitle}>
|
||||
<T>Servers in group</T>
|
||||
</div>
|
||||
{servers.length === 0 && (
|
||||
<div className={styles.note}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
checked={group.servers.includes(s.id)}
|
||||
onChange={() => update(toggleGroupServer(group, s.id, servers))}
|
||||
label={<span>{s.name || s.id}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.note}>
|
||||
<T>Upload to… on this group uploads the files to each of its servers in turn.</T>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Selection = { kind: "server" | "group"; id: string } | null;
|
||||
|
||||
function DeploymentServersEditor() {
|
||||
const tx = useTx();
|
||||
const [servers, setServers] = useSetting<DeploymentServerValid[]>("deployment.servers");
|
||||
const [groups, setGroups] = useSetting<DeploymentGroupValid[]>("deployment.groups");
|
||||
const [defaultServer, setDefaultServer] = useSetting<string>("deployment.defaultServer");
|
||||
const list = servers ?? [];
|
||||
const [selected, setSelected] = useState<string | null>(list[0]?.id ?? null);
|
||||
const groupList = groups ?? [];
|
||||
const [selected, setSelected] = useState<Selection>(list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
const [tab, setTab] = useState<ServerTab>("connection");
|
||||
const current = list.find((s) => s.id === selected) ?? null;
|
||||
const current = selected?.kind === "server" ? (list.find((s) => s.id === selected.id) ?? null) : null;
|
||||
const currentGroup = selected?.kind === "group" ? (groupList.find((g) => g.id === selected.id) ?? null) : null;
|
||||
const effectiveDefault = fixDefaultServer(list, defaultServer ?? "");
|
||||
|
||||
const commit = (next: DeploymentServerValid[]) => {
|
||||
setServers(next);
|
||||
const d = fixDefaultServer(next, defaultServer ?? "");
|
||||
if (d !== (defaultServer ?? "")) setDefaultServer(d);
|
||||
const pruned = pruneGroups(groupList, next);
|
||||
if (pruned.some((g, i) => g !== groupList[i])) setGroups(pruned);
|
||||
};
|
||||
const update = (patch: Partial<DeploymentServerValid>) => {
|
||||
if (current) commit(list.map((s) => (s.id === current.id ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const updateGroup = (patch: Partial<DeploymentGroupValid>) => {
|
||||
if (currentGroup) setGroups(groupList.map((g) => (g.id === currentGroup.id ? { ...g, ...patch } : g)));
|
||||
};
|
||||
const add = (base?: DeploymentServerValid) => {
|
||||
const server = newServer(list, base);
|
||||
commit([...list, server]);
|
||||
setSelected(server.id);
|
||||
setSelected({ kind: "server", id: server.id });
|
||||
setTab("connection");
|
||||
};
|
||||
const addGroup = () => {
|
||||
const group = newGroup(groupList);
|
||||
setGroups([...groupList, group]);
|
||||
setSelected({ kind: "group", id: group.id });
|
||||
};
|
||||
const remove = () => {
|
||||
if (currentGroup) {
|
||||
const index = groupList.indexOf(currentGroup);
|
||||
const rest = groupList.filter((g) => g.id !== currentGroup.id);
|
||||
setGroups(rest);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "group", id: next.id } : list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
return;
|
||||
}
|
||||
if (!current) return;
|
||||
const index = list.indexOf(current);
|
||||
const rest = list.filter((s) => s.id !== current.id);
|
||||
commit(rest);
|
||||
setSelected(rest[Math.min(index, rest.length - 1)]?.id ?? null);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "server", id: next.id } : null);
|
||||
};
|
||||
const isSelected = (kind: "server" | "group", id: string) => selected?.kind === kind && selected.id === id;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<div className={workflow.tableBox}>
|
||||
<div className={workflow.toolbar} role="toolbar" aria-label={tx("Deployment servers actions")}>
|
||||
<IconButton icon="plus" title="Add" size="small" onClick={() => add()} />
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.bottom }, [
|
||||
{ label: "Server", icon: "web", run: () => add() },
|
||||
{ label: "Server Group", icon: "folder", run: addGroup },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current && !currentGroup} onClick={remove} />
|
||||
<IconButton
|
||||
icon="copy"
|
||||
title="Copy"
|
||||
@@ -665,18 +756,31 @@ function DeploymentServersEditor() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
|
||||
{list.length === 0 && (
|
||||
{list.length === 0 && groupList.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{groupList.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
role="option"
|
||||
aria-selected={isSelected("group", g.id)}
|
||||
className={clsx(styles.serverRow, isSelected("group", g.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "group", id: g.id })}
|
||||
>
|
||||
<Icon name="folder" />
|
||||
<span className={styles.serverName}>{g.name || g.id}</span>
|
||||
<Badge>{g.servers.length}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{list.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
role="option"
|
||||
aria-selected={s.id === selected}
|
||||
className={clsx(styles.serverRow, s.id === selected && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected(s.id)}
|
||||
aria-selected={isSelected("server", s.id)}
|
||||
className={clsx(styles.serverRow, isSelected("server", s.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "server", id: s.id })}
|
||||
>
|
||||
<Icon name="web" />
|
||||
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
|
||||
@@ -687,7 +791,11 @@ function DeploymentServersEditor() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{current ? (
|
||||
{currentGroup ? (
|
||||
<div className={styles.detail}>
|
||||
<GroupPanel key={currentGroup.id} group={currentGroup} servers={list} update={updateGroup} />
|
||||
</div>
|
||||
) : current ? (
|
||||
<div className={styles.detail}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
@@ -780,6 +888,7 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
|
||||
"Passive mode",
|
||||
"Test Connection",
|
||||
"Use as Default",
|
||||
"Server Group",
|
||||
"Local path",
|
||||
"Deployment path",
|
||||
"Web path",
|
||||
|
||||
@@ -5,14 +5,40 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
nextServerName,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
|
||||
describe("server groups", () => {
|
||||
const servers = ["a", "b", "c"].map((id) => ({ ...DEFAULT_DEPLOYMENT_SERVER, id, name: id }));
|
||||
it("names a new group after the first free number", () => {
|
||||
const g = newGroup([{ id: "x", name: "Group 2", servers: [] }]);
|
||||
expect(g.name).toBe("Group 3");
|
||||
expect(g.servers).toEqual([]);
|
||||
expect(g.id).toMatch(/^group-/);
|
||||
});
|
||||
it("toggles members, keeping the server list's order", () => {
|
||||
let g = { id: "g", name: "G", servers: [] as string[] };
|
||||
g = toggleGroupServer(g, "c", servers);
|
||||
g = toggleGroupServer(g, "a", servers);
|
||||
expect(g.servers).toEqual(["a", "c"]);
|
||||
expect(toggleGroupServer(g, "a", servers).servers).toEqual(["c"]);
|
||||
});
|
||||
it("drops removed servers from groups and keeps untouched groups as they are", () => {
|
||||
const keep = { id: "k", name: "K", servers: ["a"] };
|
||||
const pruned = pruneGroups([keep, { id: "g", name: "G", servers: ["a", "b"] }], servers.slice(0, 1));
|
||||
expect(pruned[0]).toBe(keep);
|
||||
expect(pruned[1]!.servers).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exclude names", () => {
|
||||
it("parses a ;-separated list", () => {
|
||||
expect(parseExcludes(".git; .idea;;*.pyc ;")).toEqual([".git", ".idea", "*.pyc"]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
DEFAULT_DEPLOYMENT_SERVER,
|
||||
newRemoteId,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentMappingValid,
|
||||
type DeploymentServerValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
@@ -67,6 +68,37 @@ export function newServer(
|
||||
};
|
||||
}
|
||||
|
||||
/** A new, empty group named "Group <n>" (the first free number). */
|
||||
export function newGroup(groups: readonly DeploymentGroupValid[]): DeploymentGroupValid {
|
||||
const names = new Set(groups.map((g) => g.name));
|
||||
let n = groups.length + 1;
|
||||
while (names.has(`Group ${n}`)) n++;
|
||||
return { id: newRemoteId("group"), name: `Group ${n}`, servers: [] };
|
||||
}
|
||||
|
||||
/** Adds or removes `server` in `group`, keeping the members in the server list's order. */
|
||||
export function toggleGroupServer(
|
||||
group: DeploymentGroupValid,
|
||||
server: string,
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid {
|
||||
const members = new Set(group.servers);
|
||||
if (members.has(server)) members.delete(server);
|
||||
else members.add(server);
|
||||
return { ...group, servers: servers.map((s) => s.id).filter((id) => members.has(id)) };
|
||||
}
|
||||
|
||||
/** `groups` without members that are no longer in `servers`. */
|
||||
export function pruneGroups(
|
||||
groups: readonly DeploymentGroupValid[],
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid[] {
|
||||
const ids = new Set(servers.map((s) => s.id));
|
||||
return groups.map((g) =>
|
||||
g.servers.every((id) => ids.has(id)) ? g : { ...g, servers: g.servers.filter((id) => ids.has(id)) },
|
||||
);
|
||||
}
|
||||
|
||||
/** The default server after `servers` changed: kept when still present, else the first one (or none). */
|
||||
export function fixDefaultServer(servers: readonly DeploymentServerValid[], defaultServer: string): string {
|
||||
if (servers.some((s) => s.id === defaultServer)) return defaultServer;
|
||||
|
||||
@@ -1506,5 +1506,9 @@
|
||||
"Port": "Port",
|
||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
|
||||
"Keep the token": "Keep the token",
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks."
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.",
|
||||
"{name} (group)": "{name} (group)",
|
||||
"Servers in group": "Servers in group",
|
||||
"Upload to… on this group uploads the files to each of its servers in turn.": "Upload to… on this group uploads the files to each of its servers in turn.",
|
||||
"Server Group": "Server Group"
|
||||
}
|
||||
@@ -7907,5 +7907,9 @@
|
||||
"Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
|
||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
|
||||
"Keep the token": "保留令牌",
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。"
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。",
|
||||
"{name} (group)": "{name}(分组)",
|
||||
"Servers in group": "组内服务器",
|
||||
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
|
||||
"Server Group": "服务器分组"
|
||||
}
|
||||
@@ -43,8 +43,17 @@ export interface DeploymentServerValid {
|
||||
excludedPaths: string[];
|
||||
}
|
||||
|
||||
/** Servers that Upload to… on the group uploads to, one after another. */
|
||||
export interface DeploymentGroupValid {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Server ids, in upload order. */
|
||||
servers: string[];
|
||||
}
|
||||
|
||||
export interface DeploymentSettingsValid {
|
||||
servers: DeploymentServerValid[];
|
||||
groups: DeploymentGroupValid[];
|
||||
defaultServer: string;
|
||||
autoUpload: AutoUploadValid;
|
||||
deleteRemote: boolean;
|
||||
@@ -106,6 +115,7 @@ export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = {
|
||||
|
||||
export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
|
||||
servers: [],
|
||||
groups: [],
|
||||
defaultServer: "",
|
||||
autoUpload: "never",
|
||||
deleteRemote: false,
|
||||
@@ -206,8 +216,14 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid {
|
||||
mappings: validateMappings(s.mappings),
|
||||
excludedPaths: strings(s.excludedPaths, []),
|
||||
}));
|
||||
const groups = withIds(o.groups, "group", (g) => ({
|
||||
id: text(g.id, ""),
|
||||
name: text(g.name, ""),
|
||||
servers: strings(g.servers, []),
|
||||
}));
|
||||
return {
|
||||
servers,
|
||||
groups,
|
||||
defaultServer: text(o.defaultServer, d.defaultServer),
|
||||
autoUpload: oneOf(o.autoUpload, ["never", "onExplicitSave", "always"] as const, d.autoUpload),
|
||||
deleteRemote: bool(o.deleteRemote, d.deleteRemote),
|
||||
|
||||
@@ -73,6 +73,11 @@ export function invalidateVirtualDocument(uri: string): void {
|
||||
cache.delete(uri);
|
||||
}
|
||||
|
||||
/** Forgets every cached document: a build tool reload may have replaced library jars in place. */
|
||||
export function invalidateVirtualDocuments(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/** Contents of a virtual document (cached; failures are retried on the next call). */
|
||||
export function virtualDocument(uri: string): Promise<VirtualDocument> {
|
||||
let p = cache.get(uri);
|
||||
|
||||
@@ -344,6 +344,9 @@ export function MenuList({
|
||||
aria-label={aria["aria-label"]}
|
||||
className={styles.menu}
|
||||
onKeyDown={onKeyDown}
|
||||
// A right-click in an open menu does nothing; without this it reaches whatever is under the
|
||||
// menu in the component tree, e.g. the title bar's toolbar menu behind File › Open Recent.
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onPointerMove={(e) => {
|
||||
if (e.currentTarget.contains(e.target as Node)) pointerMove({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
|
||||
@@ -283,6 +283,17 @@ describe("Menu action enablement", () => {
|
||||
expect(open).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps a right-click in a menu from reaching the components behind it", () => {
|
||||
const behind = vi.fn((e: { defaultPrevented: boolean }) => e.defaultPrevented);
|
||||
render(
|
||||
<div onContextMenu={(e) => behind(e)}>
|
||||
<MenuList items={[{ label: "Recent", run: () => undefined }]} onDone={() => {}} />
|
||||
</div>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByRole("menuitem", { name: "Recent" }));
|
||||
expect(behind).toHaveReturnedWith(true);
|
||||
});
|
||||
|
||||
it("reports a hovered row once and lets the keyboard continue from it", () => {
|
||||
const active: string[] = [];
|
||||
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => undefined });
|
||||
|
||||
Reference in new issue
Block a user