diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4135e1b..55865182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/Cargo.lock b/Cargo.lock index ab57338d..79b698b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 2b12e36c..55a2c8ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 7f78a7ef..bd4bf139 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -191,6 +191,8 @@ struct Task { dirty: Mutex>, flush_scheduled: AtomicBool, stderr: Mutex>, + /// Set by `delete`; held while saving, so a late save can't bring the file back. + deleted: Mutex, } 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); } diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 78c5d444..93b7483b 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -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>>; 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::(); + // 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::(); { 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, ¬if_tx, &body); + dispatch(&inner, ¬if_tx, &body); } Ok(None) => break, Err(e) => { @@ -697,7 +693,28 @@ fn parse_id(id: &Value) -> Option { } } -fn dispatch(inner: &Arc, handler: &Arc, notif_tx: &std::sync::mpsc::Sender, body: &[u8]) { +enum Incoming { + Notification(String, Value), + Request(Value, String, Value), +} + +async fn handle_request(inner: Arc, handler: Arc, 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, notif_tx: &std::sync::mpsc::Sender, 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, handler: &Arc, 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)); } } } diff --git a/package.json b/package.json index d0fccf50..7956e94f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ede544b7..ff42a1ed 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 398bd016..0c4266ac 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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 { 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(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 (
step(-1)} /> step(1)} /> + {hasSource && ( + + )} void reload()} /> - {hasSource && ( - - )} {!ready ? "" diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx new file mode 100644 index 00000000..45f9626a --- /dev/null +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -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 = { HEAD: "a\n", abc123: "a\n" }; +let working = "a\n"; + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + 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()), + 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(); + 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(); + 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(); + 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(); + 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(".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(); + 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(); + expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]); + }); +});