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

..
Автор SHA1 Повідомлення Дата
l0ng-ai 7c9ff885dd fix(debugger): let a Python program read input while it is debugged
debugpy launched the program with console "internalConsole", which gives it no
standard input: input() raised EOFError right after printing its prompt. The
program now runs on the pseudo-terminal the debugger already provides for
runInTerminal, so what is typed in the debug console reaches it, as in a Run.
Its output reaches the console from that terminal, so debugpy no longer
captures it a second time (redirectOutput).
2026-11-14 21:33:53 +08:00
75 змінених файлів з 86 додано та 2392 видалено

No files matched your search

+6 -45
Переглянути файл
@@ -12,7 +12,6 @@ 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)]
@@ -180,7 +179,6 @@ 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).
@@ -474,52 +472,11 @@ 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 =
@@ -530,5 +487,9 @@ fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
Ok(dir)
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
+1 -87
Переглянути файл
@@ -2,7 +2,7 @@ use super::doc::blocking;
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
@@ -22,23 +22,6 @@ 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();
@@ -191,74 +174,6 @@ 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,
@@ -334,7 +249,6 @@ 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,7 +357,6 @@ 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
Переглянути файл
@@ -1,37 +0,0 @@
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
Переглянути файл
@@ -1,141 +0,0 @@
//! 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,7 +365,6 @@ 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,7 +66,6 @@ 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, 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,
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,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
-171
Переглянути файл
@@ -1,171 +0,0 @@
//! 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,7 +6,6 @@ mod external;
mod folding;
mod grammars;
mod highlight;
mod inheritance;
pub(crate) mod lines;
mod literals;
mod outline;
@@ -23,7 +22,6 @@ 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,8 +67,6 @@ 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,8 +107,6 @@ 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,8 +67,6 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
### 权限
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
-2
Переглянути файл
@@ -107,8 +107,6 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
标签页的位置、数量上限以及达到上限后的处理方式、预览标签页、排序方式,以及关闭标签页后激活哪一个,都在 **设置 › 编辑器 › 常规 › 编辑器标签页** 中设置。
标签页放在左侧或右侧时,标签页列的宽度随最长的名称变化,但不超过一个上限。拖动该列靠近编辑器的边缘(或聚焦后按 `←` / `→`)可以调整上限;调整对所有编辑器组生效,并会被记住。
## 拆分编辑器
| 操作 | macOS | Windows / Linux |
-31
Переглянути файл
@@ -198,37 +198,6 @@ 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,38 +285,3 @@ 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,20 +172,6 @@ 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
Переглянути файл
@@ -1,102 +0,0 @@
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,10 +362,3 @@ 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,7 +248,6 @@ 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,
@@ -747,6 +746,5 @@ 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,14 +115,6 @@ 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,11 +475,6 @@ 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`. */
@@ -1731,8 +1726,6 @@ 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 */
@@ -2896,25 +2889,6 @@ 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,
+4 -23
Переглянути файл
@@ -270,29 +270,13 @@
}
.tabsVertical > .tabs,
.tabsVertical > .tabRows {
/* Fits the labels up to the draggable limit (SideTabsResizer); the editor keeps at least 60%. */
width: max-content;
max-width: min(var(--side-tabs-limit, 260px), 40%);
/* A narrow editor group keeps most of its width for the editor; labels truncate. */
min-width: min(140px, 40%);
max-width: min(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;
@@ -301,12 +285,9 @@
flex: 1 1 auto;
border-top: 1px solid var(--border-editor-tabs, var(--border));
}
.tabsRight > .sideTabsResizer {
order: 1;
}
.tabsRight > .tabs,
.tabsRight > .tabRows {
order: 2;
order: 1;
border-right: none;
border-left: 1px solid var(--border-editor-tabs, var(--border));
}
-4
Переглянути файл
@@ -64,7 +64,6 @@ 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";
@@ -253,7 +252,6 @@ 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.
@@ -368,7 +366,6 @@ 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")}
@@ -384,7 +381,6 @@ 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
Переглянути файл
@@ -1,106 +0,0 @@
/**
* 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}
/>
);
}
+7 -226
Переглянути файл
@@ -27,14 +27,12 @@ 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";
@@ -50,7 +48,7 @@ import {
type MarkerCandidate,
type MarkerGroup,
} from "./lineMarkers";
import { baseName, overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
import { overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
import styles from "./LineMarkers.module.css";
/** Lines above / below the viewport that are asked with it. */
@@ -87,49 +85,16 @@ 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])) return;
changes++;
forgetInheritanceGraph();
if (affectsMarkers([...e.created, ...e.modified, ...e.removed])) changes++;
});
// 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();
});
}
@@ -166,13 +131,6 @@ 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 {
@@ -184,8 +142,6 @@ 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() });
@@ -208,16 +164,11 @@ 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") && (fallback || !cache.unsupported.has("inheritors"))
groups.has("inheritors") && !cache.unsupported.has("inheritors")
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
: [];
const askSupers = groups.has("supers") && (fallback || !cache.unsupported.has("supers"));
const askSupers = groups.has("supers") && !cache.unsupported.has("supers");
const up = askSupers
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
: [];
@@ -235,11 +186,8 @@ export async function fillMarkers(
asked = await deps.implementations(ask.map((c) => ({ line: c.line, col: c.col })));
} catch (e) {
if (isCancelled(e)) throw e;
// 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);
}
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)] ?? []));
@@ -260,79 +208,8 @@ 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) {
@@ -351,103 +228,10 @@ export async function fillMarkers(
...satisfied.flat().map((l) => l.path),
]);
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[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);
});
up.forEach((c, i) => (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
@@ -791,9 +575,6 @@ 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,
};
}
+1 -127
Переглянути файл
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
IpcError,
type ClassInheritance,
type HierarchyItem,
type Location,
type OutlineItem,
@@ -209,9 +208,8 @@ 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, inheritance: 0 };
const calls = { implementations: [] as Pos[][], supertypes: 0, subtypes: 0 };
const outlines: Record<string, OutlineItem[]> = {
[SHAPE_PATH]: SHAPE_OUTLINE,
"/p/Circle.java": CIRCLE_OUTLINE,
@@ -236,35 +234,6 @@ 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 };
}
@@ -668,98 +637,3 @@ 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,7 +38,6 @@ import {
loadDatabases,
listsDatabases,
sourceById,
newConsole,
openConsole,
openHistory,
openSourceDialog,
@@ -199,24 +198,6 @@ 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,29 +906,6 @@ 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;
+1 -16
Переглянути файл
@@ -22,10 +22,7 @@ import {
loadSchemas,
loadSources,
loadTables,
consoleFiles,
newConsole,
openConsole,
openConsoleFile,
openConsoleWith,
openDdl,
openObjectDdl,
@@ -45,7 +42,6 @@ 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. */
@@ -283,7 +279,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 jumpToConsole(target);
if (target) void openConsole(target);
else toolWindowService().show(DB_TOOL_WINDOW, true);
},
}),
@@ -528,14 +524,3 @@ 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.newConsole", label: "Query Console" }, sep, ...dataSourceItems()];
return [{ kind: "action", action: "db.openConsole", 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.newConsole", n, { label: "Query Console", icon: "db-console" });
const newConsole = act("db.openConsole", 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))) });
-9
Переглянути файл
@@ -98,15 +98,6 @@ 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" }),
-10
Переглянути файл
@@ -157,16 +157,6 @@ 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 });
+1 -6
Переглянути файл
@@ -67,12 +67,7 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
return {
...base(rc, adapter, cwd),
args: programArgs,
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}`) } : {}),
}),
extraJson: JSON.stringify({ cargo: filter ? { args, filter } : { args }, sourceLanguages: ["rust"] }),
};
}
+2 -15
Переглянути файл
@@ -169,9 +169,7 @@ export function DebugToolWindow() {
? "variables"
: view.tab,
});
// 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 hasMemory = !!active && sessionSupports("jvmMemory", active);
const hasNetwork = !!active && sessionSupports("jsPage", active);
// The selected session may be one without the Memory or the Network tab.
const tab: Tab =
@@ -257,15 +255,7 @@ export function DebugToolWindow() {
{tab === "variables" ? (
<ThreadsAndVariables session={active} />
) : tab === "memory" ? (
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} />
)
<MemoryView key={active.id} session={active} />
) : tab === "network" ? (
<NetworkView key={active.id} session={active.id} />
) : (
@@ -276,9 +266,6 @@ 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,9 +401,6 @@ 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; SSH also via ssh.github.com), else null. */
/** `owner/name` of a github.com remote URL (https, ssh or scp-like), else null. */
export function parseGitHubRemote(url: string): GHRepositoryCoordinates | null {
const m =
/^(?:(?:https?:\/\/(?:[^@/]+@)?|git:\/\/)(?:www\.)?|(?:ssh:\/\/(?:[^@/]+@)?|(?:[^@/]+@)?)(?:www\.|ssh\.)?)github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|git:\/\/|(?:[^@/]+@)?)(?:www\.)?github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
url.trim(),
);
return m ? { owner: m[1]!, name: m[2]! } : null;
-9
Переглянути файл
@@ -207,12 +207,3 @@
.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);
}
+10 -116
Переглянути файл
@@ -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 { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import type { HttpExchange, HttpHistoryEntry } from "@/platform/ipc";
import {
@@ -18,9 +18,6 @@ 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";
@@ -63,49 +60,6 @@ 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}>
@@ -137,52 +91,18 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
{ id: "request", label: "Request" },
]}
trailing={
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)}
/>
)}
</>
tab === "body" && 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} onKeyDown={onKeyDown}>
<div ref={pane} className={styles.tabBody} tabIndex={0}>
{tab === "body" &&
(r == null ? (
<pre className={styles.pre}>{ex.entry.error}</pre>
@@ -192,7 +112,7 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
</EmptyState>
) : (
<pre className={styles.pre} aria-label={tx("Response body")}>
{found.matches.length ? highlighted(lines, found.matches, activeIndex) : body}
{body}
{r.truncated && (
<span className={styles.truncated}>{`\n… truncated (${formatSize(r.size)} received)`}</span>
)}
@@ -211,32 +131,6 @@ 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
Переглянути файл
@@ -1,29 +0,0 @@
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
Переглянути файл
@@ -1,44 +0,0 @@
/**
* 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,7 +17,6 @@ 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";
@@ -135,7 +134,6 @@ export function activate(): () => void {
const disposers = [
registerSpringEndpointProvider(),
registerVariableNavigation(),
registerFileNavigation(),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) resetHttpClient();
}),
-67
Переглянути файл
@@ -1,67 +0,0 @@
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);
});
});
+2 -4
Переглянути файл
@@ -66,7 +66,7 @@ import {
} from "@/platform/projectPresentation";
import { relativePath } from "@/platform/project";
import { folderKind } from "./folderKind";
import { PROJECT_TYPES } from "@/platform/projectType";
import { PROJECT_TYPES } from "./projectType";
import { useHomeDir } from "./home";
import { keyLike, memberNodes, nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import {
@@ -286,9 +286,7 @@ const Row = memo(function Row({
const root = useProjectTree((s) => s.root);
const detected = useDetectedRoots();
const build = useProjectPresentation();
const projectType = useProjectTree((s) =>
s.members && node.root && !node.icon ? s.projectTypes.get(node.path) : undefined,
);
const projectType = useProjectTree((s) => (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));
+2 -8
Переглянути файл
@@ -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 { projectFolder, relativePath, useProject } from "@/platform/project";
import { 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,7 +31,6 @@ 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). */
@@ -106,12 +105,7 @@ function showProjectView() {
/** File kinds contributed to the `new` menu group (templates, HTTP Request, Scratch File…). */
function contributedNewItems(parent: string | null): MenuItem[] {
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: [] });
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true] } : { 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 "@/platform/projectType";
import { detectProjectType, isProjectMarker, PROJECT_TYPES } from "./projectType";
describe("project types", () => {
it.each([
Переглянути файл
File renamed without changes.
+4 -3
Переглянути файл
@@ -45,10 +45,11 @@ describe("automatic project types", () => {
expect([...useProjectTree.getState().projectTypes.values()]).toEqual(Array(9).fill("go"));
expect(backend.fsReadDir).toHaveBeenCalledTimes(9);
});
it("detects the type for New menu templates in a single project", async () => {
it("leaves a single project root alone", async () => {
initProjectTree("/app");
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/app")).toBe("node"));
expect(backend.fsReadDir).toHaveBeenCalledExactlyOnceWith("/app");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(backend.fsReadDir).not.toHaveBeenCalled();
expect(useProjectTree.getState().projectTypes.size).toBe(0);
});
it("refreshes only affected markers and clears a removed project type", async () => {
initProjectTree("/ws", "Workspace", ["/a", "/b"]);
+4 -5
Переглянути файл
@@ -1,6 +1,6 @@
/** Bounded, read-only detection for project roots, including collapsed workspace members. */
/** Bounded, read-only detection for workspace members, collapsed ones included; a single project root keeps its own icon. */
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { detectProjectType, isProjectMarker } from "@/platform/projectType";
import { detectProjectType, isProjectMarker } from "./projectType";
import { useProjectTree } from "./store";
async function detect(root: string) {
@@ -16,9 +16,8 @@ export function initProjectTypes(): () => void {
let reset: (() => void) | undefined;
const load = () => {
reset?.();
const { root, members } = useProjectTree.getState();
if (!root || !isTauri()) return;
const roots = members ?? [root];
const { root, members: roots } = useProjectTree.getState();
if (!root || !roots || !isTauri()) return;
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 "@/platform/projectType";
import { PROJECT_TYPES, type ProjectType } from "./projectType";
import {
projectViewRoots,
useProjectViewRoots,
+2 -17
Переглянути файл
@@ -6,15 +6,13 @@ 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 { projectFolder, useProject } from "@/platform/project";
import { isSameOrInside } from "@/platform/paths";
import { useProject } from "@/platform/project";
import { agentRunSettings, onWorkflowSettingsChange } from "@/platform/workflowSettings";
import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui";
import {
menuGroupItems,
useDebugRunSessions,
notifications,
provideRunConfigurationLookup,
provideRunOutput,
registerMenuGroup,
registerOverlay,
@@ -46,7 +44,6 @@ import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { runDirectory } from "./navigate";
import {
cleanupRemovedProject,
registerRunServiceActions,
@@ -351,7 +348,7 @@ function useRunningCount(): number | null {
}
const CONFIG_FILES =
/(?:^|\/)(?:\.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)$/;
/(?:^|\/)(?:\.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)$/;
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleReload() {
@@ -768,18 +765,6 @@ 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 });
return zigFile(input, env, s, { name: target.name, filter: target.name.replace(/\\(.)/g, "$1") });
}
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, Zig's module) also match by file. */
const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?|zig)$/.test(path);
/** 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);
/** 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 {
+1 -2
Переглянути файл
@@ -361,8 +361,7 @@ 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) {
// Unescape for display and for `--test-filter`: the runner reports the raw name.
const name = test[1]?.replace(/\\(.)/g, "$1") ?? test[2]!;
const name = test[1] ?? test[2]!;
out.push({ line: i, kind: "test", name, path: [name] });
}
});
+2 -32
Переглянути файл
@@ -8,23 +8,12 @@ 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,
zigParser,
};
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, rubyParser, unittestParser };
/** Runs several parsers side by side (unknown tool: e.g. `make test`). */
export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
@@ -34,18 +23,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
};
}
export type Framework =
| "cargo"
| "js"
| "pytest"
| "unittest"
| "phpunit"
| "go"
| "jvm"
| "ruby"
| "dotnet"
| "zig"
| "auto";
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "ruby" | "dotnet" | "auto";
export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args">): Framework {
const cmd = [config.command, ...config.args].join(" ");
@@ -76,8 +54,6 @@ 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";
@@ -122,10 +98,6 @@ 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"] };
@@ -159,8 +131,6 @@ 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,23 +180,6 @@ 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,21 +249,6 @@ 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,19 +58,6 @@ 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,8 +69,6 @@ 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`).
@@ -82,8 +80,6 @@ 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
Переглянути файл
@@ -1,174 +0,0 @@
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
Переглянути файл
@@ -1,149 +0,0 @@
/** `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;
},
};
}
+4 -28
Переглянути файл
@@ -11,7 +11,6 @@ import {
registerMenuGroup,
registerOverlay,
registerSettingsPage,
hasRunConfiguration,
type MenuGroupContext,
} from "@/workbench/contributions";
import { insertLiveTemplate, surroundTarget, surroundWith, templateEditorExtension } from "./editor";
@@ -25,7 +24,6 @@ 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;
@@ -94,20 +92,6 @@ 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);
@@ -116,24 +100,16 @@ function registerNewMenuGroups(): () => void {
id: "templates.new",
menus: ["new"],
order: 10,
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);
})
items: (_menu, ctx) =>
fileTemplates()
.filter((t) => t.enabled !== false && t.id !== "file")
.map((t) => ({
kind: "action",
action: `templates.new.${t.id}`,
arg: arg(ctx),
label: t.name,
icon: t.icon,
}));
},
})),
}),
registerMenuGroup({
id: "templates.newFromTemplate",
+1 -8
Переглянути файл
@@ -15,7 +15,6 @@ 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";
@@ -93,10 +92,6 @@ 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<{
@@ -181,9 +176,7 @@ 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, diffConfig: SIDE_DIFF })
: [],
state.base !== null ? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true }) : [],
sideControls(which, controls),
mergeStripe(which),
mergeTheme,
-8
Переглянути файл
@@ -7,8 +7,6 @@ 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) => {
@@ -45,10 +43,4 @@ 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
Переглянути файл
@@ -1,106 +0,0 @@
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
Переглянути файл
@@ -1,110 +0,0 @@
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([]);
});
+4 -19
Переглянути файл
@@ -284,8 +284,8 @@ export function showMergeDialog() {
}
function MergeDialog({ onClose }: { onClose: () => void }) {
const [repo, setRepo] = useState(currentRepo);
const head = useBranchInfo(repo)?.head ?? null;
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const branches = useBranches(repo);
const [branch, setBranch] = useState<string | null>(null);
const [flags, setFlags] = useState<Set<MergeFlag>>(() => new Set());
@@ -296,12 +296,6 @@ 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();
@@ -327,7 +321,6 @@ function MergeDialog({ onClose }: { onClose: () => void }) {
]}
>
<div className={styles.form}>
<RepoPicker repo={repo} onChange={chooseRepo} />
<div className={styles.fieldLabel}>
<T>Branch to merge:</T>
</div>
@@ -363,8 +356,8 @@ export function showRebaseDialog() {
}
function RebaseDialog({ onClose }: { onClose: () => void }) {
const [repo, setRepo] = useState(currentRepo);
const head = useBranchInfo(repo)?.head ?? null;
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const branches = useBranches(repo);
const [upstream, setUpstream] = useState<string | null>(null);
const [useOnto, setUseOnto] = useState(false);
@@ -377,13 +370,6 @@ 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();
@@ -417,7 +403,6 @@ 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>
+1 -6
Переглянути файл
@@ -7,7 +7,6 @@ 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"];
@@ -33,10 +32,6 @@ 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[] }>();
@@ -45,7 +40,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, LINES);
const fresh = Chunk.build(a, b);
built.set(side, { a, b, chunks: fresh });
return fresh;
};
-8
Переглянути файл
@@ -139,13 +139,6 @@ 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.
@@ -162,7 +155,6 @@ 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,7 +13,6 @@
"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",
@@ -182,7 +181,6 @@
"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,7 +8,6 @@
"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 任务组",
@@ -159,7 +158,6 @@
"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。",
@@ -1193,7 +1191,6 @@
"Editor color scheme": "编辑器配色方案",
"Editor resynchronised": "编辑器已重新同步",
"Editor tabs": "编辑器标签页",
"Resize editor tabs": "调整编辑器标签页宽度",
"Editor › Font": "编辑器 › 字体",
"Editor › General": "编辑器 › 常规",
"Effect": "效果",
@@ -1333,7 +1330,6 @@
"Find in Folder…": "在文件夹中查找…",
"Find in Terminal": "在终端中查找",
"Find in console": "在控制台中查找",
"Find in response": "在响应中查找",
"Find in file": "在文件中查找",
"Find in terminal": "在终端中查找",
"Find results": "查找结果",
@@ -2181,8 +2177,6 @@
"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 和依赖树",
+9 -6
Переглянути файл
@@ -25,8 +25,6 @@ export interface ConsoleFindBarProps {
onNext: () => void;
onPrevious: () => void;
onClose: () => void;
/** What is searched, for the field's placeholder and label. */
label?: string;
}
export function ConsoleFindBar({
@@ -42,7 +40,6 @@ export function ConsoleFindBar({
onNext,
onPrevious,
onClose,
label = "Find in console",
}: ConsoleFindBarProps) {
const none = !!query && !invalid && count === 0;
const counter = invalid
@@ -84,14 +81,20 @@ export function ConsoleFindBar({
return (
<div className={styles.container}>
<div className={styles.find} role="search" aria-label={tx(label)} data-console-find="" onKeyDown={onKeyDown}>
<div
className={styles.find}
role="search"
aria-label={tx("Find in console")}
data-console-find=""
onKeyDown={onKeyDown}
>
<SearchField
ref={inputRef}
className={styles.findField}
value={query}
onChange={onChange}
placeholder={label}
aria-label={label}
placeholder="Find in console"
aria-label="Find in console"
invalid={invalid || none}
toggles={[
{
-37
Переглянути файл
@@ -35,43 +35,6 @@ 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 });
+3 -10
Переглянути файл
@@ -24,19 +24,14 @@ 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. On a link
* it also offers Copy Link Address, as browsers do; Copy keeps copying the selected text.
*/
/** The Copy menu for a right-click on `target`, or null when it is not on readable 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() : "";
const href = el.closest("a[href]")?.getAttribute("href") ?? "";
const link = href.startsWith("#") ? "" : href;
if (!text && !link && !el.closest(`.${selectableText}`)) return null;
const items: MenuItem[] = [
if (!text && !el.closest(`.${selectableText}`)) return null;
return [
{
id: "copy",
label: "Copy",
@@ -46,8 +41,6 @@ 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,7 +19,6 @@ 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";
@@ -771,7 +770,6 @@ const services: {
notifications?: NotificationService;
embeddedTerminal?: EmbeddedTerminalFactory;
runOutput?: RunOutputService;
hasRunConfiguration?: (kind: string, directory: string) => boolean;
} = {};
function need<T>(s: T | undefined, name: string): T {
@@ -784,10 +782,6 @@ 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");
@@ -1232,8 +1226,6 @@ 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. */