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
This commit is contained in:
l0ng-ai committed 2026-11-07 16:12:10 +08:00
1 parent a36c7ed3d5
commit 2dc078b93b
81 files changed
+2605 -148

No files matched your search

+3
View File
@@ -113,6 +113,8 @@ pub struct CommitSettings {
pub body_limit: u32,
pub show_right_margin: bool,
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
}
impl Default for CommitSettings {
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
body_limit: 72,
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
}
}
}
+165 -9
View File
@@ -1,14 +1,14 @@
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginContextKey, PluginEvent, PluginEventSink};
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem,
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -23,6 +23,11 @@ const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
const MAX_CONTEXT_KEYS: usize = 100;
/// Most diff text of a pending commit, all files together.
const MAX_COMMIT_DIFF: usize = 1 << 20;
/// How long a window may take to describe its pending commit (it saves editors first).
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// (window or `None` for every window, plugin, key).
type ContextKey = (Option<String>, String, String);
@@ -51,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
/// The encoding git content of `path` is read in (the project's file encoding).
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
encoding_rs::UTF_8.into()
}
/// The user's values of `plugin`'s settings: the project's at `root`
/// (over global), or global.
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
@@ -150,10 +159,15 @@ struct TaskEntry {
cancelled: bool,
}
/// A question a plugin's call waits on.
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
struct PromptEntry {
plugin: String,
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
answer: Waiter,
}
enum Waiter {
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
}
/// State shared by the host and every plugin's [`PluginApi`].
@@ -211,7 +225,7 @@ impl Shared {
})
}
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
self.workspace.read().clone()
}
@@ -358,8 +372,22 @@ impl Shared {
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
prompt.answer.send(answer).is_ok()
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
/// A window answered a [`CommitRequest`].
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
fn end_task(&self, id: u64) {
@@ -540,6 +568,91 @@ fn kill_group(child: &mut std::process::Child) {
ide_core::process::kill_tree(child);
}
impl PluginApi {
/// Sends `request` to `client`'s window and waits for its answer, until
/// `cancel` fires, the plugin stops or `timeout` passes.
fn ask_commit(
&self,
client: &str,
request: CommitRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<CommitAnswer, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
}
let request_id = id.to_string();
self.shared.sink.emit(
Some(client),
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
);
let deadline = Instant::now() + timeout;
loop {
match answers.recv_timeout(Duration::from_millis(100)) {
Ok(answer) => return Ok(answer),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(e) => {
self.shared.prompts.lock().remove(&id);
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
return Err(match e {
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
HostError::Failed("the window did not answer".into())
}
_ => HostError::Cancelled,
});
}
}
}
}
}
/// The changes `selection` commits, diffed repository by repository.
pub(super) fn commit_draft(
workspace: &dyn PluginWorkspace,
selection: CommitSelection,
cancel: &CancellationToken,
) -> Result<CommitDraft, HostError> {
let mut changes = Vec::new();
let mut budget = MAX_COMMIT_DIFF;
for repo in &selection.repositories {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
};
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
changes.push(CommitChange {
repository: root.clone(),
path: to_ipc(&c.path),
old_path: c.old_path.as_deref().map(to_ipc),
status: c.status.to_owned(),
diff: c.diff,
truncated: c.truncated,
});
}
}
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
@@ -577,7 +690,10 @@ impl HostApi for PluginApi {
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
}
let prompt_id = id.to_string();
self.shared.sink.emit(
@@ -597,6 +713,37 @@ impl HostApi for PluginApi {
}
}
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let client = from_window(client, "read the pending commit")?;
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
let root =
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
if !workspace.trusted(&root) {
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
}
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
match answer {
CommitAnswer::NoRepository => Ok(None),
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
}
}
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let client = from_window(client, "set the commit message")?;
let request = CommitRequest::SetMessage { revision, message };
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
CommitAnswer::Done => Ok(()),
CommitAnswer::Stale => Err(HostError::Stale),
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
}
}
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
let client = from_window(client, "reveal a view")?;
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
@@ -1112,6 +1259,15 @@ impl PluginWorkspace for HostWorkspace {
Ok(())
}
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
HostWorkspace::projects(self)
.into_iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.and_then(|p| p.vcs_encoding(path, None).ok())
.unwrap_or_else(|| encoding_rs::UTF_8.into())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
+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`).
mod api;
pub mod commit;
mod hover;
mod icon_extensions;
mod icon_patterns;
@@ -1366,6 +1367,67 @@ impl PluginHost {
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
}
/// A window answered a plugin's `CommitRequested`; false when the call no
/// longer waits.
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
}
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
/// what `selection` commits. A check that fails or isn't available is
/// reported with `error`, so the commit still stops for it.
pub fn commit_checks(
&self,
invocation: Invocation,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
let wanted: Vec<_> = checks
.into_iter()
.filter_map(|c| {
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
})
.collect();
if wanted.is_empty() {
return Ok(Vec::new());
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
HostError::Cancelled => IdeError::cancelled(),
HostError::Invalid(m) => IdeError::invalid(m),
e => IdeError::unavailable(e.to_string()),
})?;
let mut out = Vec::new();
for (c, title) in wanted {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let granted = self
.plugins
.read()
.get(&c.plugin_id)
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
let result = if granted {
let (check, draft) = (c.check.clone(), draft.clone());
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
rt.commit_check(&check, draft.clone(), inv)
})
.map_err(|e| e.message)
} else {
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
};
let (problems, error) = match result {
Ok(problems) => (problems, None),
Err(e) => (Vec::new(), Some(e)),
};
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
}
Ok(out)
}
/// Kinds of workbench events some running or activatable plugin wants;
/// the UI reports only these.
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
+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::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
Ok(())
}
+22 -1
View File
@@ -15,7 +15,7 @@
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
//! `plugin_service_log`)
//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
//! carry the desktop commands' names and arguments, so a remote window's UI
//! routes them here unchanged. Plugin results come back as `plugin`
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
@@ -122,6 +122,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
)
}
@@ -422,6 +423,26 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_answer_commit", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_commit_checks", move |c| {
let h = h.clone();
async move {
super::commit::commit_checks(
&c.host,
alive(&h)?,
&c.window,
c.arg("selection")?,
c.arg("checks")?,
c.arg("request")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
+70
View File
@@ -232,6 +232,55 @@ pub enum PluginEvent {
/// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String },
/// A command needs the window's Commit view: answer with
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
#[serde(rename_all = "camelCase")]
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
}
/// What a plugin asks of a window's Commit view.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitRequest {
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
/// editors of the included files are saved first.
Selection,
/// Replace the message if the draft is still at `revision`.
SetMessage { revision: u64, message: String },
}
/// A window's answer to a [`CommitRequest`].
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitAnswer {
/// The window's project has no repository.
NoRepository,
Selection {
selection: CommitSelection,
},
/// The message was replaced.
Done,
/// The draft is no longer at the revision.
Stale,
}
/// What a window's Commit view would commit: per repository, the options
/// `vcs_commit` would get.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelection {
/// Changes whenever the message, the included changes or Amend do.
pub revision: u64,
pub message: String,
pub amend: bool,
pub repositories: Vec<CommitSelectionRepo>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelectionRepo {
pub root: ide_core::IpcPath,
pub options: ide_vcs::CommitOptions,
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
@@ -300,3 +349,24 @@ pub struct LineMarkerInfo {
/// Opened on click.
pub target: Option<ide_core::Location>,
}
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckRef {
pub plugin_id: String,
pub check: String,
}
/// What a plugin's before-commit check found.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckResult {
pub plugin_id: String,
pub check: String,
/// The check's declared title.
pub title: String,
pub problems: Vec<ide_plugin::CommitProblem>,
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
pub error: Option<String>,
}
+36 -2
View File
@@ -5,7 +5,7 @@
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
//! command = "check"
//! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context
@@ -42,6 +42,10 @@
//! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg"
//!
//! [[contributes.commitChecks]]
//! id = "conventional"
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
//!
//! [[contributes.fileTemplates]]
//! id = "drawing"
//! name = "Excalidraw Drawing"
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
const VIEW_ITEM_MENU: &str = "viewItem.context";
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
/// The toolbar above the commit message (Commit tool window and dialog).
const COMMIT_MESSAGE_MENU: &str = "commit.message";
const MENU_IDS: [&str; 6] =
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
@@ -122,6 +130,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if d.menu == COMMIT_MESSAGE_MENU {
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
}
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
@@ -554,6 +565,29 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
Ok(out)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CommitCheckDecl {
id: String,
title: String,
}
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
/// `commit-checker` export, listed in Commit Options.
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
validate_item_id(&d.id, "commit check id")?;
title(COMMIT_CHECKS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
}
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
#[cfg(test)]
mod tests {
use super::*;
+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`.
use crate::types::{
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover, ProviderDocument, SemanticToken,
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
ProviderDocument, SemanticToken,
};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
@@ -15,8 +16,8 @@ use crate::types::{
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings,
syntax_bindings, views_bindings,
bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
services_bindings, syntax_bindings, views_bindings,
};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
@@ -33,6 +34,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
@@ -115,6 +117,22 @@ pub trait HostApi: Send + Sync {
Ok(Vec::new())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// The commit being prepared in `client`'s window (`None`: no repository),
/// waiting for the window until `cancel` fires.
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let _ = (client, cancel);
Ok(None)
}
/// Replaces the commit message of `client`'s window if its draft is still at
/// `revision` (else [`HostError::Stale`]).
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let _ = (client, revision, message);
Err(HostError::Invalid("no commit is being prepared".into()))
}
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
@@ -526,6 +544,7 @@ struct Exports {
semantic: Option<wit_sem::Guest>,
services: Option<wit_svc::Guest>,
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -608,6 +627,7 @@ impl Runtime {
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
@@ -638,9 +658,13 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let commit_checks = match commit_check_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover },
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
limits: setup.limits,
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.
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
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))
}
const MAX_COMMIT_PROBLEMS: usize = 50;
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
let (path, range) = match p.location {
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
None => (None, None),
};
CommitProblem { message: clip(p.message, 4096), path, range }
}
const MAX_SERVICE_TABS: usize = 8;
const MAX_LOG_TOGGLES: usize = 8;
const MAX_LOG_ARGS: usize = 256;
+34
View File
@@ -210,6 +210,40 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// A file a pending commit changes (WIT `commit.commit-change`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitChange {
pub repository: String,
pub path: String,
pub old_path: Option<String>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: String,
/// `git diff` of the included changes; `None` for binary content.
pub diff: Option<String>,
pub truncated: bool,
}
/// What Commit would commit (WIT `commit.commit-draft`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitDraft {
pub revision: u64,
pub message: String,
pub amend: bool,
pub changes: Vec<CommitChange>,
}
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitProblem {
pub message: String,
/// Absolute path of the file it is in, if any (IPC form).
pub path: Option<String>,
pub range: Option<ide_core::Range>,
}
/// Resource limits and supervision policy for plugin code.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
+75 -2
View File
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -69,6 +70,7 @@ pub(crate) mod providers_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -96,6 +98,7 @@ pub(crate) mod views_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
}
use bindings::openidea::plugin::{
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
vcs, views, workspace,
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
};
/// A project handle: its root.
@@ -571,6 +574,62 @@ impl vcs::Host for StoreData {
}
}
impl commit::Host for StoreData {
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
let client = self.invocation.client.clone();
let started = Instant::now();
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
}
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::VcsWrite) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
if message.len() > MAX_COMMIT_MESSAGE {
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
}
let client = self.invocation.client.clone();
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
}
}
/// Longest commit message a plugin may set.
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
commit::CommitDraft {
revision: d.revision,
message: d.message,
amend: d.amend,
changes: d
.changes
.into_iter()
.map(|c| commit::CommitChange {
repository: c.repository,
path: c.path,
old_path: c.old_path,
status: c.status,
diff: c.diff,
truncated: c.truncated,
})
.collect(),
}
}
impl events::Host for StoreData {
fn subscribe(&mut self, kind: String) -> Fallible<()> {
let Some(kind) = EventKind::parse(&kind) else {
@@ -828,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 {
wasmtime::component::bindgen!({
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 std::path::{Path, PathBuf};
pub use text_encoding::TextEncoding;
pub use write::PendingChange;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -1069,6 +1070,17 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::commit(self, message, options, &|path| encoding(path).map(Into::into))
}
/// What [`Repo::commit_with`] would commit with `options`, file by file, without
/// committing or touching the index: patches against HEAD, `max_bytes` in all.
pub fn pending_changes<E: Into<TextEncoding>>(
&self,
options: &CommitOptions,
max_bytes: usize,
encoding: &dyn Fn(&Path) -> IdeResult<E>,
) -> IdeResult<Vec<PendingChange>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::pending_changes(self, options, max_bytes, &|path| encoding(path).map(Into::into))
}
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
/// detached.
+140 -9
View File
@@ -372,10 +372,10 @@ pub(crate) fn commit(
args.extend(["-F", "-"]);
Some(message.as_bytes())
};
if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() {
run(r.root(), &args, stdin)?;
} else {
if selects(options) {
commit_selected(r, &args, stdin, options, encoding)?;
} else {
run(r.root(), &args, stdin)?;
}
// A commit concluding a merge brings back the local changes put aside for it.
let _ = crate::preserve::restore(r);
@@ -393,9 +393,13 @@ impl Drop for TempIndex {
}
}
/// Whether `options` commit something else than the index as it is.
fn selects(options: &CommitOptions) -> bool {
!(options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty())
}
/// Commits a selection of the changes through a copy of the index (`GIT_INDEX_FILE`):
/// `exclude` goes back to HEAD in the copy, `stage` takes its working-tree content and
/// `partial` only the chosen hunks. The working tree and the user's index are left alone
/// see [`select_into`]. The working tree and the user's index are left alone
/// except that the committed files' entries end up matching the new commit, so changes
/// left out keep their staged state.
fn commit_selected(
@@ -406,6 +410,23 @@ fn commit_selected(
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<()> {
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
let rels = select_into(r, &tmp, options, encoding)?;
cli::run_env(r.root(), commit_args, stdin, &env)?;
// The user's index still holds the partial files' previous entries: align them with
// the commit so the remaining changes show as unstaged, nothing as staged.
run_paths(r, &["reset", "-q", "HEAD"], &rels)
}
/// Fills `tmp`, a copy of the index, with what a commit of `options` holds: `exclude`
/// goes back to HEAD, `stage` takes its working-tree content and `partial` only the
/// chosen hunks. Returns the root-relative paths of `stage` and `partial`.
fn select_into(
r: &Repo,
tmp: &TempIndex,
options: &CommitOptions,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<Vec<String>> {
let index = r.git_dir.join("index");
if index.exists() {
std::fs::copy(&index, &tmp.0)?;
@@ -459,10 +480,120 @@ fn commit_selected(
write_index_blob(r, &rel, &bytes, &env)?;
rels.push(rel);
}
cli::run_env(r.root(), commit_args, stdin, &env)?;
// The user's index still holds the partial files' previous entries: align them with
// the commit so the remaining changes show as unstaged, nothing as staged.
run_paths(r, &["reset", "-q", "HEAD"], &rels)
Ok(rels)
}
/// The changes a commit of `options` would make, as `git diff` reports them (renames
/// detected), from HEAD (an empty tree without one). Nothing is committed and the
/// working tree and the index are left alone: a selection is made in a copy of the
/// index. Patches stop growing at `max_bytes` in all.
pub(crate) fn pending_changes(
r: &Repo,
options: &CommitOptions,
max_bytes: usize,
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
) -> IdeResult<Vec<PendingChange>> {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = TempIndex(r.git_dir.join(format!("openidea-pending-index-{}-{n}", std::process::id())));
let env_tmp = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
let env: &[(&str, &std::ffi::OsStr)] = if selects(options) {
select_into(r, &tmp, options, encoding)?;
&env_tmp
} else {
&[]
};
// `--cached` compares with HEAD, or with an empty tree on an unborn branch.
let base = ["diff", "--cached", "-M", "--no-ext-diff", "--no-textconv", "--no-color", "--no-relative"];
let names = cli::run_env(r.root(), &[&base[..], &["--name-status", "-z"]].concat(), None, env)?;
let patch = cli::run_env(
r.root(),
&[&base[..], &["--patch", "--src-prefix=a/", "--dst-prefix=b/", "--full-index"]].concat(),
None,
env,
)?;
let mut changes = parse_name_status(&names.stdout, r.root());
let sections = split_patch(&patch.stdout);
let mut budget = max_bytes;
for (change, section) in changes.iter_mut().zip(sections) {
if patch_is_binary(section) {
continue;
}
let charset = encoding(&change.path).unwrap_or_else(|_| encoding_rs::UTF_8.into());
let text = crate::text_encoding::decode_raw(section, charset);
if text.len() <= budget {
budget -= text.len();
change.diff = Some(text.into_owned());
} else {
let mut cut = budget.min(text.len());
// Whole lines only, at a character boundary.
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let kept = text[..cut].rfind('\n').map_or(0, |i| i + 1);
budget -= kept;
change.diff = Some(text[..kept].to_owned());
change.truncated = true;
}
}
Ok(changes)
}
/// A file a pending commit changes ([`Repo::pending_changes`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingChange {
pub path: PathBuf,
/// The path before a rename or copy.
pub old_path: Option<PathBuf>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: &'static str,
/// Its section of the unified diff; `None` for binary content.
pub diff: Option<String>,
/// `diff` was cut at the size limit.
pub truncated: bool,
}
/// `git diff --name-status -z`: `M\0path\0`, `R100\0old\0new\0`.
fn parse_name_status(out: &[u8], root: &Path) -> Vec<PendingChange> {
let mut fields = out.split(|b| *b == 0).filter(|f| !f.is_empty());
let path = |f: &[u8]| root.join(String::from_utf8_lossy(f).as_ref());
let mut changes = Vec::new();
while let Some(code) = fields.next() {
let status = match code.first() {
Some(b'A') => "added",
Some(b'D') => "deleted",
Some(b'R') => "renamed",
Some(b'C') => "copied",
Some(b'T') => "type-changed",
_ => "modified",
};
let two = matches!(code.first(), Some(b'R' | b'C'));
let first = fields.next().map(path);
let (old_path, path) = if two { (first, fields.next().map(path)) } else { (None, first) };
let Some(path) = path else { break };
changes.push(PendingChange { path, old_path, status, diff: None, truncated: false });
}
changes
}
/// The per-file sections of a patch, each starting at its `diff --git` line.
fn split_patch(patch: &[u8]) -> Vec<&[u8]> {
let mut starts: Vec<usize> = Vec::new();
let mut at = 0;
for line in patch.split_inclusive(|b| *b == b'\n') {
if line.starts_with(b"diff --git ") {
starts.push(at);
}
at += line.len();
}
starts.iter().enumerate().map(|(i, &s)| &patch[s..starts.get(i + 1).copied().unwrap_or(patch.len())]).collect()
}
fn patch_is_binary(section: &[u8]) -> bool {
section
.split(|b| *b == b'\n')
.take_while(|l| !l.starts_with(b"@@"))
.any(|l| l.starts_with(b"Binary files ") || l.starts_with(b"GIT binary patch"))
}
pub(crate) fn checkout(r: &Repo, branch: &str, create: bool) -> IdeResult<()> {
+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");
}
#[test]
fn pending_changes_show_what_the_commit_would_hold_without_touching_the_index() {
let fx = Fixture::new();
let base = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
fx.write("f.txt", base);
fx.write("staged.txt", "s\n");
fx.write("old.txt", "moved\nfile\nwith\nlines\n");
fx.write("bin.dat", "\0\x01");
fx.commit_all("base");
let repo = fx.repo();
let utf8 = |_: &Path| -> ide_core::IdeResult<&encoding_rs::Encoding> { Ok(encoding_rs::UTF_8) };
let edited = "A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
fx.write("f.txt", edited);
fx.write("staged.txt", "s2\n");
fx.git(&["add", "staged.txt"]);
fx.git(&["mv", "old.txt", "new.txt"]);
fx.write("bin.dat", "\0\x02");
fx.write("added.txt", "new\n");
let index_before = std::fs::read(fx.dir.join(".git/index")).unwrap();
// The index as it is: the staged file and the rename.
let plain = repo.pending_changes(&CommitOptions::default(), 1 << 20, &utf8).unwrap();
let summary: Vec<_> = plain
.iter()
.map(|c| (c.status, c.path.strip_prefix(&fx.dir).unwrap().to_string_lossy().into_owned()))
.collect();
assert_eq!(summary, [("renamed", "new.txt".to_owned()), ("modified", "staged.txt".to_owned())]);
assert_eq!(plain[0].old_path.as_deref(), Some(fx.path("old.txt").as_path()));
assert!(plain[1].diff.as_deref().unwrap().contains("-s\n+s2\n"), "{:?}", plain[1].diff);
// A selection: the first hunk of f.txt, the binary and the new file, staged.txt left out.
let hunks = diff_hunks(base, edited);
let options = CommitOptions {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
stage: vec![fx.path("bin.dat").into(), fx.path("added.txt").into()],
exclude: vec![fx.path("staged.txt").into()],
..Default::default()
};
let picked = repo.pending_changes(&options, 1 << 20, &utf8).unwrap();
let by_name = |name: &str| picked.iter().find(|c| c.path == fx.path(name)).unwrap_or_else(|| panic!("{name}"));
assert!(picked.iter().all(|c| c.path != fx.path("staged.txt")));
assert_eq!(by_name("added.txt").status, "added");
assert_eq!(by_name("bin.dat").diff, None);
let f = by_name("f.txt").diff.clone().unwrap();
assert!(f.starts_with("diff --git a/f.txt b/f.txt\n"), "{f}");
assert!(f.contains("-a\n+A\n") && !f.contains("+J"), "{f}");
// Nothing changed: the index, the working tree, no temporary index left behind.
assert_eq!(std::fs::read(fx.dir.join(".git/index")).unwrap(), index_before);
assert_eq!(fx.read("f.txt"), edited);
let leftovers: Vec<_> = std::fs::read_dir(fx.dir.join(".git"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("openidea-"))
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
// The size limit cuts at a line and says so.
let cut = repo.pending_changes(&options, 40, &utf8).unwrap();
let total: usize = cut.iter().filter_map(|c| c.diff.as_ref()).map(String::len).sum();
assert!(total <= 40 && cut.iter().any(|c| c.truncated));
assert!(cut.iter().filter_map(|c| c.diff.as_ref()).all(|d| d.is_empty() || d.ends_with('\n')));
}
#[test]
fn partial_staging_with_built_patch() {
let fx = Fixture::new();
+77 -32
View File
@@ -34,7 +34,7 @@ plugins/
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
└─ license-header/ # file-opened events, quick fix, project scan in a task
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, 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
@@ -137,23 +137,24 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| Point | Declaration | Code needed |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
views. It adds the commands to the right-click menu of the items in that
@@ -174,6 +175,10 @@ command = "kube.logs"
when = "viewItemKind == pod"
```
A `commit.message` menu puts the command, with the plugin's icon, in the
toolbar above the commit message of the Commit tool window and the Commit
dialog (see _Commits_). It is new in API 1.3 (`engines.openidea = "^1.3"`).
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
themes can be applied, settings pages are there — **before** any plugin code
@@ -227,20 +232,20 @@ installing (or when a new version asks for more). Low-risk capabilities are
listed; high-risk ones are called out as equivalent to running code on the
machine. Users can revoke optional capabilities in Settings › Plugins.
| Capability | Risk | Unlocks today |
| ------------------- | ------ | ----------------------------------------------------------------------------------- |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
| Capability | Risk | Unlocks today |
| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `editor.read` | low | `TextEditor::document`, `selections` |
| `editor.write` | medium | `TextEditor::apply_edits` |
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks |
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` |
| `storage` | low | `host::storage` (1 MiB per plugin) |
| `events` | low | file opened/saved, editor focused, repository changed events |
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
`process:` capabilities are also derived from what the contributions start: a
language's `language-servers` and `debugger` commands and a run detector's
@@ -349,6 +354,7 @@ then run `openidea-plugin check`.
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. |
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
@@ -395,6 +401,45 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
### Commits
A plugin can work on the commit the user is preparing, in the Commit tool
window or the Commit dialog (API 1.3, `engines.openidea = "^1.3"`):
- **A button above the message.** `[[contributes.menus]]` with
`menu = "commit.message"` shows one of the plugin's commands there, with the
plugin's icon.
- **The pending commit.** `host::pending_commit()` (`vcs.read`) returns what
Commit would commit right now: per file its repository, path, status and a
`git diff` of only the included changes (the checked files and changed
blocks, against HEAD; 1 MiB in all, then `truncated`), plus the message,
Amend and a `revision`. Unsaved editors of those files are saved first, as
Commit does; nothing is staged or committed. `None` without a repository.
- **The message.** `host::set_commit_message(revision, text)` (`vcs.write`)
replaces it only while the draft is still at `revision`. If the user typed
or changed the included changes meanwhile, it fails with `Error::Stale` and
nothing is overwritten. The user reviews the text and commits.
Both work from a command or event of a window, in a trusted project; they wait
for the window, which isn't charged to the time limit. A message generator
calls a model with `host::fetch` and a `{{secret:…}}` key (see _Network_)
inside a task:
```rust
let draft = host::pending_commit()?.ok_or("no repository")?;
let message = ask_the_model(&draft.changes)?;
host::set_commit_message(draft.revision, &message)?;
```
**Before-commit checks.** Each `[[contributes.commitChecks]]` (`id`, `title`)
is listed in Commit Options, on by default. When the user commits, after the
IDE's own checks, the IDE calls the optional `commit-checker` export
(`register_plugin!(MyPlugin, commit_checks)`, trait `commit::CommitChecks`)
with the check's id and the draft. Problems (`message`, `location?`) stop the
commit and show beside the message with the check's title, until the user
changes it or chooses Commit Anyway. A check that fails, times out or lacks
`vcs.read` is reported as a problem too.
### Providers
Declare what the plugin provides in the editor; only plugins whose
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)
}
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
wit::commit::pending_commit().map(|d| d.map(__draft_in)).map_err(error)
}
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
wit::commit::set_commit_message(revision, message).map_err(error)
}
#[doc(hidden)]
pub fn __draft_in(d: wit::commit::CommitDraft) -> CommitDraft {
CommitDraft {
revision: d.revision,
message: d.message,
amend: d.amend,
changes: d
.changes
.into_iter()
.map(|c| CommitChange {
repository: c.repository,
path: c.path,
old_path: c.old_path,
status: c.status,
diff: c.diff,
truncated: c.truncated,
})
.collect(),
}
}
pub fn subscribe(k: EventKind) -> Result<(), Error> {
wit::events::subscribe(k.name()).map_err(error)
}
@@ -427,6 +456,27 @@ impl Project {
}
}
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub use imp::__draft_in;
/// The commit being prepared in the window the command or event came from:
/// the included files and changed blocks only, as Commit would commit them
/// (unsaved editors of those files are saved first). `None` when the window's
/// project has no repository. Only from a command or event of a window, in a
/// trusted project. Needs `vcs.read`.
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
imp::pending_commit()
}
/// Replaces the commit message of that window, only while its draft is still
/// at `revision` ([`CommitDraft::revision`]); [`Error::Stale`] when the user
/// changed the message or the included changes meanwhile, and nothing changes.
/// Needs `vcs.write`.
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
imp::set_commit_message(revision, message)
}
/// Replaces the plugin's diagnostics for `path` (absolute, in an open
/// project); an empty slice clears them. Needs `project.read`.
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
+63
View File
@@ -39,6 +39,7 @@
//! Build with `openidea-plugin build` (or `cargo build --release --target
//! wasm32-wasip2`); see `plugins/README.md`.
pub mod commit;
pub mod host;
pub mod hover;
pub mod indexing;
@@ -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.
pub const API_VERSION: &str = "1.3";
@@ -230,6 +248,9 @@ macro_rules! __optional_exports {
(hover, $plugin:ty, $component:ident) => {
$crate::__hover_exports!($plugin, $component);
};
(commit_checks, $plugin:ty, $component:ident) => {
$crate::__commit_checks_exports!($plugin, $component);
};
(syntax, $plugin:ty, $component:ident) => {
impl $crate::syntax_bindings::exports::openidea::plugin::syntax_provider::Guest for $component {
fn highlights(
@@ -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)]
#[macro_export]
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)
}
pub fn commit_check<T: Plugin + crate::commit::CommitChecks + Default>(
check: String,
draft: b::commit::CommitDraft,
ctx: &b::invocation::Invocation,
) -> Result<
Vec<crate::commit_checks_bindings::exports::openidea::plugin::commit_checker::CommitProblem>,
b::types::Error,
> {
use crate::commit_checks_bindings::exports::openidea::plugin::commit_checker as w;
let ctx = Invocation::__new(ctx);
let draft = crate::host::__draft_in(draft);
with::<T, _>(|p| p.check(&check, &draft, &ctx))
.map(|problems| {
problems
.into_iter()
.map(|p| w::CommitProblem { message: p.message, location: p.location.map(loc) })
.collect()
})
.map_err(error_out)
}
pub fn view_event<T: Plugin + v::Views + Default>(
view: String,
event: vt::ViewEvent,
+42
View File
@@ -66,6 +66,7 @@ struct State {
tasks: Vec<MockTask>,
vcs: Option<RepositoryStatus>,
remotes: Vec<(String, String)>,
commit: Option<CommitDraft>,
fetches: u32,
fetch_error: Option<Error>,
clipboard: Option<String>,
@@ -250,6 +251,27 @@ impl MockHost {
self.state.borrow().revealed_views.clone()
}
/// The commit being prepared that [`crate::host::pending_commit`] returns
/// (`None`: no repository).
pub fn set_commit_draft(&self, draft: Option<CommitDraft>) {
self.state.borrow_mut().commit = draft;
}
/// The draft as the plugin left it: [`crate::host::set_commit_message`]
/// replaces the message and moves the revision on.
pub fn commit_draft(&self) -> Option<CommitDraft> {
self.state.borrow().commit.clone()
}
/// The user edits the draft: its revision moves on, so a later
/// [`crate::host::set_commit_message`] for the old one is stale.
pub fn edit_commit_message(&self, message: &str) {
if let Some(d) = self.state.borrow_mut().commit.as_mut() {
d.message = message.to_owned();
d.revision += 1;
}
}
/// Makes `vcs-fetch` fail.
pub fn fail_fetch(&self, error: Option<Error>) {
self.state.borrow_mut().fetch_error = error;
@@ -618,6 +640,26 @@ pub mod current {
})
}
pub fn pending_commit() -> Result<Option<CommitDraft>, Error> {
with(|s| {
require(s, "vcs.read")?;
Ok(s.commit.clone())
})
}
pub fn set_commit_message(revision: u64, message: &str) -> Result<(), Error> {
with(|s| {
require(s, "vcs.write")?;
let draft = s.commit.as_mut().ok_or_else(|| Error::Invalid("no repository".into()))?;
if draft.revision != revision {
return Err(Error::Stale);
}
draft.message = message.to_owned();
draft.revision += 1;
Ok(())
})
}
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
with(|s| {
let cap = match kind {
+29
View File
@@ -294,6 +294,35 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// A file the commit being prepared changes ([`crate::host::pending_commit`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitChange {
/// Absolute path of the working tree root of its repository.
pub repository: String,
/// Absolute path (after a rename, the new one).
pub path: String,
/// The path before a rename or copy.
pub old_path: Option<String>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: String,
/// The included changes in `git diff` format against HEAD; `None` for binary content.
pub diff: Option<String>,
/// `diff` was cut at the size limit (1 MiB for all the changes).
pub truncated: bool,
}
/// What Commit would commit right now.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitDraft {
/// Changes with the message, the included changes or Amend; pass it to
/// [`crate::host::set_commit_message`].
pub revision: u64,
pub message: String,
/// The commit replaces the last one; `changes` are still against HEAD.
pub amend: bool,
pub changes: Vec<CommitChange>,
}
/// A range in a file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Location {
+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 invocation;
import vcs;
import commit;
import events;
import tasks;
import diagnostics;
@@ -73,6 +74,12 @@ world plugin-services {
export service-provider;
}
/// A plugin with before-commit checks (`[[contributes.commitChecks]]`).
world plugin-commit-checks {
include plugin;
export commit-checker;
}
/// Only the `syntax-provider` export: guests generate it next to `plugin`
/// (the component tools merge both into one component).
world syntax {
@@ -106,3 +113,8 @@ world hover {
world services {
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_cancel_task,
plugin::plugin_answer_prompt,
plugin::plugin_answer_commit,
plugin::plugin_commit_checks,
plugin::plugin_view_render,
plugin::plugin_view_event,
plugin::plugin_service_detail,
+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)
}
/// A window's answer to a plugin's `commitRequested`; false when the call no
/// longer waits.
#[tauri::command]
#[specta::specta]
pub fn plugin_answer_commit(
state: State<'_, AppState>,
request_id: String,
answer: ide_host::plugins::CommitAnswer,
) -> bool {
state.plugins.answer_commit(&request_id, answer)
}
/// Runs plugins' before-commit checks on what `selection` commits, until
/// `request` is cancelled.
#[tauri::command]
#[specta::specta]
pub async fn plugin_commit_checks(
window: Window,
state: State<'_, AppState>,
selection: ide_host::plugins::CommitSelection,
checks: Vec<ide_host::plugins::PluginCommitCheckRef>,
request: ide_core::RequestId,
) -> IdeResult<Vec<ide_host::plugins::PluginCommitCheckResult>> {
let plugins = state.plugins.clone();
ide_host::plugins::commit::commit_checks(&state.host, plugins, window.label(), selection, checks, request).await
}
/// Gutter markers of plugin `line-markers` providers for an open document.
#[tauri::command]
#[specta::specta]
+79 -1
View File
@@ -1072,6 +1072,16 @@ export const commands = {
* (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed.
*/
pluginAnswerPrompt: (promptId: string, answer: string[] | null) => __TAURI_INVOKE<boolean>("plugin_answer_prompt", { promptId, answer }),
/**
* A window's answer to a plugin's `commitRequested`; false when the call no
* longer waits.
*/
pluginAnswerCommit: (requestId: string, answer: CommitAnswer) => __TAURI_INVOKE<boolean>("plugin_answer_commit", { requestId, answer }),
/**
* Runs plugins' before-commit checks on what `selection` commits, until
* `request` is cancelled.
*/
pluginCommitChecks: (selection: CommitSelection, checks: PluginCommitCheckRef[], request: RequestId) => typedError<PluginCommitCheckResult[], IdeError>(__TAURI_INVOKE("plugin_commit_checks", { selection, checks, request })),
/**
* Content of a plugin view (`doc`: the document of a custom editor; `editor`: for
* a tool window, the window's active editor, as commands get it).
@@ -2320,6 +2330,15 @@ export type CommandForm = {
export type Commit = Commit_Serialize | Commit_Deserialize;
/** A window's answer to a [`CommitRequest`]. */
export type CommitAnswer =
/** The window's project has no repository. */
{ kind: "noRepository" } | { kind: "selection"; selection: CommitSelection } |
/** The message was replaced. */
{ kind: "done" } |
/** The draft is no longer at the revision. */
{ kind: "stale" };
/** Commit Options (IDEA's commit options popup). */
export type CommitOptions = {
amend: boolean,
@@ -2340,6 +2359,41 @@ export type CommitOptions = {
exclude?: IpcPath[],
};
/** A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`). */
export type CommitProblem = {
message: string,
/** Absolute path of the file it is in, if any (IPC form). */
path: string | null,
range: Range | null,
};
/** What a plugin asks of a window's Commit view. */
export type CommitRequest =
/**
* What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
* editors of the included files are saved first.
*/
{ kind: "selection" } |
/** Replace the message if the draft is still at `revision`. */
{ kind: "setMessage"; revision: number; message: string };
/**
* What a window's Commit view would commit: per repository, the options
* `vcs_commit` would get.
*/
export type CommitSelection = {
/** Changes whenever the message, the included changes or Amend do. */
revision: number,
message: string,
amend: boolean,
repositories: CommitSelectionRepo[],
};
export type CommitSelectionRepo = {
root: IpcPath,
options: CommitOptions,
};
/** Version Control › Commit: commit checks and commit-message inspections. */
export type CommitSettings = {
/**
@@ -2361,6 +2415,8 @@ export type CommitSettings = {
bodyLimit?: number,
showRightMargin?: boolean,
wrapAtMargin?: boolean,
/** Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run. */
disabledPluginChecks?: string[],
};
export type Commit_Deserialize = {
@@ -5771,6 +5827,23 @@ export type PickItem = {
description: string | null,
};
/** A plugin's before-commit check to run (`[[contributes.commitChecks]]`). */
export type PluginCommitCheckRef = {
pluginId: string,
check: string,
};
/** What a plugin's before-commit check found. */
export type PluginCommitCheckResult = {
pluginId: string,
check: string,
/** The check's declared title. */
title: string,
problems: CommitProblem[],
/** The check could not run (the plugin failed, timed out or lacks `vcs.read`). */
error: string | null,
};
/** A plugin's context key for `when` clauses (`value` is JSON text). */
export type PluginContextKey = {
pluginId: string,
@@ -5838,7 +5911,12 @@ export type PluginEvent =
/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */
{ type: "promptClosed"; promptId: string } |
/** Bring the tool window of the plugin's view `view` to the front. */
{ type: "revealView"; pluginId: string; view: string };
{ type: "revealView"; pluginId: string; view: string } |
/**
* A command needs the window's Commit view: answer with
* `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
*/
{ type: "commitRequested"; pluginId: string; requestId: string; request: CommitRequest };
/** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */
export type PluginIcon = {
+14
View File
@@ -8,12 +8,16 @@ import type {
ActiveTheme,
CapabilityInfo,
CatalogPlugin,
CommitAnswer,
CommitSelection,
Consent,
EventKind,
InstalledIntellijTheme,
InstallPreview,
IntellijThemeSource,
MarketPlugin,
PluginCommitCheckRef,
PluginCommitCheckResult,
PluginContextKey,
PluginEditor,
PluginEvent,
@@ -68,6 +72,10 @@ export interface PluginsBackend {
cancelTask(taskId: string): Promise<void>;
/** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */
answerPrompt(promptId: string, answer: string[] | null): Promise<void>;
/** This window's answer to a plugin's `commitRequested`. */
answerCommit(requestId: string, answer: CommitAnswer): Promise<void>;
/** Runs plugins' before-commit checks on what `selection` commits. */
commitChecks(selection: CommitSelection, checks: PluginCommitCheckRef[]): Promise<PluginCommitCheckResult[]>;
/** Marketplace plugins: the index read again (`refresh`) or the last one read. */
market(refresh: boolean): Promise<MarketPlugin[]>;
marketUrl(): Promise<string | null>;
@@ -187,6 +195,10 @@ export const tauriPluginsBackend: PluginsBackend = {
answerPrompt: async (promptId, answer) => {
await commands.pluginAnswerPrompt(promptId, answer);
},
answerCommit: async (requestId, answer) => {
await commands.pluginAnswerCommit(requestId, answer);
},
commitChecks: (selection, checks) => call(commands.pluginCommitChecks(selection, checks, nextRequestId())),
market: (refresh) => call(commands.pluginMarket(refresh)),
marketUrl: () => commands.pluginMarketUrl(),
setMarketUrl: async (url) => {
@@ -569,6 +581,8 @@ export function createMemoryPluginsBackend(
}),
viewEvent: async () => {},
serviceDetail: async () => [],
answerCommit: async () => {},
commitChecks: async () => [],
serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => {
onError(`${pluginId} · ${view} · ${key}: logs need the desktop app`);
return () => {};
+41 -2
View File
@@ -5,7 +5,16 @@
* batches.
*/
import { create } from "zustand";
import type { EventKind, PluginContextKey, PluginEvent, Prompt, StatusItem, WorkbenchEvent } from "@/bindings";
import type {
CommitAnswer,
CommitRequest,
EventKind,
PluginContextKey,
PluginEvent,
Prompt,
StatusItem,
WorkbenchEvent,
} from "@/bindings";
import { onEditorEvent } from "@/editor/api";
import { executeAction, getAction, onActionExecuted } from "@/platform/actions";
import { clearContext, setContext, type ContextValue } from "@/platform/context";
@@ -14,7 +23,12 @@ import { openExternalUrl } from "@/platform/openUrl";
import { useProject } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
import { maybeEditorService, registerStatusItem, toolWindowService } from "@/workbench/contributions";
import {
maybeCommitService,
maybeEditorService,
registerStatusItem,
toolWindowService,
} from "@/workbench/contributions";
import { pickItems, promptInput } from "@/workbench/inputDialogs";
import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
@@ -327,6 +341,28 @@ async function answerPrompt(promptId: string, prompt: Prompt) {
.catch(() => {});
}
/** Answers a plugin's request to this window's Commit view (`commit` API). */
async function answerCommit(requestId: string, request: CommitRequest) {
const commit = maybeCommitService();
let answer: CommitAnswer;
try {
if (request.kind === "selection") {
const selection = commit ? await commit.selection() : null;
answer = selection ? { kind: "selection", selection } : { kind: "noRepository" };
} else if (!commit) {
answer = { kind: "noRepository" };
} else {
answer = commit.setMessage(request.revision, request.message) ? { kind: "done" } : { kind: "stale" };
}
} catch {
// The selection could not be read (e.g. a file vanished): the plugin sees a stale draft.
answer = { kind: "stale" };
}
await pluginsBackend()
.answerCommit(requestId, answer)
.catch(() => {});
}
/** Handles the runtime UI events; returns whether `e` was one. */
export function handleRuntimeEvent(e: PluginEvent): boolean {
switch (e.type) {
@@ -370,6 +406,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
openPrompts.get(e.promptId)?.abort();
openPrompts.delete(e.promptId);
return true;
case "commitRequested":
void answerCommit(e.requestId, e.request);
return true;
case "revealView":
toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true);
return true;
+88 -3
View File
@@ -2,7 +2,8 @@
* Client side of the plugin UI points: declarations the
* backend validated, projected onto the workbench's own registries.
*
* - `menus`: editor / project view / editor tab context menus, and Tools in the main menu;
* - `menus`: editor / project view / editor tab context menus, Tools in the main menu, and the
* toolbar above the commit message;
* - `keymaps`: default shortcuts below the IDE's (a taken keystroke is dropped);
* - `statusBar`: items shown before the code runs (its `set-status-item` with the same id updates them);
* - `settings`: a page under Settings › Plugins generated from the schema;
@@ -10,7 +11,8 @@
* - `customEditors`: files opening in a plugin editor sharing the text document, in its own tab or
* beside the text editor;
* - `fileIcons`: icons for the files a plugin knows, whatever icon theme is in use;
* - `fileTemplates`: entries of the New menu.
* - `fileTemplates`: entries of the New menu;
* - `commitChecks`: before-commit checks listed in Commit Options, run by the plugin's code.
*/
import { useMemo } from "react";
import type { PluginInfo } from "@/bindings";
@@ -32,6 +34,7 @@ import { addContributedFileTemplate } from "@/features/templates/files/store";
import {
registerEditorKind,
registerFileEditorProvider,
registerCommitCheckProvider,
registerMenuGroup,
registerMenuSlotItem,
registerServiceProvider,
@@ -40,9 +43,11 @@ import {
registerToolWindow,
useSettingsForm,
type ContextMenuId,
type ContributedCommitProblem,
type ToolWindowAnchor,
} from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
import { pluginIconName } from "./pluginIcon";
import { usePlugins } from "./store";
import { pluginActionId } from "./points";
import { defineUiPoint } from "./projection";
@@ -66,7 +71,13 @@ import styles from "./PluginsPage.module.css";
// --- menus ------------------------------------------------------------------------
interface MenuDecl {
menu: "editor.context" | "projectView.context" | "editorTab.context" | "viewItem.context" | "tools";
menu:
| "editor.context"
| "projectView.context"
| "editorTab.context"
| "viewItem.context"
| "tools"
| "commit.message";
view?: string | null;
command: string;
when?: string | null;
@@ -118,11 +129,81 @@ function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () =
const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
return registerMenuSlotItem("pluginTools", { action, label: title });
}
if (decl.menu === "commit.message") {
const action = pluginActionId(pluginId, decl.command);
const label = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
return registerMenuSlotItem("commitMessage", {
action,
label,
icon: pluginIconName(pluginId, plugin.icon),
when: decl.when ?? null,
});
}
const key = `${++menuSeq}`;
contextItems.set(key, { pluginId, decl });
return () => void contextItems.delete(key);
}
// --- commit checks ----------------------------------------------------------------
interface CommitCheckDecl {
id: string;
title: string;
}
/** Declared before-commit checks by `<plugin id>/<check id>`. */
const commitChecks = new Map<string, { pluginId: string; check: string; title: string }>();
let unregisterCommitChecks: (() => void) | null = null;
/** (Re)publishes the checks, so Commit Options sees the change. */
function publishCommitChecks() {
unregisterCommitChecks?.();
unregisterCommitChecks = null;
if (!commitChecks.size) return;
const snapshot = [...commitChecks.entries()];
unregisterCommitChecks = registerCommitCheckProvider({
checks: () => snapshot.map(([key, c]) => ({ key, title: c.title })),
run: async (selection, keys) => {
const refs = keys.flatMap((k) => {
const c = commitChecks.get(k);
return c ? [{ pluginId: c.pluginId, check: c.check }] : [];
});
const results = await pluginsBackend()
.commitChecks(selection, refs)
.catch((e: unknown) =>
refs.map((r) => ({
...r,
title: commitChecks.get(`${r.pluginId}/${r.check}`)?.title ?? r.check,
problems: [],
error: e instanceof Error ? e.message : String(e),
})),
);
return results.flatMap((r): ContributedCommitProblem[] =>
r.error !== null
? [{ title: r.title, message: couldNotRun(r.error) }]
: r.problems.map((p) => ({
title: r.title,
message: p.message,
path: p.path ?? undefined,
line: p.range?.start.line,
})),
);
},
});
}
const couldNotRun = (reason: string) => tx`The check could not run: ${reason}`;
function projectCommitCheck(pluginId: string, decl: CommitCheckDecl): () => void {
const key = `${pluginId}/${decl.id}`;
commitChecks.set(key, { pluginId, check: decl.id, title: decl.title });
publishCommitChecks();
return () => {
commitChecks.delete(key);
publishCommitChecks();
};
}
// --- keymaps ----------------------------------------------------------------------
interface KeymapDecl {
@@ -432,6 +513,10 @@ export function defineViewUiPoints(): () => void {
title: "File Icons",
project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl),
}),
defineUiPoint<CommitCheckDecl>("commitChecks", {
title: "Commit Checks",
project: ({ pluginId, decl }) => projectCommitCheck(pluginId, decl),
}),
defineUiPoint<FileTemplateDecl>("fileTemplates", {
title: "File Templates",
project: ({ pluginId, decl }) =>
+25 -1
View File
@@ -7,7 +7,8 @@ import type { ReactNode, RefObject } from "react";
import { executeAction } from "@/platform/actions";
import { call, commands, isTauri } from "@/platform/ipc";
import { loadSettings } from "@/platform/project";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useWorkflowSettings, workflowSettings } from "@/platform/workflowSettings";
import { useCommitCheckProviders } from "@/workbench/contributions";
import { Checkbox, Input, Popup, T } from "@/ui";
import { useCommit } from "./commitStore";
import styles from "./CommitView.module.css";
@@ -24,6 +25,19 @@ async function setCheck(key: CommitCheck, value: boolean) {
}
}
async function setContributedCheck(key: string, on: boolean) {
if (!isTauri()) return;
const off = new Set(workflowSettings().vcs.commit.disabledPluginChecks);
if (on) off.delete(key);
else off.add(key);
try {
await call(commands.settingsSet("global", "vcs.commit.disabledPluginChecks", JSON.stringify([...off].sort())));
await loadSettings();
} catch {
// keep the previous value
}
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className={styles.optSection}>
@@ -49,6 +63,7 @@ export function CommitOptionsBody() {
const signOff = useCommit((s) => s.signOff);
const runHooks = useCommit((s) => s.runHooks);
const c = useWorkflowSettings((s) => s.vcs.commit);
const contributed = useCommitCheckProviders((s) => s.providers).flatMap((p) => p.checks());
const check = (key: CommitCheck, label: string, extra?: ReactNode) => (
<div className={styles.optRow}>
<Checkbox checked={c[key]} onChange={(v) => void setCheck(key, v)} label={label} />
@@ -80,6 +95,15 @@ export function CommitOptionsBody() {
<Section title="Advanced Commit Checks">
{check("analyzeCode", "Analyze code")}
{check("checkTodo", "Check TODO", <Configure />)}
{contributed.map((k) => (
<div key={k.key} className={styles.optRow}>
<Checkbox
checked={!c.disabledPluginChecks.includes(k.key)}
onChange={(v) => void setContributedCheck(k.key, v)}
label={k.title}
/>
</div>
))}
</Section>
</>
);
+18 -1
View File
@@ -33,7 +33,8 @@ import {
type TreeHandle,
T,
} from "@/ui";
import { editorService } from "@/workbench/contributions";
import { editorService, useMenuSlotItems } from "@/workbench/contributions";
import { evaluateWhen, useContextKeys } from "@/platform/context";
import { useToolWindowLayout } from "@/workbench/toolwindows/store";
import {
aggregateCheck,
@@ -931,6 +932,7 @@ export function CommitMessageField({ included, runner }: { included: number; run
},
}))}
/>
<CommitMessageActions />
<span className={styles.grow} />
{tooLong && (
<span className={clsx(styles.lineLen, styles.lineLenWarn)} title={tx("Subject line length")}>
@@ -999,6 +1001,21 @@ export function CommitMessageField({ included, runner }: { included: number; run
);
}
/** Actions contributed above the message (a plugin's `commit.message` menu items). */
function CommitMessageActions() {
const items = useMenuSlotItems((s) => s.slots.commitMessage);
useContextKeys();
return (
<>
{(items ?? [])
.filter((i) => evaluateWhen(i.when ?? undefined))
.map((i) => (
<IconButton key={i.action} icon={i.icon ?? "plugin"} action={i.action} actionArg={i.arg} size="small" />
))}
</>
);
}
/** Label of the Commit button. */
export function commitLabel(amend: boolean, problems: boolean): string {
return problems ? "Commit Anyway" : amend ? "Amend Commit" : "Commit";
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const commit = vi.hoisted(() => vi.fn());
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data });
return {
...real,
isTauri: () => false,
call: async (p: Promise<{ status: "ok"; data: unknown }>) => (await p).data,
commands: { ...real.commands, vcsCommit: commit.mockImplementation(() => ok("commit1")) },
};
});
import type { CommitSelection, FileStatus } from "@/bindings";
import { maybeCommitService, registerCommitCheckProvider } from "@/workbench/contributions";
import { commitChanges } from "../changeOps";
import { draftRevision, installDraftRevision, provideCommit } from "../commitSelection";
import { useCommit } from "../commitStore";
import { useVcs } from "../store";
const files: FileStatus[] = ["a", "b"].map((n) => ({
path: `/p/${n}.ts`,
relPath: `${n}.ts`,
staged: null,
unstaged: "modified",
renamedFrom: null,
}));
let disposers: Array<() => void> = [];
beforeEach(() => {
localStorage.clear();
commit.mockClear();
useVcs.setState({
available: false,
root: "/p",
repo: "/p",
roots: ["/p"],
status: files,
branch: null,
operations: {},
});
useCommit.setState({
message: "feat: x",
amend: false,
checks: new Map([["/p/b.ts", "none" as const]]),
excludedHunks: new Map(),
checkProblems: [],
});
disposers = [installDraftRevision(), provideCommit()];
});
afterEach(() => disposers.reverse().forEach((d) => d()));
describe("the commit plugins see", () => {
it("is what Commit would commit, and the message is replaced only at its revision", async () => {
const service = maybeCommitService()!;
const selection = (await service.selection())!;
expect(selection.message).toBe("feat: x");
expect(selection.repositories).toEqual([
{ root: "/p", options: expect.objectContaining({ stage: ["/p/a.ts"], exclude: [], partial: [] }) },
]);
// The user types meanwhile: the plugin's message would overwrite it.
useCommit.setState({ message: "feat: typed" });
expect(service.setMessage(selection.revision, "generated")).toBe(false);
expect(useCommit.getState().message).toBe("feat: typed");
expect(service.setMessage(draftRevision(), "generated")).toBe(true);
expect(useCommit.getState().message).toBe("generated");
// Including another file is a new draft too.
const before = draftRevision();
useCommit.setState({ checks: new Map() });
expect(draftRevision()).toBeGreaterThan(before);
});
it("is null without a repository", async () => {
useVcs.setState({ root: null });
expect(await maybeCommitService()!.selection()).toBeNull();
expect(maybeCommitService()!.setMessage(draftRevision(), "x")).toBe(false);
});
});
describe("contributed before-commit checks", () => {
it("stop the commit with their problems, and run on what it commits", async () => {
const seen: Array<{ selection: CommitSelection; keys: string[] }> = [];
disposers.push(
registerCommitCheckProvider({
checks: () => [{ key: "acme/markers", title: "Markers" }],
run: async (selection, keys) => {
seen.push({ selection, keys });
return [{ title: "Markers", message: "Remove the marker.", path: "/p/a.ts", line: 3 }];
},
}),
);
expect(await commitChanges()).toBeUndefined();
expect(commit).not.toHaveBeenCalled();
expect(seen).toHaveLength(1);
expect(seen[0]!.keys).toEqual(["acme/markers"]);
expect(seen[0]!.selection.repositories[0]!.options.stage).toEqual(["/p/a.ts"]);
const [problem] = useCommit.getState().checkProblems;
expect(problem).toMatchObject({ title: "Markers", text: "Remove the marker.", details: { label: "a.ts:4" } });
// Commit Anyway: the shown problems are not asked again.
expect(await commitChanges()).toBe("commit1");
expect(seen).toHaveLength(1);
});
});
+20 -60
View File
@@ -1,18 +1,17 @@
/** Local change operations: commit, rollback, add, stash. */
import { useEffect, useState } from "react";
import type { FileStatus, PartialCommitFile, Stash } from "@/bindings";
import type { FileStatus, Stash } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, T, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { commitRepos, groupOf, planCommit, remainingOverrides, renamedFromPath } from "./changes";
import { useChangelists, withListDefaults } from "./changelists";
import { groupOf, remainingOverrides, renamedFromPath } from "./changes";
import { runCommitChecks } from "./commitChecks";
import { contributedCheckProblems, currentPlan, selectionOf } from "./commitSelection";
import { forgetAmendDraft, keepMessageAfterCommit, rememberMessage, useCommit } from "./commitStore";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifyFileOperation } from "@/platform/fileOps";
import { confirmDialog, openDialog } from "./dialogs";
import { hunkKeys } from "./hunks";
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, rootOf, stripSlash } from "./model";
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, stripSlash } from "./model";
import { logConsole, runOp, saveAll } from "./ops";
import { currentRepo, loadStashes, refreshNow, useVcs } from "./store";
import styles from "./common.module.css";
@@ -29,21 +28,14 @@ export function firstLine(message: string): string {
/** Stages the included changes and commits them. Returns the new commit id. */
export async function commitChanges(): Promise<string | undefined> {
const { message, amend, signOff, author, runHooks } = useCommit.getState();
const { message, amend } = useCommit.getState();
if (!message.trim() && !amend) {
notifications().notify({ group: "vcs", level: "warning", title: "Commit message is empty" });
return undefined;
}
await saveAll();
await refreshNow();
const status = useVcs.getState().status;
const checks = withListDefaults(
status,
useVcs.getState().root ?? "",
useChangelists.getState().state,
useCommit.getState().checks,
);
const plan = planCommit(status, checks);
const plan = currentPlan();
// Concluding a merge needs no further changes (e.g. every conflict resolved as "yours").
const merging = useVcs.getState().branch?.operation === "merge";
if (!plan.included.length && !amend && !merging) {
@@ -62,36 +54,29 @@ export async function commitChanges(): Promise<string | undefined> {
}
const candidates = plan.included.map((f) => ({ path: stripSlash(f.path), exists: effectiveKind(f) !== "deleted" }));
// IDEA: failed checks show in the commit panel; the next Commit ("Commit Anyway") skips them.
if (!useCommit.getState().checkProblems.length) {
const checked = !useCommit.getState().checkProblems.length;
if (checked) {
const problems = await runCommitChecks(candidates, amend);
if (problems.length) {
useCommit.setState({ checkProblems: problems });
return undefined;
}
}
const partial = await partialCommitFiles(plan.partial);
// Like IDEA, every repository with included changes gets a commit with the same message.
const roots = useVcs.getState().roots;
const repos = commitRepos(plan.included, roots, currentRepo());
// After the checks: their modifications (reformat…) are part of the commit.
const selection = await selectionOf(plan);
if (checked) {
const problems = await contributedCheckProblems(selection);
if (problems.length) {
useCommit.setState({ checkProblems: problems });
return undefined;
}
}
const id = await runOp(
amend ? "Amend commit" : "Commit",
async () => {
// The commit is made from a copy of the index, so changes left out keep their
// staged state and the other repositories' indexes stay as they are.
const stage = [...plan.stage, ...partial.whole];
let id = "";
for (const repo of repos) {
const inRepo = (path: string) => repos.length === 1 || (rootOf(path, roots) ?? null) === repo;
const options = {
amend,
signOff,
author: author.trim() || null,
runHooks,
partial: partial.files.filter((f) => inRepo(f.path)),
stage: stage.filter(inRepo),
exclude: plan.exclude.filter(inRepo),
};
id = await call(commands.vcsCommit(repo, message.trim(), options));
for (const repo of selection.repositories) {
id = await call(commands.vcsCommit(repo.root || null, message.trim(), repo.options));
}
return id;
},
@@ -99,7 +84,7 @@ export async function commitChanges(): Promise<string | undefined> {
success: () => {
const n = plan.included.length;
const summary = firstLine(message);
const several = repos.length;
const several = selection.repositories.length;
const done = amend
? several > 1
? n === 1
@@ -135,31 +120,6 @@ export async function commitChanges(): Promise<string | undefined> {
return id;
}
/**
* The included blocks of files committed partially, as hunk ranges against HEAD of the
* text on disk (saved just before). A file whose blocks are all included again (e.g. it
* was edited since) is committed whole.
*/
async function partialCommitFiles(paths: readonly string[]): Promise<{ files: PartialCommitFile[]; whole: string[] }> {
const files: PartialCommitFile[] = [];
const whole: string[] = [];
const excludedHunks = useCommit.getState().excludedHunks;
for (const path of paths) {
const [head, disk] = await Promise.all([call(commands.vcsFileAt(path, "HEAD")), call(commands.fsReadText(path))]);
const hunks = await commands.vcsDiffHunks(head ?? "", disk);
const keys = hunkKeys(disk, hunks);
const excluded = excludedHunks.get(path) ?? excludedHunks.get(`${path}/`);
const chosen = hunks.filter((_, i) => !excluded?.has(keys[i]!));
if (chosen.length === hunks.length) whole.push(path);
else
files.push({
path,
hunks: chosen.map(({ start, lines, oldStart, oldLines }) => ({ start, lines, oldStart, oldLines })),
});
}
return { files, whole };
}
/** Delete… on unversioned files in the Changes view, as in IDEA: they move to the Trash. */
/** Delete… in the Changes view: moves the files to the Trash (a tracked file then shows as deleted). */
export async function deleteFiles(paths: readonly string[]) {
+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 { installDiagnosticsTracking } from "./commitChecks";
import { installPreparedMessage, useCommit } from "./commitStore";
import { installDraftRevision, provideCommit } from "./commitSelection";
import { activate as activateConflictBanner } from "./conflictBanner";
import { activate as activateConflictMarkers } from "./conflictMarkers";
import { MergeRebaseWidget } from "./MergeRebaseWidget";
@@ -215,6 +216,8 @@ export function activate(): () => void {
installGitLog(),
installDiagnosticsTracking(),
installPreparedMessage(),
installDraftRevision(),
provideCommit(),
installIncomingCheck(),
installFileConfirmations(),
// 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": "从此处创建分支对话",
"Couldn't regenerate the response": "无法重新生成回复",
"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;
showRightMargin: boolean;
wrapAtMargin: boolean;
/** Plugin before-commit checks turned off (`<plugin id>/<check id>`). */
disabledPluginChecks: string[];
}
export interface VcsSettingsValid {
@@ -215,6 +217,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
bodyLimit: 72,
showRightMargin: true,
wrapAtMargin: false,
disabledPluginChecks: [],
},
confirmation: {
onCreate: "ask",
@@ -342,6 +345,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
bodyLimit: num(c.bodyLimit, d.commit.bodyLimit, L.messageLimit, true),
showRightMargin: bool(c.showRightMargin, d.commit.showRightMargin),
wrapAtMargin: bool(c.wrapAtMargin, d.commit.wrapAtMargin),
disabledPluginChecks: strings(c.disabledPluginChecks, d.commit.disabledPluginChecks),
},
confirmation: {
onCreate: oneOf(conf.onCreate, confirm, d.confirmation.onCreate),
+55 -3
View File
@@ -17,7 +17,7 @@ import {
type ReactNode,
type RefObject,
} 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 { DragData } from "@/ui/pointerDrag";
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. */
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
* their own drag types (e.g. an agent task dragged from the tool window) without the
@@ -980,13 +1027,18 @@ export type ContextMenuId =
| "new";
/**
* Entries of the native main menu's open slots (src-tauri/src/menu.rs
* `Entry::Dynamic`), e.g. `pluginTools` at the end of Tools: each runs `action`.
* 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) and in-app toolbars
* (`commitMessage`, above the commit message). Each runs `action`.
*/
export interface MenuSlotItem {
action: string;
arg?: string | null;
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: {} }));
+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 invocation;
import vcs;
import commit;
import events;
import tasks;
import diagnostics;
@@ -73,6 +74,12 @@ world plugin-services {
export service-provider;
}
/// A plugin with before-commit checks (`[[contributes.commitChecks]]`).
world plugin-commit-checks {
include plugin;
export commit-checker;
}
/// Only the `syntax-provider` export: guests generate it next to `plugin`
/// (the component tools merge both into one component).
world syntax {
@@ -106,3 +113,8 @@ world hover {
world services {
export service-provider;
}
/// Only the `commit-checker` export, generated next to `plugin` like `syntax`.
world commit-checks {
export commit-checker;
}