дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(vcs): correct Git log graph projection and expand history views (#1076)
Git Log could lose commits when filtering by several paths, draw wrong ancestry after filtering or collapsing rows, and open the wrong repository in multi-root views. The graph is now projected over the full commit DAG and laid out in Bek order, so filtered and collapsed views keep the real ancestry; each log view keeps its own filters and requests. Commit metadata, messages and path changes are kept in a persistent index (by object id, in the IDE's cache directory) with seekable paging, and the log gains parent-aware merge comparisons, a lazy revision browser, read-only logs of repositories outside the project (opened with command execution disabled), commit bookmarks, configurable built-in columns and batched commit details. The adapted IntelliJ algorithms are attributed in THIRD_PARTY_LICENSES.md. Closes #872 Co-authored-by: shohruh-genius <[email protected]>
Цей коміт міститься в:
1 джерело
2d20e5a203
коміт
9f2e6b570f
62 files changed
+7473
-881
No files matched your search
+100
-20
@@ -475,7 +475,15 @@ export const commands = {
|
||||
* editor text. Cancellable with `cancel_request(request)`.
|
||||
*/
|
||||
vcsBlockHistory: (request: RequestId, path: string, text: string | null, lines: [number, number]) => typedError<BlockHistory, IdeError>(__TAURI_INVOKE("vcs_block_history", { request, path, text, lines })),
|
||||
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
|
||||
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit_Serialize[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
|
||||
vcsLogRefs: (repo: string | null) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("vcs_log_refs", { repo })),
|
||||
vcsLogUsers: (repo: string | null) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_log_users", { repo })),
|
||||
vcsOpenExternalLog: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("vcs_open_external_log", { path })),
|
||||
vcsLogDetails: (repo: string | null, revision: string) => typedError<LogDetails, IdeError>(__TAURI_INVOKE("vcs_log_details", { repo, revision })),
|
||||
vcsLogDetailsBatch: (repo: string | null, revisions: string[], request: RequestId) => typedError<LogDetails[], IdeError>(__TAURI_INVOKE("vcs_log_details_batch", { repo, revisions, request })),
|
||||
vcsLogIndex: (repo: string | null, request: number | null) => typedError<LogIndexProgress, IdeError>(__TAURI_INVOKE("vcs_log_index", { repo, request })),
|
||||
vcsRevisionChildren: (repo: string | null, revision: string, directory: string) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_revision_children", { repo, revision, directory })),
|
||||
vcsRevisionFiles: (repo: string | null, revision: string) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_revision_files", { repo, revision })),
|
||||
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
|
||||
/** Combined changes of several commits, `commits` oldest first. */
|
||||
vcsCommitsChanges: (repo: string | null, commits: string[]) => typedError<RevisionChange[], IdeError>(__TAURI_INVOKE("vcs_commits_changes", { repo, commits })),
|
||||
@@ -2116,24 +2124,7 @@ export type CommandForm = {
|
||||
formAnswers: FormAnswer[],
|
||||
};
|
||||
|
||||
export type Commit = {
|
||||
id: string,
|
||||
shortId: string,
|
||||
parents: string[],
|
||||
author: string,
|
||||
email: string,
|
||||
timeMs: number | null,
|
||||
summary: string,
|
||||
message: string,
|
||||
refs: string[],
|
||||
/** Committer time (Log › View Options › Commit Timestamp). */
|
||||
commitTimeMs?: number | null,
|
||||
/**
|
||||
* Per parent, under a user or text filter: the link passes commits the filter hides
|
||||
* (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten.
|
||||
*/
|
||||
dashed?: boolean[],
|
||||
};
|
||||
export type Commit = Commit_Serialize | Commit_Deserialize;
|
||||
|
||||
/** Commit Options (IDEA's commit options popup). */
|
||||
export type CommitOptions = {
|
||||
@@ -2174,6 +2165,64 @@ export type CommitSettings = {
|
||||
wrapAtMargin?: boolean,
|
||||
};
|
||||
|
||||
export type Commit_Deserialize = {
|
||||
id: string,
|
||||
shortId: string,
|
||||
parents: string[],
|
||||
/** Connections in the filtered graph; never use these as revision/diff parents. */
|
||||
graphParents?: string[] | null,
|
||||
graphDashed?: boolean[],
|
||||
/** Repository-relative filename at this revision in rename-following history. */
|
||||
historyPath?: string | null,
|
||||
logPosition?: number | null,
|
||||
logTotal?: number | null,
|
||||
graphBoundaryParents?: string[],
|
||||
/** Reachability from HEAD, computed independently of the visible filters. */
|
||||
onCurrentBranch?: boolean | null,
|
||||
author: string,
|
||||
email: string,
|
||||
committer?: string,
|
||||
committerEmail?: string,
|
||||
timeMs: number | null,
|
||||
summary: string,
|
||||
message: string,
|
||||
messageTruncated?: boolean,
|
||||
refs: string[],
|
||||
/** Committer time (Log › View Options › Commit Timestamp). */
|
||||
commitTimeMs?: number | null,
|
||||
/** Legacy alias of `graph_dashed`, indexed by displayed parents. */
|
||||
dashed?: boolean[],
|
||||
};
|
||||
|
||||
export type Commit_Serialize = {
|
||||
id: string,
|
||||
shortId: string,
|
||||
parents: string[],
|
||||
/** Connections in the filtered graph; never use these as revision/diff parents. */
|
||||
graphParents?: string[] | null,
|
||||
graphDashed?: boolean[],
|
||||
/** Repository-relative filename at this revision in rename-following history. */
|
||||
historyPath?: string | null,
|
||||
logPosition?: number | null,
|
||||
logTotal?: number | null,
|
||||
graphBoundaryParents?: string[],
|
||||
/** Reachability from HEAD, computed independently of the visible filters. */
|
||||
onCurrentBranch?: boolean | null,
|
||||
author: string,
|
||||
email: string,
|
||||
committer: string,
|
||||
committerEmail: string,
|
||||
timeMs: number | null,
|
||||
summary: string,
|
||||
message: string,
|
||||
messageTruncated: boolean,
|
||||
refs: string[],
|
||||
/** Committer time (Log › View Options › Commit Timestamp). */
|
||||
commitTimeMs: number | null,
|
||||
/** Legacy alias of `graph_dashed`, indexed by displayed parents. */
|
||||
dashed?: boolean[],
|
||||
};
|
||||
|
||||
export type CompareResult = {
|
||||
repositories: RepositoryComparison[],
|
||||
skippedPaths: IpcPath[],
|
||||
@@ -4857,6 +4906,22 @@ export type Location = {
|
||||
uri?: string | null,
|
||||
};
|
||||
|
||||
export type LogDetails = {
|
||||
message: string,
|
||||
containingBranches: string[],
|
||||
/** Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked. */
|
||||
signature: string,
|
||||
signer: string,
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
};
|
||||
|
||||
export type LogIndexProgress = {
|
||||
indexed: number,
|
||||
total: number,
|
||||
running: boolean,
|
||||
};
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export type LogLocation = {
|
||||
@@ -4870,11 +4935,19 @@ export type LogOrder =
|
||||
/** `--topo-order`: lines of history are not intermixed. */
|
||||
"topo" |
|
||||
/** `--date-order`: commit time, but never a parent before its children. */
|
||||
"date";
|
||||
"date" | "standard";
|
||||
|
||||
export type LogQuery = {
|
||||
speedSearch?: string | null,
|
||||
searchFields?: string[],
|
||||
/** Jump directly to an indexed window containing this commit (50 rows of context). */
|
||||
seek?: string | null,
|
||||
retainUntil?: string | null,
|
||||
branch: string | null,
|
||||
path: IpcPath | null,
|
||||
paths?: IpcPath[],
|
||||
/** File history follows renames; structure filters retain fixed paths. */
|
||||
follow?: boolean,
|
||||
author: string | null,
|
||||
text: string | null,
|
||||
skip: number,
|
||||
@@ -4884,6 +4957,8 @@ export type LogQuery = {
|
||||
* not the left). Takes precedence over `branch`.
|
||||
*/
|
||||
range?: string | null,
|
||||
/** Additional ranges unioned with each other and with selected branches. */
|
||||
ranges?: string[],
|
||||
/** `text` is a regular expression (Log filter `.*`). */
|
||||
textRegex?: boolean,
|
||||
/** `text` matches case-sensitively (Log filter `Cc`). */
|
||||
@@ -4892,6 +4967,7 @@ export type LogQuery = {
|
||||
branches?: string[],
|
||||
/** More author needles next to `author`; a commit matches any of them (Log User filter). */
|
||||
authors?: string[],
|
||||
authorsExact?: boolean,
|
||||
order?: LogOrder,
|
||||
/** Follow only the first parent of merges (`--first-parent`). */
|
||||
firstParent?: boolean,
|
||||
@@ -4904,6 +4980,10 @@ export type LogQuery = {
|
||||
*/
|
||||
sinceMs?: number | null,
|
||||
untilMs?: number | null,
|
||||
request?: RequestId | null,
|
||||
summaryOnly?: boolean,
|
||||
/** Inclusive refresh boundary. Clients join only verified linear prefixes. */
|
||||
stopAt?: string | null,
|
||||
};
|
||||
|
||||
export type LspRefreshKind = "inlayHints" | "semanticTokens" | "codeLens";
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
notifications,
|
||||
resolveFileEditor,
|
||||
useContributions,
|
||||
useStandaloneEditor,
|
||||
type EditorCloseSession,
|
||||
type EditorInput,
|
||||
type OpenOptions,
|
||||
@@ -230,6 +231,7 @@ export class EditorController implements SlotHost {
|
||||
private closeSession: EditorCloseSession | null = null;
|
||||
private closeCommitted = false;
|
||||
private projectGeneration = 0;
|
||||
private replacingProject = false;
|
||||
private admissionGeneration = 0;
|
||||
private openSequence = 0;
|
||||
private selectionRequests = new Map<string, number>();
|
||||
@@ -255,7 +257,14 @@ export class EditorController implements SlotHost {
|
||||
_provideOpenEditorPaths(() => openFilePaths(useTabs.getState()));
|
||||
let prevTabs = useTabs.getState();
|
||||
this.disposers.push(
|
||||
useTabs.subscribe((s) => {
|
||||
useTabs.subscribe((s, previous) => {
|
||||
const open = openKeys(s);
|
||||
const closed = new Map<string, EditorInput>();
|
||||
for (const pane of Object.values(previous.panes))
|
||||
for (const tab of pane.tabs) if (!open.has(tab.input.key)) closed.set(tab.input.key, tab.input);
|
||||
if (!this.replacingProject)
|
||||
for (const input of closed.values())
|
||||
useContributions.getState().editorKinds.get(input.kind)?.onClose?.(input);
|
||||
const prev = prevTabs;
|
||||
prevTabs = s;
|
||||
if (
|
||||
@@ -265,6 +274,11 @@ export class EditorController implements SlotHost {
|
||||
++this.activationGeneration;
|
||||
this.stampActive(s);
|
||||
this.onTabsChanged(prev, s);
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
const standalone = Object.values(s.panes)
|
||||
.flatMap((pane) => pane.tabs)
|
||||
.find((tab) => kinds.get(tab.input.kind)?.standalone);
|
||||
useStandaloneEditor.setState({ title: standalone ? tabTitle(standalone.input, kinds) : null });
|
||||
}),
|
||||
);
|
||||
tabOptions.activateOnClose = editorPrefs().tabs.activateOnClose as typeof tabOptions.activateOnClose;
|
||||
@@ -299,7 +313,12 @@ export class EditorController implements SlotHost {
|
||||
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
|
||||
// The guard decided already. Disposal must never prompt or autosave
|
||||
// custom documents after the backend has replaced their project.
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
this.replacingProject = true;
|
||||
try {
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
} finally {
|
||||
this.replacingProject = false;
|
||||
}
|
||||
this.closeSession = null;
|
||||
this.closeCommitted = false;
|
||||
this.preparedDocuments.clear();
|
||||
@@ -367,6 +386,7 @@ export class EditorController implements SlotHost {
|
||||
this.selectionRequests.clear();
|
||||
this.manager.stop();
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
useStandaloneEditor.setState({ title: null });
|
||||
void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerEditorKind } from "@/workbench/contributions";
|
||||
import { EditorController, useTabs } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
import { initialTabsState } from "./model/tabs";
|
||||
let controller: EditorController | null = null;
|
||||
afterEach(() => {
|
||||
controller?.stop();
|
||||
controller = null;
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
});
|
||||
describe("custom editor close lifecycle", () => {
|
||||
it("notifies once when the last split showing an input closes", async () => {
|
||||
const close = vi.fn();
|
||||
const dispose = registerEditorKind({
|
||||
kind: "close-probe",
|
||||
title: () => "Probe",
|
||||
component: () => null,
|
||||
onClose: close,
|
||||
});
|
||||
controller = new EditorController(createMemoryBackend({}));
|
||||
controller.start();
|
||||
const input = { kind: "close-probe", key: "close-probe:1" };
|
||||
try {
|
||||
await controller.openInput(input);
|
||||
controller.split(useTabs.getState().activePane, "row");
|
||||
const panes = Object.values(useTabs.getState().panes);
|
||||
expect(panes).toHaveLength(2);
|
||||
await controller.closeTab(panes[0]!.id, panes[0]!.tabs[0]!.id);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
await controller.closeInput(input.key);
|
||||
expect(close).toHaveBeenCalledExactlyOnceWith(input);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@ export async function openLocation(
|
||||
pos?: Position | null,
|
||||
options: Omit<OpenOptions, "selection"> = {},
|
||||
): Promise<void> {
|
||||
if (path.startsWith("git-log:")) {
|
||||
const { executeAction } = await import("@/platform/actions");
|
||||
await executeAction("vcs.log.revealBookmark", path);
|
||||
return;
|
||||
}
|
||||
const service = maybeEditorService();
|
||||
if (!service) {
|
||||
notifications().notify({
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import type { Commit } from "@/bindings";
|
||||
import { Dialog, List, SearchField } from "@/ui";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { commitKey } from "./multiRoot";
|
||||
|
||||
export function chooseLogCommit(commits: Commit[], title: string): Promise<Commit | null> {
|
||||
return new Promise((resolve) => {
|
||||
const close = openDialog(() => (
|
||||
<CommitChooser
|
||||
commits={commits}
|
||||
title={title}
|
||||
onDone={(commit) => {
|
||||
close();
|
||||
resolve(commit);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
function CommitChooser({
|
||||
commits,
|
||||
title,
|
||||
onDone,
|
||||
}: {
|
||||
commits: Commit[];
|
||||
title: string;
|
||||
onDone: (commit: Commit | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const shown = commits.filter((c) =>
|
||||
`${c.id} ${c.summary} ${c.author}`.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
|
||||
);
|
||||
return (
|
||||
<Dialog open title={title} onClose={() => onDone(null)} width={520}>
|
||||
<SearchField value={query} onChange={setQuery} placeholder="Search commits" aria-label="Search commits" />
|
||||
<div style={{ height: 260, display: "flex" }}>
|
||||
<List
|
||||
items={shown}
|
||||
getKey={commitKey}
|
||||
renderItem={(c) => `${c.shortId} ${c.summary} — ${c.author}`}
|
||||
onActivate={onDone}
|
||||
rowHeight={26}
|
||||
aria-label={title}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -139,12 +139,23 @@
|
||||
|
||||
/* commit table */
|
||||
.listWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.speedSearch {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-popup);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -165,6 +176,39 @@
|
||||
cursor: default;
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.columnHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg-app);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.columnHeader:hover {
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.columnCell {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.columnHeading {
|
||||
position: relative;
|
||||
padding-inline-end: 6px;
|
||||
cursor: grab;
|
||||
}
|
||||
.columnResize {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
.columnResize:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* View Options › Highlight › Current Branch: commits reachable from HEAD are tinted. */
|
||||
.currentBranch {
|
||||
--log-row-bg: var(--bg-log-current-branch);
|
||||
@@ -342,6 +386,21 @@
|
||||
user-select: text;
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.commitTooltip {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
white-space: normal;
|
||||
user-select: text;
|
||||
}
|
||||
.revisionSource {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.subjectLine {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
@@ -349,6 +408,12 @@
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.expandMessage {
|
||||
display: block;
|
||||
margin-block-start: 6px;
|
||||
font-weight: normal;
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.fullMessage {
|
||||
margin-top: 6px;
|
||||
color: var(--fg);
|
||||
|
||||
+1415
-268
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { EditorState, Text } from "@codemirror/state";
|
||||
import { Decoration, EditorView } from "@codemirror/view";
|
||||
import { Chunk } from "@codemirror/merge";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import type { EditorInput } from "@/workbench/contributions";
|
||||
import { languageFor, viewerBase } from "./cm";
|
||||
import { repositoryPath } from "./repositoryPaths";
|
||||
import styles from "./GitLog.module.css";
|
||||
import { syncMergeScroll } from "./mergeScroll";
|
||||
|
||||
/** Read-only parent / merge result / parent comparison, independent of the worktree. */
|
||||
export function GitMergeDiff({ input }: { input: EditorInput }) {
|
||||
const data = input.data as {
|
||||
repo: string;
|
||||
revision: string;
|
||||
path: string;
|
||||
parents: Array<{ rev: string; path: string }>;
|
||||
};
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const views: EditorView[] = [];
|
||||
let stopScroll: (() => void) | undefined;
|
||||
const sides = [data.parents[0]!, { rev: data.revision, path: data.path }, data.parents[1]!];
|
||||
void Promise.all([
|
||||
Promise.all(sides.map((s) => call(commands.vcsFileAt(repositoryPath(data.repo, s.path), s.rev)))),
|
||||
languageFor(data.path),
|
||||
])
|
||||
.then(([texts, language]) => {
|
||||
if (!alive || !host.current) return;
|
||||
const docs = texts.map((text) => Text.of((text ?? "").split("\n")));
|
||||
const marks = docs.map(() => new Map<number, string>());
|
||||
for (const parent of [0, 2])
|
||||
for (const chunk of Chunk.build(docs[parent]!, docs[1]!)) {
|
||||
for (const [pane, from, to] of [
|
||||
[parent, chunk.fromA, chunk.toA],
|
||||
[1, chunk.fromB, chunk.toB],
|
||||
]) {
|
||||
const doc = docs[pane!]!;
|
||||
for (let line = doc.lineAt(Math.min(from!, doc.length)); line.from < to!;) {
|
||||
marks[pane!]!.set(
|
||||
line.from,
|
||||
chunk.fromA === chunk.toA ? "inserted" : chunk.fromB === chunk.toB ? "deleted" : "modified",
|
||||
);
|
||||
if (line.number === doc.lines) break;
|
||||
line = doc.line(line.number + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [i, text] of texts.entries()) {
|
||||
const pane = document.createElement("div");
|
||||
pane.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column";
|
||||
const label = document.createElement("div");
|
||||
label.textContent = `${sides[i]!.rev.slice(0, 8)} · ${sides[i]!.path}`;
|
||||
pane.append(label);
|
||||
host.current.append(pane);
|
||||
const view = new EditorView({
|
||||
parent: pane,
|
||||
state: EditorState.create({
|
||||
doc: text ?? "",
|
||||
extensions: [
|
||||
viewerBase({ readOnly: true }),
|
||||
...(language ? [language] : []),
|
||||
EditorView.decorations.of(
|
||||
Decoration.set(
|
||||
[...marks[i]!]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([from, kind]) => Decoration.line({ class: `cm-diffLine-${kind}` }).range(from)),
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
}
|
||||
stopScroll = syncMergeScroll({ ours: views[0]!, result: views[1]!, theirs: views[2]! });
|
||||
})
|
||||
.catch((e) => alive && setError(String(e)));
|
||||
const element = host.current;
|
||||
return () => {
|
||||
alive = false;
|
||||
stopScroll?.();
|
||||
views.forEach((v) => v.destroy());
|
||||
element?.replaceChildren();
|
||||
};
|
||||
}, [data]);
|
||||
return (
|
||||
<>
|
||||
<div role={error ? "alert" : undefined}>{error}</div>
|
||||
<div className={styles.revisionSource} style={{ display: "flex" }} ref={host} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import type { EditorInput } from "@/workbench/contributions";
|
||||
import { LogIdContext, LogOptionsContext } from "./logContext";
|
||||
import { rootLogOptions } from "./logOptions";
|
||||
import { ensureEditorLog, focusLogView, parkedLogOptions, useRootGitLog } from "./logStore";
|
||||
import { LogView } from "./GitLog";
|
||||
import styles from "./GitLog.module.css";
|
||||
|
||||
export function LogScope({ id, children }: { id: string; children: ReactNode }) {
|
||||
const active = useRootGitLog((s) => s.shownLog);
|
||||
const options = rootLogOptions();
|
||||
const focus = () => {
|
||||
if (useRootGitLog.getState().shownLog !== id) focusLogView(id);
|
||||
};
|
||||
return (
|
||||
<LogIdContext.Provider value={id}>
|
||||
<LogOptionsContext.Provider value={active === id ? options : (parkedLogOptions(id) ?? options)}>
|
||||
<div className={styles.root} data-git-log onPointerDownCapture={focus} onFocusCapture={focus}>
|
||||
{children}
|
||||
</div>
|
||||
</LogOptionsContext.Provider>
|
||||
</LogIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitLogEditor({ input }: { input: EditorInput }) {
|
||||
const data = input.data as { id: string; label: string };
|
||||
useEffect(() => {
|
||||
ensureEditorLog(data.id, data.label);
|
||||
focusLogView(data.id);
|
||||
}, [data.id, data.label]);
|
||||
return (
|
||||
<LogScope id={data.id}>
|
||||
<LogView />
|
||||
</LogScope>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import { Checkbox, Dialog, Input, Tree } from "@/ui";
|
||||
import { projectFs } from "@/features/project-tree/fs";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { repoName } from "./store";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
interface PathNode {
|
||||
path: string;
|
||||
name: string;
|
||||
directory: boolean;
|
||||
}
|
||||
|
||||
export function chooseLogPaths(roots: string[], initial: string[], readOnly = false): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
const close = openDialog(() => (
|
||||
<PathFilterDialog
|
||||
roots={roots}
|
||||
initial={initial}
|
||||
readOnly={readOnly}
|
||||
onDone={(value) => {
|
||||
close();
|
||||
resolve(value);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
function PathFilterDialog({
|
||||
roots,
|
||||
initial,
|
||||
readOnly,
|
||||
onDone,
|
||||
}: {
|
||||
roots: string[];
|
||||
initial: string[];
|
||||
readOnly: boolean;
|
||||
onDone: (value: string[] | null) => void;
|
||||
}) {
|
||||
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set(initial));
|
||||
const [listings, setListings] = useState<Map<string, PathNode[]>>(() => new Map());
|
||||
const [custom, setCustom] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const toggle = (path: string, on: boolean) =>
|
||||
setChecked((before) => {
|
||||
const next = new Set(before);
|
||||
if (on) next.add(path);
|
||||
else next.delete(path);
|
||||
return next;
|
||||
});
|
||||
const apply = () => {
|
||||
const added = custom
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (
|
||||
added.some(
|
||||
(path) =>
|
||||
!roots.some((root) => path === root || path.startsWith(`${root}/`)) || path.split(/[\\/]/).includes(".."),
|
||||
)
|
||||
) {
|
||||
setError(tx("Choose an absolute path inside a selected repository."));
|
||||
return;
|
||||
}
|
||||
onDone([...new Set([...checked, ...added])]);
|
||||
};
|
||||
const nodes = roots.map((path) => ({ path, name: repoName(path), directory: true }));
|
||||
const load = async (node: PathNode) => {
|
||||
try {
|
||||
const root = roots.find((r) => node.path === r || node.path.startsWith(`${r}/`));
|
||||
if (!root) return;
|
||||
let entries: PathNode[];
|
||||
if (readOnly) {
|
||||
const files = await call(commands.vcsRevisionFiles(root, "HEAD"));
|
||||
const prefix = node.path === root ? "" : `${node.path.slice(root.length + 1)}/`;
|
||||
const children = new Map<string, PathNode>();
|
||||
for (const file of files)
|
||||
if (file.startsWith(prefix)) {
|
||||
const [name, ...rest] = file.slice(prefix.length).split("/");
|
||||
if (name) children.set(name, { name, path: `${node.path}/${name}`, directory: rest.length > 0 });
|
||||
}
|
||||
entries = [...children.values()].sort(
|
||||
(a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name),
|
||||
);
|
||||
} else
|
||||
entries = (await projectFs(root).readDir(node.path))
|
||||
.filter((entry) => entry.name !== ".git")
|
||||
.map((entry) => ({ name: entry.name, path: entry.path, directory: entry.kind === "dir" }));
|
||||
setListings((before) => new Map(before).set(node.path, entries));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Filter by Paths"
|
||||
width={560}
|
||||
onClose={() => onDone(null)}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", onClick: apply },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div style={{ height: 320, display: "flex" }}>
|
||||
<Tree<PathNode>
|
||||
roots={nodes}
|
||||
getKey={(n) => n.path}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => (n.directory ? listings.get(n.path) : undefined)}
|
||||
isExpandable={(n) => n.directory}
|
||||
loadChildren={load}
|
||||
renderNode={(n) => (
|
||||
<Checkbox label={n.name} checked={checked.has(n.path)} onChange={(on) => toggle(n.path, on)} />
|
||||
)}
|
||||
aria-label="Files and folders"
|
||||
/>
|
||||
</div>
|
||||
{checked.size > 0 && (
|
||||
<div style={{ maxHeight: 100, overflow: "auto" }} aria-label="Selected paths">
|
||||
{[...checked].map((path) => (
|
||||
<Checkbox key={path} label={path} checked onChange={(on) => toggle(path, on)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
placeholder="Additional absolute path"
|
||||
aria-label="Additional absolute path"
|
||||
/>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { Commit } from "@/bindings";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
|
||||
import {
|
||||
Dialog,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
SearchField,
|
||||
Spinner,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Tree,
|
||||
T,
|
||||
copyText,
|
||||
showContextMenu,
|
||||
} from "@/ui";
|
||||
import { openDialog } from "./dialogs";
|
||||
|
||||
import { commitRepo, type RepoCommit } from "./multiRoot";
|
||||
import { currentRepoRoot, useVcs } from "./store";
|
||||
import { repositoryPath } from "./repositoryPaths";
|
||||
import { languageFor, viewerBase } from "./cm";
|
||||
import styles from "./GitLog.module.css";
|
||||
import { choosePath } from "@/features/settings/languages/api";
|
||||
import { openLogTab } from "./logStore";
|
||||
|
||||
interface RevisionData {
|
||||
repo: string;
|
||||
revision: string;
|
||||
path: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export async function openExternalLog() {
|
||||
const picked = isTauri()
|
||||
? await (
|
||||
await import("@tauri-apps/plugin-dialog")
|
||||
).open({ title: tx("Open Git Log"), directory: true, multiple: true })
|
||||
: await choosePath({ title: "Open Git Log", directory: true });
|
||||
const paths = Array.isArray(picked) ? picked : picked ? [picked] : [];
|
||||
if (!paths.length) return;
|
||||
try {
|
||||
const roots = await Promise.all(paths.map((path) => call(commands.vcsOpenExternalLog(path))));
|
||||
openLogTab("external", roots.join(", "), "editor", roots);
|
||||
} catch (error) {
|
||||
notifications().notify({ group: "vcs", level: "error", title: "Could not open Git log", body: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
export function browseRepositoryAt(commit: Commit) {
|
||||
const repo = commitRepo(commit) ?? currentRepoRoot();
|
||||
if (!repo) return;
|
||||
openDialog((close) => (
|
||||
<RevisionBrowser repo={repo} revision={commit.id} external={(commit as RepoCommit).readOnly} onClose={close} />
|
||||
));
|
||||
}
|
||||
|
||||
function RevisionBrowser({
|
||||
repo,
|
||||
revision,
|
||||
external,
|
||||
onClose,
|
||||
}: {
|
||||
repo: string;
|
||||
revision: string;
|
||||
external?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [directories, setDirectories] = useState<Record<string, string[]>>({});
|
||||
const files = directories[""] ?? null;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchFiles, setSearchFiles] = useState<string[] | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void call(commands.vcsRevisionChildren(repo, revision, ""))
|
||||
.then((files) => {
|
||||
if (alive) setDirectories({ "": files });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (alive) setError(String(error));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [repo, revision]);
|
||||
useEffect(() => {
|
||||
if (!query || searchFiles) return;
|
||||
let alive = true;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
void call(commands.vcsRevisionFiles(repo, revision))
|
||||
.then((files) => {
|
||||
if (alive) setSearchFiles(files);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (alive) setError(String(error));
|
||||
}),
|
||||
250,
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [query, searchFiles, repo, revision]);
|
||||
const tree = useMemo(
|
||||
() =>
|
||||
(query ? (searchFiles ?? []) : (files ?? [])).filter(
|
||||
(path) => !query || path.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
|
||||
),
|
||||
[files, query, searchFiles],
|
||||
);
|
||||
const load = async (path: string) => {
|
||||
const children = await call(commands.vcsRevisionChildren(repo, revision, path.replace(/\/$/, "")));
|
||||
setDirectories((current) => ({ ...current, [path]: children }));
|
||||
};
|
||||
const open = (path: string) => {
|
||||
if (useVcs.getState().root) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify({ repo, revision, path }));
|
||||
const encoded = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
void editorService().openFile(`git-revision:${encoded}/${encodeURIComponent(path.split("/").at(-1)!)}`, {
|
||||
focus: true,
|
||||
});
|
||||
} else
|
||||
void editorService().openInput({
|
||||
kind: "git-revision",
|
||||
key: `git-revision:${repo}\0${revision}\0${path}`,
|
||||
data: { repo, revision, path, external } satisfies RevisionData,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog open title={tx`Repository at ${revision.slice(0, 8)}`} onClose={onClose} width={1000}>
|
||||
<div style={{ height: "min(70vh, 640px)", display: "flex", flexDirection: "column" }}>
|
||||
<SearchField
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search repository files"
|
||||
aria-label="Search repository files"
|
||||
/>
|
||||
{error ? (
|
||||
<div role="alert">{error}</div>
|
||||
) : !files ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<SplitGroup orientation="horizontal" id="git-revision-browser">
|
||||
<SplitPanel id="files" defaultSize="30%" minSize="20%">
|
||||
<Tree
|
||||
roots={tree}
|
||||
getKey={(n) => n}
|
||||
getLabel={(n) => n.replace(/\/$/, "").split("/").at(-1)!}
|
||||
getChildren={(n) => directories[n]}
|
||||
isExpandable={(n) => n.endsWith("/")}
|
||||
loadChildren={load}
|
||||
onSelectionChange={(_, n) => {
|
||||
if (n && !n.endsWith("/")) setSelected(n);
|
||||
}}
|
||||
onActivate={(n) => {
|
||||
if (!n.endsWith("/")) open(n);
|
||||
}}
|
||||
onContextMenu={(node, _selection, e) => {
|
||||
if (!node) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [
|
||||
{ label: "Open Revision in Editor", disabled: node.endsWith("/"), run: () => open(node) },
|
||||
{ label: "Copy Path", run: () => copyText(node) },
|
||||
{
|
||||
label: "Show History",
|
||||
run: () => openLogTab("history", repositoryPath(repo, node.replace(/\/$/, "")), "editor"),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
aria-label="Repository files"
|
||||
/>
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="source" minSize="30%">
|
||||
{selected ? (
|
||||
<div className={styles.filesPane}>
|
||||
<div className={styles.detailsBar}>
|
||||
{selected}
|
||||
<IconButton icon="external-link" title="Open Revision in Editor" onClick={() => open(selected)} />
|
||||
</div>
|
||||
<RevisionSource key={selected} repo={repo} revision={revision} path={selected} />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<T>Select a file to view its repository version</T>
|
||||
</EmptyState>
|
||||
)}
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitRevisionEditor({ input }: { input: EditorInput }) {
|
||||
return <RevisionSource {...(input.data as RevisionData)} />;
|
||||
}
|
||||
|
||||
function RevisionSource({ repo, revision, path, external }: RevisionData) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const key = `${repo}\0${revision}\0${path}`;
|
||||
const [error, setError] = useState<{ key: string; message: string } | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let view: EditorView | null = null;
|
||||
const absolute = repositoryPath(repo, path);
|
||||
const content = async () => {
|
||||
if (external) await call(commands.vcsOpenExternalLog(repo));
|
||||
return call(commands.vcsFileAt(absolute, revision));
|
||||
};
|
||||
void Promise.all([content(), languageFor(path)])
|
||||
.then(([text, language]) => {
|
||||
if (!alive || !host.current) return;
|
||||
view = new EditorView({
|
||||
parent: host.current,
|
||||
state: EditorState.create({
|
||||
doc: text ?? "",
|
||||
extensions: [viewerBase({ readOnly: true }), ...(language ? [language] : [])],
|
||||
}),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (alive) setError({ key, message: String(error) });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
view?.destroy();
|
||||
};
|
||||
}, [repo, revision, path, external, key]);
|
||||
return (
|
||||
<div className={styles.revisionSource}>
|
||||
{error?.key === key && <div role="alert">{error.message}</div>}
|
||||
<div className={styles.revisionSource} ref={host} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
vcsFileAt: async (_path: string, rev: string) => ({
|
||||
status: "ok",
|
||||
data: `const value = ${rev === "merge" ? 3 : rev === "left" ? 1 : 2};\n`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
import { GitMergeDiff } from "../GitMergeDiff";
|
||||
describe("historical merge comparison", () => {
|
||||
it("shows and highlights both parents and the immutable result without an editable worktree pane", async () => {
|
||||
const { container, unmount } = render(
|
||||
<GitMergeDiff
|
||||
input={{
|
||||
kind: "git-merge-diff",
|
||||
key: "merge:file",
|
||||
data: {
|
||||
repo: "/repo",
|
||||
path: "a.ts",
|
||||
revision: "merge",
|
||||
parents: [
|
||||
{ rev: "left", path: "a.ts" },
|
||||
{ rev: "right", path: "a.ts" },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(3));
|
||||
expect(container.querySelectorAll('.cm-content[contenteditable="false"]')).toHaveLength(3);
|
||||
expect(container.querySelectorAll(".cm-diffLine-modified").length).toBeGreaterThanOrEqual(3);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +1,94 @@
|
||||
import { commitKey } from "../multiRoot";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Commit } from "@/bindings";
|
||||
|
||||
const vcsCommitFiles = vi.fn();
|
||||
const vcsCommitsChanges = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
const api = vi.hoisted(() => ({ batch: vi.fn(), cancel: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
vcsCommitFiles: (repo: unknown, id: string) => vcsCommitFiles(repo, id),
|
||||
vcsCommitsChanges: (repo: unknown, ids: string[]) => vcsCommitsChanges(repo, ids),
|
||||
vcsLogDetailsBatch: (...args: unknown[]) => api.batch(...args),
|
||||
cancelRequest: (...args: unknown[]) => api.cancel(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { CommitDetails } from "../GitLog";
|
||||
import { loadLogDetails } from "../logDetails";
|
||||
import { useVcs } from "../store";
|
||||
import { useGitLog } from "../logStore";
|
||||
|
||||
const commit = (summary: string, d: string): Commit => ({
|
||||
id: d.repeat(40),
|
||||
shortId: d.repeat(8),
|
||||
const commit = (id: string): Commit => ({
|
||||
id,
|
||||
shortId: id,
|
||||
parents: [],
|
||||
author: "A",
|
||||
email: "[email protected]",
|
||||
timeMs: 0,
|
||||
summary,
|
||||
message: `${summary}\n\nBody of ${summary}`,
|
||||
refs: [],
|
||||
author: "A",
|
||||
email: "a@x",
|
||||
summary: id,
|
||||
message: id,
|
||||
timeMs: 0,
|
||||
});
|
||||
|
||||
// Log order: newest first.
|
||||
const log = [commit("Third", "3"), commit("Second", "2"), commit("First", "1")];
|
||||
|
||||
afterEach(() => {
|
||||
useGitLog.setState({ commits: [], selection: [], selected: null });
|
||||
vcsCommitFiles.mockReset();
|
||||
vcsCommitsChanges.mockReset();
|
||||
beforeEach(() => {
|
||||
api.batch.mockReset().mockImplementation(async (_repo: string, ids: string[]) => ({
|
||||
status: "ok",
|
||||
data: ids.map((id) => ({
|
||||
message: id,
|
||||
containingBranches: [],
|
||||
signature: "N",
|
||||
signer: "",
|
||||
key: "",
|
||||
fingerprint: "",
|
||||
})),
|
||||
}));
|
||||
api.cancel.mockReset().mockResolvedValue(undefined);
|
||||
useVcs.setState((s) => ({ repo: "/repo", root: "/repo", version: s.version + 1 }));
|
||||
});
|
||||
|
||||
describe("Git Log commit details", () => {
|
||||
it("shows one commit's files and full message", async () => {
|
||||
vcsCommitFiles.mockResolvedValue({ status: "ok", data: [{ path: "a.ts", kind: "modified", oldPath: null }] });
|
||||
useGitLog.setState({ commits: log, selected: log[1]!.id, selection: [log[1]!.id] });
|
||||
render(<CommitDetails commits={log} />);
|
||||
expect(await screen.findByText("1 file")).toBeInTheDocument();
|
||||
expect(vcsCommitFiles).toHaveBeenCalledWith(null, log[1]!.id);
|
||||
expect(screen.getByText("Body of Second")).toBeInTheDocument();
|
||||
describe("commit detail jobs", () => {
|
||||
it("retries immediately after the last consumer cancels without losing the replacement cache entry", async () => {
|
||||
let rejectOld!: (error: Error) => void;
|
||||
api.batch.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectOld = reject;
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const old = loadLogDetails(commit("retry-cancelled"), controller.signal).catch((error: unknown) => error);
|
||||
await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(1));
|
||||
controller.abort();
|
||||
const replacement = loadLogDetails(commit("retry-cancelled"));
|
||||
await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(2));
|
||||
await expect(replacement).resolves.toMatchObject({ message: "retry-cancelled" });
|
||||
rejectOld(new Error("Cancelled"));
|
||||
await old;
|
||||
await loadLogDetails(commit("retry-cancelled"));
|
||||
expect(api.batch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("combines the changes of several selected commits, oldest first, and lists them", async () => {
|
||||
vcsCommitsChanges.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [
|
||||
{ path: "a.ts", kind: "modified", oldPath: null, before: "0".repeat(40), after: log[0]!.id },
|
||||
{ path: "b.ts", kind: "added", oldPath: null, before: null, after: log[2]!.id },
|
||||
],
|
||||
});
|
||||
// Selected in click order (Cmd-click), not log order; non-contiguous.
|
||||
useGitLog.setState({ commits: log, selected: log[0]!.id, selection: [log[2]!.id, log[0]!.id] });
|
||||
render(<CommitDetails commits={log} />);
|
||||
expect(await screen.findByText(/2 files\s*in 2 commits/)).toBeInTheDocument();
|
||||
expect(vcsCommitsChanges).toHaveBeenCalledWith(null, [log[2]!.id, log[0]!.id]);
|
||||
expect(vcsCommitFiles).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.getByText("Third")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Second")).toBeNull();
|
||||
it("batches rows and reuses results across log generations", async () => {
|
||||
const [a, b] = await Promise.all([loadLogDetails(commit("batch-a")), loadLogDetails(commit("batch-b"))]);
|
||||
expect([a.message, b.message]).toEqual(["batch-a", "batch-b"]);
|
||||
expect(api.batch).toHaveBeenCalledTimes(1);
|
||||
expect(api.batch.mock.calls[0]![1]).toEqual(["batch-a", "batch-b"]);
|
||||
useGitLog.setState((s) => ({ generation: s.generation + 1 }));
|
||||
await loadLogDetails(commit("batch-a"));
|
||||
expect(api.batch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("loads the selected root even when two repositories contain the same SHA", async () => {
|
||||
const front = { ...commit("Front", "1"), repo: "/front" };
|
||||
const back = { ...commit("Back", "1"), repo: "/back" };
|
||||
vcsCommitFiles.mockResolvedValue({ status: "ok", data: [{ path: "same.ts", kind: "modified", oldPath: null }] });
|
||||
useGitLog.setState({ commits: [front, back], selected: commitKey(back), selection: [commitKey(back)] });
|
||||
render(<CommitDetails commits={[front, back]} />);
|
||||
expect(await screen.findByText("1 file")).toBeInTheDocument();
|
||||
expect(vcsCommitFiles).toHaveBeenCalledWith("/back", back.id);
|
||||
expect(screen.getByText("Body of Back")).toBeInTheDocument();
|
||||
it("cancels running native work only when its last consumer leaves", async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
api.batch.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const a = new AbortController(),
|
||||
b = new AbortController();
|
||||
const first = loadLogDetails(commit("shared"), a.signal);
|
||||
const second = loadLogDetails(commit("shared"), b.signal);
|
||||
await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(1));
|
||||
a.abort();
|
||||
expect(api.cancel).not.toHaveBeenCalled();
|
||||
b.abort();
|
||||
expect(api.cancel).toHaveBeenCalledWith(api.batch.mock.calls[0]![2]);
|
||||
finish({ status: "ok", data: [{ message: "shared" }] });
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Commit } from "@/bindings";
|
||||
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "../graph";
|
||||
import { commitKey, graphCommit } from "../multiRoot";
|
||||
import { GraphCell } from "../GitLog";
|
||||
import { toLogChanges } from "../logChanges";
|
||||
|
||||
const commit = (id: string, parents: string[] = [], refs: string[] = []): Commit => ({
|
||||
id,
|
||||
shortId: id,
|
||||
parents,
|
||||
refs,
|
||||
author: "Author",
|
||||
email: "[email protected]",
|
||||
summary: id,
|
||||
message: id,
|
||||
timeMs: 0,
|
||||
});
|
||||
|
||||
describe("graph projections", () => {
|
||||
it("draws a filtered edge without changing the revision used for a file diff", () => {
|
||||
const c = { ...commit("tip", ["hidden"]), repo: "/repo", graphParents: ["base"], graphDashed: [true] };
|
||||
const graph = graphCommit(c);
|
||||
expect(graph.parents).toEqual([commitKey({ id: "base", repo: "/repo" })]);
|
||||
expect(computeGraph([graph]).rows[0]!.down[0]!.dashed).toBe(true);
|
||||
const [file] = toLogChanges([{ path: "a.ts", kind: "modified", oldPath: null }], c);
|
||||
expect(file!.before).toBe("hidden");
|
||||
expect(c.parents).toEqual(["hidden"]);
|
||||
});
|
||||
|
||||
it("preserves filter dashes even when collapse hides no additional commits", () => {
|
||||
const c = { ...commit("tip", ["base"]), dashed: [true] };
|
||||
const collapsed = collapseLinear([c, commit("base")], new Set(["tip"]));
|
||||
expect(collapsed.graph[0]!.dashed).toEqual([true]);
|
||||
});
|
||||
|
||||
it("keeps selected commits and collapses only the requested fragment", () => {
|
||||
const chain = [
|
||||
commit("tip", ["selected"]),
|
||||
commit("selected", ["middle"]),
|
||||
commit("middle", ["base"]),
|
||||
commit("base"),
|
||||
];
|
||||
const result = collapseLinear(chain, new Set(["selected"]), new Set(["selected", "middle"]));
|
||||
expect(result.commits.map((c) => c.id)).toEqual(["tip", "selected", "base"]);
|
||||
expect(result.graph[1]).toMatchObject({ parents: ["base"], dashed: [true] });
|
||||
});
|
||||
|
||||
it("retains every node and lane beyond twenty-four simultaneous branches", () => {
|
||||
const commits = Array.from({ length: 36 }, (_, i) => commit(`tip${i}`, [`base${i}`]));
|
||||
const first = computeGraph(commits);
|
||||
const next = computeGraph(
|
||||
Array.from({ length: 36 }, (_, i) => commit(`base${i}`)),
|
||||
first.state,
|
||||
);
|
||||
expect(first.rows.at(-1)!.width).toBe(36);
|
||||
expect(next.rows.map((r) => r.lane)).toEqual(Array.from({ length: 36 }, (_, i) => i));
|
||||
expect(next.state.lanes).toEqual([]);
|
||||
const { container } = render(<GraphCell row={first.rows.at(-1)!} />);
|
||||
expect(Number(container.querySelector("svg")!.getAttribute("width"))).toBeGreaterThan(24 * 10);
|
||||
});
|
||||
|
||||
it("makes long-edge arrows navigate to the correct endpoints by keyboard", () => {
|
||||
const chain = Array.from({ length: 40 }, (_, i) => commit(`x${i}`, i < 39 ? [`x${i + 1}`] : []));
|
||||
const rows = hideLongEdges(computeGraph([commit("tip", ["base"]), ...chain, commit("base")]).rows);
|
||||
const down = rows.find((r) => r.down.some((s) => s.arrow === "down"))!;
|
||||
const up = rows.find((r) => r.up.some((s) => s.arrow === "up"))!;
|
||||
const navigate = vi.fn();
|
||||
const { rerender } = render(<GraphCell row={down} onNavigate={navigate} targetTitle={(i) => `Go to ${i}`} />);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Go to 41" }), { key: "Enter" });
|
||||
expect(navigate).toHaveBeenLastCalledWith(41, "base");
|
||||
rerender(<GraphCell row={up} onNavigate={navigate} targetTitle={(i) => `Go to ${i}`} />);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Go to 0" }), { key: " " });
|
||||
expect(navigate).toHaveBeenLastCalledWith(0, "base");
|
||||
});
|
||||
|
||||
it("keeps a referenced branch's colour when an unrelated head is inserted ahead of it", () => {
|
||||
const main = commit("main-tip", ["base"], ["HEAD", "main"]);
|
||||
const old = computeGraph([main, commit("base")]).rows[0]!;
|
||||
const next = computeGraph([commit("tag-tip", [], ["tag: release"]), main, commit("base")]).rows[1]!;
|
||||
expect(next.color).toBe(old.color);
|
||||
expect(next.lane).toBe(old.lane);
|
||||
});
|
||||
|
||||
it("targets the exact incoming fragment instead of the row or its reused colour", () => {
|
||||
const graph = computeGraph([
|
||||
{ ...commit("tip", ["base"]), dashed: [true], edgeTypes: ["collapsed" as const] },
|
||||
commit("other"),
|
||||
commit("base"),
|
||||
]);
|
||||
const fragment = vi.fn();
|
||||
render(<GraphCell row={graph.rows[1]!} onFragment={fragment} />);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Expand this fragment" })[0]!);
|
||||
expect(fragment).toHaveBeenCalledWith("base", true, "tip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("linear merge presentation", () => {
|
||||
const commits = [
|
||||
commit("merge", ["main", "feature"]),
|
||||
commit("feature", ["feature-base"]),
|
||||
commit("feature-base", ["base"]),
|
||||
commit("main", ["base"]),
|
||||
commit("base"),
|
||||
];
|
||||
|
||||
it("serializes a contained incoming block and expands back to the actual graph", () => {
|
||||
const original = structuredClone(commits);
|
||||
const linear = linearizeMerges(commits);
|
||||
expect(linear.graph.map((c) => c.id)).toEqual(commits.map((c) => c.id));
|
||||
expect(linear.graph[0]!.parents).toEqual(["feature"]);
|
||||
expect(linear.graph[2]).toMatchObject({ parents: ["main"], dashed: [true] });
|
||||
expect(linear.fragments.get("feature-base\0main")).toBe("merge");
|
||||
expect(linearizeMerges(commits, new Set(["merge"])).graph.map((c) => c.parents)).toEqual(
|
||||
commits.map((c) => c.parents),
|
||||
);
|
||||
expect(commits).toEqual(original);
|
||||
});
|
||||
|
||||
it("linearizes shared fragments while preserving their other incoming edges", () => {
|
||||
const shared = [commit("other", ["feature"]), ...commits];
|
||||
expect(linearizeMerges(shared).graph[1]!.parents).toEqual(["feature"]);
|
||||
expect(linearizeMerges(shared).graph[0]!.parents).toEqual(["feature"]);
|
||||
expect(linearizeMerges(commits.slice(0, 3)).graph[0]!.parents).toEqual(["main", "feature"]);
|
||||
});
|
||||
|
||||
it("expands nested merge fragments together with their containing merge", () => {
|
||||
const nested = [
|
||||
commit("outer", ["main", "nested"]),
|
||||
commit("nested", ["feature", "topic"]),
|
||||
commit("topic", ["base"]),
|
||||
commit("feature", ["base"]),
|
||||
commit("main", ["base"]),
|
||||
commit("base"),
|
||||
];
|
||||
const collapsed = linearizeMerges(nested);
|
||||
expect(collapsed.graph[0]!.parents).toEqual(["nested"]);
|
||||
expect(collapsed.graph[1]!.parents).toEqual(["topic"]);
|
||||
expect(linearizeMerges(nested, new Set(["outer"])).graph.map((c) => c.parents)).toEqual(
|
||||
nested.map((c) => c.parents),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Commit, LogQuery } from "@/bindings";
|
||||
|
||||
const api = vi.hoisted(() => ({ log: vi.fn(), refs: vi.fn(), cancel: vi.fn(), choose: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
vcsLog: (repo: string | null, query: LogQuery) => api.log(repo, query),
|
||||
vcsLogRefs: () => api.refs(),
|
||||
cancelRequest: (id: number) => api.cancel(id),
|
||||
vcsBranches: () => Promise.resolve({ status: "ok", data: [] }),
|
||||
vcsBranchInfo: () => Promise.resolve({ status: "ok", data: { upstream: null } }),
|
||||
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
|
||||
vcsOpenExternalLog: (repo: string) => Promise.resolve({ status: "ok", data: repo }),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../CommitChooser", () => ({ chooseLogCommit: (commits: Commit[]) => api.choose(commits) }));
|
||||
|
||||
import { commitKey } from "../multiRoot";
|
||||
import { useVcs } from "../store";
|
||||
import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions";
|
||||
import {
|
||||
displayedLogGraph,
|
||||
emptyFilters,
|
||||
extendCommitSelection,
|
||||
goToChild,
|
||||
goToParent,
|
||||
goToRef,
|
||||
logState,
|
||||
openLogTab,
|
||||
selectGitTab,
|
||||
PAGE,
|
||||
reloadLog,
|
||||
setLogBranches,
|
||||
toggleCommitSelection,
|
||||
updateLogOptions,
|
||||
useGitLog,
|
||||
} from "../logStore";
|
||||
|
||||
const c = (id: string, parents: string[] = [], refs: string[] = []): Commit => ({
|
||||
id,
|
||||
shortId: id,
|
||||
parents,
|
||||
refs,
|
||||
author: "A",
|
||||
email: "[email protected]",
|
||||
timeMs: 0,
|
||||
summary: id,
|
||||
message: id,
|
||||
});
|
||||
const ok = <T>(data: T) => ({ status: "ok" as const, data });
|
||||
|
||||
beforeEach(() => {
|
||||
api.log.mockReset().mockResolvedValue(ok([]));
|
||||
api.refs.mockReset().mockResolvedValue(ok({}));
|
||||
api.cancel.mockReset().mockResolvedValue(undefined);
|
||||
api.choose.mockReset().mockImplementation(async (commits: Commit[]) => commits[1]);
|
||||
useVcs.setState({ root: "/repo", repo: "/repo", roots: ["/repo"], branches: [] });
|
||||
useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS });
|
||||
useGitLog.setState({
|
||||
shownLog: "log",
|
||||
tab: "log",
|
||||
logTabs: [],
|
||||
filters: emptyFilters(),
|
||||
commits: [],
|
||||
rows: [],
|
||||
selection: [],
|
||||
selected: null,
|
||||
done: false,
|
||||
fragments: { expanded: new Set(), collapsed: new Set(), merges: new Set() },
|
||||
generation: useGitLog.getState().generation + 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
api.log.mockResolvedValue(ok([]));
|
||||
await reloadLog();
|
||||
});
|
||||
|
||||
describe("incremental log refresh", () => {
|
||||
it("lets an inactive visible scope finish its own request without cancelling the active scope", async () => {
|
||||
let finish!: (value: ReturnType<typeof ok<Commit[]>>) => void;
|
||||
api.log.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const main = reloadLog();
|
||||
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||
const request = api.log.mock.lastCall![1].request;
|
||||
const other = openLogTab("log", "Other");
|
||||
api.log.mockResolvedValue(ok([c("other")]));
|
||||
await reloadLog();
|
||||
finish(ok([c("main")]));
|
||||
await main;
|
||||
expect(api.cancel).not.toHaveBeenCalledWith(request);
|
||||
expect(logState("log").commits[0]!.id).toBe("main");
|
||||
expect(logState(other).commits[0]!.id).toBe("other");
|
||||
selectGitTab("log");
|
||||
expect(useGitLog.getState().commits[0]!.id).toBe("main");
|
||||
});
|
||||
it("joins a proven linear prefix and refreshes decorations without dropping loaded history", async () => {
|
||||
useGitLog.setState({ filters: { ...emptyFilters(), branches: ["HEAD"] } });
|
||||
api.log.mockResolvedValue(ok([c("old", ["base"], ["HEAD", "main"]), c("base")]));
|
||||
await reloadLog();
|
||||
api.log.mockResolvedValue(ok([c("new", ["old"], ["HEAD", "main"]), c("old", ["base"])]));
|
||||
api.refs.mockResolvedValue(ok({ HEAD: "new", main: "new", "tag: v1": "base" }));
|
||||
await reloadLog({ preserve: true });
|
||||
expect(api.log.mock.lastCall?.[1]).toMatchObject({ stopAt: "old", skip: 0 });
|
||||
expect(useGitLog.getState().commits.map((c) => [c.id, c.refs])).toEqual([
|
||||
["new", ["HEAD", "main"]],
|
||||
["old", []],
|
||||
["base", ["tag: v1"]],
|
||||
]);
|
||||
expect(useGitLog.getState().done).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches a full replacement when the prefix introduces a merge", async () => {
|
||||
useGitLog.setState({ filters: { ...emptyFilters(), branches: ["HEAD"] } });
|
||||
api.log.mockResolvedValue(ok([c("old"), c("stale")]));
|
||||
await reloadLog();
|
||||
const replacement = [c("merge", ["old", "side"]), c("side", ["old"]), c("old")];
|
||||
api.log.mockResolvedValue(ok(replacement));
|
||||
await reloadLog({ preserve: true });
|
||||
expect(api.log.mock.calls.slice(-2).map((call) => call[1].stopAt)).toEqual(["old", null]);
|
||||
expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["merge", "side", "old"]);
|
||||
});
|
||||
|
||||
it("intersects repository and path filters and clears rows for an empty intersection", async () => {
|
||||
useVcs.setState({ roots: ["/repo/front", "/repo/back"] });
|
||||
useGitLog.setState({
|
||||
filters: { ...emptyFilters(), paths: ["/repo/front/a", "/repo/back/b"], repositories: ["/repo/back"] },
|
||||
});
|
||||
api.log.mockResolvedValue(ok([c("back")]));
|
||||
await reloadLog();
|
||||
expect(api.log.mock.lastCall).toEqual([
|
||||
"/repo/back",
|
||||
expect.objectContaining({ paths: ["/repo/back/b"], authorsExact: true }),
|
||||
]);
|
||||
api.log.mockClear();
|
||||
useGitLog.setState({ filters: { ...emptyFilters(), path: "/repo/front/a", repositories: ["/repo/back"] } });
|
||||
await reloadLog({ preserve: true });
|
||||
expect(api.log).not.toHaveBeenCalled();
|
||||
expect(useGitLog.getState().commits).toEqual([]);
|
||||
expect(useGitLog.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps multi-path filters scoped to their owning external repositories", async () => {
|
||||
useVcs.setState({ root: null, repo: null, roots: [] });
|
||||
openLogTab("external", "/a", "toolWindow", ["/a", "/b", "/c"]);
|
||||
useGitLog.setState({ filters: { ...emptyFilters(), paths: ["/a/src", "/b/test"] } });
|
||||
await reloadLog();
|
||||
expect(api.log.mock.calls).toEqual([
|
||||
["/a", expect.objectContaining({ paths: ["/a/src"] })],
|
||||
["/b", expect.objectContaining({ paths: ["/b/test"] })],
|
||||
]);
|
||||
api.log.mockClear();
|
||||
useGitLog.setState({ filters: { ...emptyFilters(), path: "/outside/file" } });
|
||||
await reloadLog();
|
||||
expect(api.log).not.toHaveBeenCalled();
|
||||
expect(useGitLog.getState().commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancels a superseded page and ignores its later response", async () => {
|
||||
let finish!: (value: ReturnType<typeof ok<Commit[]>>) => void;
|
||||
api.log.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const old = reloadLog();
|
||||
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||
const request = api.log.mock.lastCall![1].request;
|
||||
api.log.mockResolvedValue(ok([c("fresh")]));
|
||||
await reloadLog();
|
||||
expect(api.cancel).toHaveBeenCalledWith(request);
|
||||
finish(ok([c("stale")]));
|
||||
await old;
|
||||
expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["fresh"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit navigation", () => {
|
||||
it("resolves references in the repositories of a standalone multi-root log", async () => {
|
||||
useVcs.setState({ root: null, repo: null, roots: [] });
|
||||
openLogTab("external", "/a", "toolWindow", ["/a", "/b"]);
|
||||
api.log.mockImplementation(async (repo: string) => ok(repo ? [c("target")] : []));
|
||||
expect(await goToRef("main")).toBe(true);
|
||||
expect(api.choose).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ repo: "/a" }),
|
||||
expect.objectContaining({ repo: "/b" }),
|
||||
]);
|
||||
expect(useGitLog.getState().selected).toBe(commitKey({ ...c("target"), repo: "/b" }));
|
||||
expect(api.log.mock.calls.every(([repo]) => repo === "/a" || repo === "/b")).toBe(true);
|
||||
});
|
||||
|
||||
it("jumps to an indexed window beyond thirty pages without paging through the prefix", async () => {
|
||||
const history = Array.from({ length: PAGE * 31 + 7 }, (_, i) => c(`commit${i}`));
|
||||
const target = history.at(-1)!;
|
||||
api.log.mockImplementation(async (_repo: string, q: LogQuery) =>
|
||||
ok(q.branch === target.id || q.seek === target.id ? [target] : history.slice(q.skip, q.skip + q.limit)),
|
||||
);
|
||||
expect(await goToRef(target.id)).toBe(true);
|
||||
expect(useGitLog.getState().selected).toBe(commitKey({ ...target, repo: "/repo" }));
|
||||
expect(api.log.mock.calls.some((call) => call[1].seek === target.id)).toBe(true);
|
||||
expect(api.log.mock.calls.length).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it("navigates the displayed collapsed adjacency in both directions", async () => {
|
||||
const commits = [c("tip", ["middle"]), c("middle", ["base"]), c("base")];
|
||||
useGitLog.setState({ commits, done: true });
|
||||
updateLogOptions({ collapseLinear: true });
|
||||
await goToParent(commits[0]!);
|
||||
expect(useGitLog.getState().selected).toBe("base");
|
||||
await goToChild(commits[2]!);
|
||||
expect(useGitLog.getState().selected).toBe("tip");
|
||||
});
|
||||
|
||||
it("expands merges in linearized mode and retains expansion across new pages", () => {
|
||||
const commits = [c("merge", ["main", "side"]), c("side", ["base"]), c("main", ["base"]), c("base")];
|
||||
useGitLog.setState({ commits });
|
||||
useLogOptions.setState({ linearizeMerges: true });
|
||||
expect(displayedLogGraph().graph[0]!.parents).toEqual(["side"]);
|
||||
updateLogOptions({ collapseLinear: false });
|
||||
expect(displayedLogGraph().graph[0]!.parents).toEqual(["main", "side"]);
|
||||
updateLogOptions({ collapseLinear: true });
|
||||
expect(displayedLogGraph().graph[0]!.parents).toEqual(["side"]);
|
||||
});
|
||||
|
||||
it("keeps selections and ranges spanning repository roots", () => {
|
||||
const commits = [
|
||||
{ ...c("a"), repo: "/a" },
|
||||
{ ...c("b"), repo: "/b" },
|
||||
];
|
||||
useGitLog.setState({ commits, selection: [commitKey(commits[0]!)] });
|
||||
toggleCommitSelection(commitKey(commits[1]!));
|
||||
expect(useGitLog.getState().selection).toEqual(commits.map(commitKey));
|
||||
extendCommitSelection(commitKey(commits[1]!), commits);
|
||||
expect(useGitLog.getState().selection).toEqual(commits.map(commitKey));
|
||||
});
|
||||
|
||||
it("sends branches and independent ranges without dropping either", async () => {
|
||||
setLogBranches(["main", "a..b", "c..d"]);
|
||||
await reloadLog();
|
||||
expect(api.log.mock.lastCall?.[1]).toMatchObject({ branches: ["main"], range: null, ranges: ["a..b", "c..d"] });
|
||||
});
|
||||
|
||||
it("offers both merge parents and selects the chosen repository-scoped commit", async () => {
|
||||
const commits = [c("merge", ["main", "side"]), c("main"), c("side")].map((c) => ({ ...c, repo: "/repo" }));
|
||||
useGitLog.setState({ commits, done: true });
|
||||
await goToParent(commits[0]!);
|
||||
expect(api.choose).toHaveBeenCalledWith(commits.slice(1));
|
||||
expect(useGitLog.getState().selected).toBe(commitKey(commits[2]!));
|
||||
});
|
||||
|
||||
it("does not navigate after a reference lookup is superseded by a filter change", async () => {
|
||||
let finish!: (value: ReturnType<typeof ok<Commit[]>>) => void;
|
||||
api.log.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const navigate = goToRef("target");
|
||||
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||
api.log.mockResolvedValue(ok([c("filtered")]));
|
||||
await reloadLog();
|
||||
finish(ok([c("target")]));
|
||||
expect(await navigate).toBe(false);
|
||||
expect(useGitLog.getState().selected).toBeNull();
|
||||
expect(useGitLog.getState().commits[0]!.id).toBe("filtered");
|
||||
});
|
||||
});
|
||||
@@ -37,8 +37,8 @@ describe("Collapse Linear Branches", () => {
|
||||
];
|
||||
const { commits: shown, graph } = collapseLinear(commits, new Set());
|
||||
expect(shown.map((x) => x.id)).toEqual(["t", "m", "base"]);
|
||||
expect(graph.find((g) => g.id === "m")).toEqual({ id: "m", parents: ["base"], dashed: [true] });
|
||||
expect(graph.find((g) => g.id === "t")).toEqual({ id: "t", parents: ["m"] });
|
||||
expect(graph.find((g) => g.id === "m")).toMatchObject({ id: "m", parents: ["base"], dashed: [true] });
|
||||
expect(graph.find((g) => g.id === "t")).toMatchObject({ id: "t", parents: ["m"] });
|
||||
const { rows } = computeGraph(graph);
|
||||
expect(rows[1]!.down.some((s) => s.dashed)).toBe(true);
|
||||
expect(rows[2]!.up.some((s) => s.dashed)).toBe(true);
|
||||
@@ -52,7 +52,12 @@ describe("Collapse Linear Branches", () => {
|
||||
c("x", [], ["tag: v1"]),
|
||||
];
|
||||
const { graph } = collapseLinear(commits, new Set());
|
||||
expect(graph.find((g) => g.id === "y")).toEqual({ id: "y", parents: ["x"], dashed: [true] });
|
||||
expect(graph.find((g) => g.id === "y")).toMatchObject({
|
||||
id: "y",
|
||||
parents: ["x"],
|
||||
dashed: [true],
|
||||
edgeTypes: ["filtered"],
|
||||
});
|
||||
expect(computeGraph(commits).rows[1]!.down.some((s) => s.dashed)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Commit } from "@/bindings";
|
||||
import { nextSpeedMatch, speedMatches, visibleCommitText } from "../logSearch";
|
||||
import { DEFAULT_LOG_OPTIONS } from "../logOptions";
|
||||
const c = (id: string): Commit => ({
|
||||
id,
|
||||
shortId: id,
|
||||
summary: "Synchronize client",
|
||||
message: "body-only",
|
||||
author: "Unique author",
|
||||
email: "[email protected]",
|
||||
refs: ["main"],
|
||||
parents: [],
|
||||
timeMs: 0,
|
||||
});
|
||||
describe("log speed search", () => {
|
||||
it("matches fuzzy words within visible columns and cycles in both directions", () => {
|
||||
const commits = [c("one"), c("two")];
|
||||
expect(speedMatches("snc clt", visibleCommitText(commits[0]!, DEFAULT_LOG_OPTIONS))).toBe(true);
|
||||
expect(nextSpeedMatch(commits, "client", DEFAULT_LOG_OPTIONS, 0)).toBe(1);
|
||||
expect(nextSpeedMatch(commits, "client", DEFAULT_LOG_OPTIONS, 1)).toBe(0);
|
||||
expect(nextSpeedMatch(commits, "client", DEFAULT_LOG_OPTIONS, 0, -1)).toBe(1);
|
||||
expect(speedMatches("Unique", visibleCommitText(commits[0]!, { ...DEFAULT_LOG_OPTIONS, showAuthor: false }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(speedMatches("body-only", visibleCommitText(commits[0]!, DEFAULT_LOG_OPTIONS))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Commit } from "@/bindings";
|
||||
import { ContextMenuHost, hideContextMenu } from "@/ui";
|
||||
|
||||
const api = vi.hoisted(() => ({ commits: [] as Commit[], details: vi.fn(), diff: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
vcsLog: () => ok(api.commits),
|
||||
vcsLogRefs: () => ok({}),
|
||||
vcsBranches: () => ok([]),
|
||||
vcsTags: () => ok([]),
|
||||
vcsBranchInfo: () => ok({ upstream: null }),
|
||||
vcsConfigGet: () => ok(null),
|
||||
vcsCommitFiles: () => ok([{ path: "src/a.ts", kind: "modified", oldPath: null }]),
|
||||
vcsLogDetails: () => api.details(),
|
||||
vcsLogDetailsBatch: async (_repo: string, ids: string[]) => ({
|
||||
status: "ok",
|
||||
data: await Promise.all(ids.map(async () => (await api.details()).data)),
|
||||
}),
|
||||
vcsLogIndex: () => ok({ indexed: 0, total: 0, running: false }),
|
||||
vcsDiffRevisions: (...args: unknown[]) => api.diff(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { CommitDetails, LogView } from "../GitLog";
|
||||
import { useVcs } from "../store";
|
||||
import { commitKey } from "../multiRoot";
|
||||
import { emptyFilters, openLogTab, reloadLog, selectGitTab, useGitLog } from "../logStore";
|
||||
import { LogScope } from "../LogScope";
|
||||
import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions";
|
||||
import { VcsDialogHost } from "../dialogs";
|
||||
|
||||
const c = (id: string, parents: string[] = [], refs: string[] = []): Commit => ({
|
||||
id,
|
||||
shortId: id,
|
||||
parents,
|
||||
refs,
|
||||
author: "Author",
|
||||
email: "[email protected]",
|
||||
summary: id,
|
||||
message: id,
|
||||
timeMs: 0,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
api.commits = [c("tip", ["middle"], ["HEAD"]), c("middle", ["base"]), c("base")];
|
||||
api.details.mockReset().mockResolvedValue({
|
||||
status: "ok",
|
||||
data: {
|
||||
message: "tip\n\nFull body loaded on demand",
|
||||
containingBranches: ["main"],
|
||||
signature: "G",
|
||||
signer: "Author",
|
||||
key: "key",
|
||||
fingerprint: "fingerprint",
|
||||
},
|
||||
});
|
||||
api.diff.mockReset().mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [
|
||||
{ path: "src/a.ts", kind: "modified", oldPath: null, before: "p1", after: "merge" },
|
||||
{ path: "elsewhere/b.ts", kind: "renamed", oldPath: "src/b.ts", before: "p1", after: "merge" },
|
||||
{ path: "unrelated/c.ts", kind: "added", oldPath: null, before: null, after: "merge" },
|
||||
],
|
||||
});
|
||||
useVcs.setState({
|
||||
root: "/repo",
|
||||
repo: "/repo",
|
||||
roots: ["/repo"],
|
||||
available: true,
|
||||
branchesLoaded: true,
|
||||
branches: [],
|
||||
branch: { head: "main", headCommit: "tip", upstream: null, ahead: 0, behind: 0, operation: null },
|
||||
});
|
||||
useGitLog.setState({
|
||||
shownLog: "log",
|
||||
tab: "log",
|
||||
logTabs: [],
|
||||
commits: [],
|
||||
rows: [],
|
||||
filters: emptyFilters(),
|
||||
selection: [],
|
||||
selected: null,
|
||||
done: false,
|
||||
branchScope: "/repo",
|
||||
generation: useGitLog.getState().generation + 1,
|
||||
});
|
||||
useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS, showBranches: false, showDetails: false });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
hideContextMenu();
|
||||
api.commits = [];
|
||||
await reloadLog();
|
||||
});
|
||||
|
||||
describe("log controls", () => {
|
||||
it("keeps a pending text edit with its original view when focus changes before the debounce", async () => {
|
||||
const other = openLogTab("log", "Other");
|
||||
selectGitTab("log");
|
||||
render(
|
||||
<>
|
||||
<LogScope id="log">
|
||||
<LogView />
|
||||
</LogScope>
|
||||
<LogScope id={other}>
|
||||
<LogView />
|
||||
</LogScope>
|
||||
</>,
|
||||
);
|
||||
const [main, second] = screen.getAllByRole("textbox", { name: "Filter by message" });
|
||||
fireEvent.change(main!, { target: { value: "pending" } });
|
||||
fireEvent.pointerDown(second!);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
});
|
||||
expect(useGitLog.getState().shownLog).toBe(other);
|
||||
expect(useGitLog.getState().filters.text).toBe("");
|
||||
fireEvent.pointerDown(main!);
|
||||
await waitFor(() => expect(useGitLog.getState().filters.text).toBe("pending"));
|
||||
});
|
||||
|
||||
it("expands a single collapsed fragment from its node without changing the global option", async () => {
|
||||
useLogOptions.setState({ collapseLinear: true });
|
||||
render(<LogView />);
|
||||
const list = await screen.findByRole("listbox", { name: "Commits" });
|
||||
await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(2));
|
||||
const tip = within(list).getAllByRole("option")[0]!;
|
||||
fireEvent.click(within(tip).getByRole("button", { name: "Collapse or expand this branch" }));
|
||||
await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
|
||||
expect(useLogOptions.getState().collapseLinear).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected middle commit visible under collapse and supports speed search", async () => {
|
||||
useLogOptions.setState({ collapseLinear: true });
|
||||
useGitLog.setState({ selected: "/repo\0middle", selection: ["/repo\0middle"] });
|
||||
render(<LogView />);
|
||||
const list = await screen.findByRole("listbox", { name: "Commits" });
|
||||
await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
|
||||
fireEvent.keyDown(list, { key: "b" });
|
||||
expect(useGitLog.getState().selected).toBe("/repo\0base");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("b");
|
||||
});
|
||||
|
||||
it("resizes and reorders columns using the keyboard", async () => {
|
||||
render(<LogView />);
|
||||
const author = await screen.findByRole("separator", { name: "Resize Author column" });
|
||||
fireEvent.keyDown(author, { key: "ArrowRight" });
|
||||
expect(useLogOptions.getState().columnWidths.author).toBe(140);
|
||||
fireEvent.keyDown(author, { key: "ArrowRight", altKey: true });
|
||||
expect(useLogOptions.getState().columnOrder).toEqual(["date", "author"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit details", () => {
|
||||
it("loads a full body, containing branches, and signature only when details are visible", async () => {
|
||||
useGitLog.setState({ selected: "/repo\0tip", selection: ["/repo\0tip"] });
|
||||
render(
|
||||
<>
|
||||
<LogView />
|
||||
<VcsDialogHost />
|
||||
</>,
|
||||
);
|
||||
await screen.findByRole("listbox", { name: "Commits" });
|
||||
expect(api.details).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
useLogOptions.setState({ showDetails: true });
|
||||
});
|
||||
expect(await screen.findByText("Full body loaded on demand")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Signature.*Verified/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand Commit Message" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Commit tip" })).toHaveTextContent("Full body loaded on demand");
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
});
|
||||
|
||||
it("compares real merge parents and retains renamed files under affected-path filtering", async () => {
|
||||
const merge = { ...c("merge", ["p1", "p2"]), repo: "/repo", graphParents: ["filtered-base"] };
|
||||
useGitLog.setState({
|
||||
commits: [merge],
|
||||
selected: commitKey(merge),
|
||||
selection: [commitKey(merge)],
|
||||
filters: { ...emptyFilters(), paths: ["/repo/src"] },
|
||||
});
|
||||
useLogOptions.setState({ changesFromParents: true, showOnlyAffected: true });
|
||||
render(
|
||||
<>
|
||||
<CommitDetails commits={[merge]} />
|
||||
<ContextMenuHost />
|
||||
</>,
|
||||
);
|
||||
expect(await screen.findByText("4 files")).toBeInTheDocument();
|
||||
expect(api.diff).toHaveBeenCalledWith("/repo", "p1", "merge");
|
||||
expect(screen.queryByText("c.ts")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "All Parents" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Parent 2: p2" }));
|
||||
await waitFor(() => expect(api.diff).toHaveBeenLastCalledWith("/repo", "p2", "merge"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
const empty = () => Promise.resolve({ status: "ok" as const, data: [] });
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
vcsLog: empty,
|
||||
vcsBranches: empty,
|
||||
vcsBranchInfo: () => Promise.resolve({ status: "ok", data: { upstream: null } }),
|
||||
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { LogScope } from "../LogScope";
|
||||
import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions";
|
||||
import {
|
||||
closeLogTab,
|
||||
emptyFilters,
|
||||
installGitLog,
|
||||
openLogTab,
|
||||
recentLogFilters,
|
||||
reloadLog,
|
||||
selectGitTab,
|
||||
setLogBranches,
|
||||
setLogFilters,
|
||||
updateLogOptions,
|
||||
useGitLog,
|
||||
} from "../logStore";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
useVcs.setState({ root: "/repo", repo: "/repo", roots: ["/repo"] });
|
||||
useGitLog.setState({
|
||||
shownLog: "log",
|
||||
tab: "log",
|
||||
logTabs: [],
|
||||
commits: [],
|
||||
rows: [],
|
||||
filters: emptyFilters(),
|
||||
selection: [],
|
||||
selected: null,
|
||||
done: true,
|
||||
generation: useGitLog.getState().generation + 1,
|
||||
});
|
||||
useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const tab of useGitLog.getState().logTabs) closeLogTab(tab.id);
|
||||
await reloadLog();
|
||||
});
|
||||
|
||||
function Probe({ name }: { name: string }) {
|
||||
const text = useGitLog((s) => s.filters.text);
|
||||
const hash = useLogOptions((s) => s.showHash);
|
||||
return <button onClick={() => setLogFilters({ text: `${name} edited` })}>{`${name}: ${text} / hash ${hash}`}</button>;
|
||||
}
|
||||
|
||||
describe("independent log views", () => {
|
||||
it("keeps both scopes visible and activates the target before applying an edit", async () => {
|
||||
setLogFilters({ text: "main" });
|
||||
const other = openLogTab("log", "Other");
|
||||
setLogFilters({ text: "other" });
|
||||
updateLogOptions({ showHash: true });
|
||||
selectGitTab("log");
|
||||
render(
|
||||
<>
|
||||
<LogScope id="log">
|
||||
<Probe name="main" />
|
||||
</LogScope>
|
||||
<LogScope id={other}>
|
||||
<Probe name="other" />
|
||||
</LogScope>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByText("main: main / hash false")).toBeInTheDocument();
|
||||
expect(screen.getByText("other: other / hash true")).toBeInTheDocument();
|
||||
const button = screen.getByText("other: other / hash true");
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(button);
|
||||
fireEvent.click(button);
|
||||
});
|
||||
expect(useGitLog.getState().shownLog).toBe(other);
|
||||
expect(screen.getByText("main: main / hash false")).toBeInTheDocument();
|
||||
expect(screen.getByText("other: other edited / hash true")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores each tab's filters and options after leaving and reopening the project", async () => {
|
||||
const off = installGitLog();
|
||||
setLogFilters({ text: "main" });
|
||||
const other = openLogTab("log", "Other");
|
||||
setLogFilters({ paths: ["/repo/src"], branches: ["feature/.*"] });
|
||||
useGitLog.setState({
|
||||
fragments: {
|
||||
expanded: new Set(["/repo\0expanded"]),
|
||||
collapsed: new Set(["/repo\0collapsed"]),
|
||||
merges: new Set(["/repo\0merge"]),
|
||||
},
|
||||
selected: "/repo\0expanded",
|
||||
selection: ["/repo\0expanded"],
|
||||
});
|
||||
updateLogOptions({ showHash: true, diffLocation: "bottom" });
|
||||
selectGitTab("log");
|
||||
await reloadLog();
|
||||
useVcs.setState({ root: "/another", repo: "/another", roots: ["/another"] });
|
||||
useVcs.setState({ root: "/repo", repo: "/repo", roots: ["/repo"] });
|
||||
expect(useGitLog.getState().filters.text).toBe("main");
|
||||
expect(useGitLog.getState().fragments.expanded.size).toBe(0);
|
||||
selectGitTab(other);
|
||||
expect(useGitLog.getState().filters).toMatchObject({ paths: ["/repo/src"], branches: ["feature/.*"] });
|
||||
expect(useLogOptions.getState()).toMatchObject({ showHash: true, diffLocation: "bottom" });
|
||||
expect([...useGitLog.getState().fragments.expanded]).toEqual(["/repo\0expanded"]);
|
||||
expect([...useGitLog.getState().fragments.collapsed]).toEqual(["/repo\0collapsed"]);
|
||||
expect([...useGitLog.getState().fragments.merges]).toEqual(["/repo\0merge"]);
|
||||
expect(useGitLog.getState().selection).toEqual(["/repo\0expanded"]);
|
||||
off();
|
||||
});
|
||||
|
||||
it("remembers branch-only filters and clears a range when a named branch replaces it", async () => {
|
||||
setLogBranches(["main..feature"]);
|
||||
await reloadLog();
|
||||
expect(useGitLog.getState().filters).toMatchObject({ range: "main..feature", branches: [] });
|
||||
setLogFilters({ branches: ["main"] });
|
||||
await reloadLog();
|
||||
expect(useGitLog.getState().filters.range).toBeNull();
|
||||
setLogBranches(["feature"]);
|
||||
await reloadLog();
|
||||
expect(recentLogFilters()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ branches: ["main"] }),
|
||||
expect.objectContaining({ range: "main..feature" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,16 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("multi-root Git log", () => {
|
||||
it("keeps filtered edge hints from both graph metadata formats", () => {
|
||||
const row = { ...commit("tip"), repo: "/a", parents: ["real"], dashed: [true] };
|
||||
expect(graphCommit(row)).toMatchObject({ parents: ["/a\0real"], dashed: [true], edgeTypes: ["filtered"] });
|
||||
expect(graphCommit({ ...row, graphParents: ["visible"], graphDashed: [false] })).toMatchObject({
|
||||
parents: ["/a\0visible"],
|
||||
dashed: [false],
|
||||
edgeTypes: ["normal"],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges streams by time without violating each root's topological order", () => {
|
||||
const child = { ...commit("child", 1), repo: "a", parents: ["parent"] };
|
||||
const parent = { ...commit("parent", 100), repo: "a" };
|
||||
@@ -86,7 +96,7 @@ describe("multi-root Git log", () => {
|
||||
selectCommit(commitKey(front));
|
||||
expect(selectedCommits()).toEqual([front]);
|
||||
toggleCommitSelection(commitKey(back));
|
||||
expect(selectedCommits()).toEqual([back]);
|
||||
expect(selectedCommits()).toEqual([front, back]);
|
||||
expect(commitFileDiffData(back, { path: "main.ts", oldPath: null, kind: "added" }).path).toBe(
|
||||
"/project/back/main.ts",
|
||||
);
|
||||
@@ -115,6 +125,30 @@ describe("multi-root Git log", () => {
|
||||
expect(rows.map((c) => c.timeMs)).toEqual([...rows.map((c) => c.timeMs)].sort((a, b) => b! - a!));
|
||||
});
|
||||
|
||||
it("does not mark native root pages complete before every fetched commit has been emitted", async () => {
|
||||
const data = Object.fromEntries(
|
||||
useVcs.getState().roots.map((repo, root) => [
|
||||
repo,
|
||||
Array.from({ length: 180 }, (_, i) => ({
|
||||
...commit(`${root}-${i}`, 10000 - i * 2 - root),
|
||||
logPosition: i,
|
||||
logTotal: 180,
|
||||
})),
|
||||
]),
|
||||
);
|
||||
log.mockImplementation(async (repo: string, q: LogQuery) => ({
|
||||
status: "ok",
|
||||
data: data[repo]!.slice(q.skip, q.skip + q.limit),
|
||||
}));
|
||||
await reloadLog();
|
||||
expect(useGitLog.getState().commits).toHaveLength(PAGE);
|
||||
expect(useGitLog.getState().done).toBe(false);
|
||||
await loadMore();
|
||||
expect(useGitLog.getState().commits).toHaveLength(360);
|
||||
expect(useGitLog.getState().done).toBe(true);
|
||||
expect(new Set(useGitLog.getState().commits.map(commitKey)).size).toBe(360);
|
||||
});
|
||||
|
||||
it("filters repositories and keeps history tabs pinned when the selected root changes", async () => {
|
||||
log.mockImplementation(async () => ({ status: "ok", data: [commit("same")] }));
|
||||
useGitLog.setState({ filters: { ...emptyFilters(), repositories: ["/project/back"] } });
|
||||
|
||||
@@ -54,7 +54,9 @@ export function gitTabs(input: GitTabsInput): ContentTab[] {
|
||||
return [
|
||||
...(input.modal ? [{ id: "changes", label: "Local Changes" }, ...stashTabs(input)] : []),
|
||||
{ id: "log", label: "Log" },
|
||||
...input.logTabs.map((t) => ({ id: t.id, label: logTabLabel(t), closable: true })),
|
||||
...input.logTabs
|
||||
.filter((t) => t.location !== "editor")
|
||||
.map((t) => ({ id: t.id, label: logTabLabel(t), closable: true })),
|
||||
{ id: "console", label: "Console" },
|
||||
{ id: "worktrees", label: "Worktrees" },
|
||||
];
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* transformations of the log's Graph / View options.
|
||||
*/
|
||||
|
||||
export type EdgeKind = "normal" | "filtered" | "collapsed" | "shallow" | "unloaded";
|
||||
|
||||
export interface GraphSegment {
|
||||
edge?: string;
|
||||
kind?: EdgeKind;
|
||||
source?: string;
|
||||
/** Lane at the segment's start (top edge for `up`, node level for `down`). */
|
||||
from: number;
|
||||
/** Lane at the segment's end (node level for `up`, bottom edge for `down`). */
|
||||
@@ -14,9 +19,12 @@ export interface GraphSegment {
|
||||
dashed?: true;
|
||||
/** A long edge continues out of sight in this direction (Show Long Edges off). */
|
||||
arrow?: "up" | "down";
|
||||
/** Destination row of a long-edge arrow in the displayed graph. */
|
||||
targetRow?: number;
|
||||
}
|
||||
|
||||
export interface GraphRow {
|
||||
id?: string;
|
||||
/** Lane of the commit's node. */
|
||||
lane: number;
|
||||
color: number;
|
||||
@@ -26,10 +34,15 @@ export interface GraphRow {
|
||||
down: GraphSegment[];
|
||||
/** Number of lane slots this row spans. */
|
||||
width: number;
|
||||
/** Graph ids corresponding to each half-edge, for fragment actions. */
|
||||
upTargets?: string[];
|
||||
downTargets?: string[];
|
||||
}
|
||||
|
||||
interface Lane {
|
||||
id: string;
|
||||
source?: string;
|
||||
kind?: EdgeKind;
|
||||
color: number;
|
||||
dashed?: true;
|
||||
}
|
||||
@@ -44,6 +57,9 @@ export interface GraphCommit {
|
||||
parents: readonly string[];
|
||||
/** Per parent: the link skips collapsed commits. */
|
||||
dashed?: readonly boolean[];
|
||||
refs?: readonly string[];
|
||||
remoteNames?: readonly string[];
|
||||
edgeTypes?: readonly ("normal" | "filtered" | "collapsed" | "shallow" | "unloaded")[];
|
||||
}
|
||||
|
||||
export const emptyGraphState = (): GraphState => ({ lanes: [], nextColor: 0 });
|
||||
@@ -68,6 +84,21 @@ export function computeGraph(
|
||||
state: GraphState = emptyGraphState(),
|
||||
): { rows: GraphRow[]; state: GraphState } {
|
||||
const lanes = [...state.lanes];
|
||||
const track = commits.some((c) => c.refs !== undefined);
|
||||
const positions = new Map(commits.map((c, i) => [c.id, i]));
|
||||
const layout = graphLayout(commits);
|
||||
const refAware = commits.some((c) => c.refs?.length);
|
||||
const stableColor = (id: string, fallback: number) => {
|
||||
if (!refAware) return fallback;
|
||||
const index = positions.get(id);
|
||||
const owner = index == null ? id : layout.owners[index]!;
|
||||
let hash = 2166136261;
|
||||
for (const char of owner) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
||||
return hash >>> 0;
|
||||
};
|
||||
const describe = (s: GraphSegment, lane: Lane) =>
|
||||
track ? { ...s, edge: `${lane.source ?? ""}\0${lane.id}`, source: lane.source, kind: lane.kind } : s;
|
||||
|
||||
let nextColor = state.nextColor;
|
||||
const rows: GraphRow[] = [];
|
||||
|
||||
@@ -82,48 +113,89 @@ export function computeGraph(
|
||||
lane = matching[0]!;
|
||||
color = lanes[lane]!.color;
|
||||
} else {
|
||||
lane = firstFree(lanes);
|
||||
color = nextColor++;
|
||||
const preferred = refAware ? Math.max(0, layout.layout[positions.get(c.id)!]! - 1) : firstFree(lanes);
|
||||
lane = !lanes[preferred] ? preferred : firstFree(lanes);
|
||||
color = stableColor(c.id, nextColor++);
|
||||
}
|
||||
const widthBefore = lanes.length;
|
||||
|
||||
const up: GraphSegment[] = [];
|
||||
const upTargets: string[] = [];
|
||||
lanes.forEach((l, i) => {
|
||||
if (!l) return;
|
||||
up.push(seg(i, l.id === c.id ? lane : i, l.color, l.dashed));
|
||||
up.push(describe(seg(i, l.id === c.id ? lane : i, l.color, l.dashed), l));
|
||||
upTargets.push(l.id);
|
||||
});
|
||||
for (const m of matching) lanes[m] = null;
|
||||
if (lane >= lanes.length) lanes.length = lane + 1;
|
||||
lanes[lane] = null;
|
||||
|
||||
const down: GraphSegment[] = [];
|
||||
const downTargets: string[] = [];
|
||||
lanes.forEach((l, i) => {
|
||||
if (l) down.push(seg(i, i, l.color, l.dashed));
|
||||
if (l) {
|
||||
down.push(describe(seg(i, i, l.color, l.dashed), l));
|
||||
downTargets.push(l.id);
|
||||
}
|
||||
});
|
||||
|
||||
c.parents.forEach((p, k) => {
|
||||
const dashed = !!c.dashed?.[k];
|
||||
const kind = c.edgeTypes?.[k] ?? (dashed ? "filtered" : "normal");
|
||||
const info = {
|
||||
id: p,
|
||||
source: c.id,
|
||||
kind: !positions.has(p) && kind !== "shallow" ? ("unloaded" as const) : kind,
|
||||
};
|
||||
if (track && kind === "shallow") {
|
||||
down.push({
|
||||
...describe(seg(lane, lane, color, dashed), { ...info, color }),
|
||||
kind: kind === "shallow" ? "shallow" : "unloaded",
|
||||
arrow: "down",
|
||||
targetRow: -1,
|
||||
});
|
||||
downTargets.push(p);
|
||||
return;
|
||||
}
|
||||
// The first parent continues this lane even if another lane also leads to it
|
||||
// (both lines then converge at the parent's node, like `git log --graph`).
|
||||
if (k === 0) {
|
||||
lanes[lane] = laneOf(p, color, dashed);
|
||||
down.push(seg(lane, lane, color, dashed));
|
||||
lanes[lane] = { ...laneOf(p, color, dashed), ...info };
|
||||
down.push(describe(seg(lane, lane, color, dashed), { ...info, color }));
|
||||
downTargets.push(p);
|
||||
return;
|
||||
}
|
||||
const existing = lanes.findIndex((l) => l?.id === p);
|
||||
if (existing >= 0) {
|
||||
down.push(seg(lane, existing, lanes[existing]!.color, dashed));
|
||||
down.push(
|
||||
describe(seg(lane, existing, lanes[existing]!.color, dashed), { ...info, color: lanes[existing]!.color }),
|
||||
);
|
||||
downTargets.push(p);
|
||||
return;
|
||||
}
|
||||
const slot = firstFree(lanes);
|
||||
const col = nextColor++;
|
||||
lanes[slot] = laneOf(p, col, dashed);
|
||||
down.push(seg(lane, slot, col, dashed));
|
||||
const col = stableColor(p, nextColor++);
|
||||
lanes[slot] = { ...laneOf(p, col, dashed), ...info };
|
||||
down.push(describe(seg(lane, slot, col, dashed), { ...info, color: col }));
|
||||
downTargets.push(p);
|
||||
});
|
||||
|
||||
while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();
|
||||
const widthAfter = Math.max(0, ...down.map((s) => s.to + 1));
|
||||
rows.push({ lane, color, up, down, width: Math.max(widthBefore, widthAfter, lane + 1) });
|
||||
const widthAfter = down.reduce((width, segment) => Math.max(width, segment.to + 1), 0);
|
||||
rows.push({
|
||||
...(track ? { id: c.id } : {}),
|
||||
lane,
|
||||
color,
|
||||
up,
|
||||
down,
|
||||
upTargets,
|
||||
downTargets,
|
||||
width: Math.max(widthBefore, widthAfter, lane + 1),
|
||||
});
|
||||
}
|
||||
if (track && rows.length) {
|
||||
const last = rows.at(-1)!;
|
||||
last.down = last.down.map((s) => (s.kind === "unloaded" ? { ...s, arrow: "down", targetRow: -1 } : s));
|
||||
}
|
||||
return { rows, state: { lanes, nextColor } };
|
||||
}
|
||||
@@ -134,13 +206,28 @@ export function computeGraph(
|
||||
* linked by dashed edges. Commits in `keep` (HEAD) stay visible.
|
||||
*/
|
||||
export function collapseLinear<
|
||||
C extends { id: string; parents: readonly string[]; refs: readonly string[]; dashed?: readonly boolean[] },
|
||||
>(commits: readonly C[], keep: ReadonlySet<string>): { commits: C[]; graph: GraphCommit[] } {
|
||||
C extends {
|
||||
id: string;
|
||||
parents: readonly string[];
|
||||
refs: readonly string[];
|
||||
dashed?: readonly boolean[];
|
||||
edgeTypes?: readonly EdgeKind[];
|
||||
},
|
||||
>(
|
||||
commits: readonly C[],
|
||||
keep: ReadonlySet<string>,
|
||||
only?: ReadonlySet<string>,
|
||||
): { commits: C[]; graph: GraphCommit[] } {
|
||||
const byId = new Map(commits.map((c) => [c.id, c]));
|
||||
const children = new Map<string, number>();
|
||||
for (const c of commits) for (const p of c.parents) children.set(p, (children.get(p) ?? 0) + 1);
|
||||
const hidden = (c: C | undefined): boolean =>
|
||||
!!c && c.parents.length === 1 && children.get(c.id) === 1 && !c.refs.length && !keep.has(c.id);
|
||||
!!c &&
|
||||
c.parents.length === 1 &&
|
||||
children.get(c.id) === 1 &&
|
||||
!c.refs.length &&
|
||||
!keep.has(c.id) &&
|
||||
(!only || only.has(c.id));
|
||||
|
||||
const visible: C[] = [];
|
||||
const graph: GraphCommit[] = [];
|
||||
@@ -148,14 +235,16 @@ export function collapseLinear<
|
||||
if (hidden(c)) continue;
|
||||
const dashed: boolean[] = [];
|
||||
const parents: string[] = [];
|
||||
const edgeTypes: EdgeKind[] = [];
|
||||
for (const [i, p] of c.parents.entries()) {
|
||||
let cur = p;
|
||||
// A filter may already link past hidden commits.
|
||||
let skipped = c.dashed?.[i] ?? false;
|
||||
let skipped = !!c.dashed?.[i];
|
||||
let kind = c.edgeTypes?.[i] ?? (skipped ? "filtered" : "normal");
|
||||
for (let next = byId.get(cur), guard = 0; next && guard < commits.length && hidden(next); guard++) {
|
||||
cur = next.parents[0]!;
|
||||
next = byId.get(cur);
|
||||
skipped = true;
|
||||
kind = "collapsed";
|
||||
}
|
||||
// Two collapsed lines can lead to the same commit: draw one edge.
|
||||
const seen = parents.indexOf(cur);
|
||||
@@ -163,10 +252,11 @@ export function collapseLinear<
|
||||
else {
|
||||
parents.push(cur);
|
||||
dashed.push(skipped);
|
||||
edgeTypes.push(kind);
|
||||
}
|
||||
}
|
||||
visible.push(c);
|
||||
graph.push(dashed.some(Boolean) ? { id: c.id, parents, dashed } : { id: c.id, parents });
|
||||
graph.push({ ...c, id: c.id, parents, dashed, edgeTypes });
|
||||
}
|
||||
return { commits: visible, graph };
|
||||
}
|
||||
@@ -183,8 +273,184 @@ export function reachableFrom(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Edges running straight through at least this many rows are long. */
|
||||
export const LONG_EDGE = 30;
|
||||
/** Ref-priority DFS layout, shared by Bek fragment recognition and lane colours.
|
||||
* Adapted from IntelliJ GraphLayoutBuilder (JetBrains contributors, Apache-2.0). */
|
||||
export function graphLayout(commits: readonly GraphCommit[]) {
|
||||
const positions = new Map(commits.map((c, i) => [c.id, i]));
|
||||
const parents = commits.map((c) =>
|
||||
c.parents.flatMap((id) => {
|
||||
const i = positions.get(id);
|
||||
return i == null ? [] : [i];
|
||||
}),
|
||||
);
|
||||
const children = commits.map(() => [] as number[]);
|
||||
parents.forEach((ps, i) => ps.forEach((p) => children[p]!.push(i)));
|
||||
const refRank = (c: GraphCommit, ref: string) =>
|
||||
`${ref === "HEAD" ? 0 : ref === "main" || ref === "master" ? 1 : ref.startsWith("tag: ") ? 4 : c.remoteNames?.includes(ref) ? 3 : 2}${ref}`;
|
||||
const priority = (i: number) =>
|
||||
commits[i]!.refs?.length ? commits[i]!.refs!.map((r) => refRank(commits[i]!, r)).sort()[0]! : `5${commits[i]!.id}`;
|
||||
const heads = commits
|
||||
.flatMap((c, i) => (!children[i]!.length || c.refs?.length ? [i] : []))
|
||||
.sort((a, b) => priority(a).localeCompare(priority(b)));
|
||||
const layout = commits.map(() => 0);
|
||||
const owners = commits.map(() => "");
|
||||
let next = 1;
|
||||
for (const head of heads) {
|
||||
if (layout[head]) continue;
|
||||
const owner =
|
||||
commits[head]!.refs?.filter((r) => r !== "HEAD").sort((a, b) =>
|
||||
refRank(commits[head]!, a).localeCompare(refRank(commits[head]!, b)),
|
||||
)[0] ?? (commits[head]!.refs?.includes("HEAD") ? "HEAD" : commits[head]!.id);
|
||||
const startLayout = next;
|
||||
const stack = [head];
|
||||
while (stack.length) {
|
||||
const node = stack.at(-1)!;
|
||||
const first = !layout[node];
|
||||
if (first) {
|
||||
layout[node] = next;
|
||||
owners[node] = `${owner}\0${next - startLayout}`;
|
||||
}
|
||||
const parent = parents[node]!.find((p) => !layout[p]);
|
||||
if (parent != null) stack.push(parent);
|
||||
else {
|
||||
if (first) next++;
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
return { layout, owners, parents, children };
|
||||
}
|
||||
|
||||
/** LinearBek fragment recognition and nested expansion.
|
||||
* Adapted from JetBrains LinearBekGraphBuilder, Copyright 2000-2025, Apache-2.0. */
|
||||
export function linearizeMerges(commits: readonly GraphCommit[], expanded: ReadonlySet<string> = new Set()) {
|
||||
const positions = new Map(commits.map((c, i) => [c.id, i]));
|
||||
const { layout } = graphLayout(commits);
|
||||
const expand = new Set(expanded);
|
||||
const build = () => {
|
||||
const graph = commits.map((c) => ({
|
||||
...c,
|
||||
parents: [...c.parents],
|
||||
dashed: c.parents.map((_, i) => !!c.dashed?.[i]),
|
||||
}));
|
||||
const fragments = new Map<string, string>();
|
||||
const nodes = new Map<string, Set<string>>();
|
||||
for (let merge = graph.length - 1; merge >= 0; merge--) {
|
||||
const down = graph[merge]!.parents.flatMap((p) => {
|
||||
const i = positions.get(p);
|
||||
return i == null ? [] : [i];
|
||||
}).sort((a, b) => a - b);
|
||||
if (down.length !== 2 || graph[merge]!.parents.length !== 2) continue;
|
||||
const [right, left] = down as [number, number];
|
||||
const body = new Set<number>(),
|
||||
tails = new Set<number>();
|
||||
const tailEdges = new Map<number, Set<string>>();
|
||||
const queue: Array<{ up: number; down: number | undefined; id: string }> = [];
|
||||
const add = (up: number) => graph[up]!.parents.forEach((id) => queue.push({ up, down: positions.get(id), id }));
|
||||
const magic = new Set<number>();
|
||||
const pending = [...graph[left]!.parents];
|
||||
while (pending.length) {
|
||||
const id = pending.pop()!,
|
||||
i = positions.get(id);
|
||||
if (i == null || i > left + LONG_EDGE || magic.has(i)) continue;
|
||||
magic.add(i);
|
||||
pending.push(...graph[i]!.parents);
|
||||
}
|
||||
let rows = 1,
|
||||
size = 1,
|
||||
old = false,
|
||||
valid = true;
|
||||
const visited = new Set<string>();
|
||||
add(right);
|
||||
while (queue.length && valid) {
|
||||
queue.sort((a, b) => (b.down ?? Infinity) - (a.down ?? Infinity));
|
||||
const e = queue.pop()!;
|
||||
const key = `${e.up}\0${e.id}`;
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
const next = e.down;
|
||||
if (next == null) {
|
||||
if (!body.has(e.up)) tails.add(e.up);
|
||||
continue;
|
||||
}
|
||||
if (next === left) {
|
||||
if (!body.has(e.up)) tails.add(e.up);
|
||||
old = true;
|
||||
} else if (next >= right + rows && next < left) {
|
||||
rows = next - right + 1;
|
||||
size++;
|
||||
add(next);
|
||||
body.add(e.up);
|
||||
} else if (next > left) {
|
||||
const li = layout[next]!,
|
||||
ll = layout[left]!,
|
||||
rl = layout[right]!;
|
||||
let allowed: boolean;
|
||||
if (ll > rl && !old) allowed = next <= left + LONG_EDGE && magic.has(next);
|
||||
else if ((li > ll && li < rl) || li === ll) allowed = true;
|
||||
else if (li >= rl) allowed = false;
|
||||
else allowed = next > left + LONG_EDGE ? tailEdges.has(e.up) || body.has(e.up) : magic.has(next);
|
||||
if (!allowed) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
if (!body.has(e.up)) {
|
||||
tails.add(e.up);
|
||||
tailEdges.set(e.up, new Set([...(tailEdges.get(e.up) ?? []), e.id]));
|
||||
}
|
||||
}
|
||||
if (size >= 200) valid = false;
|
||||
}
|
||||
if (!valid || !tails.size) continue;
|
||||
const id = graph[merge]!.id;
|
||||
nodes.set(id, new Set([merge, left, right, ...body, ...tails].map((i) => graph[i]!.id)));
|
||||
if (expand.has(id)) continue;
|
||||
for (const [up, removed] of tailEdges) {
|
||||
const c = graph[up]!;
|
||||
c.dashed = c.dashed.filter((_, i) => !removed.has(c.parents[i]!));
|
||||
c.parents = c.parents.filter((p) => !removed.has(p));
|
||||
}
|
||||
for (const tail of tails) {
|
||||
const c = graph[tail]!,
|
||||
target = graph[left]!.id;
|
||||
const index = c.parents.indexOf(target);
|
||||
if (index < 0) {
|
||||
c.parents.push(target);
|
||||
c.dashed.push(true);
|
||||
} else c.dashed[index] = true;
|
||||
fragments.set(`${c.id}\0${target}`, id);
|
||||
}
|
||||
const c = graph[merge]!,
|
||||
target = graph[left]!.id;
|
||||
c.dashed = c.dashed.filter((_, i) => c.parents[i] !== target);
|
||||
c.parents = c.parents.filter((p) => p !== target);
|
||||
}
|
||||
for (const c of graph) {
|
||||
const original = commits[positions.get(c.id)!]!;
|
||||
c.edgeTypes = c.parents.map((p, i) =>
|
||||
fragments.has(`${c.id}\0${p}`)
|
||||
? "collapsed"
|
||||
: (original.edgeTypes?.[original.parents.indexOf(p)] ?? (c.dashed[i] ? "filtered" : "normal")),
|
||||
);
|
||||
}
|
||||
return { graph, fragments, nodes };
|
||||
};
|
||||
let result = build();
|
||||
// Expanding a containing merge restores its nested fragments as well.
|
||||
for (;;) {
|
||||
let changed = false;
|
||||
for (const id of [...expand])
|
||||
for (const node of result.nodes.get(id) ?? [])
|
||||
if (node !== id && result.nodes.has(node) && !expand.has(node)) {
|
||||
expand.add(node);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return result;
|
||||
result = build();
|
||||
}
|
||||
}
|
||||
|
||||
const LONG_EDGE = 30;
|
||||
/** Rows of a hidden long edge still drawn next to each of its ends. */
|
||||
const LONG_EDGE_STUB = 2;
|
||||
|
||||
@@ -198,7 +464,7 @@ const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLan
|
||||
*/
|
||||
export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): GraphRow[] {
|
||||
const out = rows.slice();
|
||||
const width = Math.max(0, ...rows.map((r) => r.width));
|
||||
const width = rows.reduce((max, row) => Math.max(max, row.width), 0);
|
||||
const edit = (r: number, fn: (row: GraphRow) => GraphRow) => {
|
||||
out[r] = fn(out[r]!);
|
||||
};
|
||||
@@ -214,10 +480,22 @@ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE):
|
||||
const from = start + LONG_EDGE_STUB;
|
||||
const to = r - 1 - LONG_EDGE_STUB;
|
||||
for (let k = from; k <= to; k++) {
|
||||
edit(k, (row) => ({ ...row, up: row.up.filter((s) => !lane(s)), down: row.down.filter((s) => !lane(s)) }));
|
||||
edit(k, (row) => ({
|
||||
...row,
|
||||
up: row.up.filter((s) => !lane(s)),
|
||||
down: row.down.filter((s) => !lane(s)),
|
||||
upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)),
|
||||
downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)),
|
||||
}));
|
||||
}
|
||||
edit(from - 1, (row) => ({ ...row, down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down" } : s)) }));
|
||||
edit(to + 1, (row) => ({ ...row, up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up" } : s)) }));
|
||||
edit(from - 1, (row) => ({
|
||||
...row,
|
||||
down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: r } : s)),
|
||||
}));
|
||||
edit(to + 1, (row) => ({
|
||||
...row,
|
||||
up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)),
|
||||
}));
|
||||
}
|
||||
start = -1;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseCommitBookmark } from "./logBookmarks";
|
||||
import { commitKey, commitRepo } from "./multiRoot";
|
||||
/**
|
||||
* Actions for operations in progress, Git Log commits, change lists, the shelf
|
||||
@@ -23,7 +24,7 @@ import { useChangelists } from "./changelists";
|
||||
import { commitWindowFocused } from "./actions";
|
||||
import { useCommit } from "./commitStore";
|
||||
import { openCommit } from "./commitUi";
|
||||
import { goToChild, goToParent, goToRef, selectedCommits, useGitLog } from "./logStore";
|
||||
import { goToChild, goToParent, goToRef, openLogTab, selectedCommits, useGitLog } from "./logStore";
|
||||
import { chooseRevision } from "./RevisionChooser";
|
||||
import {
|
||||
cherryPick,
|
||||
@@ -59,9 +60,10 @@ import {
|
||||
unshelve,
|
||||
} from "./shelf";
|
||||
import { currentRepo, focusRepo, useVcs } from "./store";
|
||||
import { openExternalLog } from "./RevisionBrowser";
|
||||
|
||||
const VCS = "vcsAvailable";
|
||||
const IN_GIT_LOG = "vcsAvailable && activeToolWindow == git";
|
||||
const IN_GIT_LOG = "(vcsAvailable || projectOpen) && (activeToolWindow == git || vcsLogFocus)";
|
||||
const IN_COMMIT = "vcsAvailable && activeToolWindow == commit";
|
||||
|
||||
/** The commit an action refers to: its argument (commit or id), else the Git Log selection. */
|
||||
@@ -84,16 +86,18 @@ export function commitsArg(arg: unknown): Commit[] {
|
||||
return selection.length ? selection : [commitArg(undefined)].filter((c): c is Commit => !!c);
|
||||
}
|
||||
|
||||
function withCommits(fn: (cs: Commit[]) => unknown) {
|
||||
function withCommits(fn: (cs: Commit[]) => unknown, allowReadOnly = false) {
|
||||
return async ({ arg }: { arg?: unknown }) => {
|
||||
const cs = commitsArg(arg);
|
||||
if (!cs.length) {
|
||||
notifications().notify({ group: "vcs", level: "info", title: "Select a commit in the Git log" });
|
||||
return;
|
||||
}
|
||||
if (new Set(cs.map(commitRepo)).size > 1) return;
|
||||
if (!allowReadOnly && new Set(cs.map(commitRepo)).size > 1) return;
|
||||
const readOnly = cs.some((c) => "readOnly" in c && c.readOnly === true);
|
||||
if (readOnly && !allowReadOnly) return;
|
||||
const repo = commitRepo(cs[0]!);
|
||||
if (repo) await focusRepo(repo);
|
||||
if (repo && !readOnly) await focusRepo(repo);
|
||||
void fn(cs);
|
||||
};
|
||||
}
|
||||
@@ -108,7 +112,7 @@ async function openCommitInBrowser(c: Commit) {
|
||||
else notifications().notify({ group: "vcs", level: "warning", title: `No web URL for remote '${remote}'` });
|
||||
}
|
||||
|
||||
function withCommit(fn: (c: Commit) => unknown) {
|
||||
function withCommit(fn: (c: Commit) => unknown, allowReadOnly = false) {
|
||||
return async ({ arg }: { arg?: unknown }) => {
|
||||
const c = commitArg(arg);
|
||||
if (!c) {
|
||||
@@ -116,7 +120,9 @@ function withCommit(fn: (c: Commit) => unknown) {
|
||||
return;
|
||||
}
|
||||
const repo = commitRepo(c);
|
||||
if (repo) await focusRepo(repo);
|
||||
const readOnly = "readOnly" in c && c.readOnly === true;
|
||||
if (readOnly && !allowReadOnly) return;
|
||||
if (repo && !readOnly) await focusRepo(repo);
|
||||
void fn(c);
|
||||
};
|
||||
}
|
||||
@@ -154,6 +160,21 @@ export function trackOperationContext(): () => void {
|
||||
|
||||
export function registerHistoryActions(): () => void {
|
||||
const actions: Action[] = [
|
||||
{
|
||||
id: "vcs.log.revealBookmark",
|
||||
title: "Show Commit Bookmark",
|
||||
category: "Git Log",
|
||||
run: async ({ arg }) => {
|
||||
const target = typeof arg === "string" ? parseCommitBookmark(arg) : null;
|
||||
if (target) {
|
||||
if (target.repo && !useVcs.getState().roots.includes(target.repo)) {
|
||||
const root = await call(commands.vcsOpenExternalLog(target.repo));
|
||||
openLogTab("external", root, "editor", [root]);
|
||||
}
|
||||
await goToRef(target.id, target.repo || null);
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- operation in progress (Git › Continue Rebase…) ---
|
||||
{
|
||||
id: "vcs.rebase.continue",
|
||||
@@ -225,19 +246,28 @@ export function registerHistoryActions(): () => void {
|
||||
},
|
||||
|
||||
// --- Git Log toolbar ---
|
||||
{
|
||||
id: "vcs.log.openExternal",
|
||||
title: "Open External Git Log…",
|
||||
category: "Git Log",
|
||||
run: openExternalLog,
|
||||
},
|
||||
{
|
||||
id: "vcs.log.goToRef",
|
||||
title: "Go to Hash/Branch/Tag",
|
||||
category: "Git Log",
|
||||
icon: "search",
|
||||
when: VCS,
|
||||
when: "vcsAvailable || projectOpen || vcsLogFocus",
|
||||
keyWhen: IN_GIT_LOG,
|
||||
keywords: ["find commit", "jump"],
|
||||
run: async () => {
|
||||
toolWindowService().show("git", true);
|
||||
const log = useGitLog.getState();
|
||||
const tab = log.logTabs.find((t) => t.id === log.shownLog);
|
||||
if (tab?.location !== "editor") toolWindowService().show("git", true);
|
||||
const rev = await chooseRevision("Go to Hash/Branch/Tag", {
|
||||
tags: true,
|
||||
placeholder: "Enter hash or branch/tag name",
|
||||
repositories: tab?.repo ? [tab.repo] : log.filters.repositories,
|
||||
});
|
||||
if (rev && !(await goToRef(rev)))
|
||||
notifications().notify({ group: "vcs", level: "warning", title: `'${rev}' was not found in the log` });
|
||||
@@ -250,9 +280,9 @@ export function registerHistoryActions(): () => void {
|
||||
title: "Copy Revision Number",
|
||||
category: "Git Log",
|
||||
icon: "copy",
|
||||
when: VCS,
|
||||
when: "vcsAvailable || projectOpen || vcsLogFocus",
|
||||
keyWhen: IN_GIT_LOG,
|
||||
run: withCommits((cs) => navigator.clipboard?.writeText(cs.map((c) => c.id).join(" "))),
|
||||
run: withCommits((cs) => navigator.clipboard?.writeText(cs.map((c) => c.id).join(" ")), true),
|
||||
},
|
||||
{
|
||||
id: "vcs.log.createPatch",
|
||||
@@ -382,16 +412,16 @@ export function registerHistoryActions(): () => void {
|
||||
title: "Go to Child Commit",
|
||||
category: "Git Log",
|
||||
icon: "arrow-left",
|
||||
when: VCS,
|
||||
run: withCommit(goToChild),
|
||||
when: "vcsAvailable || projectOpen || vcsLogFocus",
|
||||
run: withCommit(goToChild, true),
|
||||
},
|
||||
{
|
||||
id: "vcs.log.goToParent",
|
||||
title: "Go to Parent Commit",
|
||||
category: "Git Log",
|
||||
icon: "arrow-right",
|
||||
when: VCS,
|
||||
run: withCommit(goToParent),
|
||||
when: "vcsAvailable || projectOpen || vcsLogFocus",
|
||||
run: withCommit(goToParent, true),
|
||||
},
|
||||
{
|
||||
id: "vcs.log.openInBrowser",
|
||||
|
||||
@@ -44,7 +44,7 @@ import { activate as activateConflictMarkers } from "./conflictMarkers";
|
||||
import { MergeRebaseWidget } from "./MergeRebaseWidget";
|
||||
import { installFileConfirmations } from "./confirmations";
|
||||
import { installIncomingCheck } from "./incoming";
|
||||
import { installGitLog, selectGitTab, useGitLog } from "./logStore";
|
||||
import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore";
|
||||
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -154,6 +154,7 @@ export { activateComparisonWindow, ComparisonWindow } from "./ComparisonWindow";
|
||||
export function activateDiffViewer(): () => void {
|
||||
return registerEditorKind({
|
||||
kind: DIFF_KIND,
|
||||
standalone: true,
|
||||
component: lazyComponent(() => import("./diff-editor-tab").then((m) => m.DiffEditorTab)),
|
||||
title: (input) => diffTitle(input.data as DiffData),
|
||||
icon: () => "diff",
|
||||
@@ -241,6 +242,34 @@ export function activate(): () => void {
|
||||
component: lazyComponent(() => import("./GitLog").then((m) => m.GitToolWindow)),
|
||||
headerContent: lazyComponent(() => import("./GitLog").then((m) => m.GitTabs)),
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: "git-revision",
|
||||
standalone: true,
|
||||
component: lazyComponent(() => import("./RevisionBrowser").then((m) => m.GitRevisionEditor)),
|
||||
title: (input) => {
|
||||
const data = input.data as { path: string; revision: string };
|
||||
return `${baseName(data.path)} (${data.revision.slice(0, 8)})`;
|
||||
},
|
||||
icon: () => "file",
|
||||
restorable: true,
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: "git-log",
|
||||
standalone: true,
|
||||
component: lazyComponent(() => import("./LogScope").then((m) => m.GitLogEditor)),
|
||||
title: (input) => (input.data as { label: string }).label,
|
||||
icon: () => "toolwindow-git",
|
||||
restorable: true,
|
||||
onClose: (input) => closeLogTab((input.data as { id: string }).id),
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: "git-merge-diff",
|
||||
standalone: true,
|
||||
component: lazyComponent(() => import("./GitMergeDiff").then((m) => m.GitMergeDiff)),
|
||||
title: (input) => (input.data as { path: string }).path,
|
||||
icon: () => "diff",
|
||||
restorable: true,
|
||||
}),
|
||||
activateDiffViewer(),
|
||||
registerEditorKind({
|
||||
kind: MERGE_KIND,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Commit } from "@/bindings";
|
||||
import { commitRepo } from "./multiRoot";
|
||||
|
||||
/** Stable virtual bookmark target; uses the platform's groups, mnemonic and description model. */
|
||||
export const commitBookmarkPath = (commit: Commit) =>
|
||||
`git-log:${encodeURIComponent(commitRepo(commit) ?? "")}#${commit.id}`;
|
||||
export function parseCommitBookmark(path: string): { repo: string; id: string } | null {
|
||||
if (!path.startsWith("git-log:")) return null;
|
||||
const [repo, id] = path.slice(8).split("#");
|
||||
if (!id) return null;
|
||||
try {
|
||||
return { repo: decodeURIComponent(repo!), id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { baseName } from "./model";
|
||||
import { readWorkingText, runOp, saveAll, writeFileText } from "./ops";
|
||||
import { reportApply } from "./shelf";
|
||||
import { currentRepoRoot } from "./store";
|
||||
import { commitRepo } from "./multiRoot";
|
||||
import { repositoryPath } from "./repositoryPaths";
|
||||
import { confirmDialog } from "./dialogs";
|
||||
|
||||
@@ -29,6 +30,9 @@ export interface LogChange {
|
||||
/** `null`: the change deleted the file. */
|
||||
after: string | null;
|
||||
diff: DiffData;
|
||||
readOnly?: boolean;
|
||||
group?: string;
|
||||
mergeParents?: Array<{ rev: string; path: string }>;
|
||||
}
|
||||
|
||||
/** One commit's files (`commit` given) or several commits' combined changes. */
|
||||
@@ -36,12 +40,14 @@ export function toLogChanges(
|
||||
files: readonly (ChangedFile | RevisionChange)[],
|
||||
commit: Commit | null,
|
||||
repo = currentRepoRoot(),
|
||||
readOnly = false,
|
||||
): LogChange[] {
|
||||
return files.map((f) => {
|
||||
if (commit) {
|
||||
const parent = commit.parents[0] ?? null;
|
||||
return {
|
||||
repo,
|
||||
readOnly,
|
||||
path: f.path,
|
||||
oldPath: f.oldPath,
|
||||
kind: f.kind,
|
||||
@@ -53,6 +59,7 @@ export function toLogChanges(
|
||||
const r = f as RevisionChange;
|
||||
return {
|
||||
repo,
|
||||
readOnly,
|
||||
path: r.path,
|
||||
oldPath: r.oldPath,
|
||||
kind: r.kind,
|
||||
@@ -138,6 +145,22 @@ export function changesArg(arg: unknown): LogChange[] {
|
||||
export async function showChangesDiff(changes: readonly LogChange[], preview = false) {
|
||||
const first = changes[0];
|
||||
if (!first) return;
|
||||
if (first.mergeParents?.length === 2 && first.after) {
|
||||
await editorService().openInput(
|
||||
{
|
||||
kind: "git-merge-diff",
|
||||
key: `git-merge-diff:${first.repo}:${first.after}:${first.path}`,
|
||||
data: {
|
||||
repo: first.repo ?? currentRepoRoot(),
|
||||
revision: first.after,
|
||||
path: first.path,
|
||||
parents: first.mergeParents,
|
||||
},
|
||||
},
|
||||
{ preview, focus: !preview },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const all = useLogChanges.getState().all;
|
||||
const chain = all.includes(first) ? all : [...changes];
|
||||
await openDiff(
|
||||
@@ -151,6 +174,7 @@ export async function showChangesDiff(changes: readonly LogChange[], preview = f
|
||||
|
||||
/** Compare with Local (`side` after) / Compare Before with Local: the revision ↔ the working file. */
|
||||
export async function compareChangeWithLocal(change: LogChange, side: "after" | "before" = "after") {
|
||||
if (change.readOnly) return;
|
||||
const rev = side === "after" ? change.after : change.before;
|
||||
if (!rev) return;
|
||||
const revPath = side === "before" && change.oldPath ? absolutePath(change.oldPath, change.repo) : undefined;
|
||||
@@ -163,6 +187,7 @@ export async function compareChangeWithLocal(change: LogChange, side: "after" |
|
||||
|
||||
/** Jump to Source: the working file. */
|
||||
export async function openChangeSource(change: LogChange) {
|
||||
if (change.readOnly) return;
|
||||
await editorService().openFile(absolutePath(change.path, change.repo), { focus: true });
|
||||
}
|
||||
|
||||
@@ -171,6 +196,7 @@ export async function openChangeSource(change: LogChange) {
|
||||
* change deleted is deleted.
|
||||
*/
|
||||
export async function getFromRevision(changes: readonly LogChange[]) {
|
||||
if (changes.some((c) => c.readOnly)) return;
|
||||
if (!changes.length) return;
|
||||
const ok = await confirmDialog({
|
||||
title: "Get from Revision",
|
||||
@@ -205,10 +231,17 @@ export async function getFromRevision(changes: readonly LogChange[]) {
|
||||
|
||||
/** Revert Selected Changes: applies the reverse of the selected files' changes to the working tree. */
|
||||
export async function revertSelectedChanges(changes: readonly LogChange[], commits: readonly Commit[]) {
|
||||
if (changes.some((c) => c.readOnly)) return;
|
||||
if (!changes.length || !commits.length) return;
|
||||
const repo = changes[0]!.repo ?? currentRepoRoot();
|
||||
if (changes.some((c) => (c.repo ?? currentRepoRoot()) !== repo)) return;
|
||||
await saveAll();
|
||||
// Oldest first; the backend reverses the order for a reversed patch.
|
||||
const ids = [...commits].reverse().map((c) => c.id);
|
||||
const ids = [...commits]
|
||||
.reverse()
|
||||
.filter((c) => (commitRepo(c) ?? currentRepoRoot()) === repo)
|
||||
.map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
const paths = new Set(changes.flatMap((c) => (c.oldPath ? [c.path, c.oldPath] : [c.path])));
|
||||
const outcome = await runOp(
|
||||
tx("Revert selected changes"),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react";
|
||||
import type { LogOptions } from "./logOptions";
|
||||
|
||||
export const LogIdContext = createContext<string | null>(null);
|
||||
export const LogOptionsContext = createContext<LogOptions | null>(null);
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Commit, LogDetails } from "@/bindings";
|
||||
import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc";
|
||||
import { commitRepo } from "./multiRoot";
|
||||
import { currentRepo, useVcs } from "./store";
|
||||
import { create } from "zustand";
|
||||
|
||||
const refVersions = create<Record<string, number>>(() => ({}));
|
||||
export function invalidateLogDetails(roots: readonly string[]) {
|
||||
refVersions.setState((s) => Object.fromEntries(roots.map((root) => [root, (s[root] ?? 0) + 1])));
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
repo: string | null;
|
||||
id: string;
|
||||
promise: Promise<LogDetails>;
|
||||
resolve: (details: LogDetails) => void;
|
||||
reject: (error: unknown) => void;
|
||||
users: number;
|
||||
request?: RequestId;
|
||||
settled: boolean;
|
||||
}
|
||||
const cache = new Map<string, Entry>();
|
||||
const pending: Entry[] = [];
|
||||
const batches = new Map<RequestId, Entry[]>();
|
||||
let scheduled = false;
|
||||
let running = 0;
|
||||
|
||||
function forget(entry: Entry) {
|
||||
if (cache.get(entry.key) === entry) cache.delete(entry.key);
|
||||
}
|
||||
|
||||
function flush() {
|
||||
scheduled = false;
|
||||
while (pending.length && running < 4) {
|
||||
const first = pending.shift()!;
|
||||
if (!first.users) {
|
||||
forget(first);
|
||||
first.reject(new Error("Commit details request cancelled"));
|
||||
continue;
|
||||
}
|
||||
const entries = [first];
|
||||
for (let i = 0; i < pending.length && entries.length < 64;) {
|
||||
if (!pending[i]!.users) {
|
||||
const cancelled = pending.splice(i, 1)[0]!;
|
||||
forget(cancelled);
|
||||
cancelled.reject(new Error("Commit details request cancelled"));
|
||||
} else if (pending[i]!.repo === first.repo) entries.push(...pending.splice(i, 1));
|
||||
else i++;
|
||||
}
|
||||
const request = nextRequestId();
|
||||
entries.forEach((entry) => {
|
||||
entry.request = request;
|
||||
});
|
||||
batches.set(request, entries);
|
||||
running++;
|
||||
void call(
|
||||
commands.vcsLogDetailsBatch(
|
||||
first.repo,
|
||||
entries.map((e) => e.id),
|
||||
request,
|
||||
),
|
||||
)
|
||||
.then(
|
||||
(details) =>
|
||||
entries.forEach((entry, i) => {
|
||||
if (details[i]) {
|
||||
entry.settled = true;
|
||||
entry.resolve(details[i]!);
|
||||
} else {
|
||||
forget(entry);
|
||||
entry.reject(new Error("Commit details were not returned"));
|
||||
}
|
||||
}),
|
||||
(error) =>
|
||||
entries.forEach((entry) => {
|
||||
forget(entry);
|
||||
entry.reject(error);
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
running--;
|
||||
batches.delete(request);
|
||||
flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Batches visible rows by root. Ref-dependent fields refresh on version changes;
|
||||
* the backend caches immutable messages and signatures independently. */
|
||||
export function loadLogDetails(commit: Commit, signal?: AbortSignal): Promise<LogDetails> {
|
||||
const repo = commitRepo(commit) ?? currentRepo();
|
||||
const key = `${repo}\0${commit.id}\0${useVcs.getState().version}\0${refVersions.getState()[repo ?? ""] ?? 0}`;
|
||||
let entry = cache.get(key);
|
||||
if (!entry) {
|
||||
let resolve!: Entry["resolve"], reject!: Entry["reject"];
|
||||
const promise = new Promise<LogDetails>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
entry = { key, repo, id: commit.id, promise, resolve, reject, users: 0, settled: false };
|
||||
if (cache.size >= 10000) cache.delete(cache.keys().next().value!);
|
||||
cache.set(key, entry);
|
||||
pending.push(entry);
|
||||
}
|
||||
entry.users++;
|
||||
const own = entry;
|
||||
const abort = () => {
|
||||
own.users = Math.max(0, own.users - 1);
|
||||
if (!own.settled && !own.users) forget(own);
|
||||
if (own.settled || !own.request) return;
|
||||
const batch = batches.get(own.request);
|
||||
if (batch?.every((e) => !e.users)) void commands.cancelRequest(own.request).catch(() => undefined);
|
||||
};
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export function useCommitDetails(commit: Commit, enabled = true) {
|
||||
const version = useVcs((s) => s.version);
|
||||
const repo = commitRepo(commit) ?? currentRepo();
|
||||
const refs = refVersions((s) => s[repo ?? ""] ?? 0);
|
||||
const key = `${repo}\0${commit.id}\0${version}\0${refs}`;
|
||||
const [result, setResult] = useState<{ key: string; details: LogDetails | null; error: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
void loadLogDetails(commit, controller.signal)
|
||||
.then((details) => {
|
||||
if (!controller.signal.aborted) setResult({ key, details, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setResult({ key, details: null, error: true });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [commit, key, enabled, version]);
|
||||
return { details: result?.key === key ? result.details : null, error: result?.key === key && result.error };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import type { LogIndexProgress } from "@/bindings";
|
||||
import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useVcs } from "./store";
|
||||
import { IconButton } from "@/ui";
|
||||
|
||||
interface Job {
|
||||
version?: number;
|
||||
restart?: boolean;
|
||||
users: number;
|
||||
request: RequestId | null;
|
||||
paused: boolean;
|
||||
progress: LogIndexProgress;
|
||||
timer?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
const jobs = new Map<string, Job>();
|
||||
const revision = create<{ value: number }>(() => ({ value: 0 }));
|
||||
const notify = () => revision.setState((s) => ({ value: s.value + 1 }));
|
||||
function start(repo: string, job: Job) {
|
||||
if (job.paused) return;
|
||||
if (job.request != null) {
|
||||
job.restart = true;
|
||||
return;
|
||||
}
|
||||
const request = nextRequestId();
|
||||
job.request = request;
|
||||
job.progress = { ...job.progress, running: true };
|
||||
job.timer = setInterval(() => {
|
||||
void call(commands.vcsLogIndex(repo, null))
|
||||
.then((progress) => {
|
||||
if (job.request === request) {
|
||||
job.progress = progress;
|
||||
notify();
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 750);
|
||||
void call(commands.vcsLogIndex(repo, request))
|
||||
.then(
|
||||
(progress) => {
|
||||
job.progress = progress;
|
||||
},
|
||||
() => {
|
||||
job.progress = { ...job.progress, running: false };
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (job.request !== request) return;
|
||||
job.request = null;
|
||||
clearInterval(job.timer);
|
||||
if (job.restart && job.users && !job.paused) {
|
||||
job.restart = false;
|
||||
start(repo, job);
|
||||
}
|
||||
notify();
|
||||
});
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Visible log scopes share one background job per repository. */
|
||||
export function LogIndexStatus({ repositories }: { repositories: readonly string[] }) {
|
||||
revision((s) => s.value);
|
||||
const version = useVcs((s) => s.version);
|
||||
useEffect(() => {
|
||||
for (const repo of repositories) {
|
||||
const job: Job = jobs.get(repo) ?? {
|
||||
users: 0,
|
||||
request: null,
|
||||
paused: false,
|
||||
progress: { indexed: 0, total: 0, running: false },
|
||||
};
|
||||
jobs.set(repo, job);
|
||||
const first = job.users++ === 0;
|
||||
if (first || job.version !== version) {
|
||||
job.version = version;
|
||||
start(repo, job);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
for (const repo of repositories) {
|
||||
const job = jobs.get(repo)!;
|
||||
if (--job.users === 0) {
|
||||
clearInterval(job.timer);
|
||||
if (job.request != null) void commands.cancelRequest(job.request).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [repositories, version]);
|
||||
return repositories.map((repo) => {
|
||||
const job = jobs.get(repo);
|
||||
if (!job || (!job.paused && !job.progress.running)) return null;
|
||||
return (
|
||||
<div key={repo} role="status" style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{tx`Indexing Git log: ${job.progress.indexed} / ${job.progress.total}`}
|
||||
<IconButton
|
||||
icon={job.paused ? "play" : "pause"}
|
||||
title={job.paused ? "Resume Indexing" : "Pause Indexing"}
|
||||
onClick={() => {
|
||||
job.paused = !job.paused;
|
||||
if (job.paused && job.request != null) void commands.cancelRequest(job.request).catch(() => undefined);
|
||||
else start(repo, job);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
* persisted across sessions.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import { LogOptionsContext } from "./logContext";
|
||||
|
||||
export type LogSort = "date" | "topo";
|
||||
export type LogSort = "date" | "topo" | "standard";
|
||||
|
||||
export interface LogOptions {
|
||||
sort: LogSort;
|
||||
@@ -19,6 +21,7 @@ export interface LogOptions {
|
||||
showAuthor: boolean;
|
||||
showDate: boolean;
|
||||
showHash: boolean;
|
||||
showSignature: boolean;
|
||||
highlightMine: boolean;
|
||||
highlightMerges: boolean;
|
||||
highlightCurrentBranch: boolean;
|
||||
@@ -28,6 +31,14 @@ export interface LogOptions {
|
||||
groupByDirectory: boolean;
|
||||
/** Selecting a changed file shows its diff in the editor's preview tab. */
|
||||
diffPreview: boolean;
|
||||
showDetails: boolean;
|
||||
showRepository: boolean;
|
||||
showOnlyAffected: boolean;
|
||||
changesFromParents: boolean;
|
||||
diffLocation: "editor" | "right" | "bottom";
|
||||
columnOrder: string[];
|
||||
columnWidths: Record<string, number>;
|
||||
linearizeMerges: boolean;
|
||||
}
|
||||
|
||||
/** IDEA's defaults. */
|
||||
@@ -44,12 +55,21 @@ export const DEFAULT_LOG_OPTIONS: LogOptions = {
|
||||
showAuthor: true,
|
||||
showDate: true,
|
||||
showHash: false,
|
||||
showSignature: false,
|
||||
highlightMine: true,
|
||||
highlightMerges: true,
|
||||
highlightCurrentBranch: true,
|
||||
showBranches: true,
|
||||
groupByDirectory: true,
|
||||
diffPreview: true,
|
||||
showDetails: true,
|
||||
showRepository: true,
|
||||
showOnlyAffected: false,
|
||||
changesFromParents: false,
|
||||
diffLocation: "editor",
|
||||
columnOrder: ["author", "date", "hash"],
|
||||
columnWidths: {},
|
||||
linearizeMerges: false,
|
||||
};
|
||||
|
||||
/** Options that change which commits the backend returns (a reload is needed). */
|
||||
@@ -61,7 +81,21 @@ export function parseLogOptions(raw: unknown): LogOptions {
|
||||
const o = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||
const out = { ...DEFAULT_LOG_OPTIONS };
|
||||
for (const k of Object.keys(out) as Array<keyof LogOptions>) {
|
||||
if (k === "sort") out.sort = o.sort === "topo" ? "topo" : o.sort === "date" ? "date" : DEFAULT_LOG_OPTIONS.sort;
|
||||
if (k === "sort")
|
||||
out.sort = o.sort === "standard" || o.sort === "topo" || o.sort === "date" ? o.sort : DEFAULT_LOG_OPTIONS.sort;
|
||||
else if (k === "diffLocation")
|
||||
out.diffLocation = o.diffLocation === "right" || o.diffLocation === "bottom" ? o.diffLocation : "editor";
|
||||
else if (k === "columnOrder")
|
||||
out.columnOrder = Array.isArray(o.columnOrder)
|
||||
? [...new Set(o.columnOrder.filter((v): v is string => typeof v === "string"))]
|
||||
: [...DEFAULT_LOG_OPTIONS.columnOrder];
|
||||
else if (k === "columnWidths" && o.columnWidths && typeof o.columnWidths === "object")
|
||||
out.columnWidths = Object.fromEntries(
|
||||
Object.entries(o.columnWidths).filter(
|
||||
(entry): entry is [string, number] =>
|
||||
typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] >= 40 && entry[1] <= 800,
|
||||
),
|
||||
);
|
||||
else if (typeof o[k] === "boolean") (out as Record<string, unknown>)[k] = o[k];
|
||||
}
|
||||
return out;
|
||||
@@ -76,7 +110,13 @@ function load(): LogOptions {
|
||||
}
|
||||
}
|
||||
|
||||
export const useLogOptions = create<LogOptions>(() => load());
|
||||
export const rootLogOptions = create<LogOptions>(() => load());
|
||||
export const useLogOptions = Object.assign(function useScopedLogOptions<T = LogOptions>(
|
||||
selector?: (state: LogOptions) => T,
|
||||
): T {
|
||||
const scoped = useContext(LogOptionsContext);
|
||||
return rootLogOptions((state) => (selector ? selector(scoped ?? state) : ((scoped ?? state) as T)));
|
||||
}, rootLogOptions);
|
||||
|
||||
// Settings › Version Control › Log edits them from the Settings window too.
|
||||
if (typeof window !== "undefined")
|
||||
|
||||
@@ -21,6 +21,7 @@ import { logOptions } from "./logOptions";
|
||||
import { goToRef, updateLogOptions } from "./logStore";
|
||||
|
||||
const VCS = "vcsAvailable";
|
||||
const LOG = "vcsAvailable || projectOpen || vcsLogFocus";
|
||||
|
||||
/** A ref selected in the Branches pane. */
|
||||
export interface BranchTarget {
|
||||
@@ -62,7 +63,7 @@ export function registerLogPaneActions(): () => void {
|
||||
id: "vcs.log.showBranches",
|
||||
title: "Show Branches",
|
||||
category: "Git Log",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
toggled: () => logOptions().showBranches,
|
||||
run: () => {
|
||||
updateLogOptions({ showBranches: !logOptions().showBranches });
|
||||
@@ -74,7 +75,7 @@ export function registerLogPaneActions(): () => void {
|
||||
title: "Collapse Linear Branches",
|
||||
category: "Git Log",
|
||||
icon: "collapse-all",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
run: () => updateLogOptions({ collapseLinear: true }),
|
||||
},
|
||||
{
|
||||
@@ -82,7 +83,7 @@ export function registerLogPaneActions(): () => void {
|
||||
title: "Expand Linear Branches",
|
||||
category: "Git Log",
|
||||
icon: "expand-all",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
run: () => updateLogOptions({ collapseLinear: false }),
|
||||
},
|
||||
|
||||
@@ -135,12 +136,11 @@ export function registerLogPaneActions(): () => void {
|
||||
title: "Navigate Log to Selected Branch",
|
||||
category: "Git Log",
|
||||
icon: "locate",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
const t = branchArg(arg);
|
||||
if (t?.repo) await focusRepo(t.repo);
|
||||
if (t && !(await goToRef(t.ref)))
|
||||
if (t && !(await goToRef(t.ref, t.repo)))
|
||||
notifications().notify({ group: "vcs", level: "warning", title: `'${t.ref}' was not found in the log` });
|
||||
},
|
||||
},
|
||||
@@ -151,7 +151,7 @@ export function registerLogPaneActions(): () => void {
|
||||
title: "Show Diff",
|
||||
category: "Git Log",
|
||||
icon: "diff",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
|
||||
},
|
||||
@@ -159,7 +159,7 @@ export function registerLogPaneActions(): () => void {
|
||||
id: "vcs.log.changes.compareWithLocal",
|
||||
title: "Compare with Local",
|
||||
category: "Git Log",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const c = changesArg(arg)[0];
|
||||
@@ -170,7 +170,7 @@ export function registerLogPaneActions(): () => void {
|
||||
id: "vcs.log.changes.compareBeforeWithLocal",
|
||||
title: "Compare Before with Local",
|
||||
category: "Git Log",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const c = changesArg(arg)[0];
|
||||
@@ -182,7 +182,7 @@ export function registerLogPaneActions(): () => void {
|
||||
title: "Jump to Source",
|
||||
category: "Git Log",
|
||||
icon: "edit",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
for (const c of changesArg(arg)) if (c.after) void openChangeSource(c);
|
||||
@@ -209,7 +209,7 @@ export function registerLogPaneActions(): () => void {
|
||||
id: "vcs.log.changes.groupByDirectory",
|
||||
title: "Group by Directory",
|
||||
category: "Git Log",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
toggled: () => logOptions().groupByDirectory,
|
||||
run: () => updateLogOptions({ groupByDirectory: !logOptions().groupByDirectory }),
|
||||
},
|
||||
@@ -218,7 +218,7 @@ export function registerLogPaneActions(): () => void {
|
||||
title: "Show Diff Preview",
|
||||
category: "Git Log",
|
||||
description: "Selecting a changed file shows its diff in the editor's preview tab",
|
||||
when: VCS,
|
||||
when: LOG,
|
||||
toggled: () => logOptions().diffPreview,
|
||||
run: () => updateLogOptions({ diffPreview: !logOptions().diffPreview }),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "./graph";
|
||||
import { commitKey, graphCommit, type RepoCommit } from "./multiRoot";
|
||||
import type { LogOptions } from "./logOptions";
|
||||
|
||||
/** The same displayed adjacency drives painting, navigation and fragment actions. */
|
||||
export function presentLogGraph(
|
||||
all: readonly RepoCommit[],
|
||||
options: LogOptions,
|
||||
fragments: { expanded: ReadonlySet<string>; collapsed: ReadonlySet<string>; merges: ReadonlySet<string> },
|
||||
keep: ReadonlySet<string>,
|
||||
) {
|
||||
const source = all.map(graphCommit);
|
||||
const collapsed =
|
||||
options.collapseLinear || fragments.collapsed.size
|
||||
? collapseLinear(
|
||||
source,
|
||||
new Set([...keep, ...fragments.expanded]),
|
||||
options.collapseLinear ? undefined : fragments.collapsed,
|
||||
)
|
||||
: null;
|
||||
const expanded = fragments.merges.has("*")
|
||||
? new Set(source.filter((c) => !fragments.merges.has(c.id)).map((c) => c.id))
|
||||
: fragments.merges;
|
||||
const linear = options.linearizeMerges ? linearizeMerges(collapsed?.graph ?? source, expanded) : null;
|
||||
const graph = linear?.graph ?? collapsed?.graph ?? source;
|
||||
const byId = new Map(all.map((c) => [commitKey(c), c]));
|
||||
const commits = graph.map((c) => byId.get(c.id)!);
|
||||
const laid = computeGraph(graph).rows;
|
||||
return { commits, graph, rows: options.longEdges ? laid : hideLongEdges(laid), linear, collapsed };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Commit } from "@/bindings";
|
||||
import type { LogOptions } from "./logOptions";
|
||||
import { commitRepo } from "./multiRoot";
|
||||
import { formatTime } from "./model";
|
||||
|
||||
export function visibleCommitText(c: Commit, options: LogOptions): string[] {
|
||||
return [
|
||||
c.summary,
|
||||
c.refs.join(" "),
|
||||
...(options.showRepository ? [commitRepo(c) ?? ""] : []),
|
||||
...(options.showAuthor ? [c.author] : []),
|
||||
...(options.showDate ? [formatTime(options.commitTimestamp ? (c.commitTimeMs ?? c.timeMs) : c.timeMs)] : []),
|
||||
...(options.showHash ? [c.shortId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** Case-insensitive word/subsequence matching; columns never form an artificial word. */
|
||||
export function speedMatches(query: string, columns: readonly string[]): boolean {
|
||||
return query
|
||||
.toLocaleLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.every((word) =>
|
||||
columns.some((column) => {
|
||||
let next = 0;
|
||||
for (const c of column.toLocaleLowerCase()) if (c === word[next]) next++;
|
||||
return next === word.length;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function nextSpeedMatch(
|
||||
commits: readonly Commit[],
|
||||
query: string,
|
||||
options: LogOptions,
|
||||
from: number,
|
||||
direction = 1,
|
||||
) {
|
||||
for (let step = 1; step <= commits.length; step++) {
|
||||
const i = (from + direction * step + commits.length * 2) % commits.length;
|
||||
if (speedMatches(query, visibleCommitText(commits[i]!, options))) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
+781
-129
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,28 @@
|
||||
/** Repository identity for log rows and shared repository queries. */
|
||||
import type { Branch, Commit } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import type { GraphCommit } from "./graph";
|
||||
|
||||
export type RepoCommit = Commit & { repo?: string | null; user?: string | null; remoteNames?: string[] };
|
||||
export type RepoCommit = Commit & {
|
||||
repo?: string | null;
|
||||
user?: string | null;
|
||||
userName?: string | null;
|
||||
remoteNames?: string[];
|
||||
readOnly?: boolean;
|
||||
};
|
||||
export const commitRepo = (c: Commit | undefined): string | null | undefined => (c as RepoCommit | undefined)?.repo;
|
||||
export const commitKey = (c: Pick<RepoCommit, "id" | "repo">): string => (c.repo ? `${c.repo}\0${c.id}` : c.id);
|
||||
export const graphCommit = (c: Commit): Commit => ({
|
||||
export const graphCommit = (c: Commit): Commit & GraphCommit => ({
|
||||
...c,
|
||||
id: commitKey(c),
|
||||
parents: c.parents.map((id) => commitKey({ ...c, id })),
|
||||
parents: [...(c.graphParents ?? c.parents), ...(c.graphBoundaryParents ?? [])].map((id) => commitKey({ ...c, id })),
|
||||
dashed: c.graphDashed ?? c.dashed,
|
||||
edgeTypes: [
|
||||
...(c.graphParents ?? c.parents).map((_, i) =>
|
||||
(c.graphDashed ?? c.dashed)?.[i] ? ("filtered" as const) : ("normal" as const),
|
||||
),
|
||||
...(c.graphBoundaryParents ?? []).map(() => "shallow" as const),
|
||||
],
|
||||
});
|
||||
|
||||
/** Merge ordered streams without breaking any repository's topological order. */
|
||||
|
||||
@@ -787,7 +787,6 @@
|
||||
"File name with extension": "Nom du fichier avec extension",
|
||||
"File name without extension": "Nom du fichier sans extension",
|
||||
"File not found": "Fichier introuvable",
|
||||
"File or folder": "Fichier ou dossier",
|
||||
"File patterns": "Motifs de fichiers",
|
||||
"File templates": "Modèles de fichiers",
|
||||
"File:": "Fichier :",
|
||||
@@ -803,7 +802,6 @@
|
||||
"Filter TODO Items": "Filtrer les éléments TODO",
|
||||
"Filter by Branch": "Filtrer par branche",
|
||||
"Filter by Date": "Filtrer par date",
|
||||
"Filter by Path": "Filtrer par chemin",
|
||||
"Filter by User": "Filtrer par utilisateur",
|
||||
"Filter by message": "Filtrer par message",
|
||||
"Filter by name, PID or command line": "Filtrer par nom, PID ou ligne de commande",
|
||||
@@ -1393,7 +1391,6 @@
|
||||
"Other File Types": "Autres types de fichiers",
|
||||
"Other hints (chaining, closing labels…)": "Autres indications (chaînage, libellés de fermeture…)",
|
||||
"Other names or emails (comma-separated):": "Autres noms ou e-mails (séparés par des virgules) :",
|
||||
"Other revisions (comma-separated):": "Autres révisions (séparées par des virgules) :",
|
||||
"Otherwise line breakpoints can only be set in files of languages that have a debugger.": "Sinon, les points d'arrêt de ligne ne peuvent être posés que dans les fichiers de langages dotés d'un débogueur.",
|
||||
"Outgoing commits": "Commits sortants",
|
||||
"Output": "Sortie",
|
||||
@@ -4344,7 +4341,6 @@
|
||||
"partial": "partiel",
|
||||
"editable": "modifiable",
|
||||
"on {date}": "le {date}",
|
||||
"Merge of {parents}": "Fusion de {parents}",
|
||||
"renamed from {name}": "renommé depuis {name}",
|
||||
"Label '{label}'": "Étiquette '{label}'",
|
||||
"{n} file recorded before it": "{n} fichier enregistré avant",
|
||||
|
||||
@@ -847,7 +847,6 @@
|
||||
"File name with extension": "확장자를 포함한 파일 이름",
|
||||
"File name without extension": "확장자를 제외한 파일 이름",
|
||||
"File not found": "파일을 찾을 수 없습니다",
|
||||
"File or folder": "파일 또는 폴더",
|
||||
"File patterns": "파일 패턴",
|
||||
"File templates": "파일 템플릿",
|
||||
"File:": "파일:",
|
||||
@@ -863,7 +862,6 @@
|
||||
"Filter TODO Items": "TODO 항목 필터링",
|
||||
"Filter by Branch": "브랜치별 필터링",
|
||||
"Filter by Date": "날짜별 필터링",
|
||||
"Filter by Path": "경로별 필터링",
|
||||
"Filter by User": "사용자별 필터링",
|
||||
"Filter by message": "메시지별 필터링",
|
||||
"Filter by name, PID or command line": "이름, PID 또는 명령줄로 필터링",
|
||||
@@ -1453,7 +1451,6 @@
|
||||
"Other File Types": "기타 파일 유형",
|
||||
"Other hints (chaining, closing labels…)": "기타 힌트(체이닝, 닫는 레이블…)",
|
||||
"Other names or emails (comma-separated):": "기타 이름 또는 이메일(쉼표로 구분):",
|
||||
"Other revisions (comma-separated):": "기타 리비전(쉼표로 구분):",
|
||||
"Otherwise line breakpoints can only be set in files of languages that have a debugger.": "그렇지 않으면 디버거가 있는 언어의 파일에서만 줄 중단점을 설정할 수 있습니다.",
|
||||
"Outgoing commits": "나가는 커밋",
|
||||
"Output": "출력",
|
||||
@@ -4461,7 +4458,6 @@
|
||||
"partial": "부분",
|
||||
"editable": "편집 가능",
|
||||
"on {date}": "{date}에",
|
||||
"Merge of {parents}": "{parents} 병합",
|
||||
"renamed from {name}": "{name}에서 이름 변경됨",
|
||||
"Label '{label}'": "레이블 '{label}'",
|
||||
"{n} file recorded before it": "이전에 기록된 파일 {n}개",
|
||||
|
||||
@@ -848,7 +848,6 @@
|
||||
"File name with extension": "Nome do arquivo com extensão",
|
||||
"File name without extension": "Nome do arquivo sem extensão",
|
||||
"File not found": "Arquivo não encontrado",
|
||||
"File or folder": "Arquivo ou pasta",
|
||||
"File patterns": "Padrões de arquivo",
|
||||
"File templates": "Modelos de arquivo",
|
||||
"File:": "Arquivo:",
|
||||
@@ -864,7 +863,6 @@
|
||||
"Filter TODO Items": "Filtrar itens de TODO",
|
||||
"Filter by Branch": "Filtrar por branch",
|
||||
"Filter by Date": "Filtrar por data",
|
||||
"Filter by Path": "Filtrar por caminho",
|
||||
"Filter by User": "Filtrar por usuário",
|
||||
"Filter by message": "Filtrar por mensagem",
|
||||
"Filter by name, PID or command line": "Filtrar por nome, PID ou linha de comando",
|
||||
@@ -1455,7 +1453,6 @@
|
||||
"Other File Types": "Outros tipos de arquivo",
|
||||
"Other hints (chaining, closing labels…)": "Outras dicas (encadeamento, rótulos de fechamento…)",
|
||||
"Other names or emails (comma-separated):": "Outros nomes ou e-mails (separados por vírgula):",
|
||||
"Other revisions (comma-separated):": "Outras revisões (separadas por vírgula):",
|
||||
"Otherwise line breakpoints can only be set in files of languages that have a debugger.": "Caso contrário, pontos de interrupção de linha só podem ser definidos em arquivos de linguagens que possuem um depurador.",
|
||||
"Outgoing commits": "Commits enviados",
|
||||
"Output": "Saída",
|
||||
@@ -4466,7 +4463,6 @@
|
||||
"partial": "parcial",
|
||||
"editable": "editável",
|
||||
"on {date}": "em {date}",
|
||||
"Merge of {parents}": "Mesclagem de {parents}",
|
||||
"renamed from {name}": "renomeado de {name}",
|
||||
"Label '{label}'": "Rótulo '{label}'",
|
||||
"{n} file recorded before it": "{n} arquivo gravado antes dele",
|
||||
|
||||
@@ -852,7 +852,6 @@
|
||||
"File name with extension": "带扩展名的文件名",
|
||||
"File name without extension": "不带扩展名的文件名",
|
||||
"File not found": "未找到文件",
|
||||
"File or folder": "文件或文件夹",
|
||||
"File patterns": "文件模式",
|
||||
"File templates": "文件模板",
|
||||
"File:": "文件:",
|
||||
@@ -868,7 +867,6 @@
|
||||
"Filter TODO Items": "过滤 TODO 项",
|
||||
"Filter by Branch": "按分支过滤",
|
||||
"Filter by Date": "按日期过滤",
|
||||
"Filter by Path": "按路径过滤",
|
||||
"Filter by User": "按用户过滤",
|
||||
"Filter by message": "按消息过滤",
|
||||
"Filter by name, PID or command line": "按名称、PID 或命令行过滤",
|
||||
@@ -1461,7 +1459,6 @@
|
||||
"Other File Types": "其他文件类型",
|
||||
"Other hints (chaining, closing labels…)": "其他提示(链式调用、结束标签…)",
|
||||
"Other names or emails (comma-separated):": "其他名称或电子邮件(逗号分隔):",
|
||||
"Other revisions (comma-separated):": "其他修订(逗号分隔):",
|
||||
"Otherwise line breakpoints can only be set in files of languages that have a debugger.": "否则只能在有调试器的语言文件中设置行断点。",
|
||||
"Outgoing commits": "传出的提交",
|
||||
"Output": "输出",
|
||||
@@ -4491,7 +4488,6 @@
|
||||
"partial": "部分",
|
||||
"editable": "可编辑",
|
||||
"on {date}": "于 {date}",
|
||||
"Merge of {parents}": "合并自 {parents}",
|
||||
"renamed from {name}": "已从 {name} 重命名",
|
||||
"Label '{label}'": "标签“{label}”",
|
||||
"{n} file recorded before it": "在此之前记录了 {n} 个文件",
|
||||
@@ -6082,6 +6078,67 @@
|
||||
"Keep abstract": "保留抽象声明",
|
||||
"Methods pulled up to an interface are declared abstract there.": "上拉到接口的方法将在接口中声明为抽象方法。",
|
||||
"The file changed; open the dialog again": "文件已更改,请重新打开对话框",
|
||||
"Bookmarked Commits": "已添加书签的提交",
|
||||
"Open Another Log Tab": "打开另一个日志标签页",
|
||||
"In Tool Window": "在工具窗口中",
|
||||
"In Editor": "在编辑器中",
|
||||
"External Repository…": "外部仓库…",
|
||||
"Recent Filters": "最近使用的过滤条件",
|
||||
"All commits": "所有提交",
|
||||
"Incoming Commits First": "优先显示传入提交",
|
||||
"Linearize Merges": "线性显示合并",
|
||||
"Remove Commit Bookmark": "移除提交书签",
|
||||
"Bookmark Commit": "为提交添加书签",
|
||||
"Browse Repository at Revision": "浏览此修订版本的仓库",
|
||||
"Signature": "签名",
|
||||
"Commit is outside the loaded history": "提交位于已加载历史记录之外",
|
||||
"Move Left": "向左移动",
|
||||
"Move Right": "向右移动",
|
||||
"Reset Width": "重置宽度",
|
||||
"Resize {title} column": "调整{title}列宽度",
|
||||
"Commit graph": "提交关系图",
|
||||
"Go to connected commit": "转到关联提交",
|
||||
"Expand this fragment": "展开此片段",
|
||||
"Collapse this fragment": "折叠此片段",
|
||||
"Collapse or expand this branch": "折叠或展开此分支",
|
||||
"Diff Preview Location": "差异预览位置",
|
||||
"Parent": "父提交",
|
||||
"Commit {shortId}": "提交 {shortId}",
|
||||
"Expand Commit Message": "展开提交消息",
|
||||
"Committed by": "提交者",
|
||||
"Merge of": "合并自",
|
||||
"Containing branches": "包含此提交的分支",
|
||||
"Could not load containing branches or signature details": "无法加载包含此提交的分支或签名详情",
|
||||
"Could not verify": "无法验证",
|
||||
"Choose an absolute path inside a selected repository.": "请选择所选仓库内的绝对路径。",
|
||||
"Filter by Paths": "按多个路径过滤",
|
||||
"Files and folders": "文件和文件夹",
|
||||
"Additional absolute path": "其他绝对路径",
|
||||
"Selected paths": "已选路径",
|
||||
"Open Git Log": "打开 Git 日志",
|
||||
"Could not open Git log": "无法打开 Git 日志",
|
||||
"Search repository files": "搜索仓库文件",
|
||||
"Repository files": "仓库文件",
|
||||
"Open Revision in Editor": "在编辑器中打开修订版本",
|
||||
"Open External Git Log…": "打开外部 Git 日志…",
|
||||
"Repository at {slice}": "修订版本 {slice} 的仓库",
|
||||
"Select a file to view its repository version": "选择文件以查看其仓库版本",
|
||||
"Branches, patterns, exclusions or ranges (comma-separated):": "分支、模式、排除项或范围(逗号分隔):",
|
||||
"Select Parent Commit": "选择父提交",
|
||||
"Select Child Commit": "选择子提交",
|
||||
"Select Repository Commit": "选择仓库中的提交",
|
||||
"Good signature with unknown trust": "签名有效,信任状态未知",
|
||||
"Bad signature": "签名无效",
|
||||
"Expired signature": "签名已过期",
|
||||
"Expired signing key": "签名密钥已过期",
|
||||
"Revoked signing key": "签名密钥已撤销",
|
||||
"Load Newer Commits": "加载较新的提交",
|
||||
"Shallow history boundary": "浅克隆历史边界",
|
||||
"All Parents": "所有父提交",
|
||||
"Show Commit Bookmark": "显示提交书签",
|
||||
"Indexing Git log: {indexed} / {total}": "正在索引 Git 日志:{indexed} / {total}",
|
||||
"Resume Indexing": "继续索引",
|
||||
"Pause Indexing": "暂停索引",
|
||||
"Incompatible": "不兼容",
|
||||
"Needs plugin API {engine}. Update OpenIDEA to install it.": "需要插件 API {engine}。请更新 OpenIDEA 后再安装。",
|
||||
"Icon Themes": "图标主题",
|
||||
|
||||
@@ -20,16 +20,16 @@ import { useApplyTheme } from "./theme";
|
||||
import { ToolWindowLayout } from "./toolwindows/ToolWindows";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { WindowResizeHandles } from "./WindowResizeHandles";
|
||||
import { useContributions } from "./contributions";
|
||||
import { useContributions, useStandaloneEditor } from "./contributions";
|
||||
import styles from "./Workbench.module.css";
|
||||
|
||||
/** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */
|
||||
function LightEditFrame() {
|
||||
function LightEditFrame({ title }: { title?: string }) {
|
||||
const path = useEditorRegistry((s) => s.active?.ctx.path);
|
||||
const distractionFree = useDistractionFree((s) => s.active);
|
||||
return (
|
||||
<>
|
||||
<TitleBar lightEdit bare={distractionFree} title={path ? basename(path) : "LightEdit"} />
|
||||
<TitleBar lightEdit bare={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
|
||||
<main className={styles.lightEdit} data-light-edit>
|
||||
<div className={styles.lightEditIsland}>
|
||||
<EditorArea />
|
||||
@@ -66,10 +66,11 @@ export function Workbench() {
|
||||
const banners = useContributions((s) => s.workbenchBanners);
|
||||
const project = useProject((s) => s.project);
|
||||
const lightEdit = useProject((s) => s.lightEdit);
|
||||
const standaloneTitle = useStandaloneEditor((s) => s.title);
|
||||
const openingPath = useProject((s) => s.openingPath);
|
||||
const root = project?.root ?? null;
|
||||
const distractionFree = useDistractionFree((s) => s.active);
|
||||
const frame = project ? styles.framed : lightEdit ? styles.lightEditFrame : undefined;
|
||||
const frame = project ? styles.framed : lightEdit || standaloneTitle ? styles.lightEditFrame : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -88,8 +89,8 @@ export function Workbench() {
|
||||
<ToolWindowLayout key={root} editor={<EditorArea />} />
|
||||
{!distractionFree && <StatusBar />}
|
||||
</>
|
||||
) : lightEdit ? (
|
||||
<LightEditFrame />
|
||||
) : lightEdit || standaloneTitle ? (
|
||||
<LightEditFrame title={standaloneTitle ?? undefined} />
|
||||
) : (
|
||||
<Welcome />
|
||||
)}
|
||||
|
||||
@@ -120,6 +120,10 @@ export interface EditorKindContribution {
|
||||
* `data` must be plain JSON). The editor closes tabs whose target is gone itself.
|
||||
*/
|
||||
restorable?: boolean;
|
||||
/** Can remain visible in a window without a project (for example, an external Git log). */
|
||||
standalone?: boolean;
|
||||
/** Last tab showing this input closed (including split tabs and tab-limit eviction). */
|
||||
onClose?(input: EditorInput): void;
|
||||
/**
|
||||
* The editor owns a document with unsaved state (a notebook): it takes part in
|
||||
* the dirty mark, Save All, autosave and the close prompts. Tabs with equal
|
||||
@@ -158,6 +162,7 @@ export interface EditorDocumentLifecycle {
|
||||
|
||||
/** Bumped by `notifyEditorDocumentChanged`; selectors reading `isDirty` re-run on it. */
|
||||
export const useEditorDocuments = create<{ revision: number }>(() => ({ revision: 0 }));
|
||||
export const useStandaloneEditor = create<{ title: string | null }>(() => ({ title: null }));
|
||||
|
||||
/** A custom editor document's dirty state (or pending edits) may have changed: tab marks and counts refresh. */
|
||||
export function notifyEditorDocumentChanged() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEditorRegistry } from "@/editor/api";
|
||||
import { evaluateWhen } from "@/platform/context";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { activateWorkbench } from "./activate";
|
||||
import { registerStatusItem, registerTitleItem, registerToolWindow } from "./contributions";
|
||||
import { registerStatusItem, registerTitleItem, registerToolWindow, useStandaloneEditor } from "./contributions";
|
||||
import { EmptyEditor } from "./EditorArea";
|
||||
import { chooseProjectWindow } from "./projectWindow";
|
||||
import { Workbench } from "./Workbench";
|
||||
@@ -36,6 +36,7 @@ describe("LightEdit window", () => {
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
useStandaloneEditor.setState({ title: null });
|
||||
disposers.splice(0).forEach((d) => d());
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
useEditorRegistry.setState({ active: null });
|
||||
@@ -52,6 +53,16 @@ describe("LightEdit window", () => {
|
||||
expect(screen.queryByText("Welcome to OpenIDEA")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a standalone log visible without a project or LightEdit session", () => {
|
||||
useProject.setState({ lightEdit: false });
|
||||
useStandaloneEditor.setState({ title: "External Git Log" });
|
||||
render(<Workbench />);
|
||||
expect(screen.getByText("External Git Log")).toBeInTheDocument();
|
||||
expect(document.querySelector("[data-editor-area]")).not.toBeNull();
|
||||
expect(screen.queryByText("Welcome to OpenIDEA")).toBeNull();
|
||||
expect(document.querySelector("[data-stripe-side]")).toBeNull();
|
||||
});
|
||||
|
||||
it("hints only at opening files in an empty editor", () => {
|
||||
render(<EmptyEditor />);
|
||||
expect(screen.getByText("Open File")).toBeInTheDocument();
|
||||
|
||||
Reference in new issue
Заблокувати користувача