Merge remote-tracking branch 'origin/main' into fix/flaky-lsp-tests

# Conflicts:
#	crates/ide-jsonrpc/src/lib.rs
This commit is contained in:
l0ng-ai
2026-10-27 22:13:35 +08:00
9 changed files with 311 additions and 96 deletions
+22 -3
View File
@@ -7,7 +7,8 @@ on:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# A newer push supersedes the run in progress, on main too.
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -29,7 +30,10 @@ jobs:
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { components: "clippy, rustfmt" }
# Only main saves the cache (saving takes minutes on Windows); PRs restore main's.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@nextest
- name: Linux deps
if: runner.os == 'Linux'
@@ -45,9 +49,10 @@ jobs:
# Test fixtures write LF files; keep Git from converting them on checkout.
- run: git config --global core.autocrlf false
- run: cargo nextest run --workspace --no-fail-fast
# nextest ran export_bindings, which rewrites src/bindings.ts.
- name: Bindings are up to date
if: runner.os == 'Linux'
run: pnpm gen:bindings && git diff --exit-code src/bindings.ts
run: git diff --exit-code src/bindings.ts
frontend:
name: Frontend
@@ -63,8 +68,22 @@ jobs:
- run: pnpm lint
- run: pnpm test
- run: pnpm build
- run: pnpm exec playwright install --with-deps chromium
e2e:
name: E2E
runs-on: ubuntu-24.04
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
container: mcr.microsoft.com/playwright:v1.64.1-noble
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm e2e
env:
HOME: /root
- uses: actions/upload-artifact@v4
if: failure()
with:
Generated
+30 -30
View File
@@ -3962,7 +3962,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -3980,7 +3980,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -3994,7 +3994,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4009,11 +4009,11 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.17"
version = "0.1.18"
[[package]]
name = "ide-core"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dunce",
"serde",
@@ -4025,7 +4025,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-run",
@@ -4039,7 +4039,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4056,7 +4056,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dirs 6.0.0",
"futures",
@@ -4076,7 +4076,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"libc",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4104,7 +4104,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"parking_lot",
@@ -4122,7 +4122,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"blake3",
"criterion",
@@ -4144,7 +4144,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"encoding_rs",
"futures",
@@ -4160,7 +4160,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"blake3",
"criterion",
@@ -4213,7 +4213,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"futures",
"globset",
@@ -4236,7 +4236,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-language",
@@ -4254,7 +4254,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-run",
@@ -4267,7 +4267,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"encoding_rs",
"ide-core",
@@ -4287,7 +4287,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dunce",
"ide-core",
@@ -4306,7 +4306,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"criterion",
"globset",
@@ -4326,7 +4326,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"parking_lot",
@@ -4339,7 +4339,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-language",
@@ -4354,7 +4354,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"libc",
@@ -4367,7 +4367,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"criterion",
"ide-core",
@@ -4379,7 +4379,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"flate2",
"hex",
@@ -4399,7 +4399,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dunce",
"gix",
@@ -4416,7 +4416,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"chardetng",
"dunce",
@@ -4437,7 +4437,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"open",
@@ -4453,7 +4453,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"blake3",
"dunce",
@@ -5878,7 +5878,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dirs 6.0.0",
"dunce",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.17"
version = "0.1.18"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+8
View File
@@ -191,6 +191,8 @@ struct Task {
dirty: Mutex<BTreeSet<usize>>,
flush_scheduled: AtomicBool,
stderr: Mutex<VecDeque<String>>,
/// Set by `delete`; held while saving, so a late save can't bring the file back.
deleted: Mutex<bool>,
}
impl AgentManager {
@@ -519,6 +521,7 @@ impl AgentManager {
blocking(move || worktree::remove(&root, &cwd)).await?;
}
self.tasks.write().remove(id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
Ok(())
@@ -568,6 +571,7 @@ impl Task {
dirty: Mutex::new(BTreeSet::new()),
flush_scheduled: AtomicBool::new(false),
stderr: Mutex::new(VecDeque::new()),
deleted: Mutex::new(false),
})
}
@@ -604,6 +608,10 @@ impl Task {
})
};
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
let deleted = self.deleted.lock();
if *deleted {
return;
}
if let Err(e) = bytes.map_err(|e| e.to_string()).and_then(|b| write_file(&path, &b)) {
tracing::warn!("saving agent task {}: {e}", self.id);
}
+43 -46
View File
@@ -222,13 +222,6 @@ impl Handler for NoopHandler {
}
}
/// Work for the in-order notification thread.
enum Inbound {
Notification(String, Value),
/// A request may start: the notifications before it are handled.
Release(oneshot::Sender<()>),
}
type Pending = HashMap<i64, oneshot::Sender<Result<Value, RpcError>>>;
struct Inner {
@@ -508,27 +501,30 @@ impl Peer {
});
}
// Notifications are delivered in order on a dedicated thread.
let (notif_tx, notif_rx) = std::sync::mpsc::channel::<Inbound>();
// Notifications are delivered in order on a dedicated thread. Requests start from the same
// queue, so a request handler sees every notification that arrived before it.
let (notif_tx, notif_rx) = std::sync::mpsc::channel::<Incoming>();
{
let handler = handler.clone();
let inner = inner.clone();
let runtime = tokio::runtime::Handle::current();
std::thread::Builder::new()
.name("jsonrpc-notify".into())
.spawn(move || {
while let Ok(inbound) = notif_rx.recv() {
let (method, params) = match inbound {
Inbound::Notification(method, params) => (method, params),
Inbound::Release(start) => {
let _ = start.send(());
continue;
while let Ok(msg) = notif_rx.recv() {
match msg {
Incoming::Notification(method, params) => {
let h = handler.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
h.on_notification(&method, params)
}));
if result.is_err() {
tracing::error!("jsonrpc notification handler panicked on {method}");
}
}
Incoming::Request(id, method, params) => {
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params));
}
};
let h = handler.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
h.on_notification(&method, params)
}));
if result.is_err() {
tracing::error!("jsonrpc notification handler panicked on {method}");
}
}
})
@@ -560,7 +556,7 @@ impl Peer {
match msg {
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &handler, &notif_tx, &body);
dispatch(&inner, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
@@ -697,7 +693,28 @@ fn parse_id(id: &Value) -> Option<i64> {
}
}
fn dispatch(inner: &Arc<Inner>, handler: &Arc<dyn Handler>, notif_tx: &std::sync::mpsc::Sender<Inbound>, body: &[u8]) {
enum Incoming {
Notification(String, Value),
Request(Value, String, Value),
}
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
let m = method.clone();
let result = tokio::task::spawn_blocking(move || handler.on_request(&m, params)).await;
let reply = match result {
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Ok(Err((code, message))) => {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
Err(_) => json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": codes::INTERNAL_ERROR, "message": format!("handler for {method} panicked") }
}),
};
let _ = inner.send_value(&reply);
}
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
@@ -716,30 +733,10 @@ fn dispatch(inner: &Arc<Inner>, handler: &Arc<dyn Handler>, notif_tx: &std::sync
let params = obj.remove("params").unwrap_or(Value::Null);
match id {
Some(id) if !id.is_null() => {
let handler = handler.clone();
let inner = inner.clone();
let (start, started) = oneshot::channel();
let _ = notif_tx.send(Inbound::Release(start));
tokio::spawn(async move {
// E.g. a `didOpen` before a request must be applied when the request is answered.
let _ = started.await;
let m = method.clone();
let result = tokio::task::spawn_blocking(move || handler.on_request(&m, params)).await;
let reply = match result {
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Ok(Err((code, message))) => {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
Err(_) => json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": codes::INTERNAL_ERROR, "message": format!("handler for {method} panicked") }
}),
};
let _ = inner.send_value(&reply);
});
let _ = notif_tx.send(Incoming::Request(id, method, params));
}
_ => {
let _ = notif_tx.send(Inbound::Notification(method, params));
let _ = notif_tx.send(Incoming::Notification(method, params));
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.17",
"version": "0.1.18",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.17",
"version": "0.1.18",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+59 -14
View File
@@ -12,7 +12,7 @@ import {
goToPreviousChunk,
rejectChunk,
unifiedMergeView,
type Chunk,
Chunk,
} from "@codemirror/merge";
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
import { setContext } from "@/platform/context";
@@ -82,6 +82,23 @@ export function chunkLines(chunk: Chunk, a: Text, b: Text) {
return { fromA: fromA!, toA: toA!, fromB: fromB!, toB: toB! };
}
/** The 0-based line of `to` matching `line` of `from`, given the chunks between them. */
export function mapLine(chunks: readonly Chunk[], line: number, from: Text, to: Text): number {
const span = (start: number, end: number, doc: Text) => {
const first = doc.lineAt(Math.min(start, doc.length)).number - 1;
return [first, end > start ? doc.lineAt(Math.min(end - 1, doc.length)).number : first] as const;
};
let shift = 0;
for (const c of chunks) {
const [fromA, toA] = span(c.fromA, c.toA, from);
if (line < fromA) break;
const [fromB, toB] = span(c.fromB, c.toB, to);
if (line < toA) return Math.min(fromB + (line - fromA), Math.max(fromB, toB - 1));
shift = toB - toA;
}
return Math.max(0, Math.min(line + shift, to.lines - 1));
}
/** Joins the open document of a file-backed side (working file, Compare Files); null for revisions / texts. */
async function attachSide(side: DiffSide, path: string): Promise<DocumentAttachment | null> {
const file = sideFile(side, path);
@@ -372,21 +389,49 @@ export function DiffEditor({ input }: { input: EditorInput }) {
[updateChunkInfo, data],
);
// A file-backed side is the source; for a diff of revisions (Git Log, Compare) the
// working copy of the path is, while it exists. Probed once per diff.
const fileSource = sideFile(left, path) ?? sideFile(right, path);
const [onDisk, setOnDisk] = useState<string | null>(null);
useEffect(() => {
if (fileSource) return;
let alive = true;
void call(commands.fsProbe(path)).then(
() => alive && setOnDisk(path),
() => alive && setOnDisk(null),
);
return () => {
alive = false;
};
}, [path, fileSource]);
const worktreeSource = onDisk === path ? path : null;
const source = fileSource ?? worktreeSource;
const jumpToSource = useCallback(() => {
const views = viewsRef.current;
const fileA = sideFile(left, path);
const fileB = sideFile(right, path);
const useA = focusedSide.current === "a" && !!views?.a && !!fileA;
const v = useA ? views!.a! : views?.b;
const file = useA ? fileA : (fileB ?? fileA);
const onA = focusedSide.current === "a" && !!views?.a;
const v = onA ? views!.a! : views?.b;
const own = onA ? fileA : fileB;
const file = own ?? fileB ?? fileA ?? worktreeSource;
if (!file) return;
const head = v ? v.state.selection.main.head : 0;
const line = v ? v.state.doc.lineAt(head) : null;
void editorService().openFile(file, {
selection: { line: line ? line.number - 1 : 0, col: line ? head - line.from : 0 },
focus: true,
const open = (line: number, col: number) =>
void editorService().openFile(file, { selection: { line, col }, focus: true });
if (!v) return open(0, 0);
const head = v.state.selection.main.head;
const at = v.state.doc.lineAt(head);
if (own) return open(at.number - 1, head - at.from);
// The pane shows another version of the file: follow the diff to the line there.
const from = v.state.doc;
const known = file === fileB ? views?.b.state.doc : file === fileA ? views?.a?.state.doc : undefined;
void (known ? Promise.resolve(known.toString()) : readWorkingText(file)).then((text) => {
if (text === null) return open(at.number - 1, head - at.from);
const to = Text.of(text.split(/\r?\n/));
const line = mapLine(Chunk.build(from, to), at.number - 1, from, to);
open(line, to.line(line + 1).text === at.text ? head - at.from : 0);
});
}, [left, right, path]);
}, [left, right, path, worktreeSource]);
const currentChunk = (): { chunk: Chunk; index: number } | undefined => {
const v = viewsRef.current?.b;
@@ -456,7 +501,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
const identical = ready && chunkInfo.count === 0 && (attached?.a?.text() ?? statics?.a) === (attached?.b?.text() ?? statics?.b);
// Staging applies to local changes against HEAD / the index only.
const canStage = editableB && isWorktree(right) && left.type === "rev" && !!useVcs.getState().root;
const hasSource = !!sideFile(left, path) || !!sideFile(right, path);
const hasSource = !!source;
return (
<div
@@ -475,6 +520,9 @@ export function DiffEditor({ input }: { input: EditorInput }) {
<div className={styles.toolbar} role="toolbar" aria-label={tx("Diff")}>
<IconButton icon="arrow-up" title="Previous Difference" shortcut={shortcutLabel("diff.prevDifference")} size="small" onClick={() => step(-1)} />
<IconButton icon="arrow-down" title="Next Difference" shortcut={shortcutLabel("diff.nextDifference")} size="small" onClick={() => step(1)} />
{hasSource && (
<IconButton icon="edit" title="Jump to Source" shortcut={shortcutLabel("diff.jumpToSource")} size="small" onClick={jumpToSource} />
)}
<span className={styles.sep} />
<IconButton
icon="split"
@@ -538,9 +586,6 @@ export function DiffEditor({ input }: { input: EditorInput }) {
)}
<span className={styles.sep} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void reload()} />
{hasSource && (
<IconButton icon="external-link" title="Jump to Source" shortcut={shortcutLabel("diff.jumpToSource")} size="small" onClick={jumpToSource} />
)}
<span className={styles.count}>
{!ready
? ""
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import type { EditorInput } from "@/workbench/contributions";
import { Chunk } from "@codemirror/merge";
import { Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { iconMarkup } from "@/ui";
const probe = vi.fn();
const openFile = vi.fn();
const texts: Record<string, string> = { HEAD: "a\n", abc123: "a\n" };
let working = "a\n";
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
fsProbe: (path: string) => probe(path),
vcsFileAt: (_path: string, rev: string) => Promise.resolve({ status: "ok", data: texts[rev] }),
fsReadText: () => Promise.resolve({ status: "ok", data: working }),
},
};
});
vi.mock("@/workbench/contributions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/contributions")>()),
editorService: () => ({ openFile }),
}));
import { DiffEditor, mapLine } from "../DiffEditor";
/** Git Log opens a commit's file: both sides are revisions. */
const input = {
kind: "diff",
key: "diff:test",
data: {
path: "/p/a.ts",
left: { type: "rev", rev: "HEAD" },
right: { type: "rev", rev: "abc123" },
},
} as unknown as EditorInput;
/** `d` attributes of an SVG, which is what tells two icons apart. */
const paths = (html: string) => [...html.matchAll(/ d="([^"]+)"/g)].map((m) => m[1]);
beforeEach(() => {
probe.mockReset();
openFile.mockReset();
texts.HEAD = texts.abc123 = working = "a\n";
probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: false, tooLarge: false } });
});
describe("Diff toolbar", () => {
it("puts Jump to Source right after Next Difference, in the navigation group", async () => {
render(<DiffEditor input={input} />);
await screen.findByRole("button", { name: "Jump to Source" });
const toolbar = screen.getByRole("toolbar", { name: "Diff" });
const buttons = within(toolbar).getAllByRole("button");
expect(buttons.slice(0, 3).map((b) => b.getAttribute("aria-label"))).toEqual([
"Previous Difference",
"Next Difference",
"Jump to Source",
]);
// One group: no separator between the three, one after them.
expect(buttons[1]!.nextElementSibling).toBe(buttons[2]);
expect(buttons[2]!.nextElementSibling).not.toBe(buttons[3]);
});
it("shows a pencil for Jump to Source", async () => {
render(<DiffEditor input={input} />);
const button = await screen.findByRole("button", { name: "Jump to Source" });
expect(paths(button.querySelector("svg")!.innerHTML)).toEqual(paths(iconMarkup("edit", 14)!));
});
it("jumps to the working copy of a file shown at a revision", async () => {
render(<DiffEditor input={input} />);
await screen.findByRole("button", { name: "Jump to Source" });
expect(probe).toHaveBeenCalledWith("/p/a.ts");
});
it("has no Jump to Source when the file is gone from the working tree", async () => {
probe.mockRejectedValue(new Error("no such file"));
render(<DiffEditor input={input} />);
await waitFor(() => expect(probe).toHaveBeenCalled());
await waitFor(() => expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull());
});
});
const doc = (s: string) => Text.of(s.split("\n"));
describe("mapLine", () => {
const from = doc("a\nb\nc\nd\ne\nf\ng\n");
const to = doc("a\nX\nY\nb\nc\ne\nF1\nF2\ng\n");
const map = (line: number) => mapLine(Chunk.build(from, to), line, from, to);
it("shifts lines past an insertion and keeps lines before it", () => {
expect(map(0)).toBe(0);
expect(map(1)).toBe(3);
expect(map(2)).toBe(4);
});
it("lands on the line after a removed block", () => {
expect(map(3)).toBe(5);
expect(map(4)).toBe(5);
});
it("keeps the offset inside a changed block and shifts past it", () => {
expect(map(5)).toBe(6);
expect(map(6)).toBe(8);
});
});
describe("Jump to Source of a revision diff", () => {
/** Parent, commit and working copy each add a line above the previous. */
const setUp = () => {
texts.HEAD = "a\nb\nc\nd\n";
texts.abc123 = "a\nX\nY\nb\nc\nd\n";
working = "new\na\nX\nY\nb\nc\nd\n";
};
const pane = async (index: number) => {
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor").length).toBe(2));
return EditorView.findFromDOM(document.querySelectorAll<HTMLElement>(".cm-mergeViewEditor .cm-editor")[index]!)!;
};
const jumpFrom = async (index: number, line: number) => {
const v = await pane(index);
v.dispatch({ selection: { anchor: v.state.doc.line(line + 1).from + 1 } });
v.contentDOM.dispatchEvent(new FocusEvent("focus"));
(await screen.findByRole("button", { name: "Jump to Source" })).click();
await waitFor(() => expect(openFile).toHaveBeenCalled());
return openFile.mock.calls[0];
};
it("maps a line of the commit to the working copy", async () => {
setUp();
render(<DiffEditor input={input} />);
expect(await jumpFrom(1, 2)).toEqual(["/p/a.ts", { selection: { line: 3, col: 1 }, focus: true }]);
});
it("maps a line of the parent through the diff to the working copy", async () => {
setUp();
render(<DiffEditor input={input} />);
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
});
});