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

...
Автор SHA1 Повідомлення Дата
l0ng-ai fd0c8a9f05 fix(github): recognise remotes cloned through ssh.github.com
A repository cloned over GitHub's SSH-over-HTTPS host (ssh://[email protected]:443/o/r.git) wasn't recognised as a GitHub repository, so pull requests and issues weren't offered for it. The host is accepted for SSH remotes; https://ssh.github.com is still not a GitHub web URL.
2026-11-14 22:57:23 +08:00
akshathayadav378-boopтаakshathayadav378-boop 13e18af777 fix(vcs): open Bitbucket SSH clones on bitbucket.org (#3379)
For a Bitbucket Cloud repository cloned over SSH ([email protected]:team/repo.git), Open In used ssh.bitbucket.org as the web host and GitLab routes. remoteWebUrl now maps the services' SSH front hosts (ssh.bitbucket.org, ssh.github.com, altssh.gitlab.com) to their web hosts before building the URL.

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-14 22:54:44 +08:00
charlie-morrisonтаcharlie-morrison 488e819b46 fix(vcs): keep the merge editor responsive on long files (#3381)
Opening a conflicted package-lock.json in the merge editor froze the app. The Your/Their panes diffed each side against the base with @codemirror/merge's default config, which gives up past ~16 KB of changed span and returns one chunk from the first change to the last, so each pane rendered the whole base as one deletion widget; syncMergeScroll ran an unbounded character diff on first scroll. Both now use the IDEA line comparison the diff viewer uses (ideaDiffConfig).

Co-authored-by: charlie-morrison <[email protected]>
2026-11-14 22:53:04 +08:00
l0ng-ai ad8df8fe00 feat(database): open several query consoles per data source (#3386)
New › Query Console always reopened the single console of a data source. It now creates another console each time (console, console_1, console_2…, as in DataGrip); Jump to Query Console opens the only console directly or lists them with New Query Console; Tools › Query Console keeps opening the default one. Backend: db_consoles(id, create).
2026-11-14 22:50:51 +08:00
l0ng-ai 19d6eab21a feat(http-client): find in the response body and open the files .http lines name (#3383)
The response pane had no find: ⌘F (or a Find button next to Show Raw) now opens the console find bar with matches marked in the body. ⌘-click / ⌘B on the file a .http line names (< body, > handler, >> redirect, <> saved response) opens it; saved responses resolve against .idea/httpRequests/ (where IntelliJ IDEA writes them), then the request file's folder.
2026-11-14 22:32:50 +08:00
Muuтаmuutot 5c0befeff0 feat(lsp): compute Python inheritance gutter markers from the IDE index (#3359)
Python's overrides / is-overridden gutter markers never appeared: basedpyright and pyrefly declare no typeHierarchyProvider, and a single-root server cannot see classes in attached projects. The IDE now extracts every class X(Base) with tree-sitter across the project (content roots included) and nav_inheritance exposes them; the editor merges this graph with the server's answer. A base name is trusted only when it names one class (unique in the project or declared in the same file). Class bases are cached per file and parsed again only when the file changes.

Co-authored-by: muutot <[email protected]>
2026-11-14 22:24:48 +08:00
Akbar Husain 07bd8ded5e feat(run): report Zig test runs in the test runner (#3355)
Zig test blocks had run markers but their runs never reached the test runner: no parser read the runners' output. A parser now reads zig test's per-test progress lines, error-return traces and summary, and zig build test's failure blocks and --summary all counts (added to those runs). Results are rooted at the module stem so gutter icons turn green/red per test; Rerun Failed and Run Selected build --test-filter configs for standalone zig test runs. test names are unescaped in run targets so the marker label, --test-filter and the runner's names agree.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 22:16:35 +08:00
Mr0Bread 4ab85d5f49 feat(editor): resize the vertical editor tab column by dragging its edge (#3365)
With tabs placed left or right, the column could not be resized and was never narrower than 140 px. It now fits its longest name up to a limit (260 px by default); dragging its inner edge, or ←/→ on the focused edge, changes the limit for every editor group and it is remembered. The editor keeps at least 60% of each group.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 22:15:22 +08:00
Mansur Akhmadiтаmansur-akhmadi 3c91adfae2 feat(vcs): choose the repository in the Rebase dialog when the project has several (#3351)
Git > Rebase… always rebased the repository selected in the branch widget. With two or more repositories the dialog now has the repository combo: picking one reloads its branches and the label, clears the chosen upstream and --onto base, and Rebase runs in that repository.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 22:12:34 +08:00
Mansur Akhmadiтаmansur-akhmadi 5cfa6614a1 feat(vcs): choose the repository in the Merge dialog when the project has several (#3332)
Git > Merge… always merged into the repository selected in the branch widget. With two or more repositories the dialog now has the repository combo (as in Pull): picking one reloads its branches and title, clears the chosen branch, and Merge runs in that repository.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 22:12:04 +08:00
Shohruh Ismoilovтаshohruh-genius e875d97f7c fix(project): show relevant file templates in New menus (#3342)
The New menu offered every language template in every project. It now uses the detected type of the project owning the selected folder for language-specific templates (Rust, Python, Go, PHP, Java/Kotlin); generic and web templates (JavaScript, TypeScript) stay in every project, as in IntelliJ IDEA Ultimate, and From Template still lists the whole catalog. Tomcat Context Descriptor is offered only when the project has a Tomcat run configuration.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-14 22:07:27 +08:00
Mr0Bread 31016ee684 fix(agents): add Copy Link Address to the context menu of links in readable text (#3371)
Right-clicking a link in Agent Task chat (and other readable panes using the shared Copy menu) only offered Copy, which copied the selected label. Links now also get Copy Link Address, as in browsers; Copy keeps copying the selection, and in-page anchors get no link item.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 22:05:29 +08:00
l0ng-ai 6a93f1cce6 fix(debugger): show a JVM session's Memory tab from the start; pass lldb-dap's environment for cargo debugging (#3380)
The Memory tab appeared only once the debugger reported its capabilities, so a Java or Kotlin session's tab bar grew a tab after the program started; it is now shown from the start with a placeholder until the program runs. A cargo configuration debugged with lldb-dap passed CodeLLDB's sourceLanguages and an environment object; lldb-dap now gets KEY=VALUE strings like other native programs.
2026-11-14 22:01:34 +08:00
l0ng-ai 2b4e3c79f8 fix(debugger): let a Python program read input while it is debugged (#3374)
debugpy launched the program with console "internalConsole", which gives it no standard input, so input() raised EOFError. It now runs with console "integratedTerminal": debugpy starts it through runInTerminal on the pseudo-terminal the debugger already provides, and what is typed in the debug console reaches it. Output comes from that terminal, so redirectOutput is dropped (it doubled every line). Node under js-debug still lacks input; its terminal launch needs its own fix.
2026-11-14 21:34:21 +08:00
75 змінених файлів з 2396 додано та 89 видалено

No files matched your search

+45 -6
Переглянути файл
@@ -12,6 +12,7 @@ use ide_db::{
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
/// One statement of a console run.
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -179,6 +180,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
});
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
d.add("db_consoles", |c| async move { consoles(&c.host, &c.arg::<String>("id")?, c.arg("create")?) });
}
/// The data sources configured for the project (the tree's roots).
@@ -472,11 +474,52 @@ pub async fn apply_edits(
/// created on first use. Consoles are files of the project, so a remote
/// window resolves it on the host the project runs on.
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
let file = console_dir(host, id)?.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
/// The query consoles of data source `id`: the default `console.sql`, then
/// `console_1.sql`, `console_2.sql`… in order. `create` adds the next one first
/// (DataGrip's New › Query Console).
pub fn consoles(host: &Host, id: &str, create: bool) -> IdeResult<Vec<IpcPath>> {
let dir = console_dir(host, id)?;
let default = dir.join("console.sql");
let mut numbered: Vec<(u32, PathBuf)> = std::fs::read_dir(&dir)?
.filter_map(|e| {
let path = e.ok()?.path();
let n = path.file_name()?.to_str()?.strip_prefix("console_")?.strip_suffix(".sql")?.parse().ok()?;
Some((n, path))
})
.collect();
numbered.sort();
if create {
// The default console comes first; once it exists, the next number.
if !default.exists() {
std::fs::write(&default, "")?;
} else {
let n = numbered.last().map_or(1, |(n, _)| n + 1);
let file = dir.join(format!("console_{n}.sql"));
std::fs::write(&file, "")?;
numbered.push((n, file));
}
}
let mut out: Vec<IpcPath> = Vec::new();
if default.exists() {
out.push(default.into());
}
out.extend(numbered.into_iter().map(|(_, p)| p.into()));
Ok(out)
}
/// `<dataDir>/consoles/db/<id>`: `<id>` for the default database, `<id>.<database>` for the others.
fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
let (source, database) = ide_db::split_target(id);
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(IdeError::invalid("invalid data source id"));
}
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
let name = match database {
Some(db) => {
let safe: String =
@@ -487,9 +530,5 @@ pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
Ok(dir)
}
+87 -1
Переглянути файл
@@ -2,7 +2,7 @@ use super::doc::blocking;
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
@@ -22,6 +22,23 @@ pub struct WebGoto {
const MAX_WEB_TARGETS: usize = 200;
/// A Python class definition and the base names written in its source. The UI builds a
/// name → locations index from every class in the project (attached dependency roots
/// included) to compute ↑ overrides / ↓ overridden gutter markers without a language
/// server's type-hierarchy provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClassInheritance {
/// Start of the class definition's selection range (0-based line, UTF-16 column).
pub path: IpcPath,
pub line: u32,
pub col: u32,
pub name: String,
/// Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
/// qualified name keeps only its last segment).
pub bases: Vec<String>,
}
/// The project root and its resource roots (served at `/`), for file references.
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
@@ -174,6 +191,74 @@ pub async fn nav_index_definitions(
.await
}
/// Every Python class in the project (attached dependency roots included) and its base
/// names. The index covers content roots, so this is the whole-project answer the UI uses
/// for inheritance gutter markers when no type-hierarchy provider exists.
pub async fn nav_inheritance(host: &Host, window: &str) -> IdeResult<Vec<ClassInheritance>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let python = LanguageId::new("python");
let files = project.index.files_with_extensions(&["py".into(), "pyi".into()], 20_000);
let mut cache = CLASS_BASES.lock();
let mut out = Vec::new();
for path in files {
// The open (possibly unsaved) buffer is keyed by its text, a file on disk by its
// size and modification time, so only changed files are parsed again.
let open = project.documents.text_of(&path);
let stamp = match &open {
Some(text) => Stamp::Text(text_hash(text)),
None => match std::fs::metadata(&path) {
Ok(m) => Stamp::Disk(m.len(), m.modified().ok()),
Err(_) => continue,
},
};
let classes = match cache.get(&path) {
Some((cached, classes)) if *cached == stamp => classes,
_ => {
let text = match open {
Some(text) => text,
None => match project.text_of(&path) {
Ok(text) => text,
Err(_) => continue,
},
};
let classes = syntax.class_bases(&python, &text);
&cache.entry(path.clone()).insert_entry((stamp, classes)).into_mut().1
}
};
out.extend(classes.iter().map(|class| ClassInheritance {
path: IpcPath::from(path.clone()),
line: class.line,
col: class.col,
name: class.name.clone(),
bases: class.bases.clone(),
}));
}
Ok(out)
})
.await
}
#[derive(Clone, PartialEq, Eq)]
enum Stamp {
Text(u64),
Disk(u64, Option<std::time::SystemTime>),
}
fn text_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
h.finish()
}
type ClassBasesCache = std::collections::HashMap<PathBuf, (Stamp, Vec<ide_language::ClassBases>)>;
/// Class bases per Python file, reused while the file is unchanged.
static CLASS_BASES: std::sync::LazyLock<parking_lot::Mutex<ClassBasesCache>> =
std::sync::LazyLock::new(Default::default);
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
@@ -249,6 +334,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("nav_index_definitions", |c| async move {
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("nav_inheritance", |c| async move { nav_inheritance(&c.host, &c.window).await });
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
+1
Переглянути файл
@@ -357,6 +357,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "docker_set_registry_password"
| "docker_run_env"
| "db_console_path"
| "db_consoles"
| "db_export_file"
| "vcs_write_patch"
| "app_metrics"
+37
Переглянути файл
@@ -0,0 +1,37 @@
use ide_core::NullSink;
use ide_host::ops::db;
use ide_host::{Host, HostConfig};
use std::sync::Arc;
#[tokio::test]
async fn new_query_consoles_are_numbered_after_the_default_one() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let names = |paths: Vec<ide_core::IpcPath>| -> Vec<String> {
paths.iter().map(|p| p.0.file_name().unwrap().to_string_lossy().into_owned()).collect()
};
assert!(db::consoles(&host, "pg", false).unwrap().is_empty());
// The first new console is the default one, then console_1, console_2…
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql"]);
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql", "console_1.sql"]);
let all = db::consoles(&host, "pg", true).unwrap();
assert_eq!(names(all.clone()), ["console.sql", "console_1.sql", "console_2.sql"]);
// Numbers sort numerically, and the default console path is the first one.
std::fs::write(all[0].0.with_file_name("console_10.sql"), "").unwrap();
assert_eq!(names(db::consoles(&host, "pg", false).unwrap())[3], "console_10.sql");
assert_eq!(db::console_path(&host, "pg").unwrap().0, all[0].0);
// Each database of a source has its own consoles.
assert!(db::consoles(&host, "pg:other", false).unwrap().is_empty());
assert!(db::consoles(&host, "../x", true).is_err());
}
+141
Переглянути файл
@@ -0,0 +1,141 @@
//! The native `nav_inheritance` command: it must return every Python class definition
//! in the project — attached dependency roots included — together with the base names
//! written in its source, so the UI can compute inheritance gutter markers without a
//! language server's type-hierarchy provider.
use ide_host::{Host, HostConfig, ops};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
/// Opens a synthetic project whose Python files carry known class / base shapes, then
/// waits for the index to settle (it walks content roots, so dependency roots are covered).
async fn open() -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("proj");
let src = root.join("src");
let dep = root.join("vendor");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dep).unwrap();
// Own root: a plain class, a class with two bases + a keyword arg, a qualified base,
// and a subscripted generic base. A comment / docstring that mentions `class X(Y)`
// must not be reported.
std::fs::write(
src.join("app.py"),
concat!(
"class Base:\n",
" pass\n",
"\n",
"class Mixin:\n",
" pass\n",
"\n",
"class Child(Base, Mixin, metaclass=Meta):\n",
" # class Ghost(Spook): must be ignored\n",
" \"\"\"class Phantom(Apparition): must be ignored\"\"\"\n",
" pass\n",
"\n",
"class Qualified(pkg.mod.Base):\n",
" pass\n",
"\n",
"class Generic(Generic[T]):\n",
" pass\n",
),
)
.unwrap();
// A second own-root file to prove the command scans more than one file.
std::fs::write(src.join("extra.pyi"), "class Stub(Base):\n ...\n").unwrap();
// An attached dependency root (content root) — must be covered by the index.
std::fs::write(dep.join("dep.py"), "class DepBase:\n pass\n").unwrap();
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let s = ops::nav::index_status(&host, WINDOW).unwrap();
if !s.indexing && s.files > 0 {
break;
}
assert!(Instant::now() < deadline, "indexing timed out; status: {s:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
Opened { host, root, _tmp: tmp }
}
fn find<'a>(all: &'a [ide_host::ops::nav::ClassInheritance], name: &str) -> &'a ide_host::ops::nav::ClassInheritance {
all.iter().find(|c| c.name == name).unwrap_or_else(|| panic!("no class {name:?} in {all:#?}"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn returns_all_python_classes_and_bases() {
let p = open().await;
let all = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
// Both own-root Python files are scanned (src/app.py + src/extra.pyi).
let base = find(&all, "Base");
assert!(base.path.0.ends_with("src/app.py"), "Base path: {:?}", base.path);
// The location is the class name identifier (selection-range start), i.e. after `class `.
assert_eq!(base.line, 0);
assert_eq!(base.col, 6);
assert!(base.bases.is_empty(), "a bare class has no bases: {:?}", base.bases);
let child = find(&all, "Child");
assert_eq!(child.line, 6);
assert_eq!(child.col, 6);
// The `metaclass=Meta` keyword argument is not a base.
assert!(child.bases.contains(&"Base".to_string()), "Child bases: {:?}", child.bases);
assert!(child.bases.contains(&"Mixin".to_string()), "Child bases: {:?}", child.bases);
assert!(!child.bases.iter().any(|b| b == "Meta"), "metaclass must be skipped: {:?}", child.bases);
// A qualified base keeps only its last segment.
let qualified = find(&all, "Qualified");
assert_eq!(qualified.bases, vec!["Base".to_string()], "qualified base: {:?}", qualified.bases);
// A subscripted generic base keeps the identifier.
let generic = find(&all, "Generic");
assert_eq!(generic.bases, vec!["Generic".to_string()], "generic base: {:?}", generic.bases);
// Comments / docstrings are not mistaken for class definitions.
assert!(!all.iter().any(|c| c.name == "Ghost" || c.name == "Phantom"), "parsed a comment: {all:#?}");
// The .pyi stub in the second own-root file is included.
let stub = find(&all, "Stub");
assert!(stub.path.0.ends_with("src/extra.pyi"), "Stub path: {:?}", stub.path);
assert_eq!(stub.bases, vec!["Base".to_string()]);
// The attached dependency root is covered by the project index.
let dep = find(&all, "DepBase");
assert!(dep.path.0.starts_with(&p.root), "DepBase path: {:?}", dep.path);
// Every returned entry is a Python file.
assert!(all.iter().all(|c| { matches!(c.path.0.extension().and_then(|e| e.to_str()), Some("py" | "pyi")) }));
// A file changed on disk is parsed again; the others come from the cache.
let extra = p.root.join("src/extra.pyi");
std::fs::write(&extra, "class Stub(Child): ...\nclass Added(Base): ...\n").unwrap();
let again = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
assert_eq!(find(&again, "Stub").bases, vec!["Child".to_string()]);
assert_eq!(find(&again, "Added").bases, vec!["Base".to_string()]);
assert_eq!(find(&again, "Child").bases, child.bases);
}
+1
Переглянути файл
@@ -365,6 +365,7 @@ async fn local_deployment_runs_on_the_project_host() {
"db_table_page",
"db_apply_edits",
"db_console_path",
"db_consoles",
"ssh_test",
"ssh_set_secret",
"ssh_password_saved",
+1
Переглянути файл
@@ -66,6 +66,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"docker_set_registry_password",
"docker_run_env",
"db_console_path",
"db_consoles",
"db_export_file",
"vcs_write_patch",
"remote_bootstrap_list",
+5 -5
Переглянути файл
@@ -22,11 +22,11 @@ pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, VueComponentImport,
VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility, set_external_syntax, shift_pos,
shift_range,
CSS_CLASS, CSS_ID, CSS_VARIABLE, ClassBases, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax,
ExternalToken, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind,
SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError,
VueComponentImport, VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility,
set_external_syntax, shift_pos, shift_range,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
+171
Переглянути файл
@@ -0,0 +1,171 @@
//! Extraction of Python class base names, used to build an inheritance graph
//! without an LSP `typeHierarchyProvider`.
use super::lines::LineIndex;
use crate::{LanguageId, Syntax};
use tree_sitter::{Node, Tree};
/// A Python class definition and the base names written in its source.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClassBases {
/// The class name (bare identifier).
pub name: String,
/// 0-based line of the class name (selection-range start).
pub line: u32,
/// 0-based UTF-16 column of the class name (selection-range start).
pub col: u32,
/// Source-written base names (last segment of a qualified name).
pub bases: Vec<String>,
}
impl Syntax {
/// Class definitions of `text` with their base names, in source order.
/// Only Python is supported; every other language returns an empty vector.
pub fn class_bases(&self, lang: &LanguageId, text: &str) -> Vec<ClassBases> {
self.analyze(lang, text, |g, tree, text| (g.spec.name == "python").then(|| extract(tree, text)))
.unwrap_or_default()
}
}
fn extract(tree: &Tree, text: &str) -> Vec<ClassBases> {
let idx = LineIndex::new(text);
let mut out = Vec::new();
let mut cursor = tree.walk();
visit(&mut cursor, &mut |node| {
if node.kind() != "class_definition" {
return;
}
let Some(name_node) = node.child_by_field_name("name") else { return };
let name = text.get(name_node.byte_range()).unwrap_or_default().to_owned();
if name.is_empty() {
return;
}
// Selection-range start of the class: its name identifier (`class Foo`
// -> `Foo`), matching `outline.rs` and the gutter marker candidates.
let range = idx.range(name_node.start_byte(), name_node.end_byte());
let bases = node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default();
out.push(ClassBases { name, line: range.start.line, col: range.start.col, bases });
});
out
}
/// Depth-first pre-order traversal of all descendants of `cursor`'s node.
fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
loop {
f(cursor.node());
if cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return;
}
}
}
}
fn base_names(args: Node, text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
if let Some(name) = base_name(arg, text) {
out.push(name);
}
}
out
}
/// Bare name of one base expression, or `None` for keyword arguments and
/// expressions that are not a (possibly qualified) name.
fn base_name(arg: Node, text: &str) -> Option<String> {
match arg.kind() {
"identifier" => text.get(arg.byte_range()).map(str::to_owned),
// `pkg.mod.Base` -> `Base`.
"attribute" => {
let attr = arg.child_by_field_name("attribute")?;
(attr.kind() == "identifier").then(|| text.get(attr.byte_range()).unwrap_or_default().to_owned())
}
// `Generic[T]` -> `Generic`.
"subscript" => {
let value = arg.child_by_field_name("value")?;
(value.kind() == "identifier").then(|| text.get(value.byte_range()).unwrap_or_default().to_owned())
}
// `metaclass=Meta`, `**kwargs`, `*bases` and other expressions.
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bases(src: &str) -> Vec<ClassBases> {
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
S.get_or_init(Syntax::new).class_bases(&LanguageId::new("python"), src)
}
fn shape(src: &str) -> Vec<(String, u32, u32, Vec<String>)> {
bases(src).into_iter().map(|c| (c.name, c.line, c.col, c.bases)).collect()
}
#[test]
fn no_bases() {
assert_eq!(shape("class A: pass"), vec![("A".to_owned(), 0, 6, vec![])]);
}
#[test]
fn multiple_bases_skips_metaclass() {
assert_eq!(
shape("class B(A, C, metaclass=Meta): pass"),
vec![("B".to_owned(), 0, 6, vec!["A".to_owned(), "C".to_owned()])]
);
}
#[test]
fn qualified_name_takes_last_segment() {
assert_eq!(shape("class D(pkg.mod.Base): pass"), vec![("D".to_owned(), 0, 6, vec!["Base".to_owned()])]);
}
#[test]
fn subscript_takes_value() {
assert_eq!(shape("class E(Generic[T]): pass"), vec![("E".to_owned(), 0, 6, vec!["Generic".to_owned()])]);
}
#[test]
fn multiline_bases() {
let src = "class F(\n A,\n B,\n):\n pass";
assert_eq!(shape(src), vec![("F".to_owned(), 0, 6, vec!["A".to_owned(), "B".to_owned()])]);
}
#[test]
fn nested_classes() {
let src = "class Outer:\n class Inner(Y):\n pass";
assert_eq!(
shape(src),
vec![("Outer".to_owned(), 0, 6, vec![]), ("Inner".to_owned(), 1, 10, vec!["Y".to_owned()]),]
);
}
#[test]
fn comments_and_strings_are_ignored() {
let src = r#"
# class Fake(Nope): pass
doc = """
class InDocstring(X): pass
"""
s = 'class InString(Y): pass'
class Real(Base): pass
"#;
assert_eq!(shape(src), vec![("Real".to_owned(), 6, 6, vec!["Base".to_owned()])]);
}
#[test]
fn other_languages_are_empty() {
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
let s = S.get_or_init(Syntax::new);
assert!(s.class_bases(&LanguageId::new("rust"), "struct A;").is_empty());
}
}
+2
Переглянути файл
@@ -6,6 +6,7 @@ mod external;
mod folding;
mod grammars;
mod highlight;
mod inheritance;
pub(crate) mod lines;
mod literals;
mod outline;
@@ -22,6 +23,7 @@ pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
pub use inheritance::ClassBases;
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
+2
Переглянути файл
@@ -67,6 +67,8 @@ The task tab shows the conversation: your messages, the agent's replies, its rea
On your own messages you can **Edit** (the messages after it are replaced; files already changed stay as they are) or **Retract Message**. On replies you can **Copy Markdown**, **Quote Reply**, **Regenerate Response** and **Branch Conversation from Here**, which starts a new task from that point.
Right-click a link and choose **Copy Link Address** to copy its URL; **Copy** copies the selected text.
### Permissions
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
+2
Переглянути файл
@@ -107,6 +107,8 @@ To format on every save, enable **Reformat code** (and optionally **Optimize imp
Tab placement, the tab limit and what happens when it is reached, the preview tab, sorting, and which tab is activated after one closes are set in **Settings › Editor › General › Editor Tabs**.
With tabs placed left or right, the tab column is as wide as its longest name, up to a limit. Drag the column's inner edge (or focus it and press `←` / `→`) to change the limit; it applies to every editor group and is remembered.
## Split editors
| Action | macOS | Windows / Linux |
+2
Переглянути файл
@@ -67,6 +67,8 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
### 权限
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
+2
Переглянути файл
@@ -107,6 +107,8 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
标签页的位置、数量上限以及达到上限后的处理方式、预览标签页、排序方式,以及关闭标签页后激活哪一个,都在 **设置 › 编辑器 › 常规 › 编辑器标签页** 中设置。
标签页放在左侧或右侧时,标签页列的宽度随最长的名称变化,但不超过一个上限。拖动该列靠近编辑器的边缘(或聚焦后按 `←` / `→`)可以调整上限;调整对所有编辑器组生效,并会被记住。
## 拆分编辑器
| 操作 | macOS | Windows / Linux |
+31
Переглянути файл
@@ -198,6 +198,37 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
});
test("copies a reply link's address from its context menu and keeps Copy for the selected text", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const url = "https://example.com/pull/42?tab=files#diff";
const reply = await openReply(page, `[**Review changes**](${url})\n\nOrdinary reply text`);
const link = reply.getByRole("link", { name: "Review changes" });
await link.locator("strong").click({ button: "right" });
await page.getByRole("menuitem", { name: "Copy Link Address" }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(url);
await link.evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await link.click({ button: "right" });
await page
.getByRole("menuitem", { name: /^Copy\b/ })
.first()
.click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("Review changes");
const paragraph = reply.getByText("Ordinary reply text", { exact: true });
await paragraph.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Copy Link Address" })).toHaveCount(0);
await page.keyboard.press("Escape");
});
test("keeps latest reply actions visible and quotes into the draft without shifting content", async ({ page }) => {
const reply = await openReply(page, REPLY);
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
+35
Переглянути файл
@@ -285,3 +285,38 @@ test("vertical tabs leave a narrow editor group most of its width", async ({ wor
expect(contentBox!.width).toBeGreaterThanOrEqual(paneBox!.width * 0.6 - 1);
}
});
test("dragging the vertical tab column's edge resizes it and the width is kept", async ({ workbench: page }) => {
await openFromTree(page, "settings.gradle.kts");
const placeLeft = () =>
page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const schemaModule = "/src/features/settings/model/schema.ts";
const [{ useProject }, { DEFAULT_SETTINGS }] = await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ schemaModule),
]);
const base = useProject.getState().settings ?? DEFAULT_SETTINGS;
useProject.setState({
settings: { ...base, editor: { ...base.editor, tabs: { ...base.editor.tabs, placement: "left" } } },
});
});
await placeLeft();
const strip = page.getByRole("tablist", { name: "Editor tabs" });
const handle = page.getByRole("separator", { name: "Resize editor tabs" });
const before = (await strip.boundingBox())!.width;
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x - 60, box.y + box.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(async () => (await strip.boundingBox())!.width).toBeLessThan(before - 40);
const narrowed = (await strip.boundingBox())!.width;
// The next session starts with the dragged width.
await page.reload();
await openFromTree(page, "settings.gradle.kts");
await placeLeft();
await expect.poll(async () => Math.round((await strip.boundingBox())!.width)).toBe(Math.round(narrowed));
});
+14
Переглянути файл
@@ -172,6 +172,20 @@ test("accepting a deleted version removes the file before staging", async ({ pag
]);
});
test("a long side shows only the lines it changed against the base", async ({ page }) => {
// Edits spread over a long file make the default diff give up and mark everything between them.
const base = Array.from({ length: 2000 }, (_, i) => `"node_modules/pkg-${i}": "1.0.${i}",`);
const bump = (at: number) => lines(...base.map((l, i) => (i % 100 === at ? l.replace('"1.0.', '"2.0.') : l)));
await mountMerge(page, [lines(...base), bump(3), bump(50)]);
for (const [name, line] of [
["Your version", 3],
["Their version", 50],
] as const) {
const deleted = page.getByRole("region", { name }).locator(".cm-deletedChunk").first().locator(".cm-deletedLine");
await expect(deleted).toHaveText([base[line]!]);
}
});
test("the result starts as the base and every change waits for a decision", async ({ page }) => {
await mountMerge(page);
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
+102
Переглянути файл
@@ -0,0 +1,102 @@
import { expect, stubDesktop, test } from "./fixtures";
test.use({ video: "on" });
test("offers file templates for the selected workspace member", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const markers: Record<string, string> = {
"/work/api": "go.mod",
"/work/web": "pom.xml",
"/work/library": "pom.xml",
};
window.__ipc.handlers.fs_read_dir = ({ path }) => {
const name = markers[String(path)];
return name ? [{ name, path: `${path}/${name}`, kind: "file", size: 0, mtimeMs: 0 }] : [];
};
window.__ipc.handlers.run_configs = () => [
{
id: "workspace:web:tomcat",
name: "Web / Tomcat",
kind: "tomcat",
command: "catalina",
args: ["run"],
cwd: "/work/web",
module: "/work/web",
env: {},
detected: true,
isTest: false,
debugAdapter: null,
},
];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
const current = useProject.getState().project;
useProject.setState({
project: {
...current,
name: "Workspace",
root: "/workspace",
workspace: { version: 1, name: "Workspace", projects: Object.keys(markers) },
},
});
});
const newMenu = async (path: string) => {
await page.locator(`[data-project-path="${path}"]`).click({ button: "right" });
await page.getByRole("menuitem", { name: "New", exact: true }).hover();
await expect(page.getByRole("menuitem", { name: "From Template…", exact: true })).toBeVisible();
};
await newMenu("/work/api");
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("go-new-menu.png"), animations: "disabled" });
await expect(page.getByRole("menuitem", { name: "Go Module", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "JSON File", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await newMenu("/work/web");
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "TypeScript File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("tomcat-new-menu.png"), animations: "disabled" });
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await page.locator('[data-project-path="/work/library"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Java Class", { exact: true })).toBeVisible();
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
});
test("uses the single project's type in the keyboard New popup", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
window.__ipc.handlers.fs_read_dir = ({ path }) =>
path === "/work/script" ? [{ name: "pyproject.toml", path: "/work/script/pyproject.toml", kind: "file" }] : [];
window.__ipc.handlers.run_configs = () => [];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
useProject.setState({
project: { ...useProject.getState().project, name: "script", root: "/work/script" },
});
});
await page.locator('[data-project-path="/work/script"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Python File", { exact: true })).toBeVisible();
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await popup.getByRole("textbox", { name: "Search for file type" }).fill("from template");
await popup.getByText("From Template…", { exact: true }).click();
await expect(
page.getByRole("dialog", { name: "New File from Template" }).getByText("Go File", { exact: true }),
).toBeVisible();
});
+7
Переглянути файл
@@ -362,3 +362,10 @@ pub async fn db_apply_edits(
pub fn db_console_path(state: State<'_, AppState>, id: String) -> IdeResult<IpcPath> {
ide_host::ops::db::console_path(&state.host, &id)
}
/// The query consoles of data source `id`; `create` adds the next one first.
#[tauri::command]
#[specta::specta]
pub fn db_consoles(state: State<'_, AppState>, id: String, create: bool) -> IdeResult<Vec<IpcPath>> {
ide_host::ops::db::consoles(&state.host, &id, create)
}
+2
Переглянути файл
@@ -248,6 +248,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::web_goto,
nav::nav_resolve_module,
nav::nav_index_definitions,
nav::nav_inheritance,
nav::fs_resolve_reference,
nav::path_completions,
nav::web_unresolved_references,
@@ -746,5 +747,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
db::db_table_page,
db::db_apply_edits,
db::db_console_path,
db::db_consoles,
])
}
+8
Переглянути файл
@@ -115,6 +115,14 @@ pub async fn nav_index_definitions(
ide_host::ops::nav::nav_index_definitions(&state.host, window.label(), name, from).await
}
/// Every Python class in the project (attached dependency roots included) and its base names,
/// for native inheritance gutter markers.
#[tauri::command]
#[specta::specta]
pub async fn nav_inheritance(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ClassInheritance>> {
ide_host::ops::nav::nav_inheritance(&state.host, window.label()).await
}
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
#[tauri::command]
#[specta::specta]
+26
Переглянути файл
@@ -475,6 +475,11 @@ export const commands = {
* plus its configured dependencies).
*/
navIndexDefinitions: (name: string, from: IpcPath) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("nav_index_definitions", { name, from })),
/**
* Every Python class in the project (attached dependency roots included) and its base names,
* for native inheritance gutter markers.
*/
navInheritance: () => typedError<ClassInheritance[], IdeError>(__TAURI_INVOKE("nav_inheritance")),
/** The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). */
fsResolveReference: (from: IpcPath, value: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("fs_resolve_reference", { from, value })),
/** Files and folders that continue `prefix`, a path being typed in `from`. */
@@ -1726,6 +1731,8 @@ export const commands = {
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
/** The query consoles of data source `id`; `create` adds the next one first. */
dbConsoles: (id: string, create: boolean) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("db_consoles", { id, create })),
};
/* Types */
@@ -2889,6 +2896,25 @@ export type ChoiceOption = {
label: string,
};
/**
* A Python class definition and the base names written in its source. The UI builds a
* name → locations index from every class in the project (attached dependency roots
* included) to compute ↑ overrides / ↓ overridden gutter markers without a language
* server's type-hierarchy provider.
*/
export type ClassInheritance = {
/** Start of the class definition's selection range (0-based line, UTF-16 column). */
path: IpcPath,
line: number,
col: number,
name: string,
/**
* Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
* qualified name keeps only its last segment).
*/
bases: string[],
};
export type ClassifiedReference = {
/** Optional for older Java extensions; missing means unclassified. */
access?: ReferenceAccess | null,
+23 -4
Переглянути файл
@@ -270,13 +270,29 @@
}
.tabsVertical > .tabs,
.tabsVertical > .tabRows {
/* Fits the labels up to the draggable limit (SideTabsResizer); the editor keeps at least 60%. */
width: max-content;
/* A narrow editor group keeps most of its width for the editor; labels truncate. */
min-width: min(140px, 40%);
max-width: min(260px, 40%);
max-width: min(var(--side-tabs-limit, 260px), 40%);
border-bottom: none;
border-right: 1px solid var(--border-editor-tabs, var(--border));
}
/* A zero-width handle on the column's inner border, with a wider hit area. */
.sideTabsResizer {
position: relative;
z-index: 1;
flex: none;
width: 0;
outline: none;
}
.sideTabsResizer::after {
content: "";
position: absolute;
inset: 0 -3px;
cursor: ew-resize;
}
.sideTabsResizer:focus-visible::after {
background: color-mix(in srgb, var(--focus-ring) 55%, transparent);
}
.tabsVertical .tabRows > .tabs {
min-height: 0;
border-bottom: none;
@@ -285,9 +301,12 @@
flex: 1 1 auto;
border-top: 1px solid var(--border-editor-tabs, var(--border));
}
.tabsRight > .sideTabsResizer {
order: 1;
}
.tabsRight > .tabs,
.tabsRight > .tabRows {
order: 1;
order: 2;
border-right: none;
border-left: 1px solid var(--border-editor-tabs, var(--border));
}
+4
Переглянути файл
@@ -64,6 +64,7 @@ import { TextEditorHost } from "./TextEditorHost";
import { FindBar } from "./FindBar";
import { EditorBanners, VirtualBanner } from "./Banner";
import { CodeBreadcrumbs } from "./StatusItems";
import { SideTabsResizer, useSideTabsStyle } from "./SideTabsResizer";
import styles from "./EditorArea.module.css";
import { tx } from "@/platform/i18n";
@@ -252,6 +253,7 @@ function PaneView({ paneId }: { paneId: string }) {
const pinnedCount = tabs.filter((t) => t.pinned).length;
// Settings › Editor Tabs › Tab placement Left / Right: a vertical, scrolling column.
const vertical = tabPrefs.placement === "left" || tabPrefs.placement === "right";
const sideTabsStyle = useSideTabsStyle();
const pinnedRow = tabPrefs.pinnedSeparateRow && pinnedCount > 0 && pinnedCount < tabs.length;
const dnd = useMemo(() => {
// A tab released outside the window detaches into a forced new window.
@@ -366,6 +368,7 @@ function PaneView({ paneId }: { paneId: string }) {
vertical && styles.tabsVertical,
tabPrefs.placement === "right" && styles.tabsRight,
)}
style={vertical ? sideTabsStyle : undefined}
data-pane-id={paneId}
data-focus-scope=""
aria-label={tx("Editor")}
@@ -381,6 +384,7 @@ function PaneView({ paneId }: { paneId: string }) {
) : (
strip(items, dnd.all, actions)
)}
{vertical && !hidden && <SideTabsResizer side={tabPrefs.placement === "left" ? "left" : "right"} />}
<div
className={styles.content}
role="tabpanel"
+106
Переглянути файл
@@ -0,0 +1,106 @@
/**
* Width limit of editor tabs placed left / right, as in IDEA: the column fits its
* labels up to this limit, and dragging its inner edge sets the limit for every
* editor group. Remembered across sessions.
*/
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { create } from "zustand";
import { lockDragCursor } from "@/ui";
import { tx } from "@/platform/i18n";
import styles from "./EditorArea.module.css";
const KEY = "openidea.editor.sideTabsWidth";
const MIN_WIDTH = 80;
const MAX_WIDTH = 800;
const KEY_STEP = 16;
/** CSS custom property read by `.tabsVertical` in EditorArea.module.css. */
const LIMIT_VAR = "--side-tabs-limit";
function load(): number | null {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? "null");
return typeof v === "number" && Number.isFinite(v) ? clamp(v) : null;
} catch {
return null;
}
}
function clamp(px: number) {
return Math.round(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, px)));
}
/** `null`: the default limit from the stylesheet. */
const useSideTabsWidth = create<{ width: number | null }>(() => ({ width: load() }));
function setSideTabsWidth(px: number) {
const width = clamp(px);
useSideTabsWidth.setState({ width });
try {
localStorage.setItem(KEY, JSON.stringify(width));
} catch {
// Storage unavailable: the width lasts for this session.
}
}
/** Style for the editor pane that holds a vertical tab column. */
export function useSideTabsStyle(): CSSProperties | undefined {
const width = useSideTabsWidth((s) => s.width);
return width == null ? undefined : ({ [LIMIT_VAR]: `${width}px` } as CSSProperties);
}
/** The drag handle on the editor side of the tab column; render it right after the column. */
export function SideTabsResizer({ side }: { side: "left" | "right" }) {
const sign = side === "left" ? 1 : -1;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
const handle = e.currentTarget;
const pane = handle.parentElement;
const start = handle.previousElementSibling?.getBoundingClientRect().width;
if (e.button !== 0 || !pane || start == null) return;
// No focus ring or text selection from a mouse drag.
e.preventDefault();
handle.setPointerCapture(e.pointerId);
const origin = e.clientX;
const previous = pane.style.getPropertyValue(LIMIT_VAR);
const releaseCursor = lockDragCursor("ew-resize");
let next: number | null = null;
// Only this pane follows the pointer; every group takes the new width on release.
const move = (ev: globalThis.PointerEvent) => {
if (!(ev.buttons & 1)) return end();
next = clamp(start + sign * (ev.clientX - origin));
pane.style.setProperty(LIMIT_VAR, `${next}px`);
};
const end = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", end);
handle.removeEventListener("pointercancel", end);
releaseCursor();
if (next != null) setSideTabsWidth(next);
else if (previous) pane.style.setProperty(LIMIT_VAR, previous);
else pane.style.removeProperty(LIMIT_VAR);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", end);
handle.addEventListener("pointercancel", end);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const width = e.currentTarget.previousElementSibling?.getBoundingClientRect().width;
if (!dir || width == null) return;
e.preventDefault();
setSideTabsWidth(width + sign * dir * KEY_STEP);
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={tx("Resize editor tabs")}
tabIndex={0}
className={styles.sideTabsResizer}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
/>
);
}
+226 -7
Переглянути файл
@@ -27,12 +27,14 @@ import {
isCancelled,
nextRequestId,
onIdeEvent,
type ClassInheritance,
type HierarchyItem,
type Location,
type OutlineItem,
type Pos,
type RequestId,
} from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { editorPrefs, type EditorExtras } from "@/platform/editorSettings";
import { useProject } from "@/platform/project";
import { gutterIconMarkup } from "@/ui";
@@ -48,7 +50,7 @@ import {
type MarkerCandidate,
type MarkerGroup,
} from "./lineMarkers";
import { overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
import { baseName, overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
import styles from "./LineMarkers.module.css";
/** Lines above / below the viewport that are asked with it. */
@@ -85,16 +87,49 @@ export function affectsMarkers(paths: readonly string[]): boolean {
return paths.some((p) => !OUTPUT_PATH.test(p));
}
/**
* The Python inheritance graph of the open project, shared by every document and kept
* across edits: it is a whole-project index answer, not a per-document one, so scanning
* it again for each edit would walk every Python file again. Dropped when other files
* change or a server settles (the index may have moved on).
*/
let inheritanceGraphCache: { root: string; promise: Promise<ClassInheritance[]> } | null = null;
/** The project whose graph is cached; `""` for no project (a loose file). */
function projectRoot(): string {
return useProject.getState().project?.root ?? "";
}
/** Forgets the cached graph (files changed, a server settled, or tests). */
export function forgetInheritanceGraph() {
inheritanceGraphCache = null;
}
function inheritanceGraph(): Promise<ClassInheritance[]> {
const root = projectRoot();
if (!inheritanceGraphCache || inheritanceGraphCache.root !== root) {
inheritanceGraphCache = { root, promise: lspRequest(() => commands.navInheritance()) };
// A failed request must not stick: the next pass retries it.
inheritanceGraphCache.promise.catch(() => {
if (inheritanceGraphCache?.root === root) inheritanceGraphCache = null;
});
}
return inheritanceGraphCache.promise;
}
/** Subscribes once per window and stays subscribed, so the settle echo guard survives remounts. */
function watchChanges() {
if (watching) return;
watching = true;
onIdeEvent("fsChanged", (e) => {
if (affectsMarkers([...e.created, ...e.modified, ...e.removed])) changes++;
if (!affectsMarkers([...e.created, ...e.modified, ...e.removed])) return;
changes++;
forgetInheritanceGraph();
});
// Answers (outlines too) given while a server was starting or indexing may be incomplete.
onServerSettled(() => {
docMarkers.clear();
forgetInheritanceGraph();
for (const plugin of live) plugin.refreshSoon();
});
}
@@ -131,6 +166,13 @@ export interface MarkerDeps {
implementations: (positions: Pos[]) => Promise<Location[][]>;
supers: SuperDeps;
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
/**
* Every Python class of the project and its base names (the IDE index, attached
* dependency roots included). Some servers (basedpyright, pyrefly) answer no
* `typeHierarchyProvider`, so ↑/↓ are computed from this graph instead.
* Absent for languages whose servers do provide one (Java, Go, ...).
*/
inheritance?: () => Promise<ClassInheritance[]>;
}
interface CacheEntry {
@@ -142,6 +184,8 @@ interface CacheEntry {
export interface MarkerCache {
entries: Map<string, CacheEntry>;
unsupported: Set<MarkerGroup>;
/** The Python inheritance graph for this document version, fetched once per cache. */
inheritance?: Promise<ClassInheritance[] | null>;
}
export const emptyCache = (): MarkerCache => ({ entries: new Map(), unsupported: new Set() });
@@ -164,11 +208,16 @@ export async function fillMarkers(
if (!e) cache.entries.set(key, (e = {}));
return e;
};
// A Python inheritance graph answers both groups even when the language server
// has no type hierarchy: it must keep the candidates alive through the LSP
// "unsupported" bookkeeping, or the fallback would never get to run.
const graph = deps.inheritance ? await (cache.inheritance ??= deps.inheritance().catch(() => null)) : null;
const fallback = (graph?.length ?? 0) > 0;
const down =
groups.has("inheritors") && !cache.unsupported.has("inheritors")
groups.has("inheritors") && (fallback || !cache.unsupported.has("inheritors"))
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
: [];
const askSupers = groups.has("supers") && !cache.unsupported.has("supers");
const askSupers = groups.has("supers") && (fallback || !cache.unsupported.has("supers"));
const up = askSupers
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
: [];
@@ -186,8 +235,11 @@ export async function fillMarkers(
asked = await deps.implementations(ask.map((c) => ({ line: c.line, col: c.col })));
} catch (e) {
if (isCancelled(e)) throw e;
if (isUnavailable(e)) cache.unsupported.add("inheritors");
for (let i = down.length - 1; i >= 0; i--) if (!known.has(down[i]!)) down.splice(i, 1);
// With a fallback the candidates stay: the graph may still answer them.
if (!fallback) {
if (isUnavailable(e)) cache.unsupported.add("inheritors");
for (let i = down.length - 1; i >= 0; i--) if (!known.has(down[i]!)) down.splice(i, 1);
}
}
}
implementations = down.map((c) => withoutSelf(path, c, known.get(c) ?? asked[ask.indexOf(c)] ?? []));
@@ -208,8 +260,79 @@ export async function fillMarkers(
}),
);
}
// Python without a type-hierarchy provider: the IDE's inheritance graph holds every
// `class X(A)` of the project, so ↑/↓ are computed from it instead.
const byName = new Map<string, ClassInheritance[]>();
if (graph) for (const cls of graph) byName.set(cls.name, [...(byName.get(cls.name) ?? []), cls]);
// The inheritance graph's direct subclasses, keyed by the *declaration* each base
// resolves to (path + position), so classes sharing a bare name never mix up.
const subclassesOf = new Map<string, ClassInheritance[]>();
for (const cls of graph ?? []) {
for (const base of new Set(cls.bases)) {
for (const decl of resolveBase(byName, base, cls.path)) {
const key = declarationKey(decl);
subclassesOf.set(key, [...(subclassesOf.get(key) ?? []), cls]);
}
}
}
const classOf = (c: MarkerCandidate): ClassInheritance | null => {
// A class candidate is its own declaration; a method's is the `owner` it records.
const at = c.method ? c.owner : { line: c.line, col: c.col };
if (!at) return null;
const cls = candidates.find((k) => !k.method && k.line === at.line && k.col === at.col) ?? (c.method ? null : c);
if (!cls) return null;
return byName.get(cls.name)?.find((x) => x.path === path && x.line === cls.line && x.col === cls.col) ?? null;
};
/** The declarations a subclass overrides: the subclass itself, or its same-named method. */
const subclassDeclarations = async (c: MarkerCandidate, owner: ClassInheritance): Promise<Location[]> => {
const subs = subclassesOf.get(declarationKey(owner)) ?? [];
if (!c.method) return subs.map(classLocation);
const bare = c.symbol.replace(/\s*[(<].*$/, "");
const targets: Location[] = [];
for (const sub of subs) {
const outline = await deps.supers.outline(sub.path).catch(() => [] as OutlineItem[]);
const m = methodInOutline(outline, sub, bare);
if (m) targets.push({ path: sub.path, range: m.selection_range });
}
return targets;
};
if (byName.size) {
for (let i = 0; i < down.length; i++) {
const c = down[i]!;
const owner = classOf(c);
if (!owner) continue;
// Merge, never replace: a server that answers sees one root, so its list can miss
// the subclasses living in an attached project while still being non-empty.
implementations[i] = withoutSelf(
path,
c,
mergeLocations(implementations[i] ?? [], await subclassDeclarations(c, owner)),
);
}
}
const supers: SuperMethod[][] = [];
for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers));
if (byName.size) {
for (let i = 0; i < up.length; i++) {
const c = up[i]!;
const self = classOf(c);
if (!self) continue;
const found = await superMethodsFrom(byName, self.name, c.symbol, deps);
if (!found.length) continue;
const known = supers[i]!;
// Same merge rule as ↓: add the graph's super methods to what the server answered.
const extra = found.map((f) => f.location).filter((l) => !known.some((s) => sameLocation(s.location, l)));
const owners = [...new Set(found.map((f) => f.owner))].sort((a, b) => a.localeCompare(b));
entry(c).supers = {
kind: "overriding",
line: c.line,
name: c.name,
targets: [...known.map((s) => s.location), ...extra],
tooltip: tx`Overrides method in ${owners.join(", ")}`,
chooserTitle: tx`Choose Super Method of ${c.name} (${known.length + extra.length} found)`,
};
}
}
// Go: an implementation request on a type or method answers the interfaces it satisfies.
let satisfied: Location[][] = [];
if (implicit.length) {
@@ -228,10 +351,103 @@ export async function fillMarkers(
...satisfied.flat().map((l) => l.path),
]);
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files)));
up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files)));
// A candidate the graph answered already has its marker: don't null it out here.
up.forEach((c, i) => {
if (supers[i]!.length) entry(c).supers = supersMarker(c, supers[i]!, files);
});
implicit.forEach((c, i) => (entry(c).supers = implementsMarker(c, withoutSelf(path, c, satisfied[i] ?? []), files)));
}
// One entry per declaration line: the server and the graph may point at the same
// declaration with different columns (the name vs the whole statement).
const sameLocation = (a: Location, b: Location) => a.path === b.path && a.range.start.line === b.range.start.line;
/** `first`, then the locations of `more` not already in it. */
function mergeLocations(first: readonly Location[], more: readonly Location[]): Location[] {
return [...first, ...more.filter((l) => !first.some((f) => sameLocation(f, l)))];
}
/** Identifies one class declaration in the inheritance graph. */
function declarationKey(cls: ClassInheritance): string {
return `${cls.path}:${cls.line}:${cls.col}`;
}
/**
* The classes a base name names. A bare name is only trusted when it is unique in the
* project, or names a class in the subclass's own file: a `Config` declared in hundreds
* of modules must not make every one of them a subclass of every other.
*/
function resolveBase(byName: ReadonlyMap<string, ClassInheritance[]>, name: string, from: string): ClassInheritance[] {
const found = byName.get(name) ?? [];
if (found.length <= 1) return found;
return found.filter((c) => c.path === from);
}
/** The class declaration itself as a navigation target (its name identifier). */
function classLocation(cls: ClassInheritance): Location {
const start = { line: cls.line, col: cls.col };
return { path: cls.path, range: { start, end: start } };
}
/** The method `method` (bare name) directly inside the class at `cls`'s position. */
function methodInOutline(outline: readonly OutlineItem[], cls: ClassInheritance, method: string): OutlineItem | null {
const at = cls;
const find = (items: readonly OutlineItem[]): OutlineItem | null => {
for (const it of items) {
if (it.kind === "class" && it.selection_range.start.line === at.line && it.selection_range.start.col === at.col) {
return it.children.find((ch) => ch.kind === "method" && baseName(ch.name) === method) ?? null;
}
const deeper = find(it.children);
if (deeper) return deeper;
}
return null;
};
return find(outline);
}
/**
* Super methods of `method` declared by a class in `byName`, walking the inheritance
* graph's bases up to a few levels. Empty when no base declares the method.
*/
async function superMethodsFrom(
byName: ReadonlyMap<string, ClassInheritance[]>,
start: string,
method: string,
deps: MarkerDeps,
depth = 3,
): Promise<{ location: Location; owner: string }[]> {
const bare = method.replace(/\s*[(<].*$/, "");
const seen = new Set<string>([start]);
let level = [start];
for (let d = 0; d < depth && level.length; d++) {
const next: string[] = [];
const found: { location: Location; owner: string }[] = [];
for (const name of level) {
for (const cls of byName.get(name) ?? []) {
for (const base of cls.bases) {
if (!seen.add(base)) continue;
// Same rule as the graph: only a base that names one class is followed.
const declarations = resolveBase(byName, base, cls.path);
let answered = false;
for (const baseCls of declarations) {
const outline = await deps.supers.outline(baseCls.path).catch(() => [] as OutlineItem[]);
const m = methodInOutline(outline, baseCls, bare);
if (m) {
found.push({ location: { path: baseCls.path, range: m.selection_range }, owner: base });
answered = true;
}
}
// Not found in a base that declares nothing: keep walking its own bases.
if (!answered) next.push(base);
}
}
}
if (found.length) return found;
level = next;
}
return [];
}
/**
* Overriders of a type's methods from one walk of its subtypes and their outlines, instead of one
* implementation request per method: jdtls builds the type hierarchy again for every implementation
@@ -575,6 +791,9 @@ function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: Reques
},
},
readFiles: readManyFileLines,
// Python servers answer no type hierarchy (basedpyright, pyrefly): the IDE's
// own index knows every `class X(Base)` of the project, attached roots included.
inheritance: ctx.language === "python" ? inheritanceGraph : undefined,
};
}
+127 -1
Переглянути файл
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
IpcError,
type ClassInheritance,
type HierarchyItem,
type Location,
type OutlineItem,
@@ -208,8 +209,9 @@ function server(
impls: Record<string, Location[]>,
supers: Record<string, HierarchyItem[]>,
subtypes: Record<string, HierarchyItem[]> = {},
inheritance?: ClassInheritance[],
) {
const calls = { implementations: [] as Pos[][], supertypes: 0, subtypes: 0 };
const calls = { implementations: [] as Pos[][], supertypes: 0, subtypes: 0, inheritance: 0 };
const outlines: Record<string, OutlineItem[]> = {
[SHAPE_PATH]: SHAPE_OUTLINE,
"/p/Circle.java": CIRCLE_OUTLINE,
@@ -234,6 +236,35 @@ function server(
outline: async (p) => outlines[p] ?? [],
},
readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])),
inheritance: inheritance
? async () => {
calls.inheritance++;
return inheritance;
}
: undefined,
});
return { calls, deps };
}
/** A Python project: the server answers neither implementations nor a type hierarchy. */
function pythonServer(inheritance: ClassInheritance[], outlines: Record<string, OutlineItem[]>) {
const calls = { implementations: 0, inheritance: 0 };
const deps = (): MarkerDeps => ({
implementations: async () => {
calls.implementations++;
throw new IpcError({ kind: "unavailable", message: "basedpyright doesn't support implementation" });
},
supers: {
supertypes: async () => {
throw new IpcError({ kind: "unavailable", message: "basedpyright doesn't support type hierarchy" });
},
outline: async (p) => outlines[p] ?? [],
},
readFiles: async () => new Map(),
inheritance: async () => {
calls.inheritance++;
return inheritance;
},
});
return { calls, deps };
}
@@ -637,3 +668,98 @@ describe("overriders of a type's methods from its subtypes", () => {
]);
});
});
describe("python inheritance fallback", () => {
// a.py: `class A` (line 0) with `m` (line 1), then `class B(A)` (line 3) with `m` (line 4).
const outline = [
item("A", "class", 0, 2, [item("m", "method", 1, 1)], 6),
item("B", "class", 3, 5, [item("m", "method", 4, 4)], 6),
];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "A", bases: [] },
{ path: "/p/a.py", line: 3, col: 6, name: "B", bases: ["A"] },
];
it("marks the subclass and the overridden method with no type hierarchy", async () => {
const { deps, calls } = pythonServer(graph, { "/p/a.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps(), cache);
const markers = cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind, m.targets.map((t) => t.path)]);
expect(markers).toEqual([
// The class B subclasses A...
[0, "overridden", ["/p/a.py"]],
// ...and its `m` overrides A's `m`.
[1, "overridden", ["/p/a.py"]],
[4, "overriding", ["/p/a.py"]],
]);
// The graph is asked once per cache, not once per candidate.
expect(calls.inheritance).toBe(1);
});
it("survives an unavailable implementation provider (the fallback keeps its candidates)", async () => {
const { deps, calls } = pythonServer(graph, { "/p/a.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps(), cache);
// The server said "unavailable", but the graph still answered ↓ and ↑.
expect(calls.implementations).toBeGreaterThan(0);
expect(cache.unsupported.size).toBe(0);
});
it("adds the graph's cross-project subclasses to a non-empty server answer", async () => {
// The server (single root) answers the local subclass only; the attached-project
// subclass is known to the IDE's graph and must join the list.
const outline = [item("A", "class", 0, 2, [], 6), item("B", "class", 3, 5, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "A", bases: [] },
{ path: "/p/a.py", line: 3, col: 6, name: "B", bases: ["A"] },
{ path: "/p/attached/a.py", line: 0, col: 6, name: "C", bases: ["A"] },
];
const { deps } = server({ "/p/a.py:0": [loc("/p/a.py", 3)] }, {}, {}, graph);
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps("/p/a.py"), cache);
const targets = cachedMarkers(candidates, ALL, cache)
.filter((m) => m.line === 0)
.flatMap((m) => m.targets.map((t) => t.path));
expect(targets).toEqual(["/p/a.py", "/p/attached/a.py"]);
});
it("ignores a bare base name that many classes in the project share", async () => {
// `Config` is declared in hundreds of modules: no edge, so no marker.
const outline = [item("Child", "class", 0, 2, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 0, col: 6, name: "Child", bases: ["Config"] },
];
const { deps } = pythonServer(graph, { "/p/c.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
expect(cachedMarkers(candidates, ALL, cache)).toEqual([]);
});
it("trusts a shared base name when the subclass's own file declares it", async () => {
const outline = [item("Config", "class", 0, 1, [], 6), item("Child", "class", 3, 5, [], 6)];
const graph: ClassInheritance[] = [
{ path: "/p/c.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/other.py", line: 0, col: 6, name: "Config", bases: [] },
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Config"] },
];
const { deps } = pythonServer(graph, { "/p/c.py": outline });
const candidates = markerCandidates(outline, () => "");
const cache = emptyCache();
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind])).toEqual([[0, "overridden"]]);
});
it("keeps the existing server path when no inheritance graph is injected", async () => {
const { deps } = server({}, {});
const candidates = markerCandidates([item("Foo", "class", 0, 2, [item("m", "method", 1, 1)], 6)], () => "");
const cache = emptyCache();
await fillMarkers("/p/a.py", candidates, ALL, deps("/p/a.py"), cache);
expect(cachedMarkers(candidates, ALL, cache)).toEqual([]);
});
});
+19
Переглянути файл
@@ -38,6 +38,7 @@ import {
loadDatabases,
listsDatabases,
sourceById,
newConsole,
openConsole,
openHistory,
openSourceDialog,
@@ -198,6 +199,24 @@ export function activate(): () => void {
else toolWindowService().show(DB_TOOL_WINDOW, true);
},
},
{
id: "db.newConsole",
title: "New Query Console",
category: "Tools",
icon: "db-console",
keywords: ["database", "sql console"],
when: "projectOpen",
run: async ({ arg }) => {
const current = projectRequest();
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
const { sources, selectedSource } = useDatabase.getState();
const node = arg && typeof arg === "object" && "kind" in arg ? nodeTarget(arg as DbNode) : null;
const id = node ?? selectedSource ?? (sources.length === 1 ? sources[0]!.id : null);
if (id) await newConsole(id);
else toolWindowService().show(DB_TOOL_WINDOW, true);
},
},
{
id: "db.execute",
title: "Execute SQL Statement",
+23
Переглянути файл
@@ -906,6 +906,29 @@ export async function openConsole(id: string): Promise<void> {
}
}
/** The query console files of data source `id`: `console.sql`, then `console_1.sql`, `console_2.sql`… */
export function consoleFiles(id: string): Promise<string[]> {
return call(commands.dbConsoles(id, false));
}
/** Opens one of the query consoles of data source `id`. */
export async function openConsoleFile(id: string, path: string): Promise<void> {
bindConsole(path, id);
await editorService().openFile(path, { focus: true });
}
/** Creates and opens another query console of data source `id` (DataGrip's New › Query Console). */
export async function newConsole(id: string): Promise<void> {
try {
const before = new Set(await consoleFiles(id));
const after = await call(commands.dbConsoles(id, true));
const path = after.find((p) => !before.has(p)) ?? after[after.length - 1];
if (path) await openConsoleFile(id, path);
} catch (e) {
notifications().notify({ level: "error", title: "Cannot open the query console", body: message(e) });
}
}
let nextTab = 1;
let nextRun = 1;
let nextLine = 1;
+16 -1
Переглянути файл
@@ -22,7 +22,10 @@ import {
loadSchemas,
loadSources,
loadTables,
consoleFiles,
newConsole,
openConsole,
openConsoleFile,
openConsoleWith,
openDdl,
openObjectDdl,
@@ -42,6 +45,7 @@ import {
} from "./store";
import { tableInput } from "./tableInput";
import { tx } from "@/platform/i18n";
import { chooseItem } from "@/workbench/inputDialogs";
import { databaseRequest } from "./requests";
/** Context key: the Database tree has focus. */
@@ -279,7 +283,7 @@ export function registerDatabaseTreeActions(): () => void {
const id = n ? nodeTarget(n) : null;
const { sources } = useDatabase.getState();
const target = id ?? (sources.length === 1 ? (sources[0]!.id ?? null) : null);
if (target) void openConsole(target);
if (target) void jumpToConsole(target);
else toolWindowService().show(DB_TOOL_WINDOW, true);
},
}),
@@ -524,3 +528,14 @@ export function registerDatabaseTreeActions(): () => void {
}),
]);
}
/** One console opens directly; with several, DataGrip's list of them plus New Query Console. */
async function jumpToConsole(id: string): Promise<void> {
const files = await consoleFiles(id).catch(() => []);
if (files.length <= 1) return openConsole(id);
const names = files.map((f) => (f.split(/[\\/]/).pop() ?? f).replace(/\.sql$/, ""));
const create = tx("New Query Console");
const picked = await chooseItem({ title: "Query Consoles", items: [...names, create] });
if (picked === create) await newConsole(id);
else if (picked) await openConsoleFile(id, files[names.indexOf(picked)]!);
}
+2 -2
Переглянути файл
@@ -66,7 +66,7 @@ async function sourceFromClipboard() {
/** The toolbar's "+" menu (IDEA: New). */
export function newMenu(): MenuItem[] {
return [{ kind: "action", action: "db.openConsole", label: "Query Console" }, sep, ...dataSourceItems()];
return [{ kind: "action", action: "db.newConsole", label: "Query Console" }, sep, ...dataSourceItems()];
}
function sqlScripts(n: DbNode, table: boolean): MenuItem {
@@ -88,7 +88,7 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
const driver = sourceById(id)?.driver ?? "sqlite";
const o = objectOf(n);
const t = tableOf(n);
const newConsole = act("db.openConsole", n, { label: "Query Console", icon: "db-console" });
const newConsole = act("db.newConsole", n, { label: "Query Console", icon: "db-console" });
const refresh = act("db.refresh", n);
const quickDoc = act("db.quickDoc", n);
const rename = act("db.rename", n, { disabled: !(n.kind === "source" || (o && canRename(driver, o))) });
+10 -1
Переглянути файл
@@ -98,6 +98,15 @@ describe("run config → launch config", () => {
});
});
it("cargo run under lldb-dap builds the same way and passes the environment as strings", () => {
const l = launchConfigFor(
rc({ kind: "cargo", command: "cargo", args: ["run"], env: { RUST_LOG: "debug" }, debugAdapter: "lldb-dap" }),
"/p",
)!;
expect(l.adapter).toBe("lldb-dap");
expect(extra(l)).toEqual({ cargo: { args: ["build"], filter: { kind: "bin" } }, env: ["RUST_LOG=debug"] });
});
it("cargo test builds test binaries without running", () => {
const l = launchConfigFor(
rc({ kind: "cargo", command: "cargo", args: ["test", "--workspace"], debugAdapter: "codelldb" }),
@@ -113,7 +122,7 @@ describe("run config → launch config", () => {
)!;
expect(script.program).toBe("/p/manage.py");
expect(script.args).toEqual(["runserver"]);
expect(extra(script)).toMatchObject({ python: "/p/.venv/bin/python", django: true, console: "internalConsole" });
expect(extra(script)).toMatchObject({ python: "/p/.venv/bin/python", django: true, console: "integratedTerminal" });
const mod = launchConfigFor(
rc({ kind: "python", command: "python3", args: ["-m", "app", "-v"], debugAdapter: "debugpy" }),
+10
Переглянути файл
@@ -157,6 +157,16 @@ describe("debug tool window", () => {
expect(screen.getByRole("tab", { name: "Memory" })).toBeTruthy();
});
it("shows the Memory tab of a JVM session while it starts, before its capabilities arrive", () => {
const starting = { ...session, adapter: "java-debug", status: "starting" as const, caps: null };
useDebug.setState({ sessions: { 1: starting }, active: 1 });
render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Memory" }));
expect(screen.getByText("The Memory view is available once the program has started.")).toBeTruthy();
act(() => useDebug.setState({ sessions: { 1: { ...starting, adapter: "debugpy" } } }));
expect(screen.queryByRole("tab", { name: "Memory" })).toBeNull();
});
it("keeps the Memory tab selected when the session pauses", async () => {
const caps = { jvmMemory: true } as DebugSession["caps"];
useDebug.setState({ sessions: { 1: { ...session, caps, status: "running" } }, active: 1 });
+9 -3
Переглянути файл
@@ -67,7 +67,12 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
return {
...base(rc, adapter, cwd),
args: programArgs,
extraJson: JSON.stringify({ cargo: filter ? { args, filter } : { args }, sourceLanguages: ["rust"] }),
extraJson: JSON.stringify({
cargo: filter ? { args, filter } : { args },
...(adapter === "codelldb" ? { sourceLanguages: ["rust"] } : {}),
// lldb-dap takes the environment as `KEY=VALUE` strings.
...(adapter === "lldb-dap" ? { env: Object.entries(rc.env).map(([k, v]) => `${k}=${v}`) } : {}),
}),
};
}
@@ -95,10 +100,11 @@ function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
const dbg = workflowSettings().debugger;
const extra: Record<string, unknown> = {
python: pythonFor(rc, cwd),
console: "internalConsole",
// On a terminal the program reads what is typed in the debug console (`input()`); its
// output reaches the console from there, so debugpy doesn't capture it a second time.
console: "integratedTerminal",
justMyCode: dbg.skipLibraryCode,
subProcess: dbg.python.attachSubprocess,
redirectOutput: true,
};
if (dbg.python.gevent) extra.gevent = true;
const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, "");
+15 -2
Переглянути файл
@@ -169,7 +169,9 @@ export function DebugToolWindow() {
? "variables"
: view.tab,
});
const hasMemory = !!active && sessionSupports("jvmMemory", active);
// A JVM session shows the Memory tab from the start, before the debugger has reported what it supports.
const memoryPending = !!active && !active.caps && JVM_ADAPTERS.has(rootOf(active).adapter);
const hasMemory = !!active && (sessionSupports("jvmMemory", active) || memoryPending);
const hasNetwork = !!active && sessionSupports("jsPage", active);
// The selected session may be one without the Memory or the Network tab.
const tab: Tab =
@@ -255,7 +257,15 @@ export function DebugToolWindow() {
{tab === "variables" ? (
<ThreadsAndVariables session={active} />
) : tab === "memory" ? (
<MemoryView key={active.id} session={active} />
memoryPending ? (
<div className={styles.emptyWrap}>
<EmptyState title="Memory">
<T>The Memory view is available once the program has started.</T>
</EmptyState>
</div>
) : (
<MemoryView key={active.id} session={active} />
)
) : tab === "network" ? (
<NetworkView key={active.id} session={active.id} />
) : (
@@ -266,6 +276,9 @@ export function DebugToolWindow() {
);
}
/** Debug adapters whose sessions have the Memory view (`ide-dap`'s `JVM_MEMORY_ADAPTERS`). */
const JVM_ADAPTERS = new Set(["java-debug", "kotlin-debug-adapter", "kotlin-lsp"]);
// --- threads & frames ----------------------------------------------------------
function ThreadsAndVariables({ session }: { session: DebugSession }) {
+3
Переглянути файл
@@ -401,6 +401,9 @@ describe("pull request helpers", () => {
"ssh://[email protected]/o/r.git",
"ssh://[email protected]:22/o/r.git",
"git://github.com/o/r",
// GitHub's SSH-over-HTTPS host.
"ssh://[email protected]:443/o/r.git",
"[email protected]:o/r.git",
])
expect(parseGitHubRemote(url), url).toEqual({ owner: "o", name: "r" });
expect(parseGitHubRemote("https://gitlab.com/o/r.git")).toBeNull();
+2 -2
Переглянути файл
@@ -413,10 +413,10 @@ export function repositoryDisplayName(
return showOwner ? `${repo.owner}/${repo.name}` : repo.name;
}
/** `owner/name` of a github.com remote URL (https, ssh or scp-like), else null. */
/** `owner/name` of a github.com remote URL (https, ssh or scp-like; SSH also via ssh.github.com), else null. */
export function parseGitHubRemote(url: string): GHRepositoryCoordinates | null {
const m =
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|git:\/\/|(?:[^@/]+@)?)(?:www\.)?github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
/^(?:(?:https?:\/\/(?:[^@/]+@)?|git:\/\/)(?:www\.)?|(?:ssh:\/\/(?:[^@/]+@)?|(?:[^@/]+@)?)(?:www\.|ssh\.)?)github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
url.trim(),
);
return m ? { owner: m[1]!, name: m[2]! } : null;
+9
Переглянути файл
@@ -207,3 +207,12 @@
.pendingMethod {
font-weight: 600;
}
.match {
border-radius: 2px;
color: var(--fg-search-match);
background: var(--bg-search-match);
}
.matchCurrent {
background: var(--bg-search-match-current);
}
+116 -10
Переглянути файл
@@ -3,7 +3,7 @@
* exchange (right): status line, timing, body (pretty-printed JSON / XML /
* HTML or raw), response headers and the request as sent.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import clsx from "clsx";
import type { HttpExchange, HttpHistoryEntry } from "@/platform/ipc";
import {
@@ -18,6 +18,9 @@ import {
T,
useSelectionBoundary,
} from "@/ui";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type ConsoleMatch, type FindOptions } from "@/ui/consoleFind";
import { formatBinding, hasPrimaryModifier, hostBinding } from "@/platform/keymap";
import { bodyKind, formatMs, formatSize, prettyBody } from "./format";
import { cancelRunning, clearHistory, refreshHistory, selectEntry, useHttpClient } from "./store";
import styles from "./HttpClient.module.css";
@@ -60,6 +63,49 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
const r = ex.response;
const kind = r?.body != null ? bodyKind(r.contentType, r.body) : "text";
const body = useMemo(() => (r?.body == null ? null : raw ? r.body : prettyBody(kind, r.body)), [r, raw, kind]);
const [findOpen, setFindOpen] = useState(false);
const [query, setQuery] = useState("");
const [findOptions, setFindOptions] = useState<FindOptions>(DEFAULT_FIND_OPTIONS);
const [active, setActive] = useState(0);
const findInput = useRef<HTMLInputElement>(null);
const searchable = findOpen && tab === "body" && body != null;
const lines = useMemo(() => (body ?? "").split("\n").map((text, id) => ({ id, text })), [body]);
const found = useMemo(
() => (searchable ? findInLines(lines, query, findOptions) : { matches: [], overflow: false, invalid: false }),
[searchable, lines, query, findOptions],
);
const activeIndex = found.matches.length ? Math.min(active, found.matches.length - 1) : -1;
useEffect(() => {
pane.current?.querySelector("[data-find-current]")?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeIndex, found]);
const openFind = () => {
setFindOpen(true);
// The bar mounts with this render; focus it (and select the query) once it has.
requestAnimationFrame(() => findInput.current?.select());
};
const closeFind = () => {
setFindOpen(false);
pane.current?.focus();
};
const goToMatch = (step: number) => {
const n = found.matches.length;
if (n) setActive((activeIndex + step + n) % n);
};
// ⌘F in the response, as in IDEA's response viewer: the find bar of the Run console.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (
tab === "body" &&
body != null &&
e.key.toLowerCase() === "f" &&
hasPrimaryModifier(e) &&
!e.altKey &&
!e.shiftKey
) {
e.preventDefault();
e.stopPropagation();
openFind();
}
};
return (
<div className={styles.exchange}>
<div className={styles.statusLine}>
@@ -91,18 +137,52 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
{ id: "request", label: "Request" },
]}
trailing={
tab === "body" && kind !== "text" ? (
<IconButton
icon="soft-wrap"
title={raw ? "Show Formatted" : "Show Raw"}
pressed={raw}
onClick={() => setRaw((v) => !v)}
/>
tab === "body" && body != null ? (
<>
<IconButton
icon="search"
title="Find…"
shortcut={formatBinding(hostBinding("Meta+F"))}
pressed={findOpen}
onClick={() => (findOpen ? closeFind() : openFind())}
/>
{kind !== "text" && (
<IconButton
icon="soft-wrap"
title={raw ? "Show Formatted" : "Show Raw"}
pressed={raw}
onClick={() => setRaw((v) => !v)}
/>
)}
</>
) : undefined
}
/>
{searchable && (
<ConsoleFindBar
query={query}
onChange={(q) => {
setQuery(q);
setActive(0);
}}
options={findOptions}
onOptionsChange={(o) => {
setFindOptions(o);
setActive(0);
}}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
label="Find in response"
/>
)}
{/* Focusable, so Select All and Copy work on the response like in a read-only editor. */}
<div ref={pane} className={styles.tabBody} tabIndex={0}>
<div ref={pane} className={styles.tabBody} tabIndex={0} onKeyDown={onKeyDown}>
{tab === "body" &&
(r == null ? (
<pre className={styles.pre}>{ex.entry.error}</pre>
@@ -112,7 +192,7 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
</EmptyState>
) : (
<pre className={styles.pre} aria-label={tx("Response body")}>
{body}
{found.matches.length ? highlighted(lines, found.matches, activeIndex) : body}
{r.truncated && (
<span className={styles.truncated}>{`\n… truncated (${formatSize(r.size)} received)`}</span>
)}
@@ -131,6 +211,32 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
);
}
/** The body with its find matches marked, the current one distinctly. */
function highlighted(lines: readonly { text: string }[], matches: readonly ConsoleMatch[], current: number): ReactNode {
const out: ReactNode[] = [];
let m = 0;
lines.forEach(({ text }, line) => {
if (line > 0) out.push("\n");
let at = 0;
for (; m < matches.length && matches[m]!.line === line; m++) {
const { start, end } = matches[m]!;
if (start > at) out.push(text.slice(at, start));
out.push(
<mark
key={m}
className={clsx(styles.match, m === current && styles.matchCurrent)}
data-find-current={m === current ? "" : undefined}
>
{text.slice(start, end)}
</mark>,
);
at = end;
}
if (at < text.length) out.push(text.slice(at));
});
return <Fragment>{out}</Fragment>;
}
function HeaderTable({ headers }: { headers: Array<[string, string]> }) {
return (
<table className={styles.headers}>
+29
Переглянути файл
@@ -0,0 +1,29 @@
import { Text } from "@codemirror/state";
import { describe, expect, it } from "vitest";
import { fileReferenceAt } from "./fileNavigation";
const doc = Text.of([
"POST http://x/api",
"",
"< ./body.json",
"> handler.js",
"> {% client.test('ok', () => {}); %}",
">> out/result.json",
"<> 2026-11-10T135253.200.json",
]);
const at = (line: number, col: number) => fileReferenceAt(doc, doc.line(line).from + col);
describe("fileReferenceAt", () => {
it("finds the file a body, handler, redirect or saved-response line names", () => {
expect(at(3, 4)).toMatchObject({ value: "./body.json", response: false });
expect(at(4, 3)).toMatchObject({ value: "handler.js", response: false });
expect(at(6, 5)).toMatchObject({ value: "out/result.json", response: false });
expect(at(7, 10)).toMatchObject({ value: "2026-11-10T135253.200.json", response: true });
});
it("ignores inline handler scripts, the marker itself and other lines", () => {
expect(at(5, 6)).toBeNull();
expect(at(7, 0)).toBeNull();
expect(at(1, 6)).toBeNull();
});
});
+44
Переглянути файл
@@ -0,0 +1,44 @@
/**
* Navigate › Declaration (⌘-click, ⌘B) on the file a `.http` line names: a request body
* (`< ./body.json`), a response handler script (`> handler.js`), a response redirect
* (`>> out.json`) and a saved response (`<> 2026-11-10T135253.200.json`). Saved responses
* resolve against `.idea/httpRequests/`, where IntelliJ IDEA writes them, then against the
* request file's folder.
*/
import type { Text } from "@codemirror/state";
import { docOf } from "@/editor/lsp/lsp";
import { registerNavigationProvider } from "@/editor/navigationProviders";
import { call, commands } from "@/platform/ipc";
import { HTTP_LANGUAGE } from "./editor";
/** The file named on the line at `offset`, as document offsets; `response` for a `<>` line. */
export function fileReferenceAt(
doc: Text,
offset: number,
): { value: string; from: number; to: number; response: boolean } | null {
const line = doc.lineAt(offset);
const m = /^(\s*(<>|>>!?|<|>)\s+)(\S(?:.*\S)?)\s*$/.exec(line.text);
// `> {% … %}` is an inline handler script, not a file.
if (!m || m[3]!.startsWith("{%")) return null;
const from = line.from + m[1]!.length;
const to = from + m[3]!.length;
return offset >= from && offset <= to ? { value: m[3]!, from, to, response: m[2] === "<>" } : null;
}
export function registerFileNavigation(): () => void {
return registerNavigationProvider(async (view, offset) => {
const doc = docOf(view);
if (!doc || doc.virtual || doc.language !== HTTP_LANGUAGE) return null;
const at = fileReferenceAt(view.state.doc, offset);
if (!at) return null;
const candidates = at.response ? [`/.idea/httpRequests/${at.value}`, at.value] : [at.value];
for (const value of candidates) {
const path = await call(commands.fsResolveReference(doc.path, value)).catch(() => null);
if (path) {
const start = { line: 0, col: 0 };
return { from: at.from, to: at.to, targets: [{ path, range: { start, end: start } }] };
}
}
return { from: at.from, to: at.to, targets: [] };
});
}
+2
Переглянути файл
@@ -17,6 +17,7 @@ import {
registerServiceView,
showServiceView,
} from "@/workbench/contributions";
import { registerFileNavigation } from "./fileNavigation";
import { registerVariableNavigation } from "./variableNavigation";
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
@@ -134,6 +135,7 @@ export function activate(): () => void {
const disposers = [
registerSpringEndpointProvider(),
registerVariableNavigation(),
registerFileNavigation(),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) resetHttpClient();
}),
+67
Переглянути файл
@@ -0,0 +1,67 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HttpExchange } from "@/platform/ipc";
vi.mock("./store", async (importOriginal) => ({
...(await importOriginal<typeof import("./store")>()),
refreshHistory: vi.fn(async () => {}),
}));
const { useHttpClient } = await import("./store");
const { HttpClientView } = await import("./HttpClientView");
const exchange: HttpExchange = {
entry: {
id: "e1",
timeMs: 0,
name: null,
method: "GET",
url: "http://x/api",
status: 200,
totalMs: 5,
file: null,
error: null,
},
request: { name: null, method: "GET", url: "http://x/api", headers: [], body: null },
response: {
status: 200,
statusText: "OK",
version: "HTTP/1.1",
headers: [["content-type", "text/plain"]],
contentType: "text/plain",
body: "alpha beta\nbeta gamma\nBeta",
size: 26,
truncated: false,
ttfbMs: 1,
totalMs: 5,
},
};
describe("HTTP response find", () => {
afterEach(() => useHttpClient.setState({ history: [], exchanges: {}, selected: null }));
it("opens a find bar with ⌘F in the response and marks the matches", () => {
Element.prototype.scrollIntoView ??= () => {};
useHttpClient.setState({ history: [exchange.entry], exchanges: { e1: exchange }, selected: "e1" });
render(<HttpClientView />);
const body = screen.getByLabelText("Response body");
fireEvent.keyDown(body, { key: "f", metaKey: true, ctrlKey: true });
const input = screen.getByLabelText("Find in response", { selector: "input" });
act(() => {
fireEvent.change(input, { target: { value: "beta" } });
});
const marks = body.querySelectorAll("mark");
expect([...marks].map((m) => m.textContent)).toEqual(["beta", "beta", "Beta"]);
expect(screen.getByText("1/3")).toBeTruthy();
expect(body.textContent).toBe("alpha beta\nbeta gamma\nBeta");
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("2/3")).toBeTruthy();
expect(
marks[1]!.hasAttribute("data-find-current") ||
body.querySelectorAll("mark")[1]!.hasAttribute("data-find-current"),
).toBe(true);
fireEvent.keyDown(input, { key: "Escape" });
expect(screen.queryByLabelText("Find in response", { selector: "input" })).toBeNull();
expect(body.querySelectorAll("mark")).toHaveLength(0);
});
});
+4 -2
Переглянути файл
@@ -66,7 +66,7 @@ import {
} from "@/platform/projectPresentation";
import { relativePath } from "@/platform/project";
import { folderKind } from "./folderKind";
import { PROJECT_TYPES } from "./projectType";
import { PROJECT_TYPES } from "@/platform/projectType";
import { useHomeDir } from "./home";
import { keyLike, memberNodes, nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import {
@@ -286,7 +286,9 @@ const Row = memo(function Row({
const root = useProjectTree((s) => s.root);
const detected = useDetectedRoots();
const build = useProjectPresentation();
const projectType = useProjectTree((s) => (node.root && !node.icon ? s.projectTypes.get(node.path) : undefined));
const projectType = useProjectTree((s) =>
s.members && node.root && !node.icon ? s.projectTypes.get(node.path) : undefined,
);
const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs);
const expanded = useProjectTree((s) => node.dir && s.expanded.has(node.key));
const holdsOpen = useProjectTree((s) => node.dir && holdsOpenFile(node.path, s.openFiles));
+8 -2
Переглянути файл
@@ -3,7 +3,7 @@ import { executeAction, getAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { canRevealInFileManager, fileManagerName } from "@/platform/fileManager";
import { formatBinding, hostBinding, isMacHost, shortcutLabel } from "@/platform/keymap";
import { relativePath, useProject } from "@/platform/project";
import { projectFolder, relativePath, useProject } from "@/platform/project";
import { currentDetectedRoots, currentRoots } from "@/platform/projectRoots";
import type { MenuItem } from "@/ui";
import { menuGroupItems, toolWindowService, type MenuGroupContext } from "@/workbench/contributions";
@@ -31,6 +31,7 @@ import {
vcsArg,
} from "./operations";
import { isSameOrInside } from "./paths";
import { useProjectTree } from "./store";
import { tx } from "@/platform/i18n";
/** Keys handled by the project view itself (the default keymap has no entries for them). */
@@ -105,7 +106,12 @@ function showProjectView() {
/** File kinds contributed to the `new` menu group (templates, HTTP Request, Scratch File…). */
function contributedNewItems(parent: string | null): MenuItem[] {
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true] } : { paths: [] });
const owner = parent ? projectFolder(useProject.getState().project, parent) : null;
const projectType =
parent && owner && isSameOrInside(parent, owner.root)
? useProjectTree.getState().projectTypes.get(owner.root)
: undefined;
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true], projectType } : { paths: [] });
return items.length ? [{ kind: "separator" }, ...items] : [];
}
+1 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { hasIcon } from "@/ui/icons";
import { detectProjectType, isProjectMarker, PROJECT_TYPES } from "./projectType";
import { detectProjectType, isProjectMarker, PROJECT_TYPES } from "@/platform/projectType";
describe("project types", () => {
it.each([
+3 -4
Переглянути файл
@@ -45,11 +45,10 @@ describe("automatic project types", () => {
expect([...useProjectTree.getState().projectTypes.values()]).toEqual(Array(9).fill("go"));
expect(backend.fsReadDir).toHaveBeenCalledTimes(9);
});
it("leaves a single project root alone", async () => {
it("detects the type for New menu templates in a single project", async () => {
initProjectTree("/app");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(backend.fsReadDir).not.toHaveBeenCalled();
expect(useProjectTree.getState().projectTypes.size).toBe(0);
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/app")).toBe("node"));
expect(backend.fsReadDir).toHaveBeenCalledExactlyOnceWith("/app");
});
it("refreshes only affected markers and clears a removed project type", async () => {
initProjectTree("/ws", "Workspace", ["/a", "/b"]);
+5 -4
Переглянути файл
@@ -1,6 +1,6 @@
/** Bounded, read-only detection for workspace members, collapsed ones included; a single project root keeps its own icon. */
/** Bounded, read-only detection for project roots, including collapsed workspace members. */
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { detectProjectType, isProjectMarker } from "./projectType";
import { detectProjectType, isProjectMarker } from "@/platform/projectType";
import { useProjectTree } from "./store";
async function detect(root: string) {
@@ -16,8 +16,9 @@ export function initProjectTypes(): () => void {
let reset: (() => void) | undefined;
const load = () => {
reset?.();
const { root, members: roots } = useProjectTree.getState();
if (!root || !roots || !isTauri()) return;
const { root, members } = useProjectTree.getState();
if (!root || !isTauri()) return;
const roots = members ?? [root];
const pending = new Set(roots);
const versions = new Map<string, number>();
let active = 0;
+1 -1
Переглянути файл
@@ -36,7 +36,7 @@ import {
type TreeOptions,
} from "./model";
import { projectFs } from "./fs";
import { PROJECT_TYPES, type ProjectType } from "./projectType";
import { PROJECT_TYPES, type ProjectType } from "@/platform/projectType";
import {
projectViewRoots,
useProjectViewRoots,
+17 -2
Переглянути файл
@@ -6,13 +6,15 @@ import { registerEditorContribution } from "@/editor/api";
import { executeAction, executeActionOrThrow, getAction, registerActions } from "@/platform/actions";
import { getContext, setContext, useContextKeys } from "@/platform/context";
import { call, commands, onIdeEvent, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { projectFolder, useProject } from "@/platform/project";
import { isSameOrInside } from "@/platform/paths";
import { agentRunSettings, onWorkflowSettingsChange } from "@/platform/workflowSettings";
import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui";
import {
menuGroupItems,
useDebugRunSessions,
notifications,
provideRunConfigurationLookup,
provideRunOutput,
registerMenuGroup,
registerOverlay,
@@ -44,6 +46,7 @@ import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { runDirectory } from "./navigate";
import {
cleanupRemovedProject,
registerRunServiceActions,
@@ -348,7 +351,7 @@ function useRunningCount(): number | null {
}
const CONFIG_FILES =
/(?:^|\/)(?:\.openidea\/run\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.(?:mod|work)|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile|CMakeLists\.txt|CMakeCache\.txt|index-[^/]+\.json)$/;
/(?:^|\/)(?:\.openidea\/run(?:\.local)?\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.(?:mod|work)|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile|CMakeLists\.txt|CMakeCache\.txt|index-[^/]+\.json)$/;
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleReload() {
@@ -765,6 +768,18 @@ export function activate(): () => void {
disposers.push(() => setBeforeLaunchRunner(null));
provideRunOutput({ subscribe: subscribeOutput });
disposers.push(() => provideRunOutput(undefined));
provideRunConfigurationLookup((kind, directory) => {
const { project } = useProject.getState();
if (!project?.trusted) return false;
const owner = projectFolder(project, directory);
if (!owner || !isSameOrInside(directory, owner.root)) return false;
return useRun.getState().configs.some((config) => {
if (config.kind !== kind) return false;
const path = runDirectory(config.module ?? config.cwd);
return isSameOrInside(path, owner.root) && projectFolder(project, path)?.root === owner.root;
});
});
disposers.push(() => provideRunConfigurationLookup(undefined));
if (hasProject()) void loadConfigs();
// Reload when run.toml or build files change on disk (cheap; detection is fast).
+1 -1
Переглянути файл
@@ -1132,7 +1132,7 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget)
}
if (/\.zig$/.test(input.path)) {
// `--test-filter` matches a substring of the test's full name.
return zigFile(input, env, s, { name: target.name, filter: target.name.replace(/\\(.)/g, "$1") });
return zigFile(input, env, s, { name: target.name, filter: target.name });
}
const rust = /\.rs$/.test(input.path);
if (rust && !(s.inProject && cargoOwner(rel, input.configs).length)) {
+2 -2
Переглянути файл
@@ -131,8 +131,8 @@ function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: {
showContextMenu(at, targetMenuItems(target, path, view.state.doc.toString()));
}
/** Results of frameworks that root their test tree at the file (jest/vitest, pytest) also match by file. */
const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?)$/.test(path);
/** Results of frameworks that root their test tree at the file (jest/vitest, pytest, Zig's module) also match by file. */
const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?|zig)$/.test(path);
/** Last test result of a gutter target (IDEA: green / red run icons after a test run). */
export function gutterTestState(target: RunTarget, path: string): TestState | null {
+2 -1
Переглянути файл
@@ -361,7 +361,8 @@ export function zigTargets(content: string): RunTarget[] {
if (/^pub\s+fn\s+main\s*\(/.test(line)) out.push({ line: i, kind: "main", name: "main", path: [] });
const test = /^\s*test\s+(?:"((?:[^"\\]|\\.)*)"|([A-Za-z_]\w*))\s*\{/.exec(line);
if (test) {
const name = test[1] ?? test[2]!;
// Unescape for display and for `--test-filter`: the runner reports the raw name.
const name = test[1]?.replace(/\\(.)/g, "$1") ?? test[2]!;
out.push({ line: i, kind: "test", name, path: [name] });
}
});
+32 -2
Переглянути файл
@@ -8,12 +8,23 @@ import { phpunitParser, withTeamcity } from "./phpunit";
import { pytestParser } from "./pytest";
import { rubyParser } from "./ruby";
import { unittestParser } from "./unittest";
import { zigParser } from "./zig";
import type { TestEvent, TestOutputParser } from "./types";
export type { TestEvent, TestOutputParser, TestStatus } from "./types";
export { parseDuration } from "./types";
export { TestResults, nodeOutput, nodeStatus, type TestNode } from "./results";
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, rubyParser, unittestParser };
export {
cargoParser,
goParser,
jsParser,
jvmParser,
phpunitParser,
pytestParser,
rubyParser,
unittestParser,
zigParser,
};
/** Runs several parsers side by side (unknown tool: e.g. `make test`). */
export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
@@ -23,7 +34,18 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
};
}
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "ruby" | "dotnet" | "auto";
export type Framework =
| "cargo"
| "js"
| "pytest"
| "unittest"
| "phpunit"
| "go"
| "jvm"
| "ruby"
| "dotnet"
| "zig"
| "auto";
export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args">): Framework {
const cmd = [config.command, ...config.args].join(" ");
@@ -54,6 +76,8 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
case "rake":
case "rspec":
return "ruby";
case "zig":
return "zig";
}
if (/(^|[/\\])dotnet(?:\.exe)?$/i.test(config.command) && config.args[0] === "test") return "dotnet";
if (/\bcargo\b/.test(cmd)) return "cargo";
@@ -98,6 +122,10 @@ export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" |
};
}
if (frameworkFor(config) === "phpunit") return { ...config, args: withTeamcity(config.args) };
if (frameworkFor(config) === "zig" && config.args[0] === "build") {
// `zig build test` prints nothing for a passing (or cached) run; the summary reports the counts.
return config.args.includes("--summary") ? config : { ...config, args: [...config.args, "--summary", "all"] };
}
if (frameworkFor(config) === "unittest") {
const chosen = config.args.some((a) => /^(-v|--verbosity)(=?\d)?$/.test(a));
return chosen ? config : { ...config, args: [...config.args, "-v", "2"] };
@@ -131,6 +159,8 @@ export function createTestParser(config: Pick<RunConfig, "kind" | "command" | "a
return jvmParser();
case "ruby":
return rubyParser();
case "zig":
return zigParser();
default:
return combinedParser([
cargoParser(),
+17
Переглянути файл
@@ -180,6 +180,23 @@ describe("rerunFailedConfig", () => {
).toEqual(["test", "-Dtest=com.a.FooTest#bar+baz,com.a.BarTest"]);
});
it("zig: --test-filter per failed test for `zig test`, null for build-based runs", async () => {
expect(
(await rerunFailedConfig(cfg("zig", "zig", ["test", "src/calc.zig"]), [
["calc", "adds"],
["calc", "subs"],
]))!.args,
).toEqual(["test", "src/calc.zig", "--test-filter", "adds", "--test-filter", "subs"]);
// Existing filters are replaced, not stacked.
expect(
(await rerunFailedConfig(cfg("zig", "zig", ["test", "src/calc.zig", "--test-filter", "adds"]), [
["calc", "subs"],
]))!.args,
).toEqual(["test", "src/calc.zig", "--test-filter", "subs"]);
// `zig build` forwards no args to the test binary, so a build-based run can't narrow.
expect(await rerunFailedConfig(cfg("zig", "zig", ["build", "test"]), [["tally", "subs"]])).toBeNull();
});
it("returns null when nothing failed or the framework is unknown", async () => {
expect(await rerunFailedConfig(cfg("cargo", "cargo", ["test"]), [])).toBeNull();
expect(await rerunFailedConfig(cfg("make", "make", ["test"]), [["x"]])).toBeNull();
+15
Переглянути файл
@@ -249,6 +249,21 @@ function builtinTestsConfig(config: RunConfig, paths: string[][], name: string):
case "go":
args = goArgs(config.args, paths, config.params?.scope === "file" ? config.params.target : undefined);
break;
case "zig": {
// Only a standalone `zig test <file>` can filter: `zig build` forwards no args to the test binary.
const file = config.args[0] === "test" ? config.args[1] : undefined;
const names = unique(paths.map((p) => p[p.length - 1]!).filter(Boolean));
args =
file && names.length
? [
config.args[0]!,
file,
...dropFlag(config.args.slice(2), ["--test-filter"]),
...names.flatMap((n) => ["--test-filter", n]),
]
: null;
break;
}
case "jvm":
args = jvmArgs(config, paths);
break;
+13
Переглянути файл
@@ -58,6 +58,19 @@ describe("test states for the run gutter", () => {
expect(testState({ path: ["QTests", "test_a"], file: "/work/app/città/tests.py" })).toBe("passed");
});
it("matches Zig results rooted at the module stem of the file", () => {
run([
{ type: "result", path: ["tally", "adds"], status: "passed" },
{ type: "result", path: ["tally", "subs"], status: "failed" },
{ type: "result", path: ["other", "adds"], status: "failed" },
]);
expect(testState({ path: ["adds"], file: "src/tally.zig" })).toBe("passed");
expect(testState({ path: ["subs"], file: "/work/app/src/tally.zig" })).toBe("failed");
// Same-named tests of another module don't bleed over.
expect(testState({ path: ["adds"], file: "src/other.zig" })).toBe("failed");
expect(testState({ path: ["adds"], file: "src/tally/main.zig" })).toBeNull();
});
it("newer runs win and listeners hear about changes", () => {
const listener = vi.fn();
const off = onTestStatesChanged(listener);
+4
Переглянути файл
@@ -69,6 +69,8 @@ function segmentMatches(result: string, target: string): boolean {
const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, "");
const stemOf = (p: string) => p.replace(/.*\//, "").replace(/\.[^.]+$/, "");
function sameFile(resultRoot: string, relPath: string): boolean {
const b = cleanPath(relPath);
// unittest / Django results are rooted at the dotted module (`polls.tests`).
@@ -80,6 +82,8 @@ function sameFile(resultRoot: string, relPath: string): boolean {
const module = resultRoot.replace(/\./g, "/");
return [`${module}.py`, `${module}/__init__.py`].some((f) => b === f || b.endsWith(`/${f}`));
}
// Zig results are rooted at the module stem (`tally` for `src/tally.zig`).
if (/^[\p{L}_][\p{L}\p{N}_]*$/u.test(resultRoot) && stemOf(b) === resultRoot) return true;
const a = cleanPath(resultRoot);
return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`);
}
+174
Переглянути файл
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import { createTestParser, frameworkFor, parseAll, withTestReporting } from "./index";
import { TestResults, type TestOutputParser } from "./index";
import { zigParser } from "./zig";
function results(parser: TestOutputParser, text: string) {
const r = new TestResults();
r.apply(parseAll(parser, text));
return r;
}
function leaf(r: TestResults, ...path: string[]) {
return r.get(path.join("\u0000"));
}
const FAILING_RUN = `1/3 calc.test.adds two numbers...OK
2/3 calc.test.fails on purpose...expected 7, found 5
FAIL (TestExpectedEqual)
/Users/me/.zvm/0.16.0/lib/std/testing.zig:118:17: 0x10262da43 in expectEqualInner__anon_36302 (test)
return error.TestExpectedEqual;
^
/Users/me/.zvm/0.16.0/lib/std/testing.zig:83:5: 0x10262dae3 in expectEqual (test)
return expectEqualInner(T, expected, actual);
^
/work/app/src/calc.zig:12:5: 0x10262db23 in test.fails on purpose (test)
try std.testing.expectEqual(@as(u32, 7), add(2, 3));
^
3/3 calc.test.printing helper...boom
OK
2 passed; 0 skipped; 1 failed.
error: the following test command failed with exit code 1:
/Users/me/.cache/zig/o/91f9863b4e219205507fbe58005bc1c5/test --seed=0xebfccadb
`;
describe("zig test", () => {
it("builds the module's tests with failure output and the error's location", () => {
const r = results(zigParser(), FAILING_RUN);
expect(r.roots.map((n) => n.name)).toEqual(["calc"]);
expect(leaf(r, "calc", "adds two numbers")?.own).toBe("passed");
expect(leaf(r, "calc", "printing helper")?.own).toBe("passed");
expect(leaf(r, "calc", "printing helper")?.output).toEqual(["boom"]);
const failed = leaf(r, "calc", "fails on purpose")!;
expect(failed.own).toBe("failed");
expect(failed.output[0]).toBe("expected 7, found 5");
expect(failed.output).toContain("try std.testing.expectEqual(@as(u32, 7), add(2, 3));");
expect(failed.location).toEqual({
path: "/work/app/src/calc.zig",
line: 12,
col: 5,
resolve: "path",
});
expect(r.summary).toEqual({ passed: 2, skipped: 0, failed: 1 });
expect(r.counts()).toMatchObject({ passed: 2, failed: 1 });
});
it("parses a quiet passing run and a filtered single test", () => {
const r = results(
zigParser(),
`1/3 tally.test.total adds the prices...OK
2/3 tally.test.total of nothing is zero...OK
3/3 tally.test.format pads the cents...OK
All 3 tests passed.
`,
);
expect(leaf(r, "tally", "total adds the prices")?.own).toBe("passed");
expect(r.counts()).toMatchObject({ passed: 3, failed: 0 });
const filtered = results(
zigParser(),
`1/1 calc.test.adds two numbers...OK
All 1 tests passed.
`,
);
expect(filtered.counts()).toMatchObject({ passed: 1 });
// A filter matching nothing leaves no nodes but reports the counts.
expect(results(zigParser(), "All 0 tests passed.\n").counts()).toMatchObject({ passed: 0 });
});
it("reports skips, and status printed after output without a newline", () => {
const r = results(
zigParser(),
`1/2 calc.test.will skip...SKIP
2/2 calc.test.greets...helloOK
1 passed; 1 skipped; 0 failed.
`,
);
expect(leaf(r, "calc", "will skip")?.own).toBe("skipped");
expect(leaf(r, "calc", "greets")?.own).toBe("passed");
expect(leaf(r, "calc", "greets")?.output).toEqual(["hello"]);
expect(r.summary).toEqual({ passed: 1, skipped: 1, failed: 0 });
});
it("parses zig build test failure blocks (the build runner's captured output)", () => {
const r = results(
zigParser(),
`test
+- run test 2 pass, 1 fail (3 total)
error: 'tally.test.total of nothing is zero' failed:
expected 1, found 0
/Users/me/.zvm/0.16.0/lib/std/testing.zig:94:17: 0x102d48893 in expectEqualInner__func_1083 (test)
return error.TestExpectedEqual;
^
/Users/me/.zvm/0.16.0/lib/std/testing.zig:58:5: 0x102d48937 in expectEqual (test)
return expectEqualInner(T, expected, actual);
^
/work/app/src/tally.zig:22:5: 0x102d48977 in test.total of nothing is zero (test)
try std.testing.expectEqual(@as(u32, 1), total(&.{}));
^
failed command: ./.zig-cache/o/b84ba4b8338e114a2e03f2758f6ca155/test --cache-dir=./.zig-cache --seed=0x7689fac0 --listen=-
Build Summary: 1/3 steps succeeded (1 failed); 2/3 tests passed (1 failed)
test transitive failure
+- run test 2 pass, 1 fail (3 total)
+- compile test debug native success 790ms MaxRSS:249M
`,
);
// The build runner reports passing tests only as counts; failures get nodes.
const failed = leaf(r, "tally", "total of nothing is zero")!;
expect(failed.own).toBe("failed");
expect(failed.output[0]).toBe("expected 1, found 0");
expect(failed.output).toContain("try std.testing.expectEqual(@as(u32, 1), total(&.{}));");
expect(failed.location).toEqual({ path: "/work/app/src/tally.zig", line: 22, col: 5, resolve: "path" });
expect(r.counts()).toMatchObject({ passed: 2, failed: 1 });
});
it("parses a passing zig build test --summary all (counts only, no nodes)", () => {
const r = results(
zigParser(),
`Build Summary: 3/3 steps succeeded; 3/3 tests passed
test success
+- run test 3 pass (3 total) 2ms MaxRSS:2M
+- compile test Debug native cached 36ms MaxRSS:32M
`,
);
expect(r.roots).toEqual([]);
expect(r.counts()).toMatchObject({ passed: 3, failed: 0 });
});
it("a failure block left open at the end of the output still gets its location", () => {
const r = results(
zigParser(),
`error: 'calc.test.breaks' failed:
/work/app/src/calc.zig:4:5: 0x102 in test.breaks (test)
`,
);
const failed = leaf(r, "calc", "breaks")!;
expect(failed.own).toBe("failed");
expect(failed.location).toEqual({ path: "/work/app/src/calc.zig", line: 4, col: 5, resolve: "path" });
});
});
describe("zig framework selection", () => {
const test = (args: string[]) => ({ kind: "zig", command: "zig", args }) as const;
it("selects the zig parser for zig test configurations", () => {
expect(frameworkFor(test(["build", "test"]))).toBe("zig");
expect(frameworkFor(test(["test", "src/calc.zig", "--test-filter", "adds"]))).toBe("zig");
const events = createTestParser(test(["test", "calc.zig"])).feed("1/1 calc.test.adds two numbers...OK");
expect(events).toEqual([
{ type: "start", path: ["calc", "adds two numbers"] },
{ type: "result", path: ["calc", "adds two numbers"], status: "passed" },
]);
});
it("adds --summary all to zig build test runs so passing runs report counts", () => {
expect(withTestReporting(test(["build", "test"]))).toMatchObject({ args: ["build", "test", "--summary", "all"] });
expect(withTestReporting(test(["build", "test", "--summary", "none"]))).toMatchObject({
args: ["build", "test", "--summary", "none"],
});
// A standalone `zig test` always prints per-test lines.
expect(withTestReporting(test(["test", "calc.zig"]))).toMatchObject({ args: ["test", "calc.zig"] });
});
});
+149
Переглянути файл
@@ -0,0 +1,149 @@
/** `zig test` (the runner's progress lines) and `zig build test` (failure blocks from the build runner). */
import type { TestEvent, TestOutputParser } from "./types";
// `1/3 tally.test.total adds the prices...OK` — the status lands here when the test printed nothing.
const PROGRESS_RE = /^(\d+)\/(\d+)\s+(.+?)\.\.\.(.*)$/;
// `…/src/tally.zig:22:5: 0x… in test.total of nothing is zero (test)` — error-return trace frames.
const FRAME_RE = /^\s*(.+\.zig):(\d+):(\d+): 0x[0-9a-fA-F]+ in /;
const SUMMARY_RE = /^(\d+) passed(?:; (\d+) skipped)?(?:; (\d+) failed)?\.?$/;
const BUILD_SUMMARY_RE = /^Build Summary: .*?(\d+)\/\d+ tests passed(?: \((\d+) failed\))?/;
const STEP_RE = /^\s*\+- run [^()]*?(\d+) pass(?:, (\d+) skip)?(?:, (\d+) fail)? \(\d+ total\)/;
const FAILED_RE = /^error: '(.+)' failed:$/;
// Frames inside the toolchain's standard library (`<zig>/lib/std/…`; stage3 builds `/lib/zig/std/…`).
const STD_LIB_RE = /\/lib\/(?:zig\/)?std\//;
interface Frame {
path: string;
line: number;
col: number;
}
interface Awaiting {
path: string[];
frames: Frame[];
}
/** `tally.test.total adds the prices` → module `tally`, name `total adds the prices` (the last `.test.` wins). */
function splitName(full: string): { stem: string | null; name: string } {
const at = full.lastIndexOf(".test.");
return at < 0 ? { stem: null, name: full } : { stem: full.slice(0, at), name: full.slice(at + 6) };
}
/**
* The runner writes the status right after whatever the test printed, on the same line
* (`…boomOK`) or, when the print ended in a newline, alone on the next one.
*/
function statusOf(text: string): { status: "passed" | "skipped" | "failed"; output: string } | null {
const trimmed = text.replace(/\s+$/, "");
if (trimmed.endsWith("OK")) return { status: "passed", output: trimmed.slice(0, -2) };
if (trimmed.endsWith("SKIP")) return { status: "skipped", output: trimmed.slice(0, -4) };
const failed = /^(.*)FAIL(?: \([^)]*\))?$/.exec(trimmed);
return failed ? { status: "failed", output: failed[1]! } : null;
}
export function zigParser(): TestOutputParser {
let pending: Awaiting | null = null;
let block: Awaiting | null = null;
let trace: Awaiting | null = null;
const pathOf = (stem: string | null, name: string) => (stem ? [stem, name] : [name]);
const location = (events: TestEvent[], path: string[], frames: Frame[]) => {
// The error originates in the first non-library frame; the outermost is the fallback.
const frame = frames.find((f) => !STD_LIB_RE.test(f.path)) ?? frames[frames.length - 1];
if (frame)
events.push({
type: "location",
path,
location: { path: frame.path, line: frame.line, col: frame.col, resolve: "path" },
});
};
/** A failed test's error-return trace: indented source lines and frames, up to the next unindented line. */
const traced = (events: TestEvent[], text: Awaiting, line: string): boolean => {
const m = FRAME_RE.exec(line);
if (m) {
text.frames.push({ path: m[1]!, line: Number(m[2]), col: Number(m[3]) });
return true;
}
if (line === "" || /^\s/.test(line)) {
if (line.trim()) events.push({ type: "output", path: text.path, line: line.trim() });
return true;
}
location(events, text.path, text.frames);
return false;
};
return {
feed(line) {
const events: TestEvent[] = [];
let m: RegExpExecArray | null;
// Inside a `zig build` failure block: indented (or blank) lines are the test's captured output.
if (block) {
if (traced(events, block, line)) return events;
block = null;
}
// After a `zig test` FAIL: the error-return trace follows on the next lines.
if (trace) {
if (traced(events, trace, line)) return events;
trace = null;
}
if (pending && (m = /^(OK|SKIP|FAIL(?: \([^)]*\))?)$/.exec(line))) {
const status = m[1] === "OK" ? "passed" : m[1] === "SKIP" ? "skipped" : "failed";
events.push({ type: "result", path: pending.path, status });
// A failure's trace follows on the next lines; the summary must not become its output.
trace = status === "failed" ? pending : null;
pending = null;
return events;
}
if (pending && (m = FRAME_RE.exec(line))) {
pending.frames.push({ path: m[1]!, line: Number(m[2]), col: Number(m[3]) });
return events;
}
if ((m = PROGRESS_RE.exec(line))) {
const { stem, name } = splitName(m[3]!);
pending = { path: pathOf(stem, name), frames: [] };
events.push({ type: "start", path: pending.path });
const status = statusOf(m[4]!);
if (status) {
if (status.output) events.push({ type: "output", path: pending.path, line: status.output });
events.push({ type: "result", path: pending.path, status: status.status });
trace = status.status === "failed" ? pending : null;
pending = null;
} else if (m[4]) {
events.push({ type: "output", path: pending.path, line: m[4] });
}
return events;
}
if ((m = FAILED_RE.exec(line))) {
const { stem, name } = splitName(m[1]!);
block = { path: pathOf(stem, name), frames: [] };
events.push({ type: "result", path: block.path, status: "failed" });
return events;
}
if (pending) {
// Anything else before the status is the running test's output (prints, panic text).
events.push({ type: "output", path: pending.path, line });
return events;
}
if ((m = SUMMARY_RE.exec(line))) {
events.push({ type: "summary", passed: Number(m[1]), skipped: Number(m[2] ?? 0), failed: Number(m[3] ?? 0) });
} else if ((m = BUILD_SUMMARY_RE.exec(line))) {
events.push({ type: "summary", passed: Number(m[1]), failed: Number(m[2] ?? 0) });
} else if ((m = STEP_RE.exec(line))) {
events.push({ type: "summary", passed: Number(m[1]), skipped: Number(m[2] ?? 0), failed: Number(m[3] ?? 0) });
}
return events;
},
end() {
const events: TestEvent[] = [];
if (trace) {
location(events, trace.path, trace.frames);
trace = null;
}
if (block) {
location(events, block.path, block.frames);
block = null;
}
return events;
},
};
}
+28 -4
Переглянути файл
@@ -11,6 +11,7 @@ import {
registerMenuGroup,
registerOverlay,
registerSettingsPage,
hasRunConfiguration,
type MenuGroupContext,
} from "@/workbench/contributions";
import { insertLiveTemplate, surroundTarget, surroundWith, templateEditorExtension } from "./editor";
@@ -24,6 +25,7 @@ import { tagAtCaret, updateTag } from "./emmet/editor";
import { loadEmmet } from "./emmet/model";
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
import { tx } from "@/platform/i18n";
import type { ProjectType } from "@/platform/projectType";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
@@ -92,6 +94,20 @@ function newFileAction(t: FileTemplate): Action {
};
}
// Generic templates and user or plugin templates have no project type restriction; nor do the web
// ones (JavaScript, TypeScript), which IntelliJ IDEA Ultimate offers in every project.
const templateProjectTypes: Partial<Record<string, readonly ProjectType[]>> = {
rust: ["cargo"],
python: ["python"],
jupyter: ["python"],
go: ["go"],
"go-mod": ["go"],
php: ["php"],
"php-class": ["php"],
java: ["maven", "gradle"],
kotlin: ["maven", "gradle"],
};
/** New popup / New › entries: the enabled templates ("File" is the project view's own entry), then From Template…. */
function registerNewMenuGroups(): () => void {
const arg = (ctx: MenuGroupContext) => (ctx.paths[0] ? { directory: ctx.paths[0] } : undefined);
@@ -100,16 +116,24 @@ function registerNewMenuGroups(): () => void {
id: "templates.new",
menus: ["new"],
order: 10,
items: (_menu, ctx) =>
fileTemplates()
.filter((t) => t.enabled !== false && t.id !== "file")
items: (_menu, ctx) => {
const directory = ctx.paths[0];
const tomcat = directory !== undefined && hasRunConfiguration("tomcat", directory);
return fileTemplates()
.filter((t) => {
if (t.enabled === false || t.id === "file") return false;
if (t.id === "tomcat-context") return tomcat;
const types = templateProjectTypes[t.id];
return !types || !ctx.projectType || types.includes(ctx.projectType);
})
.map((t) => ({
kind: "action",
action: `templates.new.${t.id}`,
arg: arg(ctx),
label: t.name,
icon: t.icon,
})),
}));
},
}),
registerMenuGroup({
id: "templates.newFromTemplate",
+8 -1
Переглянути файл
@@ -15,6 +15,7 @@ import { Button, Spinner, T } from "@/ui";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { blameSlot, toggleAnnotations } from "./blame";
import { languageFor, viewerBase } from "./cm";
import { ideaDiffConfig } from "./diffOptions";
import { confirmDialog } from "./dialogs";
import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator";
import { syncMergeScroll } from "./mergeScroll";
@@ -92,6 +93,10 @@ const writeAutoApply = (on: boolean) => {
}
};
// Each side against the base is compared line by line, as in the diff viewer. The default diff
// gives up on long files and marks the whole file as one changed block.
const SIDE_DIFF = ideaDiffConfig("none", "words");
export function MergeEditor({ input }: { input: EditorInput }) {
const { path } = input.data as MergeData;
const [state, setState] = useState<{
@@ -176,7 +181,9 @@ export function MergeEditor({ input }: { input: EditorInput }) {
EditorView.updateListener.of((u) => {
if (u.selectionSet) refreshMergeActions(actionHost);
}),
state.base !== null ? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true }) : [],
state.base !== null
? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true, diffConfig: SIDE_DIFF })
: [],
sideControls(which, controls),
mergeStripe(which),
mergeTheme,
+8
Переглянути файл
@@ -7,6 +7,8 @@ describe("remoteHosting", () => {
["https://github.example.com/o/r", "GitHub", "github"],
["https://gitee.com/o/r.git", "Gitee", "github"],
["[email protected]:o/r.git", "Bitbucket", "bitbucket"],
["[email protected]:team/repo.git", "Bitbucket", "bitbucket"],
["[email protected]:o/r.git", "GitHub", "github"],
["ssh://[email protected]:2222/group/sub/r.git", "GitLab", "gitlab"],
["[email protected]:group/r.git", "git.example.com", "gitlab"],
])("%s → %s", (url, name, kind) => {
@@ -43,4 +45,10 @@ describe("fileWebUrl", () => {
"https://bitbucket.org/o/r/src/main/a.py#lines-5",
);
});
it("opens a Bitbucket Cloud SSH clone on bitbucket.org, not the SSH front host (#3372)", () => {
expect(fileWebUrl("[email protected]:team/repo.git", "main", "a.py", { line: 4 })).toBe(
"https://bitbucket.org/team/repo/src/main/a.py#lines-5",
);
});
});
+106
Переглянути файл
@@ -0,0 +1,106 @@
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { MergeOptions } from "@/bindings";
const mocks = vi.hoisted(() => ({
merges: [] as Array<{ repo: string | null; branches: string[]; options: MergeOptions }>,
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data });
const head = (repo: string | null) => (repo === "/p/b" ? "dev" : "main");
const local = (repo: string | null) =>
repo === "/p/b" ? ["dev", "feature-b", "shared"] : ["main", "feature-a", "shared"];
return {
...real,
commands: {
...real.commands,
vcsBranchInfo: (repo: string | null) =>
ok({ head: head(repo), headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null }),
vcsBranches: (repo: string | null) =>
ok(local(repo).map((name) => ({ name, remote: false, current: name === head(repo) }))),
vcsMergeWith: (repo: string | null, branches: string[], options: MergeOptions) => {
mocks.merges.push({ repo, branches, options });
return ok(null);
},
},
};
});
import { VcsDialogHost } from "../dialogs";
import { showMergeDialog } from "../gitMenuDialogs";
import { useVcs } from "../store";
beforeEach(() => {
mocks.merges = [];
useVcs.setState({
root: "/p",
roots: ["/p/a", "/p/b"],
repo: "/p/a",
branch: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null },
available: false,
});
});
const pick = async (combo: string, option: string) => {
fireEvent.click(screen.getByRole("button", { name: combo }));
fireEvent.click(await screen.findByRole("option", { name: option }));
};
it("offers no repository choice when the project has one repository", async () => {
useVcs.setState({ roots: ["/p"], repo: null });
render(<VcsDialogHost />);
await act(() => showMergeDialog());
expect(await screen.findByText("Merge into main")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Repository" })).toBeNull();
await pick("Branch to merge", "feature-a");
fireEvent.click(screen.getByRole("button", { name: "Merge" }));
await waitFor(() => expect(mocks.merges).toHaveLength(1));
expect(mocks.merges[0]).toMatchObject({ repo: null, branches: ["feature-a"] });
});
it("merges into the repository picked in the dialog, offering that repository's branches", async () => {
render(<VcsDialogHost />);
await act(() => showMergeDialog());
expect(await screen.findByText("Merge into main")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Repository" })).toHaveTextContent("a");
await pick("Repository", "b");
expect(await screen.findByText("Merge into dev")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Branch to merge" }));
expect(await screen.findByRole("option", { name: "feature-b" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "feature-a" })).toBeNull();
// The current branch of the picked repository is not offered.
expect(screen.queryByRole("option", { name: "dev" })).toBeNull();
fireEvent.click(screen.getByRole("option", { name: "feature-b" }));
fireEvent.click(screen.getByRole("button", { name: "Merge" }));
await waitFor(() => expect(mocks.merges).toHaveLength(1));
expect(mocks.merges[0]).toMatchObject({ repo: "/p/b", branches: ["feature-b"] });
});
it("clears the chosen branch when another repository is picked", async () => {
render(<VcsDialogHost />);
await act(() => showMergeDialog());
expect(await screen.findByText("Merge into main")).toBeInTheDocument();
// A branch name both repositories have still has to be chosen again for the new one.
await pick("Branch to merge", "shared");
expect(screen.getByRole("button", { name: "Merge" })).toBeEnabled();
await pick("Repository", "b");
expect(await screen.findByText("Merge into dev")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Merge" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Branch to merge" })).not.toHaveTextContent("shared");
// Picking the repository that is already selected keeps the choice.
await pick("Branch to merge", "shared");
await pick("Repository", "b");
expect(screen.getByRole("button", { name: "Merge" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mocks.merges).toEqual([]);
});
+110
Переглянути файл
@@ -0,0 +1,110 @@
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { RebaseOptions } from "@/bindings";
const mocks = vi.hoisted(() => ({
rebases: [] as Array<{ repo: string | null; upstream: string; options: RebaseOptions }>,
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data });
const head = (repo: string | null) => (repo === "/p/b" ? "dev" : "main");
const local = (repo: string | null) =>
repo === "/p/b" ? ["dev", "feature-b", "shared"] : ["main", "feature-a", "shared"];
return {
...real,
commands: {
...real.commands,
vcsBranchInfo: (repo: string | null) =>
ok({ head: head(repo), headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null }),
vcsBranches: (repo: string | null) =>
ok(local(repo).map((name) => ({ name, remote: false, current: name === head(repo) }))),
vcsRebaseWith: (repo: string | null, upstream: string, options: RebaseOptions) => {
mocks.rebases.push({ repo, upstream, options });
return ok(null);
},
},
};
});
import { VcsDialogHost } from "../dialogs";
import { showRebaseDialog } from "../gitMenuDialogs";
import { useVcs } from "../store";
beforeEach(() => {
mocks.rebases = [];
useVcs.setState({
root: "/p",
roots: ["/p/a", "/p/b"],
repo: "/p/a",
branch: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null },
available: false,
});
});
const pick = async (combo: string, option: string) => {
fireEvent.click(screen.getByRole("button", { name: combo }));
fireEvent.click(await screen.findByRole("option", { name: option }));
};
it("offers no repository choice when the project has one repository", async () => {
useVcs.setState({ roots: ["/p"], repo: null });
render(<VcsDialogHost />);
await act(() => showRebaseDialog());
expect(await screen.findByText("Rebase main onto:")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Repository" })).toBeNull();
await pick("Upstream", "feature-a");
fireEvent.click(screen.getByRole("button", { name: "Rebase" }));
await waitFor(() => expect(mocks.rebases).toHaveLength(1));
expect(mocks.rebases[0]).toMatchObject({ repo: null, upstream: "feature-a" });
});
it("rebases the repository picked in the dialog, offering that repository's branches", async () => {
render(<VcsDialogHost />);
await act(() => showRebaseDialog());
expect(await screen.findByText("Rebase main onto:")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Repository" })).toHaveTextContent("a");
await pick("Repository", "b");
expect(await screen.findByText("Rebase dev onto:")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Upstream" }));
expect(await screen.findByRole("option", { name: "feature-b" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "feature-a" })).toBeNull();
// The current branch of the picked repository is not offered.
expect(screen.queryByRole("option", { name: "dev" })).toBeNull();
fireEvent.click(screen.getByRole("option", { name: "feature-b" }));
fireEvent.click(screen.getByRole("button", { name: "Rebase" }));
await waitFor(() => expect(mocks.rebases).toHaveLength(1));
expect(mocks.rebases[0]).toMatchObject({ repo: "/p/b", upstream: "feature-b" });
});
it("clears the chosen branches when another repository is picked", async () => {
render(<VcsDialogHost />);
await act(() => showRebaseDialog());
expect(await screen.findByText("Rebase main onto:")).toBeInTheDocument();
// Branch names both repositories have still have to be chosen again for the new one.
await pick("Upstream", "shared");
fireEvent.click(screen.getByRole("checkbox", { name: "--onto" }));
await pick("New base", "shared");
expect(screen.getByRole("button", { name: "Rebase" })).toBeEnabled();
await pick("Repository", "b");
expect(await screen.findByText("Rebase commits of dev not on:")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Rebase" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Upstream" })).not.toHaveTextContent("shared");
expect(screen.getByRole("button", { name: "New base" })).not.toHaveTextContent("shared");
// Picking the repository that is already selected keeps the choice.
await pick("Upstream", "shared");
await pick("New base", "shared");
await pick("Repository", "b");
expect(screen.getByRole("button", { name: "Rebase" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mocks.rebases).toEqual([]);
});
+19 -4
Переглянути файл
@@ -284,8 +284,8 @@ export function showMergeDialog() {
}
function MergeDialog({ onClose }: { onClose: () => void }) {
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const [repo, setRepo] = useState(currentRepo);
const head = useBranchInfo(repo)?.head ?? null;
const branches = useBranches(repo);
const [branch, setBranch] = useState<string | null>(null);
const [flags, setFlags] = useState<Set<MergeFlag>>(() => new Set());
@@ -296,6 +296,12 @@ function MergeDialog({ onClose }: { onClose: () => void }) {
[branches],
);
const chooseRepo = (root: string) => {
if (root === repo) return;
setRepo(root);
setBranch(null);
};
const run = async () => {
if (!branch) return;
onClose();
@@ -321,6 +327,7 @@ function MergeDialog({ onClose }: { onClose: () => void }) {
]}
>
<div className={styles.form}>
<RepoPicker repo={repo} onChange={chooseRepo} />
<div className={styles.fieldLabel}>
<T>Branch to merge:</T>
</div>
@@ -356,8 +363,8 @@ export function showRebaseDialog() {
}
function RebaseDialog({ onClose }: { onClose: () => void }) {
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const [repo, setRepo] = useState(currentRepo);
const head = useBranchInfo(repo)?.head ?? null;
const branches = useBranches(repo);
const [upstream, setUpstream] = useState<string | null>(null);
const [useOnto, setUseOnto] = useState(false);
@@ -370,6 +377,13 @@ function RebaseDialog({ onClose }: { onClose: () => void }) {
);
const ready = !!upstream && (!useOnto || !!onto);
const chooseRepo = (root: string) => {
if (root === repo) return;
setRepo(root);
setUpstream(null);
setOnto(null);
};
const run = async () => {
if (!upstream) return;
onClose();
@@ -403,6 +417,7 @@ function RebaseDialog({ onClose }: { onClose: () => void }) {
]}
>
<div className={styles.form}>
<RepoPicker repo={repo} onChange={chooseRepo} />
<div className={styles.fieldLabel}>
{useOnto ? tx`Rebase commits of ${head ?? "HEAD"} not on:` : `Rebase ${head ?? "HEAD"} onto:`}
</div>
+6 -1
Переглянути файл
@@ -7,6 +7,7 @@ import { Chunk } from "@codemirror/merge";
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { bandAt, docOffset, extent, transferY } from "./diffScroll";
import { ideaDiffConfig } from "./diffOptions";
export type MergePane = "ours" | "result" | "theirs";
const PANES: readonly MergePane[] = ["ours", "result", "theirs"];
@@ -32,6 +33,10 @@ export class ScrollEchoes<K> {
}
}
// Lined up by IDEA's line comparison. `Chunk.build` with no config runs an unbounded
// character diff, which takes minutes on a long file such as a lockfile.
const LINES = ideaDiffConfig("none", "lines");
/** Starts scrolling `views` together; returns the function that stops it. */
export function syncMergeScroll(views: Record<MergePane, EditorView>): () => void {
const built = new Map<MergePane, { a: Text; b: Text; chunks: readonly Chunk[] }>();
@@ -40,7 +45,7 @@ export function syncMergeScroll(views: Record<MergePane, EditorView>): () => voi
const b = views.result.state.doc;
const hit = built.get(side);
if (hit && hit.a === a && hit.b === b) return hit.chunks;
const fresh = Chunk.build(a, b);
const fresh = Chunk.build(a, b, LINES);
built.set(side, { a, b, chunks: fresh });
return fresh;
};
+8
Переглянути файл
@@ -139,6 +139,13 @@ export function stashLabel(message: string): { branch: string | null; text: stri
return { branch: m[1]!, text: m[2]!.trim() || message };
}
/** SSH front hosts that serve the same service as the web host in the value. */
const WEB_HOST_ALIASES: Record<string, string> = {
"ssh.bitbucket.org": "bitbucket.org",
"ssh.github.com": "github.com",
"altssh.gitlab.com": "gitlab.com",
};
/**
* Web page of a git remote URL (`[email protected]:o/r.git`, `ssh://git@host:22/o/r`,
* `https://user@host/o/r.git`), or null for local paths.
@@ -155,6 +162,7 @@ export function remoteWebUrl(url: string): string | null {
if (full) [, host, path] = full as unknown as [string, string, string];
else if (scp) [, host, path] = scp as unknown as [string, string, string];
else return null;
host = WEB_HOST_ALIASES[host.toLowerCase()] ?? host;
return `https://${host}/${path.replace(/^\/+/, "")}`;
}
+2
Переглянути файл
@@ -13,6 +13,7 @@
"Rename Group": "Rename Group",
"Remove from Group": "Remove from Group",
"Remove “{name}”? Its tasks move back to their status groups.": "Remove “{name}”? Its tasks move back to their status groups.",
"Copy Link Address": "Copy Link Address",
"Pin Agent Tasks": "Pin Agent Tasks",
"Unpin Agent Tasks": "Unpin Agent Tasks",
"New Agent Task Group": "New Agent Task Group",
@@ -181,6 +182,7 @@
"This debugger cannot list instances.": "This debugger cannot list instances.",
"The debugger cannot list instances: {detail}": "The debugger cannot list instances: {detail}",
"Pause the program to see instances.": "Pause the program to see instances.",
"The Memory view is available once the program has started.": "The Memory view is available once the program has started.",
"The object has been garbage collected.": "The object has been garbage collected.",
"The class is not loaded in the JVM.": "The class is not loaded in the JVM.",
"Could not tell which JVM this session debugs.": "Could not tell which JVM this session debugs.",
+6
Переглянути файл
@@ -8,6 +8,7 @@
"Rename Group": "重命名组",
"Remove from Group": "从组中移除",
"Remove “{name}”? Its tasks move back to their status groups.": "移除“{name}”?其中的任务将回到各自的状态分组。",
"Copy Link Address": "复制链接地址",
"Pin Agent Tasks": "固定 Agent 任务",
"Unpin Agent Tasks": "取消固定 Agent 任务",
"New Agent Task Group": "新建 Agent 任务组",
@@ -158,6 +159,7 @@
"This debugger cannot list instances.": "此调试器无法列出实例。",
"The debugger cannot list instances: {detail}": "调试器无法列出实例:{detail}",
"Pause the program to see instances.": "暂停程序以查看实例。",
"The Memory view is available once the program has started.": "程序启动后即可使用内存视图。",
"The object has been garbage collected.": "该对象已被垃圾回收。",
"The class is not loaded in the JVM.": "JVM 中未加载该类。",
"Could not tell which JVM this session debugs.": "无法确定此会话调试的是哪个 JVM。",
@@ -1191,6 +1193,7 @@
"Editor color scheme": "编辑器配色方案",
"Editor resynchronised": "编辑器已重新同步",
"Editor tabs": "编辑器标签页",
"Resize editor tabs": "调整编辑器标签页宽度",
"Editor › Font": "编辑器 › 字体",
"Editor › General": "编辑器 › 常规",
"Effect": "效果",
@@ -1330,6 +1333,7 @@
"Find in Folder…": "在文件夹中查找…",
"Find in Terminal": "在终端中查找",
"Find in console": "在控制台中查找",
"Find in response": "在响应中查找",
"Find in file": "在文件中查找",
"Find in terminal": "在终端中查找",
"Find results": "查找结果",
@@ -2177,6 +2181,8 @@
"Python interpreter": "Python 解释器",
"Python: only your code (justMyCode). JavaScript: skips node_modules and Node.js internals.": "Python:仅您的代码 (justMyCode)。JavaScript:跳过 node_modules 和 Node.js 内部代码。",
"Query Console": "查询控制台",
"New Query Console": "新建查询控制台",
"Query Consoles": "查询控制台",
"Quick Documentation": "快速文档",
"Re-check installed tools": "重新检查已安装的工具",
"Re-reads the effective POMs and dependency trees with Maven": "使用 Maven 重新读取有效 POM 和依赖树",
Переглянути файл
File renamed without changes.
+6 -9
Переглянути файл
@@ -25,6 +25,8 @@ export interface ConsoleFindBarProps {
onNext: () => void;
onPrevious: () => void;
onClose: () => void;
/** What is searched, for the field's placeholder and label. */
label?: string;
}
export function ConsoleFindBar({
@@ -40,6 +42,7 @@ export function ConsoleFindBar({
onNext,
onPrevious,
onClose,
label = "Find in console",
}: ConsoleFindBarProps) {
const none = !!query && !invalid && count === 0;
const counter = invalid
@@ -81,20 +84,14 @@ export function ConsoleFindBar({
return (
<div className={styles.container}>
<div
className={styles.find}
role="search"
aria-label={tx("Find in console")}
data-console-find=""
onKeyDown={onKeyDown}
>
<div className={styles.find} role="search" aria-label={tx(label)} data-console-find="" onKeyDown={onKeyDown}>
<SearchField
ref={inputRef}
className={styles.findField}
value={query}
onChange={onChange}
placeholder="Find in console"
aria-label="Find in console"
placeholder={label}
aria-label={label}
invalid={invalid || none}
toggles={[
{
+37
Переглянути файл
@@ -35,6 +35,43 @@ describe("copyMenuFor", () => {
expect(items![0]).toMatchObject({ label: "Copy", disabled: false });
});
it.each([
["link", "none"],
["nested", "link"],
["text", "elsewhere"],
])("offers Copy Link Address on a %s target with %s selected", async (target, selection) => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
const url = "https://example.com/pull/42?tab=files#diff";
mount(
`<div class="${selectableText}"><a id="link" href="${url}"><strong id="nested">Review changes</strong></a><p id="elsewhere">Other text</p></div>`,
);
if (selection !== "none") select(document.getElementById(selection)!);
const node = target === "text" ? document.getElementById("nested")!.firstChild : document.getElementById(target);
const items = copyMenuFor(node)!;
expect(items.map((i) => ("label" in i ? i.label : null))).toEqual(["Copy", "Copy Link Address"]);
const link = items[1]!;
if ("run" in link) link.run?.();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith(url);
});
it("keeps Copy on a link copying the selected text", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
mount(`<div class="${selectableText}"><a id="link" href="https://example.com">Review changes</a></div>`);
select(document.getElementById("link")!);
const copy = copyMenuFor(document.getElementById("link"))![0]!;
if ("run" in copy) copy.run?.();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("Review changes");
});
it("offers no Copy Link Address on an anchor without a destination or with an in-page one", () => {
mount(`<div class="${selectableText}"><a id="anchor">Section</a><a id="local" href="#top">Top</a></div>`);
for (const id of ["anchor", "local"]) expect(copyMenuFor(document.getElementById(id))).toHaveLength(1);
});
it("offers a disabled Copy in a selectable pane with nothing selected", () => {
mount(`<div class="${selectableText}"><p id="p">Hello</p></div>`);
expect(copyMenuFor(document.getElementById("p"))![0]).toMatchObject({ label: "Copy", disabled: true });
+10 -3
Переглянути файл
@@ -24,14 +24,19 @@ function selectionCovers(sel: Selection | null, target: Node): boolean {
return false;
}
/** The Copy menu for a right-click on `target`, or null when it is not on readable text. */
/**
* The Copy menu for a right-click on `target`, or null when it is not on readable text. On a link
* it also offers Copy Link Address, as browsers do; Copy keeps copying the selected text.
*/
export function copyMenuFor(target: EventTarget | null): MenuItem[] | null {
const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!el || el.closest(EDITABLE)) return null;
const sel = window.getSelection();
const text = selectionCovers(sel, el) ? sel!.toString() : "";
if (!text && !el.closest(`.${selectableText}`)) return null;
return [
const href = el.closest("a[href]")?.getAttribute("href") ?? "";
const link = href.startsWith("#") ? "" : href;
if (!text && !link && !el.closest(`.${selectableText}`)) return null;
const items: MenuItem[] = [
{
id: "copy",
label: "Copy",
@@ -41,6 +46,8 @@ export function copyMenuFor(target: EventTarget | null): MenuItem[] | null {
run: () => void copyText(text),
},
];
if (link) items.push({ id: "copy-link", label: "Copy Link Address", run: () => void copyText(link) });
return items;
}
/** Writes `text` to the clipboard; falls back to copying the live selection. */
+8
Переглянути файл
@@ -19,6 +19,7 @@ import {
} from "react";
import type { CommitSelection, FileMatches, SearchQuery, RunConfig, RunId } from "@/bindings";
import { notifyEach } from "@/platform/listeners";
import type { ProjectType } from "@/platform/projectType";
import type { MenuItem } from "@/ui/Menu";
import type { DragData } from "@/ui/pointerDrag";
import type { TreeReorder } from "@/ui/Tree";
@@ -770,6 +771,7 @@ const services: {
notifications?: NotificationService;
embeddedTerminal?: EmbeddedTerminalFactory;
runOutput?: RunOutputService;
hasRunConfiguration?: (kind: string, directory: string) => boolean;
} = {};
function need<T>(s: T | undefined, name: string): T {
@@ -782,6 +784,10 @@ export const provideToolWindowService = (s: ToolWindowService) => void (services
export const provideNotificationService = (s: NotificationService) => void (services.notifications = s);
export const provideEmbeddedTerminal = (f: EmbeddedTerminalFactory | undefined) => void (services.embeddedTerminal = f);
export const provideRunOutput = (s: RunOutputService | undefined) => void (services.runOutput = s);
export const provideRunConfigurationLookup = (lookup: typeof services.hasRunConfiguration) =>
void (services.hasRunConfiguration = lookup);
export const hasRunConfiguration = (kind: string, directory: string): boolean =>
services.hasRunConfiguration?.(kind, directory) ?? false;
export const editorService = (): EditorService => need(services.editor, "editor");
export const toolWindowService = (): ToolWindowService => need(services.toolWindows, "toolWindows");
@@ -1226,6 +1232,8 @@ export interface MenuGroupContext {
paths: string[];
/** Per path: whether it is a directory (project view). */
dirs?: boolean[];
/** New menu: the detected type of the project owning the target directory. */
projectType?: ProjectType;
/** The document text, when the menu is for an open document. */
text?: string;
/** Editor menu: 0-based caret line; gutter menu: the clicked line. */