diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 3b31a576f..5549be1af 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -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 (`/`); the rest run. + pub disabled_plugin_checks: Vec, } 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(), } } } diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index e2f6977e9..5c379f866 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -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); @@ -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) -> 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 { @@ -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>>, + answer: Waiter, +} + +enum Waiter { + Prompt(std::sync::mpsc::SyncSender>>), + Commit(std::sync::mpsc::SyncSender), } /// State shared by the host and every plugin's [`PluginApi`]. @@ -211,7 +225,7 @@ impl Shared { }) } - fn workspace(&self) -> Option> { + pub(super) fn workspace(&self) -> Option> { 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>) -> 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 { + 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 { + 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, 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 { 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)) { diff --git a/crates/ide-host/src/plugins/commit.rs b/crates/ide-host/src/plugins/commit.rs new file mode 100644 index 000000000..4f3a26076 --- /dev/null +++ b/crates/ide-host/src/plugins/commit.rs @@ -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, + window: &str, + selection: CommitSelection, + checks: Vec, + request: RequestId, +) -> IdeResult> { + 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 +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 527142e71..be40ee1c5 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -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, + cancel: &ide_core::CancellationToken, + ) -> IdeResult> { + 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 { diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index 940ac19c2..53bbe5603 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -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(()) } diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 6815ca382..be67dd51a 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -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::("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::("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 { diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index 5090998a8..61b75abb8 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -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, +} + +#[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, } + +/// 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, + /// The check could not run (the plugin failed, timed out or lacks `vcs.read`). + pub error: Option, +} diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index b5bd0047e..71e28882e 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -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 = "" # 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 { 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 Result { + 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::*; diff --git a/crates/ide-host/tests/plugin_commit.rs b/crates/ide-host/tests/plugin_commit.rs new file mode 100644 index 000000000..095d10716 --- /dev/null +++ b/crates/ide-host/tests/plugin_commit.rs @@ -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", "user.email=t@example.com"]) + .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 { + vec![self.root.clone()] + } + fn buffer_text(&self, _: &Path) -> Option { + None + } + fn document(&self, _: &Path) -> Option<(u64, String)> { + None + } + fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result { + Err(HostError::NotFound("not open".into())) + } + fn trusted(&self, _: &Path) -> bool { + self.trusted.load(Ordering::Relaxed) + } + fn project_of(&self, _: &str) -> Option { + Some(self.root.clone()) + } + fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec) -> 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, + type_after_reading: bool, + requests: Vec, +} + +struct Fixture { + host: Arc, + window: Arc>, + workspace: Arc, + 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>>> = 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 { + 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); + } +} diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 085a63950..98b44c195 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -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, 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, HostError>; @@ -526,6 +544,7 @@ struct Exports { semantic: Option, services: Option, hover: Option, + commit_checks: Option, } /// 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, 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, max: usize) -> Option { 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; diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index f0d2989a9..ccd915739 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -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, + /// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`. + pub status: String, + /// `git diff` of the included changes; `None` for binary content. + pub diff: Option, + 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, +} + +/// 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, + pub range: Option, +} + /// Resource limits and supervision policy for plugin code. #[derive(Clone, Copy, Debug)] pub struct Limits { diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 43ee90b66..350f3d539 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -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> { + 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", diff --git a/crates/ide-plugin/tests/fixtures/commit/Cargo.lock b/crates/ide-plugin/tests/fixtures/commit/Cargo.lock new file mode 100644 index 000000000..a8964abce --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/commit/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/commit/Cargo.toml b/crates/ide-plugin/tests/fixtures/commit/Cargo.toml new file mode 100644 index 000000000..322fc7b87 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/commit/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/commit/README.md b/crates/ide-plugin/tests/fixtures/commit/README.md new file mode 100644 index 000000000..8b1271a5f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/commit/README.md @@ -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. diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm new file mode 100644 index 000000000..b016857f5 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources new file mode 100644 index 000000000..36c7fcb14 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -0,0 +1 @@ +a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42 diff --git a/crates/ide-plugin/tests/fixtures/commit/plugin.toml b/crates/ide-plugin/tests/fixtures/commit/plugin.toml new file mode 100644 index 000000000..f913857f1 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/commit/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/commit/src/lib.rs b/crates/ide-plugin/tests/fixtures/commit/src/lib.rs new file mode 100644 index 000000000..a9fc2d764 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/commit/src/lib.rs @@ -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, Error> { + if id != "describe" { + return Err(Error::NotFound(id.into())); + } + let draft = host::pending_commit()?.ok_or("no repository")?; + let names: Vec = 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, 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::().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); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 457c8acf2..d12cb4711 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index b05b93efe..28a0544c1 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -885badd4727639ad3205b2551d2b8406490fc05b72c684c5d17f09aadd9541bd +8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index 5ca3b5c9c..cbedffa95 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 5f1033926..5120eaa6f 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -ff1b39ac7850fbe4495e274dd78cca4c906a20705e8b9a975f58cf564e8ff137 +5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index a76441e22..9f55c84a7 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 7d1c7e22a..c300b8de5 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -c9bb4bc3603d9e7c770cf13ad413f5b3a787e78dbeed8f41a3b8537c0740275d +58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 43c324559..b4e3e8f27 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index fa9bc67f9..c88c09b42 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -a9cb7302b86e12e05cbbf87fb402609e2f88d3a463f6c6e96b254e643b76183d +0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index 1c84e19be..d6f2fc30e 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 8b5ca350f..69ff39593 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -0be22488fe8f20a59f48fc61089b3db791a5d723d71e4d654bfd008038c01003 +bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index fcaf30d35..c57f5e944 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 083d74781..a8f34c7e9 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -92ca920847c51036ac6dc7acd299ff9afaef9b4037e0112a0159bf50e822c7f3 +c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3 diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 07dd0cd89..9e59c7db3 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -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>( + &self, + options: &CommitOptions, + max_bytes: usize, + encoding: &dyn Fn(&Path) -> IdeResult, + ) -> IdeResult> { + 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. diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 6262afe7c..64958f0fb 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -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, ) -> 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, +) -> IdeResult> { 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, +) -> IdeResult> { + 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, + /// `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, + /// `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 { + 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 = 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<()> { diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index c691f2ace..e1e85ec1e 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -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(); diff --git a/plugins/README.md b/plugins/README.md index 337acad84..945f11ca9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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:plugin@1.3.0`, 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/.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/.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:` | high | `Project::run_process`, language servers, debug adapters, runs | -| `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | -| `secrets` | medium | `{{secret:}}` 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:` | high | `Project::run_process`, language servers, debug adapters, runs | +| `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | +| `secrets` | medium | `{{secret:}}` 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:`. | | `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 diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index 31eeb2c1d..3c75ed134 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index ee8de7458..9b826c938 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -9384d8964c7c05d3df06521e0d724a932fdd17608cbcede13ec2acadfab3783c +89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 1b5f253f3..b0ee5334e 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index dcbf2f781..dda99d364 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -9d7e857ef367eaa9748d1a2b05b7b45226c254aa578c29e1ed987c7353738acf +a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index ddf8435b6..bfb8ce3b9 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index f2b224e82..cae540e1e 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -3e11039d36306f51c728bf9d08030fe620de461fa5b0037700aea95c4d27714c +7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 860581e9e..285dacc0c 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 22b2d4551..4be824457 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -ef50957a407c9aff9999fdd197a5e72c90b7ba77aedbd7569408262ab7e158be +d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index fac2c1dd2..3006754db 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index f35411f09..315bc573b 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -7160b309f39fe973e67bb20d361384955aed6afe98f00db329c3026ffe923759 +511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index e1e98c0b9..7800303fc 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 6ace343a1..ffdfa4ac5 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -1f1afa285a64a7f7758f08a80306d6397c8aee4d2ac7ed43369e77aae61add04 +8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 65069c48a..024bf460b 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 9d7fa01ca..e01b11bf6 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -ddd9205da9fc947215ab90c505d8b443d0937b1dd11734e9ce94d277517c77a6 +6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 35a01268f..b24d3a634 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 7e3fadea1..24b6717fe 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -c61a0ec6ffcbd71e590e5a16a6f08f2d2772481497edc77e11914cfb3d0bfcd1 +43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 6354495ec..1f2cfa4b4 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 556c3a33c..bbadfc664 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -e11cd0b664ae058274808bc2b2152bac2738207979758d13bc0c0cd96c38ef4f +3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 13de9cab7..750876fee 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index da046972e..68ddffbe2 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -59319cf4be14448b0126b07d074cc08c351cfd12a3de26f111c2445fe8bff664 +588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 7bc58b5a0..688ab327c 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index c3e2eaa71..3f5453ccf 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -59844ae52aa765af404b43f7895db94d0063a3a6fce5bd54bf9af680ccac357b +723d68d4de1ec8b6f246b1bd30e56f177df08f7bf3c17907b0ec9ef6cbdf1bf3 diff --git a/sdk/rust/openidea-plugin/src/commit.rs b/sdk/rust/openidea-plugin/src/commit.rs new file mode 100644 index 000000000..2c2d5a870 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/commit.rs @@ -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, +} + +impl CommitProblem { + pub fn new(message: impl Into) -> 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, Error>; +} diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 76b998a19..a5aceb139 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -212,6 +212,35 @@ mod imp { wit::vcs::remotes(p).map_err(error) } + pub fn pending_commit() -> Result, 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, 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> { diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 61625dbbd..04643cd27 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -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/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, + "openidea:plugin/commit@1.3.0": 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::(|p| p.detail(&view, &key, &ctx)).map(|tabs| tabs.into_iter().map(tab).collect()).map_err(error_out) } + pub fn commit_check( + check: String, + draft: b::commit::CommitDraft, + ctx: &b::invocation::Invocation, + ) -> Result< + Vec, + 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::(|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( view: String, event: vt::ViewEvent, diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index aa4efa1c6..27c144a52 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -66,6 +66,7 @@ struct State { tasks: Vec, vcs: Option, remotes: Vec<(String, String)>, + commit: Option, fetches: u32, fetch_error: Option, clipboard: Option, @@ -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) { + 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 { + 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) { self.state.borrow_mut().fetch_error = error; @@ -618,6 +640,26 @@ pub mod current { }) } + pub fn pending_commit() -> Result, 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 { diff --git a/sdk/rust/openidea-plugin/src/types.rs b/sdk/rust/openidea-plugin/src/types.rs index 4e0eb1140..8538213a2 100644 --- a/sdk/rust/openidea-plugin/src/types.rs +++ b/sdk/rust/openidea-plugin/src/types.rs @@ -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, + /// `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, + /// `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, +} + /// A range in a file. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Location { diff --git a/sdk/rust/openidea-plugin/wit/commit.wit b/sdk/rust/openidea-plugin/wit/commit.wit new file mode 100644 index 000000000..a70b889c1 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/commit.wit @@ -0,0 +1,75 @@ +package openidea:plugin@1.3.0; + +/// 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, + /// `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, + /// `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, + } + + /// 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, 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, + } + + /// 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) -> result, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index f6d5970b1..d52196cac 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -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; +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 447cd5052..02f4ec7b8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -487,6 +487,8 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 33598f630..91587350a 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -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, + request: ide_core::RequestId, +) -> IdeResult> { + 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] diff --git a/src/bindings.ts b/src/bindings.ts index 5ff5d418f..51f925b70 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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("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("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(__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 (`/`); 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 = { diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index a0cbbc43a..ede2d0e8b 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -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; /** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */ answerPrompt(promptId: string, answer: string[] | null): Promise; + /** This window's answer to a plugin's `commitRequested`. */ + answerCommit(requestId: string, answer: CommitAnswer): Promise; + /** Runs plugins' before-commit checks on what `selection` commits. */ + commitChecks(selection: CommitSelection, checks: PluginCommitCheckRef[]): Promise; /** Marketplace plugins: the index read again (`refresh`) or the last one read. */ market(refresh: boolean): Promise; marketUrl(): Promise; @@ -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 () => {}; diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index 45ddd8a49..0951cbca3 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -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; diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index a3eaa494e..bbd89295b 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -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 `/`. */ +const commitChecks = new Map(); +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("commitChecks", { + title: "Commit Checks", + project: ({ pluginId, decl }) => projectCommitCheck(pluginId, decl), + }), defineUiPoint("fileTemplates", { title: "File Templates", project: ({ pluginId, decl }) => diff --git a/src/features/vcs/CommitOptions.tsx b/src/features/vcs/CommitOptions.tsx index 255469fbb..9c0799689 100644 --- a/src/features/vcs/CommitOptions.tsx +++ b/src/features/vcs/CommitOptions.tsx @@ -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 (
@@ -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) => (
void setCheck(key, v)} label={label} /> @@ -80,6 +95,15 @@ export function CommitOptionsBody() {
{check("analyzeCode", "Analyze code")} {check("checkTodo", "Check TODO", )} + {contributed.map((k) => ( +
+ void setContributedCheck(k.key, v)} + label={k.title} + /> +
+ ))}
); diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index d9beae481..e6498a6f8 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -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 }, }))} /> + {tooLong && ( @@ -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) => ( + + ))} + + ); +} + /** Label of the Commit button. */ export function commitLabel(amend: boolean, problems: boolean): string { return problems ? "Commit Anyway" : amend ? "Amend Commit" : "Commit"; diff --git a/src/features/vcs/__tests__/pluginCommit.test.ts b/src/features/vcs/__tests__/pluginCommit.test.ts new file mode 100644 index 000000000..cb2fa5821 --- /dev/null +++ b/src/features/vcs/__tests__/pluginCommit.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index 53b6df13d..581969258 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -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 { - 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 { } 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 { 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 { 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[]) { diff --git a/src/features/vcs/commitSelection.ts b/src/features/vcs/commitSelection.ts new file mode 100644 index 000000000..81aa79f91 --- /dev/null +++ b/src/features/vcs/commitSelection.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 3d4b7889c..a2a7d369f 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -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. diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b1d785488..0d81d3d97 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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}" } diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 559d20d9b..5e1caa97b 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -50,6 +50,8 @@ export interface CommitSettingsValid { bodyLimit: number; showRightMargin: boolean; wrapAtMargin: boolean; + /** Plugin before-commit checks turned off (`/`). */ + 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), diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 4b359b438..a7f3019c6 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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; + /** 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 (`/`), 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; +} + +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 }>(() => ({ slots: {} })); diff --git a/wit/commit.wit b/wit/commit.wit new file mode 100644 index 000000000..a70b889c1 --- /dev/null +++ b/wit/commit.wit @@ -0,0 +1,75 @@ +package openidea:plugin@1.3.0; + +/// 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, + /// `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, + /// `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, + } + + /// 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, 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, + } + + /// 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) -> result, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index f6d5970b1..d52196cac 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -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; +}