Compare commits

..
Author SHA1 Message Date
l0ng-ai 5e83d103cc fix(project-tree): list a re-installed SNAPSHOT's entries after reloading the project
A SNAPSHOT installed again replaces its jar in place, which no file watcher
reports, so folders already expanded inside it under External Libraries kept
the old entries, even after collapsing and expanding them again. The project
view now re-reads its loaded folders inside archives when project roots change
(a build tool sync); the backend's archive listing already follows the jar's
modification time and size.
2026-11-13 20:03:53 +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
l0ng-ai cb18b88531 test(i18n): allow "Python {short}" to read the same in every locale (#3147)
The conda Python version picker labels its entries "Python 3.12", which every locale writes like English.
2026-11-13 19:12:07 +08:00
Mr0Bread 401d7a4105 fix(database): Tab goes from a result tab to the grid's first cell (#3130)
Tab on a database result tab stepped through every toolbar control (7 to 11 stops) before reaching the grid, and the grid then had no active cell. Now:
- a new WAI-ARIA Toolbar (src/ui/Toolbar.tsx) makes the result, plan, Output and pager toolbars one Tab stop with roving focus on arrows/Home/End;
- Tabs gets an optional focusContent prop, called on plain Tab, so Tab from a result tab focuses the grid (or plan tree / Output log);
- the grid selects its first cell when focused from the keyboard, Tab / Shift+Tab move between cells row by row and leave the grid past either end, and Tab in the cell editor commits and moves on.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:47 +08:00
Mr0Bread 46bd1f6fb8 fix(ui): accent the selected tab only in the part that has focus (#3124)
The editor, tool windows and database results painted their selected tab with the same accent, so it wasn't clear which part had focus. A focus-scope tracker now marks the editor pane, each tool window and the results view that contain the focused element; outside the focused scope the selected tab uses the existing --bg-tab-selected-inactive / --border-tab-selected-inactive tokens. Scopes are opt-in, so tab strips in dialogs and popups stay accented, and focus moving into a popup, menu or dialog keeps the current marks. IntelliJ theme import maps the inactive underlined tab colours to the same tokens.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:26 +08:00
Mr0Bread 85f755591b feat(database): Expand All in the Database tool window header (#3140)
The Database tool window header had Collapse All but no Expand All. Add Expand All (db.expandAll) next to it, also reachable from Find Action. It opens every node whose children are already loaded and never connects to a data source or runs a query, so unconnected sources and tables without loaded columns stay closed.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:14 +08:00
Shohruh Ismoilovandshohruh-genius 06b7513210 fix(vcs): keep Jump to Source current in focused diffs (#3137)
Pressing Cmd+Down or F4 in a focused revision diff did nothing when the working file was found after focus entered the pane: the keyboard dispatcher kept the navigator object registered before that lookup, while the diff replaced it. The diff now keeps the registered navigator and refreshes its handlers in place, so Jump to Source opens the working file at the mapped caret.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-13 19:04:56 +08:00
l0ng-ai 09aa03bb86 fix(debugger): keep the frames | variables split, put the views after the toolbar, badge live sessions (#3144)
The divider between frames and variables is now remembered across debug starts; the Debug tool window's toolbar leads with the session actions, followed by the Threads & Variables / Console / Memory tabs; and the Debug stripe button shows how many debug sessions are live, as Run does.

Refs #3112
2026-11-13 18:58:27 +08:00
l0ng-ai 317988cfa7 fix(python): refresh the interpreter page after recreating an environment, pick a conda env's Python version (#3143)
Creating an environment at the path the project interpreter already pointed to left the Python Interpreter page saying it does not exist: the package list and the validity check reloaded only when the path changed. Both now reload after an environment is created. A new conda environment now asks for a Python version instead of a base interpreter chosen among existing environments, which read as copying one; the note says it starts with only Python.

Refs #3109
2026-11-13 18:58:22 +08:00
l0ng-ai 7db2e98269 fix(project-view): offer Git and Find Usages in workspace members outside the workspace folder (#3142)
The Project view's context menu counted a node as part of the project only inside the window's root, which in a workspace is the workspace folder. Members outside it lost the Git submenu and Find Usages, although their repositories are discovered and their changes listed. Those entries now accept nodes inside the root or any workspace member; Mark Directory as stays limited to the root, whose project structure it edits, and Scratches stay outside.

Fixes #3111
2026-11-13 18:55:42 +08:00
67 changed files with 1441 additions and 104 deletions

No files matched your search

+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"),
)
}
+3
View File
@@ -204,6 +204,8 @@ argument; themes that ship no `.icls` keep the built-in syntax colours.
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -268,6 +270,7 @@ by hand:
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+3
View File
@@ -177,6 +177,8 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor`(Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -239,6 +241,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+87
View File
@@ -174,6 +174,8 @@ for (const [theme, label] of [
const surface = target.locator("..");
const other = tab(page, "Result 3");
const otherSurface = other.locator("..");
// The results hold focus, as they do at the end when the menu is opened from a result tab.
await tab(page, "Result 2").focus();
await page.mouse.move(0, 0);
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
const selectedColor = await tab(page, "Result 2")
@@ -227,6 +229,43 @@ for (const [theme, label] of [
});
}
test("accents the selected tabs only in the part holding focus, as IDEA does", async ({ workbench: page }) => {
await openFromTree(page, "README.md");
await seedResults(page);
const token = (name: string) =>
page.evaluate((name) => {
const probe = document.body.appendChild(document.createElement("div"));
probe.style.background = `var(${name})`;
const color = getComputedStyle(probe).backgroundColor;
probe.remove();
return color;
}, name);
const accent = await token("--bg-tab-selected");
const inactive = await token("--bg-tab-selected-inactive");
expect(inactive).not.toBe(accent);
const surfaces = [
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "README.md" }),
page.getByRole("tab", { name: "Database", exact: true }),
tab(page, "Result 2"),
].map((tab) => tab.locator(".."));
const colors = () =>
Promise.all(surfaces.map((surface) => surface.evaluate((element) => getComputedStyle(element).backgroundColor)));
await page.getByRole("gridcell", { name: "beta rows", exact: true }).click();
await expect.poll(colors).toEqual([inactive, accent, accent]);
// A menu opened from a result tab keeps the results active.
await tab(page, "Result 2").click({ button: "right" });
await expect(menu(page)).toBeVisible();
await expect.poll(colors).toEqual([inactive, accent, accent]);
await page.keyboard.press("Escape");
await expect(menu(page)).toHaveCount(0);
await page.locator(".cm-content").first().click();
await expect.poll(colors).toEqual([accent, inactive, inactive]);
await page.getByRole("tree", { name: "Project", exact: true }).click({ position: { x: 10, y: 10 } });
await expect.poll(colors).toEqual([inactive, inactive, inactive]);
});
test("renames the clicked inactive result without changing its rows and navigates through Output with wraparound", async ({
workbench: page,
}) => {
@@ -512,3 +551,51 @@ test("console runs replace their unpinned results; pinned results stay until Clo
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
await expect(strip).toHaveText(["Output", result(2)]);
});
test("Tab goes from a result tab to its first cell, and the result toolbar is one stop back", async ({
workbench: page,
}) => {
await openFromTree(page, "README.md");
await seedResults(page);
const grid = page.getByRole("grid");
const cell = page.getByRole("gridcell", { name: "beta rows", exact: true });
await expect(cell).toHaveAttribute("aria-selected", "false");
await tab(page, "Result 2").focus();
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await expect(cell).toHaveAttribute("aria-selected", "true");
const toolbar = page.getByRole("toolbar", { name: "Result actions", exact: true });
await page.keyboard.press("Shift+Tab");
await expect(toolbar.getByRole("button", { name: "Table", exact: true })).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(tab(page, "Result 2")).toBeFocused();
// The toolbar is entered at the control used last.
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
// Past the last cell, Tab leaves the grid instead of landing on its header's filter button.
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await page.keyboard.press("Tab");
await expect(grid).not.toBeFocused();
expect(await page.evaluate(() => !!document.activeElement?.closest("[role=grid]"))).toBe(false);
// The keyboard path to the header's local filter is the Local Filter action.
await grid.focus();
await page.evaluate(async () => {
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("db.localFilter");
});
const filter = page.getByRole("dialog", { name: "Local filter of payload", exact: true });
await expect(filter.getByRole("textbox", { name: "Search values", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(filter).toBeHidden();
await expect(grid).toBeFocused();
});
+39
View File
@@ -0,0 +1,39 @@
import { expect, stubDesktop, test } from "./fixtures";
test.use({ video: "on" });
test("opens the working file with Command+Down after a revision diff loads its source", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const path = "/dev/acme-api/README.md";
const probe = new Promise((resolve) => {
window.__ipc.handlers.complete_probe = () => resolve({ size: 48, binary: false, tooLarge: false });
});
window.__ipc.handlers.fs_probe = ({ path: candidate }) => (candidate === path ? probe : null);
window.__ipc.handlers.fs_read_text = () => "# Acme API\n\nWorking copy\n";
const keymap = "/src/platform/keymap.ts";
(await import(/* @vite-ignore */ keymap)).applyKeymapSettings("intellij-macos", {});
const actions = "/src/platform/actions.ts";
await (
await import(/* @vite-ignore */ actions)
).executeAction("diff.compareTexts", {
path,
left: { text: "# Acme API\n\nPrevious commit\n", label: "Parent" },
right: { text: "# Acme API\n\nNew commit\n", label: "Commit" },
});
});
const pane = page.locator(".cm-merge-b .cm-content");
await pane.click();
await expect(pane).toBeFocused();
const jump = page.getByRole("button", { name: "Jump to Source", exact: true });
await expect(jump).toHaveCount(0);
await page.evaluate(() => window.__ipc.handlers.complete_probe!({}));
await expect(jump).toBeVisible();
await expect(pane).toBeFocused();
await page.keyboard.press("Meta+ArrowDown");
await page.screenshot({ path: test.info().outputPath("jump-to-source.png"), animations: "disabled" });
await expect(page.getByRole("tab", { name: "README.md", exact: true, selected: true })).toBeVisible();
await expect(pane).toBeHidden();
});
+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);
}
}
}
+3
View File
@@ -199,6 +199,8 @@ ship no `.icls` at all keep the built-in syntax colours.
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -262,6 +264,7 @@ from `type`, then map:
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+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();
};
}
+1
View File
@@ -367,6 +367,7 @@ function PaneView({ paneId }: { paneId: string }) {
tabPrefs.placement === "right" && styles.tabsRight,
)}
data-pane-id={paneId}
data-focus-scope=""
aria-label={tx("Editor")}
onFocus={onFocus}
onBlur={onBlur}
@@ -107,3 +107,29 @@ describe("routine grouping", () => {
expect(tree.queryByText("functions")).toBeNull();
});
});
describe("Expand All", () => {
it("opens every loaded node and leaves unloaded ones closed", async () => {
useDatabase.setState({
sources: [
{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } },
{ id: "lite", name: "SQLite", driver: "sqlite" },
],
tables: { [tablesKey("pg", "public")]: { status: "ok", value: [{ name: "users", kind: "table" }] } },
treeExpanded: new Set(),
});
render(<DatabaseView />);
await act(() => executeAction("db.expandAll"));
expect([...useDatabase.getState().treeExpanded].sort()).toEqual([
"pg",
"pg/public",
"pg/public/routines",
"pg/public/tables",
]);
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
expect(tree.getByText("refresh_data")).toBeInTheDocument();
// Its columns aren't loaded, and neither is the SQLite source: Expand All loads nothing.
expect(tree.getByText("users").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
});
});
+37 -11
View File
@@ -58,7 +58,7 @@ import {
useDatabase,
type Loaded,
} from "./store";
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
@@ -366,6 +366,34 @@ export function DatabaseView() {
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
};
const expandable = (n: Node) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group";
// Expand All opens the nodes already loaded: it connects to nothing and runs no query.
useEffect(() => {
databaseTree.expandAll = () => {
const next = new Set(useDatabase.getState().treeExpanded);
const walk = (nodes: readonly Node[]) => {
for (const n of nodes) {
const kids = expandable(n) ? children(n) : undefined;
if (!kids) continue;
next.add(n.key);
walk(kids);
}
};
walk(roots);
useDatabase.setState({ treeExpanded: next });
};
return () => {
databaseTree.expandAll = null;
};
});
const label = (n: Node): string => {
switch (n.kind) {
case "source":
@@ -509,14 +537,7 @@ export function DatabaseView() {
getKey={(n) => n.key}
getLabel={label}
getChildren={children}
isExpandable={(n) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group"
}
isExpandable={expandable}
loadChildren={load}
getIcon={(n) => icon(n)}
getDetail={detail}
@@ -662,7 +683,12 @@ function DatabaseToolbar() {
);
}
/** Header controls (shown on hover, like IDEA's): Collapse All. */
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
export function DatabaseHeaderActions() {
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
return (
<>
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
</>
);
}
+59 -3
View File
@@ -12,6 +12,7 @@ import {
useRef,
useState,
type ClipboardEvent,
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
} from "react";
@@ -392,6 +393,7 @@ export function Grid(props: GridProps) {
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
canSetNull: () => canEdit() && nullOk(norm(sel!)),
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
canFilter: () => !!props.onFilter && !!sel && !editing,
toggle: () => {
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
},
@@ -401,6 +403,12 @@ export function Grid(props: GridProps) {
setDefault: () => {
if (actions.canSetDefault()) setDefaults(norm(sel!));
},
filter: () => {
if (!actions.canFilter()) return;
scrollColumnIntoView(sel!.c1);
const anchor = scrollRef.current?.querySelectorAll<HTMLElement>("[data-column-filter]")[sel!.c1];
if (anchor) props.onFilter!(sel!.c1, anchor);
},
};
Object.assign((cellActions.current ??= {} as CellActions), actions);
});
@@ -430,6 +438,14 @@ export function Grid(props: GridProps) {
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
return;
}
if (e.key === "Tab") {
// IDEA's tables: Tab moves between cells, leaving the grid after the last one.
const next = mod || e.altKey ? null : nextCell(sel.r1, sel.c1, e.shiftKey ? -1 : 1);
if (!next) return;
e.preventDefault();
moveTo(next[0], next[1], false);
return;
}
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
@@ -439,13 +455,32 @@ export function Grid(props: GridProps) {
const m = moves[e.key];
if (!m) return;
e.preventDefault();
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
moveTo(
Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0])),
Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1])),
e.shiftKey,
);
};
/** The cell Tab (`step` 1) or Shift+Tab (-1) goes to, row by row; null past either end. */
const nextCell = (r: number, c: number, step: 1 | -1): [number, number] | null => {
const i = Math.min(r, rowCount - 1) * columns.length + c + step;
return i < 0 || i >= rowCount * columns.length ? null : [Math.floor(i / columns.length), i % columns.length];
};
const moveTo = (r: number, c: number, extend: boolean) => {
setSel(extend && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
virtualizer.scrollToIndex(r, { align: "auto" });
scrollColumnIntoView(c);
};
/** Focus from a click lands on the clicked cell; from the keyboard, on the first cell. */
const pointerFocus = useRef(false);
const onFocus = (e: FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget || pointerFocus.current || sel || editing) return;
if (rowCount && columns.length) moveTo(0, 0, false);
};
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
if (e.button !== 0) {
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
@@ -512,6 +547,9 @@ export function Grid(props: GridProps) {
{ kind: "action", action: "db.setCellDefault", arg: cellActions.current },
);
}
if (props.onFilter) {
items.push({ kind: "separator" }, { kind: "action", action: "db.localFilter", arg: cellActions.current });
}
const extra = rowMenu?.(selectedRows()) ?? [];
if (extra.length) items.push({ kind: "separator" }, ...extra);
showContextMenu(e, items);
@@ -546,6 +584,11 @@ export function Grid(props: GridProps) {
aria-label={props["aria-label"] ?? "Results"}
aria-rowcount={rowCount}
onKeyDown={onKeyDown}
onFocus={onFocus}
onMouseDownCapture={() => {
pointerFocus.current = true;
setTimeout(() => (pointerFocus.current = false));
}}
onPaste={onPaste}
onContextMenu={onContextMenu}
>
@@ -615,6 +658,10 @@ export function Grid(props: GridProps) {
{props.onFilter && (
<button
type="button"
data-column-filter=""
// Not a Tab stop, like IDEA's table header: Tab past the last cell leaves the grid.
// The keyboard opens it with the Local Filter action.
tabIndex={-1}
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
aria-label={`Filter ${c.name}`}
title={tx("Local filter")}
@@ -709,6 +756,13 @@ export function Grid(props: GridProps) {
return;
}
if (e.key === "Enter") commit();
if (e.key === "Tab") {
// Like IDEA's table editor: Tab commits and selects the next cell.
e.preventDefault();
const next = nextCell(editing.row, editing.col, e.shiftKey ? -1 : 1);
commit();
if (next) moveTo(next[0], next[1], false);
}
if (e.key === "Escape") {
skipBlurCommit.current = true;
setEditing(null);
@@ -726,6 +780,7 @@ export function Grid(props: GridProps) {
<div
key={c}
role="gridcell"
aria-selected={selected(r, c)}
className={clsx(
styles.cell,
numeric[c] && styles.cellNumber,
@@ -751,6 +806,7 @@ export function Grid(props: GridProps) {
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox
tabIndex={-1}
className={styles.cellCheckbox}
aria-label={tx`Toggle ${columns[c]!.name}`}
checked={val === lit.t}
+25 -14
View File
@@ -21,6 +21,7 @@ import {
showContextMenu,
T,
tooltipAttrs,
Toolbar,
} from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
@@ -80,10 +81,10 @@ function Output() {
}, [lines.length]);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Output actions">
<span className={styles.spacer} />
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
</div>
</Toolbar>
<div
ref={ref}
className={styles.output}
@@ -148,12 +149,7 @@ function Pager({ tab }: { tab: ResultTab }) {
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
return (
<div
className={styles.resultPager}
role="toolbar"
aria-label={tx("Result pages")}
title={outcomeSummary(tab.outcome)}
>
<Toolbar className={styles.resultPager} aria-label="Result pages" title={outcomeSummary(tab.outcome)}>
<IconButton
icon="page-first"
title="First Page"
@@ -198,7 +194,7 @@ function Pager({ tab }: { tab: ResultTab }) {
onChange={(v) => go(0, Number(v))}
disabled={!canPage}
/>
</div>
</Toolbar>
);
}
@@ -433,7 +429,7 @@ function Result({ tab }: { tab: ResultTab }) {
}
return (
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Result actions">
<IconButton icon="database" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
<span className={styles.toolbarSeparator} />
@@ -487,7 +483,7 @@ function Result({ tab }: { tab: ResultTab }) {
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
/>
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
</div>
</Toolbar>
{view === "chart" ? (
<div className={styles.fill}>
<ChartView columns={columns} rows={r.rows} />
@@ -595,7 +591,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
const selectedKey = selected < 0 ? statement.key : String(selected);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Result actions">
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<PinButton tab={tab} />
{tab.busy && <Spinner size={12} />}
@@ -603,7 +599,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
<span className={styles.resultSource} title={tab.outcome.statement.text}>
{targetLabel(tab.sourceId)}
</span>
</div>
</Toolbar>
<div className={clsx(styles.fill, styles.planView)}>
<div className={styles.planTree}>
<div className={styles.planHeader}>
@@ -677,6 +673,13 @@ export function ResultsView() {
const active = useDatabase((s) => s.activeResult);
const hasOutput = useDatabase((s) => s.output.length > 0);
const root = useRef<HTMLDivElement>(null);
const panel = useRef<HTMLDivElement>(null);
/** Tab from a result tab: its table, plan tree or output log (IDEA focuses the tab's content). */
const focusContent = () => {
const target = panel.current?.querySelector<HTMLElement>('[role="grid"], [role="tree"], [role="log"]');
target?.focus();
return !!target && document.activeElement === target;
};
useEffect(() => {
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
document.addEventListener("focusin", syncFocus);
@@ -704,6 +707,7 @@ export function ResultsView() {
<div
ref={root}
className={styles.view}
data-focus-scope=""
onKeyDown={(e) => {
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
@@ -724,6 +728,7 @@ export function ResultsView() {
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
panelIdPrefix="db-results"
focusContent={focusContent}
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
onTogglePin={(id) => {
@@ -744,7 +749,13 @@ export function ResultsView() {
]}
/>
</ToolWindowContentTabs>
<div className={styles.fill}>
<div
ref={panel}
className={styles.fill}
role="tabpanel"
id={`db-results-panel-${tab ? tab.id : OUTPUT_TAB}`}
aria-labelledby={`db-results-tab-${tab ? tab.id : OUTPUT_TAB}`}
>
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
</div>
</div>
+11
View File
@@ -5,9 +5,12 @@ export interface CellActions {
canToggle(): boolean;
canSetNull(): boolean;
canSetDefault(): boolean;
canFilter(): boolean;
toggle(): void;
setNull(): void;
setDefault(): void;
/** Opens the local filter of the selected cell's column. */
filter(): void;
}
const grids = new WeakMap<Element, CellActions>();
@@ -59,6 +62,14 @@ export function installCellActions(): () => void {
enabled: (c) => handle(c)?.canSetDefault() ?? false,
run: (c) => handle(c)?.setDefault(),
},
{
id: "db.localFilter",
title: "Local Filter",
category: "Database",
keyWhen: "dbCellGridFocus",
enabled: (c) => handle(c)?.canFilter() ?? false,
run: (c) => handle(c)?.filter(),
},
]);
return () => {
dispose();
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
@@ -432,6 +433,75 @@ describe("result grid keyboard Submit / Revert (#2332)", () => {
});
});
describe("Tab in the results (IDEA: Tab enters the table and moves between its cells)", () => {
const selectedCells = () =>
screen
.getAllByRole("gridcell")
.filter((cell) => cell.getAttribute("aria-selected") === "true")
.map((cell) => cell.textContent);
const rows = [
["1", "Ann"],
["2", "Bob"],
];
it("goes from the result tab to the first cell, cell by cell, and back through the toolbar's one stop", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
await screen.findByRole("button", { name: "Add Row" });
act(() => screen.getByRole("tab", { name: "Result 1" }).focus());
await user.tab();
expect(screen.getByRole("grid")).toHaveFocus();
expect(selectedCells()).toEqual(["1"]);
await user.tab();
expect(selectedCells()).toEqual(["Ann"]);
await user.tab();
expect(selectedCells()).toEqual(["2"]);
await user.tab({ shift: true });
await user.tab({ shift: true });
expect(selectedCells()).toEqual(["1"]);
await user.tab({ shift: true });
expect(screen.getByRole("toolbar", { name: "Result actions" })).toContainElement(
document.activeElement as HTMLElement,
);
await user.tab({ shift: true });
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveFocus();
});
it("leaves the grid after the last cell", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.mouseDown(screen.getByRole("gridcell", { name: "Bob" }), { button: 0 });
await user.tab();
expect(screen.getByRole("grid")).not.toHaveFocus();
expect(selectedCells()).toEqual(["Bob"]);
});
it("commits the cell editor with Tab and selects the next cell", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
fireEvent.change(input, { target: { value: "Cara" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByRole("gridcell", { name: "Cara" })).toBeTruthy();
expect(selectedCells()).toEqual(["2"]);
expect(screen.getByRole("grid")).toHaveFocus();
});
it("goes from the Output tab to the log, past its toolbar", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
useDatabase.setState({ output: [{ id: 1, time: 0, kind: "statement", text: "SELECT * FROM users" }] });
await act(async () => render(<ResultsView />));
fireEvent.click(screen.getByRole("tab", { name: "Output" }));
act(() => screen.getByRole("tab", { name: "Output" }).focus());
await user.tab();
expect(screen.getByRole("log", { name: "Database output" })).toHaveFocus();
});
});
it("omits a reset inserted cell from Submit instead of writing SQL NULL", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
+10
View File
@@ -44,6 +44,10 @@ import { tx } from "@/platform/i18n";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
/** Set while the Database view is mounted: Expand All walks its tree's loaded nodes. */
export const databaseTree: { expandAll: (() => void) | null } = { expandAll: null };
const OPEN = "projectOpen";
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
@@ -481,6 +485,12 @@ export function registerDatabaseTreeActions(): () => void {
title: "Separate Procedures and Functions",
run: toggleSeparateProceduresAndFunctions,
}),
tree({
id: "db.expandAll",
title: "Expand All",
icon: "expand-all",
run: () => databaseTree.expandAll?.(),
}),
tree({
id: "db.collapseAll",
title: "Collapse All",
@@ -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",
+6
View File
@@ -55,6 +55,12 @@ export const UI_KEYS: Record<string, string[]> = {
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.underlineColor"],
// The selected tab of a part without focus (src/ui/focusScope.ts).
"--bg-tab-selected-inactive": ["EditorTabs.inactiveUnderlinedTabBackground"],
"--border-tab-selected-inactive": [
"EditorTabs.inactiveUnderlinedTabBorderColor",
"EditorTabs.inactiveUnderlineColor",
],
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
"--border-editor-tabs": ["EditorTabs.borderColor"],
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
@@ -301,6 +301,32 @@ it("offers workspace removal for every member root", () => {
);
});
it("offers Git and Find Usages in workspace members outside the workspace folder", () => {
useProject.setState({
project: {
root: "/work/workspaces/demo",
name: "demo",
settings: {},
trusted: true,
isGit: false,
workspace: { version: 1, name: "demo", projects: ["/work/repos/a", "/work/repos/b"] },
},
});
setContext("vcsAvailable", true);
try {
for (const target of [node("/work/repos/a", true, true), node("/work/repos/a/src", true)])
expect(sub(contextMenuItems([target]), "Git")).toContain("Commit…");
expect(sub(contextMenuItems([node("/work/repos/a/x.py")]), "Git")).toContain("Annotate with Git Blame");
expect(labels(contextMenuItems([node("/work/repos/a/x.py")]))).toContain("Find Usages");
// Project structure is the workspace folder's: its roots can't name a folder outside it.
expect(labels(contextMenuItems([node("/work/repos/a/src", true)]))).not.toContain("Mark Directory as");
// Scratches stay outside.
expect(sub(contextMenuItems([node("/config/scratches/s.py")]), "Git")).toBeUndefined();
} finally {
clearContext("vcsAvailable");
}
});
it("does not offer removal on the attached primary", () => {
useProject.setState({
project: {
+5 -2
View File
@@ -151,6 +151,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
const project = useProject.getState().project;
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
// Workspace members may live outside the workspace folder; they have VCS and usages too.
const memberRoots = project ? [project.root, ...(project.workspace?.projects ?? [])] : [];
const inWorkspace = nodes.every((n) => memberRoots.some((root) => isSameOrInside(n.path, root)));
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
@@ -228,7 +231,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
shortcut: keyLabel("project.open"),
run: () => files.forEach((f) => void openFile(f.path)),
},
inProject &&
inWorkspace &&
singleFile && {
id: "findUsages",
label: "Find Usages",
@@ -338,7 +341,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
);
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
const arg = vcsArg(nodes);
const dirs = nodes.some((n) => n.dir);
push(
+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();
@@ -43,13 +43,23 @@ export function AddInterpreterDialog({
const [error, setError] = useState<string | null>(null);
const ids = { base: useId(), location: useId(), existing: useId() };
const baseValue = base ?? bases[0]?.path ?? null;
const toOption = (s: { path: string; name: string }): SelectOption => ({
value: s.path,
label: s.name,
detail: tildify(s.path),
});
const baseOptions = bases.map(toOption);
// conda installs a fresh Python of the base's version: offer versions, not environments to copy.
const versionOptions = (() => {
const seen = new Map<string, SelectOption>();
for (const s of bases) {
const short = s.version?.split(".").slice(0, 2).join(".");
if (short && !seen.has(short)) seen.set(short, { value: s.path, label: tx`Python ${short}` });
}
return [...seen.values()].sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }));
})();
const baseOptions = kind === "conda" ? versionOptions : bases.map(toOption);
const baseValue =
base ?? (kind === "conda" ? (versionOptions[0]?.value as string | undefined) : bases[0]?.path) ?? null;
const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({
value: s.path,
label: `${s.name} — ${tildify(s.path)}`,
@@ -108,7 +118,9 @@ export function AddInterpreterDialog({
const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim());
const note = {
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
conda: tx("Runs “conda create -p <location>”; the new interpreter becomes the project interpreter."),
conda: tx(
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.",
),
uv: tx("Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter."),
poetry: tx(
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.",
@@ -143,6 +155,7 @@ export function AddInterpreterDialog({
aria-current={kind === k.tool || undefined}
onClick={() => {
setKind(k.tool);
setBase(null);
setError(null);
}}
>
@@ -199,9 +212,7 @@ export function AddInterpreterDialog({
}}
/>
</div>
<label htmlFor={ids.base}>
<T>Base interpreter:</T>
</label>
<label htmlFor={ids.base}>{kind === "conda" ? <T>Python version:</T> : <T>Base interpreter:</T>}</label>
<div className={styles.pathRow}>
<Select
className={styles.pathInput}
@@ -209,17 +220,19 @@ export function AddInterpreterDialog({
options={baseOptions}
onChange={setBase}
placeholder={loading ? "Detecting…" : "No Python found"}
aria-label="Base interpreter"
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
aria-label={kind === "conda" ? "Python version" : "Base interpreter"}
/>
{kind !== "conda" && (
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
/>
)}
</div>
<div className={styles.note}>{note}</div>
</>
@@ -68,6 +68,8 @@ export function PythonInterpreterPage() {
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
const root = useProject((s) => s.project?.root ?? null);
const [auto, setAuto] = useState<string | null>(null);
// Bumped when an environment is created: it may reuse the configured path, whose state changed.
const [created, setCreated] = useState(0);
// Detected interpreters plus the ones added to (or hidden from) the IDE-wide list.
const { detected, rows, hidden } = usePythonInterpreters({ withProjects: false });
const extras = useMemo(
@@ -91,7 +93,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root]);
}, [root, created]);
// The interpreter runs resolve to: the configured one while it exists, else the fallback.
const [resolved, setResolved] = useState<{ interpreter: string; path: string | null } | null>(null);
@@ -104,7 +106,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root, interpreter]);
}, [root, interpreter, created]);
// A configured interpreter whose executable is gone (PyCharm's "Invalid"): runs fall back.
const invalid =
!!interpreter && resolved?.interpreter === interpreter && !sameInterpreter(resolved.path, interpreter, root);
@@ -362,6 +364,8 @@ export function PythonInterpreterPage() {
onClose={() => setAdding(false)}
onAdded={(path) => {
setInterpreter(path);
setCreated((n) => n + 1);
reload();
setAdding(false);
}}
/>
@@ -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;
+3 -2
View File
@@ -1107,7 +1107,8 @@ export function DiffEditor({
viewer: viewerOf,
});
useEffect(() => {
nav.current = {
// Keep the registered navigator's identity while refreshing its handlers.
Object.assign(nav.current, {
next: () => step(1),
prev: () => step(-1),
jumpToSource,
@@ -1115,7 +1116,7 @@ export function DiffEditor({
file,
showSettings,
viewer: viewerOf,
};
});
});
useEffect(
@@ -560,6 +560,41 @@ describe("Jump to Source of a revision diff", () => {
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
});
it.each([{ key: "ArrowDown", metaKey: true }, { key: "F4" }])(
"jumps with $key when the working copy is found after the pane gains focus",
async (stroke) => {
setUp();
let finishProbe!: (result: unknown) => void;
probe.mockReturnValue(new Promise((resolve) => (finishProbe = resolve)));
const keymap = useKeymap.getState();
applyKeymapSettings("intellij-macos", {});
const unregister = registerCompareActions();
const uninstall = installKeyboardDispatcher(window);
const mounted = render(<DiffEditor input={input} />);
try {
const view = await pane(1);
view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 1 } });
view.focus();
expect(view.contentDOM).toBe(document.activeElement);
expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull();
finishProbe({ status: "ok", data: { size: working.length, binary: false, tooLarge: false } });
await screen.findByRole("button", { name: "Jump to Source" });
expect(view.contentDOM).toBe(document.activeElement);
fireEvent.keyDown(view.contentDOM, { ...stroke, code: stroke.key });
await waitFor(() =>
expect(openFile).toHaveBeenCalledExactlyOnceWith("/p/a.ts", { selection: { line: 3, col: 1 }, focus: true }),
);
} finally {
mounted.unmount();
setContext("diffEditorFocus", false);
uninstall();
unregister();
useKeymap.setState(keymap);
}
},
);
it("jumps on ⌘↓ in the IntelliJ macOS keymap only while a diff pane has focus", async () => {
setUp();
const keymap = useKeymap.getState();
+1
View File
@@ -264,6 +264,7 @@ describe("catalogs", () => {
"my-agent",
"Java",
"Python",
"Python {short}",
"C/C++",
"C#",
"Go",
+1
View File
@@ -404,6 +404,7 @@ export const intellijMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Meta+T"],
+1
View File
@@ -402,6 +402,7 @@ export const intellijWindows: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Ctrl+Shift+T"],
+1
View File
@@ -392,6 +392,7 @@ export const vscodeMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open.
"find.popup.nextResult": ["Meta+G"],
+6 -1
View File
@@ -1011,6 +1011,7 @@
"Decompiled .class file: sources are not available.": "Decompiled .class file: sources are not available.",
"Set DEFAULT": "Set DEFAULT",
"Toggle Boolean Cell": "Toggle Boolean Cell",
"Local Filter": "Local Filter",
"Toggle {name}": "Toggle {name}",
"true": "true",
"false": "false",
@@ -1505,5 +1506,9 @@
"Port": "Port",
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
"Keep the token": "Keep the token",
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks."
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.",
"{name} (group)": "{name} (group)",
"Servers in group": "Servers in group",
"Upload to… on this group uploads the files to each of its servers in turn.": "Upload to… on this group uploads the files to each of its servers in turn.",
"Server Group": "Server Group"
}
+11 -2
View File
@@ -5217,7 +5217,9 @@
"{count} modified action": "已修改 {count} 个操作",
"{count} modified actions": "已修改 {count} 个操作",
"Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter.": "运行“<base> -m venv <location>”;新的解释器将成为项目解释器。",
"Runs “conda create -p <location>”; the new interpreter becomes the project interpreter.": "运行“conda create -p <location>”;新的解释器将成为项目解释器。",
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.": "运行“conda create -p <location> python=<version>”:新建一个只包含 Python 的环境;新的解释器将成为项目解释器。",
"Python version": "Python 版本",
"Python {short}": "Python {short}",
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.": "在项目中运行“poetry env use <base>”;Poetry 会将环境保存在其自己的缓存目录下。",
"Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter.": "运行“uv venv <location> --python <base>”;新的解释器将成为项目解释器。",
"{vendor} {version} is unpacked into a folder of its own in {location}, where JDK detection finds it.": "{vendor} {version} 将解压到 {location} 下它自己的文件夹中,JDK 检测会在那里找到它。",
@@ -6842,6 +6844,8 @@
"Bad regex": "正则表达式无效",
"Filter rows": "筛选行",
"Result pages": "结果分页",
"Result actions": "结果操作",
"Output actions": "输出操作",
"First Page": "第一页",
"Count rows": "统计行数",
"Last Page": "最后一页",
@@ -7372,6 +7376,7 @@
"Sources not found": "未找到源代码",
"Cannot load library sources": "无法加载库源代码",
"Toggle Boolean Cell": "切换布尔单元格",
"Local Filter": "本地筛选",
"Toggle {name}": "切换 {name}",
"true": "真",
"false": "假",
@@ -7902,5 +7907,9 @@
"Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
"Keep the token": "保留令牌",
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。"
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。",
"{name} (group)": "{name}(分组)",
"Servers in group": "组内服务器",
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
"Server Group": "服务器分组"
}
+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);
+13 -1
View File
@@ -12,10 +12,21 @@ export interface CheckboxProps {
/** Tri-state display. */
indeterminate?: boolean;
className?: string;
/** `-1` keeps it out of the Tab order, e.g. inside a grid that moves between cells itself. */
tabIndex?: number;
"aria-label"?: string;
}
export function Checkbox({ checked, onChange, label, disabled, indeterminate, className, ...aria }: CheckboxProps) {
export function Checkbox({
checked,
onChange,
label,
disabled,
indeterminate,
className,
tabIndex,
...aria
}: CheckboxProps) {
const id = useId();
const tx = useGivenTx();
return (
@@ -27,6 +38,7 @@ export function Checkbox({ checked, onChange, label, disabled, indeterminate, cl
className={styles.native}
checked={checked}
disabled={disabled}
tabIndex={tabIndex}
aria-checked={indeterminate ? "mixed" : checked}
aria-label={tx(aria["aria-label"])}
onChange={(e) => onChange(e.target.checked)}
+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 });
+10 -3
View File
@@ -1,9 +1,16 @@
.strip {
--tab-selected-bg: var(--bg-tab-selected);
--tab-selected-border: var(--border-tab-selected);
flex: none;
display: flex;
align-items: stretch;
min-width: 0;
}
/* IDEA: only the part holding focus accents its selected tab (src/ui/focusScope.ts). */
:where([data-focus-scope]:not([data-focus-active])) .strip {
--tab-selected-bg: var(--bg-tab-selected-inactive);
--tab-selected-border: var(--border-tab-selected-inactive);
}
/* Islands tabs: the selected tab is a rounded pill (tab-selected-bg / -border); no underline, no separators. */
.editor {
height: var(--h-header);
@@ -47,15 +54,15 @@
}
.selected,
.selected:hover {
background: var(--bg-tab-selected);
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
background: var(--tab-selected-bg);
box-shadow: inset 0 0 0 1px var(--tab-selected-border);
}
.tinted,
.tinted:hover {
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), transparent);
}
.tinted.selected {
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--bg-tab-selected));
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--tab-selected-bg));
}
.tabButton {
display: flex;
+9
View File
@@ -132,6 +132,11 @@ export interface TabsProps {
"aria-label"?: string;
/** Tabpanel id prefix for `aria-controls`. */
panelIdPrefix?: string;
/**
* Tab from a tab moves focus into the selected tab's content (IDEA's table, list or tree)
* instead of the next control; returns false when there is nothing to focus.
*/
focusContent?: () => boolean;
/** Enables drag & drop reordering (and moving between strips of the same type). */
dnd?: TabsDnd;
/** Tabs that don't fit: `scroll` the strip (default), `squeeze` them, or `wrap` into rows. */
@@ -156,6 +161,7 @@ export function Tabs({
trailing,
className,
panelIdPrefix,
focusContent,
dnd,
layout = "scroll",
closePosition = "right",
@@ -318,6 +324,9 @@ export function Tabs({
if (onClose && item.closable !== false && !(item.pinned && onTogglePin)) onClose(item.id);
else return;
break;
case "Tab":
if (e.shiftKey || !focusContent?.()) return;
break;
default:
return;
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toolbar } from "./Toolbar";
function Bar({ busy = false, extra = false }: { busy?: boolean; extra?: boolean }) {
return (
<>
<button>Before</button>
<Toolbar aria-label="Actions">
<button>Run</button>
<button disabled={busy}>Stop</button>
{extra && <button>Pin</button>}
<button tabIndex={-1}>Hidden</button>
<input aria-label="Filter" />
</Toolbar>
<button>After</button>
</>
);
}
const tabStops = () => [...document.querySelectorAll('[role="toolbar"] [tabindex="0"]')].map((e) => e.textContent);
describe("Toolbar", () => {
it("is one Tab stop that remembers the control used last", async () => {
const user = userEvent.setup();
render(<Bar />);
screen.getByRole("button", { name: "Before" }).focus();
await user.tab();
expect(screen.getByRole("button", { name: "Run" })).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Stop" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "After" })).toHaveFocus();
await user.tab({ shift: true });
expect(screen.getByRole("button", { name: "Stop" })).toHaveFocus();
});
it("moves with the arrows, Home and End, wrapping, past controls that opt out", async () => {
const user = userEvent.setup();
render(<Bar />);
act(() => screen.getByRole("button", { name: "Run" }).focus());
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
// A text field keeps its caret keys.
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{End}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{Home}");
expect(screen.getByRole("button", { name: "Run" })).toHaveFocus();
expect(screen.getByRole("button", { name: "Hidden" })).toHaveAttribute("tabindex", "-1");
});
it("keeps one stop when controls are added or the remembered one is disabled", async () => {
const { rerender } = render(<Bar />);
act(() => screen.getByRole("button", { name: "Stop" }).focus());
expect(tabStops()).toEqual(["Stop"]);
rerender(<Bar extra />);
await waitFor(() => expect(screen.getByRole("button", { name: "Pin" })).toHaveAttribute("tabindex", "-1"));
expect(tabStops()).toEqual(["Stop"]);
rerender(<Bar extra busy />);
await waitFor(() => expect(tabStops()).toEqual(["Run"]));
fireEvent.keyDown(screen.getByRole("button", { name: "Run" }), { key: "ArrowRight" });
expect(screen.getByRole("button", { name: "Pin" })).toHaveFocus();
});
});
+88
View File
@@ -0,0 +1,88 @@
import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, type HTMLAttributes } from "react";
import { useGivenTx } from "@/platform/i18n";
export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
"aria-label": string;
orientation?: "horizontal" | "vertical";
}
const ITEMS = "button, input, select, textarea, a[href], [tabindex]";
/** Controls whose tabindex a toolbar wrote, as opposed to an owner's own `-1` (kept across remounts). */
const managed = new WeakSet<Element>();
/**
* A toolbar that is one Tab stop (WAI-ARIA toolbar): the arrow keys, Home and End move
* between its controls, and Tab enters at the control used last. IDEA's toolbar buttons
* aren't Tab stops at all; this keeps them reachable from the keyboard.
*
* Roving works on the DOM, so the controls need nothing: a control that sets its own
* `tabIndex={-1}` stays out, and controls added, removed, enabled or disabled later are
* picked up.
*/
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{ orientation = "horizontal", "aria-label": ariaLabel, children, ...rest },
ref,
) {
const tx = useGivenTx();
const root = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => root.current!);
useLayoutEffect(() => {
const el = root.current!;
let current: Element | null = null;
const candidates = () =>
[...el.querySelectorAll<HTMLElement>(ITEMS)].filter(
(item) => managed.has(item) || item.getAttribute("tabindex") !== "-1",
);
const items = () => candidates().filter((item) => !(item as HTMLButtonElement).disabled);
const sync = () => {
const all = candidates();
const enabled = all.filter((item) => !(item as HTMLButtonElement).disabled);
if (!current || !enabled.includes(current as HTMLElement)) current = enabled[0] ?? null;
for (const item of all) {
const index = item === current ? "0" : "-1";
if (item.getAttribute("tabindex") !== index) item.setAttribute("tabindex", index);
managed.add(item);
}
};
const onFocusIn = (e: FocusEvent) => {
if (!(e.target instanceof Element) || !items().includes(e.target as HTMLElement)) return;
current = e.target;
sync();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
const target = e.target as HTMLElement;
const all = items();
const index = all.indexOf(target);
if (index < 0) return;
const vertical = orientation === "vertical";
// Text fields keep their caret keys.
const text = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
let next: number;
if (e.key === (vertical ? "ArrowDown" : "ArrowRight") && !text) next = index + 1;
else if (e.key === (vertical ? "ArrowUp" : "ArrowLeft") && !text) next = index - 1;
else if (e.key === "Home" && !text) next = 0;
else if (e.key === "End" && !text) next = all.length - 1;
else return;
e.preventDefault();
all[(next + all.length) % all.length]!.focus();
};
sync();
const observer = new MutationObserver(sync);
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["disabled"] });
el.addEventListener("focusin", onFocusIn);
el.addEventListener("keydown", onKeyDown);
return () => {
observer.disconnect();
el.removeEventListener("focusin", onFocusIn);
el.removeEventListener("keydown", onKeyDown);
};
}, [orientation]);
return (
<div ref={root} role="toolbar" aria-orientation={orientation} aria-label={tx(ariaLabel)} {...rest}>
{children}
</div>
);
});
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from "vitest";
import { FOCUS_ACTIVE_ATTR, installFocusScopeTracking } from "./focusScope";
describe("focus scopes", () => {
let dispose = () => {};
afterEach(() => {
dispose();
document.body.innerHTML = "";
});
function setup() {
document.body.innerHTML = `
<section id="editor" data-focus-scope><button id="editorTab">a</button></section>
<section id="services" data-focus-scope>
<button id="tree">tree</button>
<div id="results" data-focus-scope><button id="grid">grid</button></div>
</section>
<div data-popup><button id="menuItem">menu</button></div>`;
dispose = installFocusScopeTracking();
const el = (id: string) => document.getElementById(id)!;
const active = () =>
["editor", "services", "results"].filter((id) => el(id).hasAttribute(FOCUS_ACTIVE_ATTR)).join(",");
return { el, active };
}
it("marks the focused scope with its enclosing scopes and moves the mark with focus", () => {
const { el, active } = setup();
el("editorTab").focus();
expect(active()).toBe("editor");
el("grid").focus();
expect(active()).toBe("services,results");
el("tree").focus();
expect(active()).toBe("services");
});
it("keeps the marks while focus is in a popup or nowhere", () => {
const { el, active } = setup();
el("grid").focus();
el("menuItem").focus();
expect(active()).toBe("services,results");
el("menuItem").blur();
expect(document.activeElement).toBe(document.body);
expect(active()).toBe("services,results");
});
it("marks the scope already holding focus on install and clears the marks on dispose", () => {
document.body.innerHTML = `<section id="editor" data-focus-scope><button id="tab">a</button></section>`;
document.getElementById("tab")!.focus();
dispose = installFocusScopeTracking();
const editor = document.getElementById("editor")!;
expect(editor.hasAttribute(FOCUS_ACTIVE_ATTR)).toBe(true);
dispose();
dispose = () => {};
expect(editor.hasAttribute(FOCUS_ACTIVE_ATTR)).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Focus scopes: containers marked `data-focus-scope` that show whether they hold focus, as
* IDEA's tab strips do (the selected tab is accented only in the part that has focus).
*
* The scopes containing the last element focused inside any scope get `data-focus-active`.
* Focus moving outside every scope (popups, menus, dialogs, the body) keeps the marks, so a
* context menu opened from a tab leaves its strip active.
*/
const FOCUS_SCOPE_ATTR = "data-focus-scope";
export const FOCUS_ACTIVE_ATTR = "data-focus-active";
/** Marks the scopes holding focus. Returns an uninstaller that clears the marks. */
export function installFocusScopeTracking(): () => void {
let active: Element[] = [];
const mark = (target: Element | null) => {
const chain: Element[] = [];
for (let el = target?.closest(`[${FOCUS_SCOPE_ATTR}]`); el; el = el.parentElement?.closest(`[${FOCUS_SCOPE_ATTR}]`))
chain.push(el);
if (!chain.length) return;
for (const el of active) if (!chain.includes(el)) el.removeAttribute(FOCUS_ACTIVE_ATTR);
for (const el of chain) el.setAttribute(FOCUS_ACTIVE_ATTR, "");
active = chain;
};
const onFocusIn = (e: FocusEvent) => mark(e.target instanceof Element ? e.target : null);
document.addEventListener("focusin", onFocusIn);
mark(document.activeElement);
return () => {
document.removeEventListener("focusin", onFocusIn);
for (const el of active) el.removeAttribute(FOCUS_ACTIVE_ATTR);
active = [];
};
}
+1
View File
@@ -8,6 +8,7 @@ export * from "./Input";
export * from "./Checkbox";
export * from "./Select";
export * from "./Tabs";
export * from "./Toolbar";
export {
FILES_DRAG,
REMOTE_FILES_DRAG,
+2
View File
@@ -17,6 +17,7 @@ import { installBrowserGuards } from "./browserGuards";
import { installDistractionFree } from "./distractionFree";
import { installFileDrop } from "./fileDrop";
import { installFocusTracking } from "./focus";
import { installFocusScopeTracking } from "@/ui/focusScope";
import { clearProjectNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore";
import {
NOTIFICATIONS_TOOL_WINDOW,
@@ -74,6 +75,7 @@ export function activateWorkbench(): () => void {
installToolWindowLayoutSync(),
installDistractionFree(),
installFocusTracking(setFocusedToolWindow),
installFocusScopeTracking(),
installTaskEvents(),
installNotifyEvents(),
installBrowserGuards(),
@@ -871,6 +871,7 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
ref={ref}
className={styles.window}
data-toolwindow={tw.id}
data-focus-scope=""
aria-label={titleOf(tw)}
hidden={hidden}
tabIndex={-1}