Compare commits

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

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

No files matched your search

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