Compare commits

...
Author SHA1 Message Date
l0ng-ai 2dc078b93b feat(plugins): commit workflow API for plugins
Plugin API 1.3 adds what commit-message generators and before-commit checks need:

- `menu = "commit.message"`: a plugin command, with the plugin's icon, in the toolbar above the commit message of the Commit tool window and the Commit dialog.
- `commit.pending-commit()` (`vcs.read`): what Commit would commit right now, per file its repository, path, status and a `git diff` of only the included files and changed blocks against HEAD (1 MiB in all), plus the message, Amend and a revision. The diff is made by the same selection the commit uses, in a copy of the index, so the index and the working tree stay as they are; unsaved editors of the included files are saved first, as Commit does. Only in trusted projects.
- `commit.set-commit-message(revision, message)` (`vcs.write`): replaces the message only while the draft is still at that revision, so text the user typed meanwhile is never overwritten (`stale`).
- `[[contributes.commitChecks]]` and the optional `commit-checker` export (worlds `plugin-commit-checks` / `commit-checks`): checks listed in Commit Options (on by default, `vcs.commit.disabledPluginChecks` turns them off) and run after the IDE's own; their problems, optionally with a location, stop the commit until Commit Anyway.

The window answers the host through `CommitRequested` / `plugin_answer_commit`, also for remote windows. The Rust SDK gains `host::pending_commit`, `host::set_commit_message`, `openidea_plugin::commit` and their mock; a `commit` test fixture exercises them. All components are rebuilt for the WIT change.

Refs #1650
2026-11-07 16:12:10 +08:00
81 changed files with 2605 additions and 148 deletions

No files matched your search

+3
View File
@@ -113,6 +113,8 @@ pub struct CommitSettings {
pub body_limit: u32, pub body_limit: u32,
pub show_right_margin: bool, pub show_right_margin: bool,
pub wrap_at_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 { impl Default for CommitSettings {
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
body_limit: 72, body_limit: 72,
show_right_margin: true, show_right_margin: true,
wrap_at_margin: false, wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
} }
} }
} }
+165 -9
View File
@@ -1,14 +1,14 @@
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE. //! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
//! Capabilities were checked by the runtime before any of this runs. //! 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::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit}; use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package; use ide_extension::Package;
use ide_language::ProgramPin; use ide_language::ProgramPin;
use ide_plugin::{ use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt, CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
RepositoryStatus, StatusItem, ProcessOutput, Prompt, RepositoryStatus, StatusItem,
}; };
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -23,6 +23,11 @@ const MAX_KEY: usize = 256;
/// Most status bar items per plugin. /// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8; const MAX_STATUS_ITEMS: usize = 8;
const MAX_CONTEXT_KEYS: usize = 100; 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). /// (window or `None` for every window, plugin, key).
type ContextKey = (Option<String>, String, String); 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). /// 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>) fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>; -> 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` /// The user's values of `plugin`'s settings: the project's at `root`
/// (over global), or global. /// (over global), or global.
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> { fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
@@ -150,10 +159,15 @@ struct TaskEntry {
cancelled: bool, 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 { struct PromptEntry {
plugin: String, 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`]. /// 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() self.workspace.read().clone()
} }
@@ -358,8 +372,22 @@ impl Shared {
/// The user answered (`Some`) or dismissed (`None`) a prompt. /// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool { pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let Some(prompt) = self.prompts.lock().remove(&id) else { return false }; let mut prompts = self.prompts.lock();
prompt.answer.send(answer).is_ok() 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) { fn end_task(&self, id: u64) {
@@ -540,6 +568,91 @@ fn kill_group(child: &mut std::process::Child) {
ide_core::process::kill_tree(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 { fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string()) HostError::Failed(e.to_string())
} }
@@ -577,7 +690,10 @@ impl HostApi for PluginApi {
if blocked.contains(self.id()) { if blocked.contains(self.id()) {
return Err(HostError::Cancelled); 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(); let prompt_id = id.to_string();
self.shared.sink.emit( 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> { fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
let client = from_window(client, "reveal a view")?; 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); 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(()) 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> { 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 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)) { let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
+28
View File
@@ -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
}
+62
View File
@@ -16,6 +16,7 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`). //! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api; mod api;
pub mod commit;
mod hover; mod hover;
mod icon_extensions; mod icon_extensions;
mod icon_patterns; mod icon_patterns;
@@ -1366,6 +1367,67 @@ impl PluginHost {
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer)) 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; /// Kinds of workbench events some running or activatable plugin wants;
/// the UI reports only these. /// the UI reports only these.
pub fn event_kinds(&self) -> BTreeSet<EventKind> { pub fn event_kinds(&self) -> BTreeSet<EventKind> {
+1
View File
@@ -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::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_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::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
Ok(()) Ok(())
} }
+22 -1
View File
@@ -15,7 +15,7 @@
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`, //! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`, //! `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_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 //! carry the desktop commands' names and arguments, so a remote window's UI
//! routes them here unchanged. Plugin results come back as `plugin` //! routes them here unchanged. Plugin results come back as `plugin`
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's //! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
@@ -122,6 +122,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::PromptRequested { .. } | PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. } | PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. } | PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
) )
} }
@@ -422,6 +423,26 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) } async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
}); });
let h = host.clone(); 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| { d.add("plugin_set_active_file", move |c| {
let h = h.clone(); let h = h.clone();
async move { async move {
+70
View File
@@ -232,6 +232,55 @@ pub enum PluginEvent {
/// Bring the tool window of the plugin's view `view` to the front. /// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String }, 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 /// Delivers events to the UI. `client` is the window that invoked the plugin
@@ -300,3 +349,24 @@ pub struct LineMarkerInfo {
/// Opened on click. /// Opened on click.
pub target: Option<ide_core::Location>, 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>,
}
+36 -2
View File
@@ -5,7 +5,7 @@
//! //!
//! ```toml //! ```toml
//! [[contributes.menus]] //! [[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" //! command = "check"
//! when = "editorLang == java" //! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context //! # view = "<view id>" # required for (only allowed on) viewItem.context
@@ -42,6 +42,10 @@
//! icon = "icons/drawing.svg" //! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg" //! iconDark = "icons/drawing_dark.svg"
//! //!
//! [[contributes.commitChecks]]
//! id = "conventional"
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
//!
//! [[contributes.fileTemplates]] //! [[contributes.fileTemplates]]
//! id = "drawing" //! id = "drawing"
//! name = "Excalidraw Drawing" //! name = "Excalidraw Drawing"
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors"; pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons"; pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates"; pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
const VIEW_ITEM_MENU: &str = "viewItem.context"; 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_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200; const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16; 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(", "))); return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
} }
declared_command(pkg, MENUS, &d.command)?; 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 { if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?; 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+253
View File
@@ -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);
}
}
+62 -4
View File
@@ -4,7 +4,8 @@
//! themselves are in `wit_host`. //! themselves are in `wit_host`.
use crate::types::{ use crate::types::{
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover, ProviderDocument, SemanticToken, CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
ProviderDocument, SemanticToken,
}; };
use crate::types::{ use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt, EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
@@ -15,8 +16,8 @@ use crate::types::{
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
}; };
use crate::wit_host::{ use crate::wit_host::{
bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
syntax_bindings, views_bindings, services_bindings, syntax_bindings, views_bindings,
}; };
use ide_core::CancellationToken; use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc; use ide_core::ipc_path::to_ipc;
@@ -33,6 +34,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events; use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types; 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 hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index; use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use providers_bindings::exports::openidea::plugin::providers as wit_prov; use providers_bindings::exports::openidea::plugin::providers as wit_prov;
@@ -115,6 +117,22 @@ pub trait HostApi: Send + Sync {
Ok(Vec::new()) Ok(Vec::new())
} }
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>; 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. /// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`. /// Project files matching `glob` (root-relative), at most `limit`.
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>; fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
@@ -526,6 +544,7 @@ struct Exports {
semantic: Option<wit_sem::Guest>, semantic: Option<wit_sem::Guest>,
services: Option<wit_svc::Guest>, services: Option<wit_svc::Guest>,
hover: Option<wit_hover::Guest>, hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
} }
/// Most results one syntax call may return (more are dropped). /// Most results one syntax call may return (more are dropped).
@@ -608,6 +627,7 @@ impl Runtime {
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok(); let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let service_indices = wit_svc::GuestIndices::new(&pre).ok(); let service_indices = wit_svc::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::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 instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?; let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices { let syntax = match syntax_indices {
@@ -638,9 +658,13 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None, 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 { Ok(Self {
store, store,
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover }, exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
limits: setup.limits, limits: setup.limits,
version: setup.version, version: setup.version,
}) })
@@ -1009,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. /// A user action in a view or custom editor.
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> { pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
if self.exports.views.is_none() { if self.exports.views.is_none() {
@@ -1207,6 +1255,16 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max)) 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_SERVICE_TABS: usize = 8;
const MAX_LOG_TOGGLES: usize = 8; const MAX_LOG_TOGGLES: usize = 8;
const MAX_LOG_ARGS: usize = 256; const MAX_LOG_ARGS: usize = 256;
+34
View File
@@ -210,6 +210,40 @@ pub struct RepositoryStatus {
pub changed_files: u32, 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. /// Resource limits and supervision policy for plugin code.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct Limits { pub struct Limits {
+75 -2
View File
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor, "openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, "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/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, "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/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, "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/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, "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/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation, "openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs, "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/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks, "openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics, "openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
} }
use bindings::openidea::plugin::{ use bindings::openidea::plugin::{
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui, commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
vcs, views, workspace, types as wit, ui, vcs, views, workspace,
}; };
/// A project handle: its root. /// 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 { impl events::Host for StoreData {
fn subscribe(&mut self, kind: String) -> Fallible<()> { fn subscribe(&mut self, kind: String) -> Fallible<()> {
let Some(kind) = EventKind::parse(&kind) else { let Some(kind) = EventKind::parse(&kind) else {
@@ -828,6 +887,20 @@ 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 { pub(crate) mod semantic_bindings {
wasmtime::component::bindgen!({ wasmtime::component::bindgen!({
world: "semantic-tokens", path: "../../wit", world: "semantic-tokens", path: "../../wit",
+333
View File
@@ -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"
+20
View File
@@ -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"
+9
View File
@@ -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
+21
View File
@@ -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"
+57
View File
@@ -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 @@
885badd4727639ad3205b2551d2b8406490fc05b72c684c5d17f09aadd9541bd 8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
Binary file not shown.
@@ -1 +1 @@
ff1b39ac7850fbe4495e274dd78cca4c906a20705e8b9a975f58cf564e8ff137 5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
Binary file not shown.
@@ -1 +1 @@
c9bb4bc3603d9e7c770cf13ad413f5b3a787e78dbeed8f41a3b8537c0740275d 58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
Binary file not shown.
@@ -1 +1 @@
a9cb7302b86e12e05cbbf87fb402609e2f88d3a463f6c6e96b254e643b76183d 0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
Binary file not shown.
@@ -1 +1 @@
0be22488fe8f20a59f48fc61089b3db791a5d723d71e4d654bfd008038c01003 bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
Binary file not shown.
@@ -1 +1 @@
92ca920847c51036ac6dc7acd299ff9afaef9b4037e0112a0159bf50e822c7f3 c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
+12
View File
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub use text_encoding::TextEncoding; pub use text_encoding::TextEncoding;
pub use write::PendingChange;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -1069,6 +1070,17 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); 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)) 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 /// 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 /// (`origin/x`) gets a local tracking branch and any other revision is checked out
/// detached. /// detached.
+140 -9
View File
@@ -372,10 +372,10 @@ pub(crate) fn commit(
args.extend(["-F", "-"]); args.extend(["-F", "-"]);
Some(message.as_bytes()) Some(message.as_bytes())
}; };
if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() { if selects(options) {
run(r.root(), &args, stdin)?;
} else {
commit_selected(r, &args, stdin, options, encoding)?; 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. // A commit concluding a merge brings back the local changes put aside for it.
let _ = crate::preserve::restore(r); 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`): /// 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 /// see [`select_into`]. The working tree and the user's index are left alone
/// `partial` only the chosen hunks. 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 /// except that the committed files' entries end up matching the new commit, so changes
/// left out keep their staged state. /// left out keep their staged state.
fn commit_selected( fn commit_selected(
@@ -406,6 +410,23 @@ fn commit_selected(
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>, encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<()> { ) -> IdeResult<()> {
let tmp = TempIndex(r.git_dir.join("openidea-partial-index")); 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"); let index = r.git_dir.join("index");
if index.exists() { if index.exists() {
std::fs::copy(&index, &tmp.0)?; std::fs::copy(&index, &tmp.0)?;
@@ -459,10 +480,120 @@ fn commit_selected(
write_index_blob(r, &rel, &bytes, &env)?; write_index_blob(r, &rel, &bytes, &env)?;
rels.push(rel); rels.push(rel);
} }
cli::run_env(r.root(), commit_args, stdin, &env)?; Ok(rels)
// 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) /// 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<()> { pub(crate) fn checkout(r: &Repo, branch: &str, create: bool) -> IdeResult<()> {
+65
View File
@@ -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"); 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] #[test]
fn partial_staging_with_built_patch() { fn partial_staging_with_built_patch() {
let fx = Fixture::new(); let fx = Fixture::new();
+77 -32
View File
@@ -34,7 +34,7 @@ plugins/
├─ regex-tester/ # html view talking to its code, settings, the editor's selection ├─ 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 └─ 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) 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, hover 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 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 ### Contribution points
| Point | Declaration | Code needed | | Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them | | `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no | | `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | | `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no | | `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | | `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | | `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | | `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no | | `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 | | `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | | `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | 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` | | `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` | | `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 | | `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 | | `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 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 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" 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 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 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 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 listed; high-risk ones are called out as equivalent to running code on the
machine. Users can revoke optional capabilities in Settings › Plugins. machine. Users can revoke optional capabilities in Settings › Plugins.
| Capability | Risk | Unlocks today | | Capability | Risk | Unlocks today |
| ------------------- | ------ | ----------------------------------------------------------------------------------- | | ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` | | `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` | | `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` | | `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` | | `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) | | `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` |
| `storage` | low | `host::storage` (1 MiB per plugin) | | `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events | | `events` | low | file opened/saved, editor focused, repository changed events |
| `events.input` | medium | `action-performed` events (which actions you run, and how) | | `events.input` | medium | `action-performed` events (which actions you run, and how) |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs | | `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | | `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` | | `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 | | `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 `process:` capabilities are also derived from what the contributions start: a
language's `language-servers` and `debugger` commands and a run detector's 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. | | `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). | | `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). | | `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::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::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. | | `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). 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. 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 ### Providers
Declare what the plugin provides in the editor; only plugins whose Declare what the plugin provides in the editor; only plugins whose
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
9384d8964c7c05d3df06521e0d724a932fdd17608cbcede13ec2acadfab3783c 89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
9d7e857ef367eaa9748d1a2b05b7b45226c254aa578c29e1ed987c7353738acf a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3e11039d36306f51c728bf9d08030fe620de461fa5b0037700aea95c4d27714c 7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ef50957a407c9aff9999fdd197a5e72c90b7ba77aedbd7569408262ab7e158be d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7160b309f39fe973e67bb20d361384955aed6afe98f00db329c3026ffe923759 511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1f1afa285a64a7f7758f08a80306d6397c8aee4d2ac7ed43369e77aae61add04 8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ddd9205da9fc947215ab90c505d8b443d0937b1dd11734e9ce94d277517c77a6 6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
c61a0ec6ffcbd71e590e5a16a6f08f2d2772481497edc77e11914cfb3d0bfcd1 43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
e11cd0b664ae058274808bc2b2152bac2738207979758d13bc0c0cd96c38ef4f 3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
59319cf4be14448b0126b07d074cc08c351cfd12a3de26f111c2445fe8bff664 588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
59844ae52aa765af404b43f7895db94d0063a3a6fce5bd54bf9af680ccac357b 723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3
+38
View File
@@ -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>;
}
+50
View File
@@ -212,6 +212,35 @@ mod imp {
wit::vcs::remotes(p).map_err(error) 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> { pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(k.name()).map_err(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 /// Replaces the plugin's diagnostics for `path` (absolute, in an open
/// project); an empty slice clears them. Needs `project.read`. /// project); an empty slice clears them. Needs `project.read`.
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> { pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
+63
View File
@@ -39,6 +39,7 @@
//! Build with `openidea-plugin build` (or `cargo build --release --target //! Build with `openidea-plugin build` (or `cargo build --release --target
//! wasm32-wasip2`); see `plugins/README.md`. //! wasm32-wasip2`); see `plugins/README.md`.
pub mod commit;
pub mod host; pub mod host;
pub mod hover; pub mod hover;
pub mod indexing; pub mod indexing;
@@ -167,6 +168,23 @@ pub mod hover_bindings {
}); });
} }
#[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. /// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.3"; pub const API_VERSION: &str = "1.3";
@@ -230,6 +248,9 @@ macro_rules! __optional_exports {
(hover, $plugin:ty, $component:ident) => { (hover, $plugin:ty, $component:ident) => {
$crate::__hover_exports!($plugin, $component); $crate::__hover_exports!($plugin, $component);
}; };
(commit_checks, $plugin:ty, $component:ident) => {
$crate::__commit_checks_exports!($plugin, $component);
};
(syntax, $plugin:ty, $component:ident) => { (syntax, $plugin:ty, $component:ident) => {
impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component { impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component {
fn highlights( fn highlights(
@@ -382,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)] #[doc(hidden)]
#[macro_export] #[macro_export]
macro_rules! __semantic_tokens_exports { macro_rules! __semantic_tokens_exports {
@@ -797,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) 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>( pub fn view_event<T: Plugin + v::Views + Default>(
view: String, view: String,
event: vt::ViewEvent, event: vt::ViewEvent,
+42
View File
@@ -66,6 +66,7 @@ struct State {
tasks: Vec<MockTask>, tasks: Vec<MockTask>,
vcs: Option<RepositoryStatus>, vcs: Option<RepositoryStatus>,
remotes: Vec<(String, String)>, remotes: Vec<(String, String)>,
commit: Option<CommitDraft>,
fetches: u32, fetches: u32,
fetch_error: Option<Error>, fetch_error: Option<Error>,
clipboard: Option<String>, clipboard: Option<String>,
@@ -250,6 +251,27 @@ impl MockHost {
self.state.borrow().revealed_views.clone() 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. /// Makes `vcs-fetch` fail.
pub fn fail_fetch(&self, error: Option<Error>) { pub fn fail_fetch(&self, error: Option<Error>) {
self.state.borrow_mut().fetch_error = 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> { pub fn subscribe(kind: EventKind) -> Result<(), Error> {
with(|s| { with(|s| {
let cap = match kind { let cap = match kind {
+29
View File
@@ -294,6 +294,35 @@ pub struct RepositoryStatus {
pub changed_files: u32, 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. /// A range in a file.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Location { pub struct Location {
+75
View File
@@ -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>;
}
+12
View File
@@ -17,6 +17,7 @@ world plugin {
import editor; import editor;
import invocation; import invocation;
import vcs; import vcs;
import commit;
import events; import events;
import tasks; import tasks;
import diagnostics; import diagnostics;
@@ -73,6 +74,12 @@ world plugin-services {
export service-provider; 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` /// Only the `syntax-provider` export: guests generate it next to `plugin`
/// (the component tools merge both into one component). /// (the component tools merge both into one component).
world syntax { world syntax {
@@ -106,3 +113,8 @@ world hover {
world services { world services {
export service-provider; export service-provider;
} }
/// Only the `commit-checker` export, generated next to `plugin` like `syntax`.
world commit-checks {
export commit-checker;
}
+2
View File
@@ -487,6 +487,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_context_keys, plugin::plugin_context_keys,
plugin::plugin_cancel_task, plugin::plugin_cancel_task,
plugin::plugin_answer_prompt, plugin::plugin_answer_prompt,
plugin::plugin_answer_commit,
plugin::plugin_commit_checks,
plugin::plugin_view_render, plugin::plugin_view_render,
plugin::plugin_view_event, plugin::plugin_view_event,
plugin::plugin_service_detail, plugin::plugin_service_detail,
+27
View File
@@ -393,6 +393,33 @@ pub fn plugin_answer_prompt(state: State<'_, AppState>, prompt_id: String, answe
state.plugins.answer_prompt(&prompt_id, answer) 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. /// Gutter markers of plugin `line-markers` providers for an open document.
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
+79 -1
View File
@@ -1072,6 +1072,16 @@ export const commands = {
* (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed. * (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed.
*/ */
pluginAnswerPrompt: (promptId: string, answer: string[] | null) => __TAURI_INVOKE<boolean>("plugin_answer_prompt", { promptId, answer }), 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 * 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). * 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; 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). */ /** Commit Options (IDEA's commit options popup). */
export type CommitOptions = { export type CommitOptions = {
amend: boolean, amend: boolean,
@@ -2340,6 +2359,41 @@ export type CommitOptions = {
exclude?: IpcPath[], 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. */ /** Version Control › Commit: commit checks and commit-message inspections. */
export type CommitSettings = { export type CommitSettings = {
/** /**
@@ -2361,6 +2415,8 @@ export type CommitSettings = {
bodyLimit?: number, bodyLimit?: number,
showRightMargin?: boolean, showRightMargin?: boolean,
wrapAtMargin?: boolean, wrapAtMargin?: boolean,
/** Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run. */
disabledPluginChecks?: string[],
}; };
export type Commit_Deserialize = { export type Commit_Deserialize = {
@@ -5771,6 +5827,23 @@ export type PickItem = {
description: string | null, 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). */ /** A plugin's context key for `when` clauses (`value` is JSON text). */
export type PluginContextKey = { export type PluginContextKey = {
pluginId: string, pluginId: string,
@@ -5838,7 +5911,12 @@ export type PluginEvent =
/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */ /** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */
{ type: "promptClosed"; promptId: string } | { type: "promptClosed"; promptId: string } |
/** Bring the tool window of the plugin's view `view` to the front. */ /** 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. */ /** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */
export type PluginIcon = { export type PluginIcon = {
+14
View File
@@ -8,12 +8,16 @@ import type {
ActiveTheme, ActiveTheme,
CapabilityInfo, CapabilityInfo,
CatalogPlugin, CatalogPlugin,
CommitAnswer,
CommitSelection,
Consent, Consent,
EventKind, EventKind,
InstalledIntellijTheme, InstalledIntellijTheme,
InstallPreview, InstallPreview,
IntellijThemeSource, IntellijThemeSource,
MarketPlugin, MarketPlugin,
PluginCommitCheckRef,
PluginCommitCheckResult,
PluginContextKey, PluginContextKey,
PluginEditor, PluginEditor,
PluginEvent, PluginEvent,
@@ -68,6 +72,10 @@ export interface PluginsBackend {
cancelTask(taskId: string): Promise<void>; cancelTask(taskId: string): Promise<void>;
/** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */ /** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */
answerPrompt(promptId: string, answer: string[] | null): Promise<void>; 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. */ /** Marketplace plugins: the index read again (`refresh`) or the last one read. */
market(refresh: boolean): Promise<MarketPlugin[]>; market(refresh: boolean): Promise<MarketPlugin[]>;
marketUrl(): Promise<string | null>; marketUrl(): Promise<string | null>;
@@ -187,6 +195,10 @@ export const tauriPluginsBackend: PluginsBackend = {
answerPrompt: async (promptId, answer) => { answerPrompt: async (promptId, answer) => {
await commands.pluginAnswerPrompt(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)), market: (refresh) => call(commands.pluginMarket(refresh)),
marketUrl: () => commands.pluginMarketUrl(), marketUrl: () => commands.pluginMarketUrl(),
setMarketUrl: async (url) => { setMarketUrl: async (url) => {
@@ -569,6 +581,8 @@ export function createMemoryPluginsBackend(
}), }),
viewEvent: async () => {}, viewEvent: async () => {},
serviceDetail: async () => [], serviceDetail: async () => [],
answerCommit: async () => {},
commitChecks: async () => [],
serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => { serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => {
onError(`${pluginId} · ${view} · ${key}: logs need the desktop app`); onError(`${pluginId} · ${view} · ${key}: logs need the desktop app`);
return () => {}; return () => {};
+41 -2
View File
@@ -5,7 +5,16 @@
* batches. * batches.
*/ */
import { create } from "zustand"; 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 { onEditorEvent } from "@/editor/api";
import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; import { executeAction, getAction, onActionExecuted } from "@/platform/actions";
import { clearContext, setContext, type ContextValue } from "@/platform/context"; import { clearContext, setContext, type ContextValue } from "@/platform/context";
@@ -14,7 +23,12 @@ import { openExternalUrl } from "@/platform/openUrl";
import { useProject } from "@/platform/project"; import { useProject } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap"; import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton"; 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 { pickItems, promptInput } from "@/workbench/inputDialogs";
import { confirm } from "@/workbench/messages"; import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
@@ -327,6 +341,28 @@ async function answerPrompt(promptId: string, prompt: Prompt) {
.catch(() => {}); .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. */ /** Handles the runtime UI events; returns whether `e` was one. */
export function handleRuntimeEvent(e: PluginEvent): boolean { export function handleRuntimeEvent(e: PluginEvent): boolean {
switch (e.type) { switch (e.type) {
@@ -370,6 +406,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
openPrompts.get(e.promptId)?.abort(); openPrompts.get(e.promptId)?.abort();
openPrompts.delete(e.promptId); openPrompts.delete(e.promptId);
return true; return true;
case "commitRequested":
void answerCommit(e.requestId, e.request);
return true;
case "revealView": case "revealView":
toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true); toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true);
return true; return true;
+88 -3
View File
@@ -2,7 +2,8 @@
* Client side of the plugin UI points: declarations the * Client side of the plugin UI points: declarations the
* backend validated, projected onto the workbench's own registries. * 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); * - `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); * - `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; * - `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 * - `customEditors`: files opening in a plugin editor sharing the text document, in its own tab or
* beside the text editor; * beside the text editor;
* - `fileIcons`: icons for the files a plugin knows, whatever icon theme is in use; * - `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 { useMemo } from "react";
import type { PluginInfo } from "@/bindings"; import type { PluginInfo } from "@/bindings";
@@ -32,6 +34,7 @@ import { addContributedFileTemplate } from "@/features/templates/files/store";
import { import {
registerEditorKind, registerEditorKind,
registerFileEditorProvider, registerFileEditorProvider,
registerCommitCheckProvider,
registerMenuGroup, registerMenuGroup,
registerMenuSlotItem, registerMenuSlotItem,
registerServiceProvider, registerServiceProvider,
@@ -40,9 +43,11 @@ import {
registerToolWindow, registerToolWindow,
useSettingsForm, useSettingsForm,
type ContextMenuId, type ContextMenuId,
type ContributedCommitProblem,
type ToolWindowAnchor, type ToolWindowAnchor,
} from "@/workbench/contributions"; } from "@/workbench/contributions";
import { pluginsBackend } from "./backend"; import { pluginsBackend } from "./backend";
import { pluginIconName } from "./pluginIcon";
import { usePlugins } from "./store"; import { usePlugins } from "./store";
import { pluginActionId } from "./points"; import { pluginActionId } from "./points";
import { defineUiPoint } from "./projection"; import { defineUiPoint } from "./projection";
@@ -66,7 +71,13 @@ import styles from "./PluginsPage.module.css";
// --- menus ------------------------------------------------------------------------ // --- menus ------------------------------------------------------------------------
interface MenuDecl { 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; view?: string | null;
command: string; command: string;
when?: string | null; when?: string | null;
@@ -118,11 +129,81 @@ function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () =
const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`; const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
return registerMenuSlotItem("pluginTools", { action, label: title }); 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}`; const key = `${++menuSeq}`;
contextItems.set(key, { pluginId, decl }); contextItems.set(key, { pluginId, decl });
return () => void contextItems.delete(key); 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 ---------------------------------------------------------------------- // --- keymaps ----------------------------------------------------------------------
interface KeymapDecl { interface KeymapDecl {
@@ -432,6 +513,10 @@ export function defineViewUiPoints(): () => void {
title: "File Icons", title: "File Icons",
project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl), project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl),
}), }),
defineUiPoint<CommitCheckDecl>("commitChecks", {
title: "Commit Checks",
project: ({ pluginId, decl }) => projectCommitCheck(pluginId, decl),
}),
defineUiPoint<FileTemplateDecl>("fileTemplates", { defineUiPoint<FileTemplateDecl>("fileTemplates", {
title: "File Templates", title: "File Templates",
project: ({ pluginId, decl }) => project: ({ pluginId, decl }) =>
+25 -1
View File
@@ -7,7 +7,8 @@ import type { ReactNode, RefObject } from "react";
import { executeAction } from "@/platform/actions"; import { executeAction } from "@/platform/actions";
import { call, commands, isTauri } from "@/platform/ipc"; import { call, commands, isTauri } from "@/platform/ipc";
import { loadSettings } from "@/platform/project"; 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 { Checkbox, Input, Popup, T } from "@/ui";
import { useCommit } from "./commitStore"; import { useCommit } from "./commitStore";
import styles from "./CommitView.module.css"; 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 }) { function Section({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<section className={styles.optSection}> <section className={styles.optSection}>
@@ -49,6 +63,7 @@ export function CommitOptionsBody() {
const signOff = useCommit((s) => s.signOff); const signOff = useCommit((s) => s.signOff);
const runHooks = useCommit((s) => s.runHooks); const runHooks = useCommit((s) => s.runHooks);
const c = useWorkflowSettings((s) => s.vcs.commit); 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) => ( const check = (key: CommitCheck, label: string, extra?: ReactNode) => (
<div className={styles.optRow}> <div className={styles.optRow}>
<Checkbox checked={c[key]} onChange={(v) => void setCheck(key, v)} label={label} /> <Checkbox checked={c[key]} onChange={(v) => void setCheck(key, v)} label={label} />
@@ -80,6 +95,15 @@ export function CommitOptionsBody() {
<Section title="Advanced Commit Checks"> <Section title="Advanced Commit Checks">
{check("analyzeCode", "Analyze code")} {check("analyzeCode", "Analyze code")}
{check("checkTodo", "Check TODO", <Configure />)} {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> </Section>
</> </>
); );
+18 -1
View File
@@ -33,7 +33,8 @@ import {
type TreeHandle, type TreeHandle,
T, T,
} from "@/ui"; } 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 { useToolWindowLayout } from "@/workbench/toolwindows/store";
import { import {
aggregateCheck, aggregateCheck,
@@ -931,6 +932,7 @@ export function CommitMessageField({ included, runner }: { included: number; run
}, },
}))} }))}
/> />
<CommitMessageActions />
<span className={styles.grow} /> <span className={styles.grow} />
{tooLong && ( {tooLong && (
<span className={clsx(styles.lineLen, styles.lineLenWarn)} title={tx("Subject line length")}> <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. */ /** Label of the Commit button. */
export function commitLabel(amend: boolean, problems: boolean): string { export function commitLabel(amend: boolean, problems: boolean): string {
return problems ? "Commit Anyway" : amend ? "Amend Commit" : "Commit"; 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);
});
});
+20 -60
View File
@@ -1,18 +1,17 @@
/** Local change operations: commit, rollback, add, stash. */ /** Local change operations: commit, rollback, add, stash. */
import { useEffect, useState } from "react"; 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 { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, T, fileIconName } from "@/ui"; import { Checkbox, Dialog, Icon, Input, T, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions"; import { notifications } from "@/workbench/contributions";
import { commitRepos, groupOf, planCommit, remainingOverrides, renamedFromPath } from "./changes"; import { groupOf, remainingOverrides, renamedFromPath } from "./changes";
import { useChangelists, withListDefaults } from "./changelists";
import { runCommitChecks } from "./commitChecks"; import { runCommitChecks } from "./commitChecks";
import { contributedCheckProblems, currentPlan, selectionOf } from "./commitSelection";
import { forgetAmendDraft, keepMessageAfterCommit, rememberMessage, useCommit } from "./commitStore"; import { forgetAmendDraft, keepMessageAfterCommit, rememberMessage, useCommit } from "./commitStore";
import { workflowSettings } from "@/platform/workflowSettings"; import { workflowSettings } from "@/platform/workflowSettings";
import { notifyFileOperation } from "@/platform/fileOps"; import { notifyFileOperation } from "@/platform/fileOps";
import { confirmDialog, openDialog } from "./dialogs"; import { confirmDialog, openDialog } from "./dialogs";
import { hunkKeys } from "./hunks"; import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, stripSlash } from "./model";
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, rootOf, stripSlash } from "./model";
import { logConsole, runOp, saveAll } from "./ops"; import { logConsole, runOp, saveAll } from "./ops";
import { currentRepo, loadStashes, refreshNow, useVcs } from "./store"; import { currentRepo, loadStashes, refreshNow, useVcs } from "./store";
import styles from "./common.module.css"; 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. */ /** Stages the included changes and commits them. Returns the new commit id. */
export async function commitChanges(): Promise<string | undefined> { export async function commitChanges(): Promise<string | undefined> {
const { message, amend, signOff, author, runHooks } = useCommit.getState(); const { message, amend } = useCommit.getState();
if (!message.trim() && !amend) { if (!message.trim() && !amend) {
notifications().notify({ group: "vcs", level: "warning", title: "Commit message is empty" }); notifications().notify({ group: "vcs", level: "warning", title: "Commit message is empty" });
return undefined; return undefined;
} }
await saveAll(); await saveAll();
await refreshNow(); await refreshNow();
const status = useVcs.getState().status; const plan = currentPlan();
const checks = withListDefaults(
status,
useVcs.getState().root ?? "",
useChangelists.getState().state,
useCommit.getState().checks,
);
const plan = planCommit(status, checks);
// Concluding a merge needs no further changes (e.g. every conflict resolved as "yours"). // Concluding a merge needs no further changes (e.g. every conflict resolved as "yours").
const merging = useVcs.getState().branch?.operation === "merge"; const merging = useVcs.getState().branch?.operation === "merge";
if (!plan.included.length && !amend && !merging) { 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" })); 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. // 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); const problems = await runCommitChecks(candidates, amend);
if (problems.length) { if (problems.length) {
useCommit.setState({ checkProblems: problems }); useCommit.setState({ checkProblems: problems });
return undefined; return undefined;
} }
} }
const partial = await partialCommitFiles(plan.partial); // After the checks: their modifications (reformat…) are part of the commit.
// Like IDEA, every repository with included changes gets a commit with the same message. const selection = await selectionOf(plan);
const roots = useVcs.getState().roots; if (checked) {
const repos = commitRepos(plan.included, roots, currentRepo()); const problems = await contributedCheckProblems(selection);
if (problems.length) {
useCommit.setState({ checkProblems: problems });
return undefined;
}
}
const id = await runOp( const id = await runOp(
amend ? "Amend commit" : "Commit", amend ? "Amend commit" : "Commit",
async () => { 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 = ""; let id = "";
for (const repo of repos) { for (const repo of selection.repositories) {
const inRepo = (path: string) => repos.length === 1 || (rootOf(path, roots) ?? null) === repo; id = await call(commands.vcsCommit(repo.root || null, message.trim(), repo.options));
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));
} }
return id; return id;
}, },
@@ -99,7 +84,7 @@ export async function commitChanges(): Promise<string | undefined> {
success: () => { success: () => {
const n = plan.included.length; const n = plan.included.length;
const summary = firstLine(message); const summary = firstLine(message);
const several = repos.length; const several = selection.repositories.length;
const done = amend const done = amend
? several > 1 ? several > 1
? n === 1 ? n === 1
@@ -135,31 +120,6 @@ export async function commitChanges(): Promise<string | undefined> {
return id; 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… 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). */ /** 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[]) { export async function deleteFiles(paths: readonly string[]) {
+158
View File
@@ -0,0 +1,158 @@
/**
* What Commit would commit, as one value per repository (`CommitSelection`): what
* `commitChanges` commits, what plugins read through `commit.pending-commit` and what
* contributed before-commit checks run on. Its `revision` changes with the message,
* Amend and the inclusion, so a plugin's message never replaces newer typing.
*/
import type { CommitOptions, CommitSelection, PartialCommitFile } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import { maybeEditorService, provideCommitService, useCommitCheckProviders } from "@/workbench/contributions";
import { commitRepos, planCommit, type CommitPlan } from "./changes";
import type { CommitProblem } from "./commitChecks";
import { useChangelists, withListDefaults } from "./changelists";
import { useCommit } from "./commitStore";
import { hunkKeys } from "./hunks";
import { baseName, rootOf } from "./model";
import { saveAll } from "./ops";
import { currentRepo, refreshNow, useVcs } from "./store";
let revision = 1;
/** The draft's revision: bumped whenever the message, Amend or the inclusion changes. */
export const draftRevision = () => revision;
export function installDraftRevision(): () => void {
const offCommit = useCommit.subscribe((s, p) => {
if (s.message !== p.message || s.amend !== p.amend || s.checks !== p.checks || s.excludedHunks !== p.excludedHunks)
revision++;
});
const offLists = useChangelists.subscribe(() => void revision++);
const offRepo = useVcs.subscribe((s, p) => {
if (s.root !== p.root) revision++;
});
return () => {
offCommit();
offLists();
offRepo();
};
}
/** The included changes, as the status (refreshed by the caller) and the checkboxes say. */
export function currentPlan(): CommitPlan {
const { status, root } = useVcs.getState();
const checks = withListDefaults(status, root ?? "", useChangelists.getState().state, useCommit.getState().checks);
return planCommit(status, checks);
}
/**
* 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 };
}
/**
* `plan` per repository with the Commit Options, as `vcs_commit` gets it. Read from disk:
* the caller saved the editors and refreshed the status.
*/
export async function selectionOf(plan: CommitPlan): Promise<CommitSelection> {
const rev = revision;
const { message, amend, signOff, author, runHooks } = useCommit.getState();
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());
// 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];
const fallback = useVcs.getState().root ?? "";
const repositories = repos.map((repo) => {
const inRepo = (path: string) => repos.length === 1 || (rootOf(path, roots) ?? null) === repo;
const options: CommitOptions = {
amend,
signOff,
author: author.trim() || null,
runHooks,
partial: partial.files.filter((f) => inRepo(f.path)),
stage: stage.filter(inRepo),
exclude: plan.exclude.filter(inRepo),
};
return { root: repo ?? fallback, options };
});
return { revision: rev, message: message.trim(), amend, repositories };
}
/** For plugins: what Commit would commit now (saving the editors as Commit does). */
async function pendingSelection(): Promise<CommitSelection | null> {
if (!useVcs.getState().root) return null;
await saveAll();
await refreshNow();
if (!useVcs.getState().root) return null;
const rev = revision;
const selection = await selectionOf(currentPlan());
// Typing while the blocks were read: report the revision the snapshot started from.
return { ...selection, revision: rev };
}
export function provideCommit(): () => void {
provideCommitService({
selection: pendingSelection,
setMessage: (rev, message) => {
if (rev !== revision || !useVcs.getState().root) return false;
useCommit.setState({ message });
return true;
},
});
return () => provideCommitService(undefined);
}
/** Problems of the contributed before-commit checks that are turned on. */
export async function contributedCheckProblems(selection: CommitSelection): Promise<CommitProblem[]> {
const off = new Set(workflowSettings().vcs.commit.disabledPluginChecks);
const problems: CommitProblem[] = [];
for (const provider of useCommitCheckProviders.getState().providers) {
const keys = provider
.checks()
.map((c) => c.key)
.filter((k) => !off.has(k));
if (!keys.length) continue;
for (const p of await provider.run(selection, keys)) {
const at = p.path ? `${baseName(p.path)}${p.line === undefined ? "" : `:${p.line + 1}`}` : null;
const path = p.path;
problems.push({
title: p.title,
text: p.message,
details: path
? {
label: at ?? "Show",
run: () =>
void maybeEditorService()?.openFile(path, {
selection: p.line === undefined ? undefined : { line: p.line, col: 0 },
focus: true,
}),
}
: undefined,
});
}
}
return problems;
}
+3
View File
@@ -39,6 +39,7 @@ import { registerMergeActions } from "./mergeActions";
import { baseName } from "./model"; import { baseName } from "./model";
import { installDiagnosticsTracking } from "./commitChecks"; import { installDiagnosticsTracking } from "./commitChecks";
import { installPreparedMessage, useCommit } from "./commitStore"; import { installPreparedMessage, useCommit } from "./commitStore";
import { installDraftRevision, provideCommit } from "./commitSelection";
import { activate as activateConflictBanner } from "./conflictBanner"; import { activate as activateConflictBanner } from "./conflictBanner";
import { activate as activateConflictMarkers } from "./conflictMarkers"; import { activate as activateConflictMarkers } from "./conflictMarkers";
import { MergeRebaseWidget } from "./MergeRebaseWidget"; import { MergeRebaseWidget } from "./MergeRebaseWidget";
@@ -215,6 +216,8 @@ export function activate(): () => void {
installGitLog(), installGitLog(),
installDiagnosticsTracking(), installDiagnosticsTracking(),
installPreparedMessage(), installPreparedMessage(),
installDraftRevision(),
provideCommit(),
installIncomingCheck(), installIncomingCheck(),
installFileConfirmations(), installFileConfirmations(),
// Settings › Git › Sign-off commit: the default of Commit Options › Sign-off Commit. // Settings › Git › Sign-off commit: the default of Commit Options › Sign-off Commit.
+2 -1
View File
@@ -6442,5 +6442,6 @@
"Branch Conversation from Here": "从此处创建分支对话", "Branch Conversation from Here": "从此处创建分支对话",
"Couldn't regenerate the response": "无法重新生成回复", "Couldn't regenerate the response": "无法重新生成回复",
"Reasoning": "思考过程", "Reasoning": "思考过程",
"Cannot copy the value": "无法复制该值" "Cannot copy the value": "无法复制该值",
"The check could not run: {reason}": "检查无法运行:{reason}"
} }
+4
View File
@@ -50,6 +50,8 @@ export interface CommitSettingsValid {
bodyLimit: number; bodyLimit: number;
showRightMargin: boolean; showRightMargin: boolean;
wrapAtMargin: boolean; wrapAtMargin: boolean;
/** Plugin before-commit checks turned off (`<plugin id>/<check id>`). */
disabledPluginChecks: string[];
} }
export interface VcsSettingsValid { export interface VcsSettingsValid {
@@ -215,6 +217,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
bodyLimit: 72, bodyLimit: 72,
showRightMargin: true, showRightMargin: true,
wrapAtMargin: false, wrapAtMargin: false,
disabledPluginChecks: [],
}, },
confirmation: { confirmation: {
onCreate: "ask", onCreate: "ask",
@@ -342,6 +345,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
bodyLimit: num(c.bodyLimit, d.commit.bodyLimit, L.messageLimit, true), bodyLimit: num(c.bodyLimit, d.commit.bodyLimit, L.messageLimit, true),
showRightMargin: bool(c.showRightMargin, d.commit.showRightMargin), showRightMargin: bool(c.showRightMargin, d.commit.showRightMargin),
wrapAtMargin: bool(c.wrapAtMargin, d.commit.wrapAtMargin), wrapAtMargin: bool(c.wrapAtMargin, d.commit.wrapAtMargin),
disabledPluginChecks: strings(c.disabledPluginChecks, d.commit.disabledPluginChecks),
}, },
confirmation: { confirmation: {
onCreate: oneOf(conf.onCreate, confirm, d.confirmation.onCreate), onCreate: oneOf(conf.onCreate, confirm, d.confirmation.onCreate),
+55 -3
View File
@@ -17,7 +17,7 @@ import {
type ReactNode, type ReactNode,
type RefObject, type RefObject,
} from "react"; } from "react";
import type { FileMatches, SearchQuery, RunConfig } from "@/bindings"; import type { CommitSelection, FileMatches, SearchQuery, RunConfig } from "@/bindings";
import type { MenuItem } from "@/ui/Menu"; import type { MenuItem } from "@/ui/Menu";
import type { DragData } from "@/ui/pointerDrag"; import type { DragData } from "@/ui/pointerDrag";
import type { TreeReorder } from "@/ui/Tree"; import type { TreeReorder } from "@/ui/Tree";
@@ -676,6 +676,53 @@ export const maybeEditorService = (): EditorService | undefined => services.edit
/** Creates terminal widgets; provided by the terminal feature. */ /** Creates terminal widgets; provided by the terminal feature. */
export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal; export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal;
/**
* The commit being prepared (the Commit tool window or dialog), for plugins:
* provided by the vcs feature.
*/
export interface CommitService {
/**
* What Commit would commit now, per repository (unsaved editors saved first, as
* Commit does); `null` without a repository.
*/
selection(): Promise<CommitSelection | null>;
/** Replaces the message if the draft is still at `revision`; false when it changed. */
setMessage(revision: number, message: string): boolean;
}
let commitServiceImpl: CommitService | undefined;
export const provideCommitService = (s: CommitService | undefined) => void (commitServiceImpl = s);
export const maybeCommitService = (): CommitService | undefined => commitServiceImpl;
/** A before-commit check contributed by an extension (a plugin's `commitChecks`). */
export interface ContributedCommitCheck {
/** Stable key (`<plugin id>/<check id>`), stored when the check is turned off. */
key: string;
title: string;
}
/** A problem a contributed check reported (or why the check could not run). */
export interface ContributedCommitProblem {
title: string;
message: string;
path?: string;
/** 0-based. */
line?: number;
}
export interface CommitCheckProvider {
checks(): ContributedCommitCheck[];
/** Runs the checks `keys` on what `selection` commits. */
run(selection: CommitSelection, keys: string[]): Promise<ContributedCommitProblem[]>;
}
export const useCommitCheckProviders = create<{ providers: CommitCheckProvider[] }>(() => ({ providers: [] }));
export function registerCommitCheckProvider(p: CommitCheckProvider): () => void {
useCommitCheckProviders.setState((s) => ({ providers: [...s.providers, p] }));
return () => useCommitCheckProviders.setState((s) => ({ providers: s.providers.filter((x) => x !== p) }));
}
/** /**
* Handles a pointer drag dropped on the editor content: features register handlers for * Handles a pointer drag dropped on the editor content: features register handlers for
* their own drag types (e.g. an agent task dragged from the tool window) without the * their own drag types (e.g. an agent task dragged from the tool window) without the
@@ -980,13 +1027,18 @@ export type ContextMenuId =
| "new"; | "new";
/** /**
* Entries of the native main menu's open slots (src-tauri/src/menu.rs * Entries of open menu slots: the native main menu's (src-tauri/src/menu.rs
* `Entry::Dynamic`), e.g. `pluginTools` at the end of Tools: each runs `action`. * `Entry::Dynamic`, e.g. `pluginTools` at the end of Tools) and in-app toolbars
* (`commitMessage`, above the commit message). Each runs `action`.
*/ */
export interface MenuSlotItem { export interface MenuSlotItem {
action: string; action: string;
arg?: string | null; arg?: string | null;
label: string; label: string;
/** Icon name, for toolbar slots. */
icon?: string;
/** Shown only while this `when` clause holds (toolbar slots). */
when?: string | null;
} }
export const useMenuSlotItems = create<{ slots: Record<string, MenuSlotItem[]> }>(() => ({ slots: {} })); export const useMenuSlotItems = create<{ slots: Record<string, MenuSlotItem[]> }>(() => ({ slots: {} }));
+75
View File
@@ -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>;
}
+12
View File
@@ -17,6 +17,7 @@ world plugin {
import editor; import editor;
import invocation; import invocation;
import vcs; import vcs;
import commit;
import events; import events;
import tasks; import tasks;
import diagnostics; import diagnostics;
@@ -73,6 +74,12 @@ world plugin-services {
export service-provider; 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` /// Only the `syntax-provider` export: guests generate it next to `plugin`
/// (the component tools merge both into one component). /// (the component tools merge both into one component).
world syntax { world syntax {
@@ -106,3 +113,8 @@ world hover {
world services { world services {
export service-provider; export service-provider;
} }
/// Only the `commit-checker` export, generated next to `plugin` like `syntax`.
world commit-checks {
export commit-checker;
}