Compare commits

..
Author SHA1 Message Date
l0ng-ai ac7139c94d feat(plugins): open two texts in the diff viewer (API 1.7)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

views.open-diff opens two texts in the invoking window's diff viewer, with
optional labels and title and a file name that picks the syntax highlighting;
web views send { op: "open-diff", ... }. The SDK wraps it as host::open_diff
with a mock. The plugin API moves to 1.7.0 (1.6.0 is released) and the checked-in
components are rebuilt.
2026-11-13 11:47:06 +08:00
56 changed files with 134 additions and 1526 deletions

No files matched your search

+9 -139
View File
@@ -1680,15 +1680,6 @@ pub enum PomGotoKind {
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
/// The POM of the artifact itself: a project module's, else the local repository's.
ArtifactPom,
}
/// A Maven project of the workspace, for going to a dependency on it.
pub struct ModulePom {
pub group_id: String,
pub artifact_id: String,
pub pom: PathBuf,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1768,20 +1759,6 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
goto_declaration_in(text, path, pos, local_repo, Vec::new)
}
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
/// management entry provides (or a management entry itself) go to the artifact's own
/// POM: the project module among `modules` with its coordinates, else the local
/// repository's POM of its version.
pub fn goto_declaration_in(
text: &str,
path: &Path,
pos: Pos,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1792,56 +1769,15 @@ pub fn goto_declaration_in(
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
if elements.get(1).is_some_and(|e| e.name == "parent") {
let chain = parent_chain(path, text, local_repo);
let (pom, parent_text) = chain.get(1)?;
let label = elements[1].text_of("artifactId").unwrap_or_default();
return Some(pom_goto(pom.clone(), parent_text, label));
}
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
// A management entry declares the version: it goes to the artifact (a BOM import, say).
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
return Some(found);
}
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
}
artifact_target(text, path, e, local_repo, modules)
}
/// The artifact's own POM for a dependency or plugin element `e`.
fn artifact_target(
text: &str,
path: &Path,
e: &Element,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let props = properties_at(&mut resolver, &chain, 0);
let (group, artifact) = coordinates(e, &props)?;
let label = format!("{group}:{artifact}");
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
let module_text = xml::read_text(&module.pom)?;
return Some(pom_goto(module.pom, &module_text, label));
}
let version = interpolate(&e.text_of("version")?, &props);
let pom = resolver.repo_pom(&group, &artifact, &version)?;
let pom_text = xml::read_text(&pom)?;
Some(pom_goto(pom, &pom_text, label))
}
/// The POM at `path` as a target, at its `<artifactId>`.
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
let range = xml::parse(text)
.filter(|e| e.name == "project")
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
.unwrap_or_default();
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -3151,9 +3087,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version and no local repository has its POM.
// The dependency names its own version: it is the declaration.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry declares the version; its artifact's POM is not at hand either.
// A management entry is a declaration itself, not a reference.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
@@ -3165,72 +3101,6 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
std::fs::create_dir_all(&bom_dir).unwrap();
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${{alibaba.version}}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let core = tmp.path().join("core/pom.xml");
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
std::fs::write(
&core,
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
)
.unwrap();
let modules =
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
// A dependency on a project module opens the module's POM.
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto =
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
// The <parent> section opens the parent POM.
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
assert_eq!(goto.path, canon(&parent_path));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
-15
View File
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
#[cfg(target_os = "macos")]
pub fn text() -> Option<String> {
imp::text()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -380,14 +373,6 @@ mod imp {
})
}
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
+1 -52
View File
@@ -76,8 +76,7 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
text
}
/// The `encoding_rs` encoding of a Windows code page, when it has one.
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
@@ -102,56 +101,6 @@ pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding>
})
}
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
pub fn console_code_page() -> Option<u32> {
#[cfg(windows)]
{
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
(cp != 65001).then_some(cp)
}
#[cfg(not(windows))]
{
None
}
}
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
/// piece by piece.
#[cfg(windows)]
pub fn is_single_byte_code_page(cp: u32) -> bool {
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
unsafe {
let mut info: CPINFO = std::mem::zeroed();
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
}
}
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
/// like the OEM pages 437, 850 or 852).
#[cfg(windows)]
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
use windows_sys::Win32::Globalization::MultiByteToWideChar;
let Ok(len) = i32::try_from(bytes.len()) else {
return String::from_utf8_lossy(bytes).into_owned();
};
if len == 0 {
return String::new();
}
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::from_utf8_lossy(bytes).into_owned();
}
let mut wide = vec![0u16; needed as usize];
// SAFETY: `wide` holds `needed` units.
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
String::from_utf16_lossy(&wide[..written.max(0) as usize])
}
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
+2 -42
View File
@@ -434,23 +434,10 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
cwd.trim_end_matches('/').to_owned()
}
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
/// runs from the workspace root), else its working directory.
fn scope_of(root: &Path, c: &RunConfig) -> String {
match c.extras.module.as_deref() {
Some(module) if c.kind == "cargo" => {
let module = module.replace('\\', "/");
let module = module.strip_prefix("./").unwrap_or(&module);
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
}
_ => cwd_of(root, c),
}
}
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> {
let cwd = scope_of(root, c);
let cwd = cwd_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
};
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
@@ -935,33 +922,6 @@ mod tests {
assert_eq!(c.args, ["test", "--", "math::tests::"]);
}
#[test]
fn a_workspace_member_runs_its_own_tests() {
let root = Path::new("/p");
let member = |pkg: &str, dir: &str| {
let mut c = blank(
format!("cargo:test:{pkg}"),
"test",
"cargo",
"cargo".into(),
vec!["test".into(), "-p".into(), pkg.into()],
);
c.is_test = true;
c.extras.module = Some(dir.into());
c
};
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
workspace.is_test = true;
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let rel = "crates/ide-mcp/src/stdio.rs";
let ts = detect(src, rel);
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
}
#[test]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
+2 -15
View File
@@ -455,8 +455,7 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project), else the artifact's own
/// POM (a project module's, or the local repository's). `None` when nothing
/// the local repository when a parent is not in the project). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
@@ -467,19 +466,7 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
let modules = || {
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
model
.projects
.into_iter()
.map(|p| ide_build::maven::ModulePom {
group_id: p.group_id,
artifact_id: p.artifact_id,
pom: project.root.join(&p.pom),
})
.collect()
};
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
})
.await
.map_err(join_err)?
+13 -50
View File
@@ -498,8 +498,7 @@ impl ProcessManager {
None,
)?;
}
let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref());
spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?;
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
Ok(())
})();
if let Err(e) = result {
@@ -630,10 +629,7 @@ impl ProcessManager {
#[cfg(unix)]
Some(Box::new(wait_readable)),
)
.and_then(|_| {
let decoder = || OutputDecoder::for_label(options.encoding.as_deref());
spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None)
});
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
if let Err(e) = result {
let _ = child.kill();
let _ = child.wait();
@@ -1124,35 +1120,18 @@ fn chunk(stream: Stream, text: String) -> OutputChunk {
}
}
/// Appends what `decode` writes to `pending`, through the notice filter when there is one.
fn emit(
decode: impl FnOnce(&mut String),
notice: &mut Option<ide_core::output_encoding::NoticeFilter>,
decoded: &mut String,
pending: &mut String,
) {
match notice {
Some(filter) => {
decode(decoded);
filter.filter(decoded, pending);
decoded.clear();
}
None => decode(pending),
}
}
/// Merges both streams in arrival order, batching consecutive output of the same stream.
/// `decoders` decode stdout and stderr.
fn spawn_pump(
rx: mpsc::Receiver<Msg>,
on_output: OutputFn,
entry: Arc<Entry>,
sink: SharedSink,
decoders: [OutputDecoder; 2],
encoding: Option<String>,
notice: Option<String>,
) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
let [mut out_dec, mut err_dec] = decoders;
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
// "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on
// either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout).
let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new);
@@ -1161,8 +1140,6 @@ fn spawn_pump(
let mut pending = String::new();
let mut pending_stream = Stream::Stdout;
let mut batch_started: Option<Instant> = None;
// Since when a decoder holds part of a line back to tell its encoding.
let mut held_since: Option<Instant> = None;
let mut status: Option<Exit> = None;
let mut drain_deadline: Option<Instant> = None;
@@ -1177,7 +1154,6 @@ fn spawn_pump(
let now = Instant::now();
let wait = [
batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
held_since.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)),
drain_deadline.map(|t| t.saturating_duration_since(now)),
]
.into_iter()
@@ -1194,8 +1170,14 @@ fn spawn_pump(
Stream::Stdout => (&mut out_dec, &mut out_notice),
Stream::Stderr => (&mut err_dec, &mut err_notice),
};
emit(|out| dec.decode_into(&bytes, out), notice, &mut decoded, &mut pending);
held_since = if out_dec.holds() || err_dec.holds() { held_since.or(Some(now)) } else { None };
match notice {
Some(filter) => {
dec.decode_into(&bytes, &mut decoded);
filter.filter(&decoded, &mut pending);
decoded.clear();
}
None => dec.decode_into(&bytes, &mut pending),
}
if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now());
}
@@ -1211,25 +1193,6 @@ fn spawn_pump(
if drain_deadline.is_some_and(|d| Instant::now() >= d) {
break;
}
// Nothing more came: show a held prompt.
if held_since.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
held_since = None;
for (stream, dec, notice) in [
(Stream::Stdout, &mut out_dec, &mut out_notice),
(Stream::Stderr, &mut err_dec, &mut err_notice),
] {
if dec.holds() {
if stream != pending_stream {
flush(&mut pending, pending_stream, &mut batch_started);
pending_stream = stream;
}
emit(|out| dec.release(out), notice, &mut decoded, &mut pending);
}
}
if !pending.is_empty() && batch_started.is_none() {
batch_started = Some(Instant::now());
}
}
if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) {
flush(&mut pending, pending_stream, &mut batch_started);
}
+1 -390
View File
@@ -136,7 +136,6 @@ mod tests {
pub enum OutputDecoder {
Utf8(Utf8Decoder),
Other(Box<encoding_rs::Decoder>),
Mixed(Box<MixedDecoder>),
}
impl OutputDecoder {
@@ -148,36 +147,10 @@ impl OutputDecoder {
}
}
/// [`Self::for_label`] for output read from pipes. On Windows a UTF-8 console also decodes
/// what Windows' own programs write: the lines that are not UTF-8 in the system's console
/// code page (`ping`, `dir`, `cmd`'s messages), whatever the system's language, and UTF-16
/// (`wsl.exe`, `cmd /u`).
pub fn for_pipes(label: Option<&str>) -> Self {
match Self::for_label(label) {
Self::Utf8(_) if cfg!(windows) => Self::Mixed(Box::new(MixedDecoder::new(
ide_core::output_encoding::console_code_page().and_then(NativeDecoder::for_code_page),
))),
other => other,
}
}
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
match self {
Self::Utf8(d) => d.decode_into(bytes, out),
Self::Other(d) => decode_with(d, bytes, out, false),
Self::Mixed(d) => d.decode_into(bytes, out),
}
}
/// Whether part of a line is held back until it shows its encoding.
pub fn holds(&self) -> bool {
matches!(self, Self::Mixed(d) if d.holds())
}
/// Decodes the held part of a line now (no more output came for a while).
pub fn release(&mut self, out: &mut String) {
if let Self::Mixed(d) = self {
d.release(out);
}
}
@@ -189,218 +162,10 @@ impl OutputDecoder {
decode_with(d, &[], &mut out, true);
out
}
Self::Mixed(d) => d.finish(),
}
}
}
/// Decoder for a Windows code page: `encoding_rs`'s, or Windows' own for the single-byte
/// pages `encoding_rs` lacks (the OEM pages 437, 850, 852…).
pub enum NativeDecoder {
Encoding(Box<encoding_rs::Decoder>),
#[cfg(windows)]
SingleByte(u32),
}
impl NativeDecoder {
/// `None` for UTF-8 and for code pages neither can decode piece by piece.
pub fn for_code_page(cp: u32) -> Option<Self> {
if let Some(enc) = ide_core::output_encoding::encoding_for_code_page(cp) {
return (enc != encoding_rs::UTF_8)
.then(|| Self::Encoding(Box::new(enc.new_decoder_without_bom_handling())));
}
#[cfg(windows)]
if ide_core::output_encoding::is_single_byte_code_page(cp) {
return Some(Self::SingleByte(cp));
}
None
}
fn decode_into(&mut self, bytes: &[u8], out: &mut String, last: bool) {
match self {
Self::Encoding(d) => decode_with(d, bytes, out, last),
#[cfg(windows)]
Self::SingleByte(cp) => out.push_str(&ide_core::output_encoding::decode_code_page(bytes, *cp)),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Line {
/// Only ASCII so far, which reads the same in both.
Open,
Utf8,
Native,
}
/// UTF-8, the system code page for the lines that are not UTF-8, and UTF-16: on pipes, cargo,
/// git or node write UTF-8, Windows' own programs the console code page (GBK, Shift_JIS,
/// EUC-KR, 866…) and a few of them (`wsl.exe`, `cmd /u`) UTF-16LE, even within one command.
///
/// Each line is decoded as UTF-8 or the code page (UTF-8 with replacement characters without
/// one). The part of a line after its first non-ASCII byte is held until the line ends or
/// turns out not to be UTF-8; [`Self::release`] decodes a held prompt when no more output comes.
///
/// A read with NUL bytes, which text in the others never has, is UTF-16LE (ASCII characters
/// and line ends in it have them); UTF-16 goes on until one of its lines has ended and a read
/// without NUL bytes comes.
pub struct MixedDecoder {
utf8: Utf8Decoder,
native: Option<NativeDecoder>,
line: Line,
held: Vec<u8>,
utf16: Option<Utf16>,
}
impl MixedDecoder {
pub fn new(native: Option<NativeDecoder>) -> Self {
Self { utf8: Utf8Decoder::new(), native, line: Line::Open, held: Vec::new(), utf16: None }
}
pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) {
if bytes.is_empty() {
return;
}
let nul = bytes.contains(&0);
match self.utf16.as_ref().map(|u| u.open || nul) {
Some(true) => {
if let Some(u) = &mut self.utf16 {
u.decode(bytes, out);
}
return;
}
Some(false) => self.utf16 = None,
None if nul => {
self.end_line(out);
let mut u = Utf16::new();
u.decode(utf16_aligned(bytes), out);
self.utf16 = Some(u);
return;
}
None => {}
}
for piece in bytes.split_inclusive(|b| *b == b'\n') {
let ends = piece.last() == Some(&b'\n');
match self.line {
Line::Utf8 => self.utf8.decode_into(piece, out),
Line::Native => self.decode_native(piece, out),
Line::Open => {
let mut rest = piece;
if self.held.is_empty() {
let ascii = piece.iter().take_while(|b| b.is_ascii()).count();
out.push_str(std::str::from_utf8(&piece[..ascii]).unwrap_or_default());
rest = &piece[ascii..];
}
self.held.extend_from_slice(rest);
let decided = match std::str::from_utf8(&self.held) {
Err(e) if e.error_len().is_some() => Some(Line::Native),
Ok(_) if ends => Some(Line::Utf8),
_ => None,
};
if let Some(line) = decided {
self.line = line;
self.flush_held(out);
}
}
}
if ends {
self.line = Line::Open;
}
}
}
fn flush_held(&mut self, out: &mut String) {
let held = std::mem::take(&mut self.held);
match self.line {
Line::Native => self.decode_native(&held, out),
Line::Open | Line::Utf8 => self.utf8.decode_into(&held, out),
}
}
fn decode_native(&mut self, bytes: &[u8], out: &mut String) {
match &mut self.native {
Some(native) => native.decode_into(bytes, out, false),
None => self.utf8.decode_into(bytes, out),
}
}
/// Decodes the rest of the current line as it is.
fn end_line(&mut self, out: &mut String) {
if self.holds() {
self.line = if std::str::from_utf8(&self.held).is_ok() { Line::Utf8 } else { Line::Native };
self.flush_held(out);
}
out.push_str(&self.utf8.finish());
self.line = Line::Open;
}
pub fn holds(&self) -> bool {
!self.held.is_empty()
}
/// Decodes the held part of the line as UTF-8: held text has been UTF-8 so far.
pub fn release(&mut self, out: &mut String) {
if self.holds() {
self.line = Line::Utf8;
self.flush_held(out);
}
}
pub fn finish(&mut self) -> String {
let mut out = String::new();
if let Some(mut u) = self.utf16.take() {
decode_with(&mut u.decoder, &[], &mut out, true);
}
self.end_line(&mut out);
if let Some(native) = &mut self.native {
native.decode_into(&[], &mut out, true);
}
out
}
}
struct Utf16 {
decoder: encoding_rs::Decoder,
/// Inside a line or a code unit: the next read goes on in UTF-16 whatever it holds.
open: bool,
/// An odd number of bytes so far.
odd: bool,
}
impl Utf16 {
fn new() -> Self {
Self { decoder: encoding_rs::UTF_16LE.new_decoder_with_bom_removal(), open: false, odd: false }
}
fn decode(&mut self, bytes: &[u8], out: &mut String) {
let start = out.len();
decode_with(&mut self.decoder, bytes, out, false);
self.odd ^= bytes.len() % 2 == 1;
if self.odd {
self.open = true;
} else if out.len() > start {
self.open = !out.ends_with('\n');
}
}
}
/// `bytes` from the first byte of a UTF-16LE code unit. NUL bytes are mostly the high bytes of
/// ASCII characters, at odd offsets from it; at even ones, the first byte is the high byte of a
/// character whose low byte came before (and was shown as ASCII).
fn utf16_aligned(bytes: &[u8]) -> &[u8] {
let (mut even, mut odd) = (0usize, 0usize);
for (i, b) in bytes.iter().enumerate() {
if *b == 0 {
if i % 2 == 0 {
even += 1;
} else {
odd += 1;
}
}
}
if even > odd { &bytes[1..] } else { bytes }
}
fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, last: bool) {
let mut src = bytes;
loop {
@@ -415,8 +180,7 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las
#[cfg(test)]
mod encoding_tests {
use super::{MixedDecoder, NativeDecoder, OutputDecoder};
use ide_core::output_encoding::encoding_for_code_page;
use super::OutputDecoder;
#[test]
fn gbk_split_across_chunks() {
@@ -438,157 +202,4 @@ mod encoding_tests {
assert_eq!(out, "héllo");
assert!(matches!(d, OutputDecoder::Utf8(_)));
}
fn mixed(cp: u32) -> MixedDecoder {
MixedDecoder::new(Some(NativeDecoder::for_code_page(cp).expect("code page")))
}
fn encode(cp: u32, text: &str) -> Vec<u8> {
let (bytes, _, unmappable) = encoding_for_code_page(cp).expect("code page").encode(text);
assert!(!unmappable, "{text} in {cp}");
bytes.into_owned()
}
/// Simplified and Traditional Chinese, Korean, Japanese, Cyrillic (OEM and ANSI), Western.
const SAMPLES: &[(u32, &str)] = &[
(936, "正在 Ping 127.0.0.1 具有 32 字节的数据:"),
(950, "系統找不到指定的路徑。"),
(949, "지정된 경로를 찾을 수 없습니다."),
(932, "指定されたパスが見つかりません。"),
(866, "Системе не удается найти указанный путь."),
(1251, "Системе не удается найти указанный путь."),
(1252, "Le chemin d'accès spécifié est introuvable."),
];
#[test]
fn utf8_and_code_page_lines_mix_whatever_the_chunks() {
for &(cp, native) in SAMPLES {
let mut bytes = " Compiling 编译 ü\n".as_bytes().to_vec();
bytes.extend(encode(cp, native));
bytes.extend_from_slice(b"\r\n");
bytes.extend_from_slice("ok ✓\n".as_bytes());
bytes.extend(encode(cp, native));
let expected = format!(" Compiling 编译 ü\n{native}\r\nok ✓\n{native}");
for split in 0..=bytes.len() {
let mut d = mixed(cp);
let mut out = String::new();
d.decode_into(&bytes[..split], &mut out);
d.decode_into(&bytes[split..], &mut out);
out.push_str(&d.finish());
assert_eq!(out, expected, "code page {cp}, split at {split}");
}
}
}
#[test]
fn byte_by_byte_code_page_output() {
for &(cp, native) in SAMPLES {
let bytes = [encode(cp, native), b"\n".to_vec()].concat();
let mut d = mixed(cp);
let mut out = String::new();
for b in &bytes {
d.decode_into(std::slice::from_ref(b), &mut out);
}
assert!(!d.holds());
assert_eq!(out, format!("{native}\n"), "code page {cp}");
}
}
#[test]
fn ascii_and_code_page_prompts_show_at_once_and_utf8_ones_on_release() {
let mut d = mixed(936);
let mut out = String::new();
d.decode_into(b"Password: ", &mut out);
assert_eq!(out, "Password: ");
assert!(!d.holds());
out.clear();
d.decode_into(&encode(936, "请按任意键继续. . ."), &mut out);
assert_eq!(out, "请按任意键继续. . .");
assert!(!d.holds());
let mut d = mixed(936);
out.clear();
d.decode_into("Continue? 继续 ".as_bytes(), &mut out);
assert_eq!(out, "Continue? ");
assert!(d.holds());
d.release(&mut out);
assert_eq!(out, "Continue? 继续 ");
d.decode_into("[y/N]\n".as_bytes(), &mut out);
assert_eq!(out, "Continue? 继续 [y/N]\n");
}
#[test]
fn a_released_line_split_inside_a_character_completes() {
let mut d = mixed(949);
let mut out = String::new();
let bytes = "é中".as_bytes();
d.decode_into(&bytes[..3], &mut out);
d.release(&mut out);
d.decode_into(&bytes[3..], &mut out);
out.push_str(&d.finish());
assert_eq!(out, "é中");
}
#[test]
fn utf8_code_page_has_no_mixed_decoder() {
assert!(NativeDecoder::for_code_page(65001).is_none());
}
fn utf16(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
/// What `wsl.exe -l -v` writes, on a Chinese system.
const WSL: &str = " NAME STATE VERSION\r\n* Ubuntu-22.04 Running 2\r\n\
适用于 Linux 的 Windows 子系统: 默认\r\n";
#[test]
fn utf16_output_between_utf8_and_code_page_lines_whatever_the_chunks() {
let before = " Compiling 编译\n".as_bytes();
let wsl = utf16(WSL);
let after = [encode(936, "系统找不到指定的路径。"), "\r\ndone ✓\n".as_bytes().to_vec()].concat();
let expected = format!(" Compiling 编译\n{WSL}系统找不到指定的路径。\r\ndone ✓\n");
for split in 0..=wsl.len() {
let mut d = mixed(936);
let mut out = String::new();
for chunk in [before, &wsl[..split], &wsl[split..], &after] {
d.decode_into(chunk, &mut out);
}
out.push_str(&d.finish());
assert_eq!(out, expected, "split at {split}");
}
}
#[test]
fn utf16_with_a_byte_order_mark_or_starting_with_cjk() {
let mut d = mixed(949);
let mut out = String::new();
d.decode_into(&[&[0xFF, 0xFE][..], &utf16("한국어 OK\r\n")].concat(), &mut out);
d.decode_into(&utf16("中文 OK\r\n"), &mut out);
out.push_str(&d.finish());
assert_eq!(out, "한국어 OK\r\n中文 OK\r\n");
}
#[test]
fn without_a_code_page_other_lines_are_lossy_utf8_and_utf16_still_reads() {
let mut d = MixedDecoder::new(None);
let mut out = String::new();
d.decode_into(b"caf\xe9\n", &mut out);
d.decode_into(&utf16("wsl 子系统\r\n"), &mut out);
d.decode_into("ok ✓\n".as_bytes(), &mut out);
out.push_str(&d.finish());
assert_eq!(out, "caf\u{FFFD}\nwsl 子系统\r\nok ✓\n");
}
#[cfg(windows)]
#[test]
fn oem_code_pages_without_an_encoding_rs_table_decode_through_windows() {
for cp in [437, 850, 852] {
let mut d = mixed(cp);
let mut out = String::new();
d.decode_into(b"caf\x82\r\n", &mut out);
assert_eq!(out, "caf\u{e9}\r\n", "code page {cp}");
}
}
}
-69
View File
@@ -1,69 +0,0 @@
#![cfg(windows)]
use ide_core::{IdeEvent, SharedSink};
use ide_run::{OutputChunk, ProcessManager, RunConfig, StartOptions};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
/// Stdout and stderr of a shell command line run on pipes in a UTF-8 console.
fn run(command: &str) -> String {
let (tx, events) = mpsc::channel();
let tx = Mutex::new(tx);
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
let _ = tx.lock().send(e);
});
let root = tempfile::tempdir().unwrap();
let config = RunConfig {
id: "t:out".into(),
name: "t:out".into(),
kind: "shell".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let chunks: Arc<Mutex<Vec<OutputChunk>>> = Arc::default();
let out = chunks.clone();
let pm = ProcessManager::new();
let info = pm
.start(root.path(), &config, &StartOptions::default(), sink, Box::new(move |c| out.lock().push(c)))
.expect("start");
loop {
match events.recv_timeout(Duration::from_secs(20)).expect("RunExited event") {
IdeEvent::RunExited { run, .. } if run == info.id => break,
_ => {}
}
}
chunks
.lock()
.iter()
.filter_map(|c| match c {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => Some(text.as_str()),
_ => None,
})
.collect()
}
/// `cmd`'s own messages reach the pipe in the console code page (GBK, Shift_JIS, EUC-KR,
/// 437…, by the system's language), and still read right in a UTF-8 console.
#[test]
fn native_messages_in_the_console_code_page_are_decoded() {
let text = run(r"dir C:\openidea-no-such-dir");
assert!(text.trim().lines().count() >= 2, "{text:?}");
assert!(!text.contains('\u{FFFD}'), "{text:?}");
}
/// `cmd /u` writes UTF-16LE, as `wsl.exe` does; it reads right whatever the system's language.
#[test]
fn utf16_output_is_decoded() {
let text = run("cmd /u /c echo 中文 한국어 OK");
assert_eq!(text, "中文 한국어 OK\r\n");
}
+1 -16
View File
@@ -108,9 +108,7 @@ fn discover_with_limit(
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
if entry.file_type().is_ok_and(|kind| kind.is_dir())
&& crate::roots::virtual_env_marker(&entry.path()).is_none()
{
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
children.push(entry.path());
}
}
@@ -172,19 +170,6 @@ mod tests {
assert_eq!(result.direct[0], root);
}
#[test]
fn skips_virtual_environments_whatever_their_name() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
project(root, "code10", "pyvenv.cfg");
project(root, "code10/Lib/site-packages/pkg", "pyproject.toml");
fs::create_dir_all(root.join("conda/conda-meta")).unwrap();
project(root, "conda/pkgs/dep", "setup.py");
let app = project(root, "app", "pyproject.toml");
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
assert_eq!(result.projects.iter().map(|p| p.path.to_path_buf()).collect::<Vec<_>>(), [app]);
}
#[test]
fn finds_depth_four_but_not_five_and_retains_empty_folders() {
let tmp = tempfile::tempdir().unwrap();
+1 -53
View File
@@ -17,19 +17,6 @@ const PYTHON_FILES: &[&str] = &["pyproject.toml", "setup.py", "setup.cfg", "Pipf
const SKIP_DIRS: &[&str] =
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
/// A Python virtual environment (`python -m venv`, virtualenv, uv) or conda environment,
/// whatever it is named: its packages are dependencies, not modules of the project.
/// Returns the file that marks it.
pub(crate) fn virtual_env_marker(dir: &Path) -> Option<&'static str> {
if dir.join("pyvenv.cfg").is_file() {
Some("pyvenv.cfg")
} else if dir.join("conda-meta").is_dir() {
Some("conda-meta")
} else {
None
}
}
/// Build files whose changes can change the detected roots.
pub(crate) const BUILD_FILES: &[&str] = &[
"pom.xml",
@@ -78,10 +65,7 @@ pub fn affects_roots(rel: &Path) -> bool {
let Some(name) = names.next() else { return false };
BUILD_FILES.contains(&name)
|| ROOT_NAMES.contains(&name)
|| matches!(
name,
"Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt" | "pyvenv.cfg" | "conda-meta"
)
|| matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
|| names.next() == Some("src")
}
@@ -147,13 +131,6 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
if found.iter().any(|f| *f == child || f.is_empty() && name == "src") {
continue;
}
if let Some(marker) = virtual_env_marker(&root.join(&child)) {
if !out.iter().any(|r| r.path == child) {
let from = join(&child, marker);
out.push(SourceFolder { path: child, kind: RootKind::Excluded, detected_from: Some(from) });
}
continue;
}
queue.push_back((child, depth + 1));
}
}
@@ -528,35 +505,6 @@ mod tests {
);
}
#[test]
fn virtual_environments_are_excluded_whatever_their_name() {
// A package in the environment's site-packages has build files of its own; as a
// source root it would be put on PYTHONPATH and shadow the standard library.
let dir = tree(&[
"requirements.txt",
"app.py",
"code10/pyvenv.cfg",
"code10/Lib/site-packages/pkg/pyproject.toml",
"code10/Lib/site-packages/pkg/io/__init__.py",
"envs/conda/conda-meta/",
"envs/conda/lib/pkg/setup.py",
"svc/pyproject.toml",
"svc/env/pyvenv.cfg",
"svc/env/lib/python3.12/site-packages/dep/setup.py",
]);
assert_eq!(
summary(&detect(dir.path())),
vec![
"[email protected]",
"code10=Excluded@code10/pyvenv.cfg",
"envs/conda=Excluded@envs/conda/conda-meta",
"svc=Source@svc/pyproject.toml",
"svc/env=Excluded@svc/env/pyvenv.cfg",
]
);
assert!(affects_roots(Path::new("code10/pyvenv.cfg")));
}
#[test]
fn first_ecosystem_wins_for_a_shared_folder() {
// A Gradle build with a frontend: `build` stays Gradle's, `src` is not a Node root.
+1 -1
View File
@@ -6,7 +6,7 @@
//! project-relative and `/`-separated; `""` is the project root itself.
mod detect;
pub(crate) use detect::{BUILD_FILES, virtual_env_marker};
pub(crate) use detect::BUILD_FILES;
mod gradle;
mod java;
mod maven;
+1 -1
View File
@@ -71,7 +71,7 @@ On your own messages you can **Edit** (the messages after it are replaced; files
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and command permissions don't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
Once an agent has run a task, new tasks start in its mode that approves everything (Claude Code's bypass permissions, Codex's Full Access), and the Command Execution Policy doesn't apply there. To start them in a mode that asks, set **Settings › Agents › Chat › New tasks start** to **Asking for approval**. Only your own settings choose this, never a project's.
### Review the changes
+1 -1
View File
@@ -71,7 +71,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令权限不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令执行策略不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
### 查看改动
-31
View File
@@ -22,37 +22,6 @@ pub async fn clipboard_write_text(app: AppHandle, window: WebviewWindow, text: S
with_clipboard(app, move || ide_clipboard::set_text(&text, owner).map_err(ide_core::IdeError::from)).await
}
/// The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
/// from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
/// text there (#3041). None on Windows, where the WebView's clipboard is used.
#[tauri::command]
#[specta::specta]
pub async fn clipboard_text(app: AppHandle) -> IdeResult<Option<String>> {
#[cfg(target_os = "linux")]
{
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let sender = std::cell::Cell::new(Some(tx));
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).request_text(move |_, text| {
if let Some(sender) = sender.take() {
let _ = sender.send(text.map(|t| t.to_string()));
}
});
})
.map_err(|error| IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}")))?;
Ok(rx.await.ok().flatten())
}
#[cfg(target_os = "macos")]
{
with_clipboard(app, || Ok(ide_clipboard::text())).await
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = app;
Ok(None)
}
}
/// Absolute paths on the clipboard, empty when it holds no file list.
#[tauri::command]
#[specta::specta]
-1
View File
@@ -187,7 +187,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
fs::fs_read_text,
fs::fs_open_with_system,
clipboard::clipboard_write_text,
clipboard::clipboard_text,
clipboard::clipboard_file_list,
clipboard::clipboard_has_files,
clipboard::clipboard_image,
+1 -9
View File
@@ -280,12 +280,6 @@ export const commands = {
fsOpenWithSystem: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_open_with_system", { path })),
/** Writes text locally, without WebView user-activation requirements. */
clipboardWriteText: (text: string) => typedError<null, IdeError>(__TAURI_INVOKE("clipboard_write_text", { text })),
/**
* The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
* from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
* text there (#3041). None on Windows, where the WebView's clipboard is used.
*/
clipboardText: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_text")),
/** Absolute paths on the clipboard, empty when it holds no file list. */
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
@@ -8119,9 +8113,7 @@ export type PomGotoKind =
/** A `<dependencyManagement>` entry providing the dependency's version. */
"managedDependency" |
/** A `<pluginManagement>` entry providing the plugin's version. */
"managedPlugin" |
/** The POM of the artifact itself: a project module's, else the local repository's. */
"artifactPom";
"managedPlugin";
/** A 0-based line / UTF-16 column position. */
export type Pos = {
@@ -1,104 +0,0 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { java } from "@codemirror/lang-java";
import { history, undo } from "@codemirror/commands";
import { renderDocComments, renderedDocsExtension, renderedRanges } from "./renderedDocs";
let view: EditorView | null = null;
beforeEach(() => void vi.useFakeTimers());
afterEach(() => {
view?.destroy();
view = null;
vi.useRealTimers();
});
const DOC = ["class Rooms {", " int n;", "", " /** Books a room. */", " void book() {}", "}"].join("\n");
function open(doc = DOC, extra: ReturnType<typeof history>[] = []) {
view = new EditorView({
state: EditorState.create({
doc,
extensions: [java(), renderedDocsExtension, renderDocComments.of(true), ...extra],
}),
parent: document.body,
});
return view;
}
/** Types `text` at `at` the way a keystroke does. */
const type = (v: EditorView, at: number, text: string) =>
v.dispatch({ changes: { from: at, insert: text }, userEvent: "input.type" });
/** What a full rebuild of the same text renders, in a fresh editor state. */
function fullRebuild(doc: string): Array<[number, number]> {
return renderedRanges(
EditorState.create({ doc, extensions: [java(), renderedDocsExtension, renderDocComments.of(true)] }),
);
}
const pause = () => vi.advanceTimersByTime(300);
it("moves rendered comments along with text typed in code, and rebuilds once typing pauses", () => {
const v = open();
const [before] = renderedRanges(v.state);
// `/*` in code makes everything after it a comment: a full rebuild would drop the doc
// comment at once. Typing only moves it along until the pause.
type(v, v.state.doc.line(2).from + 2, "/*");
expect(renderedRanges(v.state)).toEqual([[before![0] + 2, before![1] + 2]]);
pause();
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("renders a pasted comment at once", () => {
const v = open();
v.dispatch({
changes: { from: v.state.doc.line(5).to + 1, insert: " /** Pasted. */\n void paste() {}\n" },
userEvent: "input.paste",
});
expect(renderedRanges(v.state)).toHaveLength(2);
});
it("brings a deleted comment back rendered at once on undo", () => {
const v = open(DOC, [history()]);
const line = v.state.doc.line(4);
v.dispatch({ changes: { from: line.from, to: line.to + 1 }, userEvent: "delete.selection" });
expect(renderedRanges(v.state)).toHaveLength(0);
undo(v);
expect(renderedRanges(v.state)).toHaveLength(1);
});
it("rebuilds at once for a keystroke on a comment's line", () => {
const v = open();
// Code after the closing marker: the comment no longer has its line to itself.
type(v, v.state.doc.line(4).to, " int x;");
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("still shows the source of a comment the caret lands in after fast typing", () => {
const v = open();
type(v, v.state.doc.line(2).from + 2, "xx");
const at = v.state.doc.toString().indexOf("Books");
v.dispatch({ selection: EditorSelection.cursor(at) });
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("after the pause, matches a full rebuild of the same text, whatever was typed", () => {
// A seeded generator, so a failure replays.
let seed = 7;
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const lines = ["class A {"];
for (let i = 0; i < 12; i++) lines.push(` /** Doc ${i}. */`, ` void m${i}() {}`, " /// line doc", "");
lines.push("}");
const v = open(lines.join("\n"));
const keys = ["/", "*", "\n", "x", " ", "{", "}"];
for (let i = 0; i < 400; i++) {
const at = Math.floor(next() * (v.state.doc.length + 1));
if (next() < 0.3 && at > 0) v.dispatch({ changes: { from: at - 1, to: at }, userEvent: "delete.backward" });
else type(v, at, keys[Math.floor(next() * keys.length)]!);
if (next() < 0.05) pause();
}
pause();
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
});
+33 -93
View File
@@ -25,13 +25,8 @@
* Off, which is the default, the icon still shows over a comment, so one comment can be
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
* comments and none is flipped, its comments are found only once typing pauses
* (`deferredScanner`), the Markdown parser is not loaded, and the gutter has a column
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
* only if the file has doc comments.
*
* On, typing costs about as little: a keystroke away from every comment's lines only
* moves the rendered comments along with the text, and the file is scanned again once
* typing pauses. Anything else that changes the text (paste, undo, a reload) rebuilds at
* once, so the comments it brings in render at once.
*/
import {
EditorState,
@@ -41,7 +36,6 @@ import {
RangeSet,
StateEffect,
StateField,
type ChangeSet,
type Extension,
type Transaction,
} from "@codemirror/state";
@@ -375,55 +369,11 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean
interface DocsView {
decorations: DecorationSet;
/** Every doc comment, rendered or not, in document order. */
/** Every doc comment, rendered or not. */
comments: ReadonlyArray<PlacedComment>;
/**
* Moved along with keystrokes since the last scan, instead of found again: what the
* parser made of them may differ, until `deferredScanner` rebuilds.
*/
stale: boolean;
}
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [], stale: false };
/** Asks for the scan a stale view is waiting for. */
const rescanDocs = StateEffect.define<null>();
/** Comments moved along with a change, as an edit elsewhere moves them; their text is unchanged. */
function mapComments(comments: ReadonlyArray<PlacedComment>, changes: ChangeSet): PlacedComment[] {
return comments.map((c) => ({
...c,
from: changes.mapPos(c.from, 1),
to: changes.mapPos(c.to, -1),
line: changes.mapPos(c.line, 1),
end: changes.mapPos(c.end, -1),
}));
}
/**
* Whether a change can skip the scan: a keystroke (typed or deleted characters) that
* touches no comment's lines. Everything else (paste, undo, formatting, a reload) brings
* comments in or out at once, so it rebuilds at once; a keystroke on a comment's line
* moves its edges. A keystroke elsewhere can still change comments (`/*` makes the rest of
* the file one), which the scan after the pause takes in.
*/
function typedOutsideComments(view: DocsView, tr: Transaction): boolean {
if (!(tr.isUserEvent("input.type") || tr.isUserEvent("delete"))) return false;
const { comments } = view;
let touches = false;
tr.changes.iterChangedRanges((fromA, toA) => {
// The first comment that does not end before the change.
let lo = 0;
let hi = comments.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (comments[mid]!.end < fromA) lo = mid + 1;
else hi = mid;
}
if (lo < comments.length && comments[lo]!.line <= toA) touches = true;
});
return !touches;
}
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
/**
* Rendered comments by their source, shared by every editor, least recently used first.
@@ -492,7 +442,7 @@ function build(state: EditorState): DocsView {
),
);
}
return { decorations: Decoration.set(decorations), comments, stale: false };
return { decorations: Decoration.set(decorations), comments };
}
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
@@ -526,7 +476,7 @@ const setPassive = StateEffect.define<readonly PlacedComment[]>();
/**
* The doc comments of a file that renders none, for their icons. Rescanning the file on
* each keystroke would make typing in a large file several times slower for a feature
* that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
* found are mapped through the edits. Empty while the file renders comments: `docsField`
* has them then.
*/
@@ -537,29 +487,32 @@ const passiveDocs = StateField.define<readonly PlacedComment[]>({
// Switched off: the comments rendered a moment ago are known only to the last scan.
if (active(tr.startState)) return placeComments(tr.state);
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
return tr.docChanged ? mapComments(value, tr.changes) : value;
if (!tr.docChanged) return value;
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
return value.map((c) => ({
...c,
from: map(c.from, 1),
to: map(c.to, -1),
line: map(c.line, 1),
end: map(c.end, -1),
}));
},
});
/**
* Scans the file once edits and the parser have paused: a file that renders no comments,
* for their icons; one that does, when keystrokes have only moved its comments along.
*/
const deferredScanner = ViewPlugin.fromClass(
/** Scans a file that renders no comments once the edits and the parser have paused. */
const passiveScanner = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | null = null;
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
if (!update.docChanged && syntaxTree(update.startState) === syntaxTree(update.state)) return;
// Rendering and up to date: the view was just built, nothing is waiting.
if (active(update.state) && !update.state.field(docsField).stale) return this.cancel();
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
const { state } = this.view;
if (!active(state)) this.view.dispatch({ effects: setPassive.of(placeComments(state)) });
else if (state.field(docsField).stale) this.view.dispatch({ effects: rescanDocs.of(null) });
}, RESCAN_DELAY);
if (active(update.state)) return this.cancel();
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
}, PASSIVE_SCAN_DELAY);
}
}
cancel() {
if (this.timer !== null) clearTimeout(this.timer);
@@ -571,8 +524,8 @@ const deferredScanner = ViewPlugin.fromClass(
},
);
/** How long edits and parsing must pause before the file is scanned again. */
const RESCAN_DELAY = 300;
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
const PASSIVE_SCAN_DELAY = 300;
/** Every doc comment of the file, as last found. */
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
@@ -634,26 +587,13 @@ const docsField = StateField.define<DocsView>({
create: build,
update(value, tr) {
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
if (
const rebuild =
tr.docChanged ||
tr.reconfigured ||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel) || e.is(rescanDocs))
)
return build(tr.state);
if (tr.docChanged) {
// A keystroke away from every comment moves them along; the scan waits for a pause.
if (active(tr.state) && typedOutsideComments(value, tr))
return {
decorations: value.decorations.map(tr.changes),
comments: mapComments(value.comments, tr.changes),
stale: true,
};
return build(tr.state);
}
// The parser finishing more of the file can bring comments it had not reached: built
// at once, so a file opens with the comments on screen rendered, unless keystrokes
// already have the scan waiting for a pause.
if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) return value.stale ? value : build(tr.state);
return value;
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
// The parser finishing more of the file can bring comments it had not reached.
syntaxTree(tr.startState) !== syntaxTree(tr.state);
return rebuild ? build(tr.state) : value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
});
@@ -898,7 +838,7 @@ export const renderedDocsExtension: Extension = [
flipped,
docsField,
passiveDocs,
deferredScanner,
passiveScanner,
hovered,
hoverTracker,
showSourceAtCaret,
+3 -3
View File
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
] as MenuItem[])
: ([
{ kind: "separator" },
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
openInMenu(slot.model.path),
{ kind: "separator" },
localHistoryMenu(slot.model.path),
gitMenu(slot.model.path),
@@ -1961,7 +1961,7 @@ export class EditorController implements SlotHost {
}
/** Open In › for a file: Project View, Finder / Explorer / Files, Terminal. */
function openInMenu(path: string, line?: number): MenuItem {
function openInMenu(path: string): MenuItem {
return {
label: "Open In",
submenu: [
@@ -1971,7 +1971,7 @@ function openInMenu(path: string, line?: number): MenuItem {
: []),
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
{ kind: "separator" },
...menuGroupItems("openIn", { paths: [path], line }),
...menuGroupItems("openIn", { paths: [path] }),
],
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
/** Edit › Paste from History and Code › Unwrap/Remove: the parts that need popups or the backend. */
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { writeClipboardText } from "@/platform/clipboard";
import { EditorSelection } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { structure } from "@/editor/structure";
@@ -103,7 +103,7 @@ export async function pasteAsPlainText(view: EditorView) {
if (view.state.readOnly) return;
let text: string;
try {
text = await readClipboardPlainText();
text = (await navigator.clipboard?.readText()) ?? "";
} catch {
hint("Cannot read the clipboard");
return;
+8 -8
View File
@@ -1,5 +1,5 @@
/**
* Settings › Tools › Agents › Command Permissions: whitelist (auto-approve)
* Settings › Tools › Agents › Command Execution Policy: whitelist (auto-approve)
* and blacklist (auto-reject) command patterns evaluated on agent permission requests.
*/
import { useMemo, useState } from "react";
@@ -47,18 +47,18 @@ export function AgentPolicyPage() {
};
return (
<section className={styles.body} aria-label="Command Permissions">
<section className={styles.body} aria-label="Command Execution Policy">
<p className={styles.notice}>
<T>
Allowed commands run without asking, with or without extra arguments. Blocked commands are always rejected,
even if allowed, and support * and ? wildcards. Commands with substitutions, redirections or variable
assignments always ask.
Allowed commands run without asking: an entry matches that command, alone or with more arguments. Commands
with substitutions, redirections or variable assignments always ask. Blocked commands are rejected and win
over allowed ones; they support the * and ? wildcards.
</T>
</p>
<div className={styles.grid}>
<div className={styles.column}>
<div className={styles.columnTitle}>
<T>Allowed Commands</T>
<T>Allowed Commands (Whitelist)</T>
</div>
<div className={styles.items}>
{policy.allowedCommands.map((pattern) => (
@@ -80,7 +80,7 @@ export function AgentPolicyPage() {
value={allowDraft}
onChange={(e) => setAllowDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addAllowed()}
placeholder={tx("e.g. cargo test")}
placeholder={tx("e.g. cargo test*")}
aria-label="Allowed command pattern"
/>
<Button size="small" aria-label="Add allowed command" onClick={addAllowed}>
@@ -90,7 +90,7 @@ export function AgentPolicyPage() {
</div>
<div className={styles.column}>
<div className={styles.columnTitle}>
<T>Blocked Commands</T>
<T>Blocked Commands (Blacklist)</T>
</div>
<div className={styles.items}>
{policy.blockedCommands.map((pattern) => (
+3 -3
View File
@@ -32,7 +32,7 @@ function FormWrapper({ children }: { children: ReactNode }) {
return <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
}
describe("Command Permissions sub-page", () => {
describe("Command Execution Policy sub-page", () => {
it("registers under Agents group in settings", () => {
const dispose = activateSettingsPages();
try {
@@ -40,7 +40,7 @@ describe("Command Permissions sub-page", () => {
const agentsGroup = tree.find((n) => n.title === AGENTS_GROUP);
expect(agentsGroup).toBeDefined();
const policyNode = agentsGroup?.children.find((n) => n.key === POLICY_PAGE);
expect(policyNode?.title).toBe("Command Permissions");
expect(policyNode?.title).toBe("Command Execution Policy");
// A project's settings file can't decide what agents run without asking.
expect(policyNode?.page?.projectScope).not.toBe(true);
} finally {
@@ -56,7 +56,7 @@ describe("Command Permissions sub-page", () => {
</FormWrapper>,
);
const policy = screen.getByRole("region", { name: "Command Permissions" });
const policy = screen.getByRole("region", { name: "Command Execution Policy" });
expect(within(policy).getByText("rm -rf *")).toBeInTheDocument();
fireEvent.change(within(policy).getByRole("textbox", { name: "Allowed command pattern" }), {
+1 -1
View File
@@ -59,7 +59,7 @@ export function activateSettingsPages(): () => void {
}),
registerSettingsPage({
id: POLICY_PAGE,
title: "Command Permissions",
title: "Command Execution Policy",
group: AGENTS_GROUP,
order: 490,
keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"],
+1 -1
View File
@@ -13,7 +13,7 @@ export const REGISTRY_PAGE = "agents.list";
export const AGENTS_GROUP = "Agents";
/** Settings › Agents › External MCP Servers: User and Workspace MCP configurations. */
export const MCP_PAGE = "agents.mcpServers";
/** Settings › Agents › Command Permissions. */
/** Settings › Agents › Command Execution Policy. */
export const POLICY_PAGE = "agents.policy";
export const sessionKey = (task: string) => `agent:${task}`;
+2 -3
View File
@@ -11,7 +11,6 @@ import { quitApp } from "./closeGuard";
import { guessOs } from "./host";
import { showKeymapReference } from "./KeymapReference";
import { checkForUpdates } from "./updates";
import { readClipboardPlainText } from "@/platform/clipboard";
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
@@ -51,8 +50,8 @@ export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste"
if (focused instanceof Element && runUndoHandlers(command, focused)) return;
}
if (command === "selectAll" && selectReadingPane(document.activeElement)) return;
if (command === "paste") {
void readClipboardPlainText().then((text) => document.execCommand("insertText", false, text));
if (command === "paste" && navigator.clipboard?.readText) {
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
return;
}
document.execCommand(command);
@@ -108,11 +108,9 @@ describe("POM artifact elements", () => {
expect(inArtifactElement(pom, pom.indexOf("example-lib</artifactId>"))).toBe(true);
expect(inArtifactElement(pom, pom.indexOf("<artifactId>maven-compiler-plugin"))).toBe(true);
expect(inArtifactElement(pom, pom.indexOf("${example.version}"))).toBe(true);
// Inside `<parent>`: its artifact is the parent POM.
expect(inArtifactElement(pom, pom.indexOf("<parent>") + "<parent>".length + 1)).toBe(true);
});
it("sees no artifact element outside dependencies, plugins and the parent", () => {
it("sees no artifact element outside dependencies and plugins", () => {
expect(inArtifactElement(pom, pom.indexOf("<properties>"))).toBe(false);
expect(inArtifactElement(pom, pom.indexOf("<parent>"))).toBe(false);
// `<plugins>` and `<dependencyManagement>` are not a `<plugin>` / `<dependency>`.
+5 -6
View File
@@ -4,8 +4,7 @@
* along the parent chain (a parent POM outside the project opens from the local
* repository, read-only as a plain file) — and a dependency / plugin whose version a
* `<dependencyManagement>` / `<pluginManagement>` entry provides opens that managing
* declaration; otherwise a dependency, plugin or `<parent>` opens the artifact's own POM
* (a project module's, else the local repository's). The backend resolves both with the build model's POM reader and parent
* declaration. The backend resolves both with the build model's POM reader and parent
* resolver; here only cheap eligibility is computed (a reference span, an artifact
* element), so the ⌘-hover underline stays out of the rest of the file, and nothing
* resolves to no jump at all: an unresolvable property or parent never navigates
@@ -35,12 +34,12 @@ function inside(name: string, text: string, at: number): boolean {
}
/**
* Whether `at` may sit in a `<dependency>` / `<plugin>` / `<parent>` element: an
* eligibility test only (a `<dependency>` inside a comment counts too) — what the
* element resolves to is the backend's answer.
* Whether `at` may sit in a `<dependency>` / `<plugin>` element: an eligibility test
* only (a `<dependency>` inside a comment counts too) — whether the element's version
* actually comes from a management entry is the backend's answer.
*/
export function inArtifactElement(text: string, at: number): boolean {
return inside("dependency", text, at) || inside("plugin", text, at) || inside("parent", text, at);
return inside("dependency", text, at) || inside("plugin", text, at);
}
/** Go to declaration in a POM of the project (or an opened parent POM of the local repository). */
+1 -2
View File
@@ -6,7 +6,6 @@ import { VENDORS, vendorPreset } from "./vendors";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canModifyTable, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
import { readClipboardPlainText } from "@/platform/clipboard";
const act = (
action: string,
@@ -49,7 +48,7 @@ function dataSourceItems(): MenuItem[] {
async function sourceFromClipboard() {
let text = "";
try {
text = await readClipboardPlainText();
text = (await navigator.clipboard?.readText()) ?? "";
} catch {
// Unreadable clipboard: reported below like an unknown one.
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { writeClipboardText } from "@/platform/clipboard";
import { useEffect, useState } from "react";
import clsx from "clsx";
import { commands, isTauri, type RunConfig } from "@/platform/ipc";
@@ -94,7 +94,7 @@ export function EnvDialog({
};
const paste = async () => {
try {
const text = await readClipboardPlainText();
const text = await navigator.clipboard.readText();
setRows((r) => pasteRows(r, text));
} catch {
/* clipboard unavailable */
+2 -2
View File
@@ -1,4 +1,4 @@
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { writeClipboardText } from "@/platform/clipboard";
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { call, commands, isTauri, type RunConfig, type TomcatInstallation, type WebArtifact } from "@/platform/ipc";
@@ -899,7 +899,7 @@ export function TomcatSettings({ config, onChange }: { config: RunConfig; onChan
iconSize={13}
title="Paste"
onClick={() => {
void readClipboardPlainText().then((text) => {
void navigator.clipboard?.readText().then((text) => {
if (!text.trim()) return;
const idx = text.indexOf("=");
const name = idx >= 0 ? text.slice(0, idx).trim() : text.trim();
+1 -2
View File
@@ -16,7 +16,6 @@ import { findOutputLinks } from "./links";
import { useRun } from "./store";
import styles from "./StackTrace.module.css";
import { tx } from "@/platform/i18n";
import { readClipboardPlainText } from "@/platform/clipboard";
export interface StackTraceTab {
id: string;
@@ -84,7 +83,7 @@ export function closeStackTrace(id: string) {
async function openDialog() {
let clip = "";
try {
clip = await readClipboardPlainText();
clip = (await navigator.clipboard?.readText()) ?? "";
} catch {
// clipboard unavailable or denied: start empty
}
+1 -1
View File
@@ -682,7 +682,7 @@ export const TOOLS_PAGES: PageSpec[] = [
key: "agents.newTaskApprovals",
label: "New tasks start",
options: NEW_TASK_APPROVAL_OPTIONS,
hint: "Without asking means the agent's bypass or full-access mode, where command permissions don't apply",
hint: "Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply",
},
],
},
+1 -2
View File
@@ -50,7 +50,6 @@ import { usePostfixSettings } from "./postfix/store";
import { showChooser, type ChooserItem } from "./popup/store";
import { expandEmmetAtCaret } from "./emmet/editor";
import { hasEmmet, loadEmmet } from "./emmet/model";
import { readClipboardPlainText } from "@/platform/clipboard";
export interface TemplateDocInfo {
path: string;
@@ -83,7 +82,7 @@ function userName(): string {
async function readClipboard(): Promise<string> {
try {
return await readClipboardPlainText();
return (await navigator.clipboard?.readText()) ?? "";
} catch {
return "";
}
-40
View File
@@ -142,27 +142,6 @@ describe("ownTerminalInput", () => {
expect(reached).toEqual(["keydown", "insertText"]);
});
it("sends a shifted character an IME commits before its keydown", () => {
// WeChat / Pinyin on macOS, Shift+/ for `?`: insertText arrives while only Shift is down.
// Its keydown armed xterm's gate, which then drops the first press.
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "Shift", keyCode: 16 });
input(textarea, { inputType: "insertText", data: "?" });
keydown(textarea, { key: "?", keyCode: 229 });
keyup(textarea, 191);
expect(sent).toEqual(["?"]);
expect(reached).toEqual(["keydown"]);
});
it("leaves shifted keys xterm sent itself to xterm", () => {
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "Shift", keyCode: 16 });
keydown(textarea, { key: "A", keyCode: 65 });
input(textarea, { inputType: "insertText", data: "A" });
expect(sent).toEqual([]);
expect(reached).toEqual(["keydown", "keydown", "insertText"]);
});
it("leaves input with no keydown behind it to xterm", () => {
// Dictation, an on-screen keyboard or the emoji picker.
const { textarea, sent, reached } = setup();
@@ -280,25 +259,6 @@ describe("ownTerminalInput with xterm", () => {
return sent;
}
it("sends `?` on the first and the second press of Shift+/ with a direct-commit IME", async () => {
const sent = await replay([
["keydown", "Shift", 16],
["input", "insertText", "?", "?"],
["keydown", "?", 229],
["keyup", "?", 191],
["input", "insertText", "?", "??"],
["keydown", "?", 229],
["keyup", "?", 191],
["keyup", "Shift", 16],
["keydown", "Shift", 16],
["keydown", "A", 65],
["input", "insertText", "A", "??A"],
["keyup", "A", 65],
["keyup", "Shift", 16],
]);
expect(sent).toEqual(["?", "?", "A"]);
});
it.each([true, false])("sends a retyped word and following plain keys once (keyup first: %s)", async (release) => {
// Uppercase typing reaches both keypress and input; lowercase keydown is cancelled by xterm.
const sent = await replay([
+2 -16
View File
@@ -14,11 +14,7 @@
* - a keyCode-229 keydown never reaches xterm, so its deferred textarea diff cannot run;
* - while such a keydown is pending, an `insertText` is sent from the event's own `data`
* (not from a value diff), then the textarea is cleared;
* - composition input that arrives without a `compositionstart` is sent the same way;
* - so is an `insertText` while only a modifier is down: an IME that commits a shifted
* character directly (`?` from Shift+/ with WeChat or Pinyin) delivers it before the
* character's keydown, and the Shift keydown already armed xterm's gate, so the first press
* was dropped.
* - composition input that arrives without a `compositionstart` is sent the same way.
*
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
* input events for keys xterm already sent, and input with no IME keydown behind it.
@@ -32,8 +28,6 @@
*/
import { isImeKey } from "@/platform/ime";
const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta"]);
/** Commit-shaped input events that xterm's own input path ignores entirely. */
const COMPOSITION_COMMITS = new Set(["insertFromComposition", "insertCompositionText"]);
@@ -68,9 +62,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
let imeKey = false;
/** The pending key belongs to an IME: its commit is ours to deliver. */
let imeDown = false;
/** Since the last keyup: a modifier went down, and any other key went down. */
let modifierDown = false;
let keyDown = false;
const onKeyDown = (e: KeyboardEvent) => {
if (composing) {
@@ -78,8 +69,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
if (e.keyCode === 0) e.stopPropagation();
return;
}
if (MODIFIERS.has(e.key)) modifierDown = true;
else keyDown = true;
imeKey = isImeKey(e);
imeDown = imeKey;
if (imeKey) {
@@ -101,8 +90,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
const onKeyUp = () => {
imeKey = false;
imeDown = false;
modifierDown = false;
keyDown = false;
};
const onInput = (e: InputEvent) => {
@@ -110,8 +97,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
if (e.inputType === "insertText") {
// Without an IME keydown behind it this is xterm's: it drops the event for a key it
// already sent (Option/AltGr, plain typing) and delivers the rest (dictation, emoji).
// With only a modifier down, no key of xterm's is behind it: xterm would drop it too.
if (!imeDown && !(modifierDown && !keyDown)) return;
if (!imeDown) return;
} else if (!COMPOSITION_COMMITS.has(e.inputType)) {
return;
}
-26
View File
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { Terminal } from "@xterm/xterm";
import { commands } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste";
const notify = vi.hoisted(() => vi.fn());
@@ -526,28 +525,3 @@ it("recovers quoted local file paths when clipboard text permission is denied",
expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'");
expect(notify).not.toHaveBeenCalled();
});
it.each([
// WebKitGTK pastes nothing from a menu through execCommand or readText: native text first.
{ os: "linux", native: "ls -la", expected: ["ls -la"], execCommand: 0 },
// An image-only clipboard keeps the WebView paths.
{ os: "linux", native: "", expected: [], execCommand: 1 },
{ os: "macos", native: "ls -la", expected: ["ls -la"], execCommand: 1 },
] as const)("menu paste on $os with native text '$native'", async ({ os, native, expected, execCommand }) => {
usePlatform.setState({ os });
vi.spyOn(commands, "clipboardText").mockResolvedValue({ status: "ok", data: native });
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "web" } });
const exec = vi.fn(() => false);
Object.defineProperty(document, "queryCommandSupported", { configurable: true, value: () => true });
Object.defineProperty(document, "execCommand", { configurable: true, value: exec });
const paste = vi.fn();
try {
await pasteTerminalClipboard(paste, () => localTarget());
} finally {
usePlatform.setState({ os: "macos" });
Reflect.deleteProperty(document, "queryCommandSupported");
Reflect.deleteProperty(document, "execCommand");
}
expect(exec).toHaveBeenCalledTimes(execCommand);
expect(paste.mock.calls).toEqual(expected.map((text) => [text]));
});
+2 -12
View File
@@ -1,7 +1,6 @@
import { pasteClipboardFiles, readClipboardPlainText, readClipboardText } from "@/platform/clipboard";
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
import { call, commands, type TerminalId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { usePlatform } from "@/platform/platformInfo";
import { notifications } from "@/workbench/contributions";
export interface TerminalPasteTarget {
@@ -121,15 +120,6 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) =>
export async function pasteTerminalClipboard(paste: (text: string) => void, getTarget?: Target) {
const target = getTarget?.();
try {
// WebKitGTK pastes nothing from a menu through either WebView path; the native
// clipboard has the text. An image-only clipboard still takes the paths below.
if (target && usePlatform.getState().os === "linux") {
const text = await readClipboardPlainText().catch(() => "");
if (text) {
if (target.current()) paste(text);
return;
}
}
// Native paste preserves clipboard blobs where Clipboard.read() is unavailable.
if (target && document.queryCommandSupported?.("paste") && document.execCommand("paste")) return;
const images =
@@ -140,7 +130,7 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
)
: null;
const text = target?.remote
? await readClipboardPlainText().catch(() => "")
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
if (target && !target.current()) return;
if (text) {
+1 -9
View File
@@ -25,7 +25,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import type { Branch, ChangedFile, Commit } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { maybeEditorService, useGitLogChecks, type EditorInput } from "@/workbench/contributions";
import {
EmptyState,
@@ -2628,7 +2627,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
},
[scope],
);
useEffect(() => () => setContext("vcsLogChangesFocus", false), []);
if (!commit)
return (
@@ -2669,13 +2667,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
const details = (
<SplitGroup orientation="vertical" id="git-log-details">
<SplitPanel id="files" defaultSize="60%" minSize="60px">
<div
className={styles.filesPane}
onFocusCapture={() => setContext("vcsLogChangesFocus", true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("vcsLogChangesFocus", false);
}}
>
<div className={styles.filesPane}>
<div className={styles.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
<IconButton
icon="diff"
+1 -2
View File
@@ -62,7 +62,6 @@ import { refreshNow } from "./store";
import styles from "./MergeEditor.module.css";
import type { MergeData } from "./mergeKind";
import { readClipboardPlainText } from "@/platform/clipboard";
export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind";
const NOTHING_LEFT: MergeSummary = {
@@ -498,7 +497,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
const { from, to } = view.state.selection.main;
if (id === "selectAll") view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
else if (id === "paste") {
const text = await readClipboardPlainText().catch(() => undefined);
const text = await navigator.clipboard?.readText().catch(() => undefined);
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
} else {
await copyText(view.state.sliceDoc(from, to));
@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import { fileWebUrl, remoteHosting } from "../model";
describe("remoteHosting", () => {
it.each([
["[email protected]:o/r.git", "GitHub", "github"],
["https://github.example.com/o/r", "GitHub", "github"],
["https://gitee.com/o/r.git", "Gitee", "github"],
["[email protected]:o/r.git", "Bitbucket", "bitbucket"],
["ssh://[email protected]:2222/group/sub/r.git", "GitLab", "gitlab"],
["[email protected]:group/r.git", "git.example.com", "gitlab"],
])("%s → %s", (url, name, kind) => {
expect(remoteHosting(url)).toEqual({ name, kind });
});
it("has none for a local remote", () => {
expect(remoteHosting("/srv/git/r.git")).toBeNull();
});
});
describe("fileWebUrl", () => {
it("opens a file at a line on GitHub", () => {
expect(fileWebUrl("[email protected]:o/r.git", "main", "src/a b.ts", { line: 9 })).toBe(
"https://github.com/o/r/blob/main/src/a%20b.ts#L10",
);
});
it("keeps a branch's slashes and opens directories as trees", () => {
expect(fileWebUrl("[email protected]:o/r.git", "feature/x", "src", { dir: true, line: 3 })).toBe(
"https://github.com/o/r/tree/feature/x/src",
);
});
it("uses GitLab's /-/ routes, including subgroups", () => {
expect(fileWebUrl("ssh://[email protected]:2222/group/sub/r.git", "abc123", "docs/中文.md", { line: 0 })).toBe(
"https://gitlab.example.com/group/sub/r/-/blob/abc123/docs/%E4%B8%AD%E6%96%87.md#L1",
);
expect(fileWebUrl("[email protected]:g/r.git", "main", "", { dir: true })).toBe("https://gitlab.com/g/r/-/tree/main");
});
it("uses Bitbucket's /src/ routes", () => {
expect(fileWebUrl("[email protected]:o/r.git", "main", "a.py", { line: 4 })).toBe(
"https://bitbucket.org/o/r/src/main/a.py#lines-5",
);
});
});
+1 -37
View File
@@ -12,8 +12,7 @@ vi.mock("@/platform/ipc", async (original) => {
},
};
});
import { act, renderHook } from "@testing-library/react";
import { loadLogDetails, useCommitDetails } from "../logDetails";
import { loadLogDetails } from "../logDetails";
import { useVcs } from "../store";
import { useGitLog } from "../logStore";
const commit = (id: string): Commit => ({
@@ -93,38 +92,3 @@ describe("commit detail jobs", () => {
await Promise.all([first, second]);
});
});
describe("useCommitDetails", () => {
it("keeps the shown details while a repository refresh reloads them", async () => {
const c = commit("kept-on-refresh");
const { result, rerender } = renderHook(({ c }) => useCommitDetails(c), { initialProps: { c } });
await vi.waitFor(() => expect(result.current.details?.message).toBe("kept-on-refresh"));
let finish!: () => void;
api.batch.mockImplementationOnce(
(_repo: string, ids: string[]) =>
new Promise((resolve) => {
finish = () =>
resolve({
status: "ok",
data: ids.map(() => ({
message: "reloaded",
containingBranches: ["main"],
signature: "N",
signer: "",
key: "",
fingerprint: "",
})),
});
}),
);
act(() => useVcs.setState((s) => ({ version: s.version + 1 })));
await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(2));
expect(result.current.details?.message).toBe("kept-on-refresh");
await act(async () => finish());
await vi.waitFor(() => expect(result.current.details?.message).toBe("reloaded"));
// Another commit shows nothing until its own details arrive.
api.batch.mockImplementationOnce(() => new Promise(() => {}));
rerender({ c: commit("another") });
expect(result.current.details).toBeNull();
});
});
+1 -2
View File
@@ -373,8 +373,7 @@ export function registerVcsActions(): () => void {
icon: "diff",
when: VCS,
// ⌘D duplicates the line inside a text editor; Git › Show Diff still works there.
// In the Git Log's changed files it shows the commit's diff instead.
keyWhen: "!editorTextFocus && !vcsLogChangesFocus",
keyWhen: "!editorTextFocus",
run: async ({ arg, ctx }) => {
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
const line =
+1 -2
View File
@@ -14,7 +14,6 @@ export { compareFilesData, displayPath } from "./compareData";
import { openDiff, openVcsDiff, type DiffData } from "./diffInput";
import { baseName } from "./model";
import { tx } from "@/platform/i18n";
import { readClipboardPlainText } from "@/platform/clipboard";
function argPath(arg: unknown): string | undefined {
if (typeof arg === "string") return arg;
@@ -286,7 +285,7 @@ export function registerCompareActions(): () => void {
if (!path) return info("Open or select a file to compare with the clipboard");
let clipboard: string;
try {
clipboard = await readClipboardPlainText();
clipboard = (await navigator.clipboard?.readText()) ?? "";
} catch {
return info("The clipboard could not be read");
}
+1 -2
View File
@@ -109,8 +109,7 @@ function FrontWindow({ title, onClose, children }: { title: string; onClose: ()
<div
ref={body}
className="vcsDiffDialogBody"
// The dialog body is not a flex container: fill it so the editors reach the footer.
style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}
style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
>
{children}
</div>
+1 -4
View File
@@ -14,7 +14,7 @@ import {
registerTitleItem,
registerToolWindow,
} from "@/workbench/contributions";
import { executeAction, registerActions } from "@/platform/actions";
import { executeAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { lazyComponent } from "@/ui";
import { registerVcsActions } from "./actions";
@@ -50,7 +50,6 @@ import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore"
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
import { installRootSync } from "./rootSync";
import { tx } from "@/platform/i18n";
import { openOnRemoteActions, registerOpenOnRemote } from "./openOnRemote";
/** The Resolve/Continue/Abort buttons exist in the title bar only while there is something to resolve. */
function trackMergeRebaseItem(): () => void {
@@ -232,8 +231,6 @@ export function activate(): () => void {
(signOff) => useCommit.setState({ signOff }),
),
registerVcsActions(),
registerActions(openOnRemoteActions),
registerOpenOnRemote(),
registerHistoryActions(),
registerLogPaneActions(),
activateSettingsPages(),
+5 -13
View File
@@ -126,28 +126,20 @@ export function useCommitDetails(commit: Commit, enabled = true) {
const version = useVcs((s) => s.version);
const repo = commitRepo(commit) ?? currentRepo();
const refs = refVersions((s) => s[repo ?? ""] ?? 0);
const target = `${repo}\0${commit.id}`;
const key = `${target}\0${version}\0${refs}`;
const [result, setResult] = useState<{
target: string;
details: LogDetails | null;
error: boolean;
} | null>(null);
const key = `${repo}\0${commit.id}\0${version}\0${refs}`;
const [result, setResult] = useState<{ key: string; details: LogDetails | null; error: boolean } | null>(null);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
void loadLogDetails(commit, controller.signal)
.then((details) => {
if (!controller.signal.aborted) setResult({ target, details, error: false });
if (!controller.signal.aborted) setResult({ key, details, error: false });
})
.catch(() => {
if (!controller.signal.aborted) setResult({ target, details: null, error: true });
if (!controller.signal.aborted) setResult({ key, details: null, error: true });
});
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, enabled]);
// A repository refresh reloads the same commit's details: keep showing the last ones until
// the new ones arrive, or the section blinks on every refresh.
const current = result?.target === target ? result : null;
return { details: current?.details ?? null, error: !!current?.error };
return { details: result?.key === key ? result.details : null, error: result?.key === key && result.error };
}
-1
View File
@@ -152,7 +152,6 @@ export function registerLogPaneActions(): () => void {
icon: "diff",
when: LOG,
hidden: true,
keyWhen: "vcsLogChangesFocus",
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
},
{
+1 -45
View File
@@ -165,51 +165,7 @@ export function commitWebUrl(remoteUrl: string, sha: string): string | null {
return `${base}/${/bitbucket\.org\//.test(base) ? "commits" : "commit"}/${sha}`;
}
/** How a Git hosting service lays out its file pages. */
export type HostingKind = "github" | "gitlab" | "bitbucket";
/**
* The hosting service of a remote, by its host: GitHub (and GitHub Enterprise, Gitee, which share
* its URLs), Bitbucket, and GitLab for gitlab.* and any other host, as self-managed GitLab is the
* usual company server. Null for remotes with no web page (local paths).
*/
export function remoteHosting(url: string): { name: string; kind: HostingKind } | null {
const base = remoteWebUrl(url);
if (!base) return null;
const host = new URL(base).hostname.toLowerCase();
if (host === "github.com" || host.includes("github")) return { name: "GitHub", kind: "github" };
if (host === "gitee.com") return { name: "Gitee", kind: "github" };
if (host === "bitbucket.org") return { name: "Bitbucket", kind: "bitbucket" };
if (host.includes("gitlab")) return { name: "GitLab", kind: "gitlab" };
return { name: host, kind: "gitlab" };
}
/**
* Page of a file or directory at `revision` on the remote's web UI, with the 0-based `line`
* selected. `relative` is the `/`-separated path inside the repository ("" for its root).
*/
export function fileWebUrl(
remoteUrl: string,
revision: string,
relative: string,
opts: { dir?: boolean; line?: number } = {},
): string | null {
const base = remoteWebUrl(remoteUrl);
const hosting = remoteHosting(remoteUrl);
if (!base || !hosting) return null;
const path = [...revision.split("/"), ...relative.split("/")].filter(Boolean).map(encodeURIComponent).join("/");
const line = opts.line !== undefined && !opts.dir ? opts.line + 1 : null;
switch (hosting.kind) {
case "github":
return `${base}/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
case "gitlab":
return `${base}/-/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
case "bitbucket":
return `${base}/src/${path}${line ? `#lines-${line}` : ""}`;
}
}
/** "N file(s)", worded for the locale. */ /** "N file(s)", worded for the locale. */
/** "N file(s)", worded for the locale. */
export function fileCount(n: number): string {
return n === 1 ? tx`${n} file` : tx`${n} files`;
}
-111
View File
@@ -1,111 +0,0 @@
/**
* Open In › GitHub / GitLab / Bitbucket: the file (at the caret line) or directory on the web UI
* of its repository's remote, at the current branch's upstream (else the HEAD commit).
*/
import { call, commands } from "@/platform/ipc";
import type { Action } from "@/platform/actions";
import { notifications, registerMenuGroup } from "@/workbench/contributions";
import { openUrl } from "./issueLinks";
import { fileWebUrl, remoteHosting, rootOf } from "./model";
import { useVcs } from "./store";
interface Target {
path: string;
dir?: boolean;
line?: number;
}
/** Root → its remote's URL (null: none), read when the repositories change. */
const remoteUrls = new Map<string, string | null>();
/** The remote a root's files open on: the selected repository's upstream remote, else `origin`. */
function remoteName(root: string): string {
const { repo, branch } = useVcs.getState();
const upstream = root === repo ? branch?.upstream : null;
return upstream ? upstream.slice(0, upstream.indexOf("/")) : "origin";
}
async function readRemoteUrl(root: string): Promise<string | null> {
const url = await call(commands.vcsConfigGet(root, `remote.${remoteName(root)}.url`)).catch(() => null);
remoteUrls.set(root, url || null);
return url || null;
}
function revision(root: string): string | null {
const { repo, branch, heads } = useVcs.getState();
const upstream = root === repo ? branch?.upstream : null;
if (upstream) return upstream.slice(upstream.indexOf("/") + 1);
return heads[root]?.headCommit ?? null;
}
function targetArg(arg: unknown): Target | null {
if (typeof arg === "string") return { path: arg };
if (arg && typeof arg === "object" && typeof (arg as Target).path === "string") return arg as Target;
return null;
}
async function openOnRemote(target: Target) {
const root = rootOf(target.path, useVcs.getState().roots);
const url = root ? (remoteUrls.get(root) ?? (await readRemoteUrl(root))) : null;
const rev = root ? revision(root) : null;
const page =
root && url && rev ? fileWebUrl(url, rev, target.path.slice(root.length).replace(/^\/+/, ""), target) : null;
if (page) await openUrl(page);
else notifications().notify({ group: "vcs", level: "warning", title: "This file has no page on a Git remote" });
}
export const openOnRemoteActions: Action[] = [
{
id: "vcs.openOnRemote",
title: "Open on Git Remote",
description: "Opens the file or directory on the web page of its repository's remote.",
category: "Git",
icon: "external-link",
when: "vcsAvailable",
hidden: true,
run: ({ arg }) => {
const target = targetArg(arg);
if (target) void openOnRemote(target);
},
},
];
/** Open In › <hosting>, for files and directories inside a repository with a web remote. */
export function registerOpenOnRemote(): () => void {
const refresh = (roots: readonly string[]) => {
for (const root of [...remoteUrls.keys()]) if (!roots.includes(root)) remoteUrls.delete(root);
for (const root of roots) void readRemoteUrl(root);
};
refresh(useVcs.getState().roots);
const unsubscribe = useVcs.subscribe((s, prev) => {
if (s.roots !== prev.roots || s.branch?.upstream !== prev.branch?.upstream) refresh(s.roots);
});
const unregister = registerMenuGroup({
id: "vcs.openOnRemote",
menus: ["openIn"],
items: (_menu, { paths, dirs, line }) => {
if (paths.length !== 1) return [];
const path = paths[0]!;
const root = rootOf(path, useVcs.getState().roots);
const url = root ? remoteUrls.get(root) : null;
const hosting = url ? remoteHosting(url) : null;
if (!hosting) return [];
const target: Target = { path, dir: dirs?.[0] ?? false, line };
return [
{
id: "vcs.openOnRemote",
// A service or host name, the same in every language.
label: hosting.name,
verbatim: true,
icon: hosting.kind === "github" ? "github" : "external-link",
run: () => void openOnRemote(target),
},
];
},
});
return () => {
unsubscribe();
unregister();
remoteUrls.clear();
};
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
import { writeClipboardText } from "@/platform/clipboard";
import { commitRepo } from "./multiRoot";
/** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */
import { useEffect, useState } from "react";
@@ -224,7 +224,7 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string)
let source = "Clipboard";
try {
if (from === "clipboard") {
text = await readClipboardPlainText();
text = await navigator.clipboard.readText();
} else {
let file = path ?? null;
if (!file) {
+3 -14
View File
@@ -7,19 +7,6 @@ export async function writeClipboardText(text: string): Promise<void> {
else await navigator.clipboard.writeText(text);
}
/**
* The clipboard's plain text, read natively where the backend can (macOS, Linux), else
* through the WebView. A Paste from a menu has no paste event to read, and WebKitGTK's
* `navigator.clipboard.readText()` gives no text there. Rejects when the WebView refuses.
*/
export async function readClipboardPlainText(): Promise<string> {
if (isTauri()) {
const text = await call(commands.clipboardText()).catch(() => null);
if (text != null) return text;
}
return (await navigator.clipboard?.readText()) ?? "";
}
type FormatFiles = (paths: string[]) => string;
const rawPaths: FormatFiles = (paths) => paths.join("\n");
@@ -28,7 +15,9 @@ export async function readClipboardText(
transfer?: DataTransfer | null,
formatFiles: FormatFiles = rawPaths,
): Promise<string> {
const text = transfer ? transfer.getData("text/plain") : await readClipboardPlainText().catch(() => "");
const text = transfer
? transfer.getData("text/plain")
: ((await navigator.clipboard?.readText().catch(() => "")) ?? "");
if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text;
let paths: string[];
try {
+1 -1
View File
@@ -50,7 +50,7 @@ const shared = [
["code.quickDocumentation", "db.quickDoc", "debug.setValue", "vcs.changelist.edit"],
["code.reformat", "project.reformatCode", "db.reformatValue"],
["code.optimizeImports", "project.optimizeImports"],
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff", "vcs.log.changes.showDiff"],
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff"],
["code.moveStatementDown", "find.field.recentSearches", "find.popup.recentSearches"],
["code.foldAll", "project.collapseAll"],
["refactor.rename", "project.rename", "db.rename", "vcs.changelist.edit", "vcs.log.editMessage", "vcs.shelf.rename"],
-1
View File
@@ -303,7 +303,6 @@ export const intellijMacos: Record<string, string[]> = {
"vcs.rollback": ["Meta+Alt+Z"],
"vcs.addToVcs": ["Meta+Alt+A"],
"vcs.showDiff": ["Meta+D"],
"vcs.log.changes.showDiff": ["Meta+D"],
"vcs.showDiffInNewWindow": [],
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
-1
View File
@@ -297,7 +297,6 @@ export const intellijWindows: Record<string, string[]> = {
"vcs.rollback": ["Ctrl+Alt+Z"],
"vcs.addToVcs": ["Ctrl+Alt+A"],
"vcs.showDiff": ["Ctrl+D"],
"vcs.log.changes.showDiff": ["Ctrl+D"],
"vcs.showDiffInNewWindow": [],
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
+2 -5
View File
@@ -1087,7 +1087,7 @@
"New tasks start": "New tasks start",
"Without asking for approval": "Without asking for approval",
"Asking for approval": "Asking for approval",
"Without asking means the agent's bypass or full-access mode, where command permissions don't apply": "Without asking means the agent's bypass or full-access mode, where command permissions don't apply",
"Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply": "Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply",
"Conversation": "Conversation",
"Fold a finished turn's work": "Fold a finished turn's work",
"Reasoning and tool calls before the reply fold into one line under its message": "Reasoning and tool calls before the reply fold into one line under its message",
@@ -1456,8 +1456,5 @@
"Dependencies": "Dependencies",
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.",
"No credential store": "No credential store",
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you.",
"Open on Git Remote": "Open on Git Remote",
"Opens the file or directory on the web page of its repository's remote.": "Opens the file or directory on the web page of its repository's remote.",
"This file has no page on a Git remote": "This file has no page on a Git remote"
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you."
}
+8 -11
View File
@@ -396,11 +396,11 @@
"Custom executable": "自定义可执行文件",
"Found on PATH": "已在 PATH 中找到",
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "留空以从 PATH 查找可执行文件。更改对新会话生效。",
"Command Permissions": "命令权限",
"Allowed commands run without asking, with or without extra arguments. Blocked commands are always rejected, even if allowed, and support * and ? wildcards. Commands with substitutions, redirections or variable assignments always ask.": "允许的命令直接运行,无需确认,可带额外参数。阻止的命令总会被拒绝,优先于允许列表,支持 * 和 ? 通配符。含命令替换、重定向或变量赋值的命令总会询问。",
"Allowed Commands": "允许的命令",
"Blocked Commands": "阻止的命令",
"e.g. cargo test": "例如 cargo test",
"Command Execution Policy": "命令执行策略",
"Allowed commands run without asking: an entry matches that command, alone or with more arguments. Commands with substitutions, redirections or variable assignments always ask. Blocked commands are rejected and win over allowed ones; they support the * and ? wildcards.": "允许的命令无需确认即可运行:条目匹配该命令本身或带更多参数的该命令。含命令替换、重定向或变量赋值的命令总会询问。阻止的命令会被拒绝,且优先于允许的命令;可使用 * 和 ? 通配符。",
"Allowed Commands (Whitelist)": "允许的命令(白名单)",
"Blocked Commands (Blacklist)": "阻止的命令(黑名单)",
"e.g. cargo test*": "例如 cargo test*",
"e.g. rm -rf *": "例如 rm -rf *",
"Add allowed command": "添加允许的命令",
"Add blocked command": "添加阻止的命令",
@@ -7446,7 +7446,7 @@
"New tasks start": "新任务开始时",
"Without asking for approval": "不请求批准",
"Asking for approval": "请求批准",
"Without asking means the agent's bypass or full-access mode, where command permissions don't apply": "不请求批准即使用智能体的绕过权限或完全访问模式,此时命令权限不生效",
"Without asking means the agent's bypass or full-access mode, where the Command Execution Policy doesn't apply": "不请求批准即使用智能体的绕过权限或完全访问模式,此时命令执行策略不生效",
"Conversation": "对话",
"Fold a finished turn's work": "折叠已完成回合的过程",
"Reasoning and tool calls before the reply fold into one line under its message": "回复之前的思考和工具调用折叠成一行,显示在对应消息下方",
@@ -7458,7 +7458,7 @@
"No checksum provided: the download can't be verified.": "未提供校验和:无法校验下载的文件。",
"Sign In": "登录",
"New Tasks": "新任务",
"Coding Agents": "编程智能体",
"Coding Agents": "编程 Agent",
"Install coding agents and configure how they launch, chat and connect to the IDE.": "安装编程 Agent,并配置其启动方式、聊天行为以及与 IDE 的连接。",
"Fork Agent Task": "Fork Agent 任务",
"Couldn't fork the task": "无法 Fork 任务",
@@ -7854,8 +7854,5 @@
"All Interpreters": "所有解释器",
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "配置项目解释器及其软件包、IDE 已知的所有解释器,以及 Ruff。",
"No credential store": "没有凭据存储",
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。",
"Open on Git Remote": "在 Git 远程上打开",
"Opens the file or directory on the web page of its repository's remote.": "在仓库远程的网页上打开该文件或目录。",
"This file has no page on a Git remote": "该文件在 Git 远程上没有对应页面"
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。"
}