Порівняти коміти

...
Автор SHA1 Повідомлення Дата
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
Muuтаmuutot 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
41 змінених файлів з 661 додано та 66 видалено

No files matched your search

+4
Переглянути файл
@@ -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
Переглянути файл
@@ -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()
+23
Переглянути файл
@@ -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
Переглянути файл
@@ -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() });
+41 -1
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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,
Бінарний файл не відображається.
+3
Переглянути файл
@@ -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"/>
+68
Переглянути файл
@@ -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);
}
}
}
+6 -2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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))",
+2 -2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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 };
}
+2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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();
+30 -10
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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;
}
+5 -1
Переглянути файл
@@ -1510,5 +1510,9 @@
"{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"
"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}"
}
+5 -1
Переглянути файл
@@ -7911,5 +7911,9 @@
"{name} (group)": "{name}(分组)",
"Servers in group": "组内服务器",
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
"Server Group": "服务器分组"
"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 仓库"
}
+5
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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() {