mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac7139c94d |
No files matched your search
@@ -1680,15 +1680,6 @@ pub enum PomGotoKind {
|
||||
ManagedDependency,
|
||||
/// A `<pluginManagement>` entry providing the plugin's version.
|
||||
ManagedPlugin,
|
||||
/// The POM of the artifact itself: a project module's, else the local repository's.
|
||||
ArtifactPom,
|
||||
}
|
||||
|
||||
/// A Maven project of the workspace, for going to a dependency on it.
|
||||
pub struct ModulePom {
|
||||
pub group_id: String,
|
||||
pub artifact_id: String,
|
||||
pub pom: PathBuf,
|
||||
}
|
||||
|
||||
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
|
||||
@@ -1768,20 +1759,6 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
|
||||
/// the local repository when it is not in the project). `None` when there is no
|
||||
/// resolvable declaration here.
|
||||
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
|
||||
goto_declaration_in(text, path, pos, local_repo, Vec::new)
|
||||
}
|
||||
|
||||
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
|
||||
/// management entry provides (or a management entry itself) go to the artifact's own
|
||||
/// POM: the project module among `modules` with its coordinates, else the local
|
||||
/// repository's POM of its version.
|
||||
pub fn goto_declaration_in(
|
||||
text: &str,
|
||||
path: &Path,
|
||||
pos: Pos,
|
||||
local_repo: Option<PathBuf>,
|
||||
modules: impl FnOnce() -> Vec<ModulePom>,
|
||||
) -> Option<PomGoto> {
|
||||
let root = xml::parse(text).filter(|e| e.name == "project")?;
|
||||
let at = offset_at(text, pos);
|
||||
if let Some((name, _, _)) = property_ref_at(text, at)
|
||||
@@ -1792,56 +1769,15 @@ pub fn goto_declaration_in(
|
||||
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
|
||||
// from a management entry of this POM or an ancestor's.
|
||||
let elements = element_path(&root, at);
|
||||
if elements.get(1).is_some_and(|e| e.name == "parent") {
|
||||
let chain = parent_chain(path, text, local_repo);
|
||||
let (pom, parent_text) = chain.get(1)?;
|
||||
let label = elements[1].text_of("artifactId").unwrap_or_default();
|
||||
return Some(pom_goto(pom.clone(), parent_text, label));
|
||||
}
|
||||
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
|
||||
// A management entry declares the version: it goes to the artifact (a BOM import, say).
|
||||
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
|
||||
let kind = match e.name.as_str() {
|
||||
"dependency" => PomGotoKind::ManagedDependency,
|
||||
_ => PomGotoKind::ManagedPlugin,
|
||||
};
|
||||
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
|
||||
return Some(found);
|
||||
}
|
||||
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
|
||||
return None; // Management entries are declarations; there is nothing to go to.
|
||||
}
|
||||
artifact_target(text, path, e, local_repo, modules)
|
||||
}
|
||||
|
||||
/// The artifact's own POM for a dependency or plugin element `e`.
|
||||
fn artifact_target(
|
||||
text: &str,
|
||||
path: &Path,
|
||||
e: &Element,
|
||||
local_repo: Option<PathBuf>,
|
||||
modules: impl FnOnce() -> Vec<ModulePom>,
|
||||
) -> Option<PomGoto> {
|
||||
let mut resolver = Resolver::new(local_repo.clone(), &[]);
|
||||
let chain = parent_chain(path, text, local_repo);
|
||||
let props = properties_at(&mut resolver, &chain, 0);
|
||||
let (group, artifact) = coordinates(e, &props)?;
|
||||
let label = format!("{group}:{artifact}");
|
||||
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
|
||||
let module_text = xml::read_text(&module.pom)?;
|
||||
return Some(pom_goto(module.pom, &module_text, label));
|
||||
}
|
||||
let version = interpolate(&e.text_of("version")?, &props);
|
||||
let pom = resolver.repo_pom(&group, &artifact, &version)?;
|
||||
let pom_text = xml::read_text(&pom)?;
|
||||
Some(pom_goto(pom, &pom_text, label))
|
||||
}
|
||||
|
||||
/// The POM at `path` as a target, at its `<artifactId>`.
|
||||
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
|
||||
let range = xml::parse(text)
|
||||
.filter(|e| e.name == "project")
|
||||
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
|
||||
.unwrap_or_default();
|
||||
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
|
||||
let kind = match e.name.as_str() {
|
||||
"dependency" => PomGotoKind::ManagedDependency,
|
||||
_ => PomGotoKind::ManagedPlugin,
|
||||
};
|
||||
managed_target(text, path, e, kind, local_repo)
|
||||
}
|
||||
|
||||
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
|
||||
@@ -3151,9 +3087,9 @@ mod tests {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// The property is declared nowhere along the chain.
|
||||
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
|
||||
// The dependency names its own version and no local repository has its POM.
|
||||
// The dependency names its own version: it is the declaration.
|
||||
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
|
||||
// A management entry declares the version; its artifact's POM is not at hand either.
|
||||
// A management entry is a declaration itself, not a reference.
|
||||
let parent_path = tmp.path().join("pom.xml");
|
||||
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
|
||||
assert_eq!(
|
||||
@@ -3165,72 +3101,6 @@ mod tests {
|
||||
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
|
||||
std::fs::create_dir_all(&bom_dir).unwrap();
|
||||
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
|
||||
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
|
||||
let parent = format!(
|
||||
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
|
||||
<version>${{alibaba.version}}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>"#
|
||||
);
|
||||
let child = format!(
|
||||
r#"{CHILD_HEADER}<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>"#
|
||||
);
|
||||
write_parent_child(tmp.path(), &parent, &child);
|
||||
let core = tmp.path().join("core/pom.xml");
|
||||
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&core,
|
||||
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
|
||||
)
|
||||
.unwrap();
|
||||
let modules =
|
||||
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
|
||||
|
||||
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
|
||||
let parent_path = tmp.path().join("pom.xml");
|
||||
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
|
||||
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
|
||||
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
|
||||
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
|
||||
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
|
||||
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
|
||||
|
||||
// A dependency on a project module opens the module's POM.
|
||||
let path = tmp.path().join("app/pom.xml");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let goto =
|
||||
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
|
||||
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
|
||||
|
||||
// The <parent> section opens the parent POM.
|
||||
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
|
||||
assert_eq!(goto.path, canon(&parent_path));
|
||||
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_reference_after_non_ascii_text_is_still_found() {
|
||||
let text = format!(
|
||||
|
||||
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
|
||||
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -380,14 +373,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
|
||||
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
|
||||
@@ -455,8 +455,7 @@ pub async fn maven_dependency_declaration(
|
||||
/// absolute for a parent POM opened from the local repository): the definition of the
|
||||
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
|
||||
/// entry providing the dependency's / plugin's version, along the parent chain (into
|
||||
/// the local repository when a parent is not in the project), else the artifact's own
|
||||
/// POM (a project module's, or the local repository's). `None` when nothing
|
||||
/// the local repository when a parent is not in the project). `None` when nothing
|
||||
/// resolvable is under the caret.
|
||||
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
|
||||
let project = host.project(window)?;
|
||||
@@ -467,19 +466,7 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
|
||||
let settings = project.settings.effective();
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
|
||||
.or_else(ide_build::maven::default_local_repo);
|
||||
let modules = || {
|
||||
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
|
||||
model
|
||||
.projects
|
||||
.into_iter()
|
||||
.map(|p| ide_build::maven::ModulePom {
|
||||
group_id: p.group_id,
|
||||
artifact_id: p.artifact_id,
|
||||
pom: project.root.join(&p.pom),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
|
||||
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
|
||||
@@ -108,9 +108,7 @@ fn discover_with_limit(
|
||||
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir())
|
||||
&& crate::roots::virtual_env_marker(&entry.path()).is_none()
|
||||
{
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
children.push(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -172,19 +170,6 @@ mod tests {
|
||||
assert_eq!(result.direct[0], root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_virtual_environments_whatever_their_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
project(root, "code10", "pyvenv.cfg");
|
||||
project(root, "code10/Lib/site-packages/pkg", "pyproject.toml");
|
||||
fs::create_dir_all(root.join("conda/conda-meta")).unwrap();
|
||||
project(root, "conda/pkgs/dep", "setup.py");
|
||||
let app = project(root, "app", "pyproject.toml");
|
||||
let result = discover(vec![root.into()], &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.projects.iter().map(|p| p.path.to_path_buf()).collect::<Vec<_>>(), [app]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_depth_four_but_not_five_and_retains_empty_folders() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -17,19 +17,6 @@ const PYTHON_FILES: &[&str] = &["pyproject.toml", "setup.py", "setup.cfg", "Pipf
|
||||
const SKIP_DIRS: &[&str] =
|
||||
&["node_modules", "target", "build", "dist", "out", "venv", "__pycache__", "vendor", "bower_components"];
|
||||
|
||||
/// A Python virtual environment (`python -m venv`, virtualenv, uv) or conda environment,
|
||||
/// whatever it is named: its packages are dependencies, not modules of the project.
|
||||
/// Returns the file that marks it.
|
||||
pub(crate) fn virtual_env_marker(dir: &Path) -> Option<&'static str> {
|
||||
if dir.join("pyvenv.cfg").is_file() {
|
||||
Some("pyvenv.cfg")
|
||||
} else if dir.join("conda-meta").is_dir() {
|
||||
Some("conda-meta")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build files whose changes can change the detected roots.
|
||||
pub(crate) const BUILD_FILES: &[&str] = &[
|
||||
"pom.xml",
|
||||
@@ -78,10 +65,7 @@ pub fn affects_roots(rel: &Path) -> bool {
|
||||
let Some(name) = names.next() else { return false };
|
||||
BUILD_FILES.contains(&name)
|
||||
|| ROOT_NAMES.contains(&name)
|
||||
|| matches!(
|
||||
name,
|
||||
"Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt" | "pyvenv.cfg" | "conda-meta"
|
||||
)
|
||||
|| matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
|
||||
|| names.next() == Some("src")
|
||||
}
|
||||
|
||||
@@ -147,13 +131,6 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
|
||||
if found.iter().any(|f| *f == child || f.is_empty() && name == "src") {
|
||||
continue;
|
||||
}
|
||||
if let Some(marker) = virtual_env_marker(&root.join(&child)) {
|
||||
if !out.iter().any(|r| r.path == child) {
|
||||
let from = join(&child, marker);
|
||||
out.push(SourceFolder { path: child, kind: RootKind::Excluded, detected_from: Some(from) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
queue.push_back((child, depth + 1));
|
||||
}
|
||||
}
|
||||
@@ -528,35 +505,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_environments_are_excluded_whatever_their_name() {
|
||||
// A package in the environment's site-packages has build files of its own; as a
|
||||
// source root it would be put on PYTHONPATH and shadow the standard library.
|
||||
let dir = tree(&[
|
||||
"requirements.txt",
|
||||
"app.py",
|
||||
"code10/pyvenv.cfg",
|
||||
"code10/Lib/site-packages/pkg/pyproject.toml",
|
||||
"code10/Lib/site-packages/pkg/io/__init__.py",
|
||||
"envs/conda/conda-meta/",
|
||||
"envs/conda/lib/pkg/setup.py",
|
||||
"svc/pyproject.toml",
|
||||
"svc/env/pyvenv.cfg",
|
||||
"svc/env/lib/python3.12/site-packages/dep/setup.py",
|
||||
]);
|
||||
assert_eq!(
|
||||
summary(&detect(dir.path())),
|
||||
vec![
|
||||
"[email protected]",
|
||||
"code10=Excluded@code10/pyvenv.cfg",
|
||||
"envs/conda=Excluded@envs/conda/conda-meta",
|
||||
"svc=Source@svc/pyproject.toml",
|
||||
"svc/env=Excluded@svc/env/pyvenv.cfg",
|
||||
]
|
||||
);
|
||||
assert!(affects_roots(Path::new("code10/pyvenv.cfg")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_ecosystem_wins_for_a_shared_folder() {
|
||||
// A Gradle build with a frontend: `build` stays Gradle's, `src` is not a Node root.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! project-relative and `/`-separated; `""` is the project root itself.
|
||||
|
||||
mod detect;
|
||||
pub(crate) use detect::{BUILD_FILES, virtual_env_marker};
|
||||
pub(crate) use detect::BUILD_FILES;
|
||||
mod gradle;
|
||||
mod java;
|
||||
mod maven;
|
||||
|
||||
@@ -22,37 +22,6 @@ pub async fn clipboard_write_text(app: AppHandle, window: WebviewWindow, text: S
|
||||
with_clipboard(app, move || ide_clipboard::set_text(&text, owner).map_err(ide_core::IdeError::from)).await
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
/// from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
/// text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_text(app: AppHandle) -> IdeResult<Option<String>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let sender = std::cell::Cell::new(Some(tx));
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).request_text(move |_, text| {
|
||||
if let Some(sender) = sender.take() {
|
||||
let _ = sender.send(text.map(|t| t.to_string()));
|
||||
}
|
||||
});
|
||||
})
|
||||
.map_err(|error| IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}")))?;
|
||||
Ok(rx.await.ok().flatten())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
with_clipboard(app, || Ok(ide_clipboard::text())).await
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute paths on the clipboard, empty when it holds no file list.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -187,7 +187,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
fs::fs_read_text,
|
||||
fs::fs_open_with_system,
|
||||
clipboard::clipboard_write_text,
|
||||
clipboard::clipboard_text,
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_image,
|
||||
|
||||
+1
-9
@@ -280,12 +280,6 @@ export const commands = {
|
||||
fsOpenWithSystem: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_open_with_system", { path })),
|
||||
/** Writes text locally, without WebView user-activation requirements. */
|
||||
clipboardWriteText: (text: string) => typedError<null, IdeError>(__TAURI_INVOKE("clipboard_write_text", { text })),
|
||||
/**
|
||||
* The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
* from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
* text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
*/
|
||||
clipboardText: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_text")),
|
||||
/** Absolute paths on the clipboard, empty when it holds no file list. */
|
||||
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
@@ -8119,9 +8113,7 @@ export type PomGotoKind =
|
||||
/** A `<dependencyManagement>` entry providing the dependency's version. */
|
||||
"managedDependency" |
|
||||
/** A `<pluginManagement>` entry providing the plugin's version. */
|
||||
"managedPlugin" |
|
||||
/** The POM of the artifact itself: a project module's, else the local repository's. */
|
||||
"artifactPom";
|
||||
"managedPlugin";
|
||||
|
||||
/** A 0-based line / UTF-16 column position. */
|
||||
export type Pos = {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { history, undo } from "@codemirror/commands";
|
||||
import { renderDocComments, renderedDocsExtension, renderedRanges } from "./renderedDocs";
|
||||
|
||||
let view: EditorView | null = null;
|
||||
beforeEach(() => void vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const DOC = ["class Rooms {", " int n;", "", " /** Books a room. */", " void book() {}", "}"].join("\n");
|
||||
|
||||
function open(doc = DOC, extra: ReturnType<typeof history>[] = []) {
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [java(), renderedDocsExtension, renderDocComments.of(true), ...extra],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Types `text` at `at` the way a keystroke does. */
|
||||
const type = (v: EditorView, at: number, text: string) =>
|
||||
v.dispatch({ changes: { from: at, insert: text }, userEvent: "input.type" });
|
||||
|
||||
/** What a full rebuild of the same text renders, in a fresh editor state. */
|
||||
function fullRebuild(doc: string): Array<[number, number]> {
|
||||
return renderedRanges(
|
||||
EditorState.create({ doc, extensions: [java(), renderedDocsExtension, renderDocComments.of(true)] }),
|
||||
);
|
||||
}
|
||||
|
||||
const pause = () => vi.advanceTimersByTime(300);
|
||||
|
||||
it("moves rendered comments along with text typed in code, and rebuilds once typing pauses", () => {
|
||||
const v = open();
|
||||
const [before] = renderedRanges(v.state);
|
||||
// `/*` in code makes everything after it a comment: a full rebuild would drop the doc
|
||||
// comment at once. Typing only moves it along until the pause.
|
||||
type(v, v.state.doc.line(2).from + 2, "/*");
|
||||
expect(renderedRanges(v.state)).toEqual([[before![0] + 2, before![1] + 2]]);
|
||||
pause();
|
||||
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders a pasted comment at once", () => {
|
||||
const v = open();
|
||||
v.dispatch({
|
||||
changes: { from: v.state.doc.line(5).to + 1, insert: " /** Pasted. */\n void paste() {}\n" },
|
||||
userEvent: "input.paste",
|
||||
});
|
||||
expect(renderedRanges(v.state)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("brings a deleted comment back rendered at once on undo", () => {
|
||||
const v = open(DOC, [history()]);
|
||||
const line = v.state.doc.line(4);
|
||||
v.dispatch({ changes: { from: line.from, to: line.to + 1 }, userEvent: "delete.selection" });
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
undo(v);
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rebuilds at once for a keystroke on a comment's line", () => {
|
||||
const v = open();
|
||||
// Code after the closing marker: the comment no longer has its line to itself.
|
||||
type(v, v.state.doc.line(4).to, " int x;");
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still shows the source of a comment the caret lands in after fast typing", () => {
|
||||
const v = open();
|
||||
type(v, v.state.doc.line(2).from + 2, "xx");
|
||||
const at = v.state.doc.toString().indexOf("Books");
|
||||
v.dispatch({ selection: EditorSelection.cursor(at) });
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("after the pause, matches a full rebuild of the same text, whatever was typed", () => {
|
||||
// A seeded generator, so a failure replays.
|
||||
let seed = 7;
|
||||
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
const lines = ["class A {"];
|
||||
for (let i = 0; i < 12; i++) lines.push(` /** Doc ${i}. */`, ` void m${i}() {}`, " /// line doc", "");
|
||||
lines.push("}");
|
||||
const v = open(lines.join("\n"));
|
||||
const keys = ["/", "*", "\n", "x", " ", "{", "}"];
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const at = Math.floor(next() * (v.state.doc.length + 1));
|
||||
if (next() < 0.3 && at > 0) v.dispatch({ changes: { from: at - 1, to: at }, userEvent: "delete.backward" });
|
||||
else type(v, at, keys[Math.floor(next() * keys.length)]!);
|
||||
if (next() < 0.05) pause();
|
||||
}
|
||||
pause();
|
||||
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
|
||||
});
|
||||
@@ -25,13 +25,8 @@
|
||||
* Off, which is the default, the icon still shows over a comment, so one comment can be
|
||||
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
|
||||
* comments and none is flipped, its comments are found only once typing pauses
|
||||
* (`deferredScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* only if the file has doc comments.
|
||||
*
|
||||
* On, typing costs about as little: a keystroke away from every comment's lines only
|
||||
* moves the rendered comments along with the text, and the file is scanned again once
|
||||
* typing pauses. Anything else that changes the text (paste, undo, a reload) rebuilds at
|
||||
* once, so the comments it brings in render at once.
|
||||
*/
|
||||
import {
|
||||
EditorState,
|
||||
@@ -41,7 +36,6 @@ import {
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type ChangeSet,
|
||||
type Extension,
|
||||
type Transaction,
|
||||
} from "@codemirror/state";
|
||||
@@ -375,55 +369,11 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean
|
||||
|
||||
interface DocsView {
|
||||
decorations: DecorationSet;
|
||||
/** Every doc comment, rendered or not, in document order. */
|
||||
/** Every doc comment, rendered or not. */
|
||||
comments: ReadonlyArray<PlacedComment>;
|
||||
/**
|
||||
* Moved along with keystrokes since the last scan, instead of found again: what the
|
||||
* parser made of them may differ, until `deferredScanner` rebuilds.
|
||||
*/
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [], stale: false };
|
||||
|
||||
/** Asks for the scan a stale view is waiting for. */
|
||||
const rescanDocs = StateEffect.define<null>();
|
||||
|
||||
/** Comments moved along with a change, as an edit elsewhere moves them; their text is unchanged. */
|
||||
function mapComments(comments: ReadonlyArray<PlacedComment>, changes: ChangeSet): PlacedComment[] {
|
||||
return comments.map((c) => ({
|
||||
...c,
|
||||
from: changes.mapPos(c.from, 1),
|
||||
to: changes.mapPos(c.to, -1),
|
||||
line: changes.mapPos(c.line, 1),
|
||||
end: changes.mapPos(c.end, -1),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a change can skip the scan: a keystroke (typed or deleted characters) that
|
||||
* touches no comment's lines. Everything else (paste, undo, formatting, a reload) brings
|
||||
* comments in or out at once, so it rebuilds at once; a keystroke on a comment's line
|
||||
* moves its edges. A keystroke elsewhere can still change comments (`/*` makes the rest of
|
||||
* the file one), which the scan after the pause takes in.
|
||||
*/
|
||||
function typedOutsideComments(view: DocsView, tr: Transaction): boolean {
|
||||
if (!(tr.isUserEvent("input.type") || tr.isUserEvent("delete"))) return false;
|
||||
const { comments } = view;
|
||||
let touches = false;
|
||||
tr.changes.iterChangedRanges((fromA, toA) => {
|
||||
// The first comment that does not end before the change.
|
||||
let lo = 0;
|
||||
let hi = comments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (comments[mid]!.end < fromA) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo < comments.length && comments[lo]!.line <= toA) touches = true;
|
||||
});
|
||||
return !touches;
|
||||
}
|
||||
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
|
||||
|
||||
/**
|
||||
* Rendered comments by their source, shared by every editor, least recently used first.
|
||||
@@ -492,7 +442,7 @@ function build(state: EditorState): DocsView {
|
||||
),
|
||||
);
|
||||
}
|
||||
return { decorations: Decoration.set(decorations), comments, stale: false };
|
||||
return { decorations: Decoration.set(decorations), comments };
|
||||
}
|
||||
|
||||
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
|
||||
@@ -526,7 +476,7 @@ const setPassive = StateEffect.define<readonly PlacedComment[]>();
|
||||
/**
|
||||
* The doc comments of a file that renders none, for their icons. Rescanning the file on
|
||||
* each keystroke would make typing in a large file several times slower for a feature
|
||||
* that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments
|
||||
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
|
||||
* found are mapped through the edits. Empty while the file renders comments: `docsField`
|
||||
* has them then.
|
||||
*/
|
||||
@@ -537,29 +487,32 @@ const passiveDocs = StateField.define<readonly PlacedComment[]>({
|
||||
// Switched off: the comments rendered a moment ago are known only to the last scan.
|
||||
if (active(tr.startState)) return placeComments(tr.state);
|
||||
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
|
||||
return tr.docChanged ? mapComments(value, tr.changes) : value;
|
||||
if (!tr.docChanged) return value;
|
||||
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
|
||||
return value.map((c) => ({
|
||||
...c,
|
||||
from: map(c.from, 1),
|
||||
to: map(c.to, -1),
|
||||
line: map(c.line, 1),
|
||||
end: map(c.end, -1),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Scans the file once edits and the parser have paused: a file that renders no comments,
|
||||
* for their icons; one that does, when keystrokes have only moved its comments along.
|
||||
*/
|
||||
const deferredScanner = ViewPlugin.fromClass(
|
||||
/** Scans a file that renders no comments once the edits and the parser have paused. */
|
||||
const passiveScanner = ViewPlugin.fromClass(
|
||||
class {
|
||||
timer: ReturnType<typeof setTimeout> | null = null;
|
||||
constructor(readonly view: EditorView) {}
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged && syntaxTree(update.startState) === syntaxTree(update.state)) return;
|
||||
// Rendering and up to date: the view was just built, nothing is waiting.
|
||||
if (active(update.state) && !update.state.field(docsField).stale) return this.cancel();
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const { state } = this.view;
|
||||
if (!active(state)) this.view.dispatch({ effects: setPassive.of(placeComments(state)) });
|
||||
else if (state.field(docsField).stale) this.view.dispatch({ effects: rescanDocs.of(null) });
|
||||
}, RESCAN_DELAY);
|
||||
if (active(update.state)) return this.cancel();
|
||||
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
|
||||
}, PASSIVE_SCAN_DELAY);
|
||||
}
|
||||
}
|
||||
cancel() {
|
||||
if (this.timer !== null) clearTimeout(this.timer);
|
||||
@@ -571,8 +524,8 @@ const deferredScanner = ViewPlugin.fromClass(
|
||||
},
|
||||
);
|
||||
|
||||
/** How long edits and parsing must pause before the file is scanned again. */
|
||||
const RESCAN_DELAY = 300;
|
||||
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
|
||||
const PASSIVE_SCAN_DELAY = 300;
|
||||
|
||||
/** Every doc comment of the file, as last found. */
|
||||
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
|
||||
@@ -634,26 +587,13 @@ const docsField = StateField.define<DocsView>({
|
||||
create: build,
|
||||
update(value, tr) {
|
||||
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
|
||||
if (
|
||||
const rebuild =
|
||||
tr.docChanged ||
|
||||
tr.reconfigured ||
|
||||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel) || e.is(rescanDocs))
|
||||
)
|
||||
return build(tr.state);
|
||||
if (tr.docChanged) {
|
||||
// A keystroke away from every comment moves them along; the scan waits for a pause.
|
||||
if (active(tr.state) && typedOutsideComments(value, tr))
|
||||
return {
|
||||
decorations: value.decorations.map(tr.changes),
|
||||
comments: mapComments(value.comments, tr.changes),
|
||||
stale: true,
|
||||
};
|
||||
return build(tr.state);
|
||||
}
|
||||
// The parser finishing more of the file can bring comments it had not reached: built
|
||||
// at once, so a file opens with the comments on screen rendered, unless keystrokes
|
||||
// already have the scan waiting for a pause.
|
||||
if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) return value.stale ? value : build(tr.state);
|
||||
return value;
|
||||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
|
||||
// The parser finishing more of the file can bring comments it had not reached.
|
||||
syntaxTree(tr.startState) !== syntaxTree(tr.state);
|
||||
return rebuild ? build(tr.state) : value;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
|
||||
});
|
||||
@@ -898,7 +838,7 @@ export const renderedDocsExtension: Extension = [
|
||||
flipped,
|
||||
docsField,
|
||||
passiveDocs,
|
||||
deferredScanner,
|
||||
passiveScanner,
|
||||
hovered,
|
||||
hoverTracker,
|
||||
showSourceAtCaret,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Edit › Paste from History and Code › Unwrap/Remove: the parts that need popups or the backend. */
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { structure } from "@/editor/structure";
|
||||
@@ -103,7 +103,7 @@ export async function pasteAsPlainText(view: EditorView) {
|
||||
if (view.state.readOnly) return;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readClipboardPlainText();
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
hint("Cannot read the clipboard");
|
||||
return;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { quitApp } from "./closeGuard";
|
||||
import { guessOs } from "./host";
|
||||
import { showKeymapReference } from "./KeymapReference";
|
||||
import { checkForUpdates } from "./updates";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
@@ -51,8 +50,8 @@ export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste"
|
||||
if (focused instanceof Element && runUndoHandlers(command, focused)) return;
|
||||
}
|
||||
if (command === "selectAll" && selectReadingPane(document.activeElement)) return;
|
||||
if (command === "paste") {
|
||||
void readClipboardPlainText().then((text) => document.execCommand("insertText", false, text));
|
||||
if (command === "paste" && navigator.clipboard?.readText) {
|
||||
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
|
||||
return;
|
||||
}
|
||||
document.execCommand(command);
|
||||
|
||||
@@ -108,11 +108,9 @@ describe("POM artifact elements", () => {
|
||||
expect(inArtifactElement(pom, pom.indexOf("example-lib</artifactId>"))).toBe(true);
|
||||
expect(inArtifactElement(pom, pom.indexOf("<artifactId>maven-compiler-plugin"))).toBe(true);
|
||||
expect(inArtifactElement(pom, pom.indexOf("${example.version}"))).toBe(true);
|
||||
// Inside `<parent>`: its artifact is the parent POM.
|
||||
expect(inArtifactElement(pom, pom.indexOf("<parent>") + "<parent>".length + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("sees no artifact element outside dependencies, plugins and the parent", () => {
|
||||
it("sees no artifact element outside dependencies and plugins", () => {
|
||||
expect(inArtifactElement(pom, pom.indexOf("<properties>"))).toBe(false);
|
||||
expect(inArtifactElement(pom, pom.indexOf("<parent>"))).toBe(false);
|
||||
// `<plugins>` and `<dependencyManagement>` are not a `<plugin>` / `<dependency>`.
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* along the parent chain (a parent POM outside the project opens from the local
|
||||
* repository, read-only as a plain file) — and a dependency / plugin whose version a
|
||||
* `<dependencyManagement>` / `<pluginManagement>` entry provides opens that managing
|
||||
* declaration; otherwise a dependency, plugin or `<parent>` opens the artifact's own POM
|
||||
* (a project module's, else the local repository's). The backend resolves both with the build model's POM reader and parent
|
||||
* declaration. The backend resolves both with the build model's POM reader and parent
|
||||
* resolver; here only cheap eligibility is computed (a reference span, an artifact
|
||||
* element), so the ⌘-hover underline stays out of the rest of the file, and nothing
|
||||
* resolves to no jump at all: an unresolvable property or parent never navigates
|
||||
@@ -35,12 +34,12 @@ function inside(name: string, text: string, at: number): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `at` may sit in a `<dependency>` / `<plugin>` / `<parent>` element: an
|
||||
* eligibility test only (a `<dependency>` inside a comment counts too) — what the
|
||||
* element resolves to is the backend's answer.
|
||||
* Whether `at` may sit in a `<dependency>` / `<plugin>` element: an eligibility test
|
||||
* only (a `<dependency>` inside a comment counts too) — whether the element's version
|
||||
* actually comes from a management entry is the backend's answer.
|
||||
*/
|
||||
export function inArtifactElement(text: string, at: number): boolean {
|
||||
return inside("dependency", text, at) || inside("plugin", text, at) || inside("parent", text, at);
|
||||
return inside("dependency", text, at) || inside("plugin", text, at);
|
||||
}
|
||||
|
||||
/** Go to declaration in a POM of the project (or an opened parent POM of the local repository). */
|
||||
|
||||
@@ -6,7 +6,6 @@ import { VENDORS, vendorPreset } from "./vendors";
|
||||
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
|
||||
import { SCRIPT_LABELS, canDrop, canModifyTable, canRename, type ScriptKind } from "./objects";
|
||||
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
const act = (
|
||||
action: string,
|
||||
@@ -49,7 +48,7 @@ function dataSourceItems(): MenuItem[] {
|
||||
async function sourceFromClipboard() {
|
||||
let text = "";
|
||||
try {
|
||||
text = await readClipboardPlainText();
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
// Unreadable clipboard: reported below like an unknown one.
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { commands, isTauri, type RunConfig } from "@/platform/ipc";
|
||||
@@ -94,7 +94,7 @@ export function EnvDialog({
|
||||
};
|
||||
const paste = async () => {
|
||||
try {
|
||||
const text = await readClipboardPlainText();
|
||||
const text = await navigator.clipboard.readText();
|
||||
setRows((r) => pasteRows(r, text));
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, isTauri, type RunConfig, type TomcatInstallation, type WebArtifact } from "@/platform/ipc";
|
||||
@@ -899,7 +899,7 @@ export function TomcatSettings({ config, onChange }: { config: RunConfig; onChan
|
||||
iconSize={13}
|
||||
title="Paste"
|
||||
onClick={() => {
|
||||
void readClipboardPlainText().then((text) => {
|
||||
void navigator.clipboard?.readText().then((text) => {
|
||||
if (!text.trim()) return;
|
||||
const idx = text.indexOf("=");
|
||||
const name = idx >= 0 ? text.slice(0, idx).trim() : text.trim();
|
||||
|
||||
@@ -16,7 +16,6 @@ import { findOutputLinks } from "./links";
|
||||
import { useRun } from "./store";
|
||||
import styles from "./StackTrace.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
export interface StackTraceTab {
|
||||
id: string;
|
||||
@@ -84,7 +83,7 @@ export function closeStackTrace(id: string) {
|
||||
async function openDialog() {
|
||||
let clip = "";
|
||||
try {
|
||||
clip = await readClipboardPlainText();
|
||||
clip = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
// clipboard unavailable or denied: start empty
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ import { usePostfixSettings } from "./postfix/store";
|
||||
import { showChooser, type ChooserItem } from "./popup/store";
|
||||
import { expandEmmetAtCaret } from "./emmet/editor";
|
||||
import { hasEmmet, loadEmmet } from "./emmet/model";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
export interface TemplateDocInfo {
|
||||
path: string;
|
||||
@@ -83,7 +82,7 @@ function userName(): string {
|
||||
|
||||
async function readClipboard(): Promise<string> {
|
||||
try {
|
||||
return await readClipboardPlainText();
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -142,27 +142,6 @@ describe("ownTerminalInput", () => {
|
||||
expect(reached).toEqual(["keydown", "insertText"]);
|
||||
});
|
||||
|
||||
it("sends a shifted character an IME commits before its keydown", () => {
|
||||
// WeChat / Pinyin on macOS, Shift+/ for `?`: insertText arrives while only Shift is down.
|
||||
// Its keydown armed xterm's gate, which then drops the first press.
|
||||
const { textarea, sent, reached } = setup();
|
||||
keydown(textarea, { key: "Shift", keyCode: 16 });
|
||||
input(textarea, { inputType: "insertText", data: "?" });
|
||||
keydown(textarea, { key: "?", keyCode: 229 });
|
||||
keyup(textarea, 191);
|
||||
expect(sent).toEqual(["?"]);
|
||||
expect(reached).toEqual(["keydown"]);
|
||||
});
|
||||
|
||||
it("leaves shifted keys xterm sent itself to xterm", () => {
|
||||
const { textarea, sent, reached } = setup();
|
||||
keydown(textarea, { key: "Shift", keyCode: 16 });
|
||||
keydown(textarea, { key: "A", keyCode: 65 });
|
||||
input(textarea, { inputType: "insertText", data: "A" });
|
||||
expect(sent).toEqual([]);
|
||||
expect(reached).toEqual(["keydown", "keydown", "insertText"]);
|
||||
});
|
||||
|
||||
it("leaves input with no keydown behind it to xterm", () => {
|
||||
// Dictation, an on-screen keyboard or the emoji picker.
|
||||
const { textarea, sent, reached } = setup();
|
||||
@@ -280,25 +259,6 @@ describe("ownTerminalInput with xterm", () => {
|
||||
return sent;
|
||||
}
|
||||
|
||||
it("sends `?` on the first and the second press of Shift+/ with a direct-commit IME", async () => {
|
||||
const sent = await replay([
|
||||
["keydown", "Shift", 16],
|
||||
["input", "insertText", "?", "?"],
|
||||
["keydown", "?", 229],
|
||||
["keyup", "?", 191],
|
||||
["input", "insertText", "?", "??"],
|
||||
["keydown", "?", 229],
|
||||
["keyup", "?", 191],
|
||||
["keyup", "Shift", 16],
|
||||
["keydown", "Shift", 16],
|
||||
["keydown", "A", 65],
|
||||
["input", "insertText", "A", "??A"],
|
||||
["keyup", "A", 65],
|
||||
["keyup", "Shift", 16],
|
||||
]);
|
||||
expect(sent).toEqual(["?", "?", "A"]);
|
||||
});
|
||||
|
||||
it.each([true, false])("sends a retyped word and following plain keys once (keyup first: %s)", async (release) => {
|
||||
// Uppercase typing reaches both keypress and input; lowercase keydown is cancelled by xterm.
|
||||
const sent = await replay([
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* - a keyCode-229 keydown never reaches xterm, so its deferred textarea diff cannot run;
|
||||
* - while such a keydown is pending, an `insertText` is sent from the event's own `data`
|
||||
* (not from a value diff), then the textarea is cleared;
|
||||
* - composition input that arrives without a `compositionstart` is sent the same way;
|
||||
* - so is an `insertText` while only a modifier is down: an IME that commits a shifted
|
||||
* character directly (`?` from Shift+/ with WeChat or Pinyin) delivers it before the
|
||||
* character's keydown, and the Shift keydown already armed xterm's gate, so the first press
|
||||
* was dropped.
|
||||
* - composition input that arrives without a `compositionstart` is sent the same way.
|
||||
*
|
||||
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
|
||||
* input events for keys xterm already sent, and input with no IME keydown behind it.
|
||||
@@ -32,8 +28,6 @@
|
||||
*/
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
|
||||
const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta"]);
|
||||
|
||||
/** Commit-shaped input events that xterm's own input path ignores entirely. */
|
||||
const COMPOSITION_COMMITS = new Set(["insertFromComposition", "insertCompositionText"]);
|
||||
|
||||
@@ -68,9 +62,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
let imeKey = false;
|
||||
/** The pending key belongs to an IME: its commit is ours to deliver. */
|
||||
let imeDown = false;
|
||||
/** Since the last keyup: a modifier went down, and any other key went down. */
|
||||
let modifierDown = false;
|
||||
let keyDown = false;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (composing) {
|
||||
@@ -78,8 +69,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
if (e.keyCode === 0) e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (MODIFIERS.has(e.key)) modifierDown = true;
|
||||
else keyDown = true;
|
||||
imeKey = isImeKey(e);
|
||||
imeDown = imeKey;
|
||||
if (imeKey) {
|
||||
@@ -101,8 +90,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
const onKeyUp = () => {
|
||||
imeKey = false;
|
||||
imeDown = false;
|
||||
modifierDown = false;
|
||||
keyDown = false;
|
||||
};
|
||||
|
||||
const onInput = (e: InputEvent) => {
|
||||
@@ -110,8 +97,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
if (e.inputType === "insertText") {
|
||||
// Without an IME keydown behind it this is xterm's: it drops the event for a key it
|
||||
// already sent (Option/AltGr, plain typing) and delivers the rest (dictation, emoji).
|
||||
// With only a modifier down, no key of xterm's is behind it: xterm would drop it too.
|
||||
if (!imeDown && !(modifierDown && !keyDown)) return;
|
||||
if (!imeDown) return;
|
||||
} else if (!COMPOSITION_COMMITS.has(e.inputType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste";
|
||||
|
||||
const notify = vi.hoisted(() => vi.fn());
|
||||
@@ -526,28 +525,3 @@ it("recovers quoted local file paths when clipboard text permission is denied",
|
||||
expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// WebKitGTK pastes nothing from a menu through execCommand or readText: native text first.
|
||||
{ os: "linux", native: "ls -la", expected: ["ls -la"], execCommand: 0 },
|
||||
// An image-only clipboard keeps the WebView paths.
|
||||
{ os: "linux", native: "", expected: [], execCommand: 1 },
|
||||
{ os: "macos", native: "ls -la", expected: ["ls -la"], execCommand: 1 },
|
||||
] as const)("menu paste on $os with native text '$native'", async ({ os, native, expected, execCommand }) => {
|
||||
usePlatform.setState({ os });
|
||||
vi.spyOn(commands, "clipboardText").mockResolvedValue({ status: "ok", data: native });
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "web" } });
|
||||
const exec = vi.fn(() => false);
|
||||
Object.defineProperty(document, "queryCommandSupported", { configurable: true, value: () => true });
|
||||
Object.defineProperty(document, "execCommand", { configurable: true, value: exec });
|
||||
const paste = vi.fn();
|
||||
try {
|
||||
await pasteTerminalClipboard(paste, () => localTarget());
|
||||
} finally {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
Reflect.deleteProperty(document, "queryCommandSupported");
|
||||
Reflect.deleteProperty(document, "execCommand");
|
||||
}
|
||||
expect(exec).toHaveBeenCalledTimes(execCommand);
|
||||
expect(paste.mock.calls).toEqual(expected.map((text) => [text]));
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pasteClipboardFiles, readClipboardPlainText, readClipboardText } from "@/platform/clipboard";
|
||||
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
|
||||
import { call, commands, type TerminalId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export interface TerminalPasteTarget {
|
||||
@@ -121,15 +120,6 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) =>
|
||||
export async function pasteTerminalClipboard(paste: (text: string) => void, getTarget?: Target) {
|
||||
const target = getTarget?.();
|
||||
try {
|
||||
// WebKitGTK pastes nothing from a menu through either WebView path; the native
|
||||
// clipboard has the text. An image-only clipboard still takes the paths below.
|
||||
if (target && usePlatform.getState().os === "linux") {
|
||||
const text = await readClipboardPlainText().catch(() => "");
|
||||
if (text) {
|
||||
if (target.current()) paste(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Native paste preserves clipboard blobs where Clipboard.read() is unavailable.
|
||||
if (target && document.queryCommandSupported?.("paste") && document.execCommand("paste")) return;
|
||||
const images =
|
||||
@@ -140,7 +130,7 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
|
||||
)
|
||||
: null;
|
||||
const text = target?.remote
|
||||
? await readClipboardPlainText().catch(() => "")
|
||||
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
|
||||
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
|
||||
if (target && !target.current()) return;
|
||||
if (text) {
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { Branch, ChangedFile, Commit } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { maybeEditorService, useGitLogChecks, type EditorInput } from "@/workbench/contributions";
|
||||
import {
|
||||
EmptyState,
|
||||
@@ -2628,7 +2627,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
useEffect(() => () => setContext("vcsLogChangesFocus", false), []);
|
||||
|
||||
if (!commit)
|
||||
return (
|
||||
@@ -2669,13 +2667,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
const details = (
|
||||
<SplitGroup orientation="vertical" id="git-log-details">
|
||||
<SplitPanel id="files" defaultSize="60%" minSize="60px">
|
||||
<div
|
||||
className={styles.filesPane}
|
||||
onFocusCapture={() => setContext("vcsLogChangesFocus", true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("vcsLogChangesFocus", false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.filesPane}>
|
||||
<div className={styles.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
|
||||
<IconButton
|
||||
icon="diff"
|
||||
|
||||
@@ -62,7 +62,6 @@ import { refreshNow } from "./store";
|
||||
import styles from "./MergeEditor.module.css";
|
||||
|
||||
import type { MergeData } from "./mergeKind";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind";
|
||||
|
||||
const NOTHING_LEFT: MergeSummary = {
|
||||
@@ -498,7 +497,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (id === "selectAll") view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
else if (id === "paste") {
|
||||
const text = await readClipboardPlainText().catch(() => undefined);
|
||||
const text = await navigator.clipboard?.readText().catch(() => undefined);
|
||||
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
|
||||
} else {
|
||||
await copyText(view.state.sliceDoc(from, to));
|
||||
|
||||
@@ -12,8 +12,7 @@ vi.mock("@/platform/ipc", async (original) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { loadLogDetails, useCommitDetails } from "../logDetails";
|
||||
import { loadLogDetails } from "../logDetails";
|
||||
import { useVcs } from "../store";
|
||||
import { useGitLog } from "../logStore";
|
||||
const commit = (id: string): Commit => ({
|
||||
@@ -93,38 +92,3 @@ describe("commit detail jobs", () => {
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCommitDetails", () => {
|
||||
it("keeps the shown details while a repository refresh reloads them", async () => {
|
||||
const c = commit("kept-on-refresh");
|
||||
const { result, rerender } = renderHook(({ c }) => useCommitDetails(c), { initialProps: { c } });
|
||||
await vi.waitFor(() => expect(result.current.details?.message).toBe("kept-on-refresh"));
|
||||
let finish!: () => void;
|
||||
api.batch.mockImplementationOnce(
|
||||
(_repo: string, ids: string[]) =>
|
||||
new Promise((resolve) => {
|
||||
finish = () =>
|
||||
resolve({
|
||||
status: "ok",
|
||||
data: ids.map(() => ({
|
||||
message: "reloaded",
|
||||
containingBranches: ["main"],
|
||||
signature: "N",
|
||||
signer: "",
|
||||
key: "",
|
||||
fingerprint: "",
|
||||
})),
|
||||
});
|
||||
}),
|
||||
);
|
||||
act(() => useVcs.setState((s) => ({ version: s.version + 1 })));
|
||||
await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.details?.message).toBe("kept-on-refresh");
|
||||
await act(async () => finish());
|
||||
await vi.waitFor(() => expect(result.current.details?.message).toBe("reloaded"));
|
||||
// Another commit shows nothing until its own details arrive.
|
||||
api.batch.mockImplementationOnce(() => new Promise(() => {}));
|
||||
rerender({ c: commit("another") });
|
||||
expect(result.current.details).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -373,8 +373,7 @@ export function registerVcsActions(): () => void {
|
||||
icon: "diff",
|
||||
when: VCS,
|
||||
// ⌘D duplicates the line inside a text editor; Git › Show Diff still works there.
|
||||
// In the Git Log's changed files it shows the commit's diff instead.
|
||||
keyWhen: "!editorTextFocus && !vcsLogChangesFocus",
|
||||
keyWhen: "!editorTextFocus",
|
||||
run: async ({ arg, ctx }) => {
|
||||
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
|
||||
const line =
|
||||
|
||||
@@ -14,7 +14,6 @@ export { compareFilesData, displayPath } from "./compareData";
|
||||
import { openDiff, openVcsDiff, type DiffData } from "./diffInput";
|
||||
import { baseName } from "./model";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
function argPath(arg: unknown): string | undefined {
|
||||
if (typeof arg === "string") return arg;
|
||||
@@ -286,7 +285,7 @@ export function registerCompareActions(): () => void {
|
||||
if (!path) return info("Open or select a file to compare with the clipboard");
|
||||
let clipboard: string;
|
||||
try {
|
||||
clipboard = await readClipboardPlainText();
|
||||
clipboard = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
return info("The clipboard could not be read");
|
||||
}
|
||||
|
||||
@@ -109,8 +109,7 @@ function FrontWindow({ title, onClose, children }: { title: string; onClose: ()
|
||||
<div
|
||||
ref={body}
|
||||
className="vcsDiffDialogBody"
|
||||
// The dialog body is not a flex container: fill it so the editors reach the footer.
|
||||
style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -126,28 +126,20 @@ export function useCommitDetails(commit: Commit, enabled = true) {
|
||||
const version = useVcs((s) => s.version);
|
||||
const repo = commitRepo(commit) ?? currentRepo();
|
||||
const refs = refVersions((s) => s[repo ?? ""] ?? 0);
|
||||
const target = `${repo}\0${commit.id}`;
|
||||
const key = `${target}\0${version}\0${refs}`;
|
||||
const [result, setResult] = useState<{
|
||||
target: string;
|
||||
details: LogDetails | null;
|
||||
error: boolean;
|
||||
} | null>(null);
|
||||
const key = `${repo}\0${commit.id}\0${version}\0${refs}`;
|
||||
const [result, setResult] = useState<{ key: string; details: LogDetails | null; error: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
void loadLogDetails(commit, controller.signal)
|
||||
.then((details) => {
|
||||
if (!controller.signal.aborted) setResult({ target, details, error: false });
|
||||
if (!controller.signal.aborted) setResult({ key, details, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setResult({ target, details: null, error: true });
|
||||
if (!controller.signal.aborted) setResult({ key, details: null, error: true });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, enabled]);
|
||||
// A repository refresh reloads the same commit's details: keep showing the last ones until
|
||||
// the new ones arrive, or the section blinks on every refresh.
|
||||
const current = result?.target === target ? result : null;
|
||||
return { details: current?.details ?? null, error: !!current?.error };
|
||||
return { details: result?.key === key ? result.details : null, error: result?.key === key && result.error };
|
||||
}
|
||||
@@ -152,7 +152,6 @@ export function registerLogPaneActions(): () => void {
|
||||
icon: "diff",
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
keyWhen: "vcsLogChangesFocus",
|
||||
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { commitRepo } from "./multiRoot";
|
||||
/** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -224,7 +224,7 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string)
|
||||
let source = "Clipboard";
|
||||
try {
|
||||
if (from === "clipboard") {
|
||||
text = await readClipboardPlainText();
|
||||
text = await navigator.clipboard.readText();
|
||||
} else {
|
||||
let file = path ?? null;
|
||||
if (!file) {
|
||||
|
||||
@@ -7,19 +7,6 @@ export async function writeClipboardText(text: string): Promise<void> {
|
||||
else await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The clipboard's plain text, read natively where the backend can (macOS, Linux), else
|
||||
* through the WebView. A Paste from a menu has no paste event to read, and WebKitGTK's
|
||||
* `navigator.clipboard.readText()` gives no text there. Rejects when the WebView refuses.
|
||||
*/
|
||||
export async function readClipboardPlainText(): Promise<string> {
|
||||
if (isTauri()) {
|
||||
const text = await call(commands.clipboardText()).catch(() => null);
|
||||
if (text != null) return text;
|
||||
}
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
}
|
||||
|
||||
type FormatFiles = (paths: string[]) => string;
|
||||
const rawPaths: FormatFiles = (paths) => paths.join("\n");
|
||||
|
||||
@@ -28,7 +15,9 @@ export async function readClipboardText(
|
||||
transfer?: DataTransfer | null,
|
||||
formatFiles: FormatFiles = rawPaths,
|
||||
): Promise<string> {
|
||||
const text = transfer ? transfer.getData("text/plain") : await readClipboardPlainText().catch(() => "");
|
||||
const text = transfer
|
||||
? transfer.getData("text/plain")
|
||||
: ((await navigator.clipboard?.readText().catch(() => "")) ?? "");
|
||||
if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text;
|
||||
let paths: string[];
|
||||
try {
|
||||
|
||||
@@ -50,7 +50,7 @@ const shared = [
|
||||
["code.quickDocumentation", "db.quickDoc", "debug.setValue", "vcs.changelist.edit"],
|
||||
["code.reformat", "project.reformatCode", "db.reformatValue"],
|
||||
["code.optimizeImports", "project.optimizeImports"],
|
||||
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff", "vcs.log.changes.showDiff"],
|
||||
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff"],
|
||||
["code.moveStatementDown", "find.field.recentSearches", "find.popup.recentSearches"],
|
||||
["code.foldAll", "project.collapseAll"],
|
||||
["refactor.rename", "project.rename", "db.rename", "vcs.changelist.edit", "vcs.log.editMessage", "vcs.shelf.rename"],
|
||||
|
||||
@@ -303,7 +303,6 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"vcs.rollback": ["Meta+Alt+Z"],
|
||||
"vcs.addToVcs": ["Meta+Alt+A"],
|
||||
"vcs.showDiff": ["Meta+D"],
|
||||
"vcs.log.changes.showDiff": ["Meta+D"],
|
||||
"vcs.showDiffInNewWindow": [],
|
||||
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
|
||||
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
|
||||
|
||||
@@ -297,7 +297,6 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"vcs.rollback": ["Ctrl+Alt+Z"],
|
||||
"vcs.addToVcs": ["Ctrl+Alt+A"],
|
||||
"vcs.showDiff": ["Ctrl+D"],
|
||||
"vcs.log.changes.showDiff": ["Ctrl+D"],
|
||||
"vcs.showDiffInNewWindow": [],
|
||||
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
|
||||
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
|
||||
|
||||
Reference in new issue
Block a user