Compare commits

...
Author SHA1 Message Date
l0ng-ai 4522b9a5e1 feat(vcs): create a Git repository for a project that has none
Git › Create Git Repository… asks for the folder (the project root by default),
runs git init there and picks up the new repository, so the branch widget,
the Commit tool window and file statuses appear without reopening the project.
The title bar's Version Control menu, shown while a project has no repository,
lists it first.
2026-11-13 20:38:50 +08:00
l0ng-ai a9ad1f1bbb fix(agents): stop Claude Code processes when their connection closes (#3163)
Closing a task's connection (idle release, archive, delete) stopped its claude -p processes through a weak reference to the in-process shim, which the closing connection could already have dropped; the processes then ran until the app quit. The close watcher now holds the shim, and a session dropped without being stopped stops its process. Rollback and fork, which start a stopped agent without a turn, now schedule the same idle release.
2026-11-13 20:35:41 +08:00
l0ng-ai 4c8caca6a5 feat(editor): open the blame annotations from Code Vision's author hint (#3161)
Clicking a declaration's Code Vision author hint now runs Annotate with Git Blame for that editor and leaves the annotations open when already shown.
2026-11-13 20:25:47 +08:00
l0ng-ai 2ec2e75d40 fix(vcs): run the merge editor's buttons on a mouse click on macOS (#3160)
WebKit doesn't focus a clicked button, so focus moved to the editor area outside the merge editor and cleared the focused merge editor; Apply, Cancel, Accept Yours/Theirs and the toolbar actions then did nothing on a mouse click. The merge editor root is now focusable (tabindex -1) so a click inside it keeps focus there.
2026-11-13 20:20:50 +08:00
Muuandmuutot 04d5d9bd58 feat(color scheme): edit text style for errors and warnings (#3154)
Errors and Warnings attributes (Error, Warning, Typo, Unused symbol, Deprecated symbol) now offer bold, italic and effect choices like syntax tokens, written to --wave-*/--fg-* variables the editor reads; Typo and Deprecated symbol are listed and previewed. Deprecated symbol keeps the text colour and draws only its effect; an effect set to none draws none. Custom and imported schemes keep the font style and effect of token-less attributes (Errors and Warnings, Git), and the preview shows them.

Co-authored-by: muutot <[email protected]>
2026-11-13 20:15:01 +08:00
l0ng-ai 9a415e6875 fix(project-tree): list a re-installed SNAPSHOT's entries after reloading the project (#3158)
A SNAPSHOT installed again replaces its jar in place, which no file watcher reports, so expanded folders inside it under External Libraries kept the old entries. The project view now re-reads its loaded folders inside archives when project roots change (a build tool sync).
2026-11-13 20:04:17 +08:00
l0ng-ai 6f83f8cad0 fix(java): show a re-installed SNAPSHOT's classes after reloading the project (#3157)
A SNAPSHOT installed again replaces its jar in place, so class URIs and jdtls' class handles stay the same; the backend's virtual document cache, the editor's cache and jdtls' decompiler cache all kept the old contents. Cached library classes now record their archive's modification time and are read again once it changes; after a build tool sync jdtls refreshes external archives, closes the changed roots and drops its decompiled sources (openidea.java.refreshArchives, java-usages 1.14.0), and open classes whose jar changed are replaced. The editor drops cached virtual documents when project roots change.
2026-11-13 20:01:02 +08:00
l0ng-ai b971c8b391 fix(ui): keep a right-click in an open menu from opening the toolbar menu (#3152)
Right-clicking an entry of the in-app main menu (Windows and Linux), such as a project under File › Open Recent, opened the title bar's toolbar menu (Project Widget, VCS Widget, …): the menu is rendered in a popup but is a child of the title bar in the component tree, so the contextmenu event reached the title bar's handler. A menu now consumes right-clicks itself, which covers every menu shown above another component's context menu.
2026-11-13 19:33:30 +08:00
l0ng-ai c333990404 fix(debugger): show JVM values as {Type@id} summary and map entries as key -> value (#3150)
The Variables view showed the Java debugger's text under its type: args = {String[]} String[0]@9, map = {HashMap} HashMap@10 size=1, and a map entry as 0 = {HashMap$Node} HashMap$Node@31 "a":"b". A JVM object now reads {String[0]@9} [] and {HashMap@10} size = 1, a map entry as "a" -> "b" without its index, and a String without its type. Inline values and the value tooltip use the same text (args: [], map: size = 1). Values from other debuggers keep their text and type.
2026-11-13 19:24:18 +08:00
l0ng-ai 70b953c77a feat(deployment): server groups for uploading to several servers at once (#3149)
Deployment settings can hold server groups (+ › Server Group): a name and a set of the configured servers. Upload to… lists a group whenever one of its servers covers the selection and uploads to each of its servers in turn. Each server reports its result or error in its own notification, and a failure on one server doesn't stop the rest. Removing a server drops it from its groups. Upload to Default Server, download, sync and delete keep working on single servers.
2026-11-13 19:18:33 +08:00
50 changed files with 963 additions and 85 deletions

No files matched your search

+4
View File
@@ -1097,6 +1097,8 @@ impl AgentManager {
title_renamed: s.title_renamed,
}
};
// Copying may have started the source's agent: it stops again once idle.
source.release_when_idle();
let task = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), task.clone());
task.persist();
@@ -1166,6 +1168,8 @@ impl AgentManager {
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
// Undoing may have started the agent: it stops again once idle.
task.release_when_idle();
Ok(first)
}
+13 -3
View File
@@ -583,11 +583,12 @@ impl Shim {
impl super::Shim for Shim {
fn attach(&self, client: Peer) {
let _ = self.client.set(client.clone());
// The client went away: so do the processes.
let weak = self.me.clone();
// The client went away: so do the processes. Held until then: the connection drops
// its handler (this shim) as it closes, and the sessions would go without stopping.
let me = self.me.upgrade();
tokio::spawn(async move {
client.closed().await;
if let Some(me) = weak.upgrade() {
if let Some(me) = me {
me.closed.store(true, Ordering::Release);
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
for s in sessions {
@@ -598,6 +599,15 @@ impl super::Shim for Shim {
}
}
impl Drop for Session {
/// A session dropped without being stopped (the shim went away) takes its process along.
fn drop(&mut self) {
if let Some(p) = self.st.get_mut().proc.take() {
p.kill.cancel();
}
}
}
impl Session {
fn shim(&self) -> Option<Arc<Shim>> {
self.shim.upgrade()
@@ -311,6 +311,8 @@ struct Replay {
sent: Arc<Mutex<Vec<Vec<Value>>>>,
launched: Mutex<Vec<Vec<String>>>,
errors: Arc<Mutex<Vec<String>>>,
/// Each process's kill token.
kills: Mutex<Vec<CancellationToken>>,
}
impl Cli for Replay {
@@ -331,6 +333,7 @@ impl Cli for Replay {
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
let kill = CancellationToken::new();
self.kills.lock().push(kill.clone());
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
sent.lock().push(Vec::new());
tokio::spawn(async move {
@@ -428,6 +431,7 @@ async fn replay(name: &str) -> Replayed {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
@@ -461,6 +465,23 @@ async fn replay_simple() {
assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there");
}
/// The app closing the connection (an idle task released, archived or deleted) stops the
/// CLI processes of its sessions, idle ones included.
#[tokio::test(flavor = "multi_thread")]
async fn closing_the_connection_stops_the_processes() {
let r = replay("simple").await;
assert!(r.cli.kills.lock().iter().all(|k| !k.is_cancelled()));
r.h.client.kill();
let kills = r.cli.kills.lock().clone();
tokio::time::timeout(Duration::from_secs(5), async {
for k in kills {
k.cancelled().await;
}
})
.await
.expect("every process was stopped");
}
#[tokio::test(flavor = "multi_thread")]
async fn replay_multi_turn() {
let r = replay("multi_turn").await;
@@ -727,6 +748,7 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
@@ -770,6 +792,7 @@ async fn a_failed_turn_reports_its_failure() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h =
+21
View File
@@ -2391,6 +2391,27 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
assert!(live(&mgr, &task.id));
mgr.shutdown();
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
mgr.rollback(&task.id, 1).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
mgr.fork(&task.id, None).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
mgr.shutdown();
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
+13
View File
@@ -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 {
+41 -1
View File
@@ -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.
+15
View File
@@ -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.
+43 -2
View File
@@ -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
}
+37
View File
@@ -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(
+1 -1
View File
@@ -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"),
)
}
+17
View File
@@ -122,6 +122,23 @@ test("Apply writes and stages unresolved text only after confirmation", async ({
expect((await writes(page))[0]!.args[2]).toEqual({ changes: [{ from: 0, to: 13, insert: BASE }] });
});
test("footer buttons work when a click doesn't focus them, as in WebKit", async ({ page }) => {
await mountMerge(page);
await result(page).locator(".cm-content").click();
// WebKit focuses the clicked button's nearest focusable ancestor, not the button.
await page
.locator("#merge-host")
.getByRole("button", { name: "Apply", exact: true })
.evaluate((button) => {
let target = button.parentElement;
while (target && !target.hasAttribute("tabindex")) target = target.parentElement;
if (target) target.focus();
else (document.activeElement as HTMLElement | null)?.blur();
(button as HTMLButtonElement).click();
});
await expect(page.getByRole("dialog")).toContainText("Save and mark the file as resolved anyway?");
});
test("binary and unreadable versions show an error with no Apply controls", async ({ page }) => {
await mountMerge(page, ["\0base", "\0ours", "\0theirs"]);
await expect(page.locator("#merge-host")).toContainText("Binary files cannot be resolved in the merge editor.");
+1 -1
View File
@@ -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.
+3
View File
@@ -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);
}
}
}
+10
View File
@@ -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,
+6 -2
View File
@@ -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();
};
}
+8 -1
View File
@@ -9,7 +9,7 @@ import { Channel } from "@tauri-apps/api/core";
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { executeAction, getAction } from "@/platform/actions";
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
@@ -106,6 +106,12 @@ const lensField = StateField.define<CodeLens[]>({
const usagesEnabled = () => editorPrefs().inlay.codeVisionUsages;
const authorEnabled = () => editorPrefs().inlay.codeVisionAuthor;
/** The author hint opens the file's blame annotations, and leaves them open when shown. */
function showAnnotations(view: EditorView) {
view.focus();
if (!getAction("vcs.annotate")?.toggled?.()) void executeAction("vcs.annotate");
}
function openUsages(view: EditorView, offset: number) {
view.dispatch({ selection: EditorSelection.cursor(offset), userEvent: "select.lens" });
view.focus();
@@ -258,6 +264,7 @@ class UsagesLensPlugin {
icon: "user",
order: 20,
kind: "author",
run: showAnnotations,
});
const n = usages ? this.cache.get(key(t)) : undefined;
if (n === undefined || (n === 0 && !editorPrefs().inlay.codeVisionNoUsages)) continue;
+16 -3
View File
@@ -235,12 +235,16 @@ const lintTheme = EditorView.theme({
".cm-lintRange-error": {
textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
WebkitTextDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
fontWeight: "var(--wave-error-bold, inherit)",
fontStyle: "var(--wave-error-italic, inherit)",
textDecorationSkipInk: "none",
textUnderlineOffset: "3px",
},
".cm-lintRange-warning": {
textDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))",
WebkitTextDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))",
fontWeight: "var(--wave-warning-bold, inherit)",
fontStyle: "var(--wave-warning-italic, inherit)",
backgroundColor: "var(--editor-warning-background, transparent)",
textDecorationSkipInk: "none",
textUnderlineOffset: "3px",
@@ -254,8 +258,10 @@ const lintTheme = EditorView.theme({
".cm-lintRange-hint": { textDecoration: "none" },
// Typo level: IDEA's green wavy underline.
".cm-lintRange.cm-lsp-typo": {
textDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
WebkitTextDecoration: "underline wavy color-mix(in srgb, var(--success) 85%, transparent)",
textDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))",
WebkitTextDecoration: "var(--wave-typo-effect, underline wavy var(--wave-typo, var(--success)))",
fontWeight: "var(--wave-typo-bold, inherit)",
fontStyle: "var(--wave-typo-italic, inherit)",
textDecorationSkipInk: "none",
textUnderlineOffset: "3px",
},
@@ -263,9 +269,16 @@ const lintTheme = EditorView.theme({
textDecoration: "none",
backgroundColor: "transparent",
color: "var(--fg-unused, var(--fg-faint))",
fontWeight: "var(--fg-unused-bold, inherit)",
fontStyle: "var(--fg-unused-italic, inherit)",
},
".cm-lintRange.cm-lsp-unnecessary *": { color: "var(--fg-unused, var(--fg-faint))" },
".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" },
".cm-lintRange.cm-lsp-deprecated": {
textDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))",
WebkitTextDecoration: "var(--fg-deprecated-effect, line-through var(--fg-deprecated, var(--fg-muted)))",
fontWeight: "var(--fg-deprecated-bold, inherit)",
fontStyle: "var(--fg-deprecated-italic, inherit)",
},
// An error or warning on the same range (merged into one mark) keeps its underline.
".cm-lintRange-error.cm-lsp-typo, .cm-lintRange-error.cm-lsp-unnecessary": {
textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
@@ -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 () => {
+23 -1
View File
@@ -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);
});
});
+5 -3
View File
@@ -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;
}
+14 -8
View File
@@ -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() };
}
+12 -7
View File
@@ -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
+32
View File
@@ -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 };
}
+31 -1
View File
@@ -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]!];
+35 -6
View File
@@ -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",
+2
View File
@@ -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());
+23
View File
@@ -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");
+10
View File
@@ -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;
}
+121 -12
View File
@@ -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;
@@ -10,6 +10,7 @@ import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride }
import {
ATTRIBUTES,
SCHEMES,
EFFECT_CSS,
overrideDeclarations,
effectiveScheme,
resolvedSchemeTokens,
@@ -123,6 +124,8 @@ function previewVars(
k.startsWith("--border-") ||
k.startsWith("--ansi-") ||
k.startsWith("--git-") ||
k.startsWith("--wave-") ||
k.startsWith("--editor-") ||
k === "--caret"
)
vars[k] = v;
@@ -137,20 +140,32 @@ function previewVars(
/** Text styled like an attribute in the preview. */
function tokenStyle(attr: AttributeDef | undefined): CSSProperties {
if (!attr?.token)
return {
const base = attr?.fg ?? attr?.effect;
if (!attr?.token) {
const colour: CSSProperties = {
...(attr?.fg ? { color: `var(${attr.fg})` } : null),
...(attr?.bg ? { backgroundColor: `var(${attr.bg})` } : null),
// Token-less attributes with a font-style renderer carry it on `--<fg>-*`, the same
// declarations `overrideDeclarations` emits for their overrides.
...(attr?.fontStyle && (attr.fg ?? attr.effect)
? {
fontWeight: `var(${attr.fg ?? attr.effect}-bold, inherit)`,
fontStyle: `var(${attr.fg ?? attr.effect}-italic, inherit)`,
textDecoration: `var(${attr.effect ?? attr.fg}-effect, none)`,
}
};
// Token-less attributes with a font-style renderer carry it on `--<fg|effect>-*`, the same
// declarations `overrideDeclarations` emits for their overrides.
if (!attr?.fontStyle || !base) return colour;
const effectColor = `var(${attr.effect ?? attr.fg ?? "--fg-muted"})`;
const effect: TextAttributesOverride["effect"] = attr.defaultEffect ?? (attr.effect ? "wave" : undefined);
const decoration =
effect && effect !== "none"
? `var(${attr.effect ?? base}-effect, ${EFFECT_CSS[effect]?.(effectColor) ?? "none"})`
: undefined;
return {
...colour,
fontWeight: `var(${base}-bold, ${
attr.defaultBold ? "var(--fw-code-bold, 700)" : "inherit"
})` as CSSProperties["fontWeight"],
fontStyle: `var(${base}-italic, ${attr.defaultItalic ? "italic" : "inherit"})`,
...(decoration
? { textDecoration: decoration, ...({ WebkitTextDecoration: decoration } as CSSProperties) }
: null),
};
}
if (attr.italicOnly)
return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` };
const t = attr.token;
@@ -206,6 +221,11 @@ const SAMPLES: Record<string, CodeSample> = {
[" ", "text"],
["legacyCall", "unused"],
],
[
["typo", "typo"],
[" ", "text"],
["deprecatedCall", "deprecated"],
],
[
["bad\u203dchar", "invalid"],
[" \u00b7 ", "whitespaces"],
+2
View File
@@ -5,6 +5,7 @@
display: flex;
flex-direction: column;
background: var(--bg-app);
outline: none;
}
.toolbar {
flex: none;
@@ -84,6 +85,7 @@
}
.message {
flex: 1;
outline: none;
display: flex;
flex-direction: column;
align-items: center;
+4
View File
@@ -541,6 +541,10 @@ export function MergeEditor({ input }: { input: EditorInput }) {
}, [actionHost]);
const focusProps = {
// WebKit doesn't focus a clicked button: focus then moves to the nearest focusable
// ancestor, which must be this editor for the footer and toolbar buttons to keep it the
// focused merge editor their actions run in.
tabIndex: -1,
onFocusCapture: (e: React.FocusEvent<HTMLDivElement>) => {
const target = e.target as Node;
if (oursRef.current?.contains(target)) paneRef.current = "left";
+10
View File
@@ -49,6 +49,7 @@ import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget, type UpdateTarget } from "./remoteUi";
import { comparePathsWithRevision, showDirectoryWorkingDiff } from "./comparePaths";
import { showCloneDialog } from "./clone";
import { createRepository } from "./createRepository";
import {
showManageRemotes,
showMergeDialog,
@@ -246,6 +247,15 @@ export function registerVcsActions(): () => void {
keyWhen: IN_COMMIT_WINDOW,
run: () => useCommit.setState((s) => ({ historyTick: s.historyTick + 1 })),
},
{
id: "vcs.createRepository",
title: "Create Git Repository…",
category: "Git",
icon: "branch",
when: "projectOpen && projectLocal",
keywords: ["init", "git", "enable version control"],
run: () => createRepository(),
},
{
id: "vcs.clone",
title: "Get from Version Control…",
+26
View File
@@ -0,0 +1,26 @@
/** VCS › Create Git Repository…: `git init` in a folder chosen under the project. */
import { open } from "@/platform/dialog";
import { tx } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { discoverRoots } from "./store";
export async function createRepository() {
const root = useProject.getState().project?.root;
const dir = await open({ title: tx("Select Directory for Git Init"), directory: true, defaultPath: root });
if (typeof dir !== "string") return;
try {
await call(commands.vcsInit(dir));
} catch (e) {
notifications().notify({
group: "vcs",
level: "error",
title: tx("Couldn't create Git repository"),
body: String((e as Error)?.message ?? e),
});
return;
}
notifications().notify({ group: "vcs", level: "info", title: tx`Created Git repository in ${dir}` });
await discoverRoots();
}
+1 -1
View File
@@ -266,7 +266,7 @@ export function statusOf(path: string): FileStatus | undefined {
}
/** Repositories found below a project that is not itself under git (scanned in the background). */
async function discoverRoots() {
export async function discoverRoots() {
const root = useVcs.getState().root;
if (!root || !isTauri()) return;
const roots = await call(commands.vcsRoots()).catch(() => []);
+40
View File
@@ -4,6 +4,7 @@ import {
ATTRIBUTES,
CLEAR_COLOR,
_setSchemeSource,
overrideDeclarations,
resolvedAttribute,
schemeAfterThemeChange,
schemeCss,
@@ -341,4 +342,43 @@ describe("color schemes", () => {
expect(v.codeStyle.languages.go).toEqual({ useTab: true });
expect(v.general.softWrapFiles).toContain("*.md");
});
it("styles Errors and Warnings attributes beyond colour", () => {
const decls = overrideDeclarations({
error: { bold: true, italic: true, effect: "strikeout", effectColor: "#ff0000" },
unused: { italic: true },
});
const map = new Map(decls);
expect(map.get("--wave-error-bold")).toBe("var(--fw-code-bold, 700)");
expect(map.get("--wave-error-italic")).toBe("italic");
expect(map.get("--wave-error-effect")).toBe("line-through #ff0000");
expect(map.get("--editor-error-decoration")).toBe("line-through #ff0000");
expect(map.get("--wave-error")).toBe("#ff0000");
expect(map.get("--fg-unused-italic")).toBe("italic");
const none = new Map(overrideDeclarations({ warning: { effect: "none" }, deprecated: { effect: "underline" } }));
expect(none.get("--editor-warning-decoration")).toBe("none");
expect(none.get("--fg-deprecated-effect")).toBe("underline var(--fg-deprecated)");
});
it("keeps the font style of Errors and Warnings in custom schemes", () => {
const customs = {
"custom-x": {
name: "X",
parent: "dark" as const,
attributes: { error: { bold: true }, deprecated: { effect: "underline" as const } },
},
};
const css = schemeCss("custom-x", undefined, null, customs);
expect(css).toContain("--wave-error-bold: var(--fw-code-bold, 700)");
expect(css).toContain("--fg-deprecated-effect: underline var(--fg-deprecated)");
});
it("lists typo and deprecated symbol under Errors and Warnings", () => {
const ids = ATTRIBUTES.filter((a) => a.path.join("/") === "General/Errors and Warnings").map((a) => a.id);
expect(ids).toEqual(["error", "warning", "weakWarning", "typo", "unused", "deprecated", "invalid"]);
expect(resolvedAttribute("dark", "typo")).toMatchObject({ effect: "wave" });
expect(resolvedAttribute("dark", "deprecated")).toMatchObject({ effect: "strikeout", effectColor: "#8c8f95" });
expect(resolvedAttribute("dark", "deprecated").foreground).toBeUndefined();
expect(resolvedAttribute("dark", "error")).toMatchObject({ effect: "wave", bold: false, italic: false });
});
});
+70 -17
View File
@@ -31,7 +31,8 @@ export interface AttributeDef {
token?: string;
/** A semantic modifier whose only configurable attribute is italic. */
italicOnly?: boolean;
/** The attribute's renderer applies font style and effects (tokens, the Git surfaces). */
/** The attribute's renderer applies font style and effects (tokens, the Git surfaces,
* Errors and Warnings): overrides land on `--<fg|effect>-*`. */
fontStyle?: true;
defaultBold?: boolean;
defaultItalic?: boolean;
@@ -139,10 +140,46 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
path: ["General", "Search Results"],
bg: "--bg-search-match-current",
},
{ id: "error", name: "Error", path: ["General", "Errors and Warnings"], effect: "--wave-error" },
{ id: "warning", name: "Warning", path: ["General", "Errors and Warnings"], effect: "--wave-warning" },
{
id: "error",
name: "Error",
path: ["General", "Errors and Warnings"],
effect: "--wave-error",
defaultEffect: "wave",
fontStyle: true,
},
{
id: "warning",
name: "Warning",
path: ["General", "Errors and Warnings"],
effect: "--wave-warning",
defaultEffect: "wave",
fontStyle: true,
},
{ id: "weakWarning", name: "Weak warning", path: ["General", "Errors and Warnings"], fg: "--fg-weak-warning" },
{ id: "unused", name: "Unused symbol", path: ["General", "Errors and Warnings"], fg: "--fg-unused" },
{
id: "typo",
name: "Typo",
path: ["General", "Errors and Warnings"],
effect: "--wave-typo",
defaultEffect: "wave",
fontStyle: true,
},
{
id: "unused",
name: "Unused symbol",
path: ["General", "Errors and Warnings"],
fg: "--fg-unused",
fontStyle: true,
},
{
id: "deprecated",
name: "Deprecated symbol",
path: ["General", "Errors and Warnings"],
effect: "--fg-deprecated",
defaultEffect: "strikeout",
fontStyle: true,
},
syn("invalid", "Bad character", ["General", "Errors and Warnings"], "invalid"),
syn("todo", "TODO defaults", ["General", "Code"], "todo"),
{ id: "inlineValue", name: "Inline debugger values", path: ["Debugger"], fg: "--syn-inline-value" },
@@ -339,6 +376,11 @@ export const ATTRIBUTES: readonly AttributeDef[] = [
/** Variables a scheme sets: every attribute variable plus the editor tokens derived from them. */
const SCHEME_VARS = new Set<string>([
...ATTRIBUTES.flatMap((a) => [a.fg, a.bg, a.effect].filter((v): v is string => !!v)),
// Font style of token-less attributes (`overrideDeclarations` writes them for custom schemes too).
...ATTRIBUTES.flatMap((a) => {
const base = a.fg ?? a.effect;
return !a.token && a.fontStyle && base ? [`${base}-bold`, `${base}-italic`, `${a.effect ?? base}-effect`] : [];
}),
"--syn-comment-style",
"--syn-doc-comment-tag-effect",
"--editor-error-decoration",
@@ -477,7 +519,7 @@ export function boldWeight(bold: boolean): string {
return bold ? "var(--fw-code-bold, 700)" : "var(--fw-code, 400)";
}
const EFFECT_CSS: Record<string, (c: string) => string> = {
export const EFFECT_CSS: Record<string, (c: string) => string> = {
underline: (c) => `underline ${c}`,
wave: (c) => `underline wavy ${c}`,
strikeout: (c) => `line-through ${c}`,
@@ -507,9 +549,12 @@ export function overrideDeclarations(
}
if (setOrCleared(o.background) && attr.bg) out.push([attr.bg, o.background]);
if (setOrCleared(o.effectColor) && attr.effect) out.push([attr.effect, o.effectColor]);
if (attr.effect && setOrCleared(o.effectColor)) {
if (attr.id === "error") out.push(["--editor-error-decoration", `underline wavy ${o.effectColor}`]);
if (attr.id === "warning") out.push(["--editor-warning-decoration", `underline wavy ${o.effectColor}`]);
if (attr.effect && (setOrCleared(o.effectColor) || o.effect)) {
const style = o.effect ?? attr.defaultEffect ?? "wave";
const color = setOrCleared(o.effectColor) ? o.effectColor! : `var(${attr.effect})`;
const decoration = EFFECT_CSS[style]?.(color) ?? "none";
if (attr.id === "error") out.push(["--editor-error-decoration", decoration]);
if (attr.id === "warning") out.push(["--editor-warning-decoration", decoration]);
}
if (attr.token) {
const t = attr.token;
@@ -534,7 +579,9 @@ export function overrideDeclarations(
if (o.effect)
out.push([
`${attr.effect ?? base}-effect`,
EFFECT_CSS[o.effect]?.(setOrCleared(o.effectColor) ? o.effectColor! : "currentColor") ?? "none",
EFFECT_CSS[o.effect]?.(
setOrCleared(o.effectColor) ? o.effectColor! : attr.effect ? `var(${attr.effect})` : "currentColor",
) ?? "none",
]);
}
}
@@ -646,14 +693,20 @@ export function resolvedAttribute(
const italicCss =
vars.get(`--syn-${token}-italic`) ?? (token === "comment" ? vars.get("--syn-comment-style") : undefined);
out.italic = own?.italic ?? italicFromCss(italicCss) ?? attr.defaultItalic ?? false;
} else if (attr.fontStyle) {
const base = attr.fg ?? attr.effect;
const bold = base ? vars.get(`${base}-bold`) : undefined;
const italic = base ? vars.get(`${base}-italic`) : undefined;
if (bold !== undefined) out.bold = boldFromCss(bold) ?? false;
if (italic !== undefined) out.italic = italicFromCss(italic) ?? false;
} else if (effect) out.effect = effect;
if (token && effect) out.effect = effect;
if (effect) out.effect = effect;
} else {
// Token-less attributes whose renderer applies font style (the Git segments, Errors and
// Warnings) carry their overrides on `--<fg|effect>-*`, the same declarations
// `overrideDeclarations` emits.
if (attr.fontStyle) {
const base = attr.fg ?? attr.effect;
const bold = base ? vars.get(`${base}-bold`) : undefined;
const italic = base ? vars.get(`${base}-italic`) : undefined;
out.bold = boldFromCss(bold) ?? attr.defaultBold ?? false;
out.italic = italicFromCss(italic) ?? attr.defaultItalic ?? false;
}
if (effect) out.effect = effect;
}
return out;
}
+9 -1
View File
@@ -1506,5 +1506,13 @@
"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",
"Create Git Repository…": "Create Git Repository…",
"Select Directory for Git Init": "Select Directory for Git Init",
"Couldn't create Git repository": "Couldn't create Git repository",
"Created Git repository in {dir}": "Created Git repository in {dir}"
}
+9 -1
View File
@@ -7907,5 +7907,13 @@
"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": "服务器分组",
"Create Git Repository…": "创建 Git 仓库…",
"Select Directory for Git Init": "选择要执行 Git Init 的目录",
"Couldn't create Git repository": "无法创建 Git 仓库",
"Created Git repository in {dir}": "已在 {dir} 创建 Git 仓库"
}
+16
View File
@@ -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),
+5
View File
@@ -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);
+8
View File
@@ -204,6 +204,8 @@ html {
--fg-fold-marker: #6f737a;
--fg-whitespace: #6f737a;
--fg-unused: #6f737a;
--wave-typo: #59a869;
--fg-deprecated: #8c8f95;
--bg-identifier-under-caret: #373b39;
--bg-identifier-under-caret-write: #402f33;
--fg-inlay: #868a91;
@@ -643,6 +645,8 @@ html {
--fg-fold-marker: #8b8e94;
--fg-whitespace: #b5b7bd;
--fg-unused: #8c8c8c;
--wave-typo: #3d9950;
--fg-deprecated: #6c707e;
--bg-identifier-under-caret: #edebfc;
--bg-identifier-under-caret-write: #fce8f4;
--fg-inlay: #73767c;
@@ -935,6 +939,8 @@ html {
--fg-fold-marker: #9fa2a9;
--fg-whitespace: #8b8e95;
--fg-unused: #8b8e95;
--wave-typo: #6cbb7d;
--fg-deprecated: #b6b8be;
--bg-identifier-under-caret: #3f4a44;
--bg-identifier-under-caret-write: #553840;
--fg-inlay: #ccced3;
@@ -1147,6 +1153,8 @@ html {
--fg-fold-marker: var(--palette-muted);
--fg-whitespace: var(--palette-muted);
--fg-unused: var(--palette-muted);
--wave-typo: var(--palette-green);
--fg-deprecated: var(--palette-muted);
--fg-inlay: var(--palette-muted);
--fg-weak-warning: var(--palette-muted);
--syn-inline-value: var(--palette-muted);
+3
View File
@@ -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 });
}}
+11
View File
@@ -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 });
+1 -1
View File
@@ -95,7 +95,7 @@ function ProjectButton() {
);
}
const VCS_FALLBACK_ACTIONS = ["vcs.clone", "vcs.localHistory", "vcs.showLog"];
const VCS_FALLBACK_ACTIONS = ["vcs.createRepository", "vcs.clone", "vcs.localHistory", "vcs.showLog"];
/** "Version Control ▾" shown while no VCS widget is contributed (project without a repository). */
function VcsFallback() {