Compare commits

...
Author SHA1 Message Date
l0ng-ai 3754be8627 fix(agents): shorten the command permissions copy
Settings › Agents › Command Execution Policy becomes Command Permissions, its description is split into short sentences, the columns drop the (Whitelist) / (Blacklist) suffixes, and the allowed-command placeholder no longer suggests a trailing `*` that allow entries ignore. Chinese: 命令权限, and Coding Agents reads 编程智能体 like its parent group.
2026-11-13 13:17:38 +08:00
GuoYuandguoyucode b463252bc5 fix(run): decode Windows programs' piped output in any system language (#3060)
Commands run on pipes (Run configurations without terminal emulation, and agent commands) showed mojibake for Windows' own programs: ping, dir, cmd's messages and PowerShell's native output write a pipe in the console (OEM) code page, wsl.exe and cmd /u write UTF-16LE, while a UTF-8 console decoded every byte as UTF-8.

On Windows, a UTF-8 console now decodes piped output line by line: a UTF-8 line stays UTF-8, any other line is decoded in the console code page (encoding_rs for GBK, Big5, Shift_JIS, EUC-KR, 866, 874 and the 125x pages; Windows for the single-byte OEM pages it lacks), and a read with NUL bytes is UTF-16LE. ASCII passes through at once; the rest of a line is held until it shows its encoding, and a held prompt is shown after a batch interval without more output. An explicit console encoding and terminal emulation are unchanged; other platforms keep the plain UTF-8 decoder.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:06:11 +08:00
GuoYuandguoyucode 2abdac4fdb fix(mcp): a run point in a Cargo workspace member runs that member's tests (#3059)
execute_run_configuration with a run point in a Cargo workspace member ran the first member's tests: every member's `cargo test -p <member>` configuration runs from the workspace root, so all were equally close to the file and the first won. A Cargo configuration now covers its member's directory (extras.module) when the closest configuration is picked; its working directory stays the workspace root.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:04:35 +08:00
l0ng-ai 721f4144c1 fix(vcs): keep a commit's details shown while a refresh reloads them (#3070)
The Git log's commit details (containing branches, signature, refs) were keyed on
the repository's refresh version and dropped as soon as it changed, so each
refresh blanked the section until the reload finished. With something writing to
the repository's .git often, the section blinked continuously.

useCommitDetails now keeps the last details of the same commit until the reloaded
ones arrive; another commit still starts empty.
2026-11-13 13:01:09 +08:00
l0ng-ai 55057b8958 feat(vcs): open a file on its Git remote from Open In (#3069)
Open In in the project view and the editor (context menu and tab) now lists the
repository's hosting service, GitHub, GitLab, Bitbucket or Gitee, named after the
remote. It opens the file at the caret line, or the directory, on the remote's web
UI, at the current branch's upstream when it has one and at the HEAD commit
otherwise. The remote is the upstream's, else origin. Hosts other than the known
services are treated as self-managed GitLab and shown by host name.

The remote URL of each repository is read when the repositories or the upstream
change, so the menu is built without waiting for Git.
2026-11-13 12:57:02 +08:00
l0ng-ai 8abc8e7fbf fix(clipboard): read the clipboard natively for Paste from menus (#3068)
A Paste chosen from a menu (the terminal's context menu, Edit › Paste, Paste as
Plain Text and other paste buttons) has no paste event to read, so it used
navigator.clipboard.readText(), and the terminal tried execCommand("paste") first.
On Linux, WebKitGTK gives neither the text, so the terminal's context-menu Paste
did nothing while middle-click paste worked.

A new clipboard_text command reads the clipboard's text natively, as
clipboard_write_text already writes it: GTK on Linux, NSPasteboard on macOS, and
none on Windows, where the WebView's clipboard is still used.
readClipboardPlainText() wraps it with that fallback, and every menu paste reads
through it. On Linux the terminal pastes the native text before trying
execCommand; an image-only clipboard keeps the existing paths.
2026-11-13 12:52:26 +08:00
l0ng-ai 68dd949df3 fix(terminal): keep the first shifted character a macOS IME commits directly (#3067)
With WeChat or Pinyin on macOS, Shift+/ commits `?` with an insertText that
arrives before the character's keydown, while only Shift is down. xterm's input
gate was already armed by the Shift keydown and dropped the event, so the first
press produced nothing and the key had to be pressed twice.

The macOS input handler now also delivers an insertText that arrives while only
modifiers are down since the last keyup. Shifted keys xterm sends itself are
unchanged: their keydown comes first.
2026-11-13 12:41:17 +08:00
l0ng-ai b98bddbbab fix(vcs): fill the diff dialog with the editors (#3066)
The front diff dialog (the modal commit interface's Show Diff) left the area below a
short diff in the dialog's background: its body wrapper used flex: 1 inside the
dialog body, which is not a flex container, so it only took the content's height.
It now fills the body, and the editors' background reaches the footer.
2026-11-13 12:33:15 +08:00
l0ng-ai 21b05ab73a fix(vcs): show the commit's diff on ⌘D in the Git Log's changed files (#3065)
⌘D / Ctrl+D in the Git Log's changed files ran the global Show Diff, which diffs
the selected file's working copy against HEAD, and the context menu's Show Diff had
no shortcut. The changed files now set a vcsLogChangesFocus context; the log's Show
Diff is bound to the Show Diff key while it is set, so the menu shows it and the key
opens the commit's diff of the selected files, and the global Show Diff steps aside.
2026-11-13 12:31:56 +08:00
47 changed files with 1093 additions and 80 deletions

No files matched your search

+15
View File
@@ -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(|_| {
+52 -1
View File
@@ -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";
+42 -2
View File
@@ -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");
+50 -13
View File
@@ -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
View File
@@ -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}");
}
}
}
+69
View File
@@ -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");
}
+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 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
+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,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(&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,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,
+6
View File
@@ -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. */
+3 -3
View File
@@ -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 }),
],
};
}
+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 { 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;
+8 -8
View File
@@ -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) => (
+3 -3
View File
@@ -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" }), {
+1 -1
View File
@@ -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"],
+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 Execution Policy. */
/** Settings › Agents › Command Permissions. */
export const POLICY_PAGE = "agents.policy";
export const sessionKey = (task: string) => `agent:${task}`;
+3 -2
View File
@@ -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);
+2 -1
View File
@@ -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.
}
+2 -2
View File
@@ -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 */
+2 -2
View File
@@ -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();
+2 -1
View File
@@ -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
}
+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 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",
},
],
},
+2 -1
View File
@@ -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 "";
}
+40
View File
@@ -142,6 +142,27 @@ 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();
@@ -259,6 +280,25 @@ 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([
+16 -2
View File
@@ -14,7 +14,11 @@
* - 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.
* - 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.
*
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
* input events for keys xterm already sent, and input with no IME keydown behind it.
@@ -28,6 +32,8 @@
*/
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"]);
@@ -62,6 +68,9 @@ 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) {
@@ -69,6 +78,8 @@ 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) {
@@ -90,6 +101,8 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
const onKeyUp = () => {
imeKey = false;
imeDown = false;
modifierDown = false;
keyDown = false;
};
const onInput = (e: InputEvent) => {
@@ -97,7 +110,8 @@ 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).
if (!imeDown) return;
// With only a modifier down, no key of xterm's is behind it: xterm would drop it too.
if (!imeDown && !(modifierDown && !keyDown)) return;
} else if (!COMPOSITION_COMMITS.has(e.inputType)) {
return;
}
+26
View File
@@ -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]));
});
+12 -2
View File
@@ -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) {
+9 -1
View File
@@ -25,6 +25,7 @@ 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,
@@ -2627,6 +2628,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
},
[scope],
);
useEffect(() => () => setContext("vcsLogChangesFocus", false), []);
if (!commit)
return (
@@ -2667,7 +2669,13 @@ 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}>
<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.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
<IconButton
icon="diff"
+2 -1
View File
@@ -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",
);
});
});
+37 -1
View File
@@ -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();
});
});
+2 -1
View File
@@ -373,7 +373,8 @@ export function registerVcsActions(): () => void {
icon: "diff",
when: VCS,
// ⌘D duplicates the line inside a text editor; Git › Show Diff still works there.
keyWhen: "!editorTextFocus",
// In the Git Log's changed files it shows the commit's diff instead.
keyWhen: "!editorTextFocus && !vcsLogChangesFocus",
run: async ({ arg, ctx }) => {
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
const line =
+2 -1
View File
@@ -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");
}
+2 -1
View File
@@ -109,7 +109,8 @@ function FrontWindow({ title, onClose, children }: { title: string; onClose: ()
<div
ref={body}
className="vcsDiffDialogBody"
style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
// 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 }}
>
{children}
</div>
+4 -1
View File
@@ -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(),
+13 -5
View File
@@ -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 };
}
+1
View File
@@ -152,6 +152,7 @@ export function registerLogPaneActions(): () => void {
icon: "diff",
when: LOG,
hidden: true,
keyWhen: "vcsLogChangesFocus",
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
},
{
+45 -1
View File
@@ -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`;
}
+111
View File
@@ -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();
};
}
+2 -2
View File
@@ -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) {
+14 -3
View 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 {
+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"],
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff", "vcs.log.changes.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,6 +303,7 @@ 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,6 +297,7 @@ 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"],
+5 -2
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 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"
}
+11 -8
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 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 远程上没有对应页面"
}