From 8d4c8885ca0e7a36f6ba02054345edc1b4097d0b Mon Sep 17 00:00:00 2001 From: itning Date: Tue, 27 Oct 2026 21:10:53 +0800 Subject: [PATCH 1/2] fix(diff): Jump to Source from a commit's file, next to Next Difference (#103) Jump to Source moves next to Next Difference in the diff toolbar, with the edit (pencil) icon. Diffs where neither side is a file on disk (Git Log commit files, Compare Branches, multi-commit diffs) now offer it when the path still exists in the working copy; files deleted by the commit don't. Jumping from a pane that isn't the opened file used the other pane's caret. The caret line is now mapped through the diff to the target (the other pane's document, or the working copy read at click time including unsaved edits); the column is kept only when the line is unchanged. Co-authored-by: itning <19759891+itning@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/features/vcs/DiffEditor.tsx | 73 +++++++-- .../vcs/__tests__/diffToolbar.test.tsx | 146 ++++++++++++++++++ 2 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 src/features/vcs/__tests__/diffToolbar.test.tsx diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 398bd016c..0c4266ac9 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 000000000..45f9626a3 --- /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 }]); + }); +}); From cf421e4fcbfa4462765ec1512c20523b513aa482 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:15:46 +0800 Subject: [PATCH 2/2] chore: v0.1.18 --- Cargo.lock | 60 +++++++++++++++++++-------------------- Cargo.toml | 2 +- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab57338d5..79b698b00 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 2b12e36cc..55a2c8ad1 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/package.json b/package.json index d0fccf508..7956e94f9 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 ede544b73..ff42a1ed8 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",