Compare commits

..
Author SHA1 Message Date
l0ng-ai 547903f6e1 fix(plugins): confine resolved write destinations 2026-11-10 09:44:55 +08:00
22 changed files with 318 additions and 611 deletions

No files matched your search

-4
View File
@@ -746,10 +746,6 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
// Delve looks up Go before applying the debuggee's launch environment.
if config.adapter == "delve" {
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
-33
View File
@@ -1040,39 +1040,6 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
#[tokio::test]
async fn delve_adapter_inherits_the_selected_go_environment() {
struct CaptureLauncher {
inner: FakeLauncher,
env: Arc<Mutex<Vec<(String, String)>>>,
}
impl AdapterLauncher for CaptureLauncher {
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
*self.env.lock() = spec.env.clone();
self.inner.launch(spec, reuse)
}
}
let env = Arc::new(Mutex::new(Vec::new()));
let launcher =
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
let (owner, _) = recording_owner("main");
let config = LaunchConfig {
name: "Go".into(),
adapter: "delve".into(),
request: "launch".into(),
program: Some("/project".into()),
args: vec![],
cwd: None,
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
extra_json: Some(json!({"mode":"debug"}).to_string()),
};
let id = manager.start(config, owner).await.unwrap();
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
manager.stop(id).await.unwrap();
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
-1
View File
@@ -64,7 +64,6 @@ async fn debug_start_inner(
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::prepare_debug_compiler(&mut config.env, &config.extra_json)?;
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
+52 -2
View File
@@ -922,12 +922,13 @@ fn plugin_file_edits(
use ide_plugin::FileOperation;
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
let mut created_with_text = Vec::new();
for operation in &a.operations {
match operation {
FileOperation::Create { path, text, overwrite } => {
let path = confined(path)?;
let path = writable(path)?;
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
if !text.is_empty() {
let start = Range::point(Pos::new(0, 0));
@@ -948,7 +949,7 @@ fn plugin_file_edits(
}
}
for (path, edits) in &a.edits {
let path = if confine { confined(path)? } else { path.clone() };
let path = if confine { writable(path)? } else { path.clone() };
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
@@ -2228,6 +2229,55 @@ mod tests {
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[cfg(unix)]
#[test]
fn plugins_file_actions_confine_content_writes_and_preserve_link_entries() {
use ide_core::FileEdit;
use ide_plugin::{CodeAction, FileOperation};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::fs::write(root.join("target.txt"), "inside").unwrap();
for (name, target) in [
("outside", outside.clone()),
("dangling", tmp.path().join("missing.txt")),
("inside", root.join("target.txt")),
] {
std::os::unix::fs::symlink(target, root.join(name)).unwrap();
}
let action = |operations, edits| CodeAction {
title: "Update files".into(),
kind: None,
preferred: false,
edits,
operations,
open: Some(root.join("target.txt")),
data: None,
file_action: true,
};
for name in ["outside", "dangling"] {
let create = FileOperation::Create { path: root.join(name), text: "text".into(), overwrite: true };
assert!(plugin_file_edits(&root, &action(vec![create], Vec::new())).is_err());
let edits = vec![(root.join(name), vec![TextEdit { range: Range::default(), new_text: "text".into() }])];
assert!(plugin_file_edits(&root, &action(Vec::new(), edits)).is_err());
}
let create = FileOperation::Create { path: root.join("inside"), text: "text".into(), overwrite: true };
let (files, _) = plugin_file_edits(&root, &action(vec![create], Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Create { path, .. } if **path == root.join("target.txt")));
let operations = vec![
FileOperation::Rename { from: root.join("outside"), to: root.join("dangling") },
FileOperation::Delete { path: root.join("outside") },
];
let (files, _) = plugin_file_edits(&root, &action(operations, Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Rename { from, to }
if **from == root.join("outside") && **to == root.join("dangling")));
assert!(matches!(&files[1], FileEdit::Delete { path, .. } if **path == root.join("outside")));
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
}
#[test]
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
use ide_core::FileEdit;
+98 -13
View File
@@ -163,16 +163,10 @@ impl SecretStore for FileSecretStore {
None => map.remove(name),
};
let file = self.file(plugin);
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
ide_vfs::create_private_dir(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
replace_entry(&file, &bytes).map_err(|e| e.to_string())
}
}
@@ -924,6 +918,15 @@ fn storage_io(e: std::io::Error) -> HostError {
HostError::Failed(format!("storage: {e}"))
}
fn replace_entry(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut tmp =
tempfile::Builder::new().prefix(".entry-").suffix(".tmp").tempfile_in(path.parent().expect("has parent"))?;
tmp.write_all(bytes)?;
tmp.persist(path).map_err(|e| e.error)?;
Ok(())
}
/// A key's file name: its hash, since keys may hold any character.
fn entry_name(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
@@ -945,8 +948,7 @@ fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(key.as_bytes());
bytes.extend_from_slice(value.as_bytes());
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
replace_entry(&file, &bytes).map_err(storage_io)
}
/// The stored entries' files and the bytes of their keys and values.
@@ -983,6 +985,9 @@ impl PluginApi {
/// older version into it.
fn storage(&self) -> Result<PathBuf, HostError> {
let dir = self.shared.storage_dir(self.id());
std::fs::create_dir_all(&self.shared.data_dir).map_err(storage_io)?;
ide_vfs::create_private_dir(dir.parent().expect("has parent")).map_err(failed)?;
ide_vfs::create_private_dir(&dir).map_err(failed)?;
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
match std::fs::read(&legacy) {
Ok(bytes) => {
@@ -2682,7 +2687,9 @@ impl HostApi for PluginApi {
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
Err(e) => return Err(not_writable(real, e)),
};
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
check_write_path(real)?;
if private {
let bytes = ide_vfs::encode(text, &encoding, has_bom).map_err(|e| not_writable(real, e))?;
ide_vfs::write_atomic_private(real, &bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
@@ -2692,7 +2699,9 @@ impl HostApi for PluginApi {
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
check_write_path(real)?;
if private {
ide_vfs::write_atomic_private(real, bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
@@ -2700,6 +2709,16 @@ impl HostApi for PluginApi {
}
}
/// Approved write paths are already resolved. Refuse substitutions since approval.
fn check_write_path(path: &Path) -> Result<(), HostError> {
let parent = path.parent().ok_or_else(|| HostError::Invalid("no directory to put it in".into()))?;
let checked = ide_plugin::resolve_writable_in_root(parent, &to_ipc(path)).map_err(HostError::Invalid)?;
if checked != path {
return Err(HostError::Invalid(format!("{}: file path changed before writing", path.display())));
}
Ok(())
}
/// A file that is not there, or cannot be read, as the plugin sees it.
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
match e.kind() {
@@ -3103,6 +3122,72 @@ mod tests {
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[cfg(unix)]
#[test]
fn writes_recheck_approved_paths_after_symlink_substitution() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("nested")).unwrap();
let path = ide_plugin::resolve_writable_in_root(&root, "nested/file.txt").unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
symlink(&outside, &path).unwrap();
assert!(api.write_text_file(&path, "text").is_err());
assert!(api.write_bytes_file(&path, b"bytes").is_err());
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(root.join("nested")).unwrap();
symlink(tmp.path(), root.join("nested")).unwrap();
assert!(api.write_text_file(&path, "text").is_err());
assert!(api.write_bytes_file(&path, b"bytes").is_err());
assert!(!tmp.path().join("file.txt").exists());
}
#[cfg(unix)]
#[test]
fn storage_and_secrets_do_not_write_through_temporary_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
let dir = api.storage().unwrap();
let entry = dir.join(entry_name("key"));
symlink(&outside, entry.with_extension("tmp")).unwrap();
symlink(&outside, &entry).unwrap();
api.storage_set("key", Some("value".into())).unwrap();
assert_eq!(api.storage_get("key").unwrap().as_deref(), Some("value"));
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
secrets.set("acme.lint", "key", Some("before")).unwrap();
symlink(&outside, secrets.file("acme.lint").with_extension("tmp")).unwrap();
secrets.set("acme.lint", "key", Some("after")).unwrap();
assert_eq!(secrets.get("acme.lint", "key").as_deref(), Some("after"));
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
}
#[cfg(unix)]
#[test]
fn storage_and_secrets_refuse_symlinked_private_directories() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let dir = api.storage().unwrap();
std::fs::remove_dir(&dir).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, &dir).unwrap();
assert!(api.storage_set("key", Some("value".into())).is_err());
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
std::fs::create_dir(&secrets.dir).unwrap();
std::os::unix::fs::symlink(&outside, secrets.dir.join("acme.lint")).unwrap();
assert!(secrets.set("acme.lint", "key", Some("value")).is_err());
assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn private_directory_matches_resolved_write_paths() {
+24
View File
@@ -542,6 +542,30 @@ fn project_write_stays_inside_the_project() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[cfg(unix)]
#[test]
fn project_writes_confine_symlink_targets() {
use std::os::unix::fs::symlink;
let f = fixture(Limits::default());
f.install(&misbehave_with(f._tmp.path(), "misbehave-symlink-writer", "project.write"));
let outside = f._tmp.path().join("secret.txt");
let missing = f._tmp.path().join("missing.txt");
symlink(&outside, f.project.join("outside.txt")).unwrap();
symlink(&missing, f.project.join("dangling.txt")).unwrap();
for name in ["outside.txt", "dangling.txt"] {
let error = f.run("misbehave", "projwrite", Some(name)).unwrap_err();
assert!(error.contains("access outside the project root is not allowed"), "{error}");
}
assert_eq!(std::fs::read_to_string(outside).unwrap(), "top secret");
assert!(!missing.exists());
for (name, target) in [("inside.txt", "src/notes.txt"), ("new.txt", "new/deep.txt")] {
symlink(target, f.project.join(name)).unwrap();
f.run("misbehave", "projwrite", Some(name)).unwrap();
assert_eq!(std::fs::read_to_string(f.project.join(target)).unwrap(), "written inside the project\n");
assert!(std::fs::symlink_metadata(f.project.join(name)).unwrap().is_symlink());
}
}
/// `fs.write` replaces files the grant covers and nothing else, through the
/// real component.
#[test]
+4 -1
View File
@@ -20,7 +20,10 @@ pub use runtime::{
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
component_api, host_api,
};
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
pub use sandbox::{
MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real,
resolve_writable_in_root,
};
pub use types::*;
pub use wasmtime::component::Component;
+125 -8
View File
@@ -117,6 +117,45 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
Ok(real)
}
/// Resolves a content write's destination, including existing and dangling
/// symlinks in the final component. Entry operations use [`resolve_new_in_root`].
pub fn resolve_writable_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let (root, joined) = joined_path(root, path)?;
let real = resolve_write_target(&joined, 40).map_err(|e| format!("{path}: {e}"))?;
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
}
Ok(real)
}
fn resolve_write_target(path: &Path, links_left: usize) -> Result<PathBuf, std::io::Error> {
match dunce::canonicalize(path) {
Ok(real) => return Ok(real),
Err(error) => match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_symlink() => {
if links_left == 0 {
return Err(std::io::Error::other("too many symbolic links"));
}
let target = std::fs::read_link(path)?;
let target = if target.is_absolute() { target } else { path.parent().unwrap().join(target) };
return resolve_write_target(&target, links_left - 1);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(error),
},
}
let parent = path.parent().ok_or_else(|| std::io::Error::other("no directory to put it in"))?;
let mut real = resolve_write_target(parent, links_left)?;
match path.components().next_back() {
Some(std::path::Component::ParentDir) => {
real.pop();
}
Some(std::path::Component::Normal(name)) => real.push(name),
_ => {}
}
if real != path { resolve_write_target(&real, links_left) } else { Ok(real) }
}
/// Reads a UTF-8 file that `resolve_in_root` already approved.
pub fn read_text(real: &Path) -> Result<String, String> {
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
@@ -194,9 +233,9 @@ pub fn resolve_granted(granted: &[Capability], path: &str) -> Result<PathBuf, Re
/// The real path `path` would be written to, when an `fs.write:<path>` grant
/// covers it.
///
/// A file that does not exist yet cannot be canonicalized, so its directory is
/// canonicalized instead and the name appended; an existing file (or link) is
/// followed first, so a link cannot be used to write outside the grant.
/// Missing paths and dangling links are resolved to their target location;
/// an existing file (or link) is followed first. The destination's directory
/// must exist.
pub fn resolve_writable(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
resolve(granted, path, Wanted::Write)
}
@@ -241,12 +280,14 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
}
/// Where `asked` would be created: its directory, resolved, plus its name.
/// Where `asked` would be created, including a dangling link's target.
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?;
Ok(dir.join(name))
let real = resolve_write_target(asked, 40).map_err(|e| e.to_string())?;
let parent = real.parent().ok_or_else(|| "no directory to put it in".to_string())?;
if !parent.is_dir() {
return Err("its directory does not exist".into());
}
Ok(real)
}
/// Simplifies Windows drive paths only when the plain form preserves their meaning.
@@ -387,6 +428,82 @@ mod tests {
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
}
#[cfg(unix)]
#[test]
fn writable_paths_follow_existing_and_dangling_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::fs::write(root.join("inside.txt"), "before").unwrap();
for (name, target) in [
("outside", outside.clone()),
("dangling-outside", tmp.path().join("missing.txt")),
("inside", PathBuf::from("inside.txt")),
("dangling-inside", PathBuf::from("new/deep.txt")),
("chain", PathBuf::from("dangling-outside")),
("cycle", PathBuf::from("cycle")),
] {
symlink(target, root.join(name)).unwrap();
}
for name in ["outside", "dangling-outside", "chain"] {
assert!(resolve_writable_in_root(&root, name).unwrap_err().contains("outside the project root"));
assert!(
resolve_writable_in_root(&root, &format!("missing/../{name}"))
.unwrap_err()
.contains("outside the project root")
);
}
assert!(resolve_writable_in_root(&root, "cycle").is_err());
let inside = resolve_writable_in_root(&root, "inside").unwrap();
assert_eq!(inside, root.join("inside.txt"));
std::fs::write(inside, "after").unwrap();
assert_eq!(std::fs::read_to_string(root.join("inside.txt")).unwrap(), "after");
assert_eq!(resolve_writable_in_root(&root, "dangling-inside").unwrap(), root.join("new/deep.txt"));
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
assert!(!tmp.path().join("missing.txt").exists());
}
#[cfg(unix)]
#[test]
fn writable_grants_confine_dangling_symlink_targets() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("granted");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let grant = Capability::FsWrite(strip_verbatim(&root));
symlink("../missing.txt", root.join("outside")).unwrap();
symlink("new.txt", root.join("inside")).unwrap();
assert!(matches!(
resolve_writable(std::slice::from_ref(&grant), root.join("outside").to_str().unwrap()),
Err(Refusal::Denied(_))
));
assert_eq!(resolve_writable(&[grant], root.join("inside").to_str().unwrap()).unwrap(), root.join("new.txt"));
}
#[cfg(unix)]
#[test]
fn entry_operations_do_not_follow_outside_symlinks() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
let from = resolve_new_in_root(&root, "link").unwrap();
let to = resolve_new_in_root(&root, "renamed").unwrap();
std::fs::rename(&from, &to).unwrap();
assert!(!from.try_exists().unwrap());
assert!(std::fs::symlink_metadata(&to).unwrap().is_symlink());
std::fs::remove_file(resolve_new_in_root(&root, "renamed").unwrap()).unwrap();
assert!(std::fs::symlink_metadata(&to).is_err());
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
}
#[cfg(unix)]
#[test]
fn symlinks_cannot_escape() {
+1 -1
View File
@@ -761,7 +761,7 @@ impl workspace::HostProject for StoreData {
return Ok(Err(too_much_to_write(&path)));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_new_in_root(&root, &path) {
let real = match sandbox::resolve_writable_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
+1 -188
View File
@@ -131,117 +131,6 @@ fn delve_build_flags(args: &[String], files: bool) -> Value {
)
}
/// An explicitly selected Go executable must also be the compiler Delve finds on PATH.
pub fn prepare_debug_compiler(
env: &mut std::collections::BTreeMap<String, String>,
extra_json: &Option<String>,
) -> ide_core::IdeResult<()> {
#[cfg(windows)]
for key in ["PATH", "GOROOT"] {
if let Some(previous) = env.keys().find(|name| name.eq_ignore_ascii_case(key) && name.as_str() != key).cloned()
{
let value = env.remove(&previous).unwrap();
env.entry(key.into()).or_insert(value);
}
}
let extra: Value = extra_json.as_deref().and_then(|value| serde_json::from_str(value).ok()).unwrap_or_default();
let Some(executable) = extra["__goExecutable"].as_str().filter(|value| *value != "go" && *value != "go.exe") else {
return Ok(());
};
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
let executable = ide_core::process::find_executable(executable, &path)
.ok_or_else(|| ide_core::IdeError::unavailable(format!("Go executable not found: {executable}")))?;
if !executable.file_name().is_some_and(|name| {
if cfg!(windows) {
let name = name.to_string_lossy();
name.eq_ignore_ascii_case("go") || name.eq_ignore_ascii_case("go.exe")
} else {
name == "go"
}
}) {
return Err(ide_core::IdeError::invalid("Delve requires a Go SDK executable named go or go.exe"));
}
if !env.contains_key("GOROOT") {
let mut command = std::process::Command::new(&executable);
command.args(["env", "GOROOT"]).envs(env.iter());
let goroot = crate::exec::query_command(command, std::time::Duration::from_secs(10))
.ok_or_else(|| ide_core::IdeError::unavailable("Cannot query the selected Go SDK"))?;
env.insert("GOROOT".into(), goroot);
}
let mut paths = vec![executable.parent().unwrap().to_path_buf()];
paths.extend(std::env::split_paths(&path));
let path = std::env::join_paths(paths).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
env.insert("PATH".into(), path.to_string_lossy().into_owned());
Ok(())
}
fn resolve_debug_target(
program: &mut Option<String>,
env: &std::collections::BTreeMap<String, String>,
extra: &mut Value,
flags: &mut Vec<String>,
) -> ide_core::IdeResult<()> {
let Some(targets) = extra.get("__goTargets") else { return Ok(()) };
let targets: Vec<String> =
serde_json::from_value(targets.clone()).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
let dir = extra["__goBuildDir"]
.as_str()
.map(std::path::Path::new)
.ok_or_else(|| ide_core::IdeError::invalid("Go debug build directory is missing"))?;
if targets.iter().any(|target| target.ends_with(".go")) {
if !targets.iter().all(|target| target.ends_with(".go")) {
return Err(ide_core::IdeError::invalid("Select Go source files or one package for debugging"));
}
let files: Vec<_> =
targets.iter().map(|target| dunce::canonicalize(dir.join(target))).collect::<Result<_, _>>()?;
let first = files.first().ok_or_else(|| ide_core::IdeError::invalid("Go debug target is missing"))?;
if files.iter().any(|file| file.parent() != first.parent()) {
return Err(ide_core::IdeError::invalid("Go source files must be in the same directory"));
}
*program = Some(first.to_string_lossy().into_owned());
flags.extend(files.iter().skip(1).map(|file| file.to_string_lossy().into_owned()));
extra["__goSourceArgs"] = json!(true);
return Ok(());
}
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
let go = ide_core::process::find_executable("go", &path)
.ok_or_else(|| ide_core::IdeError::unavailable("Go executable not found"))?;
let mut command = std::process::Command::new(go);
command.args(["list", "-find", "-f", "{{.Dir}}"]).current_dir(dir).envs(env.iter());
let mut args = flags.iter();
while let Some(flag) = args.next() {
let name = flag.split('=').next().unwrap_or(flag);
if ["-tags", "-mod", "-modfile", "-overlay", "-compiler"].contains(&name) {
command.arg(flag);
if name == flag
&& let Some(value) = args.next()
{
command.arg(value);
}
} else if ["-race", "-msan", "-asan"].contains(&name) {
command.arg(flag);
}
}
command.args(&targets);
let output = crate::exec::query_command(command, std::time::Duration::from_secs(10)).ok_or_else(|| {
ide_core::IdeError::invalid(format!(
"Cannot resolve Go debug target: {}. Check its module path and build flags.",
targets.join(" ")
))
})?;
let mut dirs: Vec<_> = output.lines().filter(|line| !line.trim().is_empty()).collect();
dirs.sort();
dirs.dedup();
if dirs.len() != 1 {
return Err(ide_core::IdeError::invalid(format!(
"Go debugging requires one package; this target selects {}. Select a package or test instead.",
dirs.len()
)));
}
*program = Some(dirs[0].to_owned());
Ok(())
}
pub fn apply_to_debug(
env: &mut std::collections::BTreeMap<String, String>,
program: &mut Option<String>,
@@ -281,7 +170,6 @@ pub fn apply_to_debug(
.collect::<Vec<_>>();
merge_flags(&mut args, &flags);
}
resolve_debug_target(program, env, &mut extra, &mut args)?;
if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) {
if extra["mode"] == "test" {
if let Some(file) = files.first().and_then(Value::as_str).map(std::path::Path::new) {
@@ -302,10 +190,7 @@ pub fn apply_to_debug(
}
// Delve appends its -o after array build flags; positional source files
// require the string form so all compiler flags precede the file list.
extra["buildFlags"] = delve_build_flags(
&args,
(extra.get("__goFiles").is_some() && extra["mode"] != "test") || extra["__goSourceArgs"] == true,
);
extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some() && extra["mode"] != "test");
}
if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) {
std::fs::create_dir_all(output)?;
@@ -314,10 +199,6 @@ pub fn apply_to_debug(
map.remove("__goUseTags");
map.remove("__goToolArgs");
map.remove("__goFiles");
map.remove("__goExecutable");
map.remove("__goTargets");
map.remove("__goBuildDir");
map.remove("__goSourceArgs");
}
*extra_json = Some(extra.to_string());
Ok(())
@@ -328,74 +209,6 @@ mod command_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn debug_targets_resolve_imports_and_reject_multiple_packages() {
if crate::exec::which("go").is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("go.mod"), "module example.com/debugtargets\ngo 1.22\n").unwrap();
for name in ["a", "b"] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(root.join(name).join("lib.go"), "package p\nfunc Value() {}\n").unwrap();
}
let mut env = BTreeMap::from([("GOWORK".into(), "off".into())]);
let launch = |target: &str| {
Some(json!({"mode":"test", "__goToolArgs":[], "__goTargets":[target], "__goBuildDir":root}).to_string())
};
let mut program = None;
let mut extra = launch("example.com/debugtargets/a");
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
assert_eq!(program.as_deref(), root.join("a").to_str());
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
assert!(value.get("__goTargets").is_none());
let error =
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut launch("./..."), &GoBuildTags::default())
.unwrap_err();
assert!(error.to_string().contains("requires one package"), "{error}");
std::fs::write(root.join("main.go"), "package main\nfunc main(){helper()}\n").unwrap();
std::fs::write(root.join("helper.go"), "package main\nfunc helper(){}\n").unwrap();
let mut extra = Some(
json!({"mode":"debug","__goBuildDir":root,"__goTargets":["main.go","helper.go"],"__goToolArgs":[]})
.to_string(),
);
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
assert_eq!(program.as_deref(), root.join("main.go").to_str());
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
assert_eq!(
value["buildFlags"],
format!("'{}'", root.join("helper.go").to_string_lossy().replace('\\', "\\\\").replace('\'', "\\'"))
);
}
#[cfg(windows)]
#[test]
fn compiler_selection_preserves_case_insensitive_windows_environment_keys() {
let mut env =
BTreeMap::from([("Path".into(), "explicit path".into()), ("GoRoot".into(), "explicit root".into())]);
prepare_debug_compiler(&mut env, &None).unwrap();
assert_eq!(env["PATH"], "explicit path");
assert_eq!(env["GOROOT"], "explicit root");
assert!(!env.contains_key("Path"));
assert!(!env.contains_key("GoRoot"));
}
#[test]
fn the_selected_go_compiler_precedes_the_existing_path() {
let Some(go) = crate::exec::which("go") else {
return;
};
let mut env = BTreeMap::from([("PATH".into(), "/unrelated".into())]);
let extra = Some(json!({"__goExecutable":go}).to_string());
prepare_debug_compiler(&mut env, &extra).unwrap();
assert_eq!(std::env::split_paths(&env["PATH"]).next().as_deref(), go.parent());
assert!(!env["GOROOT"].is_empty());
env.insert("GOROOT".into(), "explicit".into());
prepare_debug_compiler(&mut env, &extra).unwrap();
assert_eq!(env["GOROOT"], "explicit");
}
#[test]
fn detected_go_runs_inherit_tags_only_for_commands_that_accept_build_flags() {
let tags = GoBuildTags { custom: "integration".into(), ..Default::default() };
-11
View File
@@ -5,7 +5,6 @@
*/
import { writeClipboardText } from "@/platform/clipboard";
import { pasteEditorClipboard } from "./cm/clipboard";
import type { ReactNode } from "react";
import { EditorSelection } from "@codemirror/state";
import { create } from "zustand";
import { setContext } from "@/platform/context";
@@ -212,16 +211,6 @@ export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
}
}
/** A kind's trailing marker (status dot …); `null` when it has none. */
export function tabTrailing(input: EditorInput, kinds: EditorKinds): ReactNode {
const kind = kinds.get(input.kind);
try {
return kind?.trailing?.(input) ?? null;
} catch {
return null;
}
}
export class EditorController implements SlotHost {
manager: DocumentManager;
readonly slots = new Map<string, EditorSlot>();
+2 -18
View File
@@ -46,15 +46,7 @@ import {
type TabItem,
type TabsDnd,
} from "@/ui";
import {
editorController,
isTextInput,
tabIcon,
tabTitle,
tabTrailing,
useTabs,
type VirtualInputData,
} from "../controller";
import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller";
import { useDocMeta } from "../model/document";
import { inputDirty } from "../model/customDocuments";
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
@@ -201,8 +193,6 @@ function PaneView({ paneId }: { paneId: string }) {
: names.filter((n) => n === name).length > 1 && t.input.path
? basename(dirname(t.input.path))
: null;
const kindMark = tabTrailing(t.input, kinds);
const pin = t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : null;
return {
id: t.id,
label: (
@@ -223,13 +213,7 @@ function PaneView({ paneId }: { paneId: string }) {
color: colors[i] || undefined,
tint: tints[i] || undefined,
tooltip: virtualTooltip(t.input) ?? (showFullPathOnHover ? (t.input.path ?? name) : undefined),
trailing:
kindMark || pin ? (
<>
{kindMark}
{pin}
</>
) : undefined,
trailing: t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : undefined,
};
});
}, [
-25
View File
@@ -1,25 +0,0 @@
/** The tab strip's agent status indicator: spinner while the task is busy, dot once done (#2233). */
import { Badge, Spinner, tooltipAttrs } from "@/ui";
import { isBusy, statusLabel } from "./model";
import { useAgents } from "./store";
export function TaskStatusMark({ task }: { task: string }) {
const status = useAgents((s) => s.tasks.get(task)?.status);
if (!status) return null;
const busy = isBusy(status);
const label = statusLabel(status);
return (
<span {...tooltipAttrs(label, undefined, true)} style={{ display: "inline-flex", alignItems: "center" }}>
{busy ? (
<Spinner size={10} variant="spokes" label={label} />
) : (
<Badge
dot
tone={status === "ready" ? "success" : status === "failed" || status === "limited" ? "danger" : "neutral"}
>
{label}
</Badge>
)}
</span>
);
}
-4
View File
@@ -3,7 +3,6 @@
* Agents tool window lists tasks by what they need from the user, a task opens as an
* editor tab (conversation + changed files), and New Task is an editor page.
*/
import { createElement } from "react";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
@@ -23,7 +22,6 @@ import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "
import { sendPaths, sendSelection } from "./references";
import { focusedSession } from "./sessionFocus";
import { connect, disconnect, openHome, useAgents } from "./store";
import { TaskStatusMark } from "./TaskStatusMark";
import { taskActions } from "./taskActions";
import { replyActions } from "./replyActions";
@@ -90,8 +88,6 @@ export function activate(): () => void {
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
},
icon: () => "toolwindow-agents",
// Busy / done marker in the tab's trailing slot (#2233).
trailing: (input) => createElement(TaskStatusMark, { task: (input.data as { task: string }).task }),
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
restorable: true,
// Rebuilding a long conversation on every switch back is slow.
+1 -1
View File
@@ -297,7 +297,7 @@ function onEvent(e: AgentEvent) {
case "task": {
const prev = s.tasks.get(e.task.id);
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
if (prev && (prev.title !== e.task.title || prev.status !== e.task.status)) notifyTabTitlesChanged();
if (prev && prev.title !== e.task.title) notifyTabTitlesChanged();
if (e.task.status === "archived") setUnread(e.task.id, false);
else if (attentionChange(prev?.status, e.task.status)) markUnread(e.task.id);
notifyChange(prev, e.task);
+3 -53
View File
@@ -253,13 +253,7 @@ describe("run config → launch config", () => {
it("delve: debug mode for run, test mode for test", () => {
const run = launchConfigFor(rc({ kind: "go", command: "go", args: ["run", "."], debugAdapter: "delve" }), "/p")!;
expect(run.program).toBe("/p");
expect(extra(run)).toMatchObject({
mode: "debug",
__goTargets: ["."],
__goExecutable: "go",
__goToolArgs: [],
__goUseTags: true,
});
expect(extra(run)).toEqual({ mode: "debug", __goToolArgs: [], __goUseTags: true });
const sub = launchConfigFor(
rc({ kind: "go", command: "go", args: ["run", "./cmd/api"], debugAdapter: "delve" }),
"/p",
@@ -269,8 +263,8 @@ describe("run config → launch config", () => {
rc({ kind: "go", command: "go", args: ["test", "./..."], debugAdapter: "delve" }),
"/p",
)!;
expect(extra(test)).toMatchObject({ mode: "test", __goTargets: ["./..."], __goToolArgs: [], __goUseTags: true });
expect(test.program).toBe("/p/...");
expect(extra(test)).toEqual({ mode: "test", __goToolArgs: [], __goUseTags: true });
expect(test.program).toBe("/p");
// A gutter test / subtest: the package, and `-run` for the test binary.
const one = launchConfigFor(
rc({
@@ -425,50 +419,6 @@ it("netcoredbg launches the evaluated DLL with resolved profile arguments, env a
expect(launchConfigFor({ ...c, params: {} }, "/repo")).toBeNull();
});
describe("Go debug target resolution inputs", () => {
it("preserves import paths and multiple source files for Go to resolve", () => {
const imported = launchConfigFor(
rc({ kind: "go", command: "/sdk/bin/go", args: ["test", "example.com/app/calc"], debugAdapter: "delve" }),
"/p",
)!;
expect(imported.program).toBe("example.com/app/calc");
expect(extra(imported)).toMatchObject({
__goTargets: ["example.com/app/calc"],
__goExecutable: "/sdk/bin/go",
__goBuildDir: "/p",
});
const files = launchConfigFor(
rc({ kind: "go", command: "go", args: ["run", "main.go", "helper.go", "hello"], debugAdapter: "delve" }),
"/p",
)!;
expect(files.args).toEqual(["hello"]);
expect(extra(files).__goTargets).toEqual(["main.go", "helper.go"]);
const packages = launchConfigFor(
rc({ kind: "go", command: "go", args: ["build", "./a", "./b"], debugAdapter: "delve" }),
"/p",
)!;
expect(extra(packages).__goTargets).toEqual(["./a", "./b"]);
});
it("keeps a typed recursive package target and the module's selected compiler", () => {
const launch = launchConfigFor(
rc({
kind: "go",
command: "./sdk/bin/go",
args: ["test", "./..."],
debugAdapter: "delve",
params: { goKind: "test", scope: "package", target: "./...", module: "backend" },
}),
"/p",
)!;
expect(extra(launch)).toMatchObject({
__goTargets: ["./..."],
__goBuildDir: "/p/backend",
__goExecutable: "/p/backend/sdk/bin/go",
});
});
});
it("raw and typed Go debug configurations inherit custom tags unless explicitly disabled", () => {
for (const typed of [false, true]) {
for (const useTags of [undefined, "true", "false"]) {
+6 -18
View File
@@ -16,7 +16,7 @@ function join(base: string, p: string) {
/** A command path with a directory part resolved against `cwd`; bare names stay for PATH lookup. */
function resolveCommand(cmd: string, cwd: string) {
return /[\\/]/.test(cmd) ? join(cwd, cmd) : cmd;
return cmd.includes("/") ? join(cwd, cmd) : cmd;
}
function splitAtDashDash(args: readonly string[]): [string[], string[]] {
@@ -225,9 +225,6 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
dlvCwd: module,
...(p.scope === "file" ? { __goFiles: target.split("|").map((f) => join(module, f.trim())) } : {}),
__goToolArgs: toolArgs,
...(p.scope === "file" ? {} : { __goTargets: [target] }),
__goBuildDir: module,
__goExecutable: resolveCommand(rc.command, module),
__goUseTags: p.useTags !== "false",
...(p.outputDir ? { output: join(root, `${p.outputDir}/go-debug`) } : {}),
});
@@ -264,11 +261,9 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
}
const selection =
test &&
/^-(?:test\.)?(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(
a,
);
if (test && /^-(?:test\.)?(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
testFlags.push(a.startsWith("-test.") ? a : a.replace(/^-/, "-test."));
/^-(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(a);
if (test && /^-(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
testFlags.push(a.replace(/^-/, "-test."));
} else if (selection) {
const value = selection[2] ?? rest[++i];
if (value !== undefined) testFlags.push(`-test.${selection[1]}`, value);
@@ -276,25 +271,18 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
if (test && (a === "-json" || a === "-c")) continue;
flags.push(a);
if (!a.includes("=") && !boolFlags.has(a) && rest[i + 1] !== undefined) flags.push(rest[++i]!);
} else if (
sub === "run" &&
targets.length &&
!(a.endsWith(".go") && targets.every((target) => target.endsWith(".go")))
) {
} else if (!test && targets.length) {
programArgs = rest.slice(i);
break;
} else targets.push(a);
}
const target = targets[0] ?? ".";
out.program = !target.startsWith(".") && !isAbsolute(target) && !target.endsWith(".go") ? target : join(cwd, target);
out.program = target.endsWith("/...") ? cwd : join(cwd, target);
out.args = [...testFlags, ...programArgs];
out.extraJson = JSON.stringify({
mode: test ? "test" : "debug",
...(flags.length ? { buildFlags: formatArgs(flags) } : {}),
__goToolArgs: flags,
__goTargets: targets.length ? targets : ["."],
__goBuildDir: cwd,
__goExecutable: resolveCommand(rc.command, cwd),
__goUseTags: rc.params?.useTags !== "false",
});
return out;
+1 -15
View File
@@ -6,7 +6,6 @@ import { ToolWindowHeader, useOwnToolWindowHeader } from "@/workbench/toolwindow
import { usePlatform } from "@/platform/platformInfo";
import { commands, isTauri } from "@/platform/ipc";
import {
Badge,
dropZoneAt,
IconButton,
Input,
@@ -14,7 +13,6 @@ import {
Popup,
registerDropTarget,
showContextMenu,
Spinner,
SplitGroup,
SplitHandle,
SplitPanel,
@@ -358,19 +356,8 @@ function GroupTabs({ group }: { group: string }) {
const focusRaf = useRef(0);
const items: TabItem[] = tabs.map((t) => {
const session = sessions[t.session];
const status = session?.status;
const status = sessions[t.session]?.status;
const dead = status === "exited" || status === "error";
// Running / done marker (#2233): a command at work spins, a finished one
// shows a filled dot, an idle shell at the prompt shows none.
const busy = !!session?.commandRunning || status === "starting";
const mark = busy ? (
<Spinner size={10} variant="spokes" label="Running" />
) : dead ? (
<Badge dot tone={status === "error" ? "danger" : "neutral"}>
{status === "error" ? "Failed" : "Exited"}
</Badge>
) : null;
return {
id: t.id,
label: tabTitle(t, sessions),
@@ -378,7 +365,6 @@ function GroupTabs({ group }: { group: string }) {
italic: dead,
color: dead ? "var(--fg-subtle)" : undefined,
tooltip: tabTooltip(t, sessions),
trailing: mark ?? undefined,
};
});
-2
View File
@@ -17,7 +17,6 @@ import { installRepeatedDoubleClick } from "./platform/doubleClick";
import { installErrorLogging, logError } from "./platform/errorLog";
import { guessOs } from "./platform/platformInfo";
import { installReorderedTapClick } from "./platform/reordered-tap-click";
import { installWebKitClipboardGuard } from "./platform/webkitClipboard";
import { installPageSwipeTracking } from "./platform/pageSwipe";
import { installImeKeyGuard } from "./platform/ime";
import { installKeyboardDispatcher } from "./platform/keymap";
@@ -39,7 +38,6 @@ installRepeatedDoubleClick();
// second press like the release before it, which the shim would take for a reordered tap and
// click twice.
if (guessOs() === "macos" && !/Chrome|Firefox/.test(navigator.userAgent)) installReorderedTapClick();
if (guessOs() === "linux" && isTauri()) installWebKitClipboardGuard();
installWindowDrag();
installPointerFocus();
installImeKeyGuard();
-172
View File
@@ -1,172 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState, StateEffect } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { installWebKitClipboardGuard } from "./webkitClipboard";
const views: EditorView[] = [];
let dispose: () => void;
beforeEach(() => {
dispose = installWebKitClipboardGuard();
});
afterEach(() => {
dispose();
views.splice(0).forEach((view) => view.destroy());
document.body.replaceChildren();
});
function editor(anchor = 5, head = anchor) {
const view = new EditorView({
doc: "first\nsecond\nthird",
selection: EditorSelection.single(anchor, head),
parent: document.body,
});
views.push(view);
view.focus();
return view;
}
function key(target: HTMLElement, type = "keydown", init: KeyboardEventInit = { key: "x", ctrlKey: true }) {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
}
function clipboard(target: HTMLElement, type = "cut") {
const data = new Map<string, string>();
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, { clipboardData: { clearData: () => data.clear(), setData: data.set.bind(data) } });
target.dispatchEvent(event);
return data.get("text/plain");
}
describe("WebKitGTK clipboard events", () => {
it.each([
{ key: "x", ctrlKey: true },
{ key: "Delete", shiftKey: true },
])("cuts only the caret line for one $key keydown and two cut events (#962)", (shortcut) => {
const view = editor();
key(view.contentDOM, "keydown", shortcut);
expect(clipboard(view.contentDOM)).toBe("first");
expect(clipboard(view.contentDOM)).toBeUndefined();
expect(view.state.doc.toString()).toBe("second\nthird");
});
it.each([0, 2, 5, 6, 12, 18])("protects a caret at offset %i", (position) => {
const view = editor(position);
const line = view.state.doc.lineAt(position);
const expected = view.state.doc.toString().slice(0, line.from) + view.state.doc.toString().slice(line.to + 1);
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe(line.text);
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe(expected);
});
it("preserves a selected-text cut and the following line", () => {
const view = editor(0, 5);
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first");
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe("\nsecond\nthird");
});
it("cuts multiple caret lines once", () => {
const view = editor();
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.allowMultipleSelections.of(true)) });
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(5), EditorSelection.cursor(12)]) });
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first\nsecond");
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe("third");
});
it("keeps a read-only document unchanged", () => {
const view = editor();
view.dispatch({ effects: StateEffect.appendConfig.of(EditorState.readOnly.of(true)) });
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first");
clipboard(view.contentDOM);
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
});
it("suppresses duplicate copies without changing the document", () => {
const view = editor();
key(view.contentDOM, "keydown", { key: "c", ctrlKey: true });
expect(clipboard(view.contentDOM, "copy")).toBe("first");
expect(clipboard(view.contentDOM, "copy")).toBeUndefined();
expect(view.state.doc.toString()).toBe("first\nsecond\nthird");
});
it.each(["cut", "copy"])("cancels the keypress fallback after the first %s", (type) => {
const view = editor();
const down = key(view.contentDOM);
expect(down.defaultPrevented).toBe(false);
clipboard(view.contentDOM, type);
expect(down.defaultPrevented).toBe(true);
});
it("allows successive key presses and auto-repeat", () => {
const view = editor();
for (const repeat of [false, true]) {
key(view.contentDOM, "keydown", { key: "x", ctrlKey: true, repeat });
expect(clipboard(view.contentDOM)).toBe(repeat ? "second" : "first");
clipboard(view.contentDOM);
}
expect(view.state.doc.toString()).toBe("third");
});
it.each(["keyup", "pointerdown", "blur"])("allows menu cuts after %s", (type) => {
const view = editor();
key(view.contentDOM);
clipboard(view.contentDOM);
if (type === "blur") window.dispatchEvent(new Event(type));
else view.contentDOM.dispatchEvent(new Event(type, { bubbles: true }));
expect(clipboard(view.contentDOM)).toBe("second");
});
it("does not suppress repeated menu cuts without a keydown", () => {
const view = editor();
expect(clipboard(view.contentDOM)).toBe("first");
expect(clipboard(view.contentDOM)).toBe("second");
});
it("allows multiple explicit cuts from an action that handled its key", () => {
const view = editor();
view.contentDOM.addEventListener("keydown", (event) => event.preventDefault());
key(view.contentDOM);
expect(clipboard(view.contentDOM)).toBe("first");
expect(clipboard(view.contentDOM)).toBe("second");
});
it("does not carry a key press into another editor", () => {
const first = editor();
const second = editor();
key(first.contentDOM);
clipboard(first.contentDOM);
expect(clipboard(second.contentDOM)).toBe("first");
expect(clipboard(second.contentDOM)).toBe("second");
});
it("does not intercept plain inputs or unrelated clipboard events", () => {
const input = document.body.appendChild(document.createElement("input"));
let cuts = 0;
input.addEventListener("cut", () => cuts++);
key(input);
clipboard(input);
clipboard(input);
expect(cuts).toBe(2);
const view = editor();
let pastes = 0;
view.contentDOM.addEventListener("paste", () => pastes++);
key(view.contentDOM);
for (let i = 0; i < 2; i++) view.contentDOM.dispatchEvent(new Event("paste", { bubbles: true }));
expect(pastes).toBe(2);
});
it("removes the guard on disposal", () => {
const view = editor();
key(view.contentDOM);
clipboard(view.contentDOM);
dispose();
expect(clipboard(view.contentDOM)).toBe("second");
});
});
-39
View File
@@ -1,39 +0,0 @@
/** WebKitGTK 2.54 can dispatch copy/cut twice for one keydown with a collapsed selection. */
export function installWebKitClipboardGuard(): () => void {
let pending: { editor: Element; keydown: KeyboardEvent; seen: Set<string> } | null = null;
const reset = () => {
pending = null;
};
const onKeyDown = (event: KeyboardEvent) => {
const editor = event.target instanceof Element ? event.target.closest(".cm-content") : null;
pending = editor ? { editor, keydown: event, seen: new Set() } : null;
};
const onClipboard = (event: Event) => {
if (!pending || !(event.target instanceof Element) || event.target.closest(".cm-content") !== pending.editor)
return;
// Actions that already handled the key may intentionally run several clipboard commands (macros).
if (pending.seen.size === 0 && pending.keydown.defaultPrevented) return;
if (pending.seen.has(event.type)) {
event.preventDefault();
event.stopImmediatePropagation();
} else {
pending.seen.add(event.type);
// The native command can report failure after CodeMirror handles it. Stop its keypress fallback too.
pending.keydown.preventDefault();
}
};
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("keyup", reset, true);
window.addEventListener("pointerdown", reset, true);
window.addEventListener("blur", reset, true);
window.addEventListener("copy", onClipboard, true);
window.addEventListener("cut", onClipboard, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("keyup", reset, true);
window.removeEventListener("pointerdown", reset, true);
window.removeEventListener("blur", reset, true);
window.removeEventListener("copy", onClipboard, true);
window.removeEventListener("cut", onClipboard, true);
};
}
-2
View File
@@ -117,8 +117,6 @@ export interface EditorKindContribution {
component: ComponentType<{ input: EditorInput }>;
title: (input: EditorInput) => string;
icon?: (input: EditorInput) => string;
/** Small marker rendered in the tab's trailing slot (a status dot for live state). */
trailing?: (input: EditorInput) => ReactNode;
/**
* Its tabs are saved with the editor session and reopened with the project (their
* `data` must be plain JSON). The editor closes tabs whose target is gone itself.