mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 03:08:18 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc078b93b | ||
|
|
a36c7ed3d5 | ||
|
|
50567473f7 | ||
|
|
de4c325a28 | ||
|
|
a4a3c2105f |
No files matched your search
@@ -9,14 +9,6 @@ The reported case is one sample of a defect class. A fix that only makes the
|
||||
screenshot go away is not done: the next question is always "does this break for other
|
||||
languages / platforms / entry points too?". Answer that before anyone asks.
|
||||
|
||||
Scale the workflow to the risk. For an obvious, low-risk CSS or copy fix, inspect
|
||||
the cause and nearby shared styles, make the focused edit, check formatting and
|
||||
verify the affected UI. Run existing targeted tests only when they cover a plausible
|
||||
regression; do not add implementation-mirroring tests or default to full-suite tests,
|
||||
E2E, builds, typechecking or dependency analysis. Reuse completed checks while the
|
||||
diff is unchanged. A brief finding and verification note is enough; skip the formal
|
||||
sibling table. Follow `AGENTS.md` for broader changes and create a PR when requested.
|
||||
|
||||
## 1. Read and reproduce
|
||||
|
||||
- `gh issue view <n> --comments` (repo `l0ng-ai/openidea`). Read attached logs/screenshots
|
||||
|
||||
@@ -84,13 +84,7 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
Scale local verification to the change. For low-risk CSS-only or copy-only changes,
|
||||
check the diff and formatting of touched files; use existing targeted tests when they
|
||||
cover a plausible regression, and verify visible changes in the running app. Reuse
|
||||
completed checks when the diff has not changed. Do not default to full tests, E2E,
|
||||
builds, typechecking or dependency analysis for these changes; CI runs the full suite.
|
||||
|
||||
For behaviour, shared infrastructure or broader changes, run the applicable CI checks:
|
||||
Run what CI runs for the parts you touched:
|
||||
|
||||
- Rust: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo nextest run --workspace`. After changing IPC types, run `pnpm gen:bindings` and
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -507,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 });
|
||||
}
|
||||
@@ -705,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) {
|
||||
@@ -2216,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;
|
||||
@@ -3072,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);
|
||||
|
||||
@@ -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!({}))]);
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
+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();
|
||||
|
||||
+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 = {
|
||||
|
||||
@@ -14,6 +14,3 @@
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.marker:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
@@ -220,9 +220,6 @@
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.runMarker:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Query result and variables: JSON in the editor's syntax colours. */
|
||||
.output :global(.tok-propertyName),
|
||||
|
||||
@@ -8,12 +8,16 @@ import type {
|
||||
ActiveTheme,
|
||||
CapabilityInfo,
|
||||
CatalogPlugin,
|
||||
CommitAnswer,
|
||||
CommitSelection,
|
||||
Consent,
|
||||
EventKind,
|
||||
InstalledIntellijTheme,
|
||||
InstallPreview,
|
||||
IntellijThemeSource,
|
||||
MarketPlugin,
|
||||
PluginCommitCheckRef,
|
||||
PluginCommitCheckResult,
|
||||
PluginContextKey,
|
||||
PluginEditor,
|
||||
PluginEvent,
|
||||
@@ -68,6 +72,10 @@ export interface PluginsBackend {
|
||||
cancelTask(taskId: string): Promise<void>;
|
||||
/** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */
|
||||
answerPrompt(promptId: string, answer: string[] | null): Promise<void>;
|
||||
/** This window's answer to a plugin's `commitRequested`. */
|
||||
answerCommit(requestId: string, answer: CommitAnswer): Promise<void>;
|
||||
/** Runs plugins' before-commit checks on what `selection` commits. */
|
||||
commitChecks(selection: CommitSelection, checks: PluginCommitCheckRef[]): Promise<PluginCommitCheckResult[]>;
|
||||
/** Marketplace plugins: the index read again (`refresh`) or the last one read. */
|
||||
market(refresh: boolean): Promise<MarketPlugin[]>;
|
||||
marketUrl(): Promise<string | null>;
|
||||
@@ -187,6 +195,10 @@ export const tauriPluginsBackend: PluginsBackend = {
|
||||
answerPrompt: async (promptId, answer) => {
|
||||
await commands.pluginAnswerPrompt(promptId, answer);
|
||||
},
|
||||
answerCommit: async (requestId, answer) => {
|
||||
await commands.pluginAnswerCommit(requestId, answer);
|
||||
},
|
||||
commitChecks: (selection, checks) => call(commands.pluginCommitChecks(selection, checks, nextRequestId())),
|
||||
market: (refresh) => call(commands.pluginMarket(refresh)),
|
||||
marketUrl: () => commands.pluginMarketUrl(),
|
||||
setMarketUrl: async (url) => {
|
||||
@@ -569,6 +581,8 @@ export function createMemoryPluginsBackend(
|
||||
}),
|
||||
viewEvent: async () => {},
|
||||
serviceDetail: async () => [],
|
||||
answerCommit: async () => {},
|
||||
commitChecks: async () => [],
|
||||
serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => {
|
||||
onError(`${pluginId} · ${view} · ${key}: logs need the desktop app`);
|
||||
return () => {};
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
* batches.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { EventKind, PluginContextKey, PluginEvent, Prompt, StatusItem, WorkbenchEvent } from "@/bindings";
|
||||
import type {
|
||||
CommitAnswer,
|
||||
CommitRequest,
|
||||
EventKind,
|
||||
PluginContextKey,
|
||||
PluginEvent,
|
||||
Prompt,
|
||||
StatusItem,
|
||||
WorkbenchEvent,
|
||||
} from "@/bindings";
|
||||
import { onEditorEvent } from "@/editor/api";
|
||||
import { executeAction, getAction, onActionExecuted } from "@/platform/actions";
|
||||
import { clearContext, setContext, type ContextValue } from "@/platform/context";
|
||||
@@ -14,7 +23,12 @@ import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { BarButton } from "@/ui/BarButton";
|
||||
import { maybeEditorService, registerStatusItem, toolWindowService } from "@/workbench/contributions";
|
||||
import {
|
||||
maybeCommitService,
|
||||
maybeEditorService,
|
||||
registerStatusItem,
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { pickItems, promptInput } from "@/workbench/inputDialogs";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
@@ -327,6 +341,28 @@ async function answerPrompt(promptId: string, prompt: Prompt) {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Answers a plugin's request to this window's Commit view (`commit` API). */
|
||||
async function answerCommit(requestId: string, request: CommitRequest) {
|
||||
const commit = maybeCommitService();
|
||||
let answer: CommitAnswer;
|
||||
try {
|
||||
if (request.kind === "selection") {
|
||||
const selection = commit ? await commit.selection() : null;
|
||||
answer = selection ? { kind: "selection", selection } : { kind: "noRepository" };
|
||||
} else if (!commit) {
|
||||
answer = { kind: "noRepository" };
|
||||
} else {
|
||||
answer = commit.setMessage(request.revision, request.message) ? { kind: "done" } : { kind: "stale" };
|
||||
}
|
||||
} catch {
|
||||
// The selection could not be read (e.g. a file vanished): the plugin sees a stale draft.
|
||||
answer = { kind: "stale" };
|
||||
}
|
||||
await pluginsBackend()
|
||||
.answerCommit(requestId, answer)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Handles the runtime UI events; returns whether `e` was one. */
|
||||
export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
switch (e.type) {
|
||||
@@ -370,6 +406,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
openPrompts.get(e.promptId)?.abort();
|
||||
openPrompts.delete(e.promptId);
|
||||
return true;
|
||||
case "commitRequested":
|
||||
void answerCommit(e.requestId, e.request);
|
||||
return true;
|
||||
case "revealView":
|
||||
toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true);
|
||||
return true;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Client side of the plugin UI points: declarations the
|
||||
* backend validated, projected onto the workbench's own registries.
|
||||
*
|
||||
* - `menus`: editor / project view / editor tab context menus, and Tools in the main menu;
|
||||
* - `menus`: editor / project view / editor tab context menus, Tools in the main menu, and the
|
||||
* toolbar above the commit message;
|
||||
* - `keymaps`: default shortcuts below the IDE's (a taken keystroke is dropped);
|
||||
* - `statusBar`: items shown before the code runs (its `set-status-item` with the same id updates them);
|
||||
* - `settings`: a page under Settings › Plugins generated from the schema;
|
||||
@@ -10,7 +11,8 @@
|
||||
* - `customEditors`: files opening in a plugin editor sharing the text document, in its own tab or
|
||||
* beside the text editor;
|
||||
* - `fileIcons`: icons for the files a plugin knows, whatever icon theme is in use;
|
||||
* - `fileTemplates`: entries of the New menu.
|
||||
* - `fileTemplates`: entries of the New menu;
|
||||
* - `commitChecks`: before-commit checks listed in Commit Options, run by the plugin's code.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import type { PluginInfo } from "@/bindings";
|
||||
@@ -32,6 +34,7 @@ import { addContributedFileTemplate } from "@/features/templates/files/store";
|
||||
import {
|
||||
registerEditorKind,
|
||||
registerFileEditorProvider,
|
||||
registerCommitCheckProvider,
|
||||
registerMenuGroup,
|
||||
registerMenuSlotItem,
|
||||
registerServiceProvider,
|
||||
@@ -40,9 +43,11 @@ import {
|
||||
registerToolWindow,
|
||||
useSettingsForm,
|
||||
type ContextMenuId,
|
||||
type ContributedCommitProblem,
|
||||
type ToolWindowAnchor,
|
||||
} from "@/workbench/contributions";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { pluginIconName } from "./pluginIcon";
|
||||
import { usePlugins } from "./store";
|
||||
import { pluginActionId } from "./points";
|
||||
import { defineUiPoint } from "./projection";
|
||||
@@ -66,7 +71,13 @@ import styles from "./PluginsPage.module.css";
|
||||
// --- menus ------------------------------------------------------------------------
|
||||
|
||||
interface MenuDecl {
|
||||
menu: "editor.context" | "projectView.context" | "editorTab.context" | "viewItem.context" | "tools";
|
||||
menu:
|
||||
| "editor.context"
|
||||
| "projectView.context"
|
||||
| "editorTab.context"
|
||||
| "viewItem.context"
|
||||
| "tools"
|
||||
| "commit.message";
|
||||
view?: string | null;
|
||||
command: string;
|
||||
when?: string | null;
|
||||
@@ -118,11 +129,81 @@ function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () =
|
||||
const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
|
||||
return registerMenuSlotItem("pluginTools", { action, label: title });
|
||||
}
|
||||
if (decl.menu === "commit.message") {
|
||||
const action = pluginActionId(pluginId, decl.command);
|
||||
const label = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
|
||||
return registerMenuSlotItem("commitMessage", {
|
||||
action,
|
||||
label,
|
||||
icon: pluginIconName(pluginId, plugin.icon),
|
||||
when: decl.when ?? null,
|
||||
});
|
||||
}
|
||||
const key = `${++menuSeq}`;
|
||||
contextItems.set(key, { pluginId, decl });
|
||||
return () => void contextItems.delete(key);
|
||||
}
|
||||
|
||||
// --- commit checks ----------------------------------------------------------------
|
||||
|
||||
interface CommitCheckDecl {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Declared before-commit checks by `<plugin id>/<check id>`. */
|
||||
const commitChecks = new Map<string, { pluginId: string; check: string; title: string }>();
|
||||
let unregisterCommitChecks: (() => void) | null = null;
|
||||
|
||||
/** (Re)publishes the checks, so Commit Options sees the change. */
|
||||
function publishCommitChecks() {
|
||||
unregisterCommitChecks?.();
|
||||
unregisterCommitChecks = null;
|
||||
if (!commitChecks.size) return;
|
||||
const snapshot = [...commitChecks.entries()];
|
||||
unregisterCommitChecks = registerCommitCheckProvider({
|
||||
checks: () => snapshot.map(([key, c]) => ({ key, title: c.title })),
|
||||
run: async (selection, keys) => {
|
||||
const refs = keys.flatMap((k) => {
|
||||
const c = commitChecks.get(k);
|
||||
return c ? [{ pluginId: c.pluginId, check: c.check }] : [];
|
||||
});
|
||||
const results = await pluginsBackend()
|
||||
.commitChecks(selection, refs)
|
||||
.catch((e: unknown) =>
|
||||
refs.map((r) => ({
|
||||
...r,
|
||||
title: commitChecks.get(`${r.pluginId}/${r.check}`)?.title ?? r.check,
|
||||
problems: [],
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})),
|
||||
);
|
||||
return results.flatMap((r): ContributedCommitProblem[] =>
|
||||
r.error !== null
|
||||
? [{ title: r.title, message: couldNotRun(r.error) }]
|
||||
: r.problems.map((p) => ({
|
||||
title: r.title,
|
||||
message: p.message,
|
||||
path: p.path ?? undefined,
|
||||
line: p.range?.start.line,
|
||||
})),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const couldNotRun = (reason: string) => tx`The check could not run: ${reason}`;
|
||||
|
||||
function projectCommitCheck(pluginId: string, decl: CommitCheckDecl): () => void {
|
||||
const key = `${pluginId}/${decl.id}`;
|
||||
commitChecks.set(key, { pluginId, check: decl.id, title: decl.title });
|
||||
publishCommitChecks();
|
||||
return () => {
|
||||
commitChecks.delete(key);
|
||||
publishCommitChecks();
|
||||
};
|
||||
}
|
||||
|
||||
// --- keymaps ----------------------------------------------------------------------
|
||||
|
||||
interface KeymapDecl {
|
||||
@@ -432,6 +513,10 @@ export function defineViewUiPoints(): () => void {
|
||||
title: "File Icons",
|
||||
project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl),
|
||||
}),
|
||||
defineUiPoint<CommitCheckDecl>("commitChecks", {
|
||||
title: "Commit Checks",
|
||||
project: ({ pluginId, decl }) => projectCommitCheck(pluginId, decl),
|
||||
}),
|
||||
defineUiPoint<FileTemplateDecl>("fileTemplates", {
|
||||
title: "File Templates",
|
||||
project: ({ pluginId, decl }) =>
|
||||
|
||||
@@ -317,3 +317,87 @@ describe("php (PHPUnit)", () => {
|
||||
expect(run?.command).toBe("vendor/bin/phpunit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("php (Pest)", () => {
|
||||
const src = [
|
||||
"<?php", // 0
|
||||
"",
|
||||
"test('adds numbers', function () {", // 2
|
||||
" expect(1 + 1)->toBe(2);",
|
||||
"});",
|
||||
"",
|
||||
'it("handles \\"quoted\\" names", fn () => true);', // 6
|
||||
"",
|
||||
"describe('division', function () {", // 8
|
||||
" it('divides', function () {", // 9
|
||||
" expect(4 / 2)->toBe(2);",
|
||||
" });",
|
||||
"",
|
||||
" describe('by zero', function () {", // 13
|
||||
" test('throws', fn () => true);", // 14
|
||||
" });",
|
||||
"});",
|
||||
"",
|
||||
"it('doubles', function (int $n) {", // 18
|
||||
" expect($n * 2)->toBe($n + $n);",
|
||||
"})->with([1, 2, 3]);",
|
||||
"// it('is commented out')",
|
||||
].join("\n");
|
||||
const pest = cfg({ id: "phpunit", kind: "phpunit", command: "vendor/bin/pest" });
|
||||
const run = (line: number) => {
|
||||
const target = detectRunTargets(src, "tests/Unit/MathTest.php").find((t) => t.line === line)!;
|
||||
return targetConfig(target, "tests/Unit/MathTest.php", [pest], src);
|
||||
};
|
||||
|
||||
it("finds tests and describe blocks with the names Pest gives them", () => {
|
||||
expect(pick(detectRunTargets(src, "tests/Unit/MathTest.php"))).toEqual([
|
||||
[2, "test", "adds numbers"],
|
||||
[6, "test", 'it handles "quoted" names'],
|
||||
[8, "suite", "division"],
|
||||
[9, "test", "division/it divides"],
|
||||
[13, "suite", "division/by zero"],
|
||||
[14, "test", "division/by zero/throws"],
|
||||
[18, "test", "it doubles"],
|
||||
]);
|
||||
expect(detectRunTargets(src, "tests/Unit/MathTest.php")[3]).toMatchObject({
|
||||
name: "division → it divides",
|
||||
pest: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores files that are not tests", () => {
|
||||
expect(detectRunTargets(src, "src/Math.php")).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs one test by the name Pest matches, data sets included", () => {
|
||||
expect(run(2)?.args).toEqual(["--filter", "/::adds numbers( with data set .*)?$/", "tests/Unit/MathTest.php"]);
|
||||
expect(run(14)?.args).toEqual([
|
||||
"--filter",
|
||||
"/::`division` → `by zero` → throws( with data set .*)?$/",
|
||||
"tests/Unit/MathTest.php",
|
||||
]);
|
||||
expect(run(18)?.args[1]).toBe("/::it doubles( with data set .*)?$/");
|
||||
expect(run(18)?.command).toBe("vendor/bin/pest");
|
||||
});
|
||||
|
||||
it("runs every test of a describe block", () => {
|
||||
expect(run(8)?.args[1]).toBe("/::`division` → /");
|
||||
expect(run(13)?.args[1]).toBe("/::`division` → `by zero` → /");
|
||||
});
|
||||
|
||||
it("keeps PHPUnit classes in the same file", () => {
|
||||
const mixed = [
|
||||
"<?php",
|
||||
"final class CalcTest extends TestCase",
|
||||
"{",
|
||||
" public function testAdd() {}",
|
||||
"}",
|
||||
"test('x', fn () => 1);",
|
||||
];
|
||||
expect(pick(detectRunTargets(mixed.join("\n"), "tests/CalcTest.php"))).toEqual([
|
||||
[1, "suite", "CalcTest"],
|
||||
[3, "test", "CalcTest/testAdd"],
|
||||
[5, "test", "x"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import { contextConfig, relToCwd, testConfigFor } from "./context";
|
||||
import { djangoLabel, djangoTestsOf } from "./djangoTypes";
|
||||
import { scanJvmTargets, simpleClassName } from "./jvmTargets";
|
||||
import { escapeRegExp } from "./testing/rerun";
|
||||
import { phpunitArgs, phpunitFilter } from "./testing/phpunit";
|
||||
import { pestFilter, phpunitArgs, phpunitFilter } from "./testing/phpunit";
|
||||
|
||||
export type TargetKind = "main" | "test" | "suite" | "generate";
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface RunTarget {
|
||||
unittest?: boolean;
|
||||
/** Ruby: a `module` suite, which runs the tests of every class declared inside it. */
|
||||
rubyModule?: boolean;
|
||||
pest?: boolean;
|
||||
}
|
||||
|
||||
/** Above this size we skip detection (generated / minified files). */
|
||||
@@ -177,6 +178,30 @@ function phpTargets(lines: string[]): RunTarget[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const PEST_RE = /^(\s*)(describe|test|it)\s*\(\s*(['"])((?:\\.|(?!\3).)*)\3/;
|
||||
|
||||
function pestTargets(lines: string[]): RunTarget[] {
|
||||
const out: RunTarget[] = [];
|
||||
const stack: Array<{ indent: number; name: string }> = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = PEST_RE.exec(lines[i]!);
|
||||
if (!m) continue;
|
||||
const indent = indentOf(m[1]!);
|
||||
while (stack.length && stack[stack.length - 1]!.indent >= indent) stack.pop();
|
||||
const text = m[4]!.replace(m[3] === "'" ? /\\(['\\])/g : /\\(["\\$])/g, "$1");
|
||||
const describes = stack.map((s) => s.name);
|
||||
if (m[2] === "describe") {
|
||||
const path = [...describes, text];
|
||||
out.push({ line: i, kind: "suite", name: path.join(" → "), path, pest: true });
|
||||
stack.push({ indent, name: text });
|
||||
} else {
|
||||
const path = [...describes, m[2] === "it" ? `it ${text}` : text];
|
||||
out.push({ line: i, kind: "test", name: path.join(" → "), path, pest: true });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pythonTargets(lines: string[]): RunTarget[] {
|
||||
const out: RunTarget[] = [];
|
||||
const classes: Array<{ indent: number; name: string; test: boolean; unittest: boolean }> = [];
|
||||
@@ -412,7 +437,9 @@ export function detectRunTargets(text: string, relPath: string, django = false):
|
||||
(django && /(^|\/)tests\.py$/.test(relPath)),
|
||||
);
|
||||
case "php":
|
||||
return /Test\.php$|(^|\/)tests?\//.test(relPath) ? phpTargets(lines) : [];
|
||||
return /Test\.php$|(^|\/)tests?\//.test(relPath)
|
||||
? [...phpTargets(lines), ...pestTargets(lines)].sort((a, b) => a.line - b.line)
|
||||
: [];
|
||||
case "go":
|
||||
return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath));
|
||||
case "jvm":
|
||||
@@ -479,7 +506,11 @@ export function targetConfig(
|
||||
return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`));
|
||||
case "phpunit": {
|
||||
const [cls, method] = [target.path[0]!, target.path[1]];
|
||||
const filter = phpunitFilter([method ? [cls, method] : [cls]]);
|
||||
const filter = !target.pest
|
||||
? phpunitFilter([method ? [cls, method] : [cls]])
|
||||
: target.kind === "suite"
|
||||
? pestFilter(target.path)
|
||||
: pestFilter(target.path.slice(0, -1), target.path[target.path.length - 1]);
|
||||
return named(phpunitArgs(file.args, { target: relToCwd(relPath, file), filter }));
|
||||
}
|
||||
case "django-test": {
|
||||
|
||||
@@ -57,7 +57,7 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
|
||||
if (/\b(jest|vitest|npm|pnpm|yarn|bun|npx|deno)\b/.test(cmd)) return "js";
|
||||
if (/\bpytest\b/.test(cmd)) return "pytest";
|
||||
if (/\bmanage\.py test\b/.test(cmd)) return "unittest";
|
||||
if (/(^|[/\\\s])phpunit(\.phar|\.bat)?(\s|$)/.test(cmd)) return "phpunit";
|
||||
if (/(^|[/\\\s])(phpunit|pest)(\.phar|\.bat)?(\s|$)/.test(cmd)) return "phpunit";
|
||||
if (/\bgo\s+test\b/.test(cmd)) return "go";
|
||||
if (/\b(gradlew?|mvnw?)\b/.test(cmd)) return "jvm";
|
||||
return "auto";
|
||||
|
||||
@@ -56,6 +56,27 @@ describe("phpunitParser", () => {
|
||||
expect(events[0]).toEqual({ type: "start", path: [SUITE, "testAdd", "testAdd with data set #0"] });
|
||||
});
|
||||
|
||||
it("reads Pest's names and location hints", () => {
|
||||
const events = run([
|
||||
"##teamcity[testSuiteStarted name='Unit' locationHint='pest_qn://tests/Unit/MathTest.php' flowId='1']",
|
||||
"##teamcity[testSuiteStarted name='Tests\\Unit\\MathTest' locationHint='pest_qn://tests/Unit/MathTest.php' flowId='1']",
|
||||
"##teamcity[testStarted name='`division` → it divides' locationHint='pest_qn://tests/Unit/MathTest.php::`division` → it divides' flowId='1']",
|
||||
"##teamcity[testFailed name='`division` → it divides' message='Failed asserting that 1 is identical to 2.' details='' flowId='1']",
|
||||
"##teamcity[testSuiteStarted name='it doubles' locationHint='pest_qn://tests/Unit/MathTest.php::it doubles' flowId='1']",
|
||||
"##teamcity[testStarted name='it doubles with data set \"(1)\"' locationHint='pest_qn://tests/Unit/MathTest.php::it doubles with data set \"(1)\"' flowId='1']",
|
||||
]);
|
||||
const divides = ["Unit", "Tests\\Unit\\MathTest", "`division` → it divides"];
|
||||
expect(events).toContainEqual({
|
||||
type: "location",
|
||||
path: divides,
|
||||
location: { path: "tests/Unit/MathTest.php", resolve: "path" },
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "start",
|
||||
path: ["Unit", "Tests\\Unit\\MathTest", "it doubles", 'it doubles with data set "(1)"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("unescapes TeamCity values", () => {
|
||||
const events = run([
|
||||
"##teamcity[testSuiteStarted name='S']",
|
||||
@@ -118,6 +139,7 @@ describe("command lines", () => {
|
||||
expect(frameworkFor({ kind: "phpunit", command: "vendor/bin/phpunit", args: [] })).toBe("phpunit");
|
||||
expect(frameworkFor({ kind: "composer", command: "composer", args: ["run-script", "test"] })).toBe("auto");
|
||||
expect(frameworkFor({ kind: "shell", command: "php", args: ["vendor/bin/phpunit"] })).toBe("phpunit");
|
||||
expect(frameworkFor({ kind: "shell", command: "vendor/bin/pest", args: ["--parallel"] })).toBe("phpunit");
|
||||
expect(createTestParser({ kind: "phpunit", command: "phpunit", args: [] })).toBeDefined();
|
||||
expect(withTestReporting({ kind: "phpunit", command: "phpunit", args: ["tests"] }).args).toEqual([
|
||||
"--teamcity",
|
||||
@@ -154,4 +176,20 @@ describe("command lines", () => {
|
||||
);
|
||||
expect(phpunitFilter([[SUITE]])).toBe("/(App\\\\Tests\\\\CalcTest::.*)$/");
|
||||
});
|
||||
|
||||
it("filters Pest tests, data sets and their class by the names Pest prints", () => {
|
||||
const cls = "Tests\\Unit\\MathTest";
|
||||
expect(phpunitFilter([["Unit", cls, "it fails on purpose"]])).toBe(
|
||||
"/(Tests\\\\Unit\\\\MathTest::it fails on purpose( with data set .*)?)$/",
|
||||
);
|
||||
expect(phpunitFilter([["Unit", cls, "`division` → `by zero` → Throws"]])).toBe(
|
||||
"/(Tests\\\\Unit\\\\MathTest::`division` → `by zero` → Throws( with data set .*)?)$/",
|
||||
);
|
||||
expect(phpunitFilter([["Unit", cls, "Adds"]])).toBe("/(Tests\\\\Unit\\\\MathTest::Adds( with data set .*)?)$/");
|
||||
expect(phpunitFilter([["Unit", cls, "it doubles", 'it doubles with data set "(2)"']])).toBe(
|
||||
"/(Tests\\\\Unit\\\\MathTest::it doubles( with data set .*)?)$/",
|
||||
);
|
||||
expect(phpunitFilter([["Unit", cls]])).toBe("/(Tests\\\\Unit\\\\MathTest::.*)$/");
|
||||
expect(phpunitFilter([["/app/phpunit.xml", "Unit"]])).toBe("/(Unit::.*)$/");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { TestEvent, TestOutputParser } from "./types";
|
||||
|
||||
/** `php_qn:///app/tests/FooTest.php::\App\FooTest::testBar` → the file. */
|
||||
function hintFile(hint: string | undefined): string | null {
|
||||
const m = /^php_qn:\/\/(.+?\.php)(?:::|$)/.exec(hint ?? "");
|
||||
const m = /^(?:php|pest)_qn:\/\/(.+?\.php)(?:::|$)/.exec(hint ?? "");
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,11 @@ export function withTeamcity(args: readonly string[]): string[] {
|
||||
|
||||
const escapePattern = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
||||
|
||||
const CLASS_NAME = /^\\?[A-Z]\w*(?:\\\w+)*$/;
|
||||
|
||||
const isTestName = (name: string, parent: string) =>
|
||||
/^[a-z_]\w*$/.test(name) || !CLASS_NAME.test(name) || (CLASS_NAME.test(parent) && parent.includes("\\"));
|
||||
|
||||
/**
|
||||
* `--filter` pattern selecting the tests at `paths` (`[suite, class, method]`, a data set under its method).
|
||||
* PHPUnit matches `Class::method with data set …`; a class or suite node selects by its name.
|
||||
@@ -165,9 +170,16 @@ export function phpunitFilter(paths: readonly (readonly string[])[]): string {
|
||||
const method = last.replace(/ with data set .*$/, "");
|
||||
// A data set sits under a node named after its method (the data-provider suite).
|
||||
const cls = path[path.length - (dataSet ? 3 : 2)];
|
||||
if (cls && /^[a-z_]\w*$/.test(method))
|
||||
if (cls && isTestName(method, cls))
|
||||
parts.add(`${escapePattern(cls)}::${escapePattern(method)}( with data set .*)?`);
|
||||
else parts.add(`${escapePattern(last)}::.*`);
|
||||
}
|
||||
return `/(${[...parts].join("|")})$/`;
|
||||
}
|
||||
|
||||
export function pestFilter(describes: readonly string[], name?: string): string {
|
||||
const prefix = describes.map((d) => `\`${d}\` → `).join("");
|
||||
return name === undefined
|
||||
? `/::${escapePattern(prefix)}/`
|
||||
: `/::${escapePattern(prefix + name)}( with data set .*)?$/`;
|
||||
}
|
||||
@@ -160,9 +160,6 @@
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.marker:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ReactNode, RefObject } from "react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { loadSettings } from "@/platform/project";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { useWorkflowSettings, workflowSettings } from "@/platform/workflowSettings";
|
||||
import { useCommitCheckProviders } from "@/workbench/contributions";
|
||||
import { Checkbox, Input, Popup, T } from "@/ui";
|
||||
import { useCommit } from "./commitStore";
|
||||
import styles from "./CommitView.module.css";
|
||||
@@ -24,6 +25,19 @@ async function setCheck(key: CommitCheck, value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setContributedCheck(key: string, on: boolean) {
|
||||
if (!isTauri()) return;
|
||||
const off = new Set(workflowSettings().vcs.commit.disabledPluginChecks);
|
||||
if (on) off.delete(key);
|
||||
else off.add(key);
|
||||
try {
|
||||
await call(commands.settingsSet("global", "vcs.commit.disabledPluginChecks", JSON.stringify([...off].sort())));
|
||||
await loadSettings();
|
||||
} catch {
|
||||
// keep the previous value
|
||||
}
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={styles.optSection}>
|
||||
@@ -49,6 +63,7 @@ export function CommitOptionsBody() {
|
||||
const signOff = useCommit((s) => s.signOff);
|
||||
const runHooks = useCommit((s) => s.runHooks);
|
||||
const c = useWorkflowSettings((s) => s.vcs.commit);
|
||||
const contributed = useCommitCheckProviders((s) => s.providers).flatMap((p) => p.checks());
|
||||
const check = (key: CommitCheck, label: string, extra?: ReactNode) => (
|
||||
<div className={styles.optRow}>
|
||||
<Checkbox checked={c[key]} onChange={(v) => void setCheck(key, v)} label={label} />
|
||||
@@ -80,6 +95,15 @@ export function CommitOptionsBody() {
|
||||
<Section title="Advanced Commit Checks">
|
||||
{check("analyzeCode", "Analyze code")}
|
||||
{check("checkTodo", "Check TODO", <Configure />)}
|
||||
{contributed.map((k) => (
|
||||
<div key={k.key} className={styles.optRow}>
|
||||
<Checkbox
|
||||
checked={!c.disabledPluginChecks.includes(k.key)}
|
||||
onChange={(v) => void setContributedCheck(k.key, v)}
|
||||
label={k.title}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,8 @@ import {
|
||||
type TreeHandle,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { editorService, useMenuSlotItems } from "@/workbench/contributions";
|
||||
import { evaluateWhen, useContextKeys } from "@/platform/context";
|
||||
import { useToolWindowLayout } from "@/workbench/toolwindows/store";
|
||||
import {
|
||||
aggregateCheck,
|
||||
@@ -931,6 +932,7 @@ export function CommitMessageField({ included, runner }: { included: number; run
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<CommitMessageActions />
|
||||
<span className={styles.grow} />
|
||||
{tooLong && (
|
||||
<span className={clsx(styles.lineLen, styles.lineLenWarn)} title={tx("Subject line length")}>
|
||||
@@ -999,6 +1001,21 @@ export function CommitMessageField({ included, runner }: { included: number; run
|
||||
);
|
||||
}
|
||||
|
||||
/** Actions contributed above the message (a plugin's `commit.message` menu items). */
|
||||
function CommitMessageActions() {
|
||||
const items = useMenuSlotItems((s) => s.slots.commitMessage);
|
||||
useContextKeys();
|
||||
return (
|
||||
<>
|
||||
{(items ?? [])
|
||||
.filter((i) => evaluateWhen(i.when ?? undefined))
|
||||
.map((i) => (
|
||||
<IconButton key={i.action} icon={i.icon ?? "plugin"} action={i.action} actionArg={i.arg} size="small" />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Label of the Commit button. */
|
||||
export function commitLabel(amend: boolean, problems: boolean): string {
|
||||
return problems ? "Commit Anyway" : amend ? "Amend Commit" : "Commit";
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const commit = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data });
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => false,
|
||||
call: async (p: Promise<{ status: "ok"; data: unknown }>) => (await p).data,
|
||||
commands: { ...real.commands, vcsCommit: commit.mockImplementation(() => ok("commit1")) },
|
||||
};
|
||||
});
|
||||
|
||||
import type { CommitSelection, FileStatus } from "@/bindings";
|
||||
import { maybeCommitService, registerCommitCheckProvider } from "@/workbench/contributions";
|
||||
import { commitChanges } from "../changeOps";
|
||||
import { draftRevision, installDraftRevision, provideCommit } from "../commitSelection";
|
||||
import { useCommit } from "../commitStore";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
const files: FileStatus[] = ["a", "b"].map((n) => ({
|
||||
path: `/p/${n}.ts`,
|
||||
relPath: `${n}.ts`,
|
||||
staged: null,
|
||||
unstaged: "modified",
|
||||
renamedFrom: null,
|
||||
}));
|
||||
|
||||
let disposers: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
commit.mockClear();
|
||||
useVcs.setState({
|
||||
available: false,
|
||||
root: "/p",
|
||||
repo: "/p",
|
||||
roots: ["/p"],
|
||||
status: files,
|
||||
branch: null,
|
||||
operations: {},
|
||||
});
|
||||
useCommit.setState({
|
||||
message: "feat: x",
|
||||
amend: false,
|
||||
checks: new Map([["/p/b.ts", "none" as const]]),
|
||||
excludedHunks: new Map(),
|
||||
checkProblems: [],
|
||||
});
|
||||
disposers = [installDraftRevision(), provideCommit()];
|
||||
});
|
||||
|
||||
afterEach(() => disposers.reverse().forEach((d) => d()));
|
||||
|
||||
describe("the commit plugins see", () => {
|
||||
it("is what Commit would commit, and the message is replaced only at its revision", async () => {
|
||||
const service = maybeCommitService()!;
|
||||
const selection = (await service.selection())!;
|
||||
expect(selection.message).toBe("feat: x");
|
||||
expect(selection.repositories).toEqual([
|
||||
{ root: "/p", options: expect.objectContaining({ stage: ["/p/a.ts"], exclude: [], partial: [] }) },
|
||||
]);
|
||||
|
||||
// The user types meanwhile: the plugin's message would overwrite it.
|
||||
useCommit.setState({ message: "feat: typed" });
|
||||
expect(service.setMessage(selection.revision, "generated")).toBe(false);
|
||||
expect(useCommit.getState().message).toBe("feat: typed");
|
||||
|
||||
expect(service.setMessage(draftRevision(), "generated")).toBe(true);
|
||||
expect(useCommit.getState().message).toBe("generated");
|
||||
// Including another file is a new draft too.
|
||||
const before = draftRevision();
|
||||
useCommit.setState({ checks: new Map() });
|
||||
expect(draftRevision()).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it("is null without a repository", async () => {
|
||||
useVcs.setState({ root: null });
|
||||
expect(await maybeCommitService()!.selection()).toBeNull();
|
||||
expect(maybeCommitService()!.setMessage(draftRevision(), "x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributed before-commit checks", () => {
|
||||
it("stop the commit with their problems, and run on what it commits", async () => {
|
||||
const seen: Array<{ selection: CommitSelection; keys: string[] }> = [];
|
||||
disposers.push(
|
||||
registerCommitCheckProvider({
|
||||
checks: () => [{ key: "acme/markers", title: "Markers" }],
|
||||
run: async (selection, keys) => {
|
||||
seen.push({ selection, keys });
|
||||
return [{ title: "Markers", message: "Remove the marker.", path: "/p/a.ts", line: 3 }];
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await commitChanges()).toBeUndefined();
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]!.keys).toEqual(["acme/markers"]);
|
||||
expect(seen[0]!.selection.repositories[0]!.options.stage).toEqual(["/p/a.ts"]);
|
||||
const [problem] = useCommit.getState().checkProblems;
|
||||
expect(problem).toMatchObject({ title: "Markers", text: "Remove the marker.", details: { label: "a.ts:4" } });
|
||||
|
||||
// Commit Anyway: the shown problems are not asked again.
|
||||
expect(await commitChanges()).toBe("commit1");
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,17 @@
|
||||
/** Local change operations: commit, rollback, add, stash. */
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FileStatus, PartialCommitFile, Stash } from "@/bindings";
|
||||
import type { FileStatus, Stash } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, Icon, Input, T, fileIconName } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { commitRepos, groupOf, planCommit, remainingOverrides, renamedFromPath } from "./changes";
|
||||
import { useChangelists, withListDefaults } from "./changelists";
|
||||
import { groupOf, remainingOverrides, renamedFromPath } from "./changes";
|
||||
import { runCommitChecks } from "./commitChecks";
|
||||
import { contributedCheckProblems, currentPlan, selectionOf } from "./commitSelection";
|
||||
import { forgetAmendDraft, keepMessageAfterCommit, rememberMessage, useCommit } from "./commitStore";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { notifyFileOperation } from "@/platform/fileOps";
|
||||
import { confirmDialog, openDialog } from "./dialogs";
|
||||
import { hunkKeys } from "./hunks";
|
||||
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, rootOf, stripSlash } from "./model";
|
||||
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, stripSlash } from "./model";
|
||||
import { logConsole, runOp, saveAll } from "./ops";
|
||||
import { currentRepo, loadStashes, refreshNow, useVcs } from "./store";
|
||||
import styles from "./common.module.css";
|
||||
@@ -29,21 +28,14 @@ export function firstLine(message: string): string {
|
||||
|
||||
/** Stages the included changes and commits them. Returns the new commit id. */
|
||||
export async function commitChanges(): Promise<string | undefined> {
|
||||
const { message, amend, signOff, author, runHooks } = useCommit.getState();
|
||||
const { message, amend } = useCommit.getState();
|
||||
if (!message.trim() && !amend) {
|
||||
notifications().notify({ group: "vcs", level: "warning", title: "Commit message is empty" });
|
||||
return undefined;
|
||||
}
|
||||
await saveAll();
|
||||
await refreshNow();
|
||||
const status = useVcs.getState().status;
|
||||
const checks = withListDefaults(
|
||||
status,
|
||||
useVcs.getState().root ?? "",
|
||||
useChangelists.getState().state,
|
||||
useCommit.getState().checks,
|
||||
);
|
||||
const plan = planCommit(status, checks);
|
||||
const plan = currentPlan();
|
||||
// Concluding a merge needs no further changes (e.g. every conflict resolved as "yours").
|
||||
const merging = useVcs.getState().branch?.operation === "merge";
|
||||
if (!plan.included.length && !amend && !merging) {
|
||||
@@ -62,36 +54,29 @@ export async function commitChanges(): Promise<string | undefined> {
|
||||
}
|
||||
const candidates = plan.included.map((f) => ({ path: stripSlash(f.path), exists: effectiveKind(f) !== "deleted" }));
|
||||
// IDEA: failed checks show in the commit panel; the next Commit ("Commit Anyway") skips them.
|
||||
if (!useCommit.getState().checkProblems.length) {
|
||||
const checked = !useCommit.getState().checkProblems.length;
|
||||
if (checked) {
|
||||
const problems = await runCommitChecks(candidates, amend);
|
||||
if (problems.length) {
|
||||
useCommit.setState({ checkProblems: problems });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const partial = await partialCommitFiles(plan.partial);
|
||||
// Like IDEA, every repository with included changes gets a commit with the same message.
|
||||
const roots = useVcs.getState().roots;
|
||||
const repos = commitRepos(plan.included, roots, currentRepo());
|
||||
// After the checks: their modifications (reformat…) are part of the commit.
|
||||
const selection = await selectionOf(plan);
|
||||
if (checked) {
|
||||
const problems = await contributedCheckProblems(selection);
|
||||
if (problems.length) {
|
||||
useCommit.setState({ checkProblems: problems });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const id = await runOp(
|
||||
amend ? "Amend commit" : "Commit",
|
||||
async () => {
|
||||
// The commit is made from a copy of the index, so changes left out keep their
|
||||
// staged state and the other repositories' indexes stay as they are.
|
||||
const stage = [...plan.stage, ...partial.whole];
|
||||
let id = "";
|
||||
for (const repo of repos) {
|
||||
const inRepo = (path: string) => repos.length === 1 || (rootOf(path, roots) ?? null) === repo;
|
||||
const options = {
|
||||
amend,
|
||||
signOff,
|
||||
author: author.trim() || null,
|
||||
runHooks,
|
||||
partial: partial.files.filter((f) => inRepo(f.path)),
|
||||
stage: stage.filter(inRepo),
|
||||
exclude: plan.exclude.filter(inRepo),
|
||||
};
|
||||
id = await call(commands.vcsCommit(repo, message.trim(), options));
|
||||
for (const repo of selection.repositories) {
|
||||
id = await call(commands.vcsCommit(repo.root || null, message.trim(), repo.options));
|
||||
}
|
||||
return id;
|
||||
},
|
||||
@@ -99,7 +84,7 @@ export async function commitChanges(): Promise<string | undefined> {
|
||||
success: () => {
|
||||
const n = plan.included.length;
|
||||
const summary = firstLine(message);
|
||||
const several = repos.length;
|
||||
const several = selection.repositories.length;
|
||||
const done = amend
|
||||
? several > 1
|
||||
? n === 1
|
||||
@@ -135,31 +120,6 @@ export async function commitChanges(): Promise<string | undefined> {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The included blocks of files committed partially, as hunk ranges against HEAD of the
|
||||
* text on disk (saved just before). A file whose blocks are all included again (e.g. it
|
||||
* was edited since) is committed whole.
|
||||
*/
|
||||
async function partialCommitFiles(paths: readonly string[]): Promise<{ files: PartialCommitFile[]; whole: string[] }> {
|
||||
const files: PartialCommitFile[] = [];
|
||||
const whole: string[] = [];
|
||||
const excludedHunks = useCommit.getState().excludedHunks;
|
||||
for (const path of paths) {
|
||||
const [head, disk] = await Promise.all([call(commands.vcsFileAt(path, "HEAD")), call(commands.fsReadText(path))]);
|
||||
const hunks = await commands.vcsDiffHunks(head ?? "", disk);
|
||||
const keys = hunkKeys(disk, hunks);
|
||||
const excluded = excludedHunks.get(path) ?? excludedHunks.get(`${path}/`);
|
||||
const chosen = hunks.filter((_, i) => !excluded?.has(keys[i]!));
|
||||
if (chosen.length === hunks.length) whole.push(path);
|
||||
else
|
||||
files.push({
|
||||
path,
|
||||
hunks: chosen.map(({ start, lines, oldStart, oldLines }) => ({ start, lines, oldStart, oldLines })),
|
||||
});
|
||||
}
|
||||
return { files, whole };
|
||||
}
|
||||
|
||||
/** Delete… on unversioned files in the Changes view, as in IDEA: they move to the Trash. */
|
||||
/** Delete… in the Changes view: moves the files to the Trash (a tracked file then shows as deleted). */
|
||||
export async function deleteFiles(paths: readonly string[]) {
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user