mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc078b93b | ||
|
|
a36c7ed3d5 | ||
|
|
50567473f7 | ||
|
|
de4c325a28 | ||
|
|
a4a3c2105f | ||
|
|
6efff739dd | ||
|
|
8ca6e4bc01 | ||
|
|
8d909598d5 | ||
|
|
b99fe1704b | ||
|
|
159c2d6fd2 | ||
|
|
5084388a68 | ||
|
|
68342b32bf | ||
|
|
352efc02a7 | ||
|
|
24ffb9f16e | ||
|
|
8ebf839d8e | ||
|
|
14a31fe8a1 | ||
|
|
1af1744290 | ||
|
|
ee6786d573 | ||
|
|
5804fcad16 |
No files matched your search
@@ -113,6 +113,8 @@ pub struct CommitSettings {
|
||||
pub body_limit: u32,
|
||||
pub show_right_margin: bool,
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
|
||||
body_limit: 72,
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +195,24 @@ pub async fn lsp_hover(
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
return result;
|
||||
};
|
||||
let (result, hovers) =
|
||||
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
host.end_request(request);
|
||||
result
|
||||
let hovers = hovers.unwrap_or_default();
|
||||
if hovers.is_empty() {
|
||||
return result;
|
||||
}
|
||||
let server = match result {
|
||||
Ok(server) => server,
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
Ok(crate::plugins::merge_hover(server, hovers))
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
|
||||
//! Capabilities were checked by the runtime before any of this runs.
|
||||
|
||||
use super::{PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem,
|
||||
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
|
||||
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -23,6 +23,11 @@ const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
const MAX_CONTEXT_KEYS: usize = 100;
|
||||
/// Most diff text of a pending commit, all files together.
|
||||
const MAX_COMMIT_DIFF: usize = 1 << 20;
|
||||
/// How long a window may take to describe its pending commit (it saves editors first).
|
||||
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// (window or `None` for every window, plugin, key).
|
||||
type ContextKey = (Option<String>, String, String);
|
||||
@@ -51,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
|
||||
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
|
||||
-> Result<(), HostError>;
|
||||
/// The encoding git content of `path` is read in (the project's file encoding).
|
||||
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
|
||||
encoding_rs::UTF_8.into()
|
||||
}
|
||||
/// The user's values of `plugin`'s settings: the project's at `root`
|
||||
/// (over global), or global.
|
||||
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
@@ -150,10 +159,15 @@ struct TaskEntry {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// A question a plugin's call waits on.
|
||||
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
|
||||
struct PromptEntry {
|
||||
plugin: String,
|
||||
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
|
||||
answer: Waiter,
|
||||
}
|
||||
|
||||
enum Waiter {
|
||||
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
|
||||
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
|
||||
}
|
||||
|
||||
/// State shared by the host and every plugin's [`PluginApi`].
|
||||
@@ -211,7 +225,7 @@ impl Shared {
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
self.workspace.read().clone()
|
||||
}
|
||||
|
||||
@@ -358,8 +372,22 @@ impl Shared {
|
||||
|
||||
/// The user answered (`Some`) or dismissed (`None`) a prompt.
|
||||
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
|
||||
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
|
||||
prompt.answer.send(answer).is_ok()
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
/// A window answered a [`CommitRequest`].
|
||||
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
fn end_task(&self, id: u64) {
|
||||
@@ -540,6 +568,91 @@ fn kill_group(child: &mut std::process::Child) {
|
||||
ide_core::process::kill_tree(child);
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
/// Sends `request` to `client`'s window and waits for its answer, until
|
||||
/// `cancel` fires, the plugin stops or `timeout` passes.
|
||||
fn ask_commit(
|
||||
&self,
|
||||
client: &str,
|
||||
request: CommitRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<CommitAnswer, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (answer, answers) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
|
||||
}
|
||||
let request_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match answers.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(answer) => return Ok(answer),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(e) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
|
||||
return Err(match e {
|
||||
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
|
||||
HostError::Failed("the window did not answer".into())
|
||||
}
|
||||
_ => HostError::Cancelled,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes `selection` commits, diffed repository by repository.
|
||||
pub(super) fn commit_draft(
|
||||
workspace: &dyn PluginWorkspace,
|
||||
selection: CommitSelection,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<CommitDraft, HostError> {
|
||||
let mut changes = Vec::new();
|
||||
let mut budget = MAX_COMMIT_DIFF;
|
||||
for repo in &selection.repositories {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
|
||||
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
|
||||
};
|
||||
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|
||||
|e| match e.kind {
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
},
|
||||
)?;
|
||||
let root = to_ipc(r.root());
|
||||
for c in pending {
|
||||
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
|
||||
changes.push(CommitChange {
|
||||
repository: root.clone(),
|
||||
path: to_ipc(&c.path),
|
||||
old_path: c.old_path.as_deref().map(to_ipc),
|
||||
status: c.status.to_owned(),
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
|
||||
}
|
||||
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
@@ -577,7 +690,10 @@ impl HostApi for PluginApi {
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
|
||||
}
|
||||
let prompt_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
@@ -597,6 +713,37 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let client = from_window(client, "read the pending commit")?;
|
||||
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
|
||||
let root =
|
||||
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
|
||||
if !workspace.trusted(&root) {
|
||||
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
|
||||
}
|
||||
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
|
||||
match answer {
|
||||
CommitAnswer::NoRepository => Ok(None),
|
||||
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
|
||||
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "set the commit message")?;
|
||||
let request = CommitRequest::SetMessage { revision, message };
|
||||
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
|
||||
CommitAnswer::Done => Ok(()),
|
||||
CommitAnswer::Stale => Err(HostError::Stale),
|
||||
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
|
||||
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
|
||||
let client = from_window(client, "reveal a view")?;
|
||||
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
|
||||
@@ -1112,6 +1259,15 @@ impl PluginWorkspace for HostWorkspace {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
|
||||
HostWorkspace::projects(self)
|
||||
.into_iter()
|
||||
.filter(|p| path.starts_with(&p.root))
|
||||
.max_by_key(|p| p.root.as_os_str().len())
|
||||
.and_then(|p| p.vcs_encoding(path, None).ok())
|
||||
.unwrap_or_else(|| encoding_rs::UTF_8.into())
|
||||
}
|
||||
|
||||
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
|
||||
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The Commit view's side of the `commit` API, as the window commands
|
||||
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
|
||||
//! local projects, `openidea-server` for remote ones.
|
||||
|
||||
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
|
||||
/// until request `request` is cancelled.
|
||||
pub async fn commit_checks(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
host.require_trust(window)?;
|
||||
let invocation = super::views::view_invocation(host, window, None, None)?;
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
.and_then(|r| r);
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use ide_lsp::types::{Hover, Markup};
|
||||
use ide_plugin::PluginHover;
|
||||
|
||||
pub(crate) fn merge(server: Option<Hover>, plugin: Vec<PluginHover>) -> Option<Hover> {
|
||||
if plugin.is_empty() {
|
||||
return server;
|
||||
}
|
||||
let mut parts = Vec::with_capacity(plugin.len() + 1);
|
||||
let mut range = None;
|
||||
if let Some(h) = server {
|
||||
range = h.range;
|
||||
if !h.contents.value.trim().is_empty() {
|
||||
parts.push(if h.contents.kind == "markdown" { h.contents.value } else { escape(&h.contents.value) });
|
||||
}
|
||||
}
|
||||
for h in plugin {
|
||||
range = range.or(h.range);
|
||||
parts.push(h.markdown);
|
||||
}
|
||||
Some(Hover { contents: Markup { kind: "markdown".into(), value: parts.join("\n\n---\n\n") }, range })
|
||||
}
|
||||
|
||||
fn escape(plain: &str) -> String {
|
||||
let mut out = String::with_capacity(plain.len() + plain.len() / 4);
|
||||
for c in plain.chars() {
|
||||
match c {
|
||||
'\n' => out.push_str(" \n"),
|
||||
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '#' | '+' | '-' | '.' | '!'
|
||||
| '|' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::{Pos, Range};
|
||||
|
||||
fn range(line: u32, from: u32, to: u32) -> Range {
|
||||
Range::new(Pos::new(line, from), Pos::new(line, to))
|
||||
}
|
||||
|
||||
fn plugin(markdown: &str, at: Option<Range>) -> PluginHover {
|
||||
PluginHover { markdown: markdown.into(), range: at }
|
||||
}
|
||||
|
||||
fn server(kind: &str, value: &str, at: Option<Range>) -> Hover {
|
||||
Hover { contents: Markup { kind: kind.into(), value: value.into() }, range: at }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_content_follows_the_server_hover() {
|
||||
let merged = merge(
|
||||
Some(server("markdown", "```php\nfunction route()\n```", Some(range(3, 4, 9)))),
|
||||
vec![plugin("**orders.index** · `GET /orders`", Some(range(3, 10, 22)))],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged.contents.kind, "markdown");
|
||||
assert_eq!(merged.contents.value, "```php\nfunction route()\n```\n\n---\n\n**orders.index** · `GET /orders`");
|
||||
assert_eq!(merged.range, Some(range(3, 4, 9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_server_hover_the_plugins_answer_alone() {
|
||||
let merged = merge(None, vec![plugin("one", None), plugin("two", Some(range(1, 0, 3)))]).unwrap();
|
||||
assert_eq!(merged.contents.value, "one\n\n---\n\ntwo");
|
||||
assert_eq!(merged.range, Some(range(1, 0, 3)));
|
||||
assert!(merge(None, Vec::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_from_the_server_keeps_its_characters() {
|
||||
let merged = merge(Some(server("plaintext", "a_b *c*\nd", None)), vec![plugin("x", None)]).unwrap();
|
||||
assert_eq!(merged.contents.value, "a\\_b \\*c\\* \nd\n\n---\n\nx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_server_hover_is_left_out() {
|
||||
let merged = merge(Some(server("markdown", " ", None)), vec![plugin("x", None)]).unwrap();
|
||||
assert_eq!(merged.contents.value, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_plugin_content_keeps_the_server_hover() {
|
||||
let kept = merge(Some(server("plaintext", "a_b", Some(range(0, 0, 1)))), Vec::new()).unwrap();
|
||||
assert_eq!((kept.contents.kind.as_str(), kept.contents.value.as_str()), ("plaintext", "a_b"));
|
||||
assert_eq!(kept.range, Some(range(0, 0, 1)));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
pub mod commit;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
@@ -38,6 +40,7 @@ pub mod views;
|
||||
pub mod web;
|
||||
|
||||
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
|
||||
pub(crate) use hover::merge as merge_hover;
|
||||
pub use icons::{
|
||||
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
|
||||
};
|
||||
@@ -1364,6 +1367,67 @@ impl PluginHost {
|
||||
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
|
||||
}
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
|
||||
/// what `selection` commits. A check that fails or isn't available is
|
||||
/// reported with `error`, so the commit still stops for it.
|
||||
pub fn commit_checks(
|
||||
&self,
|
||||
invocation: Invocation,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
|
||||
let wanted: Vec<_> = checks
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
|
||||
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
|
||||
})
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
|
||||
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
|
||||
HostError::Cancelled => IdeError::cancelled(),
|
||||
HostError::Invalid(m) => IdeError::invalid(m),
|
||||
e => IdeError::unavailable(e.to_string()),
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for (c, title) in wanted {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let granted = self
|
||||
.plugins
|
||||
.read()
|
||||
.get(&c.plugin_id)
|
||||
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
|
||||
let result = if granted {
|
||||
let (check, draft) = (c.check.clone(), draft.clone());
|
||||
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
|
||||
rt.commit_check(&check, draft.clone(), inv)
|
||||
})
|
||||
.map_err(|e| e.message)
|
||||
} else {
|
||||
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
|
||||
};
|
||||
let (problems, error) = match result {
|
||||
Ok(problems) => (problems, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Kinds of workbench events some running or activatable plugin wants;
|
||||
/// the UI reports only these.
|
||||
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
|
||||
|
||||
@@ -68,5 +68,6 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub enum ProviderKind {
|
||||
Definition,
|
||||
LineMarkers,
|
||||
SemanticTokens,
|
||||
Hover,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -116,6 +117,9 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::SemanticTokens) {
|
||||
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
|
||||
}
|
||||
if d.kinds.contains(&ProviderKind::Hover) {
|
||||
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -164,7 +168,7 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
|
||||
ProviderDocument, Runtime, SemanticToken,
|
||||
};
|
||||
use std::sync::Weak;
|
||||
@@ -345,6 +349,14 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
.into_iter()
|
||||
.filter_map(|(_, hover)| hover)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ghost text from the first plugin that has some. Not bound by the
|
||||
/// edit-path soft deadline (it may need the network) but by `cancel`:
|
||||
/// the editor cancels a request as soon as the user types on.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
|
||||
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
|
||||
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
|
||||
//! `plugin_service_log`)
|
||||
//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
|
||||
//! carry the desktop commands' names and arguments, so a remote window's UI
|
||||
//! routes them here unchanged. Plugin results come back as `plugin`
|
||||
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
|
||||
@@ -122,6 +122,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
| PluginEvent::CommitRequested { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,6 +423,26 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_commit", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_commit_checks", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::commit::commit_checks(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("selection")?,
|
||||
c.arg("checks")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
|
||||
@@ -232,6 +232,55 @@ pub enum PluginEvent {
|
||||
/// Bring the tool window of the plugin's view `view` to the front.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealView { plugin_id: String, view: String },
|
||||
/// A command needs the window's Commit view: answer with
|
||||
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
|
||||
}
|
||||
|
||||
/// What a plugin asks of a window's Commit view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitRequest {
|
||||
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
|
||||
/// editors of the included files are saved first.
|
||||
Selection,
|
||||
/// Replace the message if the draft is still at `revision`.
|
||||
SetMessage { revision: u64, message: String },
|
||||
}
|
||||
|
||||
/// A window's answer to a [`CommitRequest`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitAnswer {
|
||||
/// The window's project has no repository.
|
||||
NoRepository,
|
||||
Selection {
|
||||
selection: CommitSelection,
|
||||
},
|
||||
/// The message was replaced.
|
||||
Done,
|
||||
/// The draft is no longer at the revision.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// What a window's Commit view would commit: per repository, the options
|
||||
/// `vcs_commit` would get.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelection {
|
||||
/// Changes whenever the message, the included changes or Amend do.
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub repositories: Vec<CommitSelectionRepo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelectionRepo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub options: ide_vcs::CommitOptions,
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
@@ -300,3 +349,24 @@ pub struct LineMarkerInfo {
|
||||
/// Opened on click.
|
||||
pub target: Option<ide_core::Location>,
|
||||
}
|
||||
|
||||
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckRef {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
}
|
||||
|
||||
/// What a plugin's before-commit check found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckResult {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
/// The check's declared title.
|
||||
pub title: String,
|
||||
pub problems: Vec<ide_plugin::CommitProblem>,
|
||||
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.menus]]
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
|
||||
//! command = "check"
|
||||
//! when = "editorLang == java"
|
||||
//! # view = "<view id>" # required for (only allowed on) viewItem.context
|
||||
@@ -42,6 +42,10 @@
|
||||
//! icon = "icons/drawing.svg"
|
||||
//! iconDark = "icons/drawing_dark.svg"
|
||||
//!
|
||||
//! [[contributes.commitChecks]]
|
||||
//! id = "conventional"
|
||||
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
|
||||
//!
|
||||
//! [[contributes.fileTemplates]]
|
||||
//! id = "drawing"
|
||||
//! name = "Excalidraw Drawing"
|
||||
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
|
||||
pub const CUSTOM_EDITORS: &str = "customEditors";
|
||||
pub const FILE_ICONS: &str = "fileIcons";
|
||||
pub const FILE_TEMPLATES: &str = "fileTemplates";
|
||||
pub const COMMIT_CHECKS: &str = "commitChecks";
|
||||
|
||||
const VIEW_ITEM_MENU: &str = "viewItem.context";
|
||||
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
|
||||
/// The toolbar above the commit message (Commit tool window and dialog).
|
||||
const COMMIT_MESSAGE_MENU: &str = "commit.message";
|
||||
const MENU_IDS: [&str; 6] =
|
||||
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
@@ -122,6 +130,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
|
||||
}
|
||||
declared_command(pkg, MENUS, &d.command)?;
|
||||
if d.menu == COMMIT_MESSAGE_MENU {
|
||||
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
|
||||
}
|
||||
if let Some(w) = &d.when {
|
||||
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
|
||||
}
|
||||
@@ -554,6 +565,29 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CommitCheckDecl {
|
||||
id: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
|
||||
/// `commit-checker` export, listed in Commit Options.
|
||||
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
|
||||
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
|
||||
validate_item_id(&d.id, "commit check id")?;
|
||||
title(COMMIT_CHECKS, &d.title)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
|
||||
}
|
||||
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
|
||||
}
|
||||
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct Project {
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
pub(crate) watcher: Arc<Watcher>,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
/// Watchers of folders outside the project that language servers registered file
|
||||
@@ -305,6 +305,12 @@ impl Host {
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
// Shared handle: `unwatched_reason` is a live query on the watcher,
|
||||
// filled after `start` returns so the on-change callback can reach it.
|
||||
let watcher_weak = Arc::new(std::sync::OnceLock::<std::sync::Weak<Watcher>>::new());
|
||||
// One-shot latch for the post-start watch-limit warning (#1915): the
|
||||
// synchronous startup walk and a later batch can both observe it.
|
||||
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let phpstan = phpstan.clone();
|
||||
@@ -322,6 +328,8 @@ impl Host {
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
let build_cache = build_cache.clone();
|
||||
let notify_watch_exhausted = notify_watch_exhausted.clone();
|
||||
let watcher_weak = watcher_weak.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -408,9 +416,26 @@ impl Host {
|
||||
if git_dirs || vcs_moved {
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
// A watch limit hit after startup (per-directory mode, Linux):
|
||||
// folders past the limit never sync — surface it once, like
|
||||
// the polling fallback below (#1915). The check itself is
|
||||
// deferred to here, on a batch that already had events.
|
||||
if !notify_watch_exhausted.load(Ordering::Relaxed)
|
||||
&& let Some(msg) =
|
||||
watcher_weak.get().and_then(std::sync::Weak::upgrade).and_then(|w| w.unwatched_reason())
|
||||
{
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(msg),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}),
|
||||
)?
|
||||
};
|
||||
let watcher = Arc::new(watcher);
|
||||
let _ = watcher_weak.set(Arc::downgrade(&watcher));
|
||||
if let Some(reason) = watcher.polling() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
@@ -418,6 +443,17 @@ impl Host {
|
||||
body: Some(reason.into()),
|
||||
});
|
||||
}
|
||||
// A watch can also be refused part-way through the synchronous startup
|
||||
// walk (large tree, SYNC_WATCH_BUDGET) — after start() returns, before
|
||||
// any batch. Emit the same warning so the limit isn't silent there either.
|
||||
if let Some(reason) = watcher.unwatched_reason() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(reason),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
@@ -644,7 +680,7 @@ impl Host {
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
phpstan: None,
|
||||
watcher,
|
||||
watcher: Arc::new(watcher),
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! The `commit` interface and the `commit-checker` export, through the
|
||||
//! `test.commit` fixture and a scripted window answering `CommitRequested`.
|
||||
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{HostError, Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/commit")
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "user.name=T", "-c", "[email protected]"])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
struct Workspace {
|
||||
root: PathBuf,
|
||||
trusted: AtomicBool,
|
||||
}
|
||||
|
||||
impl PluginWorkspace for Workspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.root.clone()]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
|
||||
Err(HostError::NotFound("not open".into()))
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
self.trusted.load(Ordering::Relaxed)
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
Some(self.root.clone())
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's Commit view: its draft, and whether the user types between
|
||||
/// the plugin reading it and writing the message.
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
revision: u64,
|
||||
message: String,
|
||||
selection: Option<CommitSelection>,
|
||||
type_after_reading: bool,
|
||||
requests: Vec<CommitRequest>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Arc<PluginHost>,
|
||||
window: Arc<Mutex<Window>>,
|
||||
workspace: Arc<Workspace>,
|
||||
project: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
git(&project, &["init", "-q"]);
|
||||
std::fs::write(project.join("a.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
git(&project, &["add", "."]);
|
||||
git(&project, &["commit", "-qm", "base"]);
|
||||
std::fs::write(project.join("a.txt"), "one\nDO NOT COMMIT\nthree\n").unwrap();
|
||||
|
||||
let window = Arc::new(Mutex::new(Window { revision: 7, ..Window::default() }));
|
||||
let host_slot: Arc<Mutex<Option<std::sync::Weak<PluginHost>>>> = Arc::default();
|
||||
let (w, slot) = (window.clone(), host_slot.clone());
|
||||
// Answers on another thread, as the UI does through `plugin_answer_commit`.
|
||||
let sink = Arc::new(move |_: Option<&str>, e: PluginEvent| {
|
||||
let PluginEvent::CommitRequested { request_id, request, .. } = e else { return };
|
||||
let (w, slot) = (w.clone(), slot.clone());
|
||||
std::thread::spawn(move || {
|
||||
let answer = {
|
||||
let mut w = w.lock();
|
||||
w.requests.push(request.clone());
|
||||
match request {
|
||||
CommitRequest::Selection => {
|
||||
let selection = w.selection.clone();
|
||||
if w.type_after_reading {
|
||||
w.revision += 1;
|
||||
}
|
||||
match selection {
|
||||
Some(s) => CommitAnswer::Selection { selection: s },
|
||||
None => CommitAnswer::NoRepository,
|
||||
}
|
||||
}
|
||||
CommitRequest::SetMessage { revision, message } if revision == w.revision => {
|
||||
w.message = message;
|
||||
w.revision += 1;
|
||||
CommitAnswer::Done
|
||||
}
|
||||
CommitRequest::SetMessage { .. } => CommitAnswer::Stale,
|
||||
}
|
||||
};
|
||||
let host = slot.lock().as_ref().and_then(std::sync::Weak::upgrade);
|
||||
assert!(host.unwrap().answer_commit(&request_id, answer));
|
||||
});
|
||||
});
|
||||
let host = Arc::new(PluginHost::new(tmp.path().join("plugins"), sink, Limits::default()).unwrap());
|
||||
*host_slot.lock() = Some(Arc::downgrade(&host));
|
||||
let workspace = Arc::new(Workspace { root: project.clone(), trusted: AtomicBool::new(true) });
|
||||
host.set_workspace(Some(workspace.clone()));
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
Self { host, window, workspace, project, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), ..Invocation::default() }
|
||||
}
|
||||
|
||||
/// `a.txt` included whole, with `message`.
|
||||
fn selection(&self, message: &str) -> CommitSelection {
|
||||
let revision = self.window.lock().revision;
|
||||
CommitSelection {
|
||||
revision,
|
||||
message: message.into(),
|
||||
amend: false,
|
||||
repositories: vec![CommitSelectionRepo {
|
||||
root: self.project.clone().into(),
|
||||
options: ide_vcs::CommitOptions {
|
||||
stage: vec![self.project.join("a.txt").into()],
|
||||
run_hooks: true,
|
||||
..Default::default()
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn markers(&self, message: &str) -> Vec<PluginCommitCheckResult> {
|
||||
let check = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "markers".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
self.host.commit_checks(self.inv(), self.selection(message), vec![check], &cancel).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_reads_the_pending_commit_and_writes_its_message() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
f.window.lock().selection = Some(selection);
|
||||
let out = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("chore: modified a.txt"));
|
||||
let w = f.window.lock();
|
||||
assert_eq!(w.message, "chore: modified a.txt");
|
||||
assert_eq!(w.revision, 8);
|
||||
// Reading the draft left the index alone: nothing is staged.
|
||||
let staged = Command::new("git").current_dir(&f.project).args(["diff", "--cached", "--name-only"]).output();
|
||||
assert!(staged.unwrap().stdout.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_meanwhile_makes_the_write_stale() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
{
|
||||
let mut w = f.window.lock();
|
||||
w.selection = Some(selection);
|
||||
w.type_after_reading = true;
|
||||
}
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("stale") || err.message.contains("changed"), "{}", err.message);
|
||||
assert_eq!(f.window.lock().message, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_repository_and_restricted_mode() {
|
||||
let f = Fixture::new();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no repository"), "{}", err.message);
|
||||
|
||||
f.workspace.trusted.store(false, Ordering::Relaxed);
|
||||
f.window.lock().requests.clear();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("Restricted Mode"), "{}", err.message);
|
||||
assert!(f.window.lock().requests.is_empty(), "the window is not asked");
|
||||
|
||||
// Outside a window's command there is no Commit view to ask.
|
||||
let headless = Invocation { client: None, ..f.inv() };
|
||||
let err = f.host.run_command("test.commit", "describe", None, headless).unwrap_err();
|
||||
assert!(err.message.contains("from a command"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_report_the_message_and_markers_in_the_included_changes() {
|
||||
let f = Fixture::new();
|
||||
let results = f.markers("wip");
|
||||
assert_eq!(results.len(), 1);
|
||||
let r = &results[0];
|
||||
assert_eq!((r.title.as_str(), r.error.as_deref()), ("Commit Markers", None));
|
||||
let found: Vec<_> =
|
||||
r.problems.iter().map(|p| (p.message.as_str(), p.path.clone(), p.range.map(|r| r.start.line))).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("The message does not start with `type: `.", None, None),
|
||||
("Remove the DO NOT COMMIT marker.", Some(to_ipc(&f.project.join("a.txt"))), Some(1)),
|
||||
]
|
||||
);
|
||||
assert_eq!(f.markers("fix: drop marker")[0].problems.len(), 1);
|
||||
|
||||
// Unknown checks are not run, nor those of a plugin that lost `vcs.read`.
|
||||
let other = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "other".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
assert!(f.host.commit_checks(f.inv(), f.selection("x"), vec![other], &cancel).unwrap().is_empty());
|
||||
f.host.set_capability("test.commit", "vcs.read", false).unwrap();
|
||||
assert!(f.markers("wip").iter().all(|r| r.problems.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commit_points_need_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host =
|
||||
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
for (what, manifest) in [
|
||||
(
|
||||
"commit.message",
|
||||
manifest.replace("[[contributes.commitChecks]]\nid = \"markers\"\ntitle = \"Commit Markers\"\n", ""),
|
||||
),
|
||||
(
|
||||
"commitChecks",
|
||||
manifest.replace("[[contributes.menus]]\nmenu = \"commit.message\"\ncommand = \"describe\"\n", ""),
|
||||
),
|
||||
] {
|
||||
let pkg = tmp.path().join(what);
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.2\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3") && err.message.contains(what), "{what}: {}", err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use ide_core::Pos;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Limits, ProviderDocument};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/hover")
|
||||
}
|
||||
|
||||
fn host(dir: &Path) -> PluginHost {
|
||||
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
|
||||
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
|
||||
}
|
||||
|
||||
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
|
||||
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
|
||||
}
|
||||
|
||||
const ORDER: &str = "<?php\nclass Order\n{\n public function user() {}\n}\n$order->user;\n$order->total;\n";
|
||||
|
||||
#[test]
|
||||
fn a_declared_name_gets_the_plugins_hover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
let hovers = host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(5, 10));
|
||||
assert_eq!(hovers.len(), 1);
|
||||
assert_eq!(hovers[0].markdown, "**user** · declared on line 4");
|
||||
let range = hovers[0].range.unwrap();
|
||||
assert_eq!((range.start.line, range.start.col, range.end.line, range.end.col), (5, 8, 5, 12));
|
||||
assert!(host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(6, 10)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_declared_languages_are_asked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
assert!(host.provide_hover(&doc(tmp.path(), "javascript", ORDER), Pos::new(5, 10)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_needs_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let pkg = tmp.path().join("old");
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
("arduino", include_str!("../../../languages/arduino.toml")),
|
||||
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
|
||||
("astro", include_str!("../../../languages/astro.toml")),
|
||||
("bat", include_str!("../../../languages/bat.toml")),
|
||||
@@ -601,9 +602,11 @@ mod tests {
|
||||
"MsGenny",
|
||||
"Vue",
|
||||
"Angular Template",
|
||||
"Angular TypeScript",
|
||||
"ERB",
|
||||
"JSP",
|
||||
"Astro",
|
||||
"GoMod",
|
||||
];
|
||||
|
||||
fn reg() -> LanguageRegistry {
|
||||
@@ -716,7 +719,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
fn angular_projects_use_framework_modes() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
@@ -727,7 +730,12 @@ mod tests {
|
||||
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "docs/index.html"), None);
|
||||
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
|
||||
for file in ["apps/web/src/app/app.ts", "apps/admin/src/app.mts", "apps/admin/src/app.cts"] {
|
||||
assert_eq!(mode("typescript", file).as_deref(), Some("Angular TypeScript"), "{file}");
|
||||
}
|
||||
assert_eq!(mode("typescript", "docs/example.ts"), None);
|
||||
assert_eq!(mode("javascript", "apps/web/src/app/app.js"), None);
|
||||
assert_eq!(mode("typescriptreact", "apps/web/src/app/app.tsx"), None);
|
||||
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
|
||||
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
|
||||
}
|
||||
@@ -860,6 +868,7 @@ mod tests {
|
||||
("x.h", "c"),
|
||||
("x.hpp", "cpp"),
|
||||
("x.cc", "cpp"),
|
||||
("blink.ino", "arduino"),
|
||||
("P.cs", "csharp"),
|
||||
("V.swift", "swift"),
|
||||
("AppDelegate.m", "objective-c"),
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Go files a gopls search does not reach.
|
||||
//!
|
||||
//! gopls answers a search from the view of the file it is asked about: the
|
||||
//! modules of a `go.work`, or that file's own module, built for the host's
|
||||
//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each
|
||||
//! gets a view of its own), so the generic scope widening is skipped for Go.
|
||||
//! Usages it leaves out are found as GoLand finds them, by resolving each
|
||||
//! mention of the name in its own file's view: files of other modules that use
|
||||
//! the declaration's module through a local `replace` (a multi-module
|
||||
//! repository without `go.work`), and files whose build constraints exclude
|
||||
//! them from the default build (`_windows.go`, `//go:build`).
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The files of `candidates` the search at `origin` does not cover and that can refer to
|
||||
/// `declaration`: those of the covered modules excluded from their default build, and those
|
||||
/// of the declaration's own module or one replacing it with its directory when not covered.
|
||||
pub(crate) fn files_beyond_search(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut modules = ModuleRoots::default();
|
||||
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(declaring_path) = read_module_path(&declaring) else { return Vec::new() };
|
||||
let mut covered: HashSet<PathBuf> = workspace_modules(&origin_module).into_iter().collect();
|
||||
covered.insert(origin_module);
|
||||
let mut uses: HashMap<PathBuf, bool> = HashMap::new();
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
let Some(module) = modules.of(file) else { return false };
|
||||
// The covered modules share the search's view: only their excluded files are missed.
|
||||
if covered.contains(&module) {
|
||||
return !in_default_build(file);
|
||||
}
|
||||
*uses.entry(module.clone()).or_insert_with(|| {
|
||||
module == declaring
|
||||
|| std::fs::read_to_string(module.join("go.mod")).is_ok_and(|text| {
|
||||
local_replacements(&text, &module)
|
||||
.iter()
|
||||
.any(|(path, dir)| *path == declaring_path && *dir == declaring)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether gopls' default view builds `file`: no build constraint other than the host's
|
||||
/// own GOOS/GOARCH file name suffix.
|
||||
fn in_default_build(file: &Path) -> bool {
|
||||
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let stem = name.strip_suffix(".go").unwrap_or(&name);
|
||||
let stem = stem.strip_suffix("_test").unwrap_or(stem);
|
||||
let parts: Vec<&str> = stem.split('_').collect();
|
||||
// `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint.
|
||||
let host = |part: &&str| *part == host_goos() || *part == host_goarch();
|
||||
if parts.len() > 1 {
|
||||
let last = parts[parts.len() - 1];
|
||||
if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) {
|
||||
return false;
|
||||
}
|
||||
if parts.len() > 2 && GOARCH.contains(&last) {
|
||||
let os = parts[parts.len() - 2];
|
||||
if GOOS.contains(&os) && !host(&os) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text))
|
||||
}
|
||||
|
||||
/// A `//go:build` or `// +build` line before the package clause.
|
||||
fn constrained_header(text: &str) -> bool {
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("package ") {
|
||||
return false;
|
||||
}
|
||||
if line.starts_with("//go:build") || line.starts_with("// +build") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn host_goos() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
os => os,
|
||||
}
|
||||
}
|
||||
|
||||
fn host_goarch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
"x86" => "386",
|
||||
"powerpc64" => "ppc64",
|
||||
arch => arch,
|
||||
}
|
||||
}
|
||||
|
||||
const GOOS: &[&str] = &[
|
||||
"aix",
|
||||
"android",
|
||||
"darwin",
|
||||
"dragonfly",
|
||||
"freebsd",
|
||||
"hurd",
|
||||
"illumos",
|
||||
"ios",
|
||||
"js",
|
||||
"linux",
|
||||
"nacl",
|
||||
"netbsd",
|
||||
"openbsd",
|
||||
"plan9",
|
||||
"solaris",
|
||||
"wasip1",
|
||||
"windows",
|
||||
"zos",
|
||||
];
|
||||
const GOARCH: &[&str] = &[
|
||||
"386",
|
||||
"amd64",
|
||||
"amd64p32",
|
||||
"arm",
|
||||
"armbe",
|
||||
"arm64",
|
||||
"arm64be",
|
||||
"loong64",
|
||||
"mips",
|
||||
"mipsle",
|
||||
"mips64",
|
||||
"mips64le",
|
||||
"mips64p32",
|
||||
"mips64p32le",
|
||||
"ppc",
|
||||
"ppc64",
|
||||
"ppc64le",
|
||||
"riscv",
|
||||
"riscv64",
|
||||
"s390",
|
||||
"s390x",
|
||||
"sparc",
|
||||
"sparc64",
|
||||
"wasm",
|
||||
];
|
||||
|
||||
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
|
||||
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = Vec::new();
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let code = line.split("//").next().unwrap_or(line);
|
||||
for (at, _) in code.match_indices(word) {
|
||||
let before = code[..at].chars().next_back();
|
||||
let after = code[at + word.len()..].chars().next();
|
||||
if before.is_some_and(ident) || after.is_some_and(ident) {
|
||||
continue;
|
||||
}
|
||||
let col = code[..at].encode_utf16().count() as u32;
|
||||
let len = word.encode_utf16().count() as u32;
|
||||
out.push(Range::new(Pos::new(line_no as u32, col), Pos::new(line_no as u32, col + len)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Nearest enclosing `go.mod` directory, cached per directory.
|
||||
#[derive(Default)]
|
||||
struct ModuleRoots(HashMap<PathBuf, Option<PathBuf>>);
|
||||
|
||||
impl ModuleRoots {
|
||||
fn of(&mut self, file: &Path) -> Option<PathBuf> {
|
||||
let dir = file.parent()?.to_path_buf();
|
||||
self.dir(&dir)
|
||||
}
|
||||
|
||||
fn dir(&mut self, dir: &Path) -> Option<PathBuf> {
|
||||
if let Some(found) = self.0.get(dir) {
|
||||
return found.clone();
|
||||
}
|
||||
let found =
|
||||
if dir.join("go.mod").is_file() { Some(dir.to_path_buf()) } else { dir.parent().and_then(|p| self.dir(p)) };
|
||||
self.0.insert(dir.to_path_buf(), found.clone());
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
fn read_module_path(module: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(module.join("go.mod")).ok()?;
|
||||
text.lines().find_map(|line| Some(unquote(line.trim().strip_prefix("module ")?.trim())))
|
||||
}
|
||||
|
||||
/// The modules a `go.work` above `module` uses, as gopls loads them together.
|
||||
fn workspace_modules(module: &Path) -> Vec<PathBuf> {
|
||||
let Some(work) = module.ancestors().map(|d| d.join("go.work")).find(|p| p.is_file()) else { return Vec::new() };
|
||||
let Ok(text) = std::fs::read_to_string(&work) else { return Vec::new() };
|
||||
let base = work.parent().unwrap_or(Path::new(""));
|
||||
directives(&text, "use").into_iter().map(|dir| normalize(&base.join(dir))).collect()
|
||||
}
|
||||
|
||||
/// `replace <module> [version] => <local directory>` entries, directories resolved against `base`.
|
||||
fn local_replacements(go_mod: &str, base: &Path) -> Vec<(String, PathBuf)> {
|
||||
directives(go_mod, "replace")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let (from, to) = entry.split_once("=>")?;
|
||||
let from = unquote(from.split_whitespace().next()?);
|
||||
let to = unquote(to.split_whitespace().next()?);
|
||||
let local = to.starts_with("./")
|
||||
|| to.starts_with("../")
|
||||
|| Path::new(&to).is_absolute()
|
||||
|| to == "."
|
||||
|| to == "..";
|
||||
local.then(|| (from, normalize(&base.join(to))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The arguments of `keyword` directives, single-line and block form.
|
||||
fn directives(text: &str, keyword: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_block = false;
|
||||
for line in text.lines() {
|
||||
let line = line.split("//").next().unwrap_or("").trim();
|
||||
if in_block {
|
||||
if line == ")" {
|
||||
in_block = false;
|
||||
} else if !line.is_empty() {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix(keyword)
|
||||
&& rest.starts_with([' ', '\t', '('])
|
||||
{
|
||||
let rest = rest.trim();
|
||||
if rest == "(" {
|
||||
in_block = true;
|
||||
} else if !rest.is_empty() {
|
||||
out.push(rest.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> String {
|
||||
s.trim_matches(|c| c == '"' || c == '`').to_owned()
|
||||
}
|
||||
|
||||
fn normalize(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) -> PathBuf {
|
||||
let path = root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn names(root: &Path, files: Vec<PathBuf>) -> Vec<String> {
|
||||
files.iter().map(|f| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_replacing_the_declarations_module_are_searched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let origin = write(root, "app.go", "package app\nfunc New() {}\n");
|
||||
write(root, "example/a/go.mod", "module a\n\nreplace example.com/app => ../..\n");
|
||||
write(root, "example/b/go.mod", "module b\n\nreplace (\n\texample.com/app v1.0.0 => \"../../\"\n)\n");
|
||||
write(root, "example/c/go.mod", "module c\n\nrequire example.com/app v1.0.0\n");
|
||||
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
|
||||
let candidates = vec![
|
||||
write(root, "same.go", "package app\n"),
|
||||
write(root, "same_plan9.go", "package app\n"),
|
||||
write(root, &format!("same_{}.go", host_goos()), "package app\n"),
|
||||
write(root, "same_plan9_test.go", "package app\n"),
|
||||
write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"),
|
||||
write(root, "plan9.go", "package app\n"),
|
||||
write(root, "example/a/main.go", "package main\n"),
|
||||
write(root, "example/b/main.go", "package main\n"),
|
||||
write(root, "example/c/main.go", "package main\n"),
|
||||
write(root, "example/d/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&origin, &origin, candidates)),
|
||||
["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_modules_are_covered_by_the_first_search() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.work", "go 1.22\n\nuse (\n\t./lib // shared\n\t./app\n)\n");
|
||||
write(root, "lib/go.mod", "module example.com/lib\n");
|
||||
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
|
||||
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
|
||||
let declaration = write(root, "lib/lib.go", "package lib\n");
|
||||
let candidates = vec![
|
||||
write(root, "app/main.go", "package main\n"),
|
||||
write(root, "app/main_plan9.go", "package main\n"),
|
||||
write(root, "tool/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&declaration, &declaration, candidates)),
|
||||
["app/main_plan9.go", "tool/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_usage_site_in_another_module_searches_the_declaring_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let declaration = write(root, "app.go", "package app\n");
|
||||
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
|
||||
let origin = write(root, "cmd/main.go", "package main\n");
|
||||
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
|
||||
assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_ranges_are_whole_words_outside_line_comments() {
|
||||
let text = "x := app.New() // New here\nNewer := New\n\tvar é = New()\n";
|
||||
assert_eq!(
|
||||
word_ranges(text, "New"),
|
||||
[
|
||||
Range::new(Pos::new(0, 9), Pos::new(0, 12)),
|
||||
Range::new(Pos::new(1, 9), Pos::new(1, 12)),
|
||||
Range::new(Pos::new(2, 9), Pos::new(2, 12)),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
@@ -53,7 +54,7 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use server::{Conn, Running, Server};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
@@ -506,8 +507,20 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// `begin_start` only claims Idle servers, so a claimed server is never a
|
||||
// live one. Its root is the process's working directory: starting with
|
||||
// one deleted from disk fails (`os error 267` on Windows). Such a
|
||||
// server can never serve again, so drop it instead of leaving a
|
||||
// permanently "Crashed" entry behind (e.g. the root vanishing during
|
||||
// the crash-restart backoff, or an open-file request arriving later).
|
||||
let Some(generation) = server.begin_start() else { return };
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
if !server.root.is_dir() {
|
||||
tracing::info!("{}: workspace root {} is gone, dropping", server.name, server.root.display());
|
||||
server.announce_down();
|
||||
self.forget(std::slice::from_ref(server));
|
||||
return;
|
||||
}
|
||||
let server = server.clone();
|
||||
self.runtime.spawn(async move { me.run_instance(server, generation).await });
|
||||
}
|
||||
@@ -704,6 +717,14 @@ impl LspManager {
|
||||
server.announce_down();
|
||||
return;
|
||||
}
|
||||
// The workspace root gone means this server can never serve again:
|
||||
// restarting would respawn with a dead working directory, so the entry
|
||||
// is dropped instead of staying "Crashed" forever.
|
||||
if !server.root.is_dir() {
|
||||
server.announce_down();
|
||||
self.forget(std::slice::from_ref(&server));
|
||||
return;
|
||||
}
|
||||
let tail = server.stderr_tail(5);
|
||||
let cfg = server.effective_config();
|
||||
let mut message = match (code, &cfg.connect) {
|
||||
@@ -1688,6 +1709,19 @@ impl LspManager {
|
||||
}
|
||||
let mut v = out.finish(References::default())?;
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
if project_wide && let Some(t) = targets.first().filter(|_| snap.language.as_str() == "go") {
|
||||
// Usages in other modules take a view per module: those in reach come first.
|
||||
if let Some(partial) = partial
|
||||
&& !v.locations.is_empty()
|
||||
{
|
||||
partial(v.clone());
|
||||
}
|
||||
v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -2120,7 +2154,20 @@ impl LspManager {
|
||||
};
|
||||
let w = self.widened(snap, t, pos, cancel, query, files).await?;
|
||||
// The second edit covers the first: its files stay analysed.
|
||||
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
|
||||
let mut edit = w.second.unwrap_or(w.first);
|
||||
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
|
||||
for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations {
|
||||
more.entry(l.path.to_path_buf())
|
||||
.or_default()
|
||||
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let files =
|
||||
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
|
||||
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
|
||||
Ok(Renamed { edit, incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
@@ -2189,6 +2236,10 @@ impl LspManager {
|
||||
},
|
||||
);
|
||||
for file in candidate {
|
||||
// A move may have already removed the sampled entry on disk.
|
||||
if !file.is_file() {
|
||||
continue;
|
||||
}
|
||||
let language = self.languages.detect(&file, None);
|
||||
if !languages.contains(&language) {
|
||||
continue;
|
||||
@@ -2565,6 +2616,15 @@ impl LspManager {
|
||||
}
|
||||
HierarchyDirection::Outgoing => (query().await?, None),
|
||||
};
|
||||
let mut out = out;
|
||||
if direction == HierarchyDirection::Incoming {
|
||||
for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers {
|
||||
match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) {
|
||||
Some(item) => item.from_ranges.extend(caller.from_ranges),
|
||||
None => out.push(caller),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
@@ -3036,6 +3096,13 @@ impl LspManager {
|
||||
.find(|s| s.id == server)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
|
||||
if !s.root.is_dir() {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the workspace root of {} is gone: {}",
|
||||
s.name,
|
||||
s.root.display()
|
||||
)));
|
||||
}
|
||||
self.stop_instance(&s, false).await;
|
||||
s.reset_for_restart();
|
||||
self.ensure_started(&s);
|
||||
|
||||
+184
-3
@@ -10,9 +10,11 @@
|
||||
//! closed again. Diagnostics the server publishes for them are dropped.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::types::ReferencesIncomplete;
|
||||
use crate::text::DocText;
|
||||
use crate::types::{HierarchyItem, ReferencesIncomplete};
|
||||
use crate::{DocSnap, LspManager, Target};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
|
||||
use futures::StreamExt;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
|
||||
use lsp_types as lsp;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
@@ -51,6 +53,14 @@ pub struct CandidateFiles {
|
||||
/// blocking thread and should stop early when cancelled.
|
||||
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
|
||||
|
||||
/// What a Go search adds beyond gopls' own answer.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GoMentions {
|
||||
pub locations: Vec<Location>,
|
||||
/// The declarations containing them, as incoming calls (when asked for).
|
||||
pub callers: Vec<HierarchyItem>,
|
||||
}
|
||||
|
||||
/// A reference-based answer and, when files were opened for it, the second one.
|
||||
pub(crate) struct Widened<R> {
|
||||
pub first: R,
|
||||
@@ -110,7 +120,9 @@ impl LspManager {
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let busy = t.server.is_busy();
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
|
||||
// gopls searches the view of the file asked about; files opened for it only get views of
|
||||
// their own (see `go_scope`).
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
|
||||
let candidates = async {
|
||||
match word {
|
||||
Some(w) => self.candidates(t, w, cancel).await,
|
||||
@@ -162,6 +174,175 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
|
||||
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
|
||||
/// also the declarations containing them, as incoming calls.
|
||||
pub(crate) async fn go_mentions_beyond_search(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: &CancellationToken,
|
||||
callers: bool,
|
||||
) -> GoMentions {
|
||||
if snap.language.as_str() != "go" || !t.caps().open_close {
|
||||
return GoMentions::default();
|
||||
}
|
||||
let Some(word) = search_word(snap, pos) else { return GoMentions::default() };
|
||||
let declarations: Vec<Location> = self
|
||||
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|l| l.uri.is_none())
|
||||
.collect();
|
||||
let Some(declaration) = declarations.first() else { return GoMentions::default() };
|
||||
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
|
||||
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
|
||||
.into_iter()
|
||||
.filter_map(|f| {
|
||||
let text = std::fs::read_to_string(&f).ok()?;
|
||||
let ranges = crate::go_scope::word_ranges(&text, &word);
|
||||
(!ranges.is_empty()).then(|| (f, DocText::new(&text), ranges))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if files.is_empty() || cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
// Open, each file gives gopls a view to resolve its mentions in (its module, its build).
|
||||
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
|
||||
let texts: Vec<(PathBuf, Arc<DocText>)> =
|
||||
files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect();
|
||||
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
|
||||
.into_iter()
|
||||
.zip(&texts)
|
||||
.flat_map(|((path, _, ranges), (_, text))| {
|
||||
let text = text.clone();
|
||||
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
|
||||
})
|
||||
.collect();
|
||||
let declarations = &declarations;
|
||||
let found: Vec<Option<Location>> = futures::stream::iter(probes)
|
||||
.map(|(path, text, range)| async move {
|
||||
let params = json!({
|
||||
"textDocument": { "uri": crate::uri::path_to_uri_string(&path) },
|
||||
"position": text.ide_to_lsp(t.enc(), range.start),
|
||||
});
|
||||
let r = crate::loading::while_loading(
|
||||
&t.server,
|
||||
Some(cancel),
|
||||
crate::loading::LOADING_WAIT,
|
||||
crate::goto_is_empty,
|
||||
|| {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
"textDocument/definition",
|
||||
params.clone(),
|
||||
Some(cancel),
|
||||
crate::DEFAULT_TIMEOUT,
|
||||
)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let targets = match r {
|
||||
Ok(Some(r)) => crate::convert::goto_response(&self.conv(t.enc()), r),
|
||||
_ => return None,
|
||||
};
|
||||
let same = targets
|
||||
.iter()
|
||||
.any(|l| declarations.iter().any(|d| d.path == l.path && d.range.start == l.range.start));
|
||||
same.then(|| Location { path: path.into(), range, uri: None })
|
||||
})
|
||||
.buffer_unordered(8)
|
||||
.collect()
|
||||
.await;
|
||||
let locations: Vec<Location> = found.into_iter().flatten().collect();
|
||||
let mut out = GoMentions { callers: Vec::new(), locations };
|
||||
if callers && !cancel.is_cancelled() {
|
||||
for (path, text) in &texts {
|
||||
let here: Vec<ide_core::Range> =
|
||||
out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect();
|
||||
if !here.is_empty() {
|
||||
out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declarations of `path` containing `calls`, innermost first, each with its call sites.
|
||||
async fn go_callers_in(
|
||||
&self,
|
||||
t: &Target,
|
||||
path: &Path,
|
||||
text: &DocText,
|
||||
calls: &[ide_core::Range],
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<HierarchyItem> {
|
||||
let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } });
|
||||
let r: IdeResult<Option<lsp::DocumentSymbolResponse>> =
|
||||
self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
|
||||
let range =
|
||||
|r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end));
|
||||
// (name, kind, detail, range, selection range) of every declaration.
|
||||
let mut symbols = Vec::new();
|
||||
match r {
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => {
|
||||
let mut stack = nested;
|
||||
while let Some(s) = stack.pop() {
|
||||
symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range)));
|
||||
stack.extend(s.children.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => {
|
||||
for s in flat {
|
||||
let r = range(s.location.range);
|
||||
symbols.push((s.name, s.kind, None, r, r));
|
||||
}
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
let contains =
|
||||
|outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end;
|
||||
let mut items: Vec<HierarchyItem> = Vec::new();
|
||||
for call in calls {
|
||||
// The innermost function or method; a package-level declaration otherwise.
|
||||
let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD);
|
||||
let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| {
|
||||
symbols
|
||||
.iter()
|
||||
.filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call))
|
||||
.min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col)))
|
||||
};
|
||||
let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else {
|
||||
continue;
|
||||
};
|
||||
match items.iter_mut().find(|i| i.range == *r && i.name == *name) {
|
||||
Some(item) => item.from_ranges.push(*call),
|
||||
None => items.push(HierarchyItem {
|
||||
name: name.clone(),
|
||||
kind: crate::convert::symbol_kind(*kind),
|
||||
detail: detail.clone(),
|
||||
path: path.to_path_buf().into(),
|
||||
uri: None,
|
||||
range: *r,
|
||||
selection_range: *selection,
|
||||
from_ranges: vec![*call],
|
||||
}),
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Opens `files` on `t` (read from disk) unless the user has them open;
|
||||
/// they are closed when the returned guard drops. Files too large or not
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
|
||||
@@ -1235,6 +1235,61 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
/// The whole workspace root deleted: the server can never start again
|
||||
/// (its cwd is gone), so the crash drops the entry instead of restarting.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_crash_below_a_deleted_workspace_root_drops_the_server() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
|
||||
// A crash on a root that still exists restarts; removing it changes that.
|
||||
s.peer().kill();
|
||||
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
|
||||
eventually(|| s.launches.load(Ordering::SeqCst) == 2).await;
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
|
||||
std::fs::remove_dir_all(&h.root).unwrap();
|
||||
s.peer().kill();
|
||||
eventually(|| h.removed("mock")).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 2, "no restart with a dead working directory");
|
||||
}
|
||||
|
||||
/// The root deleted while a crash backoff is still sleeping: the pending
|
||||
/// restart must drop the server rather than leave a permanently "Crashed" row.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn root_deleted_during_crash_backoff_drops_the_server() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
|
||||
// Crash with the root alive: "Crashed" is published before the backoff
|
||||
// sleep, so removing the root here lands inside that window.
|
||||
s.peer().kill();
|
||||
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
|
||||
std::fs::remove_dir_all(&h.root).unwrap();
|
||||
eventually(|| h.removed("mock")).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart with a dead working directory");
|
||||
}
|
||||
|
||||
/// A manual restart with the root gone fails instead of spawning into a dead cwd.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn restarting_below_a_deleted_workspace_root_fails() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let id = h.mgr.servers()[0].id;
|
||||
|
||||
std::fs::remove_dir_all(&h.root).unwrap();
|
||||
let err = h.mgr.restart(id).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("workspace root"), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn missing_server_reports_once() {
|
||||
let h = harness(&[("ghost", json!({}))]);
|
||||
@@ -2930,6 +2985,165 @@ async fn references_open_unopened_files_that_mention_the_name() {
|
||||
assert_eq!(seen.lock().len(), 2);
|
||||
}
|
||||
|
||||
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
|
||||
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
|
||||
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf, PathBuf) {
|
||||
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("gopls");
|
||||
let write = |rel: &str, text: &str| {
|
||||
let path = h.root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
};
|
||||
write("go.mod", "module example.com/app\n");
|
||||
write("tools/go.mod", "module example.com/tools\n\nreplace example.com/app => ../\n");
|
||||
write("other/go.mod", "module example.com/other\n\nrequire example.com/app v1.0.0\n");
|
||||
let same = write("same.go", "package app\nvar x = New()\n");
|
||||
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
|
||||
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
|
||||
write("other/o.go", "package other\nvar b = app.New()\n");
|
||||
// Built for plan9 only: outside gopls' default view of the root module.
|
||||
let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n");
|
||||
let decl = h.root.join("a.go");
|
||||
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()];
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: candidates.clone(),
|
||||
limit_hit: false,
|
||||
}));
|
||||
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
|
||||
let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9));
|
||||
let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } });
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
|
||||
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
|
||||
let line = params["position"]["line"].as_u64().unwrap_or(0);
|
||||
match method {
|
||||
// The view of the root module: the declaration and its usage in same.go.
|
||||
"textDocument/references" => Some(json!([
|
||||
{ "uri": decl_uri, "range": at(0, 5) },
|
||||
{ "uri": same_uri, "range": at(1, 8) },
|
||||
])),
|
||||
"textDocument/rename" => Some(json!({ "changes": {
|
||||
decl_uri.clone(): [{ "range": at(0, 5), "newText": "Make" }],
|
||||
same_uri.clone(): [{ "range": at(1, 8), "newText": "Make" }],
|
||||
} })),
|
||||
"textDocument/definition" if uri == decl_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/definition" if uri == tools_uri && line == 1 => {
|
||||
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
|
||||
}
|
||||
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
|
||||
"textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/documentSymbol" if uri == tools_uri => Some(json!([
|
||||
{ "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) },
|
||||
{ "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) },
|
||||
])),
|
||||
"textDocument/documentSymbol" if uri == plan9_uri => Some(json!([
|
||||
{ "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) },
|
||||
])),
|
||||
"textDocument/prepareCallHierarchy" => Some(
|
||||
json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]),
|
||||
),
|
||||
"callHierarchy/incomingCalls" => Some(json!([{
|
||||
"from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) },
|
||||
"fromRanges": [at(1, 8)],
|
||||
}])),
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
(h, s, decl, same, tools, plan9)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
|
||||
let (h, s, decl, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let partials = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = partials.clone();
|
||||
let partial = move |r: ide_lsp::types::References| seen.lock().push(r.locations.len());
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(doc, Pos::new(0, 5), true, true, true, Default::default(), Some(&partial))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut found: Vec<(PathBuf, u32)> =
|
||||
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
|
||||
found.sort();
|
||||
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)];
|
||||
want.sort();
|
||||
assert_eq!(found, want);
|
||||
// Those in gopls' reach arrive first.
|
||||
assert_eq!(*partials.lock(), [2]);
|
||||
// Only the module replacing the declaration's module was opened, never the root module's file.
|
||||
let opened = uris(&s.notified("textDocument/didOpen"));
|
||||
assert!(opened.contains(&uri_of(&tools)));
|
||||
assert!(opened.contains(&uri_of(&plan9)));
|
||||
assert!(!opened.contains(&uri_of(&same)));
|
||||
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
|
||||
assert_eq!(s.requested("textDocument/references").len(), 1);
|
||||
|
||||
let renamed = h.mgr.rename(doc, Pos::new(0, 5), "Make".into(), Default::default()).await.unwrap();
|
||||
let mut edited: Vec<(PathBuf, Vec<u32>)> = renamed
|
||||
.edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, edits, .. } => {
|
||||
Some((path.to_path_buf(), edits.iter().map(|e| e.range.start.line).collect()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
edited.sort();
|
||||
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])];
|
||||
want.sort();
|
||||
assert_eq!(edited, want);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_incoming_calls_include_callers_beyond_the_search() {
|
||||
let (h, s, _, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let calls =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap();
|
||||
let mut callers: Vec<(PathBuf, String, Vec<u32>)> = calls
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect()))
|
||||
.collect();
|
||||
callers.sort();
|
||||
let mut want = vec![
|
||||
(same, "x".to_owned(), vec![1]),
|
||||
(tools, "a".to_owned(), vec![1]),
|
||||
(plan9.clone(), "boot".to_owned(), vec![1]),
|
||||
];
|
||||
want.sort();
|
||||
assert_eq!(callers, want);
|
||||
let boot = calls.items.iter().find(|i| i.name == "boot").unwrap();
|
||||
assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
|
||||
let (h, s, _, same, _, plan9) = go_modules_harness();
|
||||
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
|
||||
std::fs::remove_file(&plan9).unwrap();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 2);
|
||||
assert_eq!(uris(&s.notified("textDocument/didOpen")).len(), 1, "only a.go");
|
||||
assert!(s.requested("textDocument/definition").len() <= 1);
|
||||
assert!(!uris(&s.notified("textDocument/didOpen")).contains(&uri_of(&same)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn references_never_reopen_or_close_the_users_files() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! themselves are in `wit_host`.
|
||||
|
||||
use crate::types::{
|
||||
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument, SemanticToken,
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
|
||||
ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
@@ -15,8 +16,8 @@ use crate::types::{
|
||||
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{
|
||||
bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, syntax_bindings,
|
||||
views_bindings,
|
||||
bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
|
||||
services_bindings, syntax_bindings, views_bindings,
|
||||
};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
@@ -33,6 +34,8 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
use bindings::openidea::plugin::types as wit_types;
|
||||
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
|
||||
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
|
||||
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
|
||||
@@ -114,6 +117,22 @@ pub trait HostApi: Send + Sync {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
|
||||
/// The commit being prepared in `client`'s window (`None`: no repository),
|
||||
/// waiting for the window until `cancel` fires.
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let _ = (client, cancel);
|
||||
Ok(None)
|
||||
}
|
||||
/// Replaces the commit message of `client`'s window if its draft is still at
|
||||
/// `revision` (else [`HostError::Stale`]).
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let _ = (client, revision, message);
|
||||
Err(HostError::Invalid("no commit is being prepared".into()))
|
||||
}
|
||||
/// Subscribes to (or unsubscribes from) a kind of workbench event.
|
||||
/// Project files matching `glob` (root-relative), at most `limit`.
|
||||
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
|
||||
@@ -524,6 +543,8 @@ struct Exports {
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
services: Option<wit_svc::Guest>,
|
||||
hover: Option<wit_hover::Guest>,
|
||||
commit_checks: Option<wit_cc::Guest>,
|
||||
}
|
||||
|
||||
/// Most results one syntax call may return (more are dropped).
|
||||
@@ -605,6 +626,8 @@ impl Runtime {
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
|
||||
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
|
||||
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
@@ -631,9 +654,17 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let hover = match hover_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let commit_checks = match commit_check_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services },
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
})
|
||||
@@ -891,6 +922,29 @@ impl Runtime {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn provide_hover(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<PluginHover>, CallError> {
|
||||
if self.exports.hover.is_none() {
|
||||
return Err(CallError::missing_export("hover-provider"));
|
||||
}
|
||||
let d = wit_hover::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let hover = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.hover.as_ref().expect("checked").call_hover(store, &d, pos_to_wit(at))
|
||||
})?;
|
||||
Ok(hover
|
||||
.filter(|h| !h.markdown.trim().is_empty())
|
||||
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
|
||||
}
|
||||
|
||||
pub fn activate(&mut self) -> Result<(), CallError> {
|
||||
let data = self.store.data();
|
||||
let ctx = wit_types::Context {
|
||||
@@ -979,6 +1033,30 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the plugin's before-commit check `check` on `draft`.
|
||||
pub fn commit_check(
|
||||
&mut self,
|
||||
check: &str,
|
||||
draft: CommitDraft,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<CommitProblem>, CallError> {
|
||||
if self.exports.commit_checks.is_none() {
|
||||
return Err(CallError::missing_export("commit-checker"));
|
||||
}
|
||||
let draft = crate::wit_host::commit_draft_to_wit(draft);
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let checks = e.commit_checks.as_ref().expect("checked");
|
||||
let r = checks.call_check(&mut *store, check, &draft, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
match result {
|
||||
Ok(problems) => Ok(problems.into_iter().take(MAX_COMMIT_PROBLEMS).map(commit_problem_from_wit).collect()),
|
||||
Err(e) => Err(CallError::Guest(e.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A user action in a view or custom editor.
|
||||
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
|
||||
if self.exports.views.is_none() {
|
||||
@@ -1068,6 +1146,7 @@ impl Runtime {
|
||||
const MAX_PROVIDER_ITEMS: usize = 10_000;
|
||||
const MAX_SEMANTIC_TOKENS: usize = 1 << 16;
|
||||
const MAX_TOKEN_MODIFIERS: usize = 8;
|
||||
const MAX_HOVER_BYTES: usize = 64 * 1024;
|
||||
|
||||
fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position {
|
||||
wit_types::Position { line: p.line, character: p.col }
|
||||
@@ -1176,6 +1255,16 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
|
||||
const MAX_COMMIT_PROBLEMS: usize = 50;
|
||||
|
||||
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
|
||||
let (path, range) = match p.location {
|
||||
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
|
||||
None => (None, None),
|
||||
};
|
||||
CommitProblem { message: clip(p.message, 4096), path, range }
|
||||
}
|
||||
|
||||
const MAX_SERVICE_TABS: usize = 8;
|
||||
const MAX_LOG_TOGGLES: usize = 8;
|
||||
const MAX_LOG_ARGS: usize = 256;
|
||||
|
||||
@@ -210,6 +210,40 @@ pub struct RepositoryStatus {
|
||||
pub changed_files: u32,
|
||||
}
|
||||
|
||||
/// A file a pending commit changes (WIT `commit.commit-change`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitChange {
|
||||
pub repository: String,
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
|
||||
pub status: String,
|
||||
/// `git diff` of the included changes; `None` for binary content.
|
||||
pub diff: Option<String>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// What Commit would commit (WIT `commit.commit-draft`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitDraft {
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub changes: Vec<CommitChange>,
|
||||
}
|
||||
|
||||
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitProblem {
|
||||
pub message: String,
|
||||
/// Absolute path of the file it is in, if any (IPC form).
|
||||
pub path: Option<String>,
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
/// Resource limits and supervision policy for plugin code.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
@@ -426,6 +460,12 @@ pub struct SemanticToken {
|
||||
pub modifiers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PluginHover {
|
||||
pub markdown: String,
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -69,6 +70,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -96,6 +98,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
|
||||
vcs, views, workspace,
|
||||
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
|
||||
types as wit, ui, vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
@@ -571,6 +574,62 @@ impl vcs::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
impl commit::Host for StoreData {
|
||||
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
|
||||
let client = self.invocation.client.clone();
|
||||
let started = Instant::now();
|
||||
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::VcsWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if message.len() > MAX_COMMIT_MESSAGE {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest commit message a plugin may set.
|
||||
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
|
||||
|
||||
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
|
||||
commit::CommitDraft {
|
||||
revision: d.revision,
|
||||
message: d.message,
|
||||
amend: d.amend,
|
||||
changes: d
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| commit::CommitChange {
|
||||
repository: c.repository,
|
||||
path: c.path,
|
||||
old_path: c.old_path,
|
||||
status: c.status,
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
fn subscribe(&mut self, kind: String) -> Fallible<()> {
|
||||
let Some(kind) = EventKind::parse(&kind) else {
|
||||
@@ -828,9 +887,30 @@ pub(crate) mod services_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `commit-checker` export.
|
||||
pub(crate) mod commit_checks_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "commit-checks", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod semantic_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "semantic-tokens", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod hover_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "hover", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ fn components() -> Vec<PathBuf> {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
|
||||
];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Commit test fixture
|
||||
|
||||
`Describe Changes` (in the `commit.message` toolbar) reads the commit being
|
||||
prepared and writes a message listing its files; the `markers` before-commit
|
||||
check reports a message without a `type: ` prefix and every `DO NOT COMMIT`
|
||||
added by the commit.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_commit` to exercise the `commit`
|
||||
interface and the optional `commit-checker` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
|
||||
@@ -0,0 +1,21 @@
|
||||
id = "test.commit"
|
||||
name = "Commit fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["vcs.read", "vcs.write"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "describe"
|
||||
title = "Commit Fixture: Describe Changes"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "commit.message"
|
||||
command = "describe"
|
||||
|
||||
[[contributes.commitChecks]]
|
||||
id = "markers"
|
||||
title = "Commit Markers"
|
||||
@@ -0,0 +1,57 @@
|
||||
use openidea_plugin::commit::{CommitChecks, CommitProblem};
|
||||
use openidea_plugin::{CommitDraft, Error, Invocation, Location, Plugin, Position, Range, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id != "describe" {
|
||||
return Err(Error::NotFound(id.into()));
|
||||
}
|
||||
let draft = host::pending_commit()?.ok_or("no repository")?;
|
||||
let names: Vec<String> = draft
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| format!("{} {}", c.status, c.path.rsplit('/').next().unwrap_or(&c.path)))
|
||||
.collect();
|
||||
let message = format!("chore: {}", names.join(", "));
|
||||
host::set_commit_message(draft.revision, &message)?;
|
||||
Ok(Some(message))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitChecks for Fixture {
|
||||
fn check(&mut self, check: &str, draft: &CommitDraft, _: &Invocation) -> Result<Vec<CommitProblem>, Error> {
|
||||
if check != "markers" {
|
||||
return Err(Error::NotFound(check.into()));
|
||||
}
|
||||
let mut problems = Vec::new();
|
||||
if !draft.message.split_once(": ").is_some_and(|(t, _)| !t.is_empty() && !t.contains(' ')) {
|
||||
problems.push(CommitProblem::new("The message does not start with `type: `."));
|
||||
}
|
||||
for change in &draft.changes {
|
||||
let Some(diff) = &change.diff else { continue };
|
||||
let mut line = 0u32;
|
||||
for l in diff.lines() {
|
||||
if let Some(hunk) = l.strip_prefix("@@ -") {
|
||||
// `@@ -a,b +c,d @@`: c is the 1-based first line of the new side.
|
||||
let new = hunk.split(" +").nth(1).and_then(|s| s.split([',', ' ']).next());
|
||||
line = new.and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).saturating_sub(1);
|
||||
} else if l.starts_with('+') && !l.starts_with("+++") {
|
||||
if l.contains("DO NOT COMMIT") {
|
||||
let at = Position::new(line, 0);
|
||||
let location = Location { path: change.path.clone(), range: Range::new(at, at) };
|
||||
problems.push(CommitProblem::new("Remove the DO NOT COMMIT marker.").at(location));
|
||||
}
|
||||
line += 1;
|
||||
} else if !l.starts_with('-') {
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, commit_checks);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450
|
||||
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-hover"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-hover"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Hover test fixture
|
||||
|
||||
Over a name the PHP document declares as `function <name>(`, shows
|
||||
`**<name>** · declared on line <n>` for the name's range; nothing elsewhere.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_hover` to exercise the optional
|
||||
`hover-provider` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
|
||||
@@ -0,0 +1,9 @@
|
||||
id = "test.hover"
|
||||
name = "Hover fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["hover"]
|
||||
languages = ["php"]
|
||||
@@ -0,0 +1,31 @@
|
||||
use openidea_plugin::hover::{Document, Hover, HoverProvider};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin, text};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl HoverProvider for Fixture {
|
||||
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
|
||||
let text = doc.text.as_str();
|
||||
let offset = text::offset(text, at)?;
|
||||
let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
|
||||
let start = text[..offset].rfind(|c: char| !word(c)).map_or(0, |i| i + 1);
|
||||
let end = text[offset..].find(|c: char| !word(c)).map_or(text.len(), |n| offset + n);
|
||||
let name = &text[start..end];
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let declared = text.find(&format!("function {name}("))?;
|
||||
let line = text::position(text, declared).line + 1;
|
||||
let range = Range { start: text::position(text, start), end: text::position(text, end) };
|
||||
Some(Hover::new(format!("**{name}** · declared on line {line}")).range(range))
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, hover);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c913c2c94fecb43d00f73f3171cc2fda54caf7a28f170424b0addb1d40ed4cae
|
||||
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
911388ae62843d7b1e4c40d91307fbe507535c60680a9626cc07d724b75a9dfb
|
||||
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a3818b6ec40df8f141290537a7c024aadb02d7be1eb063ca9170799ca3271898
|
||||
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
9e4c12fdf905227128d97e92639cf336124929c37384e5f5796e27ebfc5a7b37
|
||||
c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
|
||||
@@ -1048,10 +1048,15 @@ fn is_composer_event(name: &str) -> bool {
|
||||
name.starts_with("pre-") || name.starts_with("post-") || name == "command"
|
||||
}
|
||||
|
||||
/// The PHPUnit entry point: the project's `vendor/bin/phpunit`, else a global `phpunit`.
|
||||
fn phpunit_command(root: &Path) -> String {
|
||||
let bin = if cfg!(windows) { "vendor/bin/phpunit.bat" } else { "vendor/bin/phpunit" };
|
||||
if root.join(bin).is_file() { bin.to_owned() } else { "phpunit".to_owned() }
|
||||
/// The PHPUnit entry point: the project's `vendor/bin/pest` or `vendor/bin/phpunit`, else a global `phpunit`.
|
||||
fn phpunit_command(root: &Path) -> (String, &'static str) {
|
||||
for (runner, label) in [("pest", "Pest"), ("phpunit", "PHPUnit")] {
|
||||
let bin = format!("vendor/bin/{runner}{}", if cfg!(windows) { ".bat" } else { "" });
|
||||
if root.join(&bin).is_file() {
|
||||
return (bin, label);
|
||||
}
|
||||
}
|
||||
("phpunit".to_owned(), "PHPUnit")
|
||||
}
|
||||
|
||||
/// Composer projects (`composer.json`) and PHPUnit suites (`phpunit.xml`, `phpunit.xml.dist`).
|
||||
@@ -1085,8 +1090,14 @@ fn detect_php(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
);
|
||||
out.push(if name == "test" { c.test() } else { c });
|
||||
}
|
||||
if phpunit_xml.is_some() || requires("phpunit/phpunit") || root.join("vendor/bin/phpunit").is_file() {
|
||||
let mut c = config("phpunit", "PHPUnit", "phpunit", &phpunit_command(root), &[]).test().debug("php-debug");
|
||||
if phpunit_xml.is_some()
|
||||
|| requires("phpunit/phpunit")
|
||||
|| requires("pestphp/pest")
|
||||
|| root.join("vendor/bin/phpunit").is_file()
|
||||
|| root.join("vendor/bin/pest").is_file()
|
||||
{
|
||||
let (command, label) = phpunit_command(root);
|
||||
let mut c = config("phpunit", label, "phpunit", &command, &[]).test().debug("php-debug");
|
||||
// A configuration file with a non-default name must be passed explicitly.
|
||||
let named = phpunit_xml.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned());
|
||||
if let Some(file) = named.filter(|f| f == "phpunit.dist.xml") {
|
||||
|
||||
@@ -827,6 +827,27 @@ fn php_phpunit_project() {
|
||||
assert!(!find(&c, "composer:lint").is_test);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_pest_project() {
|
||||
let dir = project(&[
|
||||
("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#),
|
||||
("phpunit.xml", "<phpunit/>"),
|
||||
("vendor/bin/phpunit", ""),
|
||||
("vendor/bin/phpunit.bat", ""),
|
||||
("vendor/bin/pest", ""),
|
||||
("vendor/bin/pest.bat", ""),
|
||||
]);
|
||||
let c = detect(dir.path());
|
||||
let pest = find(&c, "phpunit");
|
||||
assert_eq!(pest.name, "Pest");
|
||||
assert_eq!(cmdline(pest), if cfg!(windows) { "vendor/bin/pest.bat" } else { "vendor/bin/pest" });
|
||||
assert_eq!(pest.kind, "phpunit");
|
||||
assert!(pest.is_test);
|
||||
assert_eq!(pest.debug_adapter.as_deref(), Some("php-debug"));
|
||||
let dir = project(&[("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#)]);
|
||||
assert_eq!(find(&detect(dir.path()), "phpunit").name, "PHPUnit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_detection_needs_a_marker() {
|
||||
let dir = project(&[("index.php", "<?php")]);
|
||||
|
||||
@@ -23,38 +23,61 @@ fn wait_exit(events: &Receiver<IdeEvent>, id: RunId, timeout: Duration) -> Optio
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
match events.recv_timeout(left) {
|
||||
Ok(IdeEvent::RunExited { run, code }) if run == id => return code,
|
||||
Ok(_) => {}
|
||||
Err(_) => return None,
|
||||
match events.recv_timeout(left).expect("RunExited event") {
|
||||
IdeEvent::RunExited { run, code } if run == id => return code,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(what: &str, mut ready: impl FnMut() -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready() {
|
||||
assert!(Instant::now() < deadline, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_still_running(events: &Receiver<IdeEvent>, timeout: Duration) {
|
||||
let event = events.recv_timeout(timeout);
|
||||
assert!(
|
||||
matches!(event, Err(mpsc::RecvTimeoutError::Timeout)),
|
||||
"run exited before shutdown was released: {event:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_run_keeps_its_port_until_the_group_is_gone() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
// The `mvn`-starts-a-JVM shape: the direct child dies on SIGINT while the process it
|
||||
// started ignores it and keeps its socket open for a moment.
|
||||
// started ignores it and keeps its socket open until the test releases it.
|
||||
let script = tmp.path().join("hold.py");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import signal, socket, sys, time\n\
|
||||
"import pathlib, signal, socket, sys, time\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
s = socket.socket(); s.bind(('127.0.0.1', 0)); s.listen()\n\
|
||||
open(sys.argv[1], 'w').write(str(s.getsockname()[1]))\n\
|
||||
time.sleep(2)\n",
|
||||
while not pathlib.Path(sys.argv[2]).exists(): time.sleep(0.01)\n",
|
||||
)
|
||||
.unwrap();
|
||||
let port_file = tmp.path().join("port");
|
||||
let release_file = tmp.path().join("release");
|
||||
let config = RunConfig {
|
||||
id: "boot".into(),
|
||||
name: "boot".into(),
|
||||
kind: "shell".into(),
|
||||
command: "/bin/sh".into(),
|
||||
args: vec!["-c".into(), format!("python3 {} {} & sleep 60", script.display(), port_file.display())],
|
||||
args: vec![
|
||||
"-c".into(),
|
||||
"python3 \"$1\" \"$2\" \"$3\" & sleep 60".into(),
|
||||
"sh".into(),
|
||||
script.display().to_string(),
|
||||
port_file.display().to_string(),
|
||||
release_file.display().to_string(),
|
||||
],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
@@ -64,18 +87,24 @@ fn a_stopped_run_keeps_its_port_until_the_group_is_gone() {
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while std::fs::read_to_string(&port_file).is_err() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
wait_for("the listening port", || {
|
||||
std::fs::read_to_string(&port_file).ok().is_some_and(|text| text.trim().parse::<u16>().is_ok())
|
||||
});
|
||||
let port: u16 = std::fs::read_to_string(&port_file).expect("port").trim().parse().unwrap();
|
||||
|
||||
let stopped = Instant::now();
|
||||
pm.stop(info.id).unwrap();
|
||||
let pid = info.pid.expect("child pid") as libc::pid_t;
|
||||
wait_for("the direct child to exit", || unsafe { libc::kill(pid, 0) != 0 });
|
||||
let exit = events.recv_timeout(Duration::from_secs(1));
|
||||
let port_held = std::net::TcpStream::connect(("127.0.0.1", port)).is_ok();
|
||||
std::fs::write(&release_file, "").unwrap();
|
||||
assert!(
|
||||
matches!(exit, Err(mpsc::RecvTimeoutError::Timeout)),
|
||||
"run exited while its descendant held the port: {exit:?}"
|
||||
);
|
||||
assert!(port_held, "the descendant still holds its port");
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(20));
|
||||
let elapsed = stopped.elapsed();
|
||||
assert!(code.is_some(), "the run reports its exit");
|
||||
assert!(elapsed >= Duration::from_millis(1500), "exit came {elapsed:?} after Stop, before the port was free");
|
||||
assert!(
|
||||
std::net::TcpStream::connect(("127.0.0.1", port)).is_err(),
|
||||
"the port is still bound when the run reports finished"
|
||||
@@ -87,15 +116,16 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
// Ignores SIGINT, then exits by itself after 2 s, like a slow Spring Boot shutdown: a first
|
||||
// Stop waits for it instead of killing it (exit code 137).
|
||||
// Ignores SIGINT until stdin releases the shutdown, like a slow Spring Boot shutdown.
|
||||
// A first Stop must wait even past the grace period instead of killing it (exit code 137).
|
||||
let script = tmp.path().join("slow.py");
|
||||
let ready_file = tmp.path().join("ready");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import signal, sys, time\n\
|
||||
"import pathlib, signal, sys\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
print('ready', flush=True)\n\
|
||||
time.sleep(2)\n",
|
||||
pathlib.Path(sys.argv[1]).touch()\n\
|
||||
sys.stdin.readline()\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = RunConfig {
|
||||
@@ -103,7 +133,7 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
|
||||
name: "slow".into(),
|
||||
kind: "shell".into(),
|
||||
command: "python3".into(),
|
||||
args: vec![script.display().to_string()],
|
||||
args: vec![script.display().to_string(), ready_file.display().to_string()],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
@@ -113,12 +143,12 @@ fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
std::thread::sleep(Duration::from_millis(500)); // let the program install its handler
|
||||
let start = Instant::now();
|
||||
pm.stop(info.id).unwrap();
|
||||
wait_for("the SIGINT handler", || ready_file.exists());
|
||||
pm.stop_with_grace(info.id, Duration::from_millis(100)).unwrap();
|
||||
assert_still_running(&events, Duration::from_secs(1));
|
||||
pm.write_stdin(info.id, "finish shutdown\n").unwrap();
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(10));
|
||||
assert_eq!(code, Some(0), "the program exited by itself, not by SIGKILL");
|
||||
assert!(start.elapsed() >= Duration::from_millis(1500), "stop waited for the shutdown, not a timer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -127,11 +157,13 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
let script = tmp.path().join("stuck.py");
|
||||
let ready_file = tmp.path().join("ready");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import signal, time\n\
|
||||
"import pathlib, signal, sys\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
time.sleep(60)\n",
|
||||
pathlib.Path(sys.argv[1]).touch()\n\
|
||||
sys.stdin.readline()\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = RunConfig {
|
||||
@@ -139,7 +171,7 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
|
||||
name: "stuck".into(),
|
||||
kind: "shell".into(),
|
||||
command: "python3".into(),
|
||||
args: vec![script.display().to_string()],
|
||||
args: vec![script.display().to_string(), ready_file.display().to_string()],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
@@ -149,9 +181,9 @@ fn a_second_stop_kills_a_program_that_ignores_the_first() {
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
wait_for("the SIGINT handler", || ready_file.exists());
|
||||
pm.stop(info.id).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert_still_running(&events, Duration::from_millis(200));
|
||||
pm.stop(info.id).unwrap();
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(10));
|
||||
assert_eq!(code, Some(137), "the second Stop SIGKILLs");
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
pub use text_encoding::TextEncoding;
|
||||
pub use write::PendingChange;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1069,6 +1070,17 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::commit(self, message, options, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// What [`Repo::commit_with`] would commit with `options`, file by file, without
|
||||
/// committing or touching the index: patches against HEAD, `max_bytes` in all.
|
||||
pub fn pending_changes<E: Into<TextEncoding>>(
|
||||
&self,
|
||||
options: &CommitOptions,
|
||||
max_bytes: usize,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<E>,
|
||||
) -> IdeResult<Vec<PendingChange>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::pending_changes(self, options, max_bytes, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
|
||||
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
|
||||
/// detached.
|
||||
|
||||
@@ -15,6 +15,11 @@ pub(crate) struct Cache {
|
||||
repo: gix::ThreadSafeRepository,
|
||||
files: [PathBuf; 4],
|
||||
stamp: Vec<Option<(SystemTime, u64)>>,
|
||||
/// `objects/pack` directory and its modification time. An external
|
||||
/// `git fetch`/`pull`/`repack` adds packs without touching the `files`
|
||||
/// above — watching the directory catches them (issue #1919).
|
||||
pack_dir: PathBuf,
|
||||
pack_stamp: Option<SystemTime>,
|
||||
}
|
||||
|
||||
fn cache_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 4] {
|
||||
@@ -36,6 +41,12 @@ fn stamp(files: &[PathBuf]) -> Vec<Option<(SystemTime, u64)>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pack directory's modification time: adding or removing a pack file
|
||||
/// updates it. `None` when the directory can't be read.
|
||||
fn pack_stamp(pack_dir: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(pack_dir).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let start = if path.is_file() { path.parent().unwrap_or(path) } else { path };
|
||||
if let Err(e) = gix::discover::upwards(start) {
|
||||
@@ -57,7 +68,8 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let git_dir = absolute(repo.git_dir())?;
|
||||
let common_dir = absolute(repo.common_dir())?;
|
||||
let files = cache_files(&git_dir, &common_dir);
|
||||
let cache = Cache { stamp: stamp(&files), files, repo: ts };
|
||||
let pack_dir = common_dir.join("objects").join("pack");
|
||||
let cache = Cache { stamp: stamp(&files), files, repo: ts, pack_stamp: pack_stamp(&pack_dir), pack_dir };
|
||||
Ok(Some(Repo {
|
||||
execution_allowed: std::sync::atomic::AtomicBool::new(true),
|
||||
root,
|
||||
@@ -96,11 +108,13 @@ impl Repo {
|
||||
}
|
||||
let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let now = stamp(&cache.files);
|
||||
if now != cache.stamp {
|
||||
let pack_now = pack_stamp(&cache.pack_dir);
|
||||
if now != cache.stamp || pack_now != cache.pack_stamp {
|
||||
match gix::ThreadSafeRepository::open(&self.git_dir) {
|
||||
Ok(fresh) => {
|
||||
cache.repo = fresh;
|
||||
cache.stamp = now;
|
||||
cache.pack_stamp = pack_now;
|
||||
}
|
||||
Err(e) => tracing::warn!("reopening repository failed: {e}"),
|
||||
}
|
||||
|
||||
+140
-9
@@ -372,10 +372,10 @@ pub(crate) fn commit(
|
||||
args.extend(["-F", "-"]);
|
||||
Some(message.as_bytes())
|
||||
};
|
||||
if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() {
|
||||
run(r.root(), &args, stdin)?;
|
||||
} else {
|
||||
if selects(options) {
|
||||
commit_selected(r, &args, stdin, options, encoding)?;
|
||||
} else {
|
||||
run(r.root(), &args, stdin)?;
|
||||
}
|
||||
// A commit concluding a merge brings back the local changes put aside for it.
|
||||
let _ = crate::preserve::restore(r);
|
||||
@@ -393,9 +393,13 @@ impl Drop for TempIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `options` commit something else than the index as it is.
|
||||
fn selects(options: &CommitOptions) -> bool {
|
||||
!(options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty())
|
||||
}
|
||||
|
||||
/// Commits a selection of the changes through a copy of the index (`GIT_INDEX_FILE`):
|
||||
/// `exclude` goes back to HEAD in the copy, `stage` takes its working-tree content and
|
||||
/// `partial` only the chosen hunks. The working tree and the user's index are left alone
|
||||
/// see [`select_into`]. The working tree and the user's index are left alone
|
||||
/// except that the committed files' entries end up matching the new commit, so changes
|
||||
/// left out keep their staged state.
|
||||
fn commit_selected(
|
||||
@@ -406,6 +410,23 @@ fn commit_selected(
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<()> {
|
||||
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
|
||||
let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
|
||||
let rels = select_into(r, &tmp, options, encoding)?;
|
||||
cli::run_env(r.root(), commit_args, stdin, &env)?;
|
||||
// The user's index still holds the partial files' previous entries: align them with
|
||||
// the commit so the remaining changes show as unstaged, nothing as staged.
|
||||
run_paths(r, &["reset", "-q", "HEAD"], &rels)
|
||||
}
|
||||
|
||||
/// Fills `tmp`, a copy of the index, with what a commit of `options` holds: `exclude`
|
||||
/// goes back to HEAD, `stage` takes its working-tree content and `partial` only the
|
||||
/// chosen hunks. Returns the root-relative paths of `stage` and `partial`.
|
||||
fn select_into(
|
||||
r: &Repo,
|
||||
tmp: &TempIndex,
|
||||
options: &CommitOptions,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let index = r.git_dir.join("index");
|
||||
if index.exists() {
|
||||
std::fs::copy(&index, &tmp.0)?;
|
||||
@@ -459,10 +480,120 @@ fn commit_selected(
|
||||
write_index_blob(r, &rel, &bytes, &env)?;
|
||||
rels.push(rel);
|
||||
}
|
||||
cli::run_env(r.root(), commit_args, stdin, &env)?;
|
||||
// The user's index still holds the partial files' previous entries: align them with
|
||||
// the commit so the remaining changes show as unstaged, nothing as staged.
|
||||
run_paths(r, &["reset", "-q", "HEAD"], &rels)
|
||||
Ok(rels)
|
||||
}
|
||||
|
||||
/// The changes a commit of `options` would make, as `git diff` reports them (renames
|
||||
/// detected), from HEAD (an empty tree without one). Nothing is committed and the
|
||||
/// working tree and the index are left alone: a selection is made in a copy of the
|
||||
/// index. Patches stop growing at `max_bytes` in all.
|
||||
pub(crate) fn pending_changes(
|
||||
r: &Repo,
|
||||
options: &CommitOptions,
|
||||
max_bytes: usize,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<Vec<PendingChange>> {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let tmp = TempIndex(r.git_dir.join(format!("openidea-pending-index-{}-{n}", std::process::id())));
|
||||
let env_tmp = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
|
||||
let env: &[(&str, &std::ffi::OsStr)] = if selects(options) {
|
||||
select_into(r, &tmp, options, encoding)?;
|
||||
&env_tmp
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
// `--cached` compares with HEAD, or with an empty tree on an unborn branch.
|
||||
let base = ["diff", "--cached", "-M", "--no-ext-diff", "--no-textconv", "--no-color", "--no-relative"];
|
||||
let names = cli::run_env(r.root(), &[&base[..], &["--name-status", "-z"]].concat(), None, env)?;
|
||||
let patch = cli::run_env(
|
||||
r.root(),
|
||||
&[&base[..], &["--patch", "--src-prefix=a/", "--dst-prefix=b/", "--full-index"]].concat(),
|
||||
None,
|
||||
env,
|
||||
)?;
|
||||
let mut changes = parse_name_status(&names.stdout, r.root());
|
||||
let sections = split_patch(&patch.stdout);
|
||||
let mut budget = max_bytes;
|
||||
for (change, section) in changes.iter_mut().zip(sections) {
|
||||
if patch_is_binary(section) {
|
||||
continue;
|
||||
}
|
||||
let charset = encoding(&change.path).unwrap_or_else(|_| encoding_rs::UTF_8.into());
|
||||
let text = crate::text_encoding::decode_raw(section, charset);
|
||||
if text.len() <= budget {
|
||||
budget -= text.len();
|
||||
change.diff = Some(text.into_owned());
|
||||
} else {
|
||||
let mut cut = budget.min(text.len());
|
||||
// Whole lines only, at a character boundary.
|
||||
while cut > 0 && !text.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
let kept = text[..cut].rfind('\n').map_or(0, |i| i + 1);
|
||||
budget -= kept;
|
||||
change.diff = Some(text[..kept].to_owned());
|
||||
change.truncated = true;
|
||||
}
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// A file a pending commit changes ([`Repo::pending_changes`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PendingChange {
|
||||
pub path: PathBuf,
|
||||
/// The path before a rename or copy.
|
||||
pub old_path: Option<PathBuf>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
|
||||
pub status: &'static str,
|
||||
/// Its section of the unified diff; `None` for binary content.
|
||||
pub diff: Option<String>,
|
||||
/// `diff` was cut at the size limit.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// `git diff --name-status -z`: `M\0path\0`, `R100\0old\0new\0`.
|
||||
fn parse_name_status(out: &[u8], root: &Path) -> Vec<PendingChange> {
|
||||
let mut fields = out.split(|b| *b == 0).filter(|f| !f.is_empty());
|
||||
let path = |f: &[u8]| root.join(String::from_utf8_lossy(f).as_ref());
|
||||
let mut changes = Vec::new();
|
||||
while let Some(code) = fields.next() {
|
||||
let status = match code.first() {
|
||||
Some(b'A') => "added",
|
||||
Some(b'D') => "deleted",
|
||||
Some(b'R') => "renamed",
|
||||
Some(b'C') => "copied",
|
||||
Some(b'T') => "type-changed",
|
||||
_ => "modified",
|
||||
};
|
||||
let two = matches!(code.first(), Some(b'R' | b'C'));
|
||||
let first = fields.next().map(path);
|
||||
let (old_path, path) = if two { (first, fields.next().map(path)) } else { (None, first) };
|
||||
let Some(path) = path else { break };
|
||||
changes.push(PendingChange { path, old_path, status, diff: None, truncated: false });
|
||||
}
|
||||
changes
|
||||
}
|
||||
|
||||
/// The per-file sections of a patch, each starting at its `diff --git` line.
|
||||
fn split_patch(patch: &[u8]) -> Vec<&[u8]> {
|
||||
let mut starts: Vec<usize> = Vec::new();
|
||||
let mut at = 0;
|
||||
for line in patch.split_inclusive(|b| *b == b'\n') {
|
||||
if line.starts_with(b"diff --git ") {
|
||||
starts.push(at);
|
||||
}
|
||||
at += line.len();
|
||||
}
|
||||
starts.iter().enumerate().map(|(i, &s)| &patch[s..starts.get(i + 1).copied().unwrap_or(patch.len())]).collect()
|
||||
}
|
||||
|
||||
fn patch_is_binary(section: &[u8]) -> bool {
|
||||
section
|
||||
.split(|b| *b == b'\n')
|
||||
.take_while(|l| !l.starts_with(b"@@"))
|
||||
.any(|l| l.starts_with(b"Binary files ") || l.starts_with(b"GIT binary patch"))
|
||||
}
|
||||
|
||||
pub(crate) fn checkout(r: &Repo, branch: &str, create: bool) -> IdeResult<()> {
|
||||
|
||||
@@ -1203,6 +1203,71 @@ fn partial_commit_of_chosen_hunks_leaves_worktree_and_index_alone() {
|
||||
assert_eq!(fx.read("f.txt"), "Z\nb\nc\nd\ne\nf\ng\nh\ni\nJ\nk\nl\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_changes_show_what_the_commit_would_hold_without_touching_the_index() {
|
||||
let fx = Fixture::new();
|
||||
let base = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
|
||||
fx.write("f.txt", base);
|
||||
fx.write("staged.txt", "s\n");
|
||||
fx.write("old.txt", "moved\nfile\nwith\nlines\n");
|
||||
fx.write("bin.dat", "\0\x01");
|
||||
fx.commit_all("base");
|
||||
let repo = fx.repo();
|
||||
let utf8 = |_: &Path| -> ide_core::IdeResult<&encoding_rs::Encoding> { Ok(encoding_rs::UTF_8) };
|
||||
|
||||
let edited = "A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
|
||||
fx.write("f.txt", edited);
|
||||
fx.write("staged.txt", "s2\n");
|
||||
fx.git(&["add", "staged.txt"]);
|
||||
fx.git(&["mv", "old.txt", "new.txt"]);
|
||||
fx.write("bin.dat", "\0\x02");
|
||||
fx.write("added.txt", "new\n");
|
||||
let index_before = std::fs::read(fx.dir.join(".git/index")).unwrap();
|
||||
|
||||
// The index as it is: the staged file and the rename.
|
||||
let plain = repo.pending_changes(&CommitOptions::default(), 1 << 20, &utf8).unwrap();
|
||||
let summary: Vec<_> = plain
|
||||
.iter()
|
||||
.map(|c| (c.status, c.path.strip_prefix(&fx.dir).unwrap().to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
assert_eq!(summary, [("renamed", "new.txt".to_owned()), ("modified", "staged.txt".to_owned())]);
|
||||
assert_eq!(plain[0].old_path.as_deref(), Some(fx.path("old.txt").as_path()));
|
||||
assert!(plain[1].diff.as_deref().unwrap().contains("-s\n+s2\n"), "{:?}", plain[1].diff);
|
||||
|
||||
// A selection: the first hunk of f.txt, the binary and the new file, staged.txt left out.
|
||||
let hunks = diff_hunks(base, edited);
|
||||
let options = CommitOptions {
|
||||
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
|
||||
stage: vec![fx.path("bin.dat").into(), fx.path("added.txt").into()],
|
||||
exclude: vec![fx.path("staged.txt").into()],
|
||||
..Default::default()
|
||||
};
|
||||
let picked = repo.pending_changes(&options, 1 << 20, &utf8).unwrap();
|
||||
let by_name = |name: &str| picked.iter().find(|c| c.path == fx.path(name)).unwrap_or_else(|| panic!("{name}"));
|
||||
assert!(picked.iter().all(|c| c.path != fx.path("staged.txt")));
|
||||
assert_eq!(by_name("added.txt").status, "added");
|
||||
assert_eq!(by_name("bin.dat").diff, None);
|
||||
let f = by_name("f.txt").diff.clone().unwrap();
|
||||
assert!(f.starts_with("diff --git a/f.txt b/f.txt\n"), "{f}");
|
||||
assert!(f.contains("-a\n+A\n") && !f.contains("+J"), "{f}");
|
||||
|
||||
// Nothing changed: the index, the working tree, no temporary index left behind.
|
||||
assert_eq!(std::fs::read(fx.dir.join(".git/index")).unwrap(), index_before);
|
||||
assert_eq!(fx.read("f.txt"), edited);
|
||||
let leftovers: Vec<_> = std::fs::read_dir(fx.dir.join(".git"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("openidea-"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "{leftovers:?}");
|
||||
|
||||
// The size limit cuts at a line and says so.
|
||||
let cut = repo.pending_changes(&options, 40, &utf8).unwrap();
|
||||
let total: usize = cut.iter().filter_map(|c| c.diff.as_ref()).map(String::len).sum();
|
||||
assert!(total <= 40 && cut.iter().any(|c| c.truncated));
|
||||
assert!(cut.iter().filter_map(|c| c.diff.as_ref()).all(|d| d.is_empty() || d.ends_with('\n')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_staging_with_built_patch() {
|
||||
let fx = Fixture::new();
|
||||
@@ -1366,6 +1431,30 @@ fn status_reflects_external_changes_without_reopen() {
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_in_newly_written_packs_stay_visible() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a", "1\n");
|
||||
fx.commit_all("a");
|
||||
let repo = fx.repo();
|
||||
assert_eq!(repo.branches().unwrap().len(), 1);
|
||||
|
||||
// An external pull: objects move into a pack the IDE has never seen, then a
|
||||
// new branch points at a commit living only in that pack.
|
||||
fx.write("a", "2\n");
|
||||
fx.commit_all("b");
|
||||
fx.git(&["repack", "-adq"]);
|
||||
fx.git(&["checkout", "-q", "-b", "uat"]);
|
||||
let uat = fx.git(&["rev-parse", "uat"]);
|
||||
assert!(std::fs::read_dir(fx.path(".git/objects/pack")).unwrap().count() >= 2);
|
||||
|
||||
let names: Vec<String> = repo.branches().unwrap().into_iter().map(|b| b.name).collect();
|
||||
assert!(names.contains(&"uat".to_string()), "branches: {names:?}, want uat at {uat}");
|
||||
|
||||
// Status peels HEAD for the tree-index diff: the same failure drops it too.
|
||||
assert!(repo.status().is_ok(), "status failed: {:?}", repo.status().unwrap_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_worktree_and_submodule() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
@@ -117,6 +117,13 @@ impl Watcher {
|
||||
self.polling.as_deref()
|
||||
}
|
||||
|
||||
/// A watch failure past startup (the OS watch limit ran out mid-tree):
|
||||
/// directories past the limit never sync (issue #1915). `None` while every
|
||||
/// directory has a live watch.
|
||||
pub fn unwatched_reason(&self) -> Option<String> {
|
||||
self.inner.state.exhausted.lock().clone()
|
||||
}
|
||||
|
||||
/// Replaces the exclude globs (Settings › Project Structure / File Types
|
||||
/// changed): later events below newly excluded folders are dropped. In
|
||||
/// per-directory mode, folders no longer excluded are watched once they change.
|
||||
@@ -163,6 +170,7 @@ impl Watcher {
|
||||
queue: Mutex::default(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
exhausted: Mutex::new(None),
|
||||
});
|
||||
let weak: Weak<DirState> = Arc::downgrade(&state);
|
||||
let watcher = W::new(
|
||||
@@ -234,8 +242,9 @@ const MAX_WATCHES_HINT: &str = "the inotify watch limit is exhausted (fs.inotify
|
||||
/// instance per watcher (`EMFILE`, os error 24) or one watch per directory
|
||||
/// (`ENOSPC`, mapped by notify to [`ErrorKind::MaxFilesWatch`]). A bare OS
|
||||
/// error sends users off to search engines otherwise (issue #788).
|
||||
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
let hint = match error.kind {
|
||||
/// The fix hint when an OS watch limit is hit, or `None` for other errors.
|
||||
fn watch_limit_hint(error: ¬ify::Error) -> Option<&'static str> {
|
||||
match error.kind {
|
||||
ErrorKind::MaxFilesWatch => Some(MAX_WATCHES_HINT),
|
||||
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(24) => Some(
|
||||
"the inotify instance limit is exhausted (fs.inotify.max_user_instances); raise it, e.g. \
|
||||
@@ -243,8 +252,11 @@ fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
),
|
||||
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(28) => Some(MAX_WATCHES_HINT),
|
||||
_ => None,
|
||||
};
|
||||
match hint {
|
||||
}
|
||||
}
|
||||
|
||||
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
match watch_limit_hint(&error) {
|
||||
Some(hint) => io_error(format!("{what}: {error}; {hint}")),
|
||||
None => io_error(format!("{what}: {error}")),
|
||||
}
|
||||
@@ -259,6 +271,10 @@ struct DirState {
|
||||
queue: Mutex<Vec<(notify::Event, Instant)>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Set when a `watch()` call reports the OS watch limit (inotify
|
||||
/// `ENOSPC`/`EMFILE`): later directories are never seen, so the project is
|
||||
/// silently stale past that point — surfaced via [`Watcher::unwatched_reason`].
|
||||
exhausted: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl DirState {
|
||||
@@ -362,7 +378,15 @@ impl DirState {
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("cannot watch {}: {e}", dir.display());
|
||||
if let Some(hint) = watch_limit_hint(&e) {
|
||||
tracing::warn!("cannot watch {}: {e}", dir.display());
|
||||
// Recorded, not delivered: the watch may fail after start,
|
||||
// on the debounce thread — the UI picks it up via
|
||||
// `unwatched_reason()`.
|
||||
*self.exhausted.lock() = Some(format!("cannot watch {}: {e}; {hint}", dir.display()));
|
||||
} else {
|
||||
tracing::debug!("cannot watch {}: {e}", dir.display());
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1320,4 +1344,62 @@ mod tests {
|
||||
let other = notify::Error::io(std::io::Error::from_raw_os_error(13));
|
||||
assert!(!watcher_error("cannot start file watcher", other).message.contains("inotify"));
|
||||
}
|
||||
|
||||
/// Watch calls succeed until `budget` is spent, then `ENOSPC` — mirrors
|
||||
/// `fs.inotify.max_user_watches` running out part-way through a tree.
|
||||
struct ExhaustedWatcher {
|
||||
budget: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl notify::Watcher for ExhaustedWatcher {
|
||||
fn new<F: notify::EventHandler>(_: F, _: notify::Config) -> notify::Result<Self> {
|
||||
Ok(Self { budget: std::sync::atomic::AtomicUsize::new(3) })
|
||||
}
|
||||
|
||||
fn watch(&mut self, _dir: &Path, _: RecursiveMode) -> notify::Result<()> {
|
||||
if self.budget.fetch_sub(1, Ordering::SeqCst) == 0 {
|
||||
return Err(notify::Error::new(ErrorKind::MaxFilesWatch));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kind() -> WatcherKind {
|
||||
WatcherKind::NullWatcher
|
||||
}
|
||||
}
|
||||
|
||||
/// #1915: a watch refused mid-tree must not stay silent — the limit is
|
||||
/// reported through `unwatched_reason` so the UI can warn like the
|
||||
/// polling fallback does.
|
||||
#[test]
|
||||
fn unwatched_reason_reports_the_watch_limit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for d in ["a", "b", "c", "d"] {
|
||||
std::fs::create_dir_all(root.join(d)).unwrap();
|
||||
}
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone());
|
||||
let (tx, _rx) = std::sync::mpsc::channel::<FsBatch>();
|
||||
let emit: Emit = Arc::new(move |b| {
|
||||
let _ = tx.send(b.clone());
|
||||
});
|
||||
let inner = Watcher::start_queued::<ExhaustedWatcher>(
|
||||
&root,
|
||||
filter,
|
||||
emit,
|
||||
WatchMode::PerDirectory,
|
||||
notify::Config::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let watcher = Watcher { inner, excludes, rules, polling: None };
|
||||
assert!(watcher.polling().is_none(), "the OS watcher did start");
|
||||
let reason = watcher.unwatched_reason().expect("watch limit must be reported");
|
||||
assert!(reason.contains("max_user_watches"), "{reason}");
|
||||
assert!(reason.contains("cannot watch"), "{reason}");
|
||||
}
|
||||
}
|
||||
+189
-1
@@ -19,6 +19,7 @@ const TASK = {
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
||||
};
|
||||
|
||||
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -65,7 +66,15 @@ async function openReply(page: Page, text: string, imageData?: string) {
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, [{ type: "agent", text }]]]),
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
|
||||
{ type: "agent", text },
|
||||
],
|
||||
],
|
||||
]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
@@ -149,3 +158,182 @@ test("loads local and web reply images", async ({ page }) => {
|
||||
.toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("copies reply Markdown with the keyboard and keeps code copying separate", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
await copy.focus();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(REPLY);
|
||||
await reply.getByRole("button", { name: "Copy Code", exact: true }).click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
|
||||
});
|
||||
|
||||
test("reveals message actions on hover without shifting content and quotes into the draft", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const before = await reply.boundingBox();
|
||||
await reply.hover();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
expect(await reply.boundingBox()).toEqual(before);
|
||||
await expect(actions.getByRole("button")).toHaveCount(4);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
|
||||
await page.getByText("Show Markdown", { exact: true }).hover();
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await reply.hover();
|
||||
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
||||
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await expect(draft).toBeFocused();
|
||||
await expect(draft).toHaveValue(
|
||||
REPLY.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => (line ? `> ${line}` : ">"))
|
||||
.join("\n") + "\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps chat history readable and selections free of timestamps and status labels", async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 1800, height: 1000 });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await openReply(page, "Hi! What would you like to work on?");
|
||||
await page.evaluate(async (task) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
|
||||
store.useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
user("hi"),
|
||||
{ type: "agent", text: "Hi! What would you like to work on?" },
|
||||
user("im bob"),
|
||||
{ type: "agent", text: "Hi Bob! What are we working on today?" },
|
||||
user("yes"),
|
||||
{ type: "thought", text: "**Requesting task context**" },
|
||||
{ type: "agent", text: "What would you like me to proceed with?" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
}, TASK);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
const replies = stream.locator("[class*='_reply_']");
|
||||
await expect(replies).toHaveCount(3);
|
||||
const geometry = await stream.evaluate((root) => {
|
||||
const reply = root.querySelector<HTMLElement>("[class*='_reply_']")!;
|
||||
const bubble = root.querySelector<HTMLElement>("[class*='_user_']")!;
|
||||
const composer = root
|
||||
.closest("[class*='_conversation_']")!
|
||||
.querySelector<HTMLElement>("[class*='_composerArea_']")!;
|
||||
const rr = reply.getBoundingClientRect();
|
||||
const br = bubble.getBoundingClientRect();
|
||||
const cr = composer.getBoundingClientRect();
|
||||
return {
|
||||
reply: rr.width,
|
||||
root: root.clientWidth,
|
||||
bubbleLeft: br.left,
|
||||
replyLeft: rr.left,
|
||||
bubbleRight: br.right,
|
||||
replyRight: rr.right,
|
||||
columnCenter: (rr.left + rr.right) / 2,
|
||||
composerCenter: (cr.left + cr.right) / 2,
|
||||
};
|
||||
});
|
||||
expect(geometry.reply).toBe(900);
|
||||
expect(geometry.reply).toBeLessThan(geometry.root);
|
||||
expect(Math.abs(geometry.bubbleRight - geometry.replyRight)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.bubbleLeft - geometry.replyLeft)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
|
||||
await stream.getByText("hi", { exact: true }).hover();
|
||||
await expect(stream.locator("[class*='_messageMeta_']").first()).toHaveCSS("opacity", "1");
|
||||
const selected = await stream.evaluate((root) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection.toString();
|
||||
});
|
||||
expect(selected).toContain("hi");
|
||||
expect(selected).toContain("Hi Bob!");
|
||||
expect(selected).toContain("What would you like me to proceed with?");
|
||||
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await stream.locator("[class*='_thought_'] > summary").click();
|
||||
await expect(stream.getByText("Requesting task context", { exact: true })).toBeVisible();
|
||||
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
|
||||
await page.evaluate(async (id) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.dockTask(id);
|
||||
}, TASK.id);
|
||||
await expect(stream).toBeVisible();
|
||||
const narrow = await stream.evaluate((root) => ({
|
||||
width: root.clientWidth,
|
||||
content: root.scrollWidth,
|
||||
reply: root.querySelector<HTMLElement>("[class*='_reply_']")!.getBoundingClientRect().width,
|
||||
padding:
|
||||
Number.parseFloat(getComputedStyle(root).paddingLeft) + Number.parseFloat(getComputedStyle(root).paddingRight),
|
||||
}));
|
||||
expect(narrow.width).toBeGreaterThan(0);
|
||||
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
|
||||
expect(Math.abs(narrow.reply - (narrow.width - narrow.padding))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, "A short answer.");
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
await reply.hover();
|
||||
const box = await copy.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
|
||||
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await copy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
||||
});
|
||||
|
||||
test("selects through code blocks and tables while keeping copy controls outside the code", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const tree = "rust/\n├── Cargo.toml\n└── src/\n └── main.rs";
|
||||
const flow = "内置文本 SAMPLE\n ↓ parser::parse\nVec<Item>\n ↓ Inventory::add\n终端输出";
|
||||
const text = `项目结构:\n\n\`\`\`text\n${tree}\n\`\`\`\n\n| 模块 | 职责 |\n| --- | --- |\n| main.rs | 程序入口 |\n\n数据流是:\n\n\`\`\`text\n${flow}\n\`\`\``;
|
||||
const reply = await openReply(page, text);
|
||||
await expect(reply.getByRole("button", { name: "Copy Code", exact: true })).toHaveCount(2);
|
||||
const selected = await reply.evaluate((root) => {
|
||||
const codes = [...root.querySelectorAll("pre > code")];
|
||||
if (codes.some((code) => code.parentElement!.querySelector("button"))) throw new Error("Control in code selection");
|
||||
const start = root.querySelector("p")!.firstChild!;
|
||||
const end = codes.at(-1)!.lastChild!;
|
||||
window.getSelection()!.setBaseAndExtent(start, 0, end, end.textContent!.length);
|
||||
return window.getSelection()!.toString();
|
||||
});
|
||||
expect(selected).toContain(tree);
|
||||
expect(selected).toContain("程序入口");
|
||||
expect(selected).toContain(flow);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
const copies = reply.getByRole("button", { name: "Copy Code", exact: true });
|
||||
await copies.first().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(tree);
|
||||
await copies.last().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { AgentEntry, AgentTask } from "../src/bindings";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "long-chat",
|
||||
title: "Long conversation",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
const entries: AgentEntry[] = Array.from({ length: 80 }, (_, turn): AgentEntry[] => [
|
||||
{ type: "user", text: `Request ${turn}`, attachments: [], steered: false },
|
||||
...["read", "execute", "edit"].map((kind): AgentEntry => ({
|
||||
type: "tool",
|
||||
id: `${turn}-${kind}`,
|
||||
kind,
|
||||
title: `${kind} src/module-${turn}.ts`,
|
||||
status: "completed",
|
||||
locations: [],
|
||||
output: Array.from({ length: 80 }, (_, line) => `output ${turn}:${line}`).join("\n"),
|
||||
diffs: [],
|
||||
})),
|
||||
{ type: "thought", text: `Checking module ${turn}.\n\n${"Details of the investigation. ".repeat(30)}` },
|
||||
{
|
||||
type: "agent",
|
||||
text: `Reply ${turn}\n\n- Checked the module.\n- Updated the implementation.\n\n\`\`\`text\nmodule-${turn}\n src/\n tests/\n\`\`\``,
|
||||
},
|
||||
]).flat();
|
||||
|
||||
async function openHistory(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, entries }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, entries]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
},
|
||||
{ task, entries },
|
||||
);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(entries.length);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
return stream;
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const stream = await openHistory(page);
|
||||
const result = await stream.evaluate(async (el, selected) => {
|
||||
let text = "";
|
||||
if (selected) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
window.getSelection()!.removeAllRanges();
|
||||
window.getSelection()!.addRange(range);
|
||||
text = window.getSelection()!.toString();
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
const initialHeight = el.scrollHeight;
|
||||
const samples: Array<{ height: number; drift: number }> = [];
|
||||
// A thumb can jump into previously unvisited rows in either direction.
|
||||
for (const fraction of [0, 0.75, 0.25, 1, 0.5, 0.1, 0.9, 0]) {
|
||||
el.scrollTop = fraction * (initialHeight - el.clientHeight);
|
||||
const target = el.scrollTop;
|
||||
await new Promise(requestAnimationFrame);
|
||||
await new Promise(requestAnimationFrame);
|
||||
samples.push({ height: el.scrollHeight, drift: Math.abs(el.scrollTop - target) });
|
||||
}
|
||||
return { initialHeight, samples, text, after: window.getSelection()!.toString() };
|
||||
}, selected);
|
||||
expect(result.initialHeight).toBeGreaterThan(10_000);
|
||||
for (const sample of result.samples) {
|
||||
expect(Math.abs(sample.height - result.initialHeight)).toBeLessThanOrEqual(1);
|
||||
expect(sample.drift).toBeLessThanOrEqual(1);
|
||||
}
|
||||
if (selected) {
|
||||
expect(result.text).toContain("Request 0");
|
||||
expect(result.text).toContain("Reply 79");
|
||||
expect(result.after).toBe(result.text);
|
||||
await stream.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(result.text);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
async function placeCaret(page: Page, marker: string) {
|
||||
await page.locator(".cm-content").evaluate((element, marker) => {
|
||||
const view = (element as HTMLElement & { cmTile: { view: import("@codemirror/view").EditorView } }).cmTile.view;
|
||||
const anchor = view.state.doc.toString().indexOf(marker) + marker.length;
|
||||
view.dispatch({ selection: { anchor } });
|
||||
view.focus();
|
||||
}, marker);
|
||||
}
|
||||
|
||||
test("Angular templates highlight and close tags in inline and external documents", async ({ workbench: page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const backendPath = "/src/editor/core/backend.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { docBackend } = await import(backendPath);
|
||||
const { editorService } = await import(contributionsPath);
|
||||
const files = docBackend().files;
|
||||
files.set("/dev/acme-api/angular.json", "{}");
|
||||
files.set(
|
||||
"/dev/acme-api/app.ts",
|
||||
'@Component({\n template: `<div [title]="name">{{ name }}</div>\n @if (ready) { <p>Ready</p> }\n <r-card\n <section\n <article><\n `\n})\nexport class App {}\n',
|
||||
);
|
||||
files.set("/dev/acme-api/app.html", "<r-card\n<div\n<article><");
|
||||
files.set("/dev/acme-api/plain.ts", "const data = { template: `<r-card/>` };");
|
||||
await editorService().openFile("/dev/acme-api/app.ts");
|
||||
});
|
||||
const inline = page.locator('.cm-editor[data-language="Angular TypeScript"]');
|
||||
await expect(inline).toBeVisible();
|
||||
await expect(inline.locator(".cm-content span").filter({ hasText: /^div$/ }).first()).toBeVisible();
|
||||
await expect(inline.locator(".cm-content span").filter({ hasText: /^@if$/ })).toBeVisible();
|
||||
for (const [marker, text, result] of [
|
||||
["<r-card", "/", "<r-card/>"],
|
||||
["<section", ">", "<section></section>"],
|
||||
["<article><", "/", "<article></article>"],
|
||||
]) {
|
||||
await placeCaret(page, marker!);
|
||||
await page.keyboard.type(text!);
|
||||
await expect(inline.locator(".cm-content")).toContainText(result!);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/workbench/contributions.ts";
|
||||
await (await import(path)).editorService().openFile("/dev/acme-api/app.html");
|
||||
});
|
||||
const external = page.locator('.cm-editor[data-language="Angular Template"]');
|
||||
await expect(external).toBeVisible();
|
||||
for (const [marker, text, result] of [
|
||||
["<r-card", "/", "<r-card/>"],
|
||||
["<div", ">", "<div></div>"],
|
||||
["<article><", "/", "<article></article>"],
|
||||
]) {
|
||||
await placeCaret(page, marker!);
|
||||
await page.keyboard.type(text!);
|
||||
await expect(external.locator(".cm-content")).toContainText(result!);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/workbench/contributions.ts";
|
||||
await (await import(path)).editorService().openFile("/dev/acme-api/plain.ts");
|
||||
});
|
||||
await expect(page.locator(".cm-content span").filter({ hasText: /^r-card$/ })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("keeps a wide Git graph filled during scrollbar drags and its edges interactive afterwards", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const root = "/dev/acme-api";
|
||||
const count = 3000;
|
||||
const id = (i: number) => i.toString(16).padStart(40, "0");
|
||||
const commits = Array.from({ length: count }, (_, i) => ({
|
||||
id: id(i),
|
||||
shortId: id(i).slice(-8),
|
||||
parents: i + 18 < count ? [id(i + 18)] : [],
|
||||
refs: i === 0 ? ["HEAD", "main"] : [],
|
||||
author: "Author",
|
||||
email: "[email protected]",
|
||||
timeMs: count - i,
|
||||
summary: `Commit ${i}`,
|
||||
message: `Commit ${i}`,
|
||||
}));
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_log = ({ query }) => {
|
||||
const { skip, limit, range } = query as {
|
||||
skip: number;
|
||||
limit: number;
|
||||
range?: string;
|
||||
};
|
||||
return range ? [] : commits.slice(skip, limit ? skip + limit : undefined);
|
||||
};
|
||||
h.vcs_log_refs = () => ({});
|
||||
h.vcs_branches = () => [];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: id(0),
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_config_get = () => null;
|
||||
h.vcs_commit_files = () => [];
|
||||
h.vcs_log_details_batch = ({ revisions }) =>
|
||||
(revisions as string[]).map((revision) => ({
|
||||
message: `Commit ${parseInt(revision, 16)}`,
|
||||
containingBranches: [],
|
||||
signature: "N",
|
||||
signer: "",
|
||||
key: "",
|
||||
fingerprint: "",
|
||||
}));
|
||||
const storeModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(/* @vite-ignore */ storeModule);
|
||||
useVcs.setState({
|
||||
available: true,
|
||||
root,
|
||||
repo: root,
|
||||
roots: [root],
|
||||
branchesLoaded: true,
|
||||
});
|
||||
const contextModule = "/src/platform/context.ts";
|
||||
const { setContext } = await import(/* @vite-ignore */ contextModule);
|
||||
setContext("vcsAvailable", true);
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("vcs.showLog");
|
||||
});
|
||||
const list = page.getByRole("listbox", { name: "Commits" });
|
||||
await expect(list.getByRole("option").first()).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const module = "/src/features/vcs/logStore.ts";
|
||||
const { useGitLog, loadMore } = await import(/* @vite-ignore */ module);
|
||||
while (!useGitLog.getState().done) await loadMore();
|
||||
});
|
||||
|
||||
const box = (await list.boundingBox())!;
|
||||
const x = box.x + box.width - 5;
|
||||
await page.mouse.move(x, box.y + 8);
|
||||
await page.mouse.down();
|
||||
try {
|
||||
for (const fraction of [0.2, 0.7, 0.4, 0.85, 0.3]) {
|
||||
await page.mouse.move(x, box.y + box.height * fraction, { steps: 8 });
|
||||
expect(
|
||||
await list.evaluate((element) => {
|
||||
const top = element.scrollTop;
|
||||
const positions = [...element.querySelectorAll<HTMLElement>('[role="option"]')].map((row) => row.offsetTop);
|
||||
return {
|
||||
scrolling: top > 0,
|
||||
covered: Math.min(...positions) <= top && Math.max(...positions) + 26 >= top + element.clientHeight,
|
||||
};
|
||||
}),
|
||||
).toEqual({ scrolling: true, covered: true });
|
||||
}
|
||||
} finally {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
const index = await list.evaluate((element) => Math.ceil(element.scrollTop / 26));
|
||||
const row = list.locator(`[id="commit-log-${index}"]`);
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await list.press("ArrowDown");
|
||||
await expect(list.locator(`[id="commit-log-${index + 1}"]`)).toHaveAttribute("aria-selected", "true");
|
||||
// Rows drawn mid-drag get their edge hit targets back once scrolling stops.
|
||||
await expect(row.getByRole("button", { name: "Collapse this fragment" }).first()).toBeAttached();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
id = "arduino"
|
||||
name = "Arduino"
|
||||
# Sketches are C++ with an implicit `Arduino.h` and generated prototypes; clangd can't compile
|
||||
# them on its own, so they get C++ highlighting without a language server.
|
||||
extensions = ["ino"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "C++"
|
||||
tree-sitter = "cpp"
|
||||
@@ -3,6 +3,7 @@ name = "Go Module"
|
||||
filenames = ["go.mod"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 4, insert-spaces = false }
|
||||
ui-mode = "GoMod"
|
||||
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "Go Workspace"
|
||||
filenames = ["go.work"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 4, insert-spaces = false }
|
||||
ui-mode = "GoMod"
|
||||
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
|
||||
@@ -8,6 +8,10 @@ indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "TypeScript"
|
||||
tree-sitter = "typescript"
|
||||
|
||||
[[framework-ui-modes]]
|
||||
ui-mode = "Angular TypeScript"
|
||||
root-markers = ["angular.json", "node_modules/@angular/core"]
|
||||
|
||||
[[language-servers]]
|
||||
name = "vtsls"
|
||||
tsserver-project = true
|
||||
|
||||
@@ -26,6 +26,39 @@
|
||||
"typingLarge.mainThreadMsPerKey": 20,
|
||||
"typingLarge.tbtMs": 50,
|
||||
|
||||
"typingAngular.openMs": 200,
|
||||
"typingAngular.p95Ms": 30,
|
||||
"typingAngular.maxMs": 40,
|
||||
"typingAngular.mainThreadMsPerKey": 30,
|
||||
"typingAngular.tbtMs": 50,
|
||||
|
||||
"completion.openMs": 150,
|
||||
"completion.narrowP95Ms": 20,
|
||||
"completion.narrowMissedFramePct": 2,
|
||||
"completion.narrowMainThreadMsPerKey": 35,
|
||||
"completion.arrowP95Ms": 20,
|
||||
"completion.arrowMissedFramePct": 2,
|
||||
"completion.tbtMs": 50,
|
||||
|
||||
"consoleOutput.streamMs": 4600,
|
||||
"consoleOutput.droppedFramesPct": 2,
|
||||
"consoleOutput.keyP95Ms": 20,
|
||||
"consoleOutput.keyMissedFramePct": 10,
|
||||
"consoleOutput.tbtMs": 50,
|
||||
|
||||
"gitLog.showMs": 300,
|
||||
"gitLog.droppedFramesPct": 2,
|
||||
"gitLog.tbtMs": 50,
|
||||
"gitLog.arrowKeyP95Ms": 20,
|
||||
"gitLog.arrowKeyMissedFramePct": 5,
|
||||
"gitLogScrollbar.frameP95Ms": 17.5,
|
||||
"gitLogScrollbar.droppedFramesPct": 2,
|
||||
"gitLogScrollbar.tbtMs": 50,
|
||||
|
||||
"resizePanels.droppedFramesPct": 2,
|
||||
"resizePanels.bottomDroppedFramesPct": 2,
|
||||
"resizePanels.tbtMs": 50,
|
||||
|
||||
"keyRepeat.p95Ms": 20,
|
||||
"keyRepeat.maxMs": 33,
|
||||
"keyRepeat.missedFramePct": 2,
|
||||
|
||||
@@ -110,6 +110,88 @@ const typingAngular: Scenario = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Code completion with a language server's 2,000 items: the first letter opens
|
||||
* the popup (after CodeMirror's 100 ms typing delay), each further letter
|
||||
* narrows it, arrows move the selection.
|
||||
*/
|
||||
const completion: Scenario = {
|
||||
id: "completion",
|
||||
title: "Code completion over 2,000 items",
|
||||
async run(h) {
|
||||
await h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(2_000),
|
||||
);
|
||||
try {
|
||||
await h.openFile(`${ROOT}/src/completion/Orders.ts`, tsSource(2_000, "completion"));
|
||||
const st = await h.editorState();
|
||||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||||
await h.parserIdle();
|
||||
await h.page.keyboard.press("End");
|
||||
await h.page.keyboard.press("Enter");
|
||||
const refresh = await h.refreshMs();
|
||||
const openMs = await h.measure(() => h.page.keyboard.press("g"), ".cm-tooltip-autocomplete li", "Result<Order>");
|
||||
const t0 = await h.now();
|
||||
const before = await h.counters();
|
||||
const narrow = await h.typeMeasured("etOrd", 150);
|
||||
const work = Harness.delta(before, await h.counters());
|
||||
const arrows = await h.pressMeasured(Array(20).fill("ArrowDown"), 60);
|
||||
await h.page.keyboard.press("Escape");
|
||||
return {
|
||||
openMs,
|
||||
...keySummary(narrow, refresh, "narrow"),
|
||||
narrowMainThreadMsPerKey: work.taskMs / 5,
|
||||
...keySummary(arrows, refresh, "arrow"),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
};
|
||||
} finally {
|
||||
await h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(0),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A process printing 50,000 log lines (stack frames among them) at 200 lines a
|
||||
* frame (~4 s) into the Run console, while the user types in an editor: the
|
||||
* console must keep up and the editor must stay responsive.
|
||||
*/
|
||||
const consoleOutput: Scenario = {
|
||||
id: "consoleOutput",
|
||||
title: "Type while the Run console streams 50k lines",
|
||||
async run(h) {
|
||||
await h.openFile(SAMPLE);
|
||||
const st = await h.editorState();
|
||||
await h.setCursor(Math.floor((st?.length ?? 0) / 2));
|
||||
await h.page.keyboard.press("End");
|
||||
await h.page.keyboard.press("Enter");
|
||||
const refresh = await h.refreshMs();
|
||||
const t0 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
const streamMs = h.page.evaluate(async () => {
|
||||
const w = window as never as { __openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> } };
|
||||
const started = performance.now();
|
||||
await w.__openideaPerf.runAction("run.sampleOutput", { lines: 50_000, perFrame: 200 });
|
||||
return performance.now() - started;
|
||||
});
|
||||
// The console takes focus with it; type back in the editor while it streams.
|
||||
await h.page.waitForTimeout(100);
|
||||
await h.setCursor((await h.editorState())?.head ?? 0);
|
||||
const keys = await h.typeMeasured(TYPED.slice(0, 40), 70);
|
||||
const total = await streamMs;
|
||||
const frames = await h.inPage((p) => p.stopFrames());
|
||||
await h.inPage((p) => p.waitShown("body", "processed order #49998", 5_000));
|
||||
return {
|
||||
// 250 frames of output at the display rate is the floor.
|
||||
streamMs: total,
|
||||
...frameSummary(frames, refresh),
|
||||
...keySummary(keys, refresh, "key"),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Held key: auto-repeat at ~30 Hz must never fall behind. */
|
||||
const keyRepeat: Scenario = {
|
||||
id: "keyRepeat",
|
||||
@@ -185,6 +267,153 @@ const scroll: Scenario = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Git Log with 20,000 commits on branching lanes, loaded page by page as the
|
||||
* backend does (150 ms a page, like `git log`): showing it, walking the
|
||||
* selection with the arrow keys (the details pane follows) and wheel-scrolling
|
||||
* through several pages.
|
||||
*/
|
||||
const gitLog: Scenario = {
|
||||
id: "gitLog",
|
||||
title: "Show, navigate and scroll a 20k-commit Git Log",
|
||||
async run(h) {
|
||||
await h.page.evaluate(() =>
|
||||
(
|
||||
window as never as { __openideaPerf: { fakeGitLog(n: number, latencyMs?: number): void } }
|
||||
).__openideaPerf.fakeGitLog(20_000, 150),
|
||||
);
|
||||
const showMs = await h.measure(
|
||||
() =>
|
||||
h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { runAction(id: string): Promise<boolean> } }).__openideaPerf.runAction(
|
||||
"vcs.showLog",
|
||||
),
|
||||
),
|
||||
'[role="listbox"][aria-label="Commits"]',
|
||||
"order flow step 0",
|
||||
);
|
||||
const refresh = await h.refreshMs();
|
||||
const list = h.page.getByRole("listbox", { name: "Commits" });
|
||||
await list.getByRole("option").first().click();
|
||||
const keyLat = await h.pressMeasured(Array(40).fill("ArrowDown"), 50);
|
||||
const box = (await list.boundingBox())!;
|
||||
await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
const t0 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
// Far enough to load several more pages on the way.
|
||||
for (let i = 0; i < 160; i++) {
|
||||
await h.page.mouse.wheel(0, i < 120 ? 600 : -600);
|
||||
await h.page.waitForTimeout(16);
|
||||
}
|
||||
await h.page.waitForTimeout(200);
|
||||
const frames = await h.inPage((p) => p.stopFrames());
|
||||
return {
|
||||
showMs,
|
||||
...frameSummary(frames, refresh),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
...keySummary(keyLat, refresh, "arrowKey"),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Scrollbar-sized jumps across a loaded graph, rather than incremental wheel scrolling. */
|
||||
const gitLogScrollbar: Scenario = {
|
||||
id: "gitLogScrollbar",
|
||||
title: "Drag through a loaded 6k-commit Git Log",
|
||||
async run(h) {
|
||||
await h.page.evaluate(async () => {
|
||||
const probe = (
|
||||
window as never as {
|
||||
__openideaPerf: {
|
||||
fakeGitLog(n: number): void;
|
||||
runAction(id: string): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
).__openideaPerf;
|
||||
probe.fakeGitLog(6000);
|
||||
await probe.runAction("vcs.showLog");
|
||||
});
|
||||
const list = h.page.getByRole("listbox", { name: "Commits" });
|
||||
await list.waitFor();
|
||||
await list.evaluate(async (element) => {
|
||||
for (let i = 0; i < 300 && element.scrollHeight < 6000 * 26; i++) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
if (element.scrollHeight < 6000 * 26) throw new Error("Git Log did not finish loading");
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
await h.page.waitForTimeout(200);
|
||||
const refresh = await h.refreshMs();
|
||||
const t0 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
await list.evaluate(async (element) => {
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let i = 0; i < 180; i++) {
|
||||
element.scrollTop = ((i < 90 ? i : 180 - i) / 90) * max;
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
});
|
||||
const frames = await h.inPage((p) => p.stopFrames());
|
||||
return {
|
||||
...frameSummary(frames, refresh),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dragging the splitters with an editor, the Project tree and the Git Log
|
||||
* open: every pointer move resizes the islands, so each frame relays them out.
|
||||
*/
|
||||
const resizePanels: Scenario = {
|
||||
id: "resizePanels",
|
||||
title: "Drag the tool window splitters",
|
||||
async run(h) {
|
||||
await h.openFile(SAMPLE);
|
||||
await h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { fakeGitLog(n: number): void } }).__openideaPerf.fakeGitLog(2_000),
|
||||
);
|
||||
await h.measure(
|
||||
() =>
|
||||
h.page.evaluate(() =>
|
||||
(window as never as { __openideaPerf: { runAction(id: string): Promise<boolean> } }).__openideaPerf.runAction(
|
||||
"vcs.showLog",
|
||||
),
|
||||
),
|
||||
'[role="listbox"][aria-label="Commits"]',
|
||||
"order flow step 0",
|
||||
);
|
||||
const refresh = await h.refreshMs();
|
||||
const drag = async (name: string, dx: number, dy: number) => {
|
||||
const box = (await h.page.getByRole("separator", { name }).boundingBox())!;
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await h.page.mouse.move(x, y);
|
||||
await h.page.mouse.down();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
// Out and back twice, one pointer move a frame.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const t = Math.sin((i / 60) * Math.PI);
|
||||
await h.page.mouse.move(x + dx * t, y + dy * t);
|
||||
await h.page.waitForTimeout(16);
|
||||
}
|
||||
await h.page.mouse.up();
|
||||
await h.page.waitForTimeout(200);
|
||||
return frameSummary(await h.inPage((p) => p.stopFrames()), refresh);
|
||||
};
|
||||
const t0 = await h.now();
|
||||
const side = await drag("Resize left tool window", 300, 0);
|
||||
const bottom = await drag("Resize bottom tool window", 0, -250);
|
||||
return {
|
||||
...side,
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
bottomFrameP95Ms: bottom.frameP95Ms,
|
||||
bottomDroppedFramesPct: bottom.droppedFramesPct,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Cold load: navigation → Project tree painted. */
|
||||
const startup: Scenario = {
|
||||
id: "startup",
|
||||
@@ -369,6 +598,11 @@ export const SCENARIOS: Scenario[] = [
|
||||
typing,
|
||||
typingLarge,
|
||||
typingAngular,
|
||||
completion,
|
||||
consoleOutput,
|
||||
gitLog,
|
||||
gitLogScrollbar,
|
||||
resizePanels,
|
||||
keyRepeat,
|
||||
scroll,
|
||||
tabs,
|
||||
|
||||
+98
-33
@@ -34,7 +34,7 @@ plugins/
|
||||
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
|
||||
└─ license-header/ # file-opened events, quick fix, project scan in a task
|
||||
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit
|
||||
```
|
||||
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:[email protected]`, world
|
||||
@@ -137,23 +137,24 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
|
||||
|
||||
### Contribution points
|
||||
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
|
||||
|
||||
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
|
||||
views. It adds the commands to the right-click menu of the items in that
|
||||
@@ -174,6 +175,10 @@ command = "kube.logs"
|
||||
when = "viewItemKind == pod"
|
||||
```
|
||||
|
||||
A `commit.message` menu puts the command, with the plugin's icon, in the
|
||||
toolbar above the commit message of the Commit tool window and the Commit
|
||||
dialog (see _Commits_). It is new in API 1.3 (`engines.openidea = "^1.3"`).
|
||||
|
||||
Declared contributions are live as soon as the plugin is enabled — commands
|
||||
show in Find Action, menus and the status bar, shortcuts work, themes and icon
|
||||
themes can be applied, settings pages are there — **before** any plugin code
|
||||
@@ -227,20 +232,20 @@ installing (or when a new version asks for more). Low-risk capabilities are
|
||||
listed; high-risk ones are called out as equivalent to running code on the
|
||||
machine. Users can revoke optional capabilities in Settings › Plugins.
|
||||
|
||||
| Capability | Risk | Unlocks today |
|
||||
| ------------------- | ------ | ----------------------------------------------------------------------------------- |
|
||||
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
|
||||
| `editor.read` | low | `TextEditor::document`, `selections` |
|
||||
| `editor.write` | medium | `TextEditor::apply_edits` |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
|
||||
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
|
||||
| `storage` | low | `host::storage` (1 MiB per plugin) |
|
||||
| `events` | low | file opened/saved, editor focused, repository changed events |
|
||||
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
|
||||
| Capability | Risk | Unlocks today |
|
||||
| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
|
||||
| `editor.read` | low | `TextEditor::document`, `selections` |
|
||||
| `editor.write` | medium | `TextEditor::apply_edits` |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks |
|
||||
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` |
|
||||
| `storage` | low | `host::storage` (1 MiB per plugin) |
|
||||
| `events` | low | file opened/saved, editor focused, repository changed events |
|
||||
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
|
||||
|
||||
`process:` capabilities are also derived from what the contributions start: a
|
||||
language's `language-servers` and `debugger` commands and a run detector's
|
||||
@@ -349,6 +354,7 @@ then run `openidea-plugin check`.
|
||||
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
|
||||
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
|
||||
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
|
||||
| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. |
|
||||
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
|
||||
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
|
||||
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
|
||||
@@ -395,6 +401,45 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
|
||||
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
|
||||
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
|
||||
|
||||
### Commits
|
||||
|
||||
A plugin can work on the commit the user is preparing, in the Commit tool
|
||||
window or the Commit dialog (API 1.3, `engines.openidea = "^1.3"`):
|
||||
|
||||
- **A button above the message.** `[[contributes.menus]]` with
|
||||
`menu = "commit.message"` shows one of the plugin's commands there, with the
|
||||
plugin's icon.
|
||||
- **The pending commit.** `host::pending_commit()` (`vcs.read`) returns what
|
||||
Commit would commit right now: per file its repository, path, status and a
|
||||
`git diff` of only the included changes (the checked files and changed
|
||||
blocks, against HEAD; 1 MiB in all, then `truncated`), plus the message,
|
||||
Amend and a `revision`. Unsaved editors of those files are saved first, as
|
||||
Commit does; nothing is staged or committed. `None` without a repository.
|
||||
- **The message.** `host::set_commit_message(revision, text)` (`vcs.write`)
|
||||
replaces it only while the draft is still at `revision`. If the user typed
|
||||
or changed the included changes meanwhile, it fails with `Error::Stale` and
|
||||
nothing is overwritten. The user reviews the text and commits.
|
||||
|
||||
Both work from a command or event of a window, in a trusted project; they wait
|
||||
for the window, which isn't charged to the time limit. A message generator
|
||||
calls a model with `host::fetch` and a `{{secret:…}}` key (see _Network_)
|
||||
inside a task:
|
||||
|
||||
```rust
|
||||
let draft = host::pending_commit()?.ok_or("no repository")?;
|
||||
let message = ask_the_model(&draft.changes)?;
|
||||
host::set_commit_message(draft.revision, &message)?;
|
||||
```
|
||||
|
||||
**Before-commit checks.** Each `[[contributes.commitChecks]]` (`id`, `title`)
|
||||
is listed in Commit Options, on by default. When the user commits, after the
|
||||
IDE's own checks, the IDE calls the optional `commit-checker` export
|
||||
(`register_plugin!(MyPlugin, commit_checks)`, trait `commit::CommitChecks`)
|
||||
with the check's id and the draft. Problems (`message`, `location?`) stop the
|
||||
commit and show beside the message with the check's title, until the user
|
||||
changes it or chooses Commit Anyway. A check that fails, times out or lacks
|
||||
`vcs.read` is reported as a problem too.
|
||||
|
||||
### Providers
|
||||
|
||||
Declare what the plugin provides in the editor; only plugins whose
|
||||
@@ -402,7 +447,7 @@ declaration matches the file's language are called (and loaded):
|
||||
|
||||
```toml
|
||||
[[contributes.providers]]
|
||||
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens"
|
||||
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover"
|
||||
languages = ["java", "xml"] # editor language ids; "*" for any
|
||||
```
|
||||
|
||||
@@ -457,6 +502,26 @@ the language server's semantic tokens and win where they overlap one, so the
|
||||
editor's colour scheme styles them like the server's. They are asked again
|
||||
after each edit, under the edit-path budget; at most 65,536 per document.
|
||||
|
||||
#### Hover
|
||||
|
||||
The `hover` kind (API 1.3) adds Markdown to the hover of documents of the
|
||||
declared languages, here too including languages the plugin doesn't
|
||||
contribute. Register with `register_plugin!(MyPlugin, hover)` and implement
|
||||
`openidea_plugin::hover::HoverProvider`:
|
||||
|
||||
```rust
|
||||
impl HoverProvider for MyPlugin {
|
||||
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
|
||||
Some(Hover::new("**orders.index** · `GET /orders`"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The host asks the language server and the plugins together, under the
|
||||
edit-path budget, and shows the server's hover first, then each plugin's
|
||||
below a rule. `range`, when given, is the text the hover describes (used when
|
||||
the server names none). At most 64 KiB of Markdown per plugin.
|
||||
|
||||
### Network and secrets
|
||||
|
||||
`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
eb76da4bc9db84dcbad34143bac18c9ecf974276f793009791a15aab2eee291d
|
||||
89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0a19948927a23de81b2a453b9992e021744093cbe41ea92daa9feb2b1bc9a2a9
|
||||
a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
564bfd00694f27f35f09eb0b81d341207a755528b5fe7c46c96717b3868d0f65
|
||||
7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
f3f447366fa38f9bf4911f35b6e899405b0e072ceee3ad5ea2fe90639d70fa53
|
||||
d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
85cad466adb94f2b58e62263617e885d1c47cf70db5e8b38942bd415f0d779af
|
||||
511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
659446eda0b50aa0fb08f6af68f7d355fcaf3342847e2e579999dcf52a7a7399
|
||||
8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
51ef9d43315ca9d7239999c222fbf7a2c01bc85c2b40f53f34a1a8e5d31baa6d
|
||||
6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
bdf9455c6e7c8c340c54d783f351220b38cffed36907e1b5a1f2e11801e6f5bb
|
||||
43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7e4d00e1ea63b2eca48b9297667e04c05a9f9832f1dee2262272801d516b9a16
|
||||
3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
00cbd2dd7f0937554633ba5e533f19be85f70579c7c6fedb6fe0228f8f63bf4c
|
||||
588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
eecf2cf561dd5e3e3edbf9f67ccb479573dd538a75d4426a1d086edf0a310043
|
||||
723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Before-commit checks (world `plugin-commit-checks`): declare each under
|
||||
//! `[[contributes.commitChecks]]` and register the export with
|
||||
//! `register_plugin!(MyPlugin, commit_checks)`. They run when the user commits
|
||||
//! (after the IDE's own checks, for the ones turned on in Commit Options);
|
||||
//! problems stop the commit until the user changes it or chooses Commit Anyway.
|
||||
//! Needs `vcs.read`.
|
||||
//!
|
||||
//! To read or set the message of the commit being prepared from a command, see
|
||||
//! [`crate::host::pending_commit`] and [`crate::host::set_commit_message`].
|
||||
|
||||
use crate::{CommitDraft, Error, Invocation, Location};
|
||||
|
||||
/// Something the user should look at before committing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CommitProblem {
|
||||
/// One sentence, shown after the check's title.
|
||||
pub message: String,
|
||||
/// Where it is, opened from the problem; `None` for the commit as a whole.
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
impl CommitProblem {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), location: None }
|
||||
}
|
||||
|
||||
pub fn at(mut self, location: Location) -> Self {
|
||||
self.location = Some(location);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin's before-commit checks.
|
||||
pub trait CommitChecks {
|
||||
/// Runs the declared check `check` on `draft`: no problems lets the commit
|
||||
/// go on. An error is reported as a problem of the check.
|
||||
fn check(&mut self, check: &str, draft: &CommitDraft, ctx: &Invocation) -> Result<Vec<CommitProblem>, Error>;
|
||||
}
|
||||
@@ -212,6 +212,35 @@ mod imp {
|
||||
wit::vcs::remotes(p).map_err(error)
|
||||
}
|
||||
|
||||
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
|
||||
wit::commit::pending_commit().map(|d| d.map(__draft_in)).map_err(error)
|
||||
}
|
||||
|
||||
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
|
||||
wit::commit::set_commit_message(revision, message).map_err(error)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn __draft_in(d: wit::commit::CommitDraft) -> CommitDraft {
|
||||
CommitDraft {
|
||||
revision: d.revision,
|
||||
message: d.message,
|
||||
amend: d.amend,
|
||||
changes: d
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| CommitChange {
|
||||
repository: c.repository,
|
||||
path: c.path,
|
||||
old_path: c.old_path,
|
||||
status: c.status,
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(k: EventKind) -> Result<(), Error> {
|
||||
wit::events::subscribe(k.name()).map_err(error)
|
||||
}
|
||||
@@ -427,6 +456,27 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[doc(hidden)]
|
||||
pub use imp::__draft_in;
|
||||
|
||||
/// The commit being prepared in the window the command or event came from:
|
||||
/// the included files and changed blocks only, as Commit would commit them
|
||||
/// (unsaved editors of those files are saved first). `None` when the window's
|
||||
/// project has no repository. Only from a command or event of a window, in a
|
||||
/// trusted project. Needs `vcs.read`.
|
||||
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
|
||||
imp::pending_commit()
|
||||
}
|
||||
|
||||
/// Replaces the commit message of that window, only while its draft is still
|
||||
/// at `revision` ([`CommitDraft::revision`]); [`Error::Stale`] when the user
|
||||
/// changed the message or the included changes meanwhile, and nothing changes.
|
||||
/// Needs `vcs.write`.
|
||||
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
|
||||
imp::set_commit_message(revision, message)
|
||||
}
|
||||
|
||||
/// Replaces the plugin's diagnostics for `path` (absolute, in an open
|
||||
/// project); an empty slice clears them. Needs `project.read`.
|
||||
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::types::{Position, Range};
|
||||
|
||||
pub use crate::providers::Document;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Hover {
|
||||
pub markdown: String,
|
||||
pub range: Option<Range>,
|
||||
}
|
||||
|
||||
impl Hover {
|
||||
pub fn new(markdown: impl Into<String>) -> Self {
|
||||
Self { markdown: markdown.into(), range: None }
|
||||
}
|
||||
|
||||
pub fn range(mut self, range: Range) -> Self {
|
||||
self.range = Some(range);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HoverProvider {
|
||||
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_hover_can_name_the_range_it_describes() {
|
||||
let range = Range { start: Position { line: 2, character: 4 }, end: Position { line: 2, character: 9 } };
|
||||
let hover = Hover::new("**route** `orders.index`").range(range);
|
||||
assert_eq!(hover.markdown, "**route** `orders.index`");
|
||||
assert_eq!(hover.range, Some(range));
|
||||
assert_eq!(Hover::new("x").range, None);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@
|
||||
//! Build with `openidea-plugin build` (or `cargo build --release --target
|
||||
//! wasm32-wasip2`); see `plugins/README.md`.
|
||||
|
||||
pub mod commit;
|
||||
pub mod host;
|
||||
pub mod hover;
|
||||
pub mod indexing;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod mock;
|
||||
@@ -155,6 +157,34 @@ pub mod semantic_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[doc(hidden)]
|
||||
pub mod hover_bindings {
|
||||
wit_bindgen::generate!({
|
||||
world: "hover", path: "wit", pub_export_macro: true,
|
||||
export_macro_name: "__export_hover_world",
|
||||
default_bindings_module: "openidea_plugin::hover_bindings",
|
||||
with: { "openidea:plugin/[email protected]": crate::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[doc(hidden)]
|
||||
pub mod commit_checks_bindings {
|
||||
wit_bindgen::generate!({
|
||||
world: "commit-checks", path: "wit", pub_export_macro: true,
|
||||
export_macro_name: "__export_commit_checks_world",
|
||||
default_bindings_module: "openidea_plugin::commit_checks_bindings",
|
||||
with: {
|
||||
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::commit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The plugin API version this SDK targets.
|
||||
pub const API_VERSION: &str = "1.3";
|
||||
|
||||
@@ -215,6 +245,12 @@ macro_rules! __optional_exports {
|
||||
(services, $plugin:ty, $component:ident) => {
|
||||
$crate::__services_exports!($plugin, $component);
|
||||
};
|
||||
(hover, $plugin:ty, $component:ident) => {
|
||||
$crate::__hover_exports!($plugin, $component);
|
||||
};
|
||||
(commit_checks, $plugin:ty, $component:ident) => {
|
||||
$crate::__commit_checks_exports!($plugin, $component);
|
||||
};
|
||||
(syntax, $plugin:ty, $component:ident) => {
|
||||
impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component {
|
||||
fn highlights(
|
||||
@@ -367,6 +403,27 @@ macro_rules! __services_exports {
|
||||
};
|
||||
}
|
||||
|
||||
/// `register_plugin!(T, commit_checks)`: `T` is also a [`commit::CommitChecks`].
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __commit_checks_exports {
|
||||
($plugin:ty, $component:ident) => {
|
||||
impl $crate::commit_checks_bindings::exports::openidea::plugin::commit_checker::Guest for $component {
|
||||
fn check(
|
||||
check: ::std::string::String,
|
||||
draft: $crate::bindings::openidea::plugin::commit::CommitDraft,
|
||||
ctx: &$crate::bindings::openidea::plugin::invocation::Invocation,
|
||||
) -> ::core::result::Result<
|
||||
::std::vec::Vec<$crate::commit_checks_bindings::exports::openidea::plugin::commit_checker::CommitProblem>,
|
||||
$crate::bindings::openidea::plugin::types::Error,
|
||||
> {
|
||||
$crate::__rt::commit_check::<$plugin>(check, draft, ctx)
|
||||
}
|
||||
}
|
||||
$crate::commit_checks_bindings::__export_commit_checks_world!($component with_types_in $crate::commit_checks_bindings);
|
||||
};
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __semantic_tokens_exports {
|
||||
@@ -384,6 +441,22 @@ macro_rules! __semantic_tokens_exports {
|
||||
};
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __hover_exports {
|
||||
($plugin:ty, $component:ident) => {
|
||||
impl $crate::hover_bindings::exports::openidea::plugin::hover_provider::Guest for $component {
|
||||
fn hover(
|
||||
doc: $crate::hover_bindings::exports::openidea::plugin::hover_provider::Document,
|
||||
at: $crate::bindings::openidea::plugin::types::Position,
|
||||
) -> ::core::option::Option<$crate::hover_bindings::exports::openidea::plugin::hover_provider::HoverContent> {
|
||||
$crate::__rt::hover::<$plugin>(doc, at)
|
||||
}
|
||||
}
|
||||
$crate::hover_bindings::__export_hover_world!($component with_types_in $crate::hover_bindings);
|
||||
};
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __plugin_exports {
|
||||
@@ -639,6 +712,16 @@ pub mod __rt {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn hover<T: Plugin + crate::hover::HoverProvider + Default>(
|
||||
d: crate::hover_bindings::exports::openidea::plugin::hover_provider::Document,
|
||||
pos: b::types::Position,
|
||||
) -> Option<crate::hover_bindings::exports::openidea::plugin::hover_provider::HoverContent> {
|
||||
use crate::hover_bindings::exports::openidea::plugin::hover_provider as w;
|
||||
let doc = crate::providers::Document { path: d.path, language: d.language, version: d.version, text: d.text };
|
||||
with::<T, _>(|p| p.hover(&doc, at(pos)))
|
||||
.map(|h| w::HoverContent { markdown: h.markdown, range: h.range.map(crate::host::__range_to) })
|
||||
}
|
||||
|
||||
// ---- views ----
|
||||
|
||||
use crate::views as v;
|
||||
@@ -756,6 +839,27 @@ pub mod __rt {
|
||||
with::<T, _>(|p| p.detail(&view, &key, &ctx)).map(|tabs| tabs.into_iter().map(tab).collect()).map_err(error_out)
|
||||
}
|
||||
|
||||
pub fn commit_check<T: Plugin + crate::commit::CommitChecks + Default>(
|
||||
check: String,
|
||||
draft: b::commit::CommitDraft,
|
||||
ctx: &b::invocation::Invocation,
|
||||
) -> Result<
|
||||
Vec<crate::commit_checks_bindings::exports::openidea::plugin::commit_checker::CommitProblem>,
|
||||
b::types::Error,
|
||||
> {
|
||||
use crate::commit_checks_bindings::exports::openidea::plugin::commit_checker as w;
|
||||
let ctx = Invocation::__new(ctx);
|
||||
let draft = crate::host::__draft_in(draft);
|
||||
with::<T, _>(|p| p.check(&check, &draft, &ctx))
|
||||
.map(|problems| {
|
||||
problems
|
||||
.into_iter()
|
||||
.map(|p| w::CommitProblem { message: p.message, location: p.location.map(loc) })
|
||||
.collect()
|
||||
})
|
||||
.map_err(error_out)
|
||||
}
|
||||
|
||||
pub fn view_event<T: Plugin + v::Views + Default>(
|
||||
view: String,
|
||||
event: vt::ViewEvent,
|
||||
|
||||
@@ -66,6 +66,7 @@ struct State {
|
||||
tasks: Vec<MockTask>,
|
||||
vcs: Option<RepositoryStatus>,
|
||||
remotes: Vec<(String, String)>,
|
||||
commit: Option<CommitDraft>,
|
||||
fetches: u32,
|
||||
fetch_error: Option<Error>,
|
||||
clipboard: Option<String>,
|
||||
@@ -250,6 +251,27 @@ impl MockHost {
|
||||
self.state.borrow().revealed_views.clone()
|
||||
}
|
||||
|
||||
/// The commit being prepared that [`crate::host::pending_commit`] returns
|
||||
/// (`None`: no repository).
|
||||
pub fn set_commit_draft(&self, draft: Option<CommitDraft>) {
|
||||
self.state.borrow_mut().commit = draft;
|
||||
}
|
||||
|
||||
/// The draft as the plugin left it: [`crate::host::set_commit_message`]
|
||||
/// replaces the message and moves the revision on.
|
||||
pub fn commit_draft(&self) -> Option<CommitDraft> {
|
||||
self.state.borrow().commit.clone()
|
||||
}
|
||||
|
||||
/// The user edits the draft: its revision moves on, so a later
|
||||
/// [`crate::host::set_commit_message`] for the old one is stale.
|
||||
pub fn edit_commit_message(&self, message: &str) {
|
||||
if let Some(d) = self.state.borrow_mut().commit.as_mut() {
|
||||
d.message = message.to_owned();
|
||||
d.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes `vcs-fetch` fail.
|
||||
pub fn fail_fetch(&self, error: Option<Error>) {
|
||||
self.state.borrow_mut().fetch_error = error;
|
||||
@@ -618,6 +640,26 @@ pub mod current {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
|
||||
with(|s| {
|
||||
require(s, "vcs.read")?;
|
||||
Ok(s.commit.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
|
||||
with(|s| {
|
||||
require(s, "vcs.write")?;
|
||||
let draft = s.commit.as_mut().ok_or_else(|| Error::Invalid("no repository".into()))?;
|
||||
if draft.revision != revision {
|
||||
return Err(Error::Stale);
|
||||
}
|
||||
draft.message = message.to_owned();
|
||||
draft.revision += 1;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
|
||||
with(|s| {
|
||||
let cap = match kind {
|
||||
|
||||
@@ -294,6 +294,35 @@ pub struct RepositoryStatus {
|
||||
pub changed_files: u32,
|
||||
}
|
||||
|
||||
/// A file the commit being prepared changes ([`crate::host::pending_commit`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CommitChange {
|
||||
/// Absolute path of the working tree root of its repository.
|
||||
pub repository: String,
|
||||
/// Absolute path (after a rename, the new one).
|
||||
pub path: String,
|
||||
/// The path before a rename or copy.
|
||||
pub old_path: Option<String>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
|
||||
pub status: String,
|
||||
/// The included changes in `git diff` format against HEAD; `None` for binary content.
|
||||
pub diff: Option<String>,
|
||||
/// `diff` was cut at the size limit (1 MiB for all the changes).
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// What Commit would commit right now.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CommitDraft {
|
||||
/// Changes with the message, the included changes or Amend; pass it to
|
||||
/// [`crate::host::set_commit_message`].
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
/// The commit replaces the last one; `changes` are still against HEAD.
|
||||
pub amend: bool,
|
||||
pub changes: Vec<CommitChange>,
|
||||
}
|
||||
|
||||
/// A range in a file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Location {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// The commit being prepared in the window the current command or event came
|
||||
/// from (the Commit tool window, or the Commit dialog while it is open): the
|
||||
/// changes it holds and its message.
|
||||
interface commit {
|
||||
use types.{error};
|
||||
|
||||
/// A file the commit changes.
|
||||
record commit-change {
|
||||
/// Absolute path of the working tree root of its repository.
|
||||
repository: string,
|
||||
/// Absolute path (after a rename, the new one).
|
||||
path: string,
|
||||
/// The path before a rename or copy.
|
||||
old-path: option<string>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or
|
||||
/// `type-changed` (more may be added).
|
||||
status: string,
|
||||
/// What the commit changes in the file, in `git diff` format against
|
||||
/// HEAD: only the changed blocks included in the commit. `none` for
|
||||
/// binary content.
|
||||
diff: option<string>,
|
||||
/// `diff` was cut at the size limit (1 MiB for all the changes).
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
/// What Commit would commit right now.
|
||||
record commit-draft {
|
||||
/// Changes whenever the message, the included changes or Amend do.
|
||||
revision: u64,
|
||||
message: string,
|
||||
/// The commit replaces the last one (Amend); `changes` are still
|
||||
/// against HEAD, so they don't include the last commit's own.
|
||||
amend: bool,
|
||||
changes: list<commit-change>,
|
||||
}
|
||||
|
||||
/// The commit being prepared, as Commit would make it: the included
|
||||
/// files and changed blocks only. Unsaved editors of the included files
|
||||
/// are saved first, as Commit does; nothing is staged or committed.
|
||||
/// `none` when the window's project has no repository. Fails with
|
||||
/// `invalid` outside a command or event of a window, and in a project that
|
||||
/// is not trusted. Requires `vcs.read`.
|
||||
pending-commit: func() -> result<option<commit-draft>, error>;
|
||||
/// Replaces the commit message, only if the draft is still at `revision`
|
||||
/// (else `stale`, and nothing changes): text the user typed meanwhile is
|
||||
/// never overwritten. The user reviews it and commits. Same windows as
|
||||
/// `pending-commit`. Requires `vcs.write`.
|
||||
set-commit-message: func(revision: u64, message: string) -> result<_, error>;
|
||||
}
|
||||
|
||||
/// Before-commit checks (`[[contributes.commitChecks]]`), run when the user
|
||||
/// commits, after the IDE's own checks, for the checks enabled in Commit
|
||||
/// Options. Problems stop the commit and show beside the message until the
|
||||
/// user changes it or chooses Commit Anyway. Requires `vcs.read`.
|
||||
interface commit-checker {
|
||||
use types.{error, location};
|
||||
use invocation.{invocation};
|
||||
use commit.{commit-draft};
|
||||
|
||||
/// Something the user should look at before committing.
|
||||
record commit-problem {
|
||||
/// One sentence, shown after the check's title.
|
||||
message: string,
|
||||
/// Where it is, opened from the problem; `none` for the commit as a
|
||||
/// whole (its message, for instance).
|
||||
location: option<location>,
|
||||
}
|
||||
|
||||
/// Runs the declared check `check` on `draft`: no problems lets the commit
|
||||
/// go on. A call that fails or runs out of time is reported as a problem
|
||||
/// of the check.
|
||||
check: func(check: string, draft: commit-draft, ctx: borrow<invocation>) -> result<list<commit-problem>, error>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
interface hover-provider {
|
||||
use types.{position, range};
|
||||
|
||||
record document {
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
record hover-content {
|
||||
markdown: string,
|
||||
range: option<range>,
|
||||
}
|
||||
|
||||
hover: func(doc: document, at: position) -> option<hover-content>;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ world plugin {
|
||||
import editor;
|
||||
import invocation;
|
||||
import vcs;
|
||||
import commit;
|
||||
import events;
|
||||
import tasks;
|
||||
import diagnostics;
|
||||
@@ -61,6 +62,11 @@ world plugin-semantic-tokens {
|
||||
export semantic-tokens-provider;
|
||||
}
|
||||
|
||||
world plugin-hover {
|
||||
include plugin;
|
||||
export hover-provider;
|
||||
}
|
||||
|
||||
/// A plugin whose views show in the Services tool window
|
||||
/// (`location = "services"`): their nodes' detail pane.
|
||||
world plugin-services {
|
||||
@@ -68,6 +74,12 @@ world plugin-services {
|
||||
export service-provider;
|
||||
}
|
||||
|
||||
/// A plugin with before-commit checks (`[[contributes.commitChecks]]`).
|
||||
world plugin-commit-checks {
|
||||
include plugin;
|
||||
export commit-checker;
|
||||
}
|
||||
|
||||
/// Only the `syntax-provider` export: guests generate it next to `plugin`
|
||||
/// (the component tools merge both into one component).
|
||||
world syntax {
|
||||
@@ -93,7 +105,16 @@ world semantic-tokens {
|
||||
export semantic-tokens-provider;
|
||||
}
|
||||
|
||||
world hover {
|
||||
export hover-provider;
|
||||
}
|
||||
|
||||
/// Only the `service-provider` export, generated next to `plugin` like `syntax`.
|
||||
world services {
|
||||
export service-provider;
|
||||
}
|
||||
|
||||
/// Only the `commit-checker` export, generated next to `plugin` like `syntax`.
|
||||
world commit-checks {
|
||||
export commit-checker;
|
||||
}
|
||||
@@ -487,6 +487,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
plugin::plugin_context_keys,
|
||||
plugin::plugin_cancel_task,
|
||||
plugin::plugin_answer_prompt,
|
||||
plugin::plugin_answer_commit,
|
||||
plugin::plugin_commit_checks,
|
||||
plugin::plugin_view_render,
|
||||
plugin::plugin_view_event,
|
||||
plugin::plugin_service_detail,
|
||||
|
||||
@@ -393,6 +393,33 @@ pub fn plugin_answer_prompt(state: State<'_, AppState>, prompt_id: String, answe
|
||||
state.plugins.answer_prompt(&prompt_id, answer)
|
||||
}
|
||||
|
||||
/// A window's answer to a plugin's `commitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn plugin_answer_commit(
|
||||
state: State<'_, AppState>,
|
||||
request_id: String,
|
||||
answer: ide_host::plugins::CommitAnswer,
|
||||
) -> bool {
|
||||
state.plugins.answer_commit(&request_id, answer)
|
||||
}
|
||||
|
||||
/// Runs plugins' before-commit checks on what `selection` commits, until
|
||||
/// `request` is cancelled.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn plugin_commit_checks(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
selection: ide_host::plugins::CommitSelection,
|
||||
checks: Vec<ide_host::plugins::PluginCommitCheckRef>,
|
||||
request: ide_core::RequestId,
|
||||
) -> IdeResult<Vec<ide_host::plugins::PluginCommitCheckResult>> {
|
||||
let plugins = state.plugins.clone();
|
||||
ide_host::plugins::commit::commit_checks(&state.host, plugins, window.label(), selection, checks, request).await
|
||||
}
|
||||
|
||||
/// Gutter markers of plugin `line-markers` providers for an open document.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
+79
-1
@@ -1072,6 +1072,16 @@ export const commands = {
|
||||
* (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed.
|
||||
*/
|
||||
pluginAnswerPrompt: (promptId: string, answer: string[] | null) => __TAURI_INVOKE<boolean>("plugin_answer_prompt", { promptId, answer }),
|
||||
/**
|
||||
* A window's answer to a plugin's `commitRequested`; false when the call no
|
||||
* longer waits.
|
||||
*/
|
||||
pluginAnswerCommit: (requestId: string, answer: CommitAnswer) => __TAURI_INVOKE<boolean>("plugin_answer_commit", { requestId, answer }),
|
||||
/**
|
||||
* Runs plugins' before-commit checks on what `selection` commits, until
|
||||
* `request` is cancelled.
|
||||
*/
|
||||
pluginCommitChecks: (selection: CommitSelection, checks: PluginCommitCheckRef[], request: RequestId) => typedError<PluginCommitCheckResult[], IdeError>(__TAURI_INVOKE("plugin_commit_checks", { selection, checks, request })),
|
||||
/**
|
||||
* Content of a plugin view (`doc`: the document of a custom editor; `editor`: for
|
||||
* a tool window, the window's active editor, as commands get it).
|
||||
@@ -2320,6 +2330,15 @@ export type CommandForm = {
|
||||
|
||||
export type Commit = Commit_Serialize | Commit_Deserialize;
|
||||
|
||||
/** A window's answer to a [`CommitRequest`]. */
|
||||
export type CommitAnswer =
|
||||
/** The window's project has no repository. */
|
||||
{ kind: "noRepository" } | { kind: "selection"; selection: CommitSelection } |
|
||||
/** The message was replaced. */
|
||||
{ kind: "done" } |
|
||||
/** The draft is no longer at the revision. */
|
||||
{ kind: "stale" };
|
||||
|
||||
/** Commit Options (IDEA's commit options popup). */
|
||||
export type CommitOptions = {
|
||||
amend: boolean,
|
||||
@@ -2340,6 +2359,41 @@ export type CommitOptions = {
|
||||
exclude?: IpcPath[],
|
||||
};
|
||||
|
||||
/** A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`). */
|
||||
export type CommitProblem = {
|
||||
message: string,
|
||||
/** Absolute path of the file it is in, if any (IPC form). */
|
||||
path: string | null,
|
||||
range: Range | null,
|
||||
};
|
||||
|
||||
/** What a plugin asks of a window's Commit view. */
|
||||
export type CommitRequest =
|
||||
/**
|
||||
* What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
|
||||
* editors of the included files are saved first.
|
||||
*/
|
||||
{ kind: "selection" } |
|
||||
/** Replace the message if the draft is still at `revision`. */
|
||||
{ kind: "setMessage"; revision: number; message: string };
|
||||
|
||||
/**
|
||||
* What a window's Commit view would commit: per repository, the options
|
||||
* `vcs_commit` would get.
|
||||
*/
|
||||
export type CommitSelection = {
|
||||
/** Changes whenever the message, the included changes or Amend do. */
|
||||
revision: number,
|
||||
message: string,
|
||||
amend: boolean,
|
||||
repositories: CommitSelectionRepo[],
|
||||
};
|
||||
|
||||
export type CommitSelectionRepo = {
|
||||
root: IpcPath,
|
||||
options: CommitOptions,
|
||||
};
|
||||
|
||||
/** Version Control › Commit: commit checks and commit-message inspections. */
|
||||
export type CommitSettings = {
|
||||
/**
|
||||
@@ -2361,6 +2415,8 @@ export type CommitSettings = {
|
||||
bodyLimit?: number,
|
||||
showRightMargin?: boolean,
|
||||
wrapAtMargin?: boolean,
|
||||
/** Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run. */
|
||||
disabledPluginChecks?: string[],
|
||||
};
|
||||
|
||||
export type Commit_Deserialize = {
|
||||
@@ -5771,6 +5827,23 @@ export type PickItem = {
|
||||
description: string | null,
|
||||
};
|
||||
|
||||
/** A plugin's before-commit check to run (`[[contributes.commitChecks]]`). */
|
||||
export type PluginCommitCheckRef = {
|
||||
pluginId: string,
|
||||
check: string,
|
||||
};
|
||||
|
||||
/** What a plugin's before-commit check found. */
|
||||
export type PluginCommitCheckResult = {
|
||||
pluginId: string,
|
||||
check: string,
|
||||
/** The check's declared title. */
|
||||
title: string,
|
||||
problems: CommitProblem[],
|
||||
/** The check could not run (the plugin failed, timed out or lacks `vcs.read`). */
|
||||
error: string | null,
|
||||
};
|
||||
|
||||
/** A plugin's context key for `when` clauses (`value` is JSON text). */
|
||||
export type PluginContextKey = {
|
||||
pluginId: string,
|
||||
@@ -5838,7 +5911,12 @@ export type PluginEvent =
|
||||
/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */
|
||||
{ type: "promptClosed"; promptId: string } |
|
||||
/** Bring the tool window of the plugin's view `view` to the front. */
|
||||
{ type: "revealView"; pluginId: string; view: string };
|
||||
{ type: "revealView"; pluginId: string; view: string } |
|
||||
/**
|
||||
* A command needs the window's Commit view: answer with
|
||||
* `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
|
||||
*/
|
||||
{ type: "commitRequested"; pluginId: string; requestId: string; request: CommitRequest };
|
||||
|
||||
/** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */
|
||||
export type PluginIcon = {
|
||||
|
||||
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user