mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3754be8627 | ||
|
|
b463252bc5 | ||
|
|
2abdac4fdb | ||
|
|
721f4144c1 | ||
|
|
55057b8958 | ||
|
|
8abc8e7fbf |
No files matched your search
@@ -27,6 +27,13 @@ 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;
|
||||
|
||||
@@ -373,6 +380,14 @@ 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(|_| {
|
||||
|
||||
@@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
/// 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> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
@@ -101,6 +102,56 @@ 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";
|
||||
|
||||
@@ -434,10 +434,23 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
|
||||
cwd.trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
|
||||
/// 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).
|
||||
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
|
||||
let score = |c: &RunConfig| -> Option<usize> {
|
||||
let cwd = cwd_of(root, c);
|
||||
let cwd = scope_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();
|
||||
@@ -922,6 +935,33 @@ 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");
|
||||
|
||||
@@ -498,7 +498,8 @@ impl ProcessManager {
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref());
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
@@ -629,7 +630,10 @@ impl ProcessManager {
|
||||
#[cfg(unix)]
|
||||
Some(Box::new(wait_readable)),
|
||||
)
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
|
||||
.and_then(|_| {
|
||||
let decoder = || OutputDecoder::for_label(options.encoding.as_deref());
|
||||
spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -1120,18 +1124,35 @@ 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,
|
||||
encoding: Option<String>,
|
||||
decoders: [OutputDecoder; 2],
|
||||
notice: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
|
||||
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
let [mut out_dec, mut err_dec] = decoders;
|
||||
// "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);
|
||||
@@ -1140,6 +1161,8 @@ 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;
|
||||
|
||||
@@ -1154,6 +1177,7 @@ 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()
|
||||
@@ -1170,14 +1194,8 @@ fn spawn_pump(
|
||||
Stream::Stdout => (&mut out_dec, &mut out_notice),
|
||||
Stream::Stderr => (&mut err_dec, &mut err_notice),
|
||||
};
|
||||
match notice {
|
||||
Some(filter) => {
|
||||
dec.decode_into(&bytes, &mut decoded);
|
||||
filter.filter(&decoded, &mut pending);
|
||||
decoded.clear();
|
||||
}
|
||||
None => dec.decode_into(&bytes, &mut pending),
|
||||
}
|
||||
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 };
|
||||
if !pending.is_empty() && batch_started.is_none() {
|
||||
batch_started = Some(Instant::now());
|
||||
}
|
||||
@@ -1193,6 +1211,25 @@ 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);
|
||||
}
|
||||
|
||||
+390
-1
@@ -136,6 +136,7 @@ mod tests {
|
||||
pub enum OutputDecoder {
|
||||
Utf8(Utf8Decoder),
|
||||
Other(Box<encoding_rs::Decoder>),
|
||||
Mixed(Box<MixedDecoder>),
|
||||
}
|
||||
|
||||
impl OutputDecoder {
|
||||
@@ -147,10 +148,36 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +189,218 @@ 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 {
|
||||
@@ -180,7 +415,8 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las
|
||||
|
||||
#[cfg(test)]
|
||||
mod encoding_tests {
|
||||
use super::OutputDecoder;
|
||||
use super::{MixedDecoder, NativeDecoder, OutputDecoder};
|
||||
use ide_core::output_encoding::encoding_for_code_page;
|
||||
|
||||
#[test]
|
||||
fn gbk_split_across_chunks() {
|
||||
@@ -202,4 +438,157 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#![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");
|
||||
}
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
### Review the changes
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
|
||||
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
|
||||
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令执行策略不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
agent 运行过一个任务后,新任务会以它自动批准所有操作的模式开始(Claude Code 的绕过权限、Codex 的完全访问),此时命令权限不生效。要让新任务以请求批准的模式开始,把 **设置 › 智能体 › 聊天 › 新任务开始时** 设为 **请求批准**。这一项只由你自己的设置决定,项目设置无法更改。
|
||||
|
||||
### 查看改动
|
||||
|
||||
|
||||
@@ -22,6 +22,37 @@ 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(>k::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]
|
||||
|
||||
@@ -187,6 +187,7 @@ 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,
|
||||
|
||||
@@ -280,6 +280,12 @@ 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. */
|
||||
|
||||
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
|
||||
] as MenuItem[])
|
||||
: ([
|
||||
{ kind: "separator" },
|
||||
openInMenu(slot.model.path),
|
||||
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
|
||||
{ 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): MenuItem {
|
||||
function openInMenu(path: string, line?: number): MenuItem {
|
||||
return {
|
||||
label: "Open In",
|
||||
submenu: [
|
||||
@@ -1971,7 +1971,7 @@ function openInMenu(path: string): MenuItem {
|
||||
: []),
|
||||
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
|
||||
{ kind: "separator" },
|
||||
...menuGroupItems("openIn", { paths: [path] }),
|
||||
...menuGroupItems("openIn", { paths: [path], line }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Edit › Paste from History and Code › Unwrap/Remove: the parts that need popups or the backend. */
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, 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 navigator.clipboard?.readText()) ?? "";
|
||||
text = await readClipboardPlainText();
|
||||
} catch {
|
||||
hint("Cannot read the clipboard");
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Settings › Tools › Agents › Command Execution Policy: whitelist (auto-approve)
|
||||
* Settings › Tools › Agents › Command Permissions: 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 Execution Policy">
|
||||
<section className={styles.body} aria-label="Command Permissions">
|
||||
<p className={styles.notice}>
|
||||
<T>
|
||||
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 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.
|
||||
</T>
|
||||
</p>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.columnTitle}>
|
||||
<T>Allowed Commands (Whitelist)</T>
|
||||
<T>Allowed Commands</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 (Blacklist)</T>
|
||||
<T>Blocked Commands</T>
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{policy.blockedCommands.map((pattern) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ function FormWrapper({ children }: { children: ReactNode }) {
|
||||
return <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
|
||||
}
|
||||
|
||||
describe("Command Execution Policy sub-page", () => {
|
||||
describe("Command Permissions sub-page", () => {
|
||||
it("registers under Agents group in settings", () => {
|
||||
const dispose = activateSettingsPages();
|
||||
try {
|
||||
@@ -40,7 +40,7 @@ describe("Command Execution Policy 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 Execution Policy");
|
||||
expect(policyNode?.title).toBe("Command Permissions");
|
||||
// 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 Execution Policy sub-page", () => {
|
||||
</FormWrapper>,
|
||||
);
|
||||
|
||||
const policy = screen.getByRole("region", { name: "Command Execution Policy" });
|
||||
const policy = screen.getByRole("region", { name: "Command Permissions" });
|
||||
expect(within(policy).getByText("rm -rf *")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(within(policy).getByRole("textbox", { name: "Allowed command pattern" }), {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function activateSettingsPages(): () => void {
|
||||
}),
|
||||
registerSettingsPage({
|
||||
id: POLICY_PAGE,
|
||||
title: "Command Execution Policy",
|
||||
title: "Command Permissions",
|
||||
group: AGENTS_GROUP,
|
||||
order: 490,
|
||||
keywords: ["agent", "policy", "security", "whitelist", "blacklist", "permission", "command", "exec"],
|
||||
|
||||
@@ -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 Execution Policy. */
|
||||
/** Settings › Agents › Command Permissions. */
|
||||
export const POLICY_PAGE = "agents.policy";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
@@ -11,6 +11,7 @@ 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);
|
||||
@@ -50,8 +51,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" && navigator.clipboard?.readText) {
|
||||
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
|
||||
if (command === "paste") {
|
||||
void readClipboardPlainText().then((text) => document.execCommand("insertText", false, text));
|
||||
return;
|
||||
}
|
||||
document.execCommand(command);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -48,7 +49,7 @@ function dataSourceItems(): MenuItem[] {
|
||||
async function sourceFromClipboard() {
|
||||
let text = "";
|
||||
try {
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
text = await readClipboardPlainText();
|
||||
} catch {
|
||||
// Unreadable clipboard: reported below like an unknown one.
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, 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 navigator.clipboard.readText();
|
||||
const text = await readClipboardPlainText();
|
||||
setRows((r) => pasteRows(r, text));
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, 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 navigator.clipboard?.readText().then((text) => {
|
||||
void readClipboardPlainText().then((text) => {
|
||||
if (!text.trim()) return;
|
||||
const idx = text.indexOf("=");
|
||||
const name = idx >= 0 ? text.slice(0, idx).trim() : text.trim();
|
||||
|
||||
@@ -16,6 +16,7 @@ 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;
|
||||
@@ -83,7 +84,7 @@ export function closeStackTrace(id: string) {
|
||||
async function openDialog() {
|
||||
let clip = "";
|
||||
try {
|
||||
clip = (await navigator.clipboard?.readText()) ?? "";
|
||||
clip = await readClipboardPlainText();
|
||||
} catch {
|
||||
// clipboard unavailable or denied: start empty
|
||||
}
|
||||
|
||||
@@ -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 the Command Execution Policy doesn't apply",
|
||||
hint: "Without asking means the agent's bypass or full-access mode, where command permissions don't apply",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ 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;
|
||||
@@ -82,7 +83,7 @@ function userName(): string {
|
||||
|
||||
async function readClipboard(): Promise<string> {
|
||||
try {
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
return await readClipboardPlainText();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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());
|
||||
@@ -525,3 +526,28 @@ 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]));
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
|
||||
import { pasteClipboardFiles, readClipboardPlainText, 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 {
|
||||
@@ -120,6 +121,15 @@ 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 =
|
||||
@@ -130,7 +140,7 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
|
||||
)
|
||||
: null;
|
||||
const text = target?.remote
|
||||
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
|
||||
? await readClipboardPlainText().catch(() => "")
|
||||
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
|
||||
if (target && !target.current()) return;
|
||||
if (text) {
|
||||
|
||||
@@ -62,6 +62,7 @@ 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 = {
|
||||
@@ -497,7 +498,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 navigator.clipboard?.readText().catch(() => undefined);
|
||||
const text = await readClipboardPlainText().catch(() => undefined);
|
||||
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
|
||||
} else {
|
||||
await copyText(view.state.sliceDoc(from, to));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@ vi.mock("@/platform/ipc", async (original) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
import { loadLogDetails } from "../logDetails";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { loadLogDetails, useCommitDetails } from "../logDetails";
|
||||
import { useVcs } from "../store";
|
||||
import { useGitLog } from "../logStore";
|
||||
const commit = (id: string): Commit => ({
|
||||
@@ -92,3 +93,38 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -285,7 +286,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 navigator.clipboard?.readText()) ?? "";
|
||||
clipboard = await readClipboardPlainText();
|
||||
} catch {
|
||||
return info("The clipboard could not be read");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
registerTitleItem,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerVcsActions } from "./actions";
|
||||
@@ -50,6 +50,7 @@ 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 {
|
||||
@@ -231,6 +232,8 @@ export function activate(): () => void {
|
||||
(signOff) => useCommit.setState({ signOff }),
|
||||
),
|
||||
registerVcsActions(),
|
||||
registerActions(openOnRemoteActions),
|
||||
registerOpenOnRemote(),
|
||||
registerHistoryActions(),
|
||||
registerLogPaneActions(),
|
||||
activateSettingsPages(),
|
||||
|
||||
@@ -126,20 +126,28 @@ 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 key = `${repo}\0${commit.id}\0${version}\0${refs}`;
|
||||
const [result, setResult] = useState<{ key: string; details: LogDetails | null; error: boolean } | null>(null);
|
||||
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);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
void loadLogDetails(commit, controller.signal)
|
||||
.then((details) => {
|
||||
if (!controller.signal.aborted) setResult({ key, details, error: false });
|
||||
if (!controller.signal.aborted) setResult({ target, details, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setResult({ key, details: null, error: true });
|
||||
if (!controller.signal.aborted) setResult({ target, details: null, error: true });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, enabled]);
|
||||
return { details: result?.key === key ? result.details : null, error: result?.key === key && result.error };
|
||||
// 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 };
|
||||
}
|
||||
@@ -165,7 +165,51 @@ export function commitWebUrl(remoteUrl: string, sha: string): string | null {
|
||||
return `${base}/${/bitbucket\.org\//.test(base) ? "commits" : "commit"}/${sha}`;
|
||||
}
|
||||
|
||||
/** "N file(s)", worded for the locale. */
|
||||
/** 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. */
|
||||
export function fileCount(n: number): string {
|
||||
return n === 1 ? tx`${n} file` : tx`${n} files`;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { readClipboardPlainText, 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 navigator.clipboard.readText();
|
||||
text = await readClipboardPlainText();
|
||||
} else {
|
||||
let file = path ?? null;
|
||||
if (!file) {
|
||||
|
||||
@@ -7,6 +7,19 @@ 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");
|
||||
|
||||
@@ -15,9 +28,7 @@ export async function readClipboardText(
|
||||
transfer?: DataTransfer | null,
|
||||
formatFiles: FormatFiles = rawPaths,
|
||||
): Promise<string> {
|
||||
const text = transfer
|
||||
? transfer.getData("text/plain")
|
||||
: ((await navigator.clipboard?.readText().catch(() => "")) ?? "");
|
||||
const text = transfer ? transfer.getData("text/plain") : await readClipboardPlainText().catch(() => "");
|
||||
if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text;
|
||||
let paths: string[];
|
||||
try {
|
||||
|
||||
@@ -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 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",
|
||||
"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",
|
||||
"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,5 +1456,8 @@
|
||||
"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."
|
||||
"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"
|
||||
}
|
||||
@@ -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 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*",
|
||||
"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",
|
||||
"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 the Command Execution Policy doesn't apply": "不请求批准即使用智能体的绕过权限或完全访问模式,此时命令执行策略不生效",
|
||||
"Without asking means the agent's bypass or full-access mode, where command permissions don'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": "编程 Agent",
|
||||
"Coding Agents": "编程智能体",
|
||||
"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,5 +7854,8 @@
|
||||
"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} 中,仅你本人可读取。"
|
||||
"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 远程上没有对应页面"
|
||||
}
|
||||
Reference in new issue
Block a user