mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc078b93b | ||
|
|
a36c7ed3d5 | ||
|
|
50567473f7 | ||
|
|
de4c325a28 | ||
|
|
a4a3c2105f | ||
|
|
6efff739dd | ||
|
|
8ca6e4bc01 | ||
|
|
8d909598d5 | ||
|
|
b99fe1704b | ||
|
|
159c2d6fd2 | ||
|
|
5084388a68 | ||
|
|
68342b32bf | ||
|
|
352efc02a7 | ||
|
|
24ffb9f16e | ||
|
|
8ebf839d8e | ||
|
|
14a31fe8a1 | ||
|
|
1af1744290 | ||
|
|
ee6786d573 | ||
|
|
5804fcad16 | ||
|
|
7a0c9354fa | ||
|
|
7e7da950fc | ||
|
|
9298be8798 | ||
|
|
715ebf19e2 | ||
|
|
438ab158ad | ||
|
|
7965d171f0 |
No files matched your search
Generated
+1
@@ -4216,6 +4216,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ memchr = "2"
|
||||
portable-pty.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
|
||||
@@ -301,6 +301,8 @@ pub struct DebugManager {
|
||||
exception_choices: Mutex<BTreeMap<String, bool>>,
|
||||
function_breakpoints: Mutex<Vec<FunctionBreakpoint>>,
|
||||
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
|
||||
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
|
||||
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
@@ -587,6 +589,7 @@ impl DebugManager {
|
||||
exception_choices: Mutex::default(),
|
||||
function_breakpoints: Mutex::default(),
|
||||
data_breakpoints: Mutex::default(),
|
||||
stopping: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -994,15 +997,62 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
|
||||
// ends that DAP session, so every line the program writes while shutting down is lost.
|
||||
// Ask the program to exit ourselves and keep the adapter attached until it has drained
|
||||
// the console (`terminated`), as IDEA does with the process it owns.
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
let own_terminate = terminate && s.request == "launch" && pid > 0;
|
||||
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
|
||||
if !s.conn.is_closed() {
|
||||
if terminate && s.request == "launch" && s.supports("supportsTerminateRequest") {
|
||||
if graceful {
|
||||
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
|
||||
}
|
||||
if !graceful && !own_terminate {
|
||||
let _ = s
|
||||
.conn
|
||||
.request_timeout(
|
||||
"disconnect",
|
||||
json!({ "restart": false, "terminateDebuggee": s.request == "launch" }),
|
||||
SHUTDOWN_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if own_terminate && !graceful {
|
||||
// Signal before anything else: waiting on a request the adapter may not answer
|
||||
// would delay the shutdown the console is supposed to show.
|
||||
crate::session::signal_debuggee(s, false);
|
||||
}
|
||||
if graceful {
|
||||
// java-debug sends `terminated` only after it has drained the program's console
|
||||
// (it waits up to 5 s for the output pump). Close on the process id alone and the
|
||||
// last shutdown lines are dropped, so wait for that event here.
|
||||
let deadline = std::time::Instant::now() + crate::session::DEBUGGEE_EXIT_WAIT;
|
||||
while !s.terminated.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
// An adapter answers `disconnect` before the program it launched has exited, and that
|
||||
// program keeps its port until it is gone. Reporting the session as terminated then
|
||||
// lets Run start again into an occupied port, so wait for the program first — with the
|
||||
// connection still open, so a second Stop can find the session and kill it.
|
||||
crate::session::wait_for_debuggee(s, terminate).await;
|
||||
if terminate && s.request == "launch" {
|
||||
// The adapter reports `terminated` only after it has drained the program's console
|
||||
// (java-debug waits up to 5 s for the pump): closing before that drops the last
|
||||
// shutdown lines from the console.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(6);
|
||||
while !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() && std::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
if graceful && !s.conn.is_closed() {
|
||||
let _ = s
|
||||
.conn
|
||||
.request_timeout(
|
||||
"disconnect",
|
||||
json!({ "restart": false, "terminateDebuggee": s.request == "launch" }),
|
||||
json!({ "restart": false, "terminateDebuggee": false }),
|
||||
SHUTDOWN_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
@@ -1577,10 +1627,40 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a session and its child sessions.
|
||||
/// Stops every debug session started from `root` or below — closing a project stops the
|
||||
/// programs it launched even when the session's owner key does not name the window doing
|
||||
/// the closing (window groups, auxiliary windows).
|
||||
pub async fn stop_under(&self, root: &Path) {
|
||||
let ids: Vec<DebugSessionId> =
|
||||
self.live_sessions().into_iter().filter(|s| s.spec.cwd.starts_with(root)).map(|s| s.id).collect();
|
||||
tracing::info!(root = %root.display(), sessions = ids.len(), "closing project: stopping debug sessions");
|
||||
for id in ids {
|
||||
if let Err(e) = self.stop(id).await {
|
||||
tracing::debug!("debug session {id:?} did not stop: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a session and its child sessions. A second Stop while the first one waits kills
|
||||
/// the program instead of waiting for it — even after the adapter dropped the session.
|
||||
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
{
|
||||
let in_flight = self.stopping.lock();
|
||||
if let Some(session) = in_flight.get(&s) {
|
||||
session.stop_force.store(true, Ordering::Release);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let session = self.session(s)?;
|
||||
let children: Vec<Arc<Session>> = self.live_sessions().into_iter().filter(|c| c.parent == Some(s)).collect();
|
||||
self.stopping.lock().insert(s, session.clone());
|
||||
let result = self.stop_tree(session).await;
|
||||
self.stopping.lock().remove(&s);
|
||||
result
|
||||
}
|
||||
|
||||
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
|
||||
let children: Vec<Arc<Session>> =
|
||||
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
|
||||
for child in children {
|
||||
self.shutdown(&child, true).await;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
use tokio::process::Child;
|
||||
@@ -49,6 +49,14 @@ pub(crate) struct Session {
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub terminated: AtomicBool,
|
||||
/// Set by a second Stop: the one waiting for the program kills it instead.
|
||||
pub stop_force: AtomicBool,
|
||||
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
|
||||
pub debuggee_pid: AtomicI64,
|
||||
/// Pids signalled for the current stop: the reported pid and everything it started (an
|
||||
/// adapter may report the launcher shell, whose child is the program).
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
@@ -96,6 +104,9 @@ impl Session {
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
stop_pids: parking_lot::Mutex::new(Vec::new()),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
@@ -275,6 +286,108 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long Stop waits for a launched program to exit before reporting the session ended.
|
||||
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
fn process_tree(pid: i32) -> Vec<i32> {
|
||||
use std::collections::HashMap;
|
||||
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
|
||||
return vec![pid];
|
||||
};
|
||||
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
|
||||
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
|
||||
children.entry(parent).or_default().push(p);
|
||||
}
|
||||
let mut tree = vec![pid];
|
||||
let mut at = 0;
|
||||
while at < tree.len() {
|
||||
if let Some(kids) = children.get(&tree[at]) {
|
||||
tree.extend(kids.iter().copied());
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
|
||||
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
|
||||
/// tree, because the reported pid may die before its program does.
|
||||
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
if pid <= 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut known = s.stop_pids.lock();
|
||||
if known.is_empty() {
|
||||
*known = process_tree(pid as i32);
|
||||
}
|
||||
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
|
||||
// Children first: the shell that started the program would otherwise reparent it.
|
||||
for p in known.iter().rev() {
|
||||
// SAFETY: plain syscall on the processes this session started.
|
||||
unsafe {
|
||||
libc::kill(*p, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
if force {
|
||||
cmd.arg("/F");
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
}
|
||||
|
||||
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
|
||||
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
if !terminate || s.request != "launch" {
|
||||
return;
|
||||
}
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
if pid <= 0 {
|
||||
return;
|
||||
}
|
||||
tracing::info!(session = s.id.0, pid, "stop: waiting for the debugged program to exit");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::time::Instant;
|
||||
let targets = {
|
||||
let known = s.stop_pids.lock();
|
||||
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
|
||||
};
|
||||
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
|
||||
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
while alive() {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) {
|
||||
signal_debuggee(s, true);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// The program ignored the terminate: do not leave it running.
|
||||
signal_debuggee(s, true);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
}
|
||||
|
||||
// java-debug and Windows netcoredbg need native separators for source lookup.
|
||||
// Keep DAP path mode, but normalize Windows drive and UNC paths.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
@@ -370,8 +483,18 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
return;
|
||||
}
|
||||
let output = str_("output").unwrap_or_default();
|
||||
if tracing::enabled!(tracing::Level::DEBUG) && mgr.stopping.lock().contains_key(&s.id) {
|
||||
tracing::debug!(session = ?s.id, bytes = output.len(), "output during stop");
|
||||
}
|
||||
s.emit(DebugEvent::Output { category, output });
|
||||
}
|
||||
// The program the adapter started: Stop must wait for it, not for the adapter's reply.
|
||||
// `process` is the DAP event; java-debug announces the same thing as `processid`.
|
||||
"process" | "processid" => {
|
||||
if let Some(pid) = int("systemProcessId").or_else(|| int("processId")).filter(|p| *p > 0) {
|
||||
s.debuggee_pid.store(pid, Ordering::Release);
|
||||
}
|
||||
}
|
||||
"thread" => s.emit(DebugEvent::Thread {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
started: body.get("reason").and_then(Value::as_str) != Some("exited"),
|
||||
@@ -390,12 +513,18 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
}
|
||||
"exited" => {}
|
||||
"terminated" => {
|
||||
if !s.terminated.swap(true, Ordering::AcqRel) {
|
||||
s.emit(DebugEvent::Terminated);
|
||||
}
|
||||
let first = !s.terminated.swap(true, Ordering::AcqRel);
|
||||
let mgr = mgr.clone();
|
||||
let s = s.clone();
|
||||
tokio::spawn(async move { mgr.shutdown(&s, false).await });
|
||||
// Waiting here would stop reading the adapter: it keeps sending the program's last
|
||||
// output while the program exits. Wait off the reader task, then report the end.
|
||||
tokio::spawn(async move {
|
||||
if first {
|
||||
wait_for_debuggee(&s, true).await;
|
||||
s.emit(DebugEvent::Terminated);
|
||||
}
|
||||
mgr.shutdown(&s, false).await;
|
||||
});
|
||||
}
|
||||
"capabilities" => {
|
||||
if let Some(new) = body.get("capabilities").and_then(Value::as_object) {
|
||||
|
||||
+240
-2
@@ -5,11 +5,16 @@ use crate::adapter::{AdapterIo, LaunchFuture};
|
||||
use ide_core::IdeEvent;
|
||||
use ide_core::event::DebugEvent;
|
||||
use ide_jsonrpc::framing;
|
||||
#[cfg(unix)]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Clone)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
run_in_terminal: Option<Vec<String>>,
|
||||
@@ -17,10 +22,35 @@ struct Script {
|
||||
start_debugging: bool,
|
||||
ignore_configuration_done: bool,
|
||||
source_path: Option<String>,
|
||||
/// Report this process id in the adapter's `process` event (the program being debugged).
|
||||
debuggee_pid: Option<u32>,
|
||||
/// java-debug's spelling of the same event.
|
||||
java_process_id: bool,
|
||||
/// Send one more output event right after the `terminated` event (a program still logging
|
||||
/// while it shuts down).
|
||||
output_after_terminate: bool,
|
||||
/// Advertise `supportsTerminateRequest` (java-debug does not, so Stop signals the program).
|
||||
supports_terminate: bool,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
run_in_terminal: None,
|
||||
start_debugging: false,
|
||||
ignore_configuration_done: false,
|
||||
source_path: None,
|
||||
debuggee_pid: None,
|
||||
java_process_id: false,
|
||||
output_after_terminate: false,
|
||||
supports_terminate: true,
|
||||
hold_set_breakpoints: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeLauncher {
|
||||
log: Log,
|
||||
script: Script,
|
||||
@@ -77,7 +107,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": true,
|
||||
"supportsTerminateRequest": script.supports_terminate,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
"supportsDataBreakpoints": true,
|
||||
@@ -155,6 +185,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
if let Some(l) = pending_launch.take() {
|
||||
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
|
||||
}
|
||||
if let Some(pid) = script.debuggee_pid {
|
||||
let event = if script.java_process_id {
|
||||
json!({"type": "event", "event": "processid", "body": {"processId": pid, "shellProcessId": pid}})
|
||||
} else {
|
||||
json!({"type": "event", "event": "process", "body": {"name": "prog", "systemProcessId": pid}})
|
||||
};
|
||||
send!(event);
|
||||
}
|
||||
send!(json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "hello\n"}}));
|
||||
send!(
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
|
||||
@@ -208,6 +246,11 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
"terminate" => {
|
||||
ok!(msg, Value::Null);
|
||||
send!(json!({"type": "event", "event": "terminated"}));
|
||||
if script.output_after_terminate {
|
||||
send!(
|
||||
json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "late\n"}})
|
||||
);
|
||||
}
|
||||
}
|
||||
"disconnect" => {
|
||||
ok!(msg, Value::Null);
|
||||
@@ -1348,6 +1391,201 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_waits_for_the_launched_program_to_exit() {
|
||||
// The adapter answers `disconnect` at once; the program it started is still alive and
|
||||
// holds its port for a while. Stop must not report the session as over before it exits.
|
||||
// Reaped by a waiting thread: a zombie still "exists" for `kill(pid, 0)`, unlike the
|
||||
// program an adapter starts and reaps.
|
||||
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
|
||||
let mut child = std::process::Command::new("python3")
|
||||
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
|
||||
for _ in 0..100 {
|
||||
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await; // let the program install its trap
|
||||
let started = Instant::now();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
let elapsed = started.elapsed();
|
||||
assert!(elapsed >= Duration::from_millis(800), "stop returned after {elapsed:?}, before the program exited");
|
||||
assert!(elapsed < Duration::from_secs(5), "stop waited {elapsed:?} for a program that ended after a second");
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_second_stop_kills_the_program_instead_of_waiting() {
|
||||
let mut child = std::process::Command::new("python3")
|
||||
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)"])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let exited = exited.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
exited.store(true, Ordering::Release);
|
||||
});
|
||||
}
|
||||
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
|
||||
for _ in 0..100 {
|
||||
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
let first = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(!exited.load(Ordering::Acquire), "the program is still alive while the first Stop waits");
|
||||
let started = Instant::now();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
let first_result = tokio::time::timeout(Duration::from_secs(5), first).await.expect("the first Stop returns");
|
||||
first_result.unwrap().unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(2), "a second Stop does not wait out the program");
|
||||
for _ in 0..100 {
|
||||
if exited.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(exited.load(Ordering::Acquire), "the second Stop killed the program");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_waits_for_the_program_when_the_adapter_uses_javas_processid_event() {
|
||||
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
|
||||
let mut child = std::process::Command::new("python3")
|
||||
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
let h = harness(Script { debuggee_pid: Some(pid), java_process_id: true, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
|
||||
for _ in 0..100 {
|
||||
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let started = Instant::now();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(800),
|
||||
"stop returned after {:?}, before the program exited",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn output_keeps_arriving_while_a_stopped_program_is_exiting() {
|
||||
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
|
||||
let mut child = std::process::Command::new("python3")
|
||||
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
let h = harness(Script { debuggee_pid: Some(pid), output_after_terminate: true, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
|
||||
for _ in 0..100 {
|
||||
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let started = Instant::now();
|
||||
let stop = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
// The last line must reach the console while Stop still waits for the program.
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output == "late\n")).await;
|
||||
assert!(started.elapsed() < Duration::from_millis(800), "output arrived only after the program was gone");
|
||||
stop.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_a_project_stops_the_debug_sessions_it_started() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
|
||||
// The session's cwd is /p, whatever key it was started under.
|
||||
h.manager.stop_under(Path::new("/p")).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
|
||||
assert!(h.manager.sessions(None).is_empty(), "session {id:?} is gone");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_kills_the_program_started_by_the_reported_launcher() {
|
||||
use std::io::BufRead;
|
||||
// java-debug reports the launcher shell; the JVM is its child. Stopping only the reported
|
||||
// pid orphans the program, which keeps running (and keeps its ports).
|
||||
let mut launcher = std::process::Command::new("sh")
|
||||
.args(["-c", "python3 -c 'import time; time.sleep(30)' & echo $!; wait"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let launcher_pid = launcher.id();
|
||||
let mut line = String::new();
|
||||
std::io::BufReader::new(launcher.stdout.take().unwrap()).read_line(&mut line).unwrap();
|
||||
let program: i32 = line.trim().parse().expect("child pid");
|
||||
// Reap the launcher: a zombie still "exists" for `kill(pid, 0)`, unlike the shell jdtls reaps.
|
||||
std::thread::spawn(move || {
|
||||
let _ = launcher.wait();
|
||||
});
|
||||
|
||||
let h = harness(Script {
|
||||
debuggee_pid: Some(launcher_pid),
|
||||
supports_terminate: false, // java-debug does not advertise it
|
||||
..Default::default()
|
||||
});
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
|
||||
for _ in 0..100 {
|
||||
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(launcher_pid)) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while unsafe { libc::kill(program, 0) } == 0 {
|
||||
assert!(Instant::now() < deadline, "the program the launcher started survived stop");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
|
||||
|
||||
@@ -248,7 +248,7 @@ impl Host {
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
Some((self.debugger.clone(), window.to_owned(), project.root.clone())),
|
||||
Some(project),
|
||||
))
|
||||
}
|
||||
@@ -261,7 +261,7 @@ impl Host {
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String, std::path::PathBuf)>,
|
||||
retired: Option<Arc<Project>>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
for bridge in &bridges {
|
||||
@@ -286,8 +286,9 @@ impl Host {
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
if let Some((debug, key, root)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
debug.stop_under(&root).await;
|
||||
}
|
||||
});
|
||||
if let Some(retired) = retired {
|
||||
|
||||
@@ -195,9 +195,24 @@ pub async fn lsp_hover(
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
return result;
|
||||
};
|
||||
let (result, hovers) =
|
||||
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
host.end_request(request);
|
||||
result
|
||||
let hovers = hovers.unwrap_or_default();
|
||||
if hovers.is_empty() {
|
||||
return result;
|
||||
}
|
||||
let server = match result {
|
||||
Ok(server) => server,
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
Ok(crate::plugins::merge_hover(server, hovers))
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
|
||||
@@ -583,12 +583,15 @@ pub async fn vcs_create_branch(
|
||||
name: String,
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
overwrite: Option<bool>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| {
|
||||
r.create_branch_with_overwrite(&name, start.as_deref(), checkout, overwrite.unwrap_or(false))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -1335,7 +1338,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_create_branch", |c| async move {
|
||||
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
|
||||
vcs_create_branch(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("name")?,
|
||||
c.arg("start")?,
|
||||
c.arg("checkout")?,
|
||||
c.arg("overwrite")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add(
|
||||
"vcs_undo_commit",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
|
||||
//! Capabilities were checked by the runtime before any of this runs.
|
||||
|
||||
use super::{PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem,
|
||||
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
|
||||
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -23,6 +23,11 @@ const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
const MAX_CONTEXT_KEYS: usize = 100;
|
||||
/// Most diff text of a pending commit, all files together.
|
||||
const MAX_COMMIT_DIFF: usize = 1 << 20;
|
||||
/// How long a window may take to describe its pending commit (it saves editors first).
|
||||
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// (window or `None` for every window, plugin, key).
|
||||
type ContextKey = (Option<String>, String, String);
|
||||
@@ -51,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
|
||||
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
|
||||
-> Result<(), HostError>;
|
||||
/// The encoding git content of `path` is read in (the project's file encoding).
|
||||
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
|
||||
encoding_rs::UTF_8.into()
|
||||
}
|
||||
/// The user's values of `plugin`'s settings: the project's at `root`
|
||||
/// (over global), or global.
|
||||
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
@@ -150,10 +159,15 @@ struct TaskEntry {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// A question a plugin's call waits on.
|
||||
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
|
||||
struct PromptEntry {
|
||||
plugin: String,
|
||||
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
|
||||
answer: Waiter,
|
||||
}
|
||||
|
||||
enum Waiter {
|
||||
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
|
||||
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
|
||||
}
|
||||
|
||||
/// State shared by the host and every plugin's [`PluginApi`].
|
||||
@@ -211,7 +225,7 @@ impl Shared {
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
self.workspace.read().clone()
|
||||
}
|
||||
|
||||
@@ -358,8 +372,22 @@ impl Shared {
|
||||
|
||||
/// The user answered (`Some`) or dismissed (`None`) a prompt.
|
||||
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
|
||||
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
|
||||
prompt.answer.send(answer).is_ok()
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
/// A window answered a [`CommitRequest`].
|
||||
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
fn end_task(&self, id: u64) {
|
||||
@@ -540,6 +568,91 @@ fn kill_group(child: &mut std::process::Child) {
|
||||
ide_core::process::kill_tree(child);
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
/// Sends `request` to `client`'s window and waits for its answer, until
|
||||
/// `cancel` fires, the plugin stops or `timeout` passes.
|
||||
fn ask_commit(
|
||||
&self,
|
||||
client: &str,
|
||||
request: CommitRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<CommitAnswer, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (answer, answers) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
|
||||
}
|
||||
let request_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match answers.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(answer) => return Ok(answer),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(e) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
|
||||
return Err(match e {
|
||||
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
|
||||
HostError::Failed("the window did not answer".into())
|
||||
}
|
||||
_ => HostError::Cancelled,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes `selection` commits, diffed repository by repository.
|
||||
pub(super) fn commit_draft(
|
||||
workspace: &dyn PluginWorkspace,
|
||||
selection: CommitSelection,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<CommitDraft, HostError> {
|
||||
let mut changes = Vec::new();
|
||||
let mut budget = MAX_COMMIT_DIFF;
|
||||
for repo in &selection.repositories {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
|
||||
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
|
||||
};
|
||||
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|
||||
|e| match e.kind {
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
},
|
||||
)?;
|
||||
let root = to_ipc(r.root());
|
||||
for c in pending {
|
||||
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
|
||||
changes.push(CommitChange {
|
||||
repository: root.clone(),
|
||||
path: to_ipc(&c.path),
|
||||
old_path: c.old_path.as_deref().map(to_ipc),
|
||||
status: c.status.to_owned(),
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
|
||||
}
|
||||
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
@@ -577,7 +690,10 @@ impl HostApi for PluginApi {
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
|
||||
}
|
||||
let prompt_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
@@ -597,6 +713,37 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let client = from_window(client, "read the pending commit")?;
|
||||
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
|
||||
let root =
|
||||
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
|
||||
if !workspace.trusted(&root) {
|
||||
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
|
||||
}
|
||||
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
|
||||
match answer {
|
||||
CommitAnswer::NoRepository => Ok(None),
|
||||
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
|
||||
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "set the commit message")?;
|
||||
let request = CommitRequest::SetMessage { revision, message };
|
||||
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
|
||||
CommitAnswer::Done => Ok(()),
|
||||
CommitAnswer::Stale => Err(HostError::Stale),
|
||||
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
|
||||
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
|
||||
let client = from_window(client, "reveal a view")?;
|
||||
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
|
||||
@@ -1112,6 +1259,15 @@ impl PluginWorkspace for HostWorkspace {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
|
||||
HostWorkspace::projects(self)
|
||||
.into_iter()
|
||||
.filter(|p| path.starts_with(&p.root))
|
||||
.max_by_key(|p| p.root.as_os_str().len())
|
||||
.and_then(|p| p.vcs_encoding(path, None).ok())
|
||||
.unwrap_or_else(|| encoding_rs::UTF_8.into())
|
||||
}
|
||||
|
||||
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
|
||||
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The Commit view's side of the `commit` API, as the window commands
|
||||
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
|
||||
//! local projects, `openidea-server` for remote ones.
|
||||
|
||||
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
|
||||
/// until request `request` is cancelled.
|
||||
pub async fn commit_checks(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
host.require_trust(window)?;
|
||||
let invocation = super::views::view_invocation(host, window, None, None)?;
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
.and_then(|r| r);
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use ide_lsp::types::{Hover, Markup};
|
||||
use ide_plugin::PluginHover;
|
||||
|
||||
pub(crate) fn merge(server: Option<Hover>, plugin: Vec<PluginHover>) -> Option<Hover> {
|
||||
if plugin.is_empty() {
|
||||
return server;
|
||||
}
|
||||
let mut parts = Vec::with_capacity(plugin.len() + 1);
|
||||
let mut range = None;
|
||||
if let Some(h) = server {
|
||||
range = h.range;
|
||||
if !h.contents.value.trim().is_empty() {
|
||||
parts.push(if h.contents.kind == "markdown" { h.contents.value } else { escape(&h.contents.value) });
|
||||
}
|
||||
}
|
||||
for h in plugin {
|
||||
range = range.or(h.range);
|
||||
parts.push(h.markdown);
|
||||
}
|
||||
Some(Hover { contents: Markup { kind: "markdown".into(), value: parts.join("\n\n---\n\n") }, range })
|
||||
}
|
||||
|
||||
fn escape(plain: &str) -> String {
|
||||
let mut out = String::with_capacity(plain.len() + plain.len() / 4);
|
||||
for c in plain.chars() {
|
||||
match c {
|
||||
'\n' => out.push_str(" \n"),
|
||||
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '#' | '+' | '-' | '.' | '!'
|
||||
| '|' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::{Pos, Range};
|
||||
|
||||
fn range(line: u32, from: u32, to: u32) -> Range {
|
||||
Range::new(Pos::new(line, from), Pos::new(line, to))
|
||||
}
|
||||
|
||||
fn plugin(markdown: &str, at: Option<Range>) -> PluginHover {
|
||||
PluginHover { markdown: markdown.into(), range: at }
|
||||
}
|
||||
|
||||
fn server(kind: &str, value: &str, at: Option<Range>) -> Hover {
|
||||
Hover { contents: Markup { kind: kind.into(), value: value.into() }, range: at }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_content_follows_the_server_hover() {
|
||||
let merged = merge(
|
||||
Some(server("markdown", "```php\nfunction route()\n```", Some(range(3, 4, 9)))),
|
||||
vec![plugin("**orders.index** · `GET /orders`", Some(range(3, 10, 22)))],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged.contents.kind, "markdown");
|
||||
assert_eq!(merged.contents.value, "```php\nfunction route()\n```\n\n---\n\n**orders.index** · `GET /orders`");
|
||||
assert_eq!(merged.range, Some(range(3, 4, 9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_server_hover_the_plugins_answer_alone() {
|
||||
let merged = merge(None, vec![plugin("one", None), plugin("two", Some(range(1, 0, 3)))]).unwrap();
|
||||
assert_eq!(merged.contents.value, "one\n\n---\n\ntwo");
|
||||
assert_eq!(merged.range, Some(range(1, 0, 3)));
|
||||
assert!(merge(None, Vec::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_from_the_server_keeps_its_characters() {
|
||||
let merged = merge(Some(server("plaintext", "a_b *c*\nd", None)), vec![plugin("x", None)]).unwrap();
|
||||
assert_eq!(merged.contents.value, "a\\_b \\*c\\* \nd\n\n---\n\nx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_server_hover_is_left_out() {
|
||||
let merged = merge(Some(server("markdown", " ", None)), vec![plugin("x", None)]).unwrap();
|
||||
assert_eq!(merged.contents.value, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_plugin_content_keeps_the_server_hover() {
|
||||
let kept = merge(Some(server("plaintext", "a_b", Some(range(0, 0, 1)))), Vec::new()).unwrap();
|
||||
assert_eq!((kept.contents.kind.as_str(), kept.contents.value.as_str()), ("plaintext", "a_b"));
|
||||
assert_eq!(kept.range, Some(range(0, 0, 1)));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
pub mod commit;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
@@ -38,6 +40,7 @@ pub mod views;
|
||||
pub mod web;
|
||||
|
||||
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
|
||||
pub(crate) use hover::merge as merge_hover;
|
||||
pub use icons::{
|
||||
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
|
||||
};
|
||||
@@ -1364,6 +1367,67 @@ impl PluginHost {
|
||||
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
|
||||
}
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
|
||||
/// what `selection` commits. A check that fails or isn't available is
|
||||
/// reported with `error`, so the commit still stops for it.
|
||||
pub fn commit_checks(
|
||||
&self,
|
||||
invocation: Invocation,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
|
||||
let wanted: Vec<_> = checks
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
|
||||
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
|
||||
})
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
|
||||
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
|
||||
HostError::Cancelled => IdeError::cancelled(),
|
||||
HostError::Invalid(m) => IdeError::invalid(m),
|
||||
e => IdeError::unavailable(e.to_string()),
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for (c, title) in wanted {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let granted = self
|
||||
.plugins
|
||||
.read()
|
||||
.get(&c.plugin_id)
|
||||
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
|
||||
let result = if granted {
|
||||
let (check, draft) = (c.check.clone(), draft.clone());
|
||||
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
|
||||
rt.commit_check(&check, draft.clone(), inv)
|
||||
})
|
||||
.map_err(|e| e.message)
|
||||
} else {
|
||||
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
|
||||
};
|
||||
let (problems, error) = match result {
|
||||
Ok(problems) => (problems, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Kinds of workbench events some running or activatable plugin wants;
|
||||
/// the UI reports only these.
|
||||
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
|
||||
|
||||
@@ -68,5 +68,6 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub enum ProviderKind {
|
||||
Definition,
|
||||
LineMarkers,
|
||||
SemanticTokens,
|
||||
Hover,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -116,6 +117,9 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::SemanticTokens) {
|
||||
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
|
||||
}
|
||||
if d.kinds.contains(&ProviderKind::Hover) {
|
||||
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -164,7 +168,7 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
|
||||
ProviderDocument, Runtime, SemanticToken,
|
||||
};
|
||||
use std::sync::Weak;
|
||||
@@ -345,6 +349,14 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
.into_iter()
|
||||
.filter_map(|(_, hover)| hover)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ghost text from the first plugin that has some. Not bound by the
|
||||
/// edit-path soft deadline (it may need the network) but by `cancel`:
|
||||
/// the editor cancels a request as soon as the user types on.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
|
||||
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
|
||||
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
|
||||
//! `plugin_service_log`)
|
||||
//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
|
||||
//! carry the desktop commands' names and arguments, so a remote window's UI
|
||||
//! routes them here unchanged. Plugin results come back as `plugin`
|
||||
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
|
||||
@@ -122,6 +122,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
| PluginEvent::CommitRequested { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,6 +423,26 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_commit", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_commit_checks", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::commit::commit_checks(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("selection")?,
|
||||
c.arg("checks")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
|
||||
@@ -232,6 +232,55 @@ pub enum PluginEvent {
|
||||
/// Bring the tool window of the plugin's view `view` to the front.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealView { plugin_id: String, view: String },
|
||||
/// A command needs the window's Commit view: answer with
|
||||
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
|
||||
}
|
||||
|
||||
/// What a plugin asks of a window's Commit view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitRequest {
|
||||
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
|
||||
/// editors of the included files are saved first.
|
||||
Selection,
|
||||
/// Replace the message if the draft is still at `revision`.
|
||||
SetMessage { revision: u64, message: String },
|
||||
}
|
||||
|
||||
/// A window's answer to a [`CommitRequest`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitAnswer {
|
||||
/// The window's project has no repository.
|
||||
NoRepository,
|
||||
Selection {
|
||||
selection: CommitSelection,
|
||||
},
|
||||
/// The message was replaced.
|
||||
Done,
|
||||
/// The draft is no longer at the revision.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// What a window's Commit view would commit: per repository, the options
|
||||
/// `vcs_commit` would get.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelection {
|
||||
/// Changes whenever the message, the included changes or Amend do.
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub repositories: Vec<CommitSelectionRepo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelectionRepo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub options: ide_vcs::CommitOptions,
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
@@ -300,3 +349,24 @@ pub struct LineMarkerInfo {
|
||||
/// Opened on click.
|
||||
pub target: Option<ide_core::Location>,
|
||||
}
|
||||
|
||||
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckRef {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
}
|
||||
|
||||
/// What a plugin's before-commit check found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckResult {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
/// The check's declared title.
|
||||
pub title: String,
|
||||
pub problems: Vec<ide_plugin::CommitProblem>,
|
||||
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.menus]]
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
|
||||
//! command = "check"
|
||||
//! when = "editorLang == java"
|
||||
//! # view = "<view id>" # required for (only allowed on) viewItem.context
|
||||
@@ -42,6 +42,10 @@
|
||||
//! icon = "icons/drawing.svg"
|
||||
//! iconDark = "icons/drawing_dark.svg"
|
||||
//!
|
||||
//! [[contributes.commitChecks]]
|
||||
//! id = "conventional"
|
||||
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
|
||||
//!
|
||||
//! [[contributes.fileTemplates]]
|
||||
//! id = "drawing"
|
||||
//! name = "Excalidraw Drawing"
|
||||
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
|
||||
pub const CUSTOM_EDITORS: &str = "customEditors";
|
||||
pub const FILE_ICONS: &str = "fileIcons";
|
||||
pub const FILE_TEMPLATES: &str = "fileTemplates";
|
||||
pub const COMMIT_CHECKS: &str = "commitChecks";
|
||||
|
||||
const VIEW_ITEM_MENU: &str = "viewItem.context";
|
||||
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
|
||||
/// The toolbar above the commit message (Commit tool window and dialog).
|
||||
const COMMIT_MESSAGE_MENU: &str = "commit.message";
|
||||
const MENU_IDS: [&str; 6] =
|
||||
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
@@ -122,6 +130,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
|
||||
}
|
||||
declared_command(pkg, MENUS, &d.command)?;
|
||||
if d.menu == COMMIT_MESSAGE_MENU {
|
||||
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
|
||||
}
|
||||
if let Some(w) = &d.when {
|
||||
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
|
||||
}
|
||||
@@ -554,6 +565,29 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CommitCheckDecl {
|
||||
id: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
|
||||
/// `commit-checker` export, listed in Commit Options.
|
||||
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
|
||||
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
|
||||
validate_item_id(&d.id, "commit check id")?;
|
||||
title(COMMIT_CHECKS, &d.title)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
|
||||
}
|
||||
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
|
||||
}
|
||||
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct Project {
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
pub(crate) watcher: Arc<Watcher>,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
/// Watchers of folders outside the project that language servers registered file
|
||||
@@ -305,6 +305,12 @@ impl Host {
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
// Shared handle: `unwatched_reason` is a live query on the watcher,
|
||||
// filled after `start` returns so the on-change callback can reach it.
|
||||
let watcher_weak = Arc::new(std::sync::OnceLock::<std::sync::Weak<Watcher>>::new());
|
||||
// One-shot latch for the post-start watch-limit warning (#1915): the
|
||||
// synchronous startup walk and a later batch can both observe it.
|
||||
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let phpstan = phpstan.clone();
|
||||
@@ -322,6 +328,8 @@ impl Host {
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
let build_cache = build_cache.clone();
|
||||
let notify_watch_exhausted = notify_watch_exhausted.clone();
|
||||
let watcher_weak = watcher_weak.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -408,9 +416,26 @@ impl Host {
|
||||
if git_dirs || vcs_moved {
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
// A watch limit hit after startup (per-directory mode, Linux):
|
||||
// folders past the limit never sync — surface it once, like
|
||||
// the polling fallback below (#1915). The check itself is
|
||||
// deferred to here, on a batch that already had events.
|
||||
if !notify_watch_exhausted.load(Ordering::Relaxed)
|
||||
&& let Some(msg) =
|
||||
watcher_weak.get().and_then(std::sync::Weak::upgrade).and_then(|w| w.unwatched_reason())
|
||||
{
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(msg),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}),
|
||||
)?
|
||||
};
|
||||
let watcher = Arc::new(watcher);
|
||||
let _ = watcher_weak.set(Arc::downgrade(&watcher));
|
||||
if let Some(reason) = watcher.polling() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
@@ -418,6 +443,17 @@ impl Host {
|
||||
body: Some(reason.into()),
|
||||
});
|
||||
}
|
||||
// A watch can also be refused part-way through the synchronous startup
|
||||
// walk (large tree, SYNC_WATCH_BUDGET) — after start() returns, before
|
||||
// any batch. Emit the same warning so the limit isn't silent there either.
|
||||
if let Some(reason) = watcher.unwatched_reason() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(reason),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
@@ -644,7 +680,7 @@ impl Host {
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
phpstan: None,
|
||||
watcher,
|
||||
watcher: Arc::new(watcher),
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! The `commit` interface and the `commit-checker` export, through the
|
||||
//! `test.commit` fixture and a scripted window answering `CommitRequested`.
|
||||
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{HostError, Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/commit")
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "user.name=T", "-c", "[email protected]"])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
struct Workspace {
|
||||
root: PathBuf,
|
||||
trusted: AtomicBool,
|
||||
}
|
||||
|
||||
impl PluginWorkspace for Workspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.root.clone()]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
|
||||
Err(HostError::NotFound("not open".into()))
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
self.trusted.load(Ordering::Relaxed)
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
Some(self.root.clone())
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's Commit view: its draft, and whether the user types between
|
||||
/// the plugin reading it and writing the message.
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
revision: u64,
|
||||
message: String,
|
||||
selection: Option<CommitSelection>,
|
||||
type_after_reading: bool,
|
||||
requests: Vec<CommitRequest>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Arc<PluginHost>,
|
||||
window: Arc<Mutex<Window>>,
|
||||
workspace: Arc<Workspace>,
|
||||
project: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
git(&project, &["init", "-q"]);
|
||||
std::fs::write(project.join("a.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
git(&project, &["add", "."]);
|
||||
git(&project, &["commit", "-qm", "base"]);
|
||||
std::fs::write(project.join("a.txt"), "one\nDO NOT COMMIT\nthree\n").unwrap();
|
||||
|
||||
let window = Arc::new(Mutex::new(Window { revision: 7, ..Window::default() }));
|
||||
let host_slot: Arc<Mutex<Option<std::sync::Weak<PluginHost>>>> = Arc::default();
|
||||
let (w, slot) = (window.clone(), host_slot.clone());
|
||||
// Answers on another thread, as the UI does through `plugin_answer_commit`.
|
||||
let sink = Arc::new(move |_: Option<&str>, e: PluginEvent| {
|
||||
let PluginEvent::CommitRequested { request_id, request, .. } = e else { return };
|
||||
let (w, slot) = (w.clone(), slot.clone());
|
||||
std::thread::spawn(move || {
|
||||
let answer = {
|
||||
let mut w = w.lock();
|
||||
w.requests.push(request.clone());
|
||||
match request {
|
||||
CommitRequest::Selection => {
|
||||
let selection = w.selection.clone();
|
||||
if w.type_after_reading {
|
||||
w.revision += 1;
|
||||
}
|
||||
match selection {
|
||||
Some(s) => CommitAnswer::Selection { selection: s },
|
||||
None => CommitAnswer::NoRepository,
|
||||
}
|
||||
}
|
||||
CommitRequest::SetMessage { revision, message } if revision == w.revision => {
|
||||
w.message = message;
|
||||
w.revision += 1;
|
||||
CommitAnswer::Done
|
||||
}
|
||||
CommitRequest::SetMessage { .. } => CommitAnswer::Stale,
|
||||
}
|
||||
};
|
||||
let host = slot.lock().as_ref().and_then(std::sync::Weak::upgrade);
|
||||
assert!(host.unwrap().answer_commit(&request_id, answer));
|
||||
});
|
||||
});
|
||||
let host = Arc::new(PluginHost::new(tmp.path().join("plugins"), sink, Limits::default()).unwrap());
|
||||
*host_slot.lock() = Some(Arc::downgrade(&host));
|
||||
let workspace = Arc::new(Workspace { root: project.clone(), trusted: AtomicBool::new(true) });
|
||||
host.set_workspace(Some(workspace.clone()));
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
Self { host, window, workspace, project, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), ..Invocation::default() }
|
||||
}
|
||||
|
||||
/// `a.txt` included whole, with `message`.
|
||||
fn selection(&self, message: &str) -> CommitSelection {
|
||||
let revision = self.window.lock().revision;
|
||||
CommitSelection {
|
||||
revision,
|
||||
message: message.into(),
|
||||
amend: false,
|
||||
repositories: vec![CommitSelectionRepo {
|
||||
root: self.project.clone().into(),
|
||||
options: ide_vcs::CommitOptions {
|
||||
stage: vec![self.project.join("a.txt").into()],
|
||||
run_hooks: true,
|
||||
..Default::default()
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn markers(&self, message: &str) -> Vec<PluginCommitCheckResult> {
|
||||
let check = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "markers".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
self.host.commit_checks(self.inv(), self.selection(message), vec![check], &cancel).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_reads_the_pending_commit_and_writes_its_message() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
f.window.lock().selection = Some(selection);
|
||||
let out = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("chore: modified a.txt"));
|
||||
let w = f.window.lock();
|
||||
assert_eq!(w.message, "chore: modified a.txt");
|
||||
assert_eq!(w.revision, 8);
|
||||
// Reading the draft left the index alone: nothing is staged.
|
||||
let staged = Command::new("git").current_dir(&f.project).args(["diff", "--cached", "--name-only"]).output();
|
||||
assert!(staged.unwrap().stdout.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_meanwhile_makes_the_write_stale() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
{
|
||||
let mut w = f.window.lock();
|
||||
w.selection = Some(selection);
|
||||
w.type_after_reading = true;
|
||||
}
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("stale") || err.message.contains("changed"), "{}", err.message);
|
||||
assert_eq!(f.window.lock().message, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_repository_and_restricted_mode() {
|
||||
let f = Fixture::new();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no repository"), "{}", err.message);
|
||||
|
||||
f.workspace.trusted.store(false, Ordering::Relaxed);
|
||||
f.window.lock().requests.clear();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("Restricted Mode"), "{}", err.message);
|
||||
assert!(f.window.lock().requests.is_empty(), "the window is not asked");
|
||||
|
||||
// Outside a window's command there is no Commit view to ask.
|
||||
let headless = Invocation { client: None, ..f.inv() };
|
||||
let err = f.host.run_command("test.commit", "describe", None, headless).unwrap_err();
|
||||
assert!(err.message.contains("from a command"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_report_the_message_and_markers_in_the_included_changes() {
|
||||
let f = Fixture::new();
|
||||
let results = f.markers("wip");
|
||||
assert_eq!(results.len(), 1);
|
||||
let r = &results[0];
|
||||
assert_eq!((r.title.as_str(), r.error.as_deref()), ("Commit Markers", None));
|
||||
let found: Vec<_> =
|
||||
r.problems.iter().map(|p| (p.message.as_str(), p.path.clone(), p.range.map(|r| r.start.line))).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("The message does not start with `type: `.", None, None),
|
||||
("Remove the DO NOT COMMIT marker.", Some(to_ipc(&f.project.join("a.txt"))), Some(1)),
|
||||
]
|
||||
);
|
||||
assert_eq!(f.markers("fix: drop marker")[0].problems.len(), 1);
|
||||
|
||||
// Unknown checks are not run, nor those of a plugin that lost `vcs.read`.
|
||||
let other = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "other".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
assert!(f.host.commit_checks(f.inv(), f.selection("x"), vec![other], &cancel).unwrap().is_empty());
|
||||
f.host.set_capability("test.commit", "vcs.read", false).unwrap();
|
||||
assert!(f.markers("wip").iter().all(|r| r.problems.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commit_points_need_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host =
|
||||
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
for (what, manifest) in [
|
||||
(
|
||||
"commit.message",
|
||||
manifest.replace("[[contributes.commitChecks]]\nid = \"markers\"\ntitle = \"Commit Markers\"\n", ""),
|
||||
),
|
||||
(
|
||||
"commitChecks",
|
||||
manifest.replace("[[contributes.menus]]\nmenu = \"commit.message\"\ncommand = \"describe\"\n", ""),
|
||||
),
|
||||
] {
|
||||
let pkg = tmp.path().join(what);
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.2\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3") && err.message.contains(what), "{what}: {}", err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use ide_core::Pos;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Limits, ProviderDocument};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/hover")
|
||||
}
|
||||
|
||||
fn host(dir: &Path) -> PluginHost {
|
||||
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
|
||||
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
|
||||
}
|
||||
|
||||
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
|
||||
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
|
||||
}
|
||||
|
||||
const ORDER: &str = "<?php\nclass Order\n{\n public function user() {}\n}\n$order->user;\n$order->total;\n";
|
||||
|
||||
#[test]
|
||||
fn a_declared_name_gets_the_plugins_hover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
let hovers = host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(5, 10));
|
||||
assert_eq!(hovers.len(), 1);
|
||||
assert_eq!(hovers[0].markdown, "**user** · declared on line 4");
|
||||
let range = hovers[0].range.unwrap();
|
||||
assert_eq!((range.start.line, range.start.col, range.end.line, range.end.col), (5, 8, 5, 12));
|
||||
assert!(host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(6, 10)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_declared_languages_are_asked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
assert!(host.provide_hover(&doc(tmp.path(), "javascript", ORDER), Pos::new(5, 10)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_needs_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let pkg = tmp.path().join("old");
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
|
||||
|
||||
// A branch changes the line its way.
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true, None).await.unwrap();
|
||||
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
|
||||
f.edit_line(lib, theirs).await;
|
||||
f.save(lib).await;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
("arduino", include_str!("../../../languages/arduino.toml")),
|
||||
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
|
||||
("astro", include_str!("../../../languages/astro.toml")),
|
||||
("bat", include_str!("../../../languages/bat.toml")),
|
||||
@@ -601,9 +602,11 @@ mod tests {
|
||||
"MsGenny",
|
||||
"Vue",
|
||||
"Angular Template",
|
||||
"Angular TypeScript",
|
||||
"ERB",
|
||||
"JSP",
|
||||
"Astro",
|
||||
"GoMod",
|
||||
];
|
||||
|
||||
fn reg() -> LanguageRegistry {
|
||||
@@ -716,7 +719,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
fn angular_projects_use_framework_modes() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
@@ -727,7 +730,12 @@ mod tests {
|
||||
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "docs/index.html"), None);
|
||||
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
|
||||
for file in ["apps/web/src/app/app.ts", "apps/admin/src/app.mts", "apps/admin/src/app.cts"] {
|
||||
assert_eq!(mode("typescript", file).as_deref(), Some("Angular TypeScript"), "{file}");
|
||||
}
|
||||
assert_eq!(mode("typescript", "docs/example.ts"), None);
|
||||
assert_eq!(mode("javascript", "apps/web/src/app/app.js"), None);
|
||||
assert_eq!(mode("typescriptreact", "apps/web/src/app/app.tsx"), None);
|
||||
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
|
||||
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
|
||||
}
|
||||
@@ -860,6 +868,7 @@ mod tests {
|
||||
("x.h", "c"),
|
||||
("x.hpp", "cpp"),
|
||||
("x.cc", "cpp"),
|
||||
("blink.ino", "arduino"),
|
||||
("P.cs", "csharp"),
|
||||
("V.swift", "swift"),
|
||||
("AppDelegate.m", "objective-c"),
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Go files a gopls search does not reach.
|
||||
//!
|
||||
//! gopls answers a search from the view of the file it is asked about: the
|
||||
//! modules of a `go.work`, or that file's own module, built for the host's
|
||||
//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each
|
||||
//! gets a view of its own), so the generic scope widening is skipped for Go.
|
||||
//! Usages it leaves out are found as GoLand finds them, by resolving each
|
||||
//! mention of the name in its own file's view: files of other modules that use
|
||||
//! the declaration's module through a local `replace` (a multi-module
|
||||
//! repository without `go.work`), and files whose build constraints exclude
|
||||
//! them from the default build (`_windows.go`, `//go:build`).
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The files of `candidates` the search at `origin` does not cover and that can refer to
|
||||
/// `declaration`: those of the covered modules excluded from their default build, and those
|
||||
/// of the declaration's own module or one replacing it with its directory when not covered.
|
||||
pub(crate) fn files_beyond_search(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut modules = ModuleRoots::default();
|
||||
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(declaring_path) = read_module_path(&declaring) else { return Vec::new() };
|
||||
let mut covered: HashSet<PathBuf> = workspace_modules(&origin_module).into_iter().collect();
|
||||
covered.insert(origin_module);
|
||||
let mut uses: HashMap<PathBuf, bool> = HashMap::new();
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
let Some(module) = modules.of(file) else { return false };
|
||||
// The covered modules share the search's view: only their excluded files are missed.
|
||||
if covered.contains(&module) {
|
||||
return !in_default_build(file);
|
||||
}
|
||||
*uses.entry(module.clone()).or_insert_with(|| {
|
||||
module == declaring
|
||||
|| std::fs::read_to_string(module.join("go.mod")).is_ok_and(|text| {
|
||||
local_replacements(&text, &module)
|
||||
.iter()
|
||||
.any(|(path, dir)| *path == declaring_path && *dir == declaring)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether gopls' default view builds `file`: no build constraint other than the host's
|
||||
/// own GOOS/GOARCH file name suffix.
|
||||
fn in_default_build(file: &Path) -> bool {
|
||||
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let stem = name.strip_suffix(".go").unwrap_or(&name);
|
||||
let stem = stem.strip_suffix("_test").unwrap_or(stem);
|
||||
let parts: Vec<&str> = stem.split('_').collect();
|
||||
// `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint.
|
||||
let host = |part: &&str| *part == host_goos() || *part == host_goarch();
|
||||
if parts.len() > 1 {
|
||||
let last = parts[parts.len() - 1];
|
||||
if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) {
|
||||
return false;
|
||||
}
|
||||
if parts.len() > 2 && GOARCH.contains(&last) {
|
||||
let os = parts[parts.len() - 2];
|
||||
if GOOS.contains(&os) && !host(&os) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text))
|
||||
}
|
||||
|
||||
/// A `//go:build` or `// +build` line before the package clause.
|
||||
fn constrained_header(text: &str) -> bool {
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("package ") {
|
||||
return false;
|
||||
}
|
||||
if line.starts_with("//go:build") || line.starts_with("// +build") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn host_goos() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
os => os,
|
||||
}
|
||||
}
|
||||
|
||||
fn host_goarch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
"x86" => "386",
|
||||
"powerpc64" => "ppc64",
|
||||
arch => arch,
|
||||
}
|
||||
}
|
||||
|
||||
const GOOS: &[&str] = &[
|
||||
"aix",
|
||||
"android",
|
||||
"darwin",
|
||||
"dragonfly",
|
||||
"freebsd",
|
||||
"hurd",
|
||||
"illumos",
|
||||
"ios",
|
||||
"js",
|
||||
"linux",
|
||||
"nacl",
|
||||
"netbsd",
|
||||
"openbsd",
|
||||
"plan9",
|
||||
"solaris",
|
||||
"wasip1",
|
||||
"windows",
|
||||
"zos",
|
||||
];
|
||||
const GOARCH: &[&str] = &[
|
||||
"386",
|
||||
"amd64",
|
||||
"amd64p32",
|
||||
"arm",
|
||||
"armbe",
|
||||
"arm64",
|
||||
"arm64be",
|
||||
"loong64",
|
||||
"mips",
|
||||
"mipsle",
|
||||
"mips64",
|
||||
"mips64le",
|
||||
"mips64p32",
|
||||
"mips64p32le",
|
||||
"ppc",
|
||||
"ppc64",
|
||||
"ppc64le",
|
||||
"riscv",
|
||||
"riscv64",
|
||||
"s390",
|
||||
"s390x",
|
||||
"sparc",
|
||||
"sparc64",
|
||||
"wasm",
|
||||
];
|
||||
|
||||
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
|
||||
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = Vec::new();
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let code = line.split("//").next().unwrap_or(line);
|
||||
for (at, _) in code.match_indices(word) {
|
||||
let before = code[..at].chars().next_back();
|
||||
let after = code[at + word.len()..].chars().next();
|
||||
if before.is_some_and(ident) || after.is_some_and(ident) {
|
||||
continue;
|
||||
}
|
||||
let col = code[..at].encode_utf16().count() as u32;
|
||||
let len = word.encode_utf16().count() as u32;
|
||||
out.push(Range::new(Pos::new(line_no as u32, col), Pos::new(line_no as u32, col + len)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Nearest enclosing `go.mod` directory, cached per directory.
|
||||
#[derive(Default)]
|
||||
struct ModuleRoots(HashMap<PathBuf, Option<PathBuf>>);
|
||||
|
||||
impl ModuleRoots {
|
||||
fn of(&mut self, file: &Path) -> Option<PathBuf> {
|
||||
let dir = file.parent()?.to_path_buf();
|
||||
self.dir(&dir)
|
||||
}
|
||||
|
||||
fn dir(&mut self, dir: &Path) -> Option<PathBuf> {
|
||||
if let Some(found) = self.0.get(dir) {
|
||||
return found.clone();
|
||||
}
|
||||
let found =
|
||||
if dir.join("go.mod").is_file() { Some(dir.to_path_buf()) } else { dir.parent().and_then(|p| self.dir(p)) };
|
||||
self.0.insert(dir.to_path_buf(), found.clone());
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
fn read_module_path(module: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(module.join("go.mod")).ok()?;
|
||||
text.lines().find_map(|line| Some(unquote(line.trim().strip_prefix("module ")?.trim())))
|
||||
}
|
||||
|
||||
/// The modules a `go.work` above `module` uses, as gopls loads them together.
|
||||
fn workspace_modules(module: &Path) -> Vec<PathBuf> {
|
||||
let Some(work) = module.ancestors().map(|d| d.join("go.work")).find(|p| p.is_file()) else { return Vec::new() };
|
||||
let Ok(text) = std::fs::read_to_string(&work) else { return Vec::new() };
|
||||
let base = work.parent().unwrap_or(Path::new(""));
|
||||
directives(&text, "use").into_iter().map(|dir| normalize(&base.join(dir))).collect()
|
||||
}
|
||||
|
||||
/// `replace <module> [version] => <local directory>` entries, directories resolved against `base`.
|
||||
fn local_replacements(go_mod: &str, base: &Path) -> Vec<(String, PathBuf)> {
|
||||
directives(go_mod, "replace")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let (from, to) = entry.split_once("=>")?;
|
||||
let from = unquote(from.split_whitespace().next()?);
|
||||
let to = unquote(to.split_whitespace().next()?);
|
||||
let local = to.starts_with("./")
|
||||
|| to.starts_with("../")
|
||||
|| Path::new(&to).is_absolute()
|
||||
|| to == "."
|
||||
|| to == "..";
|
||||
local.then(|| (from, normalize(&base.join(to))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The arguments of `keyword` directives, single-line and block form.
|
||||
fn directives(text: &str, keyword: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_block = false;
|
||||
for line in text.lines() {
|
||||
let line = line.split("//").next().unwrap_or("").trim();
|
||||
if in_block {
|
||||
if line == ")" {
|
||||
in_block = false;
|
||||
} else if !line.is_empty() {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix(keyword)
|
||||
&& rest.starts_with([' ', '\t', '('])
|
||||
{
|
||||
let rest = rest.trim();
|
||||
if rest == "(" {
|
||||
in_block = true;
|
||||
} else if !rest.is_empty() {
|
||||
out.push(rest.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> String {
|
||||
s.trim_matches(|c| c == '"' || c == '`').to_owned()
|
||||
}
|
||||
|
||||
fn normalize(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) -> PathBuf {
|
||||
let path = root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn names(root: &Path, files: Vec<PathBuf>) -> Vec<String> {
|
||||
files.iter().map(|f| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_replacing_the_declarations_module_are_searched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let origin = write(root, "app.go", "package app\nfunc New() {}\n");
|
||||
write(root, "example/a/go.mod", "module a\n\nreplace example.com/app => ../..\n");
|
||||
write(root, "example/b/go.mod", "module b\n\nreplace (\n\texample.com/app v1.0.0 => \"../../\"\n)\n");
|
||||
write(root, "example/c/go.mod", "module c\n\nrequire example.com/app v1.0.0\n");
|
||||
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
|
||||
let candidates = vec![
|
||||
write(root, "same.go", "package app\n"),
|
||||
write(root, "same_plan9.go", "package app\n"),
|
||||
write(root, &format!("same_{}.go", host_goos()), "package app\n"),
|
||||
write(root, "same_plan9_test.go", "package app\n"),
|
||||
write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"),
|
||||
write(root, "plan9.go", "package app\n"),
|
||||
write(root, "example/a/main.go", "package main\n"),
|
||||
write(root, "example/b/main.go", "package main\n"),
|
||||
write(root, "example/c/main.go", "package main\n"),
|
||||
write(root, "example/d/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&origin, &origin, candidates)),
|
||||
["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_modules_are_covered_by_the_first_search() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.work", "go 1.22\n\nuse (\n\t./lib // shared\n\t./app\n)\n");
|
||||
write(root, "lib/go.mod", "module example.com/lib\n");
|
||||
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
|
||||
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
|
||||
let declaration = write(root, "lib/lib.go", "package lib\n");
|
||||
let candidates = vec![
|
||||
write(root, "app/main.go", "package main\n"),
|
||||
write(root, "app/main_plan9.go", "package main\n"),
|
||||
write(root, "tool/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&declaration, &declaration, candidates)),
|
||||
["app/main_plan9.go", "tool/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_usage_site_in_another_module_searches_the_declaring_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let declaration = write(root, "app.go", "package app\n");
|
||||
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
|
||||
let origin = write(root, "cmd/main.go", "package main\n");
|
||||
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
|
||||
assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_ranges_are_whole_words_outside_line_comments() {
|
||||
let text = "x := app.New() // New here\nNewer := New\n\tvar é = New()\n";
|
||||
assert_eq!(
|
||||
word_ranges(text, "New"),
|
||||
[
|
||||
Range::new(Pos::new(0, 9), Pos::new(0, 12)),
|
||||
Range::new(Pos::new(1, 9), Pos::new(1, 12)),
|
||||
Range::new(Pos::new(2, 9), Pos::new(2, 12)),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
@@ -53,7 +54,7 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use server::{Conn, Running, Server};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
@@ -506,8 +507,20 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// `begin_start` only claims Idle servers, so a claimed server is never a
|
||||
// live one. Its root is the process's working directory: starting with
|
||||
// one deleted from disk fails (`os error 267` on Windows). Such a
|
||||
// server can never serve again, so drop it instead of leaving a
|
||||
// permanently "Crashed" entry behind (e.g. the root vanishing during
|
||||
// the crash-restart backoff, or an open-file request arriving later).
|
||||
let Some(generation) = server.begin_start() else { return };
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
if !server.root.is_dir() {
|
||||
tracing::info!("{}: workspace root {} is gone, dropping", server.name, server.root.display());
|
||||
server.announce_down();
|
||||
self.forget(std::slice::from_ref(server));
|
||||
return;
|
||||
}
|
||||
let server = server.clone();
|
||||
self.runtime.spawn(async move { me.run_instance(server, generation).await });
|
||||
}
|
||||
@@ -704,6 +717,14 @@ impl LspManager {
|
||||
server.announce_down();
|
||||
return;
|
||||
}
|
||||
// The workspace root gone means this server can never serve again:
|
||||
// restarting would respawn with a dead working directory, so the entry
|
||||
// is dropped instead of staying "Crashed" forever.
|
||||
if !server.root.is_dir() {
|
||||
server.announce_down();
|
||||
self.forget(std::slice::from_ref(&server));
|
||||
return;
|
||||
}
|
||||
let tail = server.stderr_tail(5);
|
||||
let cfg = server.effective_config();
|
||||
let mut message = match (code, &cfg.connect) {
|
||||
@@ -1364,8 +1385,14 @@ impl LspManager {
|
||||
let mut first = Vec::new();
|
||||
if method == "textDocument/implementation" {
|
||||
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
if complete && !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
// A type declared in the sources has no implementations in the dependencies:
|
||||
// none found there is the whole answer, without a type hierarchy over every jar.
|
||||
if complete {
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
out.value = Some(Vec::new());
|
||||
continue;
|
||||
}
|
||||
if let Some(partial) = partial
|
||||
&& !locs.is_empty()
|
||||
@@ -1423,9 +1450,9 @@ impl LspManager {
|
||||
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
|
||||
}
|
||||
|
||||
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
|
||||
/// workspace's sources are searched (`libraries_skipped`); with it, those found
|
||||
/// there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources are
|
||||
/// searched (`libraries_skipped` when `project_wide`); with it and `project_wide`, those
|
||||
/// found there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn references_streaming(
|
||||
&self,
|
||||
@@ -1578,7 +1605,10 @@ impl LspManager {
|
||||
};
|
||||
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
|
||||
} else {
|
||||
let first = query(false).await?;
|
||||
// Bulk probes (usage counts, Safe Delete) ask about the sources' own symbols,
|
||||
// which no dependency uses: searching every jar and the JDK only adds seconds
|
||||
// per symbol and delays the searches the user asks for.
|
||||
let first = query(!libraries).await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
@@ -1679,6 +1709,19 @@ impl LspManager {
|
||||
}
|
||||
let mut v = out.finish(References::default())?;
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
if project_wide && let Some(t) = targets.first().filter(|_| snap.language.as_str() == "go") {
|
||||
// Usages in other modules take a view per module: those in reach come first.
|
||||
if let Some(partial) = partial
|
||||
&& !v.locations.is_empty()
|
||||
{
|
||||
partial(v.clone());
|
||||
}
|
||||
v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -2111,7 +2154,20 @@ impl LspManager {
|
||||
};
|
||||
let w = self.widened(snap, t, pos, cancel, query, files).await?;
|
||||
// The second edit covers the first: its files stay analysed.
|
||||
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
|
||||
let mut edit = w.second.unwrap_or(w.first);
|
||||
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
|
||||
for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations {
|
||||
more.entry(l.path.to_path_buf())
|
||||
.or_default()
|
||||
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let files =
|
||||
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
|
||||
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
|
||||
Ok(Renamed { edit, incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
@@ -2180,6 +2236,10 @@ impl LspManager {
|
||||
},
|
||||
);
|
||||
for file in candidate {
|
||||
// A move may have already removed the sampled entry on disk.
|
||||
if !file.is_file() {
|
||||
continue;
|
||||
}
|
||||
let language = self.languages.detect(&file, None);
|
||||
if !languages.contains(&language) {
|
||||
continue;
|
||||
@@ -2556,6 +2616,15 @@ impl LspManager {
|
||||
}
|
||||
HierarchyDirection::Outgoing => (query().await?, None),
|
||||
};
|
||||
let mut out = out;
|
||||
if direction == HierarchyDirection::Incoming {
|
||||
for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers {
|
||||
match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) {
|
||||
Some(item) => item.from_ranges.extend(caller.from_ranges),
|
||||
None => out.push(caller),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
@@ -3027,6 +3096,13 @@ impl LspManager {
|
||||
.find(|s| s.id == server)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
|
||||
if !s.root.is_dir() {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the workspace root of {} is gone: {}",
|
||||
s.name,
|
||||
s.root.display()
|
||||
)));
|
||||
}
|
||||
self.stop_instance(&s, false).await;
|
||||
s.reset_for_restart();
|
||||
self.ensure_started(&s);
|
||||
|
||||
+184
-3
@@ -10,9 +10,11 @@
|
||||
//! closed again. Diagnostics the server publishes for them are dropped.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::types::ReferencesIncomplete;
|
||||
use crate::text::DocText;
|
||||
use crate::types::{HierarchyItem, ReferencesIncomplete};
|
||||
use crate::{DocSnap, LspManager, Target};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
|
||||
use futures::StreamExt;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
|
||||
use lsp_types as lsp;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
@@ -51,6 +53,14 @@ pub struct CandidateFiles {
|
||||
/// blocking thread and should stop early when cancelled.
|
||||
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
|
||||
|
||||
/// What a Go search adds beyond gopls' own answer.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GoMentions {
|
||||
pub locations: Vec<Location>,
|
||||
/// The declarations containing them, as incoming calls (when asked for).
|
||||
pub callers: Vec<HierarchyItem>,
|
||||
}
|
||||
|
||||
/// A reference-based answer and, when files were opened for it, the second one.
|
||||
pub(crate) struct Widened<R> {
|
||||
pub first: R,
|
||||
@@ -110,7 +120,9 @@ impl LspManager {
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let busy = t.server.is_busy();
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
|
||||
// gopls searches the view of the file asked about; files opened for it only get views of
|
||||
// their own (see `go_scope`).
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
|
||||
let candidates = async {
|
||||
match word {
|
||||
Some(w) => self.candidates(t, w, cancel).await,
|
||||
@@ -162,6 +174,175 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
|
||||
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
|
||||
/// also the declarations containing them, as incoming calls.
|
||||
pub(crate) async fn go_mentions_beyond_search(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: &CancellationToken,
|
||||
callers: bool,
|
||||
) -> GoMentions {
|
||||
if snap.language.as_str() != "go" || !t.caps().open_close {
|
||||
return GoMentions::default();
|
||||
}
|
||||
let Some(word) = search_word(snap, pos) else { return GoMentions::default() };
|
||||
let declarations: Vec<Location> = self
|
||||
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|l| l.uri.is_none())
|
||||
.collect();
|
||||
let Some(declaration) = declarations.first() else { return GoMentions::default() };
|
||||
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
|
||||
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
|
||||
.into_iter()
|
||||
.filter_map(|f| {
|
||||
let text = std::fs::read_to_string(&f).ok()?;
|
||||
let ranges = crate::go_scope::word_ranges(&text, &word);
|
||||
(!ranges.is_empty()).then(|| (f, DocText::new(&text), ranges))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if files.is_empty() || cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
// Open, each file gives gopls a view to resolve its mentions in (its module, its build).
|
||||
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
|
||||
let texts: Vec<(PathBuf, Arc<DocText>)> =
|
||||
files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect();
|
||||
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
|
||||
.into_iter()
|
||||
.zip(&texts)
|
||||
.flat_map(|((path, _, ranges), (_, text))| {
|
||||
let text = text.clone();
|
||||
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
|
||||
})
|
||||
.collect();
|
||||
let declarations = &declarations;
|
||||
let found: Vec<Option<Location>> = futures::stream::iter(probes)
|
||||
.map(|(path, text, range)| async move {
|
||||
let params = json!({
|
||||
"textDocument": { "uri": crate::uri::path_to_uri_string(&path) },
|
||||
"position": text.ide_to_lsp(t.enc(), range.start),
|
||||
});
|
||||
let r = crate::loading::while_loading(
|
||||
&t.server,
|
||||
Some(cancel),
|
||||
crate::loading::LOADING_WAIT,
|
||||
crate::goto_is_empty,
|
||||
|| {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
"textDocument/definition",
|
||||
params.clone(),
|
||||
Some(cancel),
|
||||
crate::DEFAULT_TIMEOUT,
|
||||
)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let targets = match r {
|
||||
Ok(Some(r)) => crate::convert::goto_response(&self.conv(t.enc()), r),
|
||||
_ => return None,
|
||||
};
|
||||
let same = targets
|
||||
.iter()
|
||||
.any(|l| declarations.iter().any(|d| d.path == l.path && d.range.start == l.range.start));
|
||||
same.then(|| Location { path: path.into(), range, uri: None })
|
||||
})
|
||||
.buffer_unordered(8)
|
||||
.collect()
|
||||
.await;
|
||||
let locations: Vec<Location> = found.into_iter().flatten().collect();
|
||||
let mut out = GoMentions { callers: Vec::new(), locations };
|
||||
if callers && !cancel.is_cancelled() {
|
||||
for (path, text) in &texts {
|
||||
let here: Vec<ide_core::Range> =
|
||||
out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect();
|
||||
if !here.is_empty() {
|
||||
out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declarations of `path` containing `calls`, innermost first, each with its call sites.
|
||||
async fn go_callers_in(
|
||||
&self,
|
||||
t: &Target,
|
||||
path: &Path,
|
||||
text: &DocText,
|
||||
calls: &[ide_core::Range],
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<HierarchyItem> {
|
||||
let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } });
|
||||
let r: IdeResult<Option<lsp::DocumentSymbolResponse>> =
|
||||
self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
|
||||
let range =
|
||||
|r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end));
|
||||
// (name, kind, detail, range, selection range) of every declaration.
|
||||
let mut symbols = Vec::new();
|
||||
match r {
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => {
|
||||
let mut stack = nested;
|
||||
while let Some(s) = stack.pop() {
|
||||
symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range)));
|
||||
stack.extend(s.children.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => {
|
||||
for s in flat {
|
||||
let r = range(s.location.range);
|
||||
symbols.push((s.name, s.kind, None, r, r));
|
||||
}
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
let contains =
|
||||
|outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end;
|
||||
let mut items: Vec<HierarchyItem> = Vec::new();
|
||||
for call in calls {
|
||||
// The innermost function or method; a package-level declaration otherwise.
|
||||
let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD);
|
||||
let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| {
|
||||
symbols
|
||||
.iter()
|
||||
.filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call))
|
||||
.min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col)))
|
||||
};
|
||||
let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else {
|
||||
continue;
|
||||
};
|
||||
match items.iter_mut().find(|i| i.range == *r && i.name == *name) {
|
||||
Some(item) => item.from_ranges.push(*call),
|
||||
None => items.push(HierarchyItem {
|
||||
name: name.clone(),
|
||||
kind: crate::convert::symbol_kind(*kind),
|
||||
detail: detail.clone(),
|
||||
path: path.to_path_buf().into(),
|
||||
uri: None,
|
||||
range: *r,
|
||||
selection_range: *selection,
|
||||
from_ranges: vec![*call],
|
||||
}),
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Opens `files` on `t` (read from disk) unless the user has them open;
|
||||
/// they are closed when the returned guard drops. Files too large or not
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
|
||||
@@ -1235,6 +1235,61 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
|
||||
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
|
||||
}
|
||||
|
||||
/// The whole workspace root deleted: the server can never start again
|
||||
/// (its cwd is gone), so the crash drops the entry instead of restarting.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_crash_below_a_deleted_workspace_root_drops_the_server() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
|
||||
// A crash on a root that still exists restarts; removing it changes that.
|
||||
s.peer().kill();
|
||||
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
|
||||
eventually(|| s.launches.load(Ordering::SeqCst) == 2).await;
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
|
||||
std::fs::remove_dir_all(&h.root).unwrap();
|
||||
s.peer().kill();
|
||||
eventually(|| h.removed("mock")).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 2, "no restart with a dead working directory");
|
||||
}
|
||||
|
||||
/// The root deleted while a crash backoff is still sleeping: the pending
|
||||
/// restart must drop the server rather than leave a permanently "Crashed" row.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn root_deleted_during_crash_backoff_drops_the_server() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
|
||||
// Crash with the root alive: "Crashed" is published before the backoff
|
||||
// sleep, so removing the root here lands inside that window.
|
||||
s.peer().kill();
|
||||
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
|
||||
std::fs::remove_dir_all(&h.root).unwrap();
|
||||
eventually(|| h.removed("mock")).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart with a dead working directory");
|
||||
}
|
||||
|
||||
/// A manual restart with the root gone fails instead of spawning into a dead cwd.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn restarting_below_a_deleted_workspace_root_fails() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let id = h.mgr.servers()[0].id;
|
||||
|
||||
std::fs::remove_dir_all(&h.root).unwrap();
|
||||
let err = h.mgr.restart(id).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("workspace root"), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn missing_server_reports_once() {
|
||||
let h = harness(&[("ghost", json!({}))]);
|
||||
@@ -1836,10 +1891,11 @@ async fn java_implementations_come_from_sources_first() {
|
||||
.collect::<Value>()
|
||||
};
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
|
||||
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
|
||||
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
|
||||
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
|
||||
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
|
||||
Some(3) => json!({ "locations": [], "complete": false }),
|
||||
_ => json!({ "locations": [], "complete": true }),
|
||||
}),
|
||||
"textDocument/implementation" => Some(at(&[3, 1])),
|
||||
@@ -1870,7 +1926,14 @@ async fn java_implementations_come_from_sources_first() {
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
// Declared in the sources and implemented nowhere there: no search through the dependencies.
|
||||
let found = h.mgr.goto(doc, Pos::new(1, 6), GotoKind::Implementation).await.unwrap();
|
||||
assert!(found.is_empty());
|
||||
let markers = h.mgr.implementations(doc, &[Pos::new(1, 6)], Default::default()).await.unwrap();
|
||||
assert!(markers[0].is_empty());
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// None in the sources of a dependency's type: the standard request, with the dependencies.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
@@ -2922,6 +2985,165 @@ async fn references_open_unopened_files_that_mention_the_name() {
|
||||
assert_eq!(seen.lock().len(), 2);
|
||||
}
|
||||
|
||||
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
|
||||
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
|
||||
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf, PathBuf) {
|
||||
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("gopls");
|
||||
let write = |rel: &str, text: &str| {
|
||||
let path = h.root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
};
|
||||
write("go.mod", "module example.com/app\n");
|
||||
write("tools/go.mod", "module example.com/tools\n\nreplace example.com/app => ../\n");
|
||||
write("other/go.mod", "module example.com/other\n\nrequire example.com/app v1.0.0\n");
|
||||
let same = write("same.go", "package app\nvar x = New()\n");
|
||||
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
|
||||
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
|
||||
write("other/o.go", "package other\nvar b = app.New()\n");
|
||||
// Built for plan9 only: outside gopls' default view of the root module.
|
||||
let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n");
|
||||
let decl = h.root.join("a.go");
|
||||
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()];
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: candidates.clone(),
|
||||
limit_hit: false,
|
||||
}));
|
||||
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
|
||||
let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9));
|
||||
let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } });
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
|
||||
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
|
||||
let line = params["position"]["line"].as_u64().unwrap_or(0);
|
||||
match method {
|
||||
// The view of the root module: the declaration and its usage in same.go.
|
||||
"textDocument/references" => Some(json!([
|
||||
{ "uri": decl_uri, "range": at(0, 5) },
|
||||
{ "uri": same_uri, "range": at(1, 8) },
|
||||
])),
|
||||
"textDocument/rename" => Some(json!({ "changes": {
|
||||
decl_uri.clone(): [{ "range": at(0, 5), "newText": "Make" }],
|
||||
same_uri.clone(): [{ "range": at(1, 8), "newText": "Make" }],
|
||||
} })),
|
||||
"textDocument/definition" if uri == decl_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/definition" if uri == tools_uri && line == 1 => {
|
||||
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
|
||||
}
|
||||
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
|
||||
"textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/documentSymbol" if uri == tools_uri => Some(json!([
|
||||
{ "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) },
|
||||
{ "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) },
|
||||
])),
|
||||
"textDocument/documentSymbol" if uri == plan9_uri => Some(json!([
|
||||
{ "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) },
|
||||
])),
|
||||
"textDocument/prepareCallHierarchy" => Some(
|
||||
json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]),
|
||||
),
|
||||
"callHierarchy/incomingCalls" => Some(json!([{
|
||||
"from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) },
|
||||
"fromRanges": [at(1, 8)],
|
||||
}])),
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
(h, s, decl, same, tools, plan9)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
|
||||
let (h, s, decl, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let partials = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = partials.clone();
|
||||
let partial = move |r: ide_lsp::types::References| seen.lock().push(r.locations.len());
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(doc, Pos::new(0, 5), true, true, true, Default::default(), Some(&partial))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut found: Vec<(PathBuf, u32)> =
|
||||
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
|
||||
found.sort();
|
||||
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)];
|
||||
want.sort();
|
||||
assert_eq!(found, want);
|
||||
// Those in gopls' reach arrive first.
|
||||
assert_eq!(*partials.lock(), [2]);
|
||||
// Only the module replacing the declaration's module was opened, never the root module's file.
|
||||
let opened = uris(&s.notified("textDocument/didOpen"));
|
||||
assert!(opened.contains(&uri_of(&tools)));
|
||||
assert!(opened.contains(&uri_of(&plan9)));
|
||||
assert!(!opened.contains(&uri_of(&same)));
|
||||
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
|
||||
assert_eq!(s.requested("textDocument/references").len(), 1);
|
||||
|
||||
let renamed = h.mgr.rename(doc, Pos::new(0, 5), "Make".into(), Default::default()).await.unwrap();
|
||||
let mut edited: Vec<(PathBuf, Vec<u32>)> = renamed
|
||||
.edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, edits, .. } => {
|
||||
Some((path.to_path_buf(), edits.iter().map(|e| e.range.start.line).collect()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
edited.sort();
|
||||
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])];
|
||||
want.sort();
|
||||
assert_eq!(edited, want);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_incoming_calls_include_callers_beyond_the_search() {
|
||||
let (h, s, _, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let calls =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap();
|
||||
let mut callers: Vec<(PathBuf, String, Vec<u32>)> = calls
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect()))
|
||||
.collect();
|
||||
callers.sort();
|
||||
let mut want = vec![
|
||||
(same, "x".to_owned(), vec![1]),
|
||||
(tools, "a".to_owned(), vec![1]),
|
||||
(plan9.clone(), "boot".to_owned(), vec![1]),
|
||||
];
|
||||
want.sort();
|
||||
assert_eq!(callers, want);
|
||||
let boot = calls.items.iter().find(|i| i.name == "boot").unwrap();
|
||||
assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
|
||||
let (h, s, _, same, _, plan9) = go_modules_harness();
|
||||
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
|
||||
std::fs::remove_file(&plan9).unwrap();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 2);
|
||||
assert_eq!(uris(&s.notified("textDocument/didOpen")).len(), 1, "only a.go");
|
||||
assert!(s.requested("textDocument/definition").len() <= 1);
|
||||
assert!(!uris(&s.notified("textDocument/didOpen")).contains(&uri_of(&same)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn references_never_reopen_or_close_the_users_files() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -3100,9 +3322,17 @@ async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
|
||||
// Scoped probes (code lenses) search everything at once.
|
||||
// Scoped probes with the libraries search everything at once.
|
||||
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 2);
|
||||
// Usage counts and Safe Delete (no libraries): the sources alone, not widened.
|
||||
let scoped =
|
||||
h.mgr.references_streaming(doc, Pos::new(0, 5), true, false, false, Default::default(), None).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 1);
|
||||
assert!(scoped.locations.iter().all(|l| l.uri.is_none()));
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[5..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [None, Some(json!("sources"))]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! themselves are in `wit_host`.
|
||||
|
||||
use crate::types::{
|
||||
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument, SemanticToken,
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
|
||||
ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
@@ -15,8 +16,8 @@ use crate::types::{
|
||||
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{
|
||||
bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, syntax_bindings,
|
||||
views_bindings,
|
||||
bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
|
||||
services_bindings, syntax_bindings, views_bindings,
|
||||
};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
@@ -33,6 +34,8 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
use bindings::openidea::plugin::types as wit_types;
|
||||
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
|
||||
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
|
||||
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
|
||||
@@ -114,6 +117,22 @@ pub trait HostApi: Send + Sync {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
|
||||
/// The commit being prepared in `client`'s window (`None`: no repository),
|
||||
/// waiting for the window until `cancel` fires.
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let _ = (client, cancel);
|
||||
Ok(None)
|
||||
}
|
||||
/// Replaces the commit message of `client`'s window if its draft is still at
|
||||
/// `revision` (else [`HostError::Stale`]).
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let _ = (client, revision, message);
|
||||
Err(HostError::Invalid("no commit is being prepared".into()))
|
||||
}
|
||||
/// Subscribes to (or unsubscribes from) a kind of workbench event.
|
||||
/// Project files matching `glob` (root-relative), at most `limit`.
|
||||
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
|
||||
@@ -524,6 +543,8 @@ struct Exports {
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
services: Option<wit_svc::Guest>,
|
||||
hover: Option<wit_hover::Guest>,
|
||||
commit_checks: Option<wit_cc::Guest>,
|
||||
}
|
||||
|
||||
/// Most results one syntax call may return (more are dropped).
|
||||
@@ -605,6 +626,8 @@ impl Runtime {
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
|
||||
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
|
||||
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
@@ -631,9 +654,17 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let hover = match hover_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let commit_checks = match commit_check_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services },
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
})
|
||||
@@ -891,6 +922,29 @@ impl Runtime {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn provide_hover(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<PluginHover>, CallError> {
|
||||
if self.exports.hover.is_none() {
|
||||
return Err(CallError::missing_export("hover-provider"));
|
||||
}
|
||||
let d = wit_hover::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let hover = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.hover.as_ref().expect("checked").call_hover(store, &d, pos_to_wit(at))
|
||||
})?;
|
||||
Ok(hover
|
||||
.filter(|h| !h.markdown.trim().is_empty())
|
||||
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
|
||||
}
|
||||
|
||||
pub fn activate(&mut self) -> Result<(), CallError> {
|
||||
let data = self.store.data();
|
||||
let ctx = wit_types::Context {
|
||||
@@ -979,6 +1033,30 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the plugin's before-commit check `check` on `draft`.
|
||||
pub fn commit_check(
|
||||
&mut self,
|
||||
check: &str,
|
||||
draft: CommitDraft,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<CommitProblem>, CallError> {
|
||||
if self.exports.commit_checks.is_none() {
|
||||
return Err(CallError::missing_export("commit-checker"));
|
||||
}
|
||||
let draft = crate::wit_host::commit_draft_to_wit(draft);
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let checks = e.commit_checks.as_ref().expect("checked");
|
||||
let r = checks.call_check(&mut *store, check, &draft, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
match result {
|
||||
Ok(problems) => Ok(problems.into_iter().take(MAX_COMMIT_PROBLEMS).map(commit_problem_from_wit).collect()),
|
||||
Err(e) => Err(CallError::Guest(e.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A user action in a view or custom editor.
|
||||
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
|
||||
if self.exports.views.is_none() {
|
||||
@@ -1068,6 +1146,7 @@ impl Runtime {
|
||||
const MAX_PROVIDER_ITEMS: usize = 10_000;
|
||||
const MAX_SEMANTIC_TOKENS: usize = 1 << 16;
|
||||
const MAX_TOKEN_MODIFIERS: usize = 8;
|
||||
const MAX_HOVER_BYTES: usize = 64 * 1024;
|
||||
|
||||
fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position {
|
||||
wit_types::Position { line: p.line, character: p.col }
|
||||
@@ -1176,6 +1255,16 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
|
||||
const MAX_COMMIT_PROBLEMS: usize = 50;
|
||||
|
||||
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
|
||||
let (path, range) = match p.location {
|
||||
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
|
||||
None => (None, None),
|
||||
};
|
||||
CommitProblem { message: clip(p.message, 4096), path, range }
|
||||
}
|
||||
|
||||
const MAX_SERVICE_TABS: usize = 8;
|
||||
const MAX_LOG_TOGGLES: usize = 8;
|
||||
const MAX_LOG_ARGS: usize = 256;
|
||||
|
||||
@@ -210,6 +210,40 @@ pub struct RepositoryStatus {
|
||||
pub changed_files: u32,
|
||||
}
|
||||
|
||||
/// A file a pending commit changes (WIT `commit.commit-change`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitChange {
|
||||
pub repository: String,
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
|
||||
pub status: String,
|
||||
/// `git diff` of the included changes; `None` for binary content.
|
||||
pub diff: Option<String>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// What Commit would commit (WIT `commit.commit-draft`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitDraft {
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub changes: Vec<CommitChange>,
|
||||
}
|
||||
|
||||
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitProblem {
|
||||
pub message: String,
|
||||
/// Absolute path of the file it is in, if any (IPC form).
|
||||
pub path: Option<String>,
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
/// Resource limits and supervision policy for plugin code.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
@@ -426,6 +460,12 @@ pub struct SemanticToken {
|
||||
pub modifiers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PluginHover {
|
||||
pub markdown: String,
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -69,6 +70,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -96,6 +98,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
|
||||
vcs, views, workspace,
|
||||
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
|
||||
types as wit, ui, vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
@@ -571,6 +574,62 @@ impl vcs::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
impl commit::Host for StoreData {
|
||||
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
|
||||
let client = self.invocation.client.clone();
|
||||
let started = Instant::now();
|
||||
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::VcsWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if message.len() > MAX_COMMIT_MESSAGE {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest commit message a plugin may set.
|
||||
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
|
||||
|
||||
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
|
||||
commit::CommitDraft {
|
||||
revision: d.revision,
|
||||
message: d.message,
|
||||
amend: d.amend,
|
||||
changes: d
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| commit::CommitChange {
|
||||
repository: c.repository,
|
||||
path: c.path,
|
||||
old_path: c.old_path,
|
||||
status: c.status,
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
fn subscribe(&mut self, kind: String) -> Fallible<()> {
|
||||
let Some(kind) = EventKind::parse(&kind) else {
|
||||
@@ -828,9 +887,30 @@ pub(crate) mod services_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `commit-checker` export.
|
||||
pub(crate) mod commit_checks_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "commit-checks", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod semantic_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "semantic-tokens", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod hover_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "hover", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ fn components() -> Vec<PathBuf> {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
|
||||
];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Commit test fixture
|
||||
|
||||
`Describe Changes` (in the `commit.message` toolbar) reads the commit being
|
||||
prepared and writes a message listing its files; the `markers` before-commit
|
||||
check reports a message without a `type: ` prefix and every `DO NOT COMMIT`
|
||||
added by the commit.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_commit` to exercise the `commit`
|
||||
interface and the optional `commit-checker` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
|
||||
@@ -0,0 +1,21 @@
|
||||
id = "test.commit"
|
||||
name = "Commit fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["vcs.read", "vcs.write"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "describe"
|
||||
title = "Commit Fixture: Describe Changes"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "commit.message"
|
||||
command = "describe"
|
||||
|
||||
[[contributes.commitChecks]]
|
||||
id = "markers"
|
||||
title = "Commit Markers"
|
||||
@@ -0,0 +1,57 @@
|
||||
use openidea_plugin::commit::{CommitChecks, CommitProblem};
|
||||
use openidea_plugin::{CommitDraft, Error, Invocation, Location, Plugin, Position, Range, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id != "describe" {
|
||||
return Err(Error::NotFound(id.into()));
|
||||
}
|
||||
let draft = host::pending_commit()?.ok_or("no repository")?;
|
||||
let names: Vec<String> = draft
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| format!("{} {}", c.status, c.path.rsplit('/').next().unwrap_or(&c.path)))
|
||||
.collect();
|
||||
let message = format!("chore: {}", names.join(", "));
|
||||
host::set_commit_message(draft.revision, &message)?;
|
||||
Ok(Some(message))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitChecks for Fixture {
|
||||
fn check(&mut self, check: &str, draft: &CommitDraft, _: &Invocation) -> Result<Vec<CommitProblem>, Error> {
|
||||
if check != "markers" {
|
||||
return Err(Error::NotFound(check.into()));
|
||||
}
|
||||
let mut problems = Vec::new();
|
||||
if !draft.message.split_once(": ").is_some_and(|(t, _)| !t.is_empty() && !t.contains(' ')) {
|
||||
problems.push(CommitProblem::new("The message does not start with `type: `."));
|
||||
}
|
||||
for change in &draft.changes {
|
||||
let Some(diff) = &change.diff else { continue };
|
||||
let mut line = 0u32;
|
||||
for l in diff.lines() {
|
||||
if let Some(hunk) = l.strip_prefix("@@ -") {
|
||||
// `@@ -a,b +c,d @@`: c is the 1-based first line of the new side.
|
||||
let new = hunk.split(" +").nth(1).and_then(|s| s.split([',', ' ']).next());
|
||||
line = new.and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).saturating_sub(1);
|
||||
} else if l.starts_with('+') && !l.starts_with("+++") {
|
||||
if l.contains("DO NOT COMMIT") {
|
||||
let at = Position::new(line, 0);
|
||||
let location = Location { path: change.path.clone(), range: Range::new(at, at) };
|
||||
problems.push(CommitProblem::new("Remove the DO NOT COMMIT marker.").at(location));
|
||||
}
|
||||
line += 1;
|
||||
} else if !l.starts_with('-') {
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, commit_checks);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450
|
||||
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-hover"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-hover"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Hover test fixture
|
||||
|
||||
Over a name the PHP document declares as `function <name>(`, shows
|
||||
`**<name>** · declared on line <n>` for the name's range; nothing elsewhere.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_hover` to exercise the optional
|
||||
`hover-provider` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
|
||||
@@ -0,0 +1,9 @@
|
||||
id = "test.hover"
|
||||
name = "Hover fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["hover"]
|
||||
languages = ["php"]
|
||||
@@ -0,0 +1,31 @@
|
||||
use openidea_plugin::hover::{Document, Hover, HoverProvider};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin, text};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl HoverProvider for Fixture {
|
||||
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
|
||||
let text = doc.text.as_str();
|
||||
let offset = text::offset(text, at)?;
|
||||
let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
|
||||
let start = text[..offset].rfind(|c: char| !word(c)).map_or(0, |i| i + 1);
|
||||
let end = text[offset..].find(|c: char| !word(c)).map_or(text.len(), |n| offset + n);
|
||||
let name = &text[start..end];
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let declared = text.find(&format!("function {name}("))?;
|
||||
let line = text::position(text, declared).line + 1;
|
||||
let range = Range { start: text::position(text, start), end: text::position(text, end) };
|
||||
Some(Hover::new(format!("**{name}** · declared on line {line}")).range(range))
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, hover);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c913c2c94fecb43d00f73f3171cc2fda54caf7a28f170424b0addb1d40ed4cae
|
||||
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
911388ae62843d7b1e4c40d91307fbe507535c60680a9626cc07d724b75a9dfb
|
||||
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a3818b6ec40df8f141290537a7c024aadb02d7be1eb063ca9170799ca3271898
|
||||
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
9e4c12fdf905227128d97e92639cf336124929c37384e5f5796e27ebfc5a7b37
|
||||
c58bf55470bd795f9e664ad3a8bd1173c863b1b9405d2e81f1da1c4039884dd3
|
||||
@@ -1048,10 +1048,15 @@ fn is_composer_event(name: &str) -> bool {
|
||||
name.starts_with("pre-") || name.starts_with("post-") || name == "command"
|
||||
}
|
||||
|
||||
/// The PHPUnit entry point: the project's `vendor/bin/phpunit`, else a global `phpunit`.
|
||||
fn phpunit_command(root: &Path) -> String {
|
||||
let bin = if cfg!(windows) { "vendor/bin/phpunit.bat" } else { "vendor/bin/phpunit" };
|
||||
if root.join(bin).is_file() { bin.to_owned() } else { "phpunit".to_owned() }
|
||||
/// The PHPUnit entry point: the project's `vendor/bin/pest` or `vendor/bin/phpunit`, else a global `phpunit`.
|
||||
fn phpunit_command(root: &Path) -> (String, &'static str) {
|
||||
for (runner, label) in [("pest", "Pest"), ("phpunit", "PHPUnit")] {
|
||||
let bin = format!("vendor/bin/{runner}{}", if cfg!(windows) { ".bat" } else { "" });
|
||||
if root.join(&bin).is_file() {
|
||||
return (bin, label);
|
||||
}
|
||||
}
|
||||
("phpunit".to_owned(), "PHPUnit")
|
||||
}
|
||||
|
||||
/// Composer projects (`composer.json`) and PHPUnit suites (`phpunit.xml`, `phpunit.xml.dist`).
|
||||
@@ -1085,8 +1090,14 @@ fn detect_php(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
);
|
||||
out.push(if name == "test" { c.test() } else { c });
|
||||
}
|
||||
if phpunit_xml.is_some() || requires("phpunit/phpunit") || root.join("vendor/bin/phpunit").is_file() {
|
||||
let mut c = config("phpunit", "PHPUnit", "phpunit", &phpunit_command(root), &[]).test().debug("php-debug");
|
||||
if phpunit_xml.is_some()
|
||||
|| requires("phpunit/phpunit")
|
||||
|| requires("pestphp/pest")
|
||||
|| root.join("vendor/bin/phpunit").is_file()
|
||||
|| root.join("vendor/bin/pest").is_file()
|
||||
{
|
||||
let (command, label) = phpunit_command(root);
|
||||
let mut c = config("phpunit", label, "phpunit", &command, &[]).test().debug("php-debug");
|
||||
// A configuration file with a non-default name must be passed explicitly.
|
||||
let named = phpunit_xml.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned());
|
||||
if let Some(file) = named.filter(|f| f == "phpunit.dist.xml") {
|
||||
|
||||
@@ -633,15 +633,18 @@ impl ProcessManager {
|
||||
self.stop_with_grace(id, STOP_GRACE)
|
||||
}
|
||||
|
||||
/// [`Self::stop`] with an explicit grace period before the kill. A configuration's
|
||||
/// stop command runs first; signals follow only if the process outlives it.
|
||||
/// [`Self::stop`] with an explicit grace period for the configured stop command. The
|
||||
/// first stop only asks the process tree to exit (stop command, then `SIGINT`) and waits;
|
||||
/// a second stop kills it.
|
||||
pub fn stop_with_grace(&self, id: RunId, grace: Duration) -> IdeResult<()> {
|
||||
let entry = self.entry(id)?;
|
||||
if entry.exited.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
let already_stopping = entry.stopping.swap(true, Ordering::AcqRel);
|
||||
let Some(pid) = *entry.pid.lock() else { return Ok(()) };
|
||||
// A run that ended by itself can leave members of its group behind: a Stop must
|
||||
// still be able to reach them.
|
||||
if entry.exited.load(Ordering::Acquire) && !group_alive(pid, &entry) {
|
||||
return Ok(());
|
||||
}
|
||||
if already_stopping {
|
||||
// Second stop request: don't wait out the grace period.
|
||||
kill_tree(pid, true);
|
||||
@@ -671,9 +674,11 @@ impl ProcessManager {
|
||||
}
|
||||
kill_tree(pid, false);
|
||||
}
|
||||
if !exits_within(grace) {
|
||||
tracing::debug!(id = %entry.id, pid, "process ignored interrupt; killing");
|
||||
kill_tree(pid, true);
|
||||
// A first Stop only asks the process tree to exit, as IDEA does: it waits for
|
||||
// the shutdown to finish (a Spring Boot app can take seconds) and a second Stop
|
||||
// is what kills. Killing on a timer here cut shutdown logs off with SIGKILL.
|
||||
while group_alive(pid, &entry) {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
})
|
||||
.map_err(|e| IdeError::internal(format!("failed to start stop thread: {e}")))?;
|
||||
@@ -1061,6 +1066,18 @@ fn spawn_pump(
|
||||
if let Some(pty) = &entry.pty {
|
||||
pty.lock().take();
|
||||
}
|
||||
// A stopped run is over when the whole process group is gone, not when the child we
|
||||
// spawned exits: a shell or `mvn` can end while the application it started keeps its
|
||||
// port, and reporting "finished" then would let Run start into an occupied port.
|
||||
// A second Stop kills the group, which ends this wait.
|
||||
#[cfg(unix)]
|
||||
if entry.stopping.load(Ordering::Acquire)
|
||||
&& let Some(pid) = *entry.pid.lock()
|
||||
{
|
||||
while group_alive(pid, &entry) {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
entry.exited.store(true, Ordering::Release);
|
||||
tracing::debug!(id = %entry.id, ?code, "run exited");
|
||||
sink.emit(IdeEvent::RunExited { run: entry.id, code });
|
||||
|
||||
@@ -827,6 +827,27 @@ fn php_phpunit_project() {
|
||||
assert!(!find(&c, "composer:lint").is_test);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_pest_project() {
|
||||
let dir = project(&[
|
||||
("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#),
|
||||
("phpunit.xml", "<phpunit/>"),
|
||||
("vendor/bin/phpunit", ""),
|
||||
("vendor/bin/phpunit.bat", ""),
|
||||
("vendor/bin/pest", ""),
|
||||
("vendor/bin/pest.bat", ""),
|
||||
]);
|
||||
let c = detect(dir.path());
|
||||
let pest = find(&c, "phpunit");
|
||||
assert_eq!(pest.name, "Pest");
|
||||
assert_eq!(cmdline(pest), if cfg!(windows) { "vendor/bin/pest.bat" } else { "vendor/bin/pest" });
|
||||
assert_eq!(pest.kind, "phpunit");
|
||||
assert!(pest.is_test);
|
||||
assert_eq!(pest.debug_adapter.as_deref(), Some("php-debug"));
|
||||
let dir = project(&[("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#)]);
|
||||
assert_eq!(find(&detect(dir.path()), "phpunit").name, "PHPUnit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_detection_needs_a_marker() {
|
||||
let dir = project(&[("index.php", "<?php")]);
|
||||
|
||||
@@ -204,6 +204,9 @@ fn stop_kills_whole_process_group() {
|
||||
let grandchild: i32 = out.trim().parse().expect("pid");
|
||||
assert!(alive(grandchild));
|
||||
|
||||
// The first Stop only asks the group to exit (and waits); the second one kills it.
|
||||
h.pm.stop(id).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
h.pm.stop(id).unwrap();
|
||||
wait_exit(&h, id, Duration::from_secs(10));
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! A stopped run reports its exit only once the whole process group is gone, so the next
|
||||
//! start cannot meet a port the previous process still holds.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::{IdeEvent, RunId, SharedSink};
|
||||
use ide_run::{ProcessManager, RunConfig, StartOptions};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn channel_sink() -> (SharedSink, Receiver<IdeEvent>) {
|
||||
let (tx, events) = mpsc::channel();
|
||||
let tx = Mutex::new(tx);
|
||||
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
|
||||
let _ = tx.lock().send(e);
|
||||
});
|
||||
(sink, events)
|
||||
}
|
||||
|
||||
fn wait_exit(events: &Receiver<IdeEvent>, id: RunId, timeout: Duration) -> Option<i32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
match events.recv_timeout(left).expect("RunExited event") {
|
||||
IdeEvent::RunExited { run, code } if run == id => return code,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(what: &str, mut ready: impl FnMut() -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ready() {
|
||||
assert!(Instant::now() < deadline, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_still_running(events: &Receiver<IdeEvent>, timeout: Duration) {
|
||||
let event = events.recv_timeout(timeout);
|
||||
assert!(
|
||||
matches!(event, Err(mpsc::RecvTimeoutError::Timeout)),
|
||||
"run exited before shutdown was released: {event:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_run_keeps_its_port_until_the_group_is_gone() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
// The `mvn`-starts-a-JVM shape: the direct child dies on SIGINT while the process it
|
||||
// started ignores it and keeps its socket open until the test releases it.
|
||||
let script = tmp.path().join("hold.py");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import pathlib, signal, socket, sys, time\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
s = socket.socket(); s.bind(('127.0.0.1', 0)); s.listen()\n\
|
||||
open(sys.argv[1], 'w').write(str(s.getsockname()[1]))\n\
|
||||
while not pathlib.Path(sys.argv[2]).exists(): time.sleep(0.01)\n",
|
||||
)
|
||||
.unwrap();
|
||||
let port_file = tmp.path().join("port");
|
||||
let release_file = tmp.path().join("release");
|
||||
let config = RunConfig {
|
||||
id: "boot".into(),
|
||||
name: "boot".into(),
|
||||
kind: "shell".into(),
|
||||
command: "/bin/sh".into(),
|
||||
args: vec![
|
||||
"-c".into(),
|
||||
"python3 \"$1\" \"$2\" \"$3\" & sleep 60".into(),
|
||||
"sh".into(),
|
||||
script.display().to_string(),
|
||||
port_file.display().to_string(),
|
||||
release_file.display().to_string(),
|
||||
],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
wait_for("the listening port", || {
|
||||
std::fs::read_to_string(&port_file).ok().is_some_and(|text| text.trim().parse::<u16>().is_ok())
|
||||
});
|
||||
let port: u16 = std::fs::read_to_string(&port_file).expect("port").trim().parse().unwrap();
|
||||
|
||||
pm.stop(info.id).unwrap();
|
||||
let pid = info.pid.expect("child pid") as libc::pid_t;
|
||||
wait_for("the direct child to exit", || unsafe { libc::kill(pid, 0) != 0 });
|
||||
let exit = events.recv_timeout(Duration::from_secs(1));
|
||||
let port_held = std::net::TcpStream::connect(("127.0.0.1", port)).is_ok();
|
||||
std::fs::write(&release_file, "").unwrap();
|
||||
assert!(
|
||||
matches!(exit, Err(mpsc::RecvTimeoutError::Timeout)),
|
||||
"run exited while its descendant held the port: {exit:?}"
|
||||
);
|
||||
assert!(port_held, "the descendant still holds its port");
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(20));
|
||||
assert!(code.is_some(), "the run reports its exit");
|
||||
assert!(
|
||||
std::net::TcpStream::connect(("127.0.0.1", port)).is_err(),
|
||||
"the port is still bound when the run reports finished"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
// Ignores SIGINT until stdin releases the shutdown, like a slow Spring Boot shutdown.
|
||||
// A first Stop must wait even past the grace period instead of killing it (exit code 137).
|
||||
let script = tmp.path().join("slow.py");
|
||||
let ready_file = tmp.path().join("ready");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import pathlib, signal, sys\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
pathlib.Path(sys.argv[1]).touch()\n\
|
||||
sys.stdin.readline()\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = RunConfig {
|
||||
id: "slow".into(),
|
||||
name: "slow".into(),
|
||||
kind: "shell".into(),
|
||||
command: "python3".into(),
|
||||
args: vec![script.display().to_string(), ready_file.display().to_string()],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
wait_for("the SIGINT handler", || ready_file.exists());
|
||||
pm.stop_with_grace(info.id, Duration::from_millis(100)).unwrap();
|
||||
assert_still_running(&events, Duration::from_secs(1));
|
||||
pm.write_stdin(info.id, "finish shutdown\n").unwrap();
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(10));
|
||||
assert_eq!(code, Some(0), "the program exited by itself, not by SIGKILL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_stop_kills_a_program_that_ignores_the_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (sink, events) = channel_sink();
|
||||
let pm = ProcessManager::new();
|
||||
let script = tmp.path().join("stuck.py");
|
||||
let ready_file = tmp.path().join("ready");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"import pathlib, signal, sys\n\
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)\n\
|
||||
pathlib.Path(sys.argv[1]).touch()\n\
|
||||
sys.stdin.readline()\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = RunConfig {
|
||||
id: "stuck".into(),
|
||||
name: "stuck".into(),
|
||||
kind: "shell".into(),
|
||||
command: "python3".into(),
|
||||
args: vec![script.display().to_string(), ready_file.display().to_string()],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start");
|
||||
wait_for("the SIGINT handler", || ready_file.exists());
|
||||
pm.stop(info.id).unwrap();
|
||||
assert_still_running(&events, Duration::from_millis(200));
|
||||
pm.stop(info.id).unwrap();
|
||||
let code = wait_exit(&events, info.id, Duration::from_secs(10));
|
||||
assert_eq!(code, Some(137), "the second Stop SIGKILLs");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1087,8 +1099,19 @@ impl Repo {
|
||||
/// Creates branch `name` at `start` (default HEAD), optionally switching to it. A
|
||||
/// remote-tracking start point (`origin/x`) becomes the new branch's upstream.
|
||||
pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
|
||||
self.create_branch_with_overwrite(name, start, checkout, false)
|
||||
}
|
||||
|
||||
/// Creates a branch or moves its tip when overwrite is explicitly enabled.
|
||||
pub fn create_branch_with_overwrite(
|
||||
&self,
|
||||
name: &str,
|
||||
start: Option<&str>,
|
||||
checkout: bool,
|
||||
overwrite: bool,
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::create_branch(self, name, start, checkout)
|
||||
write::create_branch(self, name, start, checkout, overwrite)
|
||||
}
|
||||
/// Undo Commit: resets the current branch to the parent of `commit` (which must be
|
||||
/// HEAD and not a merge) keeping its changes staged; undoing the initial commit
|
||||
|
||||
@@ -15,6 +15,11 @@ pub(crate) struct Cache {
|
||||
repo: gix::ThreadSafeRepository,
|
||||
files: [PathBuf; 4],
|
||||
stamp: Vec<Option<(SystemTime, u64)>>,
|
||||
/// `objects/pack` directory and its modification time. An external
|
||||
/// `git fetch`/`pull`/`repack` adds packs without touching the `files`
|
||||
/// above — watching the directory catches them (issue #1919).
|
||||
pack_dir: PathBuf,
|
||||
pack_stamp: Option<SystemTime>,
|
||||
}
|
||||
|
||||
fn cache_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 4] {
|
||||
@@ -36,6 +41,12 @@ fn stamp(files: &[PathBuf]) -> Vec<Option<(SystemTime, u64)>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pack directory's modification time: adding or removing a pack file
|
||||
/// updates it. `None` when the directory can't be read.
|
||||
fn pack_stamp(pack_dir: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(pack_dir).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let start = if path.is_file() { path.parent().unwrap_or(path) } else { path };
|
||||
if let Err(e) = gix::discover::upwards(start) {
|
||||
@@ -57,7 +68,8 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let git_dir = absolute(repo.git_dir())?;
|
||||
let common_dir = absolute(repo.common_dir())?;
|
||||
let files = cache_files(&git_dir, &common_dir);
|
||||
let cache = Cache { stamp: stamp(&files), files, repo: ts };
|
||||
let pack_dir = common_dir.join("objects").join("pack");
|
||||
let cache = Cache { stamp: stamp(&files), files, repo: ts, pack_stamp: pack_stamp(&pack_dir), pack_dir };
|
||||
Ok(Some(Repo {
|
||||
execution_allowed: std::sync::atomic::AtomicBool::new(true),
|
||||
root,
|
||||
@@ -96,11 +108,13 @@ impl Repo {
|
||||
}
|
||||
let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let now = stamp(&cache.files);
|
||||
if now != cache.stamp {
|
||||
let pack_now = pack_stamp(&cache.pack_dir);
|
||||
if now != cache.stamp || pack_now != cache.pack_stamp {
|
||||
match gix::ThreadSafeRepository::open(&self.git_dir) {
|
||||
Ok(fresh) => {
|
||||
cache.repo = fresh;
|
||||
cache.stamp = now;
|
||||
cache.pack_stamp = pack_now;
|
||||
}
|
||||
Err(e) => tracing::warn!("reopening repository failed: {e}"),
|
||||
}
|
||||
|
||||
+153
-11
@@ -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<()> {
|
||||
@@ -641,7 +772,13 @@ fn stash_count(r: &Repo) -> IdeResult<usize> {
|
||||
}
|
||||
|
||||
/// Creates `name` at `start` (default HEAD); a remote-tracking start point is tracked.
|
||||
pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
|
||||
pub(crate) fn create_branch(
|
||||
r: &Repo,
|
||||
name: &str,
|
||||
start: Option<&str>,
|
||||
checkout: bool,
|
||||
overwrite: bool,
|
||||
) -> IdeResult<()> {
|
||||
let name = name.trim();
|
||||
let valid = !name.is_empty()
|
||||
&& !name.starts_with('-')
|
||||
@@ -658,7 +795,12 @@ pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout:
|
||||
let repo = r.gix()?;
|
||||
resolve(&repo, s)?;
|
||||
}
|
||||
let mut args = if checkout { vec!["switch", "-c", name] } else { vec!["branch", "--", name] };
|
||||
let mut args = match (checkout, overwrite) {
|
||||
(true, false) => vec!["switch", "-c", name],
|
||||
(true, true) => vec!["switch", "-C", name],
|
||||
(false, false) => vec!["branch", "--", name],
|
||||
(false, true) => vec!["branch", "--force", "--", name],
|
||||
};
|
||||
args.extend(start);
|
||||
run(r.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -950,6 +950,36 @@ fn branch_create_checkout_merge_rebase_delete() {
|
||||
assert!(!repo.branches().unwrap().iter().any(|b| b.name == "unmerged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "base\n");
|
||||
let base = fx.commit_all("base");
|
||||
fx.write("a.txt", "main\n");
|
||||
let tip = fx.commit_all("main");
|
||||
let repo = fx.repo();
|
||||
repo.create_branch("topic", None, false).unwrap();
|
||||
|
||||
repo.create_branch_with_overwrite("topic", Some(&base), false, true).unwrap();
|
||||
assert_eq!(fx.git(&["rev-parse", "topic"]), base);
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
|
||||
|
||||
repo.create_branch_with_overwrite("topic", Some(&tip), false, true).unwrap();
|
||||
fx.write("a.txt", "dirty\n");
|
||||
assert!(repo.create_branch_with_overwrite("topic", Some(&base), true, true).is_err());
|
||||
assert_eq!(fx.git(&["rev-parse", "topic"]), tip);
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
|
||||
assert_eq!(fx.read("a.txt"), "dirty\n");
|
||||
|
||||
fx.write("a.txt", "main\n");
|
||||
repo.create_branch_with_overwrite("topic", Some(&base), true, true).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic"));
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD"]), base);
|
||||
|
||||
repo.create_branch_with_overwrite("topic", Some(&tip), true, true).unwrap();
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_branch_from_remote_branch_tracks_it() {
|
||||
let (fx, _bare) = Fixture::with_origin();
|
||||
|
||||
@@ -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();
|
||||
@@ -1366,6 +1431,30 @@ fn status_reflects_external_changes_without_reopen() {
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_in_newly_written_packs_stay_visible() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a", "1\n");
|
||||
fx.commit_all("a");
|
||||
let repo = fx.repo();
|
||||
assert_eq!(repo.branches().unwrap().len(), 1);
|
||||
|
||||
// An external pull: objects move into a pack the IDE has never seen, then a
|
||||
// new branch points at a commit living only in that pack.
|
||||
fx.write("a", "2\n");
|
||||
fx.commit_all("b");
|
||||
fx.git(&["repack", "-adq"]);
|
||||
fx.git(&["checkout", "-q", "-b", "uat"]);
|
||||
let uat = fx.git(&["rev-parse", "uat"]);
|
||||
assert!(std::fs::read_dir(fx.path(".git/objects/pack")).unwrap().count() >= 2);
|
||||
|
||||
let names: Vec<String> = repo.branches().unwrap().into_iter().map(|b| b.name).collect();
|
||||
assert!(names.contains(&"uat".to_string()), "branches: {names:?}, want uat at {uat}");
|
||||
|
||||
// Status peels HEAD for the tree-index diff: the same failure drops it too.
|
||||
assert!(repo.status().is_ok(), "status failed: {:?}", repo.status().unwrap_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_worktree_and_submodule() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
@@ -117,6 +117,13 @@ impl Watcher {
|
||||
self.polling.as_deref()
|
||||
}
|
||||
|
||||
/// A watch failure past startup (the OS watch limit ran out mid-tree):
|
||||
/// directories past the limit never sync (issue #1915). `None` while every
|
||||
/// directory has a live watch.
|
||||
pub fn unwatched_reason(&self) -> Option<String> {
|
||||
self.inner.state.exhausted.lock().clone()
|
||||
}
|
||||
|
||||
/// Replaces the exclude globs (Settings › Project Structure / File Types
|
||||
/// changed): later events below newly excluded folders are dropped. In
|
||||
/// per-directory mode, folders no longer excluded are watched once they change.
|
||||
@@ -163,6 +170,7 @@ impl Watcher {
|
||||
queue: Mutex::default(),
|
||||
wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
exhausted: Mutex::new(None),
|
||||
});
|
||||
let weak: Weak<DirState> = Arc::downgrade(&state);
|
||||
let watcher = W::new(
|
||||
@@ -234,8 +242,9 @@ const MAX_WATCHES_HINT: &str = "the inotify watch limit is exhausted (fs.inotify
|
||||
/// instance per watcher (`EMFILE`, os error 24) or one watch per directory
|
||||
/// (`ENOSPC`, mapped by notify to [`ErrorKind::MaxFilesWatch`]). A bare OS
|
||||
/// error sends users off to search engines otherwise (issue #788).
|
||||
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
let hint = match error.kind {
|
||||
/// The fix hint when an OS watch limit is hit, or `None` for other errors.
|
||||
fn watch_limit_hint(error: ¬ify::Error) -> Option<&'static str> {
|
||||
match error.kind {
|
||||
ErrorKind::MaxFilesWatch => Some(MAX_WATCHES_HINT),
|
||||
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(24) => Some(
|
||||
"the inotify instance limit is exhausted (fs.inotify.max_user_instances); raise it, e.g. \
|
||||
@@ -243,8 +252,11 @@ fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
),
|
||||
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(28) => Some(MAX_WATCHES_HINT),
|
||||
_ => None,
|
||||
};
|
||||
match hint {
|
||||
}
|
||||
}
|
||||
|
||||
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
|
||||
match watch_limit_hint(&error) {
|
||||
Some(hint) => io_error(format!("{what}: {error}; {hint}")),
|
||||
None => io_error(format!("{what}: {error}")),
|
||||
}
|
||||
@@ -259,6 +271,10 @@ struct DirState {
|
||||
queue: Mutex<Vec<(notify::Event, Instant)>>,
|
||||
wake: Condvar,
|
||||
stop: AtomicBool,
|
||||
/// Set when a `watch()` call reports the OS watch limit (inotify
|
||||
/// `ENOSPC`/`EMFILE`): later directories are never seen, so the project is
|
||||
/// silently stale past that point — surfaced via [`Watcher::unwatched_reason`].
|
||||
exhausted: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl DirState {
|
||||
@@ -362,7 +378,15 @@ impl DirState {
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("cannot watch {}: {e}", dir.display());
|
||||
if let Some(hint) = watch_limit_hint(&e) {
|
||||
tracing::warn!("cannot watch {}: {e}", dir.display());
|
||||
// Recorded, not delivered: the watch may fail after start,
|
||||
// on the debounce thread — the UI picks it up via
|
||||
// `unwatched_reason()`.
|
||||
*self.exhausted.lock() = Some(format!("cannot watch {}: {e}; {hint}", dir.display()));
|
||||
} else {
|
||||
tracing::debug!("cannot watch {}: {e}", dir.display());
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1320,4 +1344,62 @@ mod tests {
|
||||
let other = notify::Error::io(std::io::Error::from_raw_os_error(13));
|
||||
assert!(!watcher_error("cannot start file watcher", other).message.contains("inotify"));
|
||||
}
|
||||
|
||||
/// Watch calls succeed until `budget` is spent, then `ENOSPC` — mirrors
|
||||
/// `fs.inotify.max_user_watches` running out part-way through a tree.
|
||||
struct ExhaustedWatcher {
|
||||
budget: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl notify::Watcher for ExhaustedWatcher {
|
||||
fn new<F: notify::EventHandler>(_: F, _: notify::Config) -> notify::Result<Self> {
|
||||
Ok(Self { budget: std::sync::atomic::AtomicUsize::new(3) })
|
||||
}
|
||||
|
||||
fn watch(&mut self, _dir: &Path, _: RecursiveMode) -> notify::Result<()> {
|
||||
if self.budget.fetch_sub(1, Ordering::SeqCst) == 0 {
|
||||
return Err(notify::Error::new(ErrorKind::MaxFilesWatch));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kind() -> WatcherKind {
|
||||
WatcherKind::NullWatcher
|
||||
}
|
||||
}
|
||||
|
||||
/// #1915: a watch refused mid-tree must not stay silent — the limit is
|
||||
/// reported through `unwatched_reason` so the UI can warn like the
|
||||
/// polling fallback does.
|
||||
#[test]
|
||||
fn unwatched_reason_reports_the_watch_limit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for d in ["a", "b", "c", "d"] {
|
||||
std::fs::create_dir_all(root.join(d)).unwrap();
|
||||
}
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone());
|
||||
let (tx, _rx) = std::sync::mpsc::channel::<FsBatch>();
|
||||
let emit: Emit = Arc::new(move |b| {
|
||||
let _ = tx.send(b.clone());
|
||||
});
|
||||
let inner = Watcher::start_queued::<ExhaustedWatcher>(
|
||||
&root,
|
||||
filter,
|
||||
emit,
|
||||
WatchMode::PerDirectory,
|
||||
notify::Config::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let watcher = Watcher { inner, excludes, rules, polling: None };
|
||||
assert!(watcher.polling().is_none(), "the OS watcher did start");
|
||||
let reason = watcher.unwatched_reason().expect("watch limit must be reported");
|
||||
assert!(reason.contains("max_user_watches"), "{reason}");
|
||||
assert!(reason.contains("cannot watch"), "{reason}");
|
||||
}
|
||||
}
|
||||
+189
-1
@@ -19,6 +19,7 @@ const TASK = {
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
||||
};
|
||||
|
||||
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -65,7 +66,15 @@ async function openReply(page: Page, text: string, imageData?: string) {
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, [{ type: "agent", text }]]]),
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
|
||||
{ type: "agent", text },
|
||||
],
|
||||
],
|
||||
]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
@@ -149,3 +158,182 @@ test("loads local and web reply images", async ({ page }) => {
|
||||
.toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("copies reply Markdown with the keyboard and keeps code copying separate", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
await copy.focus();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(REPLY);
|
||||
await reply.getByRole("button", { name: "Copy Code", exact: true }).click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
|
||||
});
|
||||
|
||||
test("reveals message actions on hover without shifting content and quotes into the draft", async ({ page }) => {
|
||||
const reply = await openReply(page, REPLY);
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
const actions = copy.locator("..");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const before = await reply.boundingBox();
|
||||
await reply.hover();
|
||||
await expect(actions).toHaveCSS("opacity", "1");
|
||||
expect(await reply.boundingBox()).toEqual(before);
|
||||
await expect(actions.getByRole("button")).toHaveCount(4);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(actions).toHaveCSS("opacity", "0");
|
||||
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
|
||||
await page.getByText("Show Markdown", { exact: true }).hover();
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await reply.hover();
|
||||
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
|
||||
const draft = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await expect(draft).toBeFocused();
|
||||
await expect(draft).toHaveValue(
|
||||
REPLY.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => (line ? `> ${line}` : ">"))
|
||||
.join("\n") + "\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps chat history readable and selections free of timestamps and status labels", async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 1800, height: 1000 });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await openReply(page, "Hi! What would you like to work on?");
|
||||
await page.evaluate(async (task) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
|
||||
store.useAgents.setState({
|
||||
entries: new Map([
|
||||
[
|
||||
task.id,
|
||||
[
|
||||
user("hi"),
|
||||
{ type: "agent", text: "Hi! What would you like to work on?" },
|
||||
user("im bob"),
|
||||
{ type: "agent", text: "Hi Bob! What are we working on today?" },
|
||||
user("yes"),
|
||||
{ type: "thought", text: "**Requesting task context**" },
|
||||
{ type: "agent", text: "What would you like me to proceed with?" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
}, TASK);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
const replies = stream.locator("[class*='_reply_']");
|
||||
await expect(replies).toHaveCount(3);
|
||||
const geometry = await stream.evaluate((root) => {
|
||||
const reply = root.querySelector<HTMLElement>("[class*='_reply_']")!;
|
||||
const bubble = root.querySelector<HTMLElement>("[class*='_user_']")!;
|
||||
const composer = root
|
||||
.closest("[class*='_conversation_']")!
|
||||
.querySelector<HTMLElement>("[class*='_composerArea_']")!;
|
||||
const rr = reply.getBoundingClientRect();
|
||||
const br = bubble.getBoundingClientRect();
|
||||
const cr = composer.getBoundingClientRect();
|
||||
return {
|
||||
reply: rr.width,
|
||||
root: root.clientWidth,
|
||||
bubbleLeft: br.left,
|
||||
replyLeft: rr.left,
|
||||
bubbleRight: br.right,
|
||||
replyRight: rr.right,
|
||||
columnCenter: (rr.left + rr.right) / 2,
|
||||
composerCenter: (cr.left + cr.right) / 2,
|
||||
};
|
||||
});
|
||||
expect(geometry.reply).toBe(900);
|
||||
expect(geometry.reply).toBeLessThan(geometry.root);
|
||||
expect(Math.abs(geometry.bubbleRight - geometry.replyRight)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.bubbleLeft - geometry.replyLeft)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
|
||||
await stream.getByText("hi", { exact: true }).hover();
|
||||
await expect(stream.locator("[class*='_messageMeta_']").first()).toHaveCSS("opacity", "1");
|
||||
const selected = await stream.evaluate((root) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection.toString();
|
||||
});
|
||||
expect(selected).toContain("hi");
|
||||
expect(selected).toContain("Hi Bob!");
|
||||
expect(selected).toContain("What would you like me to proceed with?");
|
||||
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await stream.locator("[class*='_thought_'] > summary").click();
|
||||
await expect(stream.getByText("Requesting task context", { exact: true })).toBeVisible();
|
||||
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
|
||||
await page.evaluate(async (id) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.dockTask(id);
|
||||
}, TASK.id);
|
||||
await expect(stream).toBeVisible();
|
||||
const narrow = await stream.evaluate((root) => ({
|
||||
width: root.clientWidth,
|
||||
content: root.scrollWidth,
|
||||
reply: root.querySelector<HTMLElement>("[class*='_reply_']")!.getBoundingClientRect().width,
|
||||
padding:
|
||||
Number.parseFloat(getComputedStyle(root).paddingLeft) + Number.parseFloat(getComputedStyle(root).paddingRight),
|
||||
}));
|
||||
expect(narrow.width).toBeGreaterThan(0);
|
||||
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
|
||||
expect(Math.abs(narrow.reply - (narrow.width - narrow.padding))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const reply = await openReply(page, "A short answer.");
|
||||
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
|
||||
await reply.hover();
|
||||
const box = await copy.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
|
||||
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await copy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
|
||||
});
|
||||
|
||||
test("selects through code blocks and tables while keeping copy controls outside the code", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const tree = "rust/\n├── Cargo.toml\n└── src/\n └── main.rs";
|
||||
const flow = "内置文本 SAMPLE\n ↓ parser::parse\nVec<Item>\n ↓ Inventory::add\n终端输出";
|
||||
const text = `项目结构:\n\n\`\`\`text\n${tree}\n\`\`\`\n\n| 模块 | 职责 |\n| --- | --- |\n| main.rs | 程序入口 |\n\n数据流是:\n\n\`\`\`text\n${flow}\n\`\`\``;
|
||||
const reply = await openReply(page, text);
|
||||
await expect(reply.getByRole("button", { name: "Copy Code", exact: true })).toHaveCount(2);
|
||||
const selected = await reply.evaluate((root) => {
|
||||
const codes = [...root.querySelectorAll("pre > code")];
|
||||
if (codes.some((code) => code.parentElement!.querySelector("button"))) throw new Error("Control in code selection");
|
||||
const start = root.querySelector("p")!.firstChild!;
|
||||
const end = codes.at(-1)!.lastChild!;
|
||||
window.getSelection()!.setBaseAndExtent(start, 0, end, end.textContent!.length);
|
||||
return window.getSelection()!.toString();
|
||||
});
|
||||
expect(selected).toContain(tree);
|
||||
expect(selected).toContain("程序入口");
|
||||
expect(selected).toContain(flow);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
const copies = reply.getByRole("button", { name: "Copy Code", exact: true });
|
||||
await copies.first().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(tree);
|
||||
await copies.last().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { AgentEntry, AgentTask } from "../src/bindings";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "long-chat",
|
||||
title: "Long conversation",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
const entries: AgentEntry[] = Array.from({ length: 80 }, (_, turn): AgentEntry[] => [
|
||||
{ type: "user", text: `Request ${turn}`, attachments: [], steered: false },
|
||||
...["read", "execute", "edit"].map((kind): AgentEntry => ({
|
||||
type: "tool",
|
||||
id: `${turn}-${kind}`,
|
||||
kind,
|
||||
title: `${kind} src/module-${turn}.ts`,
|
||||
status: "completed",
|
||||
locations: [],
|
||||
output: Array.from({ length: 80 }, (_, line) => `output ${turn}:${line}`).join("\n"),
|
||||
diffs: [],
|
||||
})),
|
||||
{ type: "thought", text: `Checking module ${turn}.\n\n${"Details of the investigation. ".repeat(30)}` },
|
||||
{
|
||||
type: "agent",
|
||||
text: `Reply ${turn}\n\n- Checked the module.\n- Updated the implementation.\n\n\`\`\`text\nmodule-${turn}\n src/\n tests/\n\`\`\``,
|
||||
},
|
||||
]).flat();
|
||||
|
||||
async function openHistory(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, entries }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, entries]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.openTaskInEditorLocation(task.id);
|
||||
},
|
||||
{ task, entries },
|
||||
);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(entries.length);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
return stream;
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const stream = await openHistory(page);
|
||||
const result = await stream.evaluate(async (el, selected) => {
|
||||
let text = "";
|
||||
if (selected) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
window.getSelection()!.removeAllRanges();
|
||||
window.getSelection()!.addRange(range);
|
||||
text = window.getSelection()!.toString();
|
||||
}
|
||||
await new Promise(requestAnimationFrame);
|
||||
const initialHeight = el.scrollHeight;
|
||||
const samples: Array<{ height: number; drift: number }> = [];
|
||||
// A thumb can jump into previously unvisited rows in either direction.
|
||||
for (const fraction of [0, 0.75, 0.25, 1, 0.5, 0.1, 0.9, 0]) {
|
||||
el.scrollTop = fraction * (initialHeight - el.clientHeight);
|
||||
const target = el.scrollTop;
|
||||
await new Promise(requestAnimationFrame);
|
||||
await new Promise(requestAnimationFrame);
|
||||
samples.push({ height: el.scrollHeight, drift: Math.abs(el.scrollTop - target) });
|
||||
}
|
||||
return { initialHeight, samples, text, after: window.getSelection()!.toString() };
|
||||
}, selected);
|
||||
expect(result.initialHeight).toBeGreaterThan(10_000);
|
||||
for (const sample of result.samples) {
|
||||
expect(Math.abs(sample.height - result.initialHeight)).toBeLessThanOrEqual(1);
|
||||
expect(sample.drift).toBeLessThanOrEqual(1);
|
||||
}
|
||||
if (selected) {
|
||||
expect(result.text).toContain("Request 0");
|
||||
expect(result.text).toContain("Reply 79");
|
||||
expect(result.after).toBe(result.text);
|
||||
await stream.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(result.text);
|
||||
await page.evaluate(() => window.getSelection()!.removeAllRanges());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
async function placeCaret(page: Page, marker: string) {
|
||||
await page.locator(".cm-content").evaluate((element, marker) => {
|
||||
const view = (element as HTMLElement & { cmTile: { view: import("@codemirror/view").EditorView } }).cmTile.view;
|
||||
const anchor = view.state.doc.toString().indexOf(marker) + marker.length;
|
||||
view.dispatch({ selection: { anchor } });
|
||||
view.focus();
|
||||
}, marker);
|
||||
}
|
||||
|
||||
test("Angular templates highlight and close tags in inline and external documents", async ({ workbench: page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const backendPath = "/src/editor/core/backend.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { docBackend } = await import(backendPath);
|
||||
const { editorService } = await import(contributionsPath);
|
||||
const files = docBackend().files;
|
||||
files.set("/dev/acme-api/angular.json", "{}");
|
||||
files.set(
|
||||
"/dev/acme-api/app.ts",
|
||||
'@Component({\n template: `<div [title]="name">{{ name }}</div>\n @if (ready) { <p>Ready</p> }\n <r-card\n <section\n <article><\n `\n})\nexport class App {}\n',
|
||||
);
|
||||
files.set("/dev/acme-api/app.html", "<r-card\n<div\n<article><");
|
||||
files.set("/dev/acme-api/plain.ts", "const data = { template: `<r-card/>` };");
|
||||
await editorService().openFile("/dev/acme-api/app.ts");
|
||||
});
|
||||
const inline = page.locator('.cm-editor[data-language="Angular TypeScript"]');
|
||||
await expect(inline).toBeVisible();
|
||||
await expect(inline.locator(".cm-content span").filter({ hasText: /^div$/ }).first()).toBeVisible();
|
||||
await expect(inline.locator(".cm-content span").filter({ hasText: /^@if$/ })).toBeVisible();
|
||||
for (const [marker, text, result] of [
|
||||
["<r-card", "/", "<r-card/>"],
|
||||
["<section", ">", "<section></section>"],
|
||||
["<article><", "/", "<article></article>"],
|
||||
]) {
|
||||
await placeCaret(page, marker!);
|
||||
await page.keyboard.type(text!);
|
||||
await expect(inline.locator(".cm-content")).toContainText(result!);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/workbench/contributions.ts";
|
||||
await (await import(path)).editorService().openFile("/dev/acme-api/app.html");
|
||||
});
|
||||
const external = page.locator('.cm-editor[data-language="Angular Template"]');
|
||||
await expect(external).toBeVisible();
|
||||
for (const [marker, text, result] of [
|
||||
["<r-card", "/", "<r-card/>"],
|
||||
["<div", ">", "<div></div>"],
|
||||
["<article><", "/", "<article></article>"],
|
||||
]) {
|
||||
await placeCaret(page, marker!);
|
||||
await page.keyboard.type(text!);
|
||||
await expect(external.locator(".cm-content")).toContainText(result!);
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/workbench/contributions.ts";
|
||||
await (await import(path)).editorService().openFile("/dev/acme-api/plain.ts");
|
||||
});
|
||||
await expect(page.locator(".cm-content span").filter({ hasText: /^r-card$/ })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("opens a commit's branch menu and acts in its linked repository", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const primary = "/dev/acme-api";
|
||||
const linked = "/dev/acme-web";
|
||||
const tip = "1".repeat(40);
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () =>
|
||||
[
|
||||
{ root: primary, head: "main" },
|
||||
{ root: linked, head: "release" },
|
||||
].map((repo) => ({ ...repo, operation: null, headCommit: tip }));
|
||||
h.vcs_branch_info = ({ repo }) => ({
|
||||
head: repo === linked ? "release" : "main",
|
||||
headCommit: tip,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_branches = ({ repo }) =>
|
||||
[repo === linked ? "origin/dev" : "origin/other"].map((name) => ({
|
||||
name,
|
||||
commit: tip,
|
||||
remote: true,
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
lastCommitMs: 0,
|
||||
}));
|
||||
h.vcs_log = ({ repo }) => {
|
||||
const summary = repo === linked ? "Linked repository commit" : "Primary repository commit";
|
||||
return [
|
||||
{
|
||||
id: tip,
|
||||
shortId: tip.slice(0, 8),
|
||||
parents: [],
|
||||
refs: [repo === linked ? "origin/dev" : "origin/other"],
|
||||
author: "A",
|
||||
email: "[email protected]",
|
||||
timeMs: 0,
|
||||
summary,
|
||||
message: summary,
|
||||
},
|
||||
];
|
||||
};
|
||||
h.vcs_log_refs = () => ({});
|
||||
h.vcs_log_details_batch = ({ revisions }) =>
|
||||
(revisions as string[]).map(() => ({
|
||||
message: "Linked repository commit",
|
||||
containingBranches: [],
|
||||
signature: "N",
|
||||
signer: "",
|
||||
key: "",
|
||||
fingerprint: "",
|
||||
}));
|
||||
h.vcs_commit_files = () => [];
|
||||
h.vcs_config_get = () => null;
|
||||
h.vcs_tags = () => [];
|
||||
h.vcs_status = () => [];
|
||||
h.vcs_create_branch = () => null;
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root: primary, repo: primary });
|
||||
await refreshNow();
|
||||
const contextModule = "/src/platform/context.ts";
|
||||
const { setContext } = await import(/* @vite-ignore */ contextModule);
|
||||
setContext("vcsAvailable", true);
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("vcs.showLog");
|
||||
});
|
||||
|
||||
await page.getByRole("option", { name: /Linked repository commit/ }).click({ button: "right" });
|
||||
const menu = page.getByRole("menu", { name: "Context menu" });
|
||||
await expect(menu.getByRole("menuitem", { name: "Branch 'origin/other'" })).toHaveCount(0);
|
||||
await menu.getByRole("menuitem", { name: "Branch 'origin/dev'" }).hover();
|
||||
await expect(page.getByRole("menuitem", { name: "Pull into 'release' Using Rebase" })).toBeVisible();
|
||||
const newBranch = page.getByRole("menuitem", { name: "New Branch from 'origin/dev'…" });
|
||||
await newBranch.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create New Branch from origin/dev" });
|
||||
await dialog.getByRole("textbox", { name: "New branch name:" }).fill("test-branch");
|
||||
await dialog.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => ipcCalls(page, "vcs_create_branch"))
|
||||
.toMatchObject([{ repo: "/dev/acme-web", name: "test-branch", start: "origin/dev", checkout: true }]);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("keeps a wide Git graph filled during scrollbar drags and its edges interactive afterwards", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const root = "/dev/acme-api";
|
||||
const count = 3000;
|
||||
const id = (i: number) => i.toString(16).padStart(40, "0");
|
||||
const commits = Array.from({ length: count }, (_, i) => ({
|
||||
id: id(i),
|
||||
shortId: id(i).slice(-8),
|
||||
parents: i + 18 < count ? [id(i + 18)] : [],
|
||||
refs: i === 0 ? ["HEAD", "main"] : [],
|
||||
author: "Author",
|
||||
email: "[email protected]",
|
||||
timeMs: count - i,
|
||||
summary: `Commit ${i}`,
|
||||
message: `Commit ${i}`,
|
||||
}));
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_log = ({ query }) => {
|
||||
const { skip, limit, range } = query as {
|
||||
skip: number;
|
||||
limit: number;
|
||||
range?: string;
|
||||
};
|
||||
return range ? [] : commits.slice(skip, limit ? skip + limit : undefined);
|
||||
};
|
||||
h.vcs_log_refs = () => ({});
|
||||
h.vcs_branches = () => [];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: id(0),
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_config_get = () => null;
|
||||
h.vcs_commit_files = () => [];
|
||||
h.vcs_log_details_batch = ({ revisions }) =>
|
||||
(revisions as string[]).map((revision) => ({
|
||||
message: `Commit ${parseInt(revision, 16)}`,
|
||||
containingBranches: [],
|
||||
signature: "N",
|
||||
signer: "",
|
||||
key: "",
|
||||
fingerprint: "",
|
||||
}));
|
||||
const storeModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(/* @vite-ignore */ storeModule);
|
||||
useVcs.setState({
|
||||
available: true,
|
||||
root,
|
||||
repo: root,
|
||||
roots: [root],
|
||||
branchesLoaded: true,
|
||||
});
|
||||
const contextModule = "/src/platform/context.ts";
|
||||
const { setContext } = await import(/* @vite-ignore */ contextModule);
|
||||
setContext("vcsAvailable", true);
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("vcs.showLog");
|
||||
});
|
||||
const list = page.getByRole("listbox", { name: "Commits" });
|
||||
await expect(list.getByRole("option").first()).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const module = "/src/features/vcs/logStore.ts";
|
||||
const { useGitLog, loadMore } = await import(/* @vite-ignore */ module);
|
||||
while (!useGitLog.getState().done) await loadMore();
|
||||
});
|
||||
|
||||
const box = (await list.boundingBox())!;
|
||||
const x = box.x + box.width - 5;
|
||||
await page.mouse.move(x, box.y + 8);
|
||||
await page.mouse.down();
|
||||
try {
|
||||
for (const fraction of [0.2, 0.7, 0.4, 0.85, 0.3]) {
|
||||
await page.mouse.move(x, box.y + box.height * fraction, { steps: 8 });
|
||||
expect(
|
||||
await list.evaluate((element) => {
|
||||
const top = element.scrollTop;
|
||||
const positions = [...element.querySelectorAll<HTMLElement>('[role="option"]')].map((row) => row.offsetTop);
|
||||
return {
|
||||
scrolling: top > 0,
|
||||
covered: Math.min(...positions) <= top && Math.max(...positions) + 26 >= top + element.clientHeight,
|
||||
};
|
||||
}),
|
||||
).toEqual({ scrolling: true, covered: true });
|
||||
}
|
||||
} finally {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
const index = await list.evaluate((element) => Math.ceil(element.scrollTop / 26));
|
||||
const row = list.locator(`[id="commit-log-${index}"]`);
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await list.press("ArrowDown");
|
||||
await expect(list.locator(`[id="commit-log-${index + 1}"]`)).toHaveAttribute("aria-selected", "true");
|
||||
// Rows drawn mid-drag get their edge hit targets back once scrolling stops.
|
||||
await expect(row.getByRole("button", { name: "Collapse this fragment" }).first()).toBeAttached();
|
||||
});
|
||||
+63
-23
@@ -7,30 +7,47 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
|
||||
async function openNewBranchDialog(page: Page) {
|
||||
async function openNewBranchDialog(page: Page, from?: string) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (root) => {
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => [];
|
||||
h.vcs_create_branch = () => null;
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
void executeAction("vcs.newBranch");
|
||||
}, ROOT);
|
||||
const dialog = page.getByRole("dialog", { name: "Create New Branch" });
|
||||
await page.evaluate(
|
||||
async ({ root, from }) => {
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => [];
|
||||
h.vcs_branches = () => [
|
||||
{
|
||||
name: "main",
|
||||
current: true,
|
||||
remote: false,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
lastCommitMs: null,
|
||||
commit: "1111111",
|
||||
},
|
||||
];
|
||||
h.vcs_create_branch = () => null;
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
await loadBranches();
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
if (from) void executeAction("vcs.log.newBranch", { id: from, summary: "Selected commit", repo: root });
|
||||
else void executeAction("vcs.newBranch");
|
||||
},
|
||||
{ root: ROOT, from },
|
||||
);
|
||||
const dialog = page.getByRole("dialog", { name: from ? `Create New Branch from ${from}` : "Create New Branch" });
|
||||
const input = dialog.getByRole("textbox", { name: "New branch name:" });
|
||||
await expect(input).toBeVisible();
|
||||
return { dialog, input };
|
||||
@@ -48,6 +65,29 @@ test("a typed space becomes a separator and Create stays enabled", async ({ work
|
||||
expect(call).toMatchObject({ name: "fix-login-redirect" });
|
||||
});
|
||||
|
||||
test("overwrites a local branch only after explicitly opting in", async ({ workbench: page }) => {
|
||||
const from = "2".repeat(40);
|
||||
const { dialog, input } = await openNewBranchDialog(page, from);
|
||||
await input.fill("main");
|
||||
const create = dialog.getByRole("button", { name: "Create", exact: true });
|
||||
await expect(create).toBeDisabled();
|
||||
const overwrite = dialog.getByRole("checkbox", { name: "Overwrite existing branch" });
|
||||
await expect(overwrite).toBeVisible();
|
||||
await expect(overwrite).not.toBeChecked();
|
||||
await overwrite.check();
|
||||
await expect(create).toBeEnabled();
|
||||
await input.fill("new-branch");
|
||||
await expect(overwrite).toHaveCount(0);
|
||||
await input.fill("main");
|
||||
await expect(overwrite).not.toBeChecked();
|
||||
await expect(create).toBeDisabled();
|
||||
await overwrite.check();
|
||||
await create.click();
|
||||
await expect
|
||||
.poll(() => ipcCalls(page, "vcs_create_branch"))
|
||||
.toEqual([{ repo: ROOT, name: "main", start: from, checkout: true, overwrite: true }]);
|
||||
});
|
||||
|
||||
test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => {
|
||||
const { input } = await openNewBranchDialog(page);
|
||||
await input.fill("ab");
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
id = "arduino"
|
||||
name = "Arduino"
|
||||
# Sketches are C++ with an implicit `Arduino.h` and generated prototypes; clangd can't compile
|
||||
# them on its own, so they get C++ highlighting without a language server.
|
||||
extensions = ["ino"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "C++"
|
||||
tree-sitter = "cpp"
|
||||
@@ -3,6 +3,7 @@ name = "Go Module"
|
||||
filenames = ["go.mod"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 4, insert-spaces = false }
|
||||
ui-mode = "GoMod"
|
||||
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "Go Workspace"
|
||||
filenames = ["go.work"]
|
||||
line-comment = "//"
|
||||
indent = { tab-size = 4, insert-spaces = false }
|
||||
ui-mode = "GoMod"
|
||||
|
||||
[[language-servers]]
|
||||
name = "gopls"
|
||||
|
||||
@@ -8,6 +8,10 @@ indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "TypeScript"
|
||||
tree-sitter = "typescript"
|
||||
|
||||
[[framework-ui-modes]]
|
||||
ui-mode = "Angular TypeScript"
|
||||
root-markers = ["angular.json", "node_modules/@angular/core"]
|
||||
|
||||
[[language-servers]]
|
||||
name = "vtsls"
|
||||
tsserver-project = true
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
"gitLog.tbtMs": 50,
|
||||
"gitLog.arrowKeyP95Ms": 20,
|
||||
"gitLog.arrowKeyMissedFramePct": 5,
|
||||
"gitLogScrollbar.frameP95Ms": 17.5,
|
||||
"gitLogScrollbar.droppedFramesPct": 2,
|
||||
"gitLogScrollbar.tbtMs": 50,
|
||||
|
||||
"resizePanels.droppedFramesPct": 2,
|
||||
"resizePanels.bottomDroppedFramesPct": 2,
|
||||
|
||||
@@ -316,6 +316,52 @@ const gitLog: Scenario = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Scrollbar-sized jumps across a loaded graph, rather than incremental wheel scrolling. */
|
||||
const gitLogScrollbar: Scenario = {
|
||||
id: "gitLogScrollbar",
|
||||
title: "Drag through a loaded 6k-commit Git Log",
|
||||
async run(h) {
|
||||
await h.page.evaluate(async () => {
|
||||
const probe = (
|
||||
window as never as {
|
||||
__openideaPerf: {
|
||||
fakeGitLog(n: number): void;
|
||||
runAction(id: string): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
).__openideaPerf;
|
||||
probe.fakeGitLog(6000);
|
||||
await probe.runAction("vcs.showLog");
|
||||
});
|
||||
const list = h.page.getByRole("listbox", { name: "Commits" });
|
||||
await list.waitFor();
|
||||
await list.evaluate(async (element) => {
|
||||
for (let i = 0; i < 300 && element.scrollHeight < 6000 * 26; i++) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
if (element.scrollHeight < 6000 * 26) throw new Error("Git Log did not finish loading");
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
await h.page.waitForTimeout(200);
|
||||
const refresh = await h.refreshMs();
|
||||
const t0 = await h.now();
|
||||
await h.inPage((p) => p.startFrames());
|
||||
await list.evaluate(async (element) => {
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let i = 0; i < 180; i++) {
|
||||
element.scrollTop = ((i < 90 ? i : 180 - i) / 90) * max;
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
});
|
||||
const frames = await h.inPage((p) => p.stopFrames());
|
||||
return {
|
||||
...frameSummary(frames, refresh),
|
||||
tbtMs: totalBlockingTime(await h.longTasksSince(t0)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dragging the splitters with an editor, the Project tree and the Git Log
|
||||
* open: every pointer move resizes the islands, so each frame relays them out.
|
||||
@@ -555,6 +601,7 @@ export const SCENARIOS: Scenario[] = [
|
||||
completion,
|
||||
consoleOutput,
|
||||
gitLog,
|
||||
gitLogScrollbar,
|
||||
resizePanels,
|
||||
keyRepeat,
|
||||
scroll,
|
||||
|
||||
+98
-33
@@ -34,7 +34,7 @@ plugins/
|
||||
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
|
||||
└─ license-header/ # file-opened events, quick fix, project scan in a task
|
||||
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit
|
||||
```
|
||||
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:[email protected]`, world
|
||||
@@ -137,23 +137,24 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
|
||||
|
||||
### Contribution points
|
||||
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
|
||||
|
||||
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
|
||||
views. It adds the commands to the right-click menu of the items in that
|
||||
@@ -174,6 +175,10 @@ command = "kube.logs"
|
||||
when = "viewItemKind == pod"
|
||||
```
|
||||
|
||||
A `commit.message` menu puts the command, with the plugin's icon, in the
|
||||
toolbar above the commit message of the Commit tool window and the Commit
|
||||
dialog (see _Commits_). It is new in API 1.3 (`engines.openidea = "^1.3"`).
|
||||
|
||||
Declared contributions are live as soon as the plugin is enabled — commands
|
||||
show in Find Action, menus and the status bar, shortcuts work, themes and icon
|
||||
themes can be applied, settings pages are there — **before** any plugin code
|
||||
@@ -227,20 +232,20 @@ installing (or when a new version asks for more). Low-risk capabilities are
|
||||
listed; high-risk ones are called out as equivalent to running code on the
|
||||
machine. Users can revoke optional capabilities in Settings › Plugins.
|
||||
|
||||
| Capability | Risk | Unlocks today |
|
||||
| ------------------- | ------ | ----------------------------------------------------------------------------------- |
|
||||
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
|
||||
| `editor.read` | low | `TextEditor::document`, `selections` |
|
||||
| `editor.write` | medium | `TextEditor::apply_edits` |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
|
||||
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
|
||||
| `storage` | low | `host::storage` (1 MiB per plugin) |
|
||||
| `events` | low | file opened/saved, editor focused, repository changed events |
|
||||
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
|
||||
| Capability | Risk | Unlocks today |
|
||||
| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
|
||||
| `editor.read` | low | `TextEditor::document`, `selections` |
|
||||
| `editor.write` | medium | `TextEditor::apply_edits` |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks |
|
||||
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` |
|
||||
| `storage` | low | `host::storage` (1 MiB per plugin) |
|
||||
| `events` | low | file opened/saved, editor focused, repository changed events |
|
||||
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
|
||||
|
||||
`process:` capabilities are also derived from what the contributions start: a
|
||||
language's `language-servers` and `debugger` commands and a run detector's
|
||||
@@ -349,6 +354,7 @@ then run `openidea-plugin check`.
|
||||
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
|
||||
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
|
||||
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
|
||||
| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. |
|
||||
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
|
||||
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
|
||||
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
|
||||
@@ -395,6 +401,45 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
|
||||
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
|
||||
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
|
||||
|
||||
### Commits
|
||||
|
||||
A plugin can work on the commit the user is preparing, in the Commit tool
|
||||
window or the Commit dialog (API 1.3, `engines.openidea = "^1.3"`):
|
||||
|
||||
- **A button above the message.** `[[contributes.menus]]` with
|
||||
`menu = "commit.message"` shows one of the plugin's commands there, with the
|
||||
plugin's icon.
|
||||
- **The pending commit.** `host::pending_commit()` (`vcs.read`) returns what
|
||||
Commit would commit right now: per file its repository, path, status and a
|
||||
`git diff` of only the included changes (the checked files and changed
|
||||
blocks, against HEAD; 1 MiB in all, then `truncated`), plus the message,
|
||||
Amend and a `revision`. Unsaved editors of those files are saved first, as
|
||||
Commit does; nothing is staged or committed. `None` without a repository.
|
||||
- **The message.** `host::set_commit_message(revision, text)` (`vcs.write`)
|
||||
replaces it only while the draft is still at `revision`. If the user typed
|
||||
or changed the included changes meanwhile, it fails with `Error::Stale` and
|
||||
nothing is overwritten. The user reviews the text and commits.
|
||||
|
||||
Both work from a command or event of a window, in a trusted project; they wait
|
||||
for the window, which isn't charged to the time limit. A message generator
|
||||
calls a model with `host::fetch` and a `{{secret:…}}` key (see _Network_)
|
||||
inside a task:
|
||||
|
||||
```rust
|
||||
let draft = host::pending_commit()?.ok_or("no repository")?;
|
||||
let message = ask_the_model(&draft.changes)?;
|
||||
host::set_commit_message(draft.revision, &message)?;
|
||||
```
|
||||
|
||||
**Before-commit checks.** Each `[[contributes.commitChecks]]` (`id`, `title`)
|
||||
is listed in Commit Options, on by default. When the user commits, after the
|
||||
IDE's own checks, the IDE calls the optional `commit-checker` export
|
||||
(`register_plugin!(MyPlugin, commit_checks)`, trait `commit::CommitChecks`)
|
||||
with the check's id and the draft. Problems (`message`, `location?`) stop the
|
||||
commit and show beside the message with the check's title, until the user
|
||||
changes it or chooses Commit Anyway. A check that fails, times out or lacks
|
||||
`vcs.read` is reported as a problem too.
|
||||
|
||||
### Providers
|
||||
|
||||
Declare what the plugin provides in the editor; only plugins whose
|
||||
@@ -402,7 +447,7 @@ declaration matches the file's language are called (and loaded):
|
||||
|
||||
```toml
|
||||
[[contributes.providers]]
|
||||
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens"
|
||||
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover"
|
||||
languages = ["java", "xml"] # editor language ids; "*" for any
|
||||
```
|
||||
|
||||
@@ -457,6 +502,26 @@ the language server's semantic tokens and win where they overlap one, so the
|
||||
editor's colour scheme styles them like the server's. They are asked again
|
||||
after each edit, under the edit-path budget; at most 65,536 per document.
|
||||
|
||||
#### Hover
|
||||
|
||||
The `hover` kind (API 1.3) adds Markdown to the hover of documents of the
|
||||
declared languages, here too including languages the plugin doesn't
|
||||
contribute. Register with `register_plugin!(MyPlugin, hover)` and implement
|
||||
`openidea_plugin::hover::HoverProvider`:
|
||||
|
||||
```rust
|
||||
impl HoverProvider for MyPlugin {
|
||||
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
|
||||
Some(Hover::new("**orders.index** · `GET /orders`"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The host asks the language server and the plugins together, under the
|
||||
edit-path budget, and shows the server's hover first, then each plugin's
|
||||
below a rule. `range`, when given, is the text the hover describes (used when
|
||||
the server names none). At most 64 KiB of Markdown per plugin.
|
||||
|
||||
### Network and secrets
|
||||
|
||||
`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
eb76da4bc9db84dcbad34143bac18c9ecf974276f793009791a15aab2eee291d
|
||||
89515d507386505d244ed6ac19a85b7288f6d8ef30f19f4b770d8b0f84f33d63
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0a19948927a23de81b2a453b9992e021744093cbe41ea92daa9feb2b1bc9a2a9
|
||||
a0b3353006032af9f9052c8413a00ff8069294d1c3e86ed520f9c4b8012094c1
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
564bfd00694f27f35f09eb0b81d341207a755528b5fe7c46c96717b3868d0f65
|
||||
7b055db21ef99eea933df6b311248ac18ec8a57d57cbe9af0bc5f694c8e8d48a
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
f3f447366fa38f9bf4911f35b6e899405b0e072ceee3ad5ea2fe90639d70fa53
|
||||
d742d45a1bee8c5d0c4c2bab9341eb3d3fc7feae1c1229485330dd0b58374092
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
85cad466adb94f2b58e62263617e885d1c47cf70db5e8b38942bd415f0d779af
|
||||
511a8419aab4bde97c09a7a7134ee523dade17b771697382ffb521ad1e5798bf
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
659446eda0b50aa0fb08f6af68f7d355fcaf3342847e2e579999dcf52a7a7399
|
||||
8b5e78147995375b841f342c7441158d20d33388be808f08c233eca27c3b4d7f
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
51ef9d43315ca9d7239999c222fbf7a2c01bc85c2b40f53f34a1a8e5d31baa6d
|
||||
6925c7bdc12a5bd74e864ab4e706c155b0a994d5bbf27cc437f0220aed4cfc68
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
bdf9455c6e7c8c340c54d783f351220b38cffed36907e1b5a1f2e11801e6f5bb
|
||||
43c47ef0199e7c7550655bd571c75d4109cb6435ae0e99fa68123c8e4d823e48
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7e4d00e1ea63b2eca48b9297667e04c05a9f9832f1dee2262272801d516b9a16
|
||||
3a632f982c08c24cc2d67511f1fac3ff3b29229e552399b46e4d0aa2623200f7
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
00cbd2dd7f0937554633ba5e533f19be85f70579c7c6fedb6fe0228f8f63bf4c
|
||||
588b3af37102b1871b40184509b299b39ecd15212ccdb58467badb889ca7bc03
|
||||
BIN
Binary file not shown.
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user