Compare commits

..
Author SHA1 Message Date
l0ng-ai 19072fbfce fix(keymap): list the Git log's Show Diff in the VS Code preset
#3065 bound vcs.log.changes.showDiff in the IntelliJ keymaps, and the VS Code
preset must name every IntelliJ action id (keymap.test.ts). VS Code has no Show
Diff key, so it gets none, like vcs.showDiff.
2026-11-13 13:13:20 +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
l0ng-ai 7d7de0b966 fix(python): exclude virtual environments from detected roots whatever their name (#3058)
Root detection only skipped environments named `venv` / `.venv`. A venv with another
name in the project (e.g. `code10`) was walked into, and any package in its
site-packages that ships `pyproject.toml`, `setup.py` or `requirements.txt` became a
source root. Source roots go on PYTHONPATH for runs, so a package directory with an
`io` package shadowed the standard library and Python 3.10 failed at startup with
`init_sys_streams: module 'io' has no attribute 'open'`.

A directory with `pyvenv.cfg` (venv, virtualenv, uv) or `conda-meta` (conda) is now
an excluded root and not searched for modules; creating one re-runs detection.
Project discovery in the workspace folder picker skips such directories too, so
packages inside an environment are not offered as projects.
2026-11-13 12:21:38 +08:00
Clarence 830f4aa802 perf(editor): don't rescan rendered doc comments on every keystroke (#3052)
With Render Documentation Comments on, every keystroke rescanned all comment nodes and rebuilt every rendered comment, so typing in a large file cost about 10 ms per keystroke against about 1 ms with rendering off.

A keystroke (input.type / delete) that touches no comment's lines now only maps the rendered comments and their decorations through the change and marks the view stale; the file is scanned again 300 ms after typing and parsing pause, by the scanner that already does this while rendering is off (renamed deferredScanner). Paste, drop, undo/redo, formatting, reloads, keystrokes on a comment's lines, toggles and relabels still rebuild at once, and parser progress rebuilds at once unless a scan is already waiting, so files still open with visible comments rendered.

Co-authored-by: Clarence404 <[email protected]>
2026-11-13 12:18:34 +08:00
l0ng-ai c905f1428e fix(maven): go from a dependency, plugin or parent to its own POM (#3057)
Go to Declaration in pom.xml found nothing on a dependency that names its own
version, on a <dependencyManagement> entry (a BOM import, say) or on <parent>.
They now open the artifact's POM: the project module with those coordinates, else
the local repository's POM of that version (properties resolved). A dependency
whose version a management entry provides still goes to that entry.
2026-11-13 12:10:46 +08:00
l0ng-ai a8036ce6fa feat(plugins): open two texts in the diff viewer (API 1.7) (#3055)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

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

No files matched your search

+140 -10
View File
@@ -1680,6 +1680,15 @@ pub enum PomGotoKind {
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
/// The POM of the artifact itself: a project module's, else the local repository's.
ArtifactPom,
}
/// A Maven project of the workspace, for going to a dependency on it.
pub struct ModulePom {
pub group_id: String,
pub artifact_id: String,
pub pom: PathBuf,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1759,6 +1768,20 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
goto_declaration_in(text, path, pos, local_repo, Vec::new)
}
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
/// management entry provides (or a management entry itself) go to the artifact's own
/// POM: the project module among `modules` with its coordinates, else the local
/// repository's POM of its version.
pub fn goto_declaration_in(
text: &str,
path: &Path,
pos: Pos,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1769,15 +1792,56 @@ pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<Pa
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
if elements.get(1).is_some_and(|e| e.name == "parent") {
let chain = parent_chain(path, text, local_repo);
let (pom, parent_text) = chain.get(1)?;
let label = elements[1].text_of("artifactId").unwrap_or_default();
return Some(pom_goto(pom.clone(), parent_text, label));
}
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
// A management entry declares the version: it goes to the artifact (a BOM import, say).
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
return Some(found);
}
}
artifact_target(text, path, e, local_repo, modules)
}
/// The artifact's own POM for a dependency or plugin element `e`.
fn artifact_target(
text: &str,
path: &Path,
e: &Element,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let props = properties_at(&mut resolver, &chain, 0);
let (group, artifact) = coordinates(e, &props)?;
let label = format!("{group}:{artifact}");
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
let module_text = xml::read_text(&module.pom)?;
return Some(pom_goto(module.pom, &module_text, label));
}
let version = interpolate(&e.text_of("version")?, &props);
let pom = resolver.repo_pom(&group, &artifact, &version)?;
let pom_text = xml::read_text(&pom)?;
Some(pom_goto(pom, &pom_text, label))
}
/// The POM at `path` as a target, at its `<artifactId>`.
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
let range = xml::parse(text)
.filter(|e| e.name == "project")
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
.unwrap_or_default();
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -3087,9 +3151,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version: it is the declaration.
// The dependency names its own version and no local repository has its POM.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry is a declaration itself, not a reference.
// A management entry declares the version; its artifact's POM is not at hand either.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
@@ -3101,6 +3165,72 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
std::fs::create_dir_all(&bom_dir).unwrap();
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${{alibaba.version}}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let core = tmp.path().join("core/pom.xml");
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
std::fs::write(
&core,
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
)
.unwrap();
let modules =
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
// A dependency on a project module opens the module's POM.
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto =
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
// The <parent> section opens the parent POM.
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
assert_eq!(goto.path, canon(&parent_path));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
+15
View File
@@ -27,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");
+15 -2
View File
@@ -455,7 +455,8 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project). `None` when nothing
/// the local repository when a parent is not in the project), else the artifact's own
/// POM (a project module's, or the local repository's). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
@@ -466,7 +467,19 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
let modules = || {
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
model
.projects
.into_iter()
.map(|p| ide_build::maven::ModulePom {
group_id: p.group_id,
artifact_id: p.artifact_id,
pom: project.root.join(&p.pom),
})
.collect()
};
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
})
.await
.map_err(join_err)?
+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");
}
+16 -1
View File
@@ -108,7 +108,9 @@ fn discover_with_limit(
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
if entry.file_type().is_ok_and(|kind| kind.is_dir())
&& crate::roots::virtual_env_marker(&entry.path()).is_none()
{
children.push(entry.path());
}
}
@@ -170,6 +172,19 @@ mod tests {
assert_eq!(result.direct[0], root);
}
#[test]
fn skips_virtual_environments_whatever_their_name() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
project(root, "code10", "pyvenv.cfg");
project(root, "code10/Lib/site-packages/pkg", "pyproject.toml");
fs::create_dir_all(root.join("conda/conda-meta")).unwrap();
project(root, "conda/pkgs/dep", "setup.py");
let app = project(root, "app", "pyproject.toml");
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
assert_eq!(result.projects.iter().map(|p| p.path.to_path_buf()).collect::<Vec<_>>(), [app]);
}
#[test]
fn finds_depth_four_but_not_five_and_retains_empty_folders() {
let tmp = tempfile::tempdir().unwrap();
+53 -1
View File
@@ -17,6 +17,19 @@ const PYTHON_FILES: &[&str] = &["pyproject.toml", "setup.py", "setup.cfg", "Pipf
const SKIP_DIRS: &[&str] =
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
/// A Python virtual environment (`python -m venv`, virtualenv, uv) or conda environment,
/// whatever it is named: its packages are dependencies, not modules of the project.
/// Returns the file that marks it.
pub(crate) fn virtual_env_marker(dir: &Path) -> Option<&'static str> {
if dir.join("pyvenv.cfg").is_file() {
Some("pyvenv.cfg")
} else if dir.join("conda-meta").is_dir() {
Some("conda-meta")
} else {
None
}
}
/// Build files whose changes can change the detected roots.
pub(crate) const BUILD_FILES: &[&str] = &[
"pom.xml",
@@ -65,7 +78,10 @@ pub fn affects_roots(rel: &Path) -> bool {
let Some(name) = names.next() else { return false };
BUILD_FILES.contains(&name)
|| ROOT_NAMES.contains(&name)
|| matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
|| matches!(
name,
"Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt" | "pyvenv.cfg" | "conda-meta"
)
|| names.next() == Some("src")
}
@@ -131,6 +147,13 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
if found.iter().any(|f| *f == child || f.is_empty() && name == "src") {
continue;
}
if let Some(marker) = virtual_env_marker(&root.join(&child)) {
if !out.iter().any(|r| r.path == child) {
let from = join(&child, marker);
out.push(SourceFolder { path: child, kind: RootKind::Excluded, detected_from: Some(from) });
}
continue;
}
queue.push_back((child, depth + 1));
}
}
@@ -505,6 +528,35 @@ mod tests {
);
}
#[test]
fn virtual_environments_are_excluded_whatever_their_name() {
// A package in the environment's site-packages has build files of its own; as a
// source root it would be put on PYTHONPATH and shadow the standard library.
let dir = tree(&[
"requirements.txt",
"app.py",
"code10/pyvenv.cfg",
"code10/Lib/site-packages/pkg/pyproject.toml",
"code10/Lib/site-packages/pkg/io/__init__.py",
"envs/conda/conda-meta/",
"envs/conda/lib/pkg/setup.py",
"svc/pyproject.toml",
"svc/env/pyvenv.cfg",
"svc/env/lib/python3.12/site-packages/dep/setup.py",
]);
assert_eq!(
summary(&detect(dir.path())),
vec![
"[email protected]",
"code10=Excluded@code10/pyvenv.cfg",
"envs/conda=Excluded@envs/conda/conda-meta",
"svc=Source@svc/pyproject.toml",
"svc/env=Excluded@svc/env/pyvenv.cfg",
]
);
assert!(affects_roots(Path::new("code10/pyvenv.cfg")));
}
#[test]
fn first_ecosystem_wins_for_a_shared_folder() {
// A Gradle build with a frontend: `build` stays Gradle's, `src` is not a Node root.
+1 -1
View File
@@ -6,7 +6,7 @@
//! project-relative and `/`-separated; `""` is the project root itself.
mod detect;
pub(crate) use detect::BUILD_FILES;
pub(crate) use detect::{BUILD_FILES, virtual_env_marker};
mod gradle;
mod java;
mod maven;
+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,
+9 -1
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. */
@@ -8113,7 +8119,9 @@ export type PomGotoKind =
/** A `<dependencyManagement>` entry providing the dependency's version. */
"managedDependency" |
/** A `<pluginManagement>` entry providing the plugin's version. */
"managedPlugin";
"managedPlugin" |
/** The POM of the artifact itself: a project module's, else the local repository's. */
"artifactPom";
/** A 0-based line / UTF-16 column position. */
export type Pos = {
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { java } from "@codemirror/lang-java";
import { history, undo } from "@codemirror/commands";
import { renderDocComments, renderedDocsExtension, renderedRanges } from "./renderedDocs";
let view: EditorView | null = null;
beforeEach(() => void vi.useFakeTimers());
afterEach(() => {
view?.destroy();
view = null;
vi.useRealTimers();
});
const DOC = ["class Rooms {", " int n;", "", " /** Books a room. */", " void book() {}", "}"].join("\n");
function open(doc = DOC, extra: ReturnType<typeof history>[] = []) {
view = new EditorView({
state: EditorState.create({
doc,
extensions: [java(), renderedDocsExtension, renderDocComments.of(true), ...extra],
}),
parent: document.body,
});
return view;
}
/** Types `text` at `at` the way a keystroke does. */
const type = (v: EditorView, at: number, text: string) =>
v.dispatch({ changes: { from: at, insert: text }, userEvent: "input.type" });
/** What a full rebuild of the same text renders, in a fresh editor state. */
function fullRebuild(doc: string): Array<[number, number]> {
return renderedRanges(
EditorState.create({ doc, extensions: [java(), renderedDocsExtension, renderDocComments.of(true)] }),
);
}
const pause = () => vi.advanceTimersByTime(300);
it("moves rendered comments along with text typed in code, and rebuilds once typing pauses", () => {
const v = open();
const [before] = renderedRanges(v.state);
// `/*` in code makes everything after it a comment: a full rebuild would drop the doc
// comment at once. Typing only moves it along until the pause.
type(v, v.state.doc.line(2).from + 2, "/*");
expect(renderedRanges(v.state)).toEqual([[before![0] + 2, before![1] + 2]]);
pause();
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("renders a pasted comment at once", () => {
const v = open();
v.dispatch({
changes: { from: v.state.doc.line(5).to + 1, insert: " /** Pasted. */\n void paste() {}\n" },
userEvent: "input.paste",
});
expect(renderedRanges(v.state)).toHaveLength(2);
});
it("brings a deleted comment back rendered at once on undo", () => {
const v = open(DOC, [history()]);
const line = v.state.doc.line(4);
v.dispatch({ changes: { from: line.from, to: line.to + 1 }, userEvent: "delete.selection" });
expect(renderedRanges(v.state)).toHaveLength(0);
undo(v);
expect(renderedRanges(v.state)).toHaveLength(1);
});
it("rebuilds at once for a keystroke on a comment's line", () => {
const v = open();
// Code after the closing marker: the comment no longer has its line to itself.
type(v, v.state.doc.line(4).to, " int x;");
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("still shows the source of a comment the caret lands in after fast typing", () => {
const v = open();
type(v, v.state.doc.line(2).from + 2, "xx");
const at = v.state.doc.toString().indexOf("Books");
v.dispatch({ selection: EditorSelection.cursor(at) });
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("after the pause, matches a full rebuild of the same text, whatever was typed", () => {
// A seeded generator, so a failure replays.
let seed = 7;
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const lines = ["class A {"];
for (let i = 0; i < 12; i++) lines.push(` /** Doc ${i}. */`, ` void m${i}() {}`, " /// line doc", "");
lines.push("}");
const v = open(lines.join("\n"));
const keys = ["/", "*", "\n", "x", " ", "{", "}"];
for (let i = 0; i < 400; i++) {
const at = Math.floor(next() * (v.state.doc.length + 1));
if (next() < 0.3 && at > 0) v.dispatch({ changes: { from: at - 1, to: at }, userEvent: "delete.backward" });
else type(v, at, keys[Math.floor(next() * keys.length)]!);
if (next() < 0.05) pause();
}
pause();
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
});
+93 -33
View File
@@ -25,8 +25,13 @@
* Off, which is the default, the icon still shows over a comment, so one comment can be
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
* comments and none is flipped, its comments are found only once typing pauses
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
* (`deferredScanner`), the Markdown parser is not loaded, and the gutter has a column
* only if the file has doc comments.
*
* On, typing costs about as little: a keystroke away from every comment's lines only
* moves the rendered comments along with the text, and the file is scanned again once
* typing pauses. Anything else that changes the text (paste, undo, a reload) rebuilds at
* once, so the comments it brings in render at once.
*/
import {
EditorState,
@@ -36,6 +41,7 @@ import {
RangeSet,
StateEffect,
StateField,
type ChangeSet,
type Extension,
type Transaction,
} from "@codemirror/state";
@@ -369,11 +375,55 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean
interface DocsView {
decorations: DecorationSet;
/** Every doc comment, rendered or not. */
/** Every doc comment, rendered or not, in document order. */
comments: ReadonlyArray<PlacedComment>;
/**
* Moved along with keystrokes since the last scan, instead of found again: what the
* parser made of them may differ, until `deferredScanner` rebuilds.
*/
stale: boolean;
}
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [], stale: false };
/** Asks for the scan a stale view is waiting for. */
const rescanDocs = StateEffect.define<null>();
/** Comments moved along with a change, as an edit elsewhere moves them; their text is unchanged. */
function mapComments(comments: ReadonlyArray<PlacedComment>, changes: ChangeSet): PlacedComment[] {
return comments.map((c) => ({
...c,
from: changes.mapPos(c.from, 1),
to: changes.mapPos(c.to, -1),
line: changes.mapPos(c.line, 1),
end: changes.mapPos(c.end, -1),
}));
}
/**
* Whether a change can skip the scan: a keystroke (typed or deleted characters) that
* touches no comment's lines. Everything else (paste, undo, formatting, a reload) brings
* comments in or out at once, so it rebuilds at once; a keystroke on a comment's line
* moves its edges. A keystroke elsewhere can still change comments (`/*` makes the rest of
* the file one), which the scan after the pause takes in.
*/
function typedOutsideComments(view: DocsView, tr: Transaction): boolean {
if (!(tr.isUserEvent("input.type") || tr.isUserEvent("delete"))) return false;
const { comments } = view;
let touches = false;
tr.changes.iterChangedRanges((fromA, toA) => {
// The first comment that does not end before the change.
let lo = 0;
let hi = comments.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (comments[mid]!.end < fromA) lo = mid + 1;
else hi = mid;
}
if (lo < comments.length && comments[lo]!.line <= toA) touches = true;
});
return !touches;
}
/**
* Rendered comments by their source, shared by every editor, least recently used first.
@@ -442,7 +492,7 @@ function build(state: EditorState): DocsView {
),
);
}
return { decorations: Decoration.set(decorations), comments };
return { decorations: Decoration.set(decorations), comments, stale: false };
}
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
@@ -476,7 +526,7 @@ const setPassive = StateEffect.define<readonly PlacedComment[]>();
/**
* The doc comments of a file that renders none, for their icons. Rescanning the file on
* each keystroke would make typing in a large file several times slower for a feature
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
* that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments
* found are mapped through the edits. Empty while the file renders comments: `docsField`
* has them then.
*/
@@ -487,32 +537,29 @@ const passiveDocs = StateField.define<readonly PlacedComment[]>({
// Switched off: the comments rendered a moment ago are known only to the last scan.
if (active(tr.startState)) return placeComments(tr.state);
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
if (!tr.docChanged) return value;
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
return value.map((c) => ({
...c,
from: map(c.from, 1),
to: map(c.to, -1),
line: map(c.line, 1),
end: map(c.end, -1),
}));
return tr.docChanged ? mapComments(value, tr.changes) : value;
},
});
/** Scans a file that renders no comments once the edits and the parser have paused. */
const passiveScanner = ViewPlugin.fromClass(
/**
* Scans the file once edits and the parser have paused: a file that renders no comments,
* for their icons; one that does, when keystrokes have only moved its comments along.
*/
const deferredScanner = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | null = null;
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
if (active(update.state)) return this.cancel();
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
}, PASSIVE_SCAN_DELAY);
}
if (!update.docChanged && syntaxTree(update.startState) === syntaxTree(update.state)) return;
// Rendering and up to date: the view was just built, nothing is waiting.
if (active(update.state) && !update.state.field(docsField).stale) return this.cancel();
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
const { state } = this.view;
if (!active(state)) this.view.dispatch({ effects: setPassive.of(placeComments(state)) });
else if (state.field(docsField).stale) this.view.dispatch({ effects: rescanDocs.of(null) });
}, RESCAN_DELAY);
}
cancel() {
if (this.timer !== null) clearTimeout(this.timer);
@@ -524,8 +571,8 @@ const passiveScanner = ViewPlugin.fromClass(
},
);
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
const PASSIVE_SCAN_DELAY = 300;
/** How long edits and parsing must pause before the file is scanned again. */
const RESCAN_DELAY = 300;
/** Every doc comment of the file, as last found. */
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
@@ -587,13 +634,26 @@ const docsField = StateField.define<DocsView>({
create: build,
update(value, tr) {
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
const rebuild =
tr.docChanged ||
if (
tr.reconfigured ||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
// The parser finishing more of the file can bring comments it had not reached.
syntaxTree(tr.startState) !== syntaxTree(tr.state);
return rebuild ? build(tr.state) : value;
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel) || e.is(rescanDocs))
)
return build(tr.state);
if (tr.docChanged) {
// A keystroke away from every comment moves them along; the scan waits for a pause.
if (active(tr.state) && typedOutsideComments(value, tr))
return {
decorations: value.decorations.map(tr.changes),
comments: mapComments(value.comments, tr.changes),
stale: true,
};
return build(tr.state);
}
// The parser finishing more of the file can bring comments it had not reached: built
// at once, so a file opens with the comments on screen rendered, unless keystrokes
// already have the scan waiting for a pause.
if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) return value.stale ? value : build(tr.state);
return value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
});
@@ -838,7 +898,7 @@ export const renderedDocsExtension: Extension = [
flipped,
docsField,
passiveDocs,
passiveScanner,
deferredScanner,
hovered,
hoverTracker,
showSourceAtCaret,
+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;
+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);
@@ -108,9 +108,11 @@ describe("POM artifact elements", () => {
expect(inArtifactElement(pom, pom.indexOf("example-lib</artifactId>"))).toBe(true);
expect(inArtifactElement(pom, pom.indexOf("<artifactId>maven-compiler-plugin"))).toBe(true);
expect(inArtifactElement(pom, pom.indexOf("${example.version}"))).toBe(true);
// Inside `<parent>`: its artifact is the parent POM.
expect(inArtifactElement(pom, pom.indexOf("<parent>") + "<parent>".length + 1)).toBe(true);
});
it("sees no artifact element outside dependencies and plugins", () => {
it("sees no artifact element outside dependencies, plugins and the parent", () => {
expect(inArtifactElement(pom, pom.indexOf("<properties>"))).toBe(false);
expect(inArtifactElement(pom, pom.indexOf("<parent>"))).toBe(false);
// `<plugins>` and `<dependencyManagement>` are not a `<plugin>` / `<dependency>`.
+6 -5
View File
@@ -4,7 +4,8 @@
* along the parent chain (a parent POM outside the project opens from the local
* repository, read-only as a plain file) — and a dependency / plugin whose version a
* `<dependencyManagement>` / `<pluginManagement>` entry provides opens that managing
* declaration. The backend resolves both with the build model's POM reader and parent
* declaration; otherwise a dependency, plugin or `<parent>` opens the artifact's own POM
* (a project module's, else the local repository's). The backend resolves both with the build model's POM reader and parent
* resolver; here only cheap eligibility is computed (a reference span, an artifact
* element), so the ⌘-hover underline stays out of the rest of the file, and nothing
* resolves to no jump at all: an unresolvable property or parent never navigates
@@ -34,12 +35,12 @@ function inside(name: string, text: string, at: number): boolean {
}
/**
* Whether `at` may sit in a `<dependency>` / `<plugin>` element: an eligibility test
* only (a `<dependency>` inside a comment counts too) — whether the element's version
* actually comes from a management entry is the backend's answer.
* Whether `at` may sit in a `<dependency>` / `<plugin>` / `<parent>` element: an
* eligibility test only (a `<dependency>` inside a comment counts too) — what the
* element resolves to is the backend's answer.
*/
export function inArtifactElement(text: string, at: number): boolean {
return inside("dependency", text, at) || inside("plugin", text, at);
return inside("dependency", text, at) || inside("plugin", text, at) || inside("parent", text, at);
}
/** Go to declaration in a POM of the project (or an opened parent POM of the local repository). */
+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
}
+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"],
+1
View File
@@ -300,6 +300,7 @@ export const vscodeMacos: Record<string, string[]> = {
"vcs.rollback": [],
"vcs.addToVcs": [],
"vcs.showDiff": [],
"vcs.log.changes.showDiff": [],
"vcs.showDiffInNewWindow": [],
"vcs.nextChange": ["Alt+F5"],
"vcs.prevChange": ["Alt+Shift+F5"],
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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 远程上没有对应页面"
}