дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-14 21:55:44 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
7c9ff885dd |
No files matched your search
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -67,8 +67,6 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
|
||||
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
|
||||
|
||||
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
|
||||
|
||||
### 权限
|
||||
|
||||
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
|
||||
|
||||
@@ -107,8 +107,6 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
|
||||
|
||||
标签页的位置、数量上限以及达到上限后的处理方式、预览标签页、排序方式,以及关闭标签页后激活哪一个,都在 **设置 › 编辑器 › 常规 › 编辑器标签页** 中设置。
|
||||
|
||||
标签页放在左侧或右侧时,标签页列的宽度随最长的名称变化,但不超过一个上限。拖动该列靠近编辑器的边缘(或聚焦后按 `←` / `→`)可以调整上限;调整对所有编辑器组生效,并会被记住。
|
||||
|
||||
## 拆分编辑器
|
||||
|
||||
| 操作 | macOS | Windows / Linux |
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]!);
|
||||
}
|
||||
@@ -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))) });
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"] }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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,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.
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 和依赖树",
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Заблокувати користувача