дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 22:25:48 +02:00
Порівняти коміти
6
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
fd0c8a9f05 | ||
|
|
13e18af777 | ||
|
|
488e819b46 | ||
|
|
ad8df8fe00 | ||
|
|
19d6eab21a | ||
|
|
5c0befeff0 |
No files matched your search
@@ -12,6 +12,7 @@ use ide_db::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One statement of a console run.
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
@@ -179,6 +180,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
|
||||
});
|
||||
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
|
||||
d.add("db_consoles", |c| async move { consoles(&c.host, &c.arg::<String>("id")?, c.arg("create")?) });
|
||||
}
|
||||
|
||||
/// The data sources configured for the project (the tree's roots).
|
||||
@@ -472,11 +474,52 @@ pub async fn apply_edits(
|
||||
/// created on first use. Consoles are files of the project, so a remote
|
||||
/// window resolves it on the host the project runs on.
|
||||
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
|
||||
let file = console_dir(host, id)?.join("console.sql");
|
||||
if !file.exists() {
|
||||
std::fs::write(&file, "")?;
|
||||
}
|
||||
Ok(file.into())
|
||||
}
|
||||
|
||||
/// The query consoles of data source `id`: the default `console.sql`, then
|
||||
/// `console_1.sql`, `console_2.sql`… in order. `create` adds the next one first
|
||||
/// (DataGrip's New › Query Console).
|
||||
pub fn consoles(host: &Host, id: &str, create: bool) -> IdeResult<Vec<IpcPath>> {
|
||||
let dir = console_dir(host, id)?;
|
||||
let default = dir.join("console.sql");
|
||||
let mut numbered: Vec<(u32, PathBuf)> = std::fs::read_dir(&dir)?
|
||||
.filter_map(|e| {
|
||||
let path = e.ok()?.path();
|
||||
let n = path.file_name()?.to_str()?.strip_prefix("console_")?.strip_suffix(".sql")?.parse().ok()?;
|
||||
Some((n, path))
|
||||
})
|
||||
.collect();
|
||||
numbered.sort();
|
||||
if create {
|
||||
// The default console comes first; once it exists, the next number.
|
||||
if !default.exists() {
|
||||
std::fs::write(&default, "")?;
|
||||
} else {
|
||||
let n = numbered.last().map_or(1, |(n, _)| n + 1);
|
||||
let file = dir.join(format!("console_{n}.sql"));
|
||||
std::fs::write(&file, "")?;
|
||||
numbered.push((n, file));
|
||||
}
|
||||
}
|
||||
let mut out: Vec<IpcPath> = Vec::new();
|
||||
if default.exists() {
|
||||
out.push(default.into());
|
||||
}
|
||||
out.extend(numbered.into_iter().map(|(_, p)| p.into()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `<dataDir>/consoles/db/<id>`: `<id>` for the default database, `<id>.<database>` for the others.
|
||||
fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
|
||||
let (source, database) = ide_db::split_target(id);
|
||||
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(IdeError::invalid("invalid data source id"));
|
||||
}
|
||||
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
|
||||
let name = match database {
|
||||
Some(db) => {
|
||||
let safe: String =
|
||||
@@ -487,9 +530,5 @@ pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
|
||||
};
|
||||
let dir = host.data_dir.join("consoles").join("db").join(&name);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let file = dir.join("console.sql");
|
||||
if !file.exists() {
|
||||
std::fs::write(&file, "")?;
|
||||
}
|
||||
Ok(file.into())
|
||||
Ok(dir)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
|
||||
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
@@ -22,6 +22,23 @@ pub struct WebGoto {
|
||||
|
||||
const MAX_WEB_TARGETS: usize = 200;
|
||||
|
||||
/// A Python class definition and the base names written in its source. The UI builds a
|
||||
/// name → locations index from every class in the project (attached dependency roots
|
||||
/// included) to compute ↑ overrides / ↓ overridden gutter markers without a language
|
||||
/// server's type-hierarchy provider.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClassInheritance {
|
||||
/// Start of the class definition's selection range (0-based line, UTF-16 column).
|
||||
pub path: IpcPath,
|
||||
pub line: u32,
|
||||
pub col: u32,
|
||||
pub name: String,
|
||||
/// Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
|
||||
/// qualified name keeps only its last segment).
|
||||
pub bases: Vec<String>,
|
||||
}
|
||||
|
||||
/// The project root and its resource roots (served at `/`), for file references.
|
||||
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
|
||||
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
|
||||
@@ -174,6 +191,74 @@ pub async fn nav_index_definitions(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Every Python class in the project (attached dependency roots included) and its base
|
||||
/// names. The index covers content roots, so this is the whole-project answer the UI uses
|
||||
/// for inheritance gutter markers when no type-hierarchy provider exists.
|
||||
pub async fn nav_inheritance(host: &Host, window: &str) -> IdeResult<Vec<ClassInheritance>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
blocking(move || {
|
||||
let python = LanguageId::new("python");
|
||||
let files = project.index.files_with_extensions(&["py".into(), "pyi".into()], 20_000);
|
||||
let mut cache = CLASS_BASES.lock();
|
||||
let mut out = Vec::new();
|
||||
for path in files {
|
||||
// The open (possibly unsaved) buffer is keyed by its text, a file on disk by its
|
||||
// size and modification time, so only changed files are parsed again.
|
||||
let open = project.documents.text_of(&path);
|
||||
let stamp = match &open {
|
||||
Some(text) => Stamp::Text(text_hash(text)),
|
||||
None => match std::fs::metadata(&path) {
|
||||
Ok(m) => Stamp::Disk(m.len(), m.modified().ok()),
|
||||
Err(_) => continue,
|
||||
},
|
||||
};
|
||||
let classes = match cache.get(&path) {
|
||||
Some((cached, classes)) if *cached == stamp => classes,
|
||||
_ => {
|
||||
let text = match open {
|
||||
Some(text) => text,
|
||||
None => match project.text_of(&path) {
|
||||
Ok(text) => text,
|
||||
Err(_) => continue,
|
||||
},
|
||||
};
|
||||
let classes = syntax.class_bases(&python, &text);
|
||||
&cache.entry(path.clone()).insert_entry((stamp, classes)).into_mut().1
|
||||
}
|
||||
};
|
||||
out.extend(classes.iter().map(|class| ClassInheritance {
|
||||
path: IpcPath::from(path.clone()),
|
||||
line: class.line,
|
||||
col: class.col,
|
||||
name: class.name.clone(),
|
||||
bases: class.bases.clone(),
|
||||
}));
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum Stamp {
|
||||
Text(u64),
|
||||
Disk(u64, Option<std::time::SystemTime>),
|
||||
}
|
||||
|
||||
fn text_hash(text: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
type ClassBasesCache = std::collections::HashMap<PathBuf, (Stamp, Vec<ide_language::ClassBases>)>;
|
||||
|
||||
/// Class bases per Python file, reused while the file is unchanged.
|
||||
static CLASS_BASES: std::sync::LazyLock<parking_lot::Mutex<ClassBasesCache>> =
|
||||
std::sync::LazyLock::new(Default::default);
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
@@ -249,6 +334,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("nav_index_definitions", |c| async move {
|
||||
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("nav_inheritance", |c| async move { nav_inheritance(&c.host, &c.window).await });
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
|
||||
@@ -357,6 +357,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "docker_set_registry_password"
|
||||
| "docker_run_env"
|
||||
| "db_console_path"
|
||||
| "db_consoles"
|
||||
| "db_export_file"
|
||||
| "vcs_write_patch"
|
||||
| "app_metrics"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::ops::db;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_query_consoles_are_numbered_after_the_default_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let names = |paths: Vec<ide_core::IpcPath>| -> Vec<String> {
|
||||
paths.iter().map(|p| p.0.file_name().unwrap().to_string_lossy().into_owned()).collect()
|
||||
};
|
||||
assert!(db::consoles(&host, "pg", false).unwrap().is_empty());
|
||||
// The first new console is the default one, then console_1, console_2…
|
||||
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql"]);
|
||||
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql", "console_1.sql"]);
|
||||
let all = db::consoles(&host, "pg", true).unwrap();
|
||||
assert_eq!(names(all.clone()), ["console.sql", "console_1.sql", "console_2.sql"]);
|
||||
// Numbers sort numerically, and the default console path is the first one.
|
||||
std::fs::write(all[0].0.with_file_name("console_10.sql"), "").unwrap();
|
||||
assert_eq!(names(db::consoles(&host, "pg", false).unwrap())[3], "console_10.sql");
|
||||
assert_eq!(db::console_path(&host, "pg").unwrap().0, all[0].0);
|
||||
// Each database of a source has its own consoles.
|
||||
assert!(db::consoles(&host, "pg:other", false).unwrap().is_empty());
|
||||
assert!(db::consoles(&host, "../x", true).is_err());
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! The native `nav_inheritance` command: it must return every Python class definition
|
||||
//! in the project — attached dependency roots included — together with the base names
|
||||
//! written in its source, so the UI can compute inheritance gutter markers without a
|
||||
//! language server's type-hierarchy provider.
|
||||
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
struct Opened {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
/// Opens a synthetic project whose Python files carry known class / base shapes, then
|
||||
/// waits for the index to settle (it walks content roots, so dependency roots are covered).
|
||||
async fn open() -> Opened {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("proj");
|
||||
let src = root.join("src");
|
||||
let dep = root.join("vendor");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::create_dir_all(&dep).unwrap();
|
||||
|
||||
// Own root: a plain class, a class with two bases + a keyword arg, a qualified base,
|
||||
// and a subscripted generic base. A comment / docstring that mentions `class X(Y)`
|
||||
// must not be reported.
|
||||
std::fs::write(
|
||||
src.join("app.py"),
|
||||
concat!(
|
||||
"class Base:\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"class Mixin:\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"class Child(Base, Mixin, metaclass=Meta):\n",
|
||||
" # class Ghost(Spook): must be ignored\n",
|
||||
" \"\"\"class Phantom(Apparition): must be ignored\"\"\"\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"class Qualified(pkg.mod.Base):\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
"class Generic(Generic[T]):\n",
|
||||
" pass\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// A second own-root file to prove the command scans more than one file.
|
||||
std::fs::write(src.join("extra.pyi"), "class Stub(Base):\n ...\n").unwrap();
|
||||
// An attached dependency root (content root) — must be covered by the index.
|
||||
std::fs::write(dep.join("dep.py"), "class DepBase:\n pass\n").unwrap();
|
||||
|
||||
let app = tmp.path().join("app");
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: app.join("config"),
|
||||
data_dir: app.join("data"),
|
||||
local_data_dir: app.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let s = ops::nav::index_status(&host, WINDOW).unwrap();
|
||||
if !s.indexing && s.files > 0 {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "indexing timed out; status: {s:?}");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Opened { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn find<'a>(all: &'a [ide_host::ops::nav::ClassInheritance], name: &str) -> &'a ide_host::ops::nav::ClassInheritance {
|
||||
all.iter().find(|c| c.name == name).unwrap_or_else(|| panic!("no class {name:?} in {all:#?}"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn returns_all_python_classes_and_bases() {
|
||||
let p = open().await;
|
||||
let all = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
|
||||
|
||||
// Both own-root Python files are scanned (src/app.py + src/extra.pyi).
|
||||
let base = find(&all, "Base");
|
||||
assert!(base.path.0.ends_with("src/app.py"), "Base path: {:?}", base.path);
|
||||
// The location is the class name identifier (selection-range start), i.e. after `class `.
|
||||
assert_eq!(base.line, 0);
|
||||
assert_eq!(base.col, 6);
|
||||
assert!(base.bases.is_empty(), "a bare class has no bases: {:?}", base.bases);
|
||||
|
||||
let child = find(&all, "Child");
|
||||
assert_eq!(child.line, 6);
|
||||
assert_eq!(child.col, 6);
|
||||
// The `metaclass=Meta` keyword argument is not a base.
|
||||
assert!(child.bases.contains(&"Base".to_string()), "Child bases: {:?}", child.bases);
|
||||
assert!(child.bases.contains(&"Mixin".to_string()), "Child bases: {:?}", child.bases);
|
||||
assert!(!child.bases.iter().any(|b| b == "Meta"), "metaclass must be skipped: {:?}", child.bases);
|
||||
|
||||
// A qualified base keeps only its last segment.
|
||||
let qualified = find(&all, "Qualified");
|
||||
assert_eq!(qualified.bases, vec!["Base".to_string()], "qualified base: {:?}", qualified.bases);
|
||||
|
||||
// A subscripted generic base keeps the identifier.
|
||||
let generic = find(&all, "Generic");
|
||||
assert_eq!(generic.bases, vec!["Generic".to_string()], "generic base: {:?}", generic.bases);
|
||||
|
||||
// Comments / docstrings are not mistaken for class definitions.
|
||||
assert!(!all.iter().any(|c| c.name == "Ghost" || c.name == "Phantom"), "parsed a comment: {all:#?}");
|
||||
|
||||
// The .pyi stub in the second own-root file is included.
|
||||
let stub = find(&all, "Stub");
|
||||
assert!(stub.path.0.ends_with("src/extra.pyi"), "Stub path: {:?}", stub.path);
|
||||
assert_eq!(stub.bases, vec!["Base".to_string()]);
|
||||
|
||||
// The attached dependency root is covered by the project index.
|
||||
let dep = find(&all, "DepBase");
|
||||
assert!(dep.path.0.starts_with(&p.root), "DepBase path: {:?}", dep.path);
|
||||
|
||||
// Every returned entry is a Python file.
|
||||
assert!(all.iter().all(|c| { matches!(c.path.0.extension().and_then(|e| e.to_str()), Some("py" | "pyi")) }));
|
||||
|
||||
// A file changed on disk is parsed again; the others come from the cache.
|
||||
let extra = p.root.join("src/extra.pyi");
|
||||
std::fs::write(&extra, "class Stub(Child): ...\nclass Added(Base): ...\n").unwrap();
|
||||
let again = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
|
||||
assert_eq!(find(&again, "Stub").bases, vec!["Child".to_string()]);
|
||||
assert_eq!(find(&again, "Added").bases, vec!["Base".to_string()]);
|
||||
assert_eq!(find(&again, "Child").bases, child.bases);
|
||||
}
|
||||
@@ -365,6 +365,7 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
"db_table_page",
|
||||
"db_apply_edits",
|
||||
"db_console_path",
|
||||
"db_consoles",
|
||||
"ssh_test",
|
||||
"ssh_set_secret",
|
||||
"ssh_password_saved",
|
||||
|
||||
@@ -66,6 +66,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"docker_set_registry_password",
|
||||
"docker_run_env",
|
||||
"db_console_path",
|
||||
"db_consoles",
|
||||
"db_export_file",
|
||||
"vcs_write_patch",
|
||||
"remote_bootstrap_list",
|
||||
|
||||
@@ -22,11 +22,11 @@ pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
|
||||
pub use plugin_point::PluginLanguages;
|
||||
pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
|
||||
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, VueComponentImport,
|
||||
VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility, set_external_syntax, shift_pos,
|
||||
shift_range,
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ClassBases, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax,
|
||||
ExternalToken, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind,
|
||||
SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError,
|
||||
VueComponentImport, VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility,
|
||||
set_external_syntax, shift_pos, shift_range,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Extraction of Python class base names, used to build an inheritance graph
|
||||
//! without an LSP `typeHierarchyProvider`.
|
||||
|
||||
use super::lines::LineIndex;
|
||||
use crate::{LanguageId, Syntax};
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
/// A Python class definition and the base names written in its source.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClassBases {
|
||||
/// The class name (bare identifier).
|
||||
pub name: String,
|
||||
/// 0-based line of the class name (selection-range start).
|
||||
pub line: u32,
|
||||
/// 0-based UTF-16 column of the class name (selection-range start).
|
||||
pub col: u32,
|
||||
/// Source-written base names (last segment of a qualified name).
|
||||
pub bases: Vec<String>,
|
||||
}
|
||||
|
||||
impl Syntax {
|
||||
/// Class definitions of `text` with their base names, in source order.
|
||||
/// Only Python is supported; every other language returns an empty vector.
|
||||
pub fn class_bases(&self, lang: &LanguageId, text: &str) -> Vec<ClassBases> {
|
||||
self.analyze(lang, text, |g, tree, text| (g.spec.name == "python").then(|| extract(tree, text)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(tree: &Tree, text: &str) -> Vec<ClassBases> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = tree.walk();
|
||||
visit(&mut cursor, &mut |node| {
|
||||
if node.kind() != "class_definition" {
|
||||
return;
|
||||
}
|
||||
let Some(name_node) = node.child_by_field_name("name") else { return };
|
||||
let name = text.get(name_node.byte_range()).unwrap_or_default().to_owned();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Selection-range start of the class: its name identifier (`class Foo`
|
||||
// -> `Foo`), matching `outline.rs` and the gutter marker candidates.
|
||||
let range = idx.range(name_node.start_byte(), name_node.end_byte());
|
||||
let bases = node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default();
|
||||
out.push(ClassBases { name, line: range.start.line, col: range.start.col, bases });
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Depth-first pre-order traversal of all descendants of `cursor`'s node.
|
||||
fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
|
||||
loop {
|
||||
f(cursor.node());
|
||||
if cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
if cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn base_names(args: Node, text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = args.walk();
|
||||
for arg in args.named_children(&mut cursor) {
|
||||
if let Some(name) = base_name(arg, text) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bare name of one base expression, or `None` for keyword arguments and
|
||||
/// expressions that are not a (possibly qualified) name.
|
||||
fn base_name(arg: Node, text: &str) -> Option<String> {
|
||||
match arg.kind() {
|
||||
"identifier" => text.get(arg.byte_range()).map(str::to_owned),
|
||||
// `pkg.mod.Base` -> `Base`.
|
||||
"attribute" => {
|
||||
let attr = arg.child_by_field_name("attribute")?;
|
||||
(attr.kind() == "identifier").then(|| text.get(attr.byte_range()).unwrap_or_default().to_owned())
|
||||
}
|
||||
// `Generic[T]` -> `Generic`.
|
||||
"subscript" => {
|
||||
let value = arg.child_by_field_name("value")?;
|
||||
(value.kind() == "identifier").then(|| text.get(value.byte_range()).unwrap_or_default().to_owned())
|
||||
}
|
||||
// `metaclass=Meta`, `**kwargs`, `*bases` and other expressions.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bases(src: &str) -> Vec<ClassBases> {
|
||||
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
|
||||
S.get_or_init(Syntax::new).class_bases(&LanguageId::new("python"), src)
|
||||
}
|
||||
|
||||
fn shape(src: &str) -> Vec<(String, u32, u32, Vec<String>)> {
|
||||
bases(src).into_iter().map(|c| (c.name, c.line, c.col, c.bases)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bases() {
|
||||
assert_eq!(shape("class A: pass"), vec![("A".to_owned(), 0, 6, vec![])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_bases_skips_metaclass() {
|
||||
assert_eq!(
|
||||
shape("class B(A, C, metaclass=Meta): pass"),
|
||||
vec![("B".to_owned(), 0, 6, vec!["A".to_owned(), "C".to_owned()])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_name_takes_last_segment() {
|
||||
assert_eq!(shape("class D(pkg.mod.Base): pass"), vec![("D".to_owned(), 0, 6, vec!["Base".to_owned()])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscript_takes_value() {
|
||||
assert_eq!(shape("class E(Generic[T]): pass"), vec![("E".to_owned(), 0, 6, vec!["Generic".to_owned()])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_bases() {
|
||||
let src = "class F(\n A,\n B,\n):\n pass";
|
||||
assert_eq!(shape(src), vec![("F".to_owned(), 0, 6, vec!["A".to_owned(), "B".to_owned()])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_classes() {
|
||||
let src = "class Outer:\n class Inner(Y):\n pass";
|
||||
assert_eq!(
|
||||
shape(src),
|
||||
vec![("Outer".to_owned(), 0, 6, vec![]), ("Inner".to_owned(), 1, 10, vec!["Y".to_owned()]),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_strings_are_ignored() {
|
||||
let src = r#"
|
||||
# class Fake(Nope): pass
|
||||
doc = """
|
||||
class InDocstring(X): pass
|
||||
"""
|
||||
s = 'class InString(Y): pass'
|
||||
class Real(Base): pass
|
||||
"#;
|
||||
assert_eq!(shape(src), vec![("Real".to_owned(), 6, 6, vec!["Base".to_owned()])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_languages_are_empty() {
|
||||
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
|
||||
let s = S.get_or_init(Syntax::new);
|
||||
assert!(s.class_bases(&LanguageId::new("rust"), "struct A;").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod external;
|
||||
mod folding;
|
||||
mod grammars;
|
||||
mod highlight;
|
||||
mod inheritance;
|
||||
pub(crate) mod lines;
|
||||
mod literals;
|
||||
mod outline;
|
||||
@@ -22,6 +23,7 @@ pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
|
||||
use grammars::{SPECS, Spec};
|
||||
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, Range};
|
||||
pub use inheritance::ClassBases;
|
||||
pub use lines::{shift_pos, shift_range};
|
||||
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
|
||||
pub use outline::merge_visibility;
|
||||
|
||||
@@ -172,6 +172,20 @@ test("accepting a deleted version removes the file before staging", async ({ pag
|
||||
]);
|
||||
});
|
||||
|
||||
test("a long side shows only the lines it changed against the base", async ({ page }) => {
|
||||
// Edits spread over a long file make the default diff give up and mark everything between them.
|
||||
const base = Array.from({ length: 2000 }, (_, i) => `"node_modules/pkg-${i}": "1.0.${i}",`);
|
||||
const bump = (at: number) => lines(...base.map((l, i) => (i % 100 === at ? l.replace('"1.0.', '"2.0.') : l)));
|
||||
await mountMerge(page, [lines(...base), bump(3), bump(50)]);
|
||||
for (const [name, line] of [
|
||||
["Your version", 3],
|
||||
["Their version", 50],
|
||||
] as const) {
|
||||
const deleted = page.getByRole("region", { name }).locator(".cm-deletedChunk").first().locator(".cm-deletedLine");
|
||||
await expect(deleted).toHaveText([base[line]!]);
|
||||
}
|
||||
});
|
||||
|
||||
test("the result starts as the base and every change waits for a decision", async ({ page }) => {
|
||||
await mountMerge(page);
|
||||
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
|
||||
|
||||
@@ -362,3 +362,10 @@ pub async fn db_apply_edits(
|
||||
pub fn db_console_path(state: State<'_, AppState>, id: String) -> IdeResult<IpcPath> {
|
||||
ide_host::ops::db::console_path(&state.host, &id)
|
||||
}
|
||||
|
||||
/// The query consoles of data source `id`; `create` adds the next one first.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn db_consoles(state: State<'_, AppState>, id: String, create: bool) -> IdeResult<Vec<IpcPath>> {
|
||||
ide_host::ops::db::consoles(&state.host, &id, create)
|
||||
}
|
||||
@@ -248,6 +248,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
nav::web_goto,
|
||||
nav::nav_resolve_module,
|
||||
nav::nav_index_definitions,
|
||||
nav::nav_inheritance,
|
||||
nav::fs_resolve_reference,
|
||||
nav::path_completions,
|
||||
nav::web_unresolved_references,
|
||||
@@ -746,5 +747,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
db::db_table_page,
|
||||
db::db_apply_edits,
|
||||
db::db_console_path,
|
||||
db::db_consoles,
|
||||
])
|
||||
}
|
||||
@@ -115,6 +115,14 @@ pub async fn nav_index_definitions(
|
||||
ide_host::ops::nav::nav_index_definitions(&state.host, window.label(), name, from).await
|
||||
}
|
||||
|
||||
/// Every Python class in the project (attached dependency roots included) and its base names,
|
||||
/// for native inheritance gutter markers.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn nav_inheritance(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ClassInheritance>> {
|
||||
ide_host::ops::nav::nav_inheritance(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -475,6 +475,11 @@ export const commands = {
|
||||
* plus its configured dependencies).
|
||||
*/
|
||||
navIndexDefinitions: (name: string, from: IpcPath) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("nav_index_definitions", { name, from })),
|
||||
/**
|
||||
* Every Python class in the project (attached dependency roots included) and its base names,
|
||||
* for native inheritance gutter markers.
|
||||
*/
|
||||
navInheritance: () => typedError<ClassInheritance[], IdeError>(__TAURI_INVOKE("nav_inheritance")),
|
||||
/** The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). */
|
||||
fsResolveReference: (from: IpcPath, value: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("fs_resolve_reference", { from, value })),
|
||||
/** Files and folders that continue `prefix`, a path being typed in `from`. */
|
||||
@@ -1726,6 +1731,8 @@ export const commands = {
|
||||
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
|
||||
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
|
||||
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
|
||||
/** The query consoles of data source `id`; `create` adds the next one first. */
|
||||
dbConsoles: (id: string, create: boolean) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("db_consoles", { id, create })),
|
||||
};
|
||||
|
||||
/* Types */
|
||||
@@ -2889,6 +2896,25 @@ export type ChoiceOption = {
|
||||
label: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* A Python class definition and the base names written in its source. The UI builds a
|
||||
* name → locations index from every class in the project (attached dependency roots
|
||||
* included) to compute ↑ overrides / ↓ overridden gutter markers without a language
|
||||
* server's type-hierarchy provider.
|
||||
*/
|
||||
export type ClassInheritance = {
|
||||
/** Start of the class definition's selection range (0-based line, UTF-16 column). */
|
||||
path: IpcPath,
|
||||
line: number,
|
||||
col: number,
|
||||
name: string,
|
||||
/**
|
||||
* Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
|
||||
* qualified name keeps only its last segment).
|
||||
*/
|
||||
bases: string[],
|
||||
};
|
||||
|
||||
export type ClassifiedReference = {
|
||||
/** Optional for older Java extensions; missing means unclassified. */
|
||||
access?: ReferenceAccess | null,
|
||||
|
||||
@@ -27,12 +27,14 @@ import {
|
||||
isCancelled,
|
||||
nextRequestId,
|
||||
onIdeEvent,
|
||||
type ClassInheritance,
|
||||
type HierarchyItem,
|
||||
type Location,
|
||||
type OutlineItem,
|
||||
type Pos,
|
||||
type RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { editorPrefs, type EditorExtras } from "@/platform/editorSettings";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { gutterIconMarkup } from "@/ui";
|
||||
@@ -48,7 +50,7 @@ import {
|
||||
type MarkerCandidate,
|
||||
type MarkerGroup,
|
||||
} from "./lineMarkers";
|
||||
import { overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
|
||||
import { baseName, overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
|
||||
import styles from "./LineMarkers.module.css";
|
||||
|
||||
/** Lines above / below the viewport that are asked with it. */
|
||||
@@ -85,16 +87,49 @@ export function affectsMarkers(paths: readonly string[]): boolean {
|
||||
return paths.some((p) => !OUTPUT_PATH.test(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Python inheritance graph of the open project, shared by every document and kept
|
||||
* across edits: it is a whole-project index answer, not a per-document one, so scanning
|
||||
* it again for each edit would walk every Python file again. Dropped when other files
|
||||
* change or a server settles (the index may have moved on).
|
||||
*/
|
||||
let inheritanceGraphCache: { root: string; promise: Promise<ClassInheritance[]> } | null = null;
|
||||
|
||||
/** The project whose graph is cached; `""` for no project (a loose file). */
|
||||
function projectRoot(): string {
|
||||
return useProject.getState().project?.root ?? "";
|
||||
}
|
||||
|
||||
/** Forgets the cached graph (files changed, a server settled, or tests). */
|
||||
export function forgetInheritanceGraph() {
|
||||
inheritanceGraphCache = null;
|
||||
}
|
||||
|
||||
function inheritanceGraph(): Promise<ClassInheritance[]> {
|
||||
const root = projectRoot();
|
||||
if (!inheritanceGraphCache || inheritanceGraphCache.root !== root) {
|
||||
inheritanceGraphCache = { root, promise: lspRequest(() => commands.navInheritance()) };
|
||||
// A failed request must not stick: the next pass retries it.
|
||||
inheritanceGraphCache.promise.catch(() => {
|
||||
if (inheritanceGraphCache?.root === root) inheritanceGraphCache = null;
|
||||
});
|
||||
}
|
||||
return inheritanceGraphCache.promise;
|
||||
}
|
||||
|
||||
/** Subscribes once per window and stays subscribed, so the settle echo guard survives remounts. */
|
||||
function watchChanges() {
|
||||
if (watching) return;
|
||||
watching = true;
|
||||
onIdeEvent("fsChanged", (e) => {
|
||||
if (affectsMarkers([...e.created, ...e.modified, ...e.removed])) changes++;
|
||||
if (!affectsMarkers([...e.created, ...e.modified, ...e.removed])) return;
|
||||
changes++;
|
||||
forgetInheritanceGraph();
|
||||
});
|
||||
// Answers (outlines too) given while a server was starting or indexing may be incomplete.
|
||||
onServerSettled(() => {
|
||||
docMarkers.clear();
|
||||
forgetInheritanceGraph();
|
||||
for (const plugin of live) plugin.refreshSoon();
|
||||
});
|
||||
}
|
||||
@@ -131,6 +166,13 @@ export interface MarkerDeps {
|
||||
implementations: (positions: Pos[]) => Promise<Location[][]>;
|
||||
supers: SuperDeps;
|
||||
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
|
||||
/**
|
||||
* Every Python class of the project and its base names (the IDE index, attached
|
||||
* dependency roots included). Some servers (basedpyright, pyrefly) answer no
|
||||
* `typeHierarchyProvider`, so ↑/↓ are computed from this graph instead.
|
||||
* Absent for languages whose servers do provide one (Java, Go, ...).
|
||||
*/
|
||||
inheritance?: () => Promise<ClassInheritance[]>;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -142,6 +184,8 @@ interface CacheEntry {
|
||||
export interface MarkerCache {
|
||||
entries: Map<string, CacheEntry>;
|
||||
unsupported: Set<MarkerGroup>;
|
||||
/** The Python inheritance graph for this document version, fetched once per cache. */
|
||||
inheritance?: Promise<ClassInheritance[] | null>;
|
||||
}
|
||||
|
||||
export const emptyCache = (): MarkerCache => ({ entries: new Map(), unsupported: new Set() });
|
||||
@@ -164,11 +208,16 @@ export async function fillMarkers(
|
||||
if (!e) cache.entries.set(key, (e = {}));
|
||||
return e;
|
||||
};
|
||||
// A Python inheritance graph answers both groups even when the language server
|
||||
// has no type hierarchy: it must keep the candidates alive through the LSP
|
||||
// "unsupported" bookkeeping, or the fallback would never get to run.
|
||||
const graph = deps.inheritance ? await (cache.inheritance ??= deps.inheritance().catch(() => null)) : null;
|
||||
const fallback = (graph?.length ?? 0) > 0;
|
||||
const down =
|
||||
groups.has("inheritors") && !cache.unsupported.has("inheritors")
|
||||
groups.has("inheritors") && (fallback || !cache.unsupported.has("inheritors"))
|
||||
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
|
||||
: [];
|
||||
const askSupers = groups.has("supers") && !cache.unsupported.has("supers");
|
||||
const askSupers = groups.has("supers") && (fallback || !cache.unsupported.has("supers"));
|
||||
const up = askSupers
|
||||
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
|
||||
: [];
|
||||
@@ -186,8 +235,11 @@ export async function fillMarkers(
|
||||
asked = await deps.implementations(ask.map((c) => ({ line: c.line, col: c.col })));
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) throw e;
|
||||
if (isUnavailable(e)) cache.unsupported.add("inheritors");
|
||||
for (let i = down.length - 1; i >= 0; i--) if (!known.has(down[i]!)) down.splice(i, 1);
|
||||
// With a fallback the candidates stay: the graph may still answer them.
|
||||
if (!fallback) {
|
||||
if (isUnavailable(e)) cache.unsupported.add("inheritors");
|
||||
for (let i = down.length - 1; i >= 0; i--) if (!known.has(down[i]!)) down.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
implementations = down.map((c) => withoutSelf(path, c, known.get(c) ?? asked[ask.indexOf(c)] ?? []));
|
||||
@@ -208,8 +260,79 @@ export async function fillMarkers(
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Python without a type-hierarchy provider: the IDE's inheritance graph holds every
|
||||
// `class X(A)` of the project, so ↑/↓ are computed from it instead.
|
||||
const byName = new Map<string, ClassInheritance[]>();
|
||||
if (graph) for (const cls of graph) byName.set(cls.name, [...(byName.get(cls.name) ?? []), cls]);
|
||||
// The inheritance graph's direct subclasses, keyed by the *declaration* each base
|
||||
// resolves to (path + position), so classes sharing a bare name never mix up.
|
||||
const subclassesOf = new Map<string, ClassInheritance[]>();
|
||||
for (const cls of graph ?? []) {
|
||||
for (const base of new Set(cls.bases)) {
|
||||
for (const decl of resolveBase(byName, base, cls.path)) {
|
||||
const key = declarationKey(decl);
|
||||
subclassesOf.set(key, [...(subclassesOf.get(key) ?? []), cls]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const classOf = (c: MarkerCandidate): ClassInheritance | null => {
|
||||
// A class candidate is its own declaration; a method's is the `owner` it records.
|
||||
const at = c.method ? c.owner : { line: c.line, col: c.col };
|
||||
if (!at) return null;
|
||||
const cls = candidates.find((k) => !k.method && k.line === at.line && k.col === at.col) ?? (c.method ? null : c);
|
||||
if (!cls) return null;
|
||||
return byName.get(cls.name)?.find((x) => x.path === path && x.line === cls.line && x.col === cls.col) ?? null;
|
||||
};
|
||||
/** The declarations a subclass overrides: the subclass itself, or its same-named method. */
|
||||
const subclassDeclarations = async (c: MarkerCandidate, owner: ClassInheritance): Promise<Location[]> => {
|
||||
const subs = subclassesOf.get(declarationKey(owner)) ?? [];
|
||||
if (!c.method) return subs.map(classLocation);
|
||||
const bare = c.symbol.replace(/\s*[(<].*$/, "");
|
||||
const targets: Location[] = [];
|
||||
for (const sub of subs) {
|
||||
const outline = await deps.supers.outline(sub.path).catch(() => [] as OutlineItem[]);
|
||||
const m = methodInOutline(outline, sub, bare);
|
||||
if (m) targets.push({ path: sub.path, range: m.selection_range });
|
||||
}
|
||||
return targets;
|
||||
};
|
||||
if (byName.size) {
|
||||
for (let i = 0; i < down.length; i++) {
|
||||
const c = down[i]!;
|
||||
const owner = classOf(c);
|
||||
if (!owner) continue;
|
||||
// Merge, never replace: a server that answers sees one root, so its list can miss
|
||||
// the subclasses living in an attached project while still being non-empty.
|
||||
implementations[i] = withoutSelf(
|
||||
path,
|
||||
c,
|
||||
mergeLocations(implementations[i] ?? [], await subclassDeclarations(c, owner)),
|
||||
);
|
||||
}
|
||||
}
|
||||
const supers: SuperMethod[][] = [];
|
||||
for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers));
|
||||
if (byName.size) {
|
||||
for (let i = 0; i < up.length; i++) {
|
||||
const c = up[i]!;
|
||||
const self = classOf(c);
|
||||
if (!self) continue;
|
||||
const found = await superMethodsFrom(byName, self.name, c.symbol, deps);
|
||||
if (!found.length) continue;
|
||||
const known = supers[i]!;
|
||||
// Same merge rule as ↓: add the graph's super methods to what the server answered.
|
||||
const extra = found.map((f) => f.location).filter((l) => !known.some((s) => sameLocation(s.location, l)));
|
||||
const owners = [...new Set(found.map((f) => f.owner))].sort((a, b) => a.localeCompare(b));
|
||||
entry(c).supers = {
|
||||
kind: "overriding",
|
||||
line: c.line,
|
||||
name: c.name,
|
||||
targets: [...known.map((s) => s.location), ...extra],
|
||||
tooltip: tx`Overrides method in ${owners.join(", ")}`,
|
||||
chooserTitle: tx`Choose Super Method of ${c.name} (${known.length + extra.length} found)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Go: an implementation request on a type or method answers the interfaces it satisfies.
|
||||
let satisfied: Location[][] = [];
|
||||
if (implicit.length) {
|
||||
@@ -228,10 +351,103 @@ export async function fillMarkers(
|
||||
...satisfied.flat().map((l) => l.path),
|
||||
]);
|
||||
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files)));
|
||||
up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files)));
|
||||
// A candidate the graph answered already has its marker: don't null it out here.
|
||||
up.forEach((c, i) => {
|
||||
if (supers[i]!.length) entry(c).supers = supersMarker(c, supers[i]!, files);
|
||||
});
|
||||
implicit.forEach((c, i) => (entry(c).supers = implementsMarker(c, withoutSelf(path, c, satisfied[i] ?? []), files)));
|
||||
}
|
||||
|
||||
// One entry per declaration line: the server and the graph may point at the same
|
||||
// declaration with different columns (the name vs the whole statement).
|
||||
const sameLocation = (a: Location, b: Location) => a.path === b.path && a.range.start.line === b.range.start.line;
|
||||
|
||||
/** `first`, then the locations of `more` not already in it. */
|
||||
function mergeLocations(first: readonly Location[], more: readonly Location[]): Location[] {
|
||||
return [...first, ...more.filter((l) => !first.some((f) => sameLocation(f, l)))];
|
||||
}
|
||||
|
||||
/** Identifies one class declaration in the inheritance graph. */
|
||||
function declarationKey(cls: ClassInheritance): string {
|
||||
return `${cls.path}:${cls.line}:${cls.col}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The classes a base name names. A bare name is only trusted when it is unique in the
|
||||
* project, or names a class in the subclass's own file: a `Config` declared in hundreds
|
||||
* of modules must not make every one of them a subclass of every other.
|
||||
*/
|
||||
function resolveBase(byName: ReadonlyMap<string, ClassInheritance[]>, name: string, from: string): ClassInheritance[] {
|
||||
const found = byName.get(name) ?? [];
|
||||
if (found.length <= 1) return found;
|
||||
return found.filter((c) => c.path === from);
|
||||
}
|
||||
|
||||
/** The class declaration itself as a navigation target (its name identifier). */
|
||||
function classLocation(cls: ClassInheritance): Location {
|
||||
const start = { line: cls.line, col: cls.col };
|
||||
return { path: cls.path, range: { start, end: start } };
|
||||
}
|
||||
|
||||
/** The method `method` (bare name) directly inside the class at `cls`'s position. */
|
||||
function methodInOutline(outline: readonly OutlineItem[], cls: ClassInheritance, method: string): OutlineItem | null {
|
||||
const at = cls;
|
||||
const find = (items: readonly OutlineItem[]): OutlineItem | null => {
|
||||
for (const it of items) {
|
||||
if (it.kind === "class" && it.selection_range.start.line === at.line && it.selection_range.start.col === at.col) {
|
||||
return it.children.find((ch) => ch.kind === "method" && baseName(ch.name) === method) ?? null;
|
||||
}
|
||||
const deeper = find(it.children);
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return find(outline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Super methods of `method` declared by a class in `byName`, walking the inheritance
|
||||
* graph's bases up to a few levels. Empty when no base declares the method.
|
||||
*/
|
||||
async function superMethodsFrom(
|
||||
byName: ReadonlyMap<string, ClassInheritance[]>,
|
||||
start: string,
|
||||
method: string,
|
||||
deps: MarkerDeps,
|
||||
depth = 3,
|
||||
): Promise<{ location: Location; owner: string }[]> {
|
||||
const bare = method.replace(/\s*[(<].*$/, "");
|
||||
const seen = new Set<string>([start]);
|
||||
let level = [start];
|
||||
for (let d = 0; d < depth && level.length; d++) {
|
||||
const next: string[] = [];
|
||||
const found: { location: Location; owner: string }[] = [];
|
||||
for (const name of level) {
|
||||
for (const cls of byName.get(name) ?? []) {
|
||||
for (const base of cls.bases) {
|
||||
if (!seen.add(base)) continue;
|
||||
// Same rule as the graph: only a base that names one class is followed.
|
||||
const declarations = resolveBase(byName, base, cls.path);
|
||||
let answered = false;
|
||||
for (const baseCls of declarations) {
|
||||
const outline = await deps.supers.outline(baseCls.path).catch(() => [] as OutlineItem[]);
|
||||
const m = methodInOutline(outline, baseCls, bare);
|
||||
if (m) {
|
||||
found.push({ location: { path: baseCls.path, range: m.selection_range }, owner: base });
|
||||
answered = true;
|
||||
}
|
||||
}
|
||||
// Not found in a base that declares nothing: keep walking its own bases.
|
||||
if (!answered) next.push(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.length) return found;
|
||||
level = next;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Overriders of a type's methods from one walk of its subtypes and their outlines, instead of one
|
||||
* implementation request per method: jdtls builds the type hierarchy again for every implementation
|
||||
@@ -575,6 +791,9 @@ function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: Reques
|
||||
},
|
||||
},
|
||||
readFiles: readManyFileLines,
|
||||
// Python servers answer no type hierarchy (basedpyright, pyrefly): the IDE's
|
||||
// own index knows every `class X(Base)` of the project, attached roots included.
|
||||
inheritance: ctx.language === "python" ? inheritanceGraph : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
IpcError,
|
||||
type ClassInheritance,
|
||||
type HierarchyItem,
|
||||
type Location,
|
||||
type OutlineItem,
|
||||
@@ -208,8 +209,9 @@ function server(
|
||||
impls: Record<string, Location[]>,
|
||||
supers: Record<string, HierarchyItem[]>,
|
||||
subtypes: Record<string, HierarchyItem[]> = {},
|
||||
inheritance?: ClassInheritance[],
|
||||
) {
|
||||
const calls = { implementations: [] as Pos[][], supertypes: 0, subtypes: 0 };
|
||||
const calls = { implementations: [] as Pos[][], supertypes: 0, subtypes: 0, inheritance: 0 };
|
||||
const outlines: Record<string, OutlineItem[]> = {
|
||||
[SHAPE_PATH]: SHAPE_OUTLINE,
|
||||
"/p/Circle.java": CIRCLE_OUTLINE,
|
||||
@@ -234,6 +236,35 @@ function server(
|
||||
outline: async (p) => outlines[p] ?? [],
|
||||
},
|
||||
readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])),
|
||||
inheritance: inheritance
|
||||
? async () => {
|
||||
calls.inheritance++;
|
||||
return inheritance;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
/** A Python project: the server answers neither implementations nor a type hierarchy. */
|
||||
function pythonServer(inheritance: ClassInheritance[], outlines: Record<string, OutlineItem[]>) {
|
||||
const calls = { implementations: 0, inheritance: 0 };
|
||||
const deps = (): MarkerDeps => ({
|
||||
implementations: async () => {
|
||||
calls.implementations++;
|
||||
throw new IpcError({ kind: "unavailable", message: "basedpyright doesn't support implementation" });
|
||||
},
|
||||
supers: {
|
||||
supertypes: async () => {
|
||||
throw new IpcError({ kind: "unavailable", message: "basedpyright doesn't support type hierarchy" });
|
||||
},
|
||||
outline: async (p) => outlines[p] ?? [],
|
||||
},
|
||||
readFiles: async () => new Map(),
|
||||
inheritance: async () => {
|
||||
calls.inheritance++;
|
||||
return inheritance;
|
||||
},
|
||||
});
|
||||
return { calls, deps };
|
||||
}
|
||||
@@ -637,3 +668,98 @@ describe("overriders of a type's methods from its subtypes", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("python inheritance fallback", () => {
|
||||
// a.py: `class A` (line 0) with `m` (line 1), then `class B(A)` (line 3) with `m` (line 4).
|
||||
const outline = [
|
||||
item("A", "class", 0, 2, [item("m", "method", 1, 1)], 6),
|
||||
item("B", "class", 3, 5, [item("m", "method", 4, 4)], 6),
|
||||
];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/a.py", line: 0, col: 6, name: "A", bases: [] },
|
||||
{ path: "/p/a.py", line: 3, col: 6, name: "B", bases: ["A"] },
|
||||
];
|
||||
|
||||
it("marks the subclass and the overridden method with no type hierarchy", async () => {
|
||||
const { deps, calls } = pythonServer(graph, { "/p/a.py": outline });
|
||||
const candidates = markerCandidates(outline, () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/a.py", candidates, ALL, deps(), cache);
|
||||
const markers = cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind, m.targets.map((t) => t.path)]);
|
||||
expect(markers).toEqual([
|
||||
// The class B subclasses A...
|
||||
[0, "overridden", ["/p/a.py"]],
|
||||
// ...and its `m` overrides A's `m`.
|
||||
[1, "overridden", ["/p/a.py"]],
|
||||
[4, "overriding", ["/p/a.py"]],
|
||||
]);
|
||||
// The graph is asked once per cache, not once per candidate.
|
||||
expect(calls.inheritance).toBe(1);
|
||||
});
|
||||
|
||||
it("survives an unavailable implementation provider (the fallback keeps its candidates)", async () => {
|
||||
const { deps, calls } = pythonServer(graph, { "/p/a.py": outline });
|
||||
const candidates = markerCandidates(outline, () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/a.py", candidates, ALL, deps(), cache);
|
||||
// The server said "unavailable", but the graph still answered ↓ and ↑.
|
||||
expect(calls.implementations).toBeGreaterThan(0);
|
||||
expect(cache.unsupported.size).toBe(0);
|
||||
});
|
||||
|
||||
it("adds the graph's cross-project subclasses to a non-empty server answer", async () => {
|
||||
// The server (single root) answers the local subclass only; the attached-project
|
||||
// subclass is known to the IDE's graph and must join the list.
|
||||
const outline = [item("A", "class", 0, 2, [], 6), item("B", "class", 3, 5, [], 6)];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/a.py", line: 0, col: 6, name: "A", bases: [] },
|
||||
{ path: "/p/a.py", line: 3, col: 6, name: "B", bases: ["A"] },
|
||||
{ path: "/p/attached/a.py", line: 0, col: 6, name: "C", bases: ["A"] },
|
||||
];
|
||||
const { deps } = server({ "/p/a.py:0": [loc("/p/a.py", 3)] }, {}, {}, graph);
|
||||
const candidates = markerCandidates(outline, () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/a.py", candidates, ALL, deps("/p/a.py"), cache);
|
||||
const targets = cachedMarkers(candidates, ALL, cache)
|
||||
.filter((m) => m.line === 0)
|
||||
.flatMap((m) => m.targets.map((t) => t.path));
|
||||
expect(targets).toEqual(["/p/a.py", "/p/attached/a.py"]);
|
||||
});
|
||||
|
||||
it("ignores a bare base name that many classes in the project share", async () => {
|
||||
// `Config` is declared in hundreds of modules: no edge, so no marker.
|
||||
const outline = [item("Child", "class", 0, 2, [], 6)];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/a.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/b.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/c.py", line: 0, col: 6, name: "Child", bases: ["Config"] },
|
||||
];
|
||||
const { deps } = pythonServer(graph, { "/p/c.py": outline });
|
||||
const candidates = markerCandidates(outline, () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
|
||||
expect(cachedMarkers(candidates, ALL, cache)).toEqual([]);
|
||||
});
|
||||
|
||||
it("trusts a shared base name when the subclass's own file declares it", async () => {
|
||||
const outline = [item("Config", "class", 0, 1, [], 6), item("Child", "class", 3, 5, [], 6)];
|
||||
const graph: ClassInheritance[] = [
|
||||
{ path: "/p/c.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/other.py", line: 0, col: 6, name: "Config", bases: [] },
|
||||
{ path: "/p/c.py", line: 3, col: 6, name: "Child", bases: ["Config"] },
|
||||
];
|
||||
const { deps } = pythonServer(graph, { "/p/c.py": outline });
|
||||
const candidates = markerCandidates(outline, () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/c.py", candidates, ALL, deps(), cache);
|
||||
expect(cachedMarkers(candidates, ALL, cache).map((m) => [m.line, m.kind])).toEqual([[0, "overridden"]]);
|
||||
});
|
||||
|
||||
it("keeps the existing server path when no inheritance graph is injected", async () => {
|
||||
const { deps } = server({}, {});
|
||||
const candidates = markerCandidates([item("Foo", "class", 0, 2, [item("m", "method", 1, 1)], 6)], () => "");
|
||||
const cache = emptyCache();
|
||||
await fillMarkers("/p/a.py", candidates, ALL, deps("/p/a.py"), cache);
|
||||
expect(cachedMarkers(candidates, ALL, cache)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
loadDatabases,
|
||||
listsDatabases,
|
||||
sourceById,
|
||||
newConsole,
|
||||
openConsole,
|
||||
openHistory,
|
||||
openSourceDialog,
|
||||
@@ -198,6 +199,24 @@ export function activate(): () => void {
|
||||
else toolWindowService().show(DB_TOOL_WINDOW, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db.newConsole",
|
||||
title: "New Query Console",
|
||||
category: "Tools",
|
||||
icon: "db-console",
|
||||
keywords: ["database", "sql console"],
|
||||
when: "projectOpen",
|
||||
run: async ({ arg }) => {
|
||||
const current = projectRequest();
|
||||
if (!useDatabase.getState().loaded) await loadSources();
|
||||
if (!current()) return;
|
||||
const { sources, selectedSource } = useDatabase.getState();
|
||||
const node = arg && typeof arg === "object" && "kind" in arg ? nodeTarget(arg as DbNode) : null;
|
||||
const id = node ?? selectedSource ?? (sources.length === 1 ? sources[0]!.id : null);
|
||||
if (id) await newConsole(id);
|
||||
else toolWindowService().show(DB_TOOL_WINDOW, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db.execute",
|
||||
title: "Execute SQL Statement",
|
||||
|
||||
@@ -906,6 +906,29 @@ export async function openConsole(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The query console files of data source `id`: `console.sql`, then `console_1.sql`, `console_2.sql`… */
|
||||
export function consoleFiles(id: string): Promise<string[]> {
|
||||
return call(commands.dbConsoles(id, false));
|
||||
}
|
||||
|
||||
/** Opens one of the query consoles of data source `id`. */
|
||||
export async function openConsoleFile(id: string, path: string): Promise<void> {
|
||||
bindConsole(path, id);
|
||||
await editorService().openFile(path, { focus: true });
|
||||
}
|
||||
|
||||
/** Creates and opens another query console of data source `id` (DataGrip's New › Query Console). */
|
||||
export async function newConsole(id: string): Promise<void> {
|
||||
try {
|
||||
const before = new Set(await consoleFiles(id));
|
||||
const after = await call(commands.dbConsoles(id, true));
|
||||
const path = after.find((p) => !before.has(p)) ?? after[after.length - 1];
|
||||
if (path) await openConsoleFile(id, path);
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Cannot open the query console", body: message(e) });
|
||||
}
|
||||
}
|
||||
|
||||
let nextTab = 1;
|
||||
let nextRun = 1;
|
||||
let nextLine = 1;
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
loadSchemas,
|
||||
loadSources,
|
||||
loadTables,
|
||||
consoleFiles,
|
||||
newConsole,
|
||||
openConsole,
|
||||
openConsoleFile,
|
||||
openConsoleWith,
|
||||
openDdl,
|
||||
openObjectDdl,
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
} from "./store";
|
||||
import { tableInput } from "./tableInput";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { chooseItem } from "@/workbench/inputDialogs";
|
||||
import { databaseRequest } from "./requests";
|
||||
|
||||
/** Context key: the Database tree has focus. */
|
||||
@@ -279,7 +283,7 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
const id = n ? nodeTarget(n) : null;
|
||||
const { sources } = useDatabase.getState();
|
||||
const target = id ?? (sources.length === 1 ? (sources[0]!.id ?? null) : null);
|
||||
if (target) void openConsole(target);
|
||||
if (target) void jumpToConsole(target);
|
||||
else toolWindowService().show(DB_TOOL_WINDOW, true);
|
||||
},
|
||||
}),
|
||||
@@ -524,3 +528,14 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/** One console opens directly; with several, DataGrip's list of them plus New Query Console. */
|
||||
async function jumpToConsole(id: string): Promise<void> {
|
||||
const files = await consoleFiles(id).catch(() => []);
|
||||
if (files.length <= 1) return openConsole(id);
|
||||
const names = files.map((f) => (f.split(/[\\/]/).pop() ?? f).replace(/\.sql$/, ""));
|
||||
const create = tx("New Query Console");
|
||||
const picked = await chooseItem({ title: "Query Consoles", items: [...names, create] });
|
||||
if (picked === create) await newConsole(id);
|
||||
else if (picked) await openConsoleFile(id, files[names.indexOf(picked)]!);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ async function sourceFromClipboard() {
|
||||
|
||||
/** The toolbar's "+" menu (IDEA: New). */
|
||||
export function newMenu(): MenuItem[] {
|
||||
return [{ kind: "action", action: "db.openConsole", label: "Query Console" }, sep, ...dataSourceItems()];
|
||||
return [{ kind: "action", action: "db.newConsole", label: "Query Console" }, sep, ...dataSourceItems()];
|
||||
}
|
||||
|
||||
function sqlScripts(n: DbNode, table: boolean): MenuItem {
|
||||
@@ -88,7 +88,7 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
|
||||
const driver = sourceById(id)?.driver ?? "sqlite";
|
||||
const o = objectOf(n);
|
||||
const t = tableOf(n);
|
||||
const newConsole = act("db.openConsole", n, { label: "Query Console", icon: "db-console" });
|
||||
const newConsole = act("db.newConsole", n, { label: "Query Console", icon: "db-console" });
|
||||
const refresh = act("db.refresh", n);
|
||||
const quickDoc = act("db.quickDoc", n);
|
||||
const rename = act("db.rename", n, { disabled: !(n.kind === "source" || (o && canRename(driver, o))) });
|
||||
|
||||
@@ -401,6 +401,9 @@ describe("pull request helpers", () => {
|
||||
"ssh://[email protected]/o/r.git",
|
||||
"ssh://[email protected]:22/o/r.git",
|
||||
"git://github.com/o/r",
|
||||
// GitHub's SSH-over-HTTPS host.
|
||||
"ssh://[email protected]:443/o/r.git",
|
||||
"[email protected]:o/r.git",
|
||||
])
|
||||
expect(parseGitHubRemote(url), url).toEqual({ owner: "o", name: "r" });
|
||||
expect(parseGitHubRemote("https://gitlab.com/o/r.git")).toBeNull();
|
||||
|
||||
@@ -413,10 +413,10 @@ export function repositoryDisplayName(
|
||||
return showOwner ? `${repo.owner}/${repo.name}` : repo.name;
|
||||
}
|
||||
|
||||
/** `owner/name` of a github.com remote URL (https, ssh or scp-like), else null. */
|
||||
/** `owner/name` of a github.com remote URL (https, ssh or scp-like; SSH also via ssh.github.com), else null. */
|
||||
export function parseGitHubRemote(url: string): GHRepositoryCoordinates | null {
|
||||
const m =
|
||||
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|git:\/\/|(?:[^@/]+@)?)(?:www\.)?github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
|
||||
/^(?:(?:https?:\/\/(?:[^@/]+@)?|git:\/\/)(?:www\.)?|(?:ssh:\/\/(?:[^@/]+@)?|(?:[^@/]+@)?)(?:www\.|ssh\.)?)github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
|
||||
url.trim(),
|
||||
);
|
||||
return m ? { owner: m[1]!, name: m[2]! } : null;
|
||||
|
||||
@@ -207,3 +207,12 @@
|
||||
.pendingMethod {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.match {
|
||||
border-radius: 2px;
|
||||
color: var(--fg-search-match);
|
||||
background: var(--bg-search-match);
|
||||
}
|
||||
.matchCurrent {
|
||||
background: var(--bg-search-match-current);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* exchange (right): status line, timing, body (pretty-printed JSON / XML /
|
||||
* HTML or raw), response headers and the request as sent.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { HttpExchange, HttpHistoryEntry } from "@/platform/ipc";
|
||||
import {
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
T,
|
||||
useSelectionBoundary,
|
||||
} from "@/ui";
|
||||
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, type ConsoleMatch, type FindOptions } from "@/ui/consoleFind";
|
||||
import { formatBinding, hasPrimaryModifier, hostBinding } from "@/platform/keymap";
|
||||
import { bodyKind, formatMs, formatSize, prettyBody } from "./format";
|
||||
import { cancelRunning, clearHistory, refreshHistory, selectEntry, useHttpClient } from "./store";
|
||||
import styles from "./HttpClient.module.css";
|
||||
@@ -60,6 +63,49 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
|
||||
const r = ex.response;
|
||||
const kind = r?.body != null ? bodyKind(r.contentType, r.body) : "text";
|
||||
const body = useMemo(() => (r?.body == null ? null : raw ? r.body : prettyBody(kind, r.body)), [r, raw, kind]);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [findOptions, setFindOptions] = useState<FindOptions>(DEFAULT_FIND_OPTIONS);
|
||||
const [active, setActive] = useState(0);
|
||||
const findInput = useRef<HTMLInputElement>(null);
|
||||
const searchable = findOpen && tab === "body" && body != null;
|
||||
const lines = useMemo(() => (body ?? "").split("\n").map((text, id) => ({ id, text })), [body]);
|
||||
const found = useMemo(
|
||||
() => (searchable ? findInLines(lines, query, findOptions) : { matches: [], overflow: false, invalid: false }),
|
||||
[searchable, lines, query, findOptions],
|
||||
);
|
||||
const activeIndex = found.matches.length ? Math.min(active, found.matches.length - 1) : -1;
|
||||
useEffect(() => {
|
||||
pane.current?.querySelector("[data-find-current]")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}, [activeIndex, found]);
|
||||
const openFind = () => {
|
||||
setFindOpen(true);
|
||||
// The bar mounts with this render; focus it (and select the query) once it has.
|
||||
requestAnimationFrame(() => findInput.current?.select());
|
||||
};
|
||||
const closeFind = () => {
|
||||
setFindOpen(false);
|
||||
pane.current?.focus();
|
||||
};
|
||||
const goToMatch = (step: number) => {
|
||||
const n = found.matches.length;
|
||||
if (n) setActive((activeIndex + step + n) % n);
|
||||
};
|
||||
// ⌘F in the response, as in IDEA's response viewer: the find bar of the Run console.
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
tab === "body" &&
|
||||
body != null &&
|
||||
e.key.toLowerCase() === "f" &&
|
||||
hasPrimaryModifier(e) &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openFind();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.exchange}>
|
||||
<div className={styles.statusLine}>
|
||||
@@ -91,18 +137,52 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
|
||||
{ id: "request", label: "Request" },
|
||||
]}
|
||||
trailing={
|
||||
tab === "body" && kind !== "text" ? (
|
||||
<IconButton
|
||||
icon="soft-wrap"
|
||||
title={raw ? "Show Formatted" : "Show Raw"}
|
||||
pressed={raw}
|
||||
onClick={() => setRaw((v) => !v)}
|
||||
/>
|
||||
tab === "body" && body != null ? (
|
||||
<>
|
||||
<IconButton
|
||||
icon="search"
|
||||
title="Find…"
|
||||
shortcut={formatBinding(hostBinding("Meta+F"))}
|
||||
pressed={findOpen}
|
||||
onClick={() => (findOpen ? closeFind() : openFind())}
|
||||
/>
|
||||
{kind !== "text" && (
|
||||
<IconButton
|
||||
icon="soft-wrap"
|
||||
title={raw ? "Show Formatted" : "Show Raw"}
|
||||
pressed={raw}
|
||||
onClick={() => setRaw((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{searchable && (
|
||||
<ConsoleFindBar
|
||||
query={query}
|
||||
onChange={(q) => {
|
||||
setQuery(q);
|
||||
setActive(0);
|
||||
}}
|
||||
options={findOptions}
|
||||
onOptionsChange={(o) => {
|
||||
setFindOptions(o);
|
||||
setActive(0);
|
||||
}}
|
||||
count={found.matches.length}
|
||||
active={activeIndex}
|
||||
overflow={found.overflow}
|
||||
invalid={found.invalid}
|
||||
inputRef={findInput}
|
||||
onNext={() => goToMatch(1)}
|
||||
onPrevious={() => goToMatch(-1)}
|
||||
onClose={closeFind}
|
||||
label="Find in response"
|
||||
/>
|
||||
)}
|
||||
{/* Focusable, so Select All and Copy work on the response like in a read-only editor. */}
|
||||
<div ref={pane} className={styles.tabBody} tabIndex={0}>
|
||||
<div ref={pane} className={styles.tabBody} tabIndex={0} onKeyDown={onKeyDown}>
|
||||
{tab === "body" &&
|
||||
(r == null ? (
|
||||
<pre className={styles.pre}>{ex.entry.error}</pre>
|
||||
@@ -112,7 +192,7 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
|
||||
</EmptyState>
|
||||
) : (
|
||||
<pre className={styles.pre} aria-label={tx("Response body")}>
|
||||
{body}
|
||||
{found.matches.length ? highlighted(lines, found.matches, activeIndex) : body}
|
||||
{r.truncated && (
|
||||
<span className={styles.truncated}>{`\n… truncated (${formatSize(r.size)} received)`}</span>
|
||||
)}
|
||||
@@ -131,6 +211,32 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The body with its find matches marked, the current one distinctly. */
|
||||
function highlighted(lines: readonly { text: string }[], matches: readonly ConsoleMatch[], current: number): ReactNode {
|
||||
const out: ReactNode[] = [];
|
||||
let m = 0;
|
||||
lines.forEach(({ text }, line) => {
|
||||
if (line > 0) out.push("\n");
|
||||
let at = 0;
|
||||
for (; m < matches.length && matches[m]!.line === line; m++) {
|
||||
const { start, end } = matches[m]!;
|
||||
if (start > at) out.push(text.slice(at, start));
|
||||
out.push(
|
||||
<mark
|
||||
key={m}
|
||||
className={clsx(styles.match, m === current && styles.matchCurrent)}
|
||||
data-find-current={m === current ? "" : undefined}
|
||||
>
|
||||
{text.slice(start, end)}
|
||||
</mark>,
|
||||
);
|
||||
at = end;
|
||||
}
|
||||
if (at < text.length) out.push(text.slice(at));
|
||||
});
|
||||
return <Fragment>{out}</Fragment>;
|
||||
}
|
||||
|
||||
function HeaderTable({ headers }: { headers: Array<[string, string]> }) {
|
||||
return (
|
||||
<table className={styles.headers}>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Text } from "@codemirror/state";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileReferenceAt } from "./fileNavigation";
|
||||
|
||||
const doc = Text.of([
|
||||
"POST http://x/api",
|
||||
"",
|
||||
"< ./body.json",
|
||||
"> handler.js",
|
||||
"> {% client.test('ok', () => {}); %}",
|
||||
">> out/result.json",
|
||||
"<> 2026-11-10T135253.200.json",
|
||||
]);
|
||||
const at = (line: number, col: number) => fileReferenceAt(doc, doc.line(line).from + col);
|
||||
|
||||
describe("fileReferenceAt", () => {
|
||||
it("finds the file a body, handler, redirect or saved-response line names", () => {
|
||||
expect(at(3, 4)).toMatchObject({ value: "./body.json", response: false });
|
||||
expect(at(4, 3)).toMatchObject({ value: "handler.js", response: false });
|
||||
expect(at(6, 5)).toMatchObject({ value: "out/result.json", response: false });
|
||||
expect(at(7, 10)).toMatchObject({ value: "2026-11-10T135253.200.json", response: true });
|
||||
});
|
||||
|
||||
it("ignores inline handler scripts, the marker itself and other lines", () => {
|
||||
expect(at(5, 6)).toBeNull();
|
||||
expect(at(7, 0)).toBeNull();
|
||||
expect(at(1, 6)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Navigate › Declaration (⌘-click, ⌘B) on the file a `.http` line names: a request body
|
||||
* (`< ./body.json`), a response handler script (`> handler.js`), a response redirect
|
||||
* (`>> out.json`) and a saved response (`<> 2026-11-10T135253.200.json`). Saved responses
|
||||
* resolve against `.idea/httpRequests/`, where IntelliJ IDEA writes them, then against the
|
||||
* request file's folder.
|
||||
*/
|
||||
import type { Text } from "@codemirror/state";
|
||||
import { docOf } from "@/editor/lsp/lsp";
|
||||
import { registerNavigationProvider } from "@/editor/navigationProviders";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { HTTP_LANGUAGE } from "./editor";
|
||||
|
||||
/** The file named on the line at `offset`, as document offsets; `response` for a `<>` line. */
|
||||
export function fileReferenceAt(
|
||||
doc: Text,
|
||||
offset: number,
|
||||
): { value: string; from: number; to: number; response: boolean } | null {
|
||||
const line = doc.lineAt(offset);
|
||||
const m = /^(\s*(<>|>>!?|<|>)\s+)(\S(?:.*\S)?)\s*$/.exec(line.text);
|
||||
// `> {% … %}` is an inline handler script, not a file.
|
||||
if (!m || m[3]!.startsWith("{%")) return null;
|
||||
const from = line.from + m[1]!.length;
|
||||
const to = from + m[3]!.length;
|
||||
return offset >= from && offset <= to ? { value: m[3]!, from, to, response: m[2] === "<>" } : null;
|
||||
}
|
||||
|
||||
export function registerFileNavigation(): () => void {
|
||||
return registerNavigationProvider(async (view, offset) => {
|
||||
const doc = docOf(view);
|
||||
if (!doc || doc.virtual || doc.language !== HTTP_LANGUAGE) return null;
|
||||
const at = fileReferenceAt(view.state.doc, offset);
|
||||
if (!at) return null;
|
||||
const candidates = at.response ? [`/.idea/httpRequests/${at.value}`, at.value] : [at.value];
|
||||
for (const value of candidates) {
|
||||
const path = await call(commands.fsResolveReference(doc.path, value)).catch(() => null);
|
||||
if (path) {
|
||||
const start = { line: 0, col: 0 };
|
||||
return { from: at.from, to: at.to, targets: [{ path, range: { start, end: start } }] };
|
||||
}
|
||||
}
|
||||
return { from: at.from, to: at.to, targets: [] };
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
registerServiceView,
|
||||
showServiceView,
|
||||
} from "@/workbench/contributions";
|
||||
import { registerFileNavigation } from "./fileNavigation";
|
||||
import { registerVariableNavigation } from "./variableNavigation";
|
||||
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
|
||||
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
|
||||
@@ -134,6 +135,7 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerSpringEndpointProvider(),
|
||||
registerVariableNavigation(),
|
||||
registerFileNavigation(),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetHttpClient();
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HttpExchange } from "@/platform/ipc";
|
||||
|
||||
vi.mock("./store", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./store")>()),
|
||||
refreshHistory: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { useHttpClient } = await import("./store");
|
||||
const { HttpClientView } = await import("./HttpClientView");
|
||||
|
||||
const exchange: HttpExchange = {
|
||||
entry: {
|
||||
id: "e1",
|
||||
timeMs: 0,
|
||||
name: null,
|
||||
method: "GET",
|
||||
url: "http://x/api",
|
||||
status: 200,
|
||||
totalMs: 5,
|
||||
file: null,
|
||||
error: null,
|
||||
},
|
||||
request: { name: null, method: "GET", url: "http://x/api", headers: [], body: null },
|
||||
response: {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
version: "HTTP/1.1",
|
||||
headers: [["content-type", "text/plain"]],
|
||||
contentType: "text/plain",
|
||||
body: "alpha beta\nbeta gamma\nBeta",
|
||||
size: 26,
|
||||
truncated: false,
|
||||
ttfbMs: 1,
|
||||
totalMs: 5,
|
||||
},
|
||||
};
|
||||
|
||||
describe("HTTP response find", () => {
|
||||
afterEach(() => useHttpClient.setState({ history: [], exchanges: {}, selected: null }));
|
||||
|
||||
it("opens a find bar with ⌘F in the response and marks the matches", () => {
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
useHttpClient.setState({ history: [exchange.entry], exchanges: { e1: exchange }, selected: "e1" });
|
||||
render(<HttpClientView />);
|
||||
const body = screen.getByLabelText("Response body");
|
||||
fireEvent.keyDown(body, { key: "f", metaKey: true, ctrlKey: true });
|
||||
const input = screen.getByLabelText("Find in response", { selector: "input" });
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "beta" } });
|
||||
});
|
||||
const marks = body.querySelectorAll("mark");
|
||||
expect([...marks].map((m) => m.textContent)).toEqual(["beta", "beta", "Beta"]);
|
||||
expect(screen.getByText("1/3")).toBeTruthy();
|
||||
expect(body.textContent).toBe("alpha beta\nbeta gamma\nBeta");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("2/3")).toBeTruthy();
|
||||
expect(
|
||||
marks[1]!.hasAttribute("data-find-current") ||
|
||||
body.querySelectorAll("mark")[1]!.hasAttribute("data-find-current"),
|
||||
).toBe(true);
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.queryByLabelText("Find in response", { selector: "input" })).toBeNull();
|
||||
expect(body.querySelectorAll("mark")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { Button, Spinner, T } from "@/ui";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { blameSlot, toggleAnnotations } from "./blame";
|
||||
import { languageFor, viewerBase } from "./cm";
|
||||
import { ideaDiffConfig } from "./diffOptions";
|
||||
import { confirmDialog } from "./dialogs";
|
||||
import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator";
|
||||
import { syncMergeScroll } from "./mergeScroll";
|
||||
@@ -92,6 +93,10 @@ const writeAutoApply = (on: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Each side against the base is compared line by line, as in the diff viewer. The default diff
|
||||
// gives up on long files and marks the whole file as one changed block.
|
||||
const SIDE_DIFF = ideaDiffConfig("none", "words");
|
||||
|
||||
export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
const { path } = input.data as MergeData;
|
||||
const [state, setState] = useState<{
|
||||
@@ -176,7 +181,9 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.selectionSet) refreshMergeActions(actionHost);
|
||||
}),
|
||||
state.base !== null ? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true }) : [],
|
||||
state.base !== null
|
||||
? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true, diffConfig: SIDE_DIFF })
|
||||
: [],
|
||||
sideControls(which, controls),
|
||||
mergeStripe(which),
|
||||
mergeTheme,
|
||||
|
||||
@@ -7,6 +7,8 @@ describe("remoteHosting", () => {
|
||||
["https://github.example.com/o/r", "GitHub", "github"],
|
||||
["https://gitee.com/o/r.git", "Gitee", "github"],
|
||||
["[email protected]:o/r.git", "Bitbucket", "bitbucket"],
|
||||
["[email protected]:team/repo.git", "Bitbucket", "bitbucket"],
|
||||
["[email protected]:o/r.git", "GitHub", "github"],
|
||||
["ssh://[email protected]:2222/group/sub/r.git", "GitLab", "gitlab"],
|
||||
["[email protected]:group/r.git", "git.example.com", "gitlab"],
|
||||
])("%s → %s", (url, name, kind) => {
|
||||
@@ -43,4 +45,10 @@ describe("fileWebUrl", () => {
|
||||
"https://bitbucket.org/o/r/src/main/a.py#lines-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a Bitbucket Cloud SSH clone on bitbucket.org, not the SSH front host (#3372)", () => {
|
||||
expect(fileWebUrl("[email protected]:team/repo.git", "main", "a.py", { line: 4 })).toBe(
|
||||
"https://bitbucket.org/team/repo/src/main/a.py#lines-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { Chunk } from "@codemirror/merge";
|
||||
import type { Text } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { bandAt, docOffset, extent, transferY } from "./diffScroll";
|
||||
import { ideaDiffConfig } from "./diffOptions";
|
||||
|
||||
export type MergePane = "ours" | "result" | "theirs";
|
||||
const PANES: readonly MergePane[] = ["ours", "result", "theirs"];
|
||||
@@ -32,6 +33,10 @@ export class ScrollEchoes<K> {
|
||||
}
|
||||
}
|
||||
|
||||
// Lined up by IDEA's line comparison. `Chunk.build` with no config runs an unbounded
|
||||
// character diff, which takes minutes on a long file such as a lockfile.
|
||||
const LINES = ideaDiffConfig("none", "lines");
|
||||
|
||||
/** Starts scrolling `views` together; returns the function that stops it. */
|
||||
export function syncMergeScroll(views: Record<MergePane, EditorView>): () => void {
|
||||
const built = new Map<MergePane, { a: Text; b: Text; chunks: readonly Chunk[] }>();
|
||||
@@ -40,7 +45,7 @@ export function syncMergeScroll(views: Record<MergePane, EditorView>): () => voi
|
||||
const b = views.result.state.doc;
|
||||
const hit = built.get(side);
|
||||
if (hit && hit.a === a && hit.b === b) return hit.chunks;
|
||||
const fresh = Chunk.build(a, b);
|
||||
const fresh = Chunk.build(a, b, LINES);
|
||||
built.set(side, { a, b, chunks: fresh });
|
||||
return fresh;
|
||||
};
|
||||
|
||||
@@ -139,6 +139,13 @@ export function stashLabel(message: string): { branch: string | null; text: stri
|
||||
return { branch: m[1]!, text: m[2]!.trim() || message };
|
||||
}
|
||||
|
||||
/** SSH front hosts that serve the same service as the web host in the value. */
|
||||
const WEB_HOST_ALIASES: Record<string, string> = {
|
||||
"ssh.bitbucket.org": "bitbucket.org",
|
||||
"ssh.github.com": "github.com",
|
||||
"altssh.gitlab.com": "gitlab.com",
|
||||
};
|
||||
|
||||
/**
|
||||
* Web page of a git remote URL (`[email protected]:o/r.git`, `ssh://git@host:22/o/r`,
|
||||
* `https://user@host/o/r.git`), or null for local paths.
|
||||
@@ -155,6 +162,7 @@ export function remoteWebUrl(url: string): string | null {
|
||||
if (full) [, host, path] = full as unknown as [string, string, string];
|
||||
else if (scp) [, host, path] = scp as unknown as [string, string, string];
|
||||
else return null;
|
||||
host = WEB_HOST_ALIASES[host.toLowerCase()] ?? host;
|
||||
return `https://${host}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1333,6 +1333,7 @@
|
||||
"Find in Folder…": "在文件夹中查找…",
|
||||
"Find in Terminal": "在终端中查找",
|
||||
"Find in console": "在控制台中查找",
|
||||
"Find in response": "在响应中查找",
|
||||
"Find in file": "在文件中查找",
|
||||
"Find in terminal": "在终端中查找",
|
||||
"Find results": "查找结果",
|
||||
@@ -2180,6 +2181,8 @@
|
||||
"Python interpreter": "Python 解释器",
|
||||
"Python: only your code (justMyCode). JavaScript: skips node_modules and Node.js internals.": "Python:仅您的代码 (justMyCode)。JavaScript:跳过 node_modules 和 Node.js 内部代码。",
|
||||
"Query Console": "查询控制台",
|
||||
"New Query Console": "新建查询控制台",
|
||||
"Query Consoles": "查询控制台",
|
||||
"Quick Documentation": "快速文档",
|
||||
"Re-check installed tools": "重新检查已安装的工具",
|
||||
"Re-reads the effective POMs and dependency trees with Maven": "使用 Maven 重新读取有效 POM 和依赖树",
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ConsoleFindBarProps {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onClose: () => void;
|
||||
/** What is searched, for the field's placeholder and label. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ConsoleFindBar({
|
||||
@@ -40,6 +42,7 @@ export function ConsoleFindBar({
|
||||
onNext,
|
||||
onPrevious,
|
||||
onClose,
|
||||
label = "Find in console",
|
||||
}: ConsoleFindBarProps) {
|
||||
const none = !!query && !invalid && count === 0;
|
||||
const counter = invalid
|
||||
@@ -81,20 +84,14 @@ export function ConsoleFindBar({
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={styles.find}
|
||||
role="search"
|
||||
aria-label={tx("Find in console")}
|
||||
data-console-find=""
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className={styles.find} role="search" aria-label={tx(label)} data-console-find="" onKeyDown={onKeyDown}>
|
||||
<SearchField
|
||||
ref={inputRef}
|
||||
className={styles.findField}
|
||||
value={query}
|
||||
onChange={onChange}
|
||||
placeholder="Find in console"
|
||||
aria-label="Find in console"
|
||||
placeholder={label}
|
||||
aria-label={label}
|
||||
invalid={invalid || none}
|
||||
toggles={[
|
||||
{
|
||||
|
||||
Reference in new issue
Заблокувати користувача