mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 03:08:18 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de95efe307 | ||
|
|
c07bde2cd7 | ||
|
|
8e87711eaa |
No files matched your search
@@ -41,6 +41,8 @@ impl Default for VcsSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GitSettings {
|
||||
/// Show blame after the current editor line.
|
||||
pub inline_blame: bool,
|
||||
/// Path to the git executable; empty = auto-detect.
|
||||
pub executable: String,
|
||||
/// Default of Update Project: `merge` | `rebase`.
|
||||
@@ -71,6 +73,7 @@ pub struct GitSettings {
|
||||
impl Default for GitSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inline_blame: false,
|
||||
executable: String::new(),
|
||||
update_method: "merge".into(),
|
||||
auto_update_on_push_rejected: false,
|
||||
|
||||
@@ -3350,6 +3350,8 @@ export type GitOperation =
|
||||
{ op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] };
|
||||
|
||||
export type GitSettings = {
|
||||
/** Show blame after the current editor line. */
|
||||
inlineBlame?: boolean,
|
||||
/** Path to the git executable; empty = auto-detect. */
|
||||
executable?: string,
|
||||
/** Default of Update Project: `merge` | `rebase`. */
|
||||
|
||||
@@ -32,6 +32,9 @@ import type { LanguageDescription } from "@codemirror/language";
|
||||
import { create } from "zustand";
|
||||
import { maybeEditorService } from "@/workbench/contributions";
|
||||
|
||||
/** Documents above this size (UTF-16 units) skip per-line decorations. */
|
||||
export const HEAVY_LIMIT = 8_000_000;
|
||||
|
||||
export interface EditorDocContext {
|
||||
doc: number;
|
||||
path: string;
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { EditorConfigProps } from "@/bindings";
|
||||
import type { EditorSettings } from "@/platform/ipc";
|
||||
import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings";
|
||||
import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts";
|
||||
import { editorContributions, type EditorDocContext } from "@/editor/api";
|
||||
import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api";
|
||||
import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree";
|
||||
import { openideaHighlightStyle } from "./highlight";
|
||||
import { openideaTheme } from "./theme";
|
||||
@@ -78,8 +78,7 @@ export function prefsOf(settings: EditorSettings | undefined): EditorExtras {
|
||||
return editorPrefs({ editor: settings });
|
||||
}
|
||||
|
||||
/** Documents above this size (UTF-16 units) skip per-line decorations. */
|
||||
export const HEAVY_LIMIT = 8_000_000;
|
||||
export { HEAVY_LIMIT } from "@/editor/api";
|
||||
|
||||
export interface EffectiveIndent {
|
||||
tabSize: number;
|
||||
|
||||
@@ -492,6 +492,7 @@ export const VCS_PAGES: PageSpec[] = [
|
||||
keywords: ["Test", "git version"],
|
||||
component: GitExecutableRow,
|
||||
},
|
||||
{ kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView, activateHover, closeHoverTooltips, hasHoverTooltips } from "@codemirror/view";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BlameLine, Settings } from "@/bindings";
|
||||
import { HEAVY_LIMIT, _emitEditorEvent } from "@/editor/api";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useI18n } from "@/platform/i18n";
|
||||
import { formatInlineBlame, inlineBlame, relativeBlameDate } from "../inlineBlame";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ blame: vi.fn(), file: vi.fn(), log: vi.fn(), action: vi.fn() }));
|
||||
vi.mock("@/platform/actions", async (original) => ({
|
||||
...(await original<typeof import("@/platform/actions")>()),
|
||||
executeAction: mocks.action,
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const actual = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
commands: { ...actual.commands, vcsBlame: mocks.blame, vcsFileAt: mocks.file, vcsLog: mocks.log },
|
||||
};
|
||||
});
|
||||
|
||||
const now = Date.UTC(2026, 10, 2, 12);
|
||||
const alice: BlameLine = {
|
||||
commit: "a".repeat(40),
|
||||
author: "Alice Smith",
|
||||
timeMs: now - 86400_000,
|
||||
summary: "First line",
|
||||
};
|
||||
const bob: BlameLine = { commit: "b".repeat(40), author: "Bob Jones", timeMs: now - 3600_000, summary: "Second line" };
|
||||
const uncommitted: BlameLine = { commit: "0".repeat(40), author: "Not Committed Yet", timeMs: now, summary: "" };
|
||||
const ctx = { doc: 1, path: "/repo/file.ts", language: "typescript", uiMode: null, readOnly: false };
|
||||
let view: EditorView | undefined;
|
||||
|
||||
function settings(enabled: boolean) {
|
||||
useProject.setState({ settings: { vcs: { git: { inlineBlame: enabled } } } as Settings });
|
||||
}
|
||||
|
||||
function editor(doc = "one\ntwo\nthree", context = ctx) {
|
||||
view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({ doc, extensions: [inlineBlame(context)] }),
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
const hint = () => view?.dom.querySelector<HTMLElement>(".cm-vcs-inline-blame");
|
||||
const tick = () => vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
vi.clearAllMocks();
|
||||
useI18n.setState({ locale: "en", texts: {} });
|
||||
settings(true);
|
||||
useVcs.setState({ available: true, roots: ["/repo"], statusLoaded: true, byPath: new Map(), version: 0 });
|
||||
mocks.file.mockResolvedValue({ status: "ok", data: "one\ntwo\nthree" });
|
||||
mocks.blame.mockResolvedValue({ status: "ok", data: [alice, bob, uncommitted] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = undefined;
|
||||
useProject.setState({ settings: null });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("inline blame formatting", () => {
|
||||
it("formats authors, relative dates, summaries and missing dates", () => {
|
||||
expect(formatInlineBlame(alice, now)).toBe("Alice Smith, 1 day ago · First line");
|
||||
expect(formatInlineBlame({ ...bob, timeMs: null, summary: "Long\nmessage" }, now)).toBe("Bob Jones · Long message");
|
||||
expect(formatInlineBlame(uncommitted, now)).toBe("You, now · Uncommitted changes");
|
||||
expect(relativeBlameDate(now + 1000, now)).toBe("now");
|
||||
expect(relativeBlameDate(now - 120_000, now)).toBe("2 minutes ago");
|
||||
expect(relativeBlameDate(now - 400 * 86400_000, now)).toBe("1 year ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("current line decoration", () => {
|
||||
it("debounces caret moves and reuses blame for the same document version", async () => {
|
||||
const v = editor();
|
||||
await vi.advanceTimersByTimeAsync(299);
|
||||
expect(mocks.blame).not.toHaveBeenCalled();
|
||||
v.dispatch({ selection: { anchor: 4 } });
|
||||
await vi.advanceTimersByTimeAsync(299);
|
||||
expect(mocks.blame).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(hint()?.textContent).toContain("Bob Jones");
|
||||
v.dispatch({ selection: { anchor: 0 } });
|
||||
expect(hint()).toBeNull();
|
||||
await tick();
|
||||
expect(hint()?.textContent).toContain("Alice Smith");
|
||||
expect(mocks.blame).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.file).toHaveBeenCalledTimes(1);
|
||||
expect(hint()?.contentEditable).toBe("false");
|
||||
hint()?.click();
|
||||
expect(mocks.action).toHaveBeenCalledWith("vcs.showCommit", alice.commit);
|
||||
});
|
||||
|
||||
it("uses only the primary selection and hides multiline selections", async () => {
|
||||
const v = editor();
|
||||
v.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(4)], 1) });
|
||||
await tick();
|
||||
expect(hint()?.textContent).toContain("Bob Jones");
|
||||
v.dispatch({ selection: { anchor: 0, head: 5 } });
|
||||
await tick();
|
||||
expect(hint()).toBeNull();
|
||||
v.dispatch({ selection: { anchor: 4, head: 6 } });
|
||||
await tick();
|
||||
expect(hint()?.textContent).toContain("Bob Jones");
|
||||
});
|
||||
|
||||
it("loads and caches the full commit message on hover and closes it on caret movement", async () => {
|
||||
const message = "First line\n\nThe complete commit body.";
|
||||
mocks.log.mockResolvedValue({ status: "ok", data: [{ message }] });
|
||||
const v = editor();
|
||||
await tick();
|
||||
activateHover(v, 3, 1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hasHoverTooltips(v.state)).toBe(true);
|
||||
const tooltip = v.dom.querySelector(".cm-vcs-inline-blame-tooltip");
|
||||
expect(tooltip?.textContent).toContain(message);
|
||||
expect(tooltip?.textContent).toContain(alice.author);
|
||||
expect(tooltip?.textContent).toContain(alice.commit);
|
||||
expect(tooltip?.textContent).toContain(new Date(alice.timeMs!).toLocaleString());
|
||||
v.dispatch({ effects: closeHoverTooltips });
|
||||
activateHover(v, 3, 1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mocks.log).toHaveBeenCalledTimes(1);
|
||||
v.dispatch({ selection: { anchor: 4 } });
|
||||
expect(hasHoverTooltips(v.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("closes an open tooltip when the setting is disabled", async () => {
|
||||
mocks.log.mockResolvedValue({ status: "ok", data: [{ message: "Full message" }] });
|
||||
const v = editor();
|
||||
await tick();
|
||||
activateHover(v, 3, 1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hasHoverTooltips(v.state)).toBe(true);
|
||||
settings(false);
|
||||
expect(hasHoverTooltips(v.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("refetches edits, saves and VCS refreshes", async () => {
|
||||
const v = editor();
|
||||
await tick();
|
||||
mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted, bob, uncommitted] });
|
||||
v.dispatch({ changes: { from: 0, insert: "x" } });
|
||||
expect(hint()).toBeNull();
|
||||
await tick();
|
||||
expect(mocks.blame).toHaveBeenLastCalledWith(ctx.path, "xone\ntwo\nthree", null);
|
||||
expect(hint()?.textContent).toBe("You, now · Uncommitted changes");
|
||||
_emitEditorEvent({ type: "docSaved", doc: 1, path: ctx.path });
|
||||
await tick();
|
||||
useVcs.setState({ version: 1 });
|
||||
await tick();
|
||||
expect(mocks.blame).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("discards a response from an older document version", async () => {
|
||||
let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void;
|
||||
mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done)));
|
||||
const v = editor();
|
||||
await tick();
|
||||
v.dispatch({ changes: { from: 0, insert: "x" } });
|
||||
resolve({ status: "ok", data: [alice] });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hint()).toBeNull();
|
||||
mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted] });
|
||||
await tick();
|
||||
expect(hint()?.textContent).toBe("You, now · Uncommitted changes");
|
||||
});
|
||||
|
||||
it("coalesces an in-flight blame when the caret moves", async () => {
|
||||
let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void;
|
||||
mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done)));
|
||||
const v = editor();
|
||||
await tick();
|
||||
v.dispatch({ selection: { anchor: 4 } });
|
||||
await tick();
|
||||
resolve({ status: "ok", data: [alice, bob] });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hint()?.textContent).toContain("Bob Jones");
|
||||
expect(mocks.blame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("responds to the setting immediately", async () => {
|
||||
settings(false);
|
||||
editor();
|
||||
await tick();
|
||||
expect(mocks.blame).not.toHaveBeenCalled();
|
||||
settings(true);
|
||||
await tick();
|
||||
expect(hint()).not.toBeNull();
|
||||
settings(false);
|
||||
expect(hint()).toBeNull();
|
||||
await tick();
|
||||
expect(mocks.blame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores a pending response after disabling", async () => {
|
||||
let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void;
|
||||
mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done)));
|
||||
editor();
|
||||
await tick();
|
||||
settings(false);
|
||||
resolve({ status: "ok", data: [alice] });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hint()).toBeNull();
|
||||
await tick();
|
||||
expect(hint()).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["untracked", "ignored"] as const)("skips %s paths", async (unstaged) => {
|
||||
useVcs.setState({
|
||||
byPath: new Map([[ctx.path, { path: ctx.path, relPath: "file.ts", unstaged, staged: null, renamedFrom: null }]]),
|
||||
});
|
||||
editor();
|
||||
await tick();
|
||||
expect(mocks.file).not.toHaveBeenCalled();
|
||||
expect(hint()).toBeNull();
|
||||
});
|
||||
|
||||
it("skips ignored paths missing from status and HEAD", async () => {
|
||||
mocks.file.mockResolvedValue({ status: "ok", data: null });
|
||||
editor();
|
||||
await tick();
|
||||
expect(mocks.blame).not.toHaveBeenCalled();
|
||||
expect(hint()).toBeNull();
|
||||
});
|
||||
|
||||
it("skips paths outside repositories", async () => {
|
||||
editor("one", { ...ctx, path: "/elsewhere/file.ts" });
|
||||
await tick();
|
||||
expect(mocks.file).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips large, virtual and read-only documents", async () => {
|
||||
editor("x".repeat(HEAVY_LIMIT + 1));
|
||||
await tick();
|
||||
expect(mocks.file).not.toHaveBeenCalled();
|
||||
expect(inlineBlame({ ...ctx, virtual: true })).toEqual([]);
|
||||
expect(inlineBlame({ ...ctx, readOnly: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancels pending work when the view is destroyed", async () => {
|
||||
editor().destroy();
|
||||
view = undefined;
|
||||
await tick();
|
||||
expect(mocks.file).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { registerHistoryActions, trackOperationContext } from "./historyActions"
|
||||
import { registerLogPaneActions } from "./logPaneActions";
|
||||
import { controlLabel, controlOperation, inProgress } from "./operations";
|
||||
import { annotationsShown, blameSlot } from "./blame";
|
||||
import { inlineBlame } from "./inlineBlame";
|
||||
import { BranchWidget, useBranchesPopup } from "./BranchWidget";
|
||||
import { showConflictsDialog } from "./conflictsUi";
|
||||
import { registerCompareActions } from "./compare";
|
||||
@@ -212,7 +213,7 @@ export function activate(): () => void {
|
||||
registerEditorContribution({
|
||||
id: "vcs.changeMarkers",
|
||||
order: 60,
|
||||
extensions: (ctx) => [changeMarkers(ctx), blameSlot()],
|
||||
extensions: (ctx) => [changeMarkers(ctx), blameSlot(), inlineBlame(ctx)],
|
||||
}),
|
||||
registerMenuGroup({
|
||||
id: "vcs.gutter",
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
hoverTooltip,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import type { BlameLine } from "@/bindings";
|
||||
import { HEAVY_LIMIT, onEditorEvent, type EditorDocContext } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { tx, useI18n } from "@/platform/i18n";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
||||
import { isUncommitted } from "./blame";
|
||||
import { repoOf, statusOf, useVcs } from "./store";
|
||||
|
||||
export function relativeBlameDate(timeMs: number | null, now = Date.now()): string {
|
||||
if (timeMs === null) return "";
|
||||
const seconds = Math.max(0, (now - timeMs) / 1000);
|
||||
const units = [
|
||||
["year", 365 * 86400],
|
||||
["month", 30 * 86400],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
] as const;
|
||||
const unit = units.find(([, size]) => seconds >= size);
|
||||
return unit
|
||||
? new Intl.RelativeTimeFormat(useI18n.getState().locale, { numeric: "always" }).format(
|
||||
-Math.floor(seconds / unit[1]),
|
||||
unit[0],
|
||||
)
|
||||
: tx("now");
|
||||
}
|
||||
|
||||
export function formatInlineBlame(line: BlameLine, now = Date.now()): string {
|
||||
if (isUncommitted(line)) return tx("You, now · Uncommitted changes");
|
||||
const date = relativeBlameDate(line.timeMs, now);
|
||||
return `${line.author}${date ? `, ${date}` : ""} · ${line.summary.replace(/\s+/g, " ")}`;
|
||||
}
|
||||
|
||||
/** Only the current document version is retained, including its in-flight request. */
|
||||
class BlameCache {
|
||||
private doc: Text | undefined;
|
||||
private pending: Promise<BlameLine[]> | undefined;
|
||||
|
||||
clear() {
|
||||
this.doc = undefined;
|
||||
this.pending = undefined;
|
||||
}
|
||||
|
||||
get(doc: Text, load: () => Promise<BlameLine[]>): Promise<BlameLine[]> {
|
||||
if (doc !== this.doc || !this.pending) {
|
||||
this.doc = doc;
|
||||
this.pending = load();
|
||||
}
|
||||
return this.pending;
|
||||
}
|
||||
}
|
||||
|
||||
class BlameWidget extends WidgetType {
|
||||
constructor(readonly line: BlameLine) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: BlameWidget) {
|
||||
return formatInlineBlame(other.line) === formatInlineBlame(this.line) && other.line.commit === this.line.commit;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const el = document.createElement("span");
|
||||
el.className = "cm-vcs-inline-blame";
|
||||
el.textContent = formatInlineBlame(this.line);
|
||||
el.contentEditable = "false";
|
||||
if (!isUncommitted(this.line)) {
|
||||
el.setAttribute("role", "button");
|
||||
el.tabIndex = 0;
|
||||
const open = () => void executeAction("vcs.showCommit", this.line.commit);
|
||||
el.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
el.addEventListener("click", open);
|
||||
el.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
const setInlineBlame = StateEffect.define<DecorationSet>();
|
||||
const inlineBlameField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(value, tr) {
|
||||
if (tr.docChanged || tr.selection) return Decoration.none;
|
||||
for (const effect of tr.effects) if (effect.is(setInlineBlame)) return effect.value;
|
||||
return value;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
export function inlineBlame(ctx: EditorDocContext): Extension {
|
||||
if (ctx.virtual || ctx.readOnly) return [];
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly cache = new BlameCache();
|
||||
private readonly messages = new Map<string, Promise<string>>();
|
||||
private readonly off: (() => void)[];
|
||||
private generation = 0;
|
||||
private destroyed = false;
|
||||
line: BlameLine | undefined;
|
||||
|
||||
constructor(readonly view: EditorView) {
|
||||
this.off = [
|
||||
useVcs.subscribe((s, prev) => {
|
||||
if (s.version !== prev.version) this.invalidate();
|
||||
}),
|
||||
onEditorEvent((e) => {
|
||||
if (e.type === "docSaved" && e.path === ctx.path) this.invalidate();
|
||||
}),
|
||||
onWorkflowSettingsChange(
|
||||
(s) => s.vcs.git.inlineBlame,
|
||||
() => this.invalidate(),
|
||||
),
|
||||
useI18n.subscribe(() => this.invalidate()),
|
||||
];
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private enabled() {
|
||||
const vcs = useVcs.getState();
|
||||
const status = statusOf(ctx.path);
|
||||
return (
|
||||
workflowSettings().vcs.git.inlineBlame &&
|
||||
isTauri() &&
|
||||
vcs.available &&
|
||||
vcs.statusLoaded &&
|
||||
!!repoOf(ctx.path) &&
|
||||
status?.unstaged !== "untracked" &&
|
||||
status?.unstaged !== "ignored" &&
|
||||
this.view.state.doc.length <= HEAVY_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.line = undefined;
|
||||
if (this.view.state.field(inlineBlameField).size)
|
||||
this.view.dispatch({ effects: setInlineBlame.of(Decoration.none) });
|
||||
}
|
||||
|
||||
private invalidate() {
|
||||
this.cache.clear();
|
||||
this.clear();
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged) this.cache.clear();
|
||||
if (update.docChanged || update.selectionSet) {
|
||||
this.line = undefined;
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
clearTimeout(this.timer);
|
||||
++this.generation;
|
||||
this.timer = setTimeout(() => void this.refresh(), 300);
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
if (this.destroyed || !this.enabled()) return;
|
||||
const { doc, selection } = this.view.state;
|
||||
const caret = doc.lineAt(selection.main.head);
|
||||
if (doc.lineAt(selection.main.from).number !== doc.lineAt(selection.main.to).number) return;
|
||||
const generation = this.generation;
|
||||
try {
|
||||
const lines = await this.cache.get(doc, async () => {
|
||||
// Blame also returns synthetic entries for paths absent from HEAD (ignored / new files).
|
||||
if ((await call(commands.vcsFileAt(ctx.path, "HEAD"))) === null) return [];
|
||||
return call(commands.vcsBlame(ctx.path, doc.toString(), null));
|
||||
});
|
||||
if (this.destroyed || generation !== this.generation || doc !== this.view.state.doc || !this.enabled())
|
||||
return;
|
||||
this.line = lines[caret.number - 1];
|
||||
if (this.line) {
|
||||
const widget = Decoration.widget({ widget: new BlameWidget(this.line), side: 1 }).range(caret.to);
|
||||
this.view.dispatch({ effects: setInlineBlame.of(Decoration.set([widget])) });
|
||||
}
|
||||
} catch {
|
||||
// Repository removed, unborn HEAD, inaccessible file, etc.: leave the line undecorated.
|
||||
}
|
||||
}
|
||||
|
||||
message(line: BlameLine): Promise<string> {
|
||||
if (isUncommitted(line)) return Promise.resolve(tx("You, now · Uncommitted changes"));
|
||||
let message = this.messages.get(line.commit);
|
||||
if (!message) {
|
||||
message = call(
|
||||
commands.vcsLog(repoOf(ctx.path), {
|
||||
branch: line.commit,
|
||||
path: null,
|
||||
author: null,
|
||||
text: null,
|
||||
skip: 0,
|
||||
limit: 1,
|
||||
}),
|
||||
).then(
|
||||
(commits) => commits[0]?.message ?? line.summary,
|
||||
() => line.summary,
|
||||
);
|
||||
if (this.messages.size >= 100) this.messages.delete(this.messages.keys().next().value!);
|
||||
this.messages.set(line.commit, message);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
clearTimeout(this.timer);
|
||||
this.cache.clear();
|
||||
this.off.forEach((off) => off());
|
||||
}
|
||||
},
|
||||
);
|
||||
return [
|
||||
inlineBlameField,
|
||||
plugin,
|
||||
hoverTooltip(
|
||||
async (view, pos, side) => {
|
||||
const instance = view.plugin(plugin);
|
||||
const line = instance?.line;
|
||||
const end = view.state.doc.lineAt(view.state.selection.main.head).to;
|
||||
if (!instance || !line || pos !== end || side < 0) return null;
|
||||
const message = await instance.message(line);
|
||||
if (view.plugin(plugin)?.line !== line) return null;
|
||||
return {
|
||||
pos: end,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "cm-vcs-inline-blame-tooltip";
|
||||
dom.textContent = isUncommitted(line)
|
||||
? message
|
||||
: `${line.author}\n${line.timeMs === null ? "" : new Date(line.timeMs).toLocaleString()}\n${line.commit}\n\n${message}`;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
hideOnChange: true,
|
||||
hideOn: (tr) => tr.effects.some((effect) => effect.is(setInlineBlame)),
|
||||
},
|
||||
),
|
||||
EditorView.baseTheme({
|
||||
".cm-vcs-inline-blame": {
|
||||
color: "var(--fg-inlay)",
|
||||
marginLeft: "3ch",
|
||||
display: "inline-block",
|
||||
maxWidth: "min(60ch, 50vw)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
verticalAlign: "bottom",
|
||||
userSelect: "none",
|
||||
},
|
||||
'.cm-vcs-inline-blame[role="button"]': { cursor: "pointer" },
|
||||
".cm-vcs-inline-blame-tooltip": {
|
||||
padding: "8px 12px",
|
||||
maxWidth: "min(70ch, 80vw)",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"run.confirmTerminate",
|
||||
"run.showOnStart",
|
||||
"vcs.showDirtyDirectories",
|
||||
"vcs.git.inlineBlame",
|
||||
"vcs.git.showPushDialog",
|
||||
"vcs.git.pushDialogOnlyProtected",
|
||||
"vcs.git.combineStashAndShelf",
|
||||
|
||||
@@ -5497,5 +5497,8 @@
|
||||
"Cannot paste image": "无法粘贴图片",
|
||||
"Remote server does not support image paste.": "远程服务器不支持粘贴图片。",
|
||||
"Clipboard image reading is unavailable.": "无法读取剪贴板图片。",
|
||||
"Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。"
|
||||
"Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。",
|
||||
"Show inline blame for the current line": "为当前行显示内联 Git Blame",
|
||||
"You, now · Uncommitted changes": "你,刚刚 · 未提交的更改",
|
||||
"now": "刚刚"
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import { splitCommandLine } from "./commandLine";
|
||||
import { DEFAULT_WORKFLOW, validateWorkflow } from "./workflowSettings";
|
||||
|
||||
describe("validateWorkflow", () => {
|
||||
it("defaults inline blame off and accepts only boolean values", () => {
|
||||
expect(DEFAULT_WORKFLOW.vcs.git.inlineBlame).toBe(false);
|
||||
expect(validateWorkflow({ vcs: { git: { inlineBlame: true } } }).vcs.git.inlineBlame).toBe(true);
|
||||
expect(validateWorkflow({ vcs: { git: { inlineBlame: "true" } } }).vcs.git.inlineBlame).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to defaults for missing or malformed values", () => {
|
||||
expect(validateWorkflow(undefined)).toEqual(DEFAULT_WORKFLOW);
|
||||
const v = validateWorkflow({
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface IssueLinkValid {
|
||||
}
|
||||
|
||||
export interface GitSettingsValid {
|
||||
inlineBlame: boolean;
|
||||
executable: string;
|
||||
updateMethod: UpdateMethod;
|
||||
autoUpdateOnPushRejected: boolean;
|
||||
@@ -183,6 +184,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
|
||||
limitHistory: true,
|
||||
historyLimit: 1000,
|
||||
git: {
|
||||
inlineBlame: false,
|
||||
executable: "",
|
||||
updateMethod: "merge",
|
||||
autoUpdateOnPushRejected: false,
|
||||
@@ -309,6 +311,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
|
||||
limitHistory: bool(v.limitHistory, d.limitHistory),
|
||||
historyLimit: num(v.historyLimit, d.historyLimit, L.historyLimit, true),
|
||||
git: {
|
||||
inlineBlame: bool(g.inlineBlame, d.git.inlineBlame),
|
||||
executable: text(g.executable, d.git.executable).trim(),
|
||||
updateMethod: oneOf(g.updateMethod, ["merge", "rebase"] as const, d.git.updateMethod),
|
||||
autoUpdateOnPushRejected: bool(g.autoUpdateOnPushRejected, d.git.autoUpdateOnPushRejected),
|
||||
|
||||
Reference in new issue
Block a user