From 8d4c8885ca0e7a36f6ba02054345edc1b4097d0b Mon Sep 17 00:00:00 2001 From: itning Date: Tue, 27 Oct 2026 21:10:53 +0800 Subject: [PATCH 1/5] 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 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 }]); + }); +}); 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/5] 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 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/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", From b5bc1501a007201c39e1a62112f43d2312115eae Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:38:42 +0800 Subject: [PATCH 3/5] fix(jsonrpc): Start incoming requests after the notifications that preceded them Requests from the other side were handed to the blocking pool as soon as they were read, while notifications run in order on their own thread, so a request could be handled before an earlier didOpen. Requests now start from the same ordered queue. --- crates/ide-jsonrpc/src/lib.rs | 78 ++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 638fd75f..caa1cde2 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -498,20 +498,30 @@ impl Peer { }); } - // Notifications are delivered in order on a dedicated thread. - let (notif_tx, notif_rx) = std::sync::mpsc::channel::<(String, Value)>(); + // 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((method, params)) = notif_rx.recv() { - 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}"); + 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)); + } } } }) @@ -543,7 +553,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) => { @@ -680,12 +690,28 @@ fn parse_id(id: &Value) -> Option { } } -fn dispatch( - inner: &Arc, - handler: &Arc, - notif_tx: &std::sync::mpsc::Sender<(String, Value)>, - 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, @@ -704,26 +730,10 @@ fn dispatch( 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(); - tokio::spawn(async move { - 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((method, params)); + let _ = notif_tx.send(Incoming::Notification(method, params)); } } } From 60f2f47547d1b023005c2ee07994e82cd68f8714 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:50:42 +0800 Subject: [PATCH 4/5] ci: Cut redundant work from the CI run - Save the Rust cache only on main; pull requests restore it. - Check bindings against the file nextest's export_bindings already wrote instead of compiling the test again. - Run e2e in its own job on the Playwright image, in parallel with the frontend checks, without installing browsers on each run. - Cancel a run in progress when a newer push arrives, on main too. --- .github/workflows/ci.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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: From d7ff91760f969d4e9fa84323ed88900c3309263e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:03:24 +0800 Subject: [PATCH 5/5] fix(agents): Keep a deleted task from being saved again A save that ran after delete (from the turn ending or the agent exiting) wrote the task file back, so the task returned on the next load. Deleting now marks the task under the lock that saving holds. --- crates/ide-agent/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index c62073bf..c276076f 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); }