feat(vcs): inline blame for the current line

Цей коміт міститься в:
l0ng-ai committed 2026-11-02 12:09:18 +08:00
1 джерело 44c603f7db
коміт 8e87711eaa
12 files changed
+563 -5

No files matched your search

+3
Переглянути файл
@@ -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,
+2
Переглянути файл
@@ -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`. */
+3
Переглянути файл
@@ -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;
+2 -3
Переглянути файл
@@ -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;
+1
Переглянути файл
@@ -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" },
],
},
{
+255
Переглянути файл
@@ -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();
});
});
+2 -1
Переглянути файл
@@ -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",
+281
Переглянути файл
@@ -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",
},
}),
];
}
+1
Переглянути файл
@@ -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",
+4 -1
Переглянути файл
@@ -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 追溯信息",
"You, now · Uncommitted changes": "你,刚刚 · 未提交的更改",
"now": "刚刚"
}
+6
Переглянути файл
@@ -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({
+3
Переглянути файл
@@ -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),