Merge remote-tracking branch 'origin/main' into feat/remote-development

Цей коміт міститься в:
l0ng-ai committed 2026-10-27 21:18:06 +08:00
коміт 0217586e2e
6 files changed
+238 -47

No files matched your search

Створено
+30 -30
Переглянути файл
@@ -3974,7 +3974,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -3992,7 +3992,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4006,7 +4006,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4021,11 +4021,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",
@@ -4037,7 +4037,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-run",
@@ -4051,7 +4051,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4068,7 +4068,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dirs 6.0.0",
"futures",
@@ -4087,7 +4087,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"libc",
@@ -4101,7 +4101,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4156,7 +4156,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"parking_lot",
@@ -4174,7 +4174,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"blake3",
"criterion",
@@ -4196,7 +4196,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"encoding_rs",
"futures",
@@ -4212,7 +4212,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"blake3",
"criterion",
@@ -4265,7 +4265,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"futures",
"globset",
@@ -4288,7 +4288,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-language",
@@ -4306,7 +4306,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-run",
@@ -4336,7 +4336,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"encoding_rs",
"ide-core",
@@ -4356,7 +4356,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dunce",
"ide-core",
@@ -4375,7 +4375,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"criterion",
"globset",
@@ -4395,7 +4395,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"parking_lot",
@@ -4427,7 +4427,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"ide-language",
@@ -4442,7 +4442,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"libc",
@@ -4455,7 +4455,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"criterion",
"ide-core",
@@ -4467,7 +4467,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"flate2",
"hex",
@@ -4487,7 +4487,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"dunce",
"gix",
@@ -4504,7 +4504,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"chardetng",
"dunce",
@@ -4525,7 +4525,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"ide-core",
"open",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"blake3",
"dunce",
@@ -5966,7 +5966,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"blake3",
"dirs 6.0.0",
+1 -1
Переглянути файл
@@ -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"
+1 -1
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
? ""
+146
Переглянути файл
@@ -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 }]);
});
});