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