дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
CI / Changes (push) Successful in 2s
Language smoke / Language smoke (typescript) (push) Failing after 5s
CI / Cargo deny (push) Successful in 10m50s
CI / Rust (windows-latest) (push) Failing after 18s
CI / Frontend (push) Failing after 30m6s
Language smoke / Language smoke (kotlin) (push) Failing after 5s
CI / E2E (push) Failing after 30m45s
CI / Rust (ubuntu-22.04) (push) Failing after 2m14s
CI / Rust (macos-latest) (push) Failing after 1h0m27s
CI / Remote server (linux musl) (push) Successful in 8m3s
Language smoke / Language smoke (go) (push) Failing after 7s
Language smoke / Language smoke (python) (push) Failing after 4s
Language smoke / Language smoke (rust) (push) Failing after 4s
CI / Plugins (SDK, official, fixtures) (push) Successful in 11m37s
Language smoke / Language smoke (java) (push) Failing after 5s
With Use non-modal commit interface unticked, parts of the IDE still assumed the Commit tool window: Commit File only set the inclusion and focused a message field that mode does not have, and diffs opened as editor tabs. Commit File and Commit Changelist now open the Commit dialog with those changes included, and user-opened VCS diffs (Local Changes, log, Shelf, compare actions) open in a front window whose close shortcut stays inside it. With the setting ticked, the default, behaviour is unchanged. Co-authored-by: manini-1 <[email protected]>
479 рядки
17 KiB
TypeScript
479 рядки
17 KiB
TypeScript
/** Commit tool window model: change groups, directory tree, inclusion (pure). */
|
||
import type { ChangelistState, FileStatus } from "@/bindings";
|
||
import { baseName, dirName, effectiveKind, relTo, repoLabel, rootOf, stripSlash, type EffectiveKind } from "./model";
|
||
import { tx } from "@/platform/i18n";
|
||
|
||
export type GroupId = "conflicts" | "changes" | "unversioned" | "staged" | "unstaged";
|
||
|
||
export const GROUP_LABEL: Record<GroupId, string> = {
|
||
conflicts: "Merge Conflicts",
|
||
changes: "Changes",
|
||
unversioned: "Unversioned Files",
|
||
staged: "Staged Changes",
|
||
unstaged: "Unstaged Changes",
|
||
};
|
||
|
||
/** The index side a file under a "by staging state" group is compared on (partially staged files only). */
|
||
export function stagingSide(group: GroupId, fs: FileStatus): "staged" | "unstaged" | null {
|
||
return (group === "staged" || group === "unstaged") && isPartiallyStaged(fs) ? group : null;
|
||
}
|
||
|
||
/**
|
||
* Inclusion of a file in the next commit:
|
||
* `all` – stage everything before committing; `none` – keep out (unstage);
|
||
* `staged` – commit only what is already staged (partially staged files);
|
||
* `hunks` – commit only the changed blocks not excluded in the diff (IDEA's partial
|
||
* commit; see `excludedHunks`), through a temporary index.
|
||
*/
|
||
export type CheckState = "all" | "none" | "staged" | "hunks";
|
||
|
||
export interface ChangeNode {
|
||
key: string;
|
||
type: "group" | "dir" | "file";
|
||
label: string;
|
||
group: GroupId;
|
||
children?: ChangeNode[];
|
||
/** Files below this node (the file itself for file nodes). */
|
||
files: FileStatus[];
|
||
kind?: EffectiveKind;
|
||
/** Absolute path (files and directories). */
|
||
path?: string;
|
||
/** Detail text: the relative directory (flat mode) or the old name. */
|
||
detail?: string;
|
||
/** Change list id (change list groups and their descendants). */
|
||
changelist?: string;
|
||
/** The active change list's group. */
|
||
active?: boolean;
|
||
}
|
||
|
||
export function groupOf(fs: FileStatus): GroupId | null {
|
||
const kind = effectiveKind(fs);
|
||
if (!kind || kind === "ignored") return null;
|
||
if (kind === "conflicted") return "conflicts";
|
||
if (kind === "untracked") return "unversioned";
|
||
return "changes";
|
||
}
|
||
|
||
export function isPartiallyStaged(fs: FileStatus): boolean {
|
||
return (
|
||
!!fs.staged &&
|
||
!!fs.unstaged &&
|
||
fs.unstaged !== "untracked" &&
|
||
fs.unstaged !== "conflicted" &&
|
||
fs.staged !== "conflicted"
|
||
);
|
||
}
|
||
|
||
export function initialCheck(fs: FileStatus): CheckState {
|
||
const g = groupOf(fs);
|
||
// Unversioned files and unresolved conflicts are opt-in, as in IDEA.
|
||
if (g !== "changes") return "none";
|
||
return isPartiallyStaged(fs) ? "staged" : "all";
|
||
}
|
||
|
||
export function checkOf(fs: FileStatus, checks: ReadonlyMap<string, CheckState>): CheckState {
|
||
return checks.get(fs.path) ?? initialCheck(fs);
|
||
}
|
||
|
||
/** Aggregate checkbox state for a set of files. */
|
||
export function aggregateCheck(files: readonly FileStatus[], checks: ReadonlyMap<string, CheckState>) {
|
||
let on = 0;
|
||
let partial = false;
|
||
for (const f of files) {
|
||
const c = checkOf(f, checks);
|
||
if (c !== "none") on++;
|
||
if (c === "staged" || c === "hunks") partial = true;
|
||
}
|
||
return {
|
||
checked: on > 0 && on === files.length && !partial,
|
||
indeterminate: (on > 0 && on < files.length) || partial,
|
||
on,
|
||
};
|
||
}
|
||
|
||
const KIND_ORDER: GroupId[] = ["conflicts", "changes", "unversioned"];
|
||
|
||
function fileNode(fs: FileStatus, root: string, group: GroupId, flat: boolean, scope: string = group): ChangeNode {
|
||
const path = stripSlash(fs.path);
|
||
const rel = relTo(root, path);
|
||
const kind = effectiveKind(fs) ?? "modified";
|
||
const dir = dirName(rel);
|
||
return {
|
||
key: `${scope}:${path}`,
|
||
type: "file",
|
||
label: baseName(path),
|
||
group,
|
||
files: [fs],
|
||
kind,
|
||
path,
|
||
detail: !fs.renamedFrom
|
||
? flat
|
||
? dir
|
||
: undefined
|
||
: flat && dir
|
||
? tx`${dir} · from ${fs.renamedFrom}`
|
||
: tx`from ${fs.renamedFrom}`,
|
||
};
|
||
}
|
||
|
||
interface Trie {
|
||
dirs: Map<string, Trie>;
|
||
files: FileStatus[];
|
||
}
|
||
|
||
function dirNodes(trie: Trie, prefix: string, root: string, group: GroupId, scope: string = group): ChangeNode[] {
|
||
const out: ChangeNode[] = [];
|
||
const dirs = [...trie.dirs.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||
for (const [name, sub] of dirs) {
|
||
// Compact chains of directories that only contain one directory ("src/main/kotlin").
|
||
let label = name;
|
||
let node = sub;
|
||
let rel = prefix ? `${prefix}/${name}` : name;
|
||
while (node.files.length === 0 && node.dirs.size === 1) {
|
||
const [n, s] = [...node.dirs.entries()][0]!;
|
||
label += `/${n}`;
|
||
rel += `/${n}`;
|
||
node = s;
|
||
}
|
||
const children = dirNodes(node, rel, root, group, scope);
|
||
const files = collect(node);
|
||
out.push({
|
||
key: `${scope}:dir:${rel}`,
|
||
type: "dir",
|
||
label,
|
||
group,
|
||
children,
|
||
files,
|
||
path: `${root}/${rel}`,
|
||
});
|
||
}
|
||
const files = [...trie.files].sort((a, b) => baseName(a.path).localeCompare(baseName(b.path)));
|
||
for (const f of files) out.push(fileNode(f, root, group, false, scope));
|
||
return out;
|
||
}
|
||
|
||
function collect(t: Trie): FileStatus[] {
|
||
const out = [...t.files];
|
||
for (const s of t.dirs.values()) out.push(...collect(s));
|
||
return out;
|
||
}
|
||
|
||
function childNodes(files: readonly FileStatus[], root: string, group: GroupId, groupByDir: boolean, scope: string) {
|
||
if (groupByDir) {
|
||
const trie: Trie = { dirs: new Map(), files: [] };
|
||
for (const f of files) {
|
||
const rel = relTo(root, stripSlash(f.path));
|
||
const parts = rel.split("/");
|
||
parts.pop();
|
||
let t = trie;
|
||
for (const p of parts) {
|
||
let next = t.dirs.get(p);
|
||
if (!next) t.dirs.set(p, (next = { dirs: new Map(), files: [] }));
|
||
t = next;
|
||
}
|
||
t.files.push(f);
|
||
}
|
||
return dirNodes(trie, "", root, group, scope);
|
||
}
|
||
return [...files]
|
||
.sort((a, b) => baseName(a.path).localeCompare(baseName(b.path)) || a.path.localeCompare(b.path))
|
||
.map((f) => fileNode(f, root, group, true, scope));
|
||
}
|
||
|
||
/** With several repositories, a node per repository (IDEA's "Group by Repository") above the files. */
|
||
function repoNodes(
|
||
files: readonly FileStatus[],
|
||
root: string,
|
||
group: GroupId,
|
||
groupByDir: boolean,
|
||
scope: string,
|
||
repos: readonly string[],
|
||
): ChangeNode[] {
|
||
if (repos.length < 2) return childNodes(files, root, group, groupByDir, scope);
|
||
const byRepo = new Map<string, FileStatus[]>();
|
||
for (const f of files) {
|
||
const repo = rootOf(f.path, repos) ?? root;
|
||
let list = byRepo.get(repo);
|
||
if (!list) byRepo.set(repo, (list = []));
|
||
list.push(f);
|
||
}
|
||
const order = (r: string) => (repos.includes(r) ? repos.indexOf(r) : repos.length);
|
||
return [...byRepo]
|
||
.sort(([a], [b]) => order(a) - order(b))
|
||
.map(([repo, own]) => ({
|
||
key: `${scope}:repo:${repo}`,
|
||
type: "dir" as const,
|
||
label: repoLabel(repo, root),
|
||
group,
|
||
children: childNodes(own, repo, group, groupByDir, `${scope}@${repo}`),
|
||
files: own,
|
||
path: repo,
|
||
}));
|
||
}
|
||
|
||
function tagList(nodes: ChangeNode[], id: string): ChangeNode[] {
|
||
for (const n of nodes) {
|
||
n.changelist = id;
|
||
if (n.children) tagList(n.children, id);
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
/**
|
||
* Group roots (Merge Conflicts / Changes / Unversioned Files) with flat or directory
|
||
* children. With change lists, "Changes" is split into one group per list
|
||
* (`ChangelistState.files` keys are project-relative paths). With several
|
||
* repositories (`repos`), each group's files are grouped by repository first. With
|
||
* `byStaging`, "Changes" becomes "Staged Changes" and "Unstaged Changes" instead.
|
||
*/
|
||
export function buildChangeTree(
|
||
status: readonly FileStatus[],
|
||
root: string,
|
||
groupByDir: boolean,
|
||
lists?: ChangelistState,
|
||
repos: readonly string[] = [],
|
||
byStaging = false,
|
||
): ChangeNode[] {
|
||
const byGroup = new Map<GroupId, FileStatus[]>();
|
||
for (const fs of status) {
|
||
const g = groupOf(fs);
|
||
if (!g) continue;
|
||
let list = byGroup.get(g);
|
||
if (!list) byGroup.set(g, (list = []));
|
||
list.push(fs);
|
||
}
|
||
const roots: ChangeNode[] = [];
|
||
for (const g of KIND_ORDER) {
|
||
const files = byGroup.get(g) ?? [];
|
||
if (g === "changes" && byStaging) {
|
||
// A partially staged file is listed under both, each opening its own side of the index.
|
||
const sides: Array<["staged" | "unstaged", FileStatus[]]> = [
|
||
["staged", files.filter((f) => !!f.staged)],
|
||
["unstaged", files.filter((f) => !!f.unstaged)],
|
||
];
|
||
for (const [side, own] of sides) {
|
||
if (!own.length) continue;
|
||
const children = repoNodes(own, root, side, groupByDir, side, repos);
|
||
roots.push({
|
||
key: `group:${side}`,
|
||
type: "group",
|
||
label: GROUP_LABEL[side],
|
||
group: side,
|
||
children,
|
||
files: own,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
if (g === "changes" && lists) {
|
||
const byList = new Map<string, FileStatus[]>(lists.lists.map((l) => [l.id, []]));
|
||
for (const f of files) {
|
||
const id = lists.files?.[relTo(root, stripSlash(f.path))];
|
||
byList.get(id && byList.has(id) ? id : lists.active)?.push(f);
|
||
}
|
||
for (const l of lists.lists) {
|
||
const own = byList.get(l.id) ?? [];
|
||
// Empty lists are shown once the user has more than the default one.
|
||
if (!own.length && lists.lists.length < 2) continue;
|
||
const scope = `changes@${l.id}`;
|
||
roots.push({
|
||
key: `group:cl:${l.id}`,
|
||
type: "group",
|
||
label: l.name,
|
||
group: g,
|
||
children: tagList(repoNodes(own, root, g, groupByDir, scope, repos), l.id),
|
||
files: own,
|
||
changelist: l.id,
|
||
active: l.id === lists.active,
|
||
detail: l.comment ? l.comment.split("\n")[0] : undefined,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
if (!files.length) continue;
|
||
const children = repoNodes(files, root, g, groupByDir, g, repos);
|
||
roots.push({ key: `group:${g}`, type: "group", label: GROUP_LABEL[g], group: g, children, files });
|
||
}
|
||
return roots;
|
||
}
|
||
|
||
export function changeGroupPaths(roots: readonly ChangeNode[], path: string): string[] {
|
||
const group = roots.find((node) => node.files.some((file) => stripSlash(file.path) === path));
|
||
if (!group) return [path];
|
||
const paths: string[] = [];
|
||
const walk = (nodes: readonly ChangeNode[]) => {
|
||
for (const node of nodes) {
|
||
if (node.type === "file" && node.path) paths.push(node.path);
|
||
else walk(node.children ?? []);
|
||
}
|
||
};
|
||
walk([group]);
|
||
return paths;
|
||
}
|
||
|
||
/** Every file node of the tree, in tree order (a partially staged file is listed under both staging groups). */
|
||
export function changeFileNodes(roots: readonly ChangeNode[]): ChangeNode[] {
|
||
const out: ChangeNode[] = [];
|
||
const walk = (nodes: readonly ChangeNode[]) => {
|
||
for (const node of nodes) {
|
||
if (node.type === "file" && node.path) out.push(node);
|
||
else walk(node.children ?? []);
|
||
}
|
||
};
|
||
walk(roots);
|
||
return out;
|
||
}
|
||
|
||
/** File nodes, in tree order, of the group a file node belongs to. */
|
||
export function groupFileNodes(roots: readonly ChangeNode[], node: ChangeNode): ChangeNode[] {
|
||
const group = roots.find((r) => r.group === node.group && r.files.some((f) => stripSlash(f.path) === node.path));
|
||
if (!group) return [node];
|
||
const out: ChangeNode[] = [];
|
||
const walk = (nodes: readonly ChangeNode[]) => {
|
||
for (const n of nodes) {
|
||
if (n.type === "file" && n.path) out.push(n);
|
||
else walk(n.children ?? []);
|
||
}
|
||
};
|
||
walk([group]);
|
||
return out;
|
||
}
|
||
|
||
export interface CommitPlan {
|
||
/** Included files committed with their working-tree content. */
|
||
stage: string[];
|
||
/** Staged files left out of the commit; they stay staged. */
|
||
exclude: string[];
|
||
/** Files that end up in the commit. */
|
||
included: FileStatus[];
|
||
/** Absolute paths of included files committed with only some of their hunks. */
|
||
partial: string[];
|
||
}
|
||
|
||
/**
|
||
* Repositories to commit: those holding `included` files (the selected one last, so its
|
||
* commit id is returned), else just the selected one (amend, concluding a merge).
|
||
*/
|
||
export function commitRepos(
|
||
included: readonly FileStatus[],
|
||
roots: readonly string[],
|
||
selected: string | null,
|
||
): Array<string | null> {
|
||
const repos = [...new Set(included.map((f) => rootOf(f.path, roots)).filter((r): r is string => !!r))];
|
||
if (!repos.length) return [selected];
|
||
return selected && repos.includes(selected) ? [...repos.filter((r) => r !== selected), selected] : repos;
|
||
}
|
||
|
||
/** Absolute path of the source of a staged rename (`renamedFrom` is repository-relative). */
|
||
export function renamedFromPath(fs: FileStatus): string | null {
|
||
if (!fs.renamedFrom) return null;
|
||
const path = stripSlash(fs.path);
|
||
const root = path.endsWith(fs.relPath) ? path.slice(0, path.length - fs.relPath.length) : null;
|
||
return root === null ? null : `${root}${fs.renamedFrom}`;
|
||
}
|
||
|
||
/** What the commit takes beyond the index, and what staged changes it leaves out. */
|
||
export function planCommit(status: readonly FileStatus[], checks: ReadonlyMap<string, CheckState>): CommitPlan {
|
||
const plan: CommitPlan = { stage: [], exclude: [], included: [], partial: [] };
|
||
for (const fs of status) {
|
||
if (!groupOf(fs)) continue;
|
||
const c = checkOf(fs, checks);
|
||
const path = stripSlash(fs.path);
|
||
if (c === "none") {
|
||
if (fs.staged && fs.staged !== "conflicted") {
|
||
plan.exclude.push(path);
|
||
// A staged rename is a deletion + addition: leaving the deletion in would commit it.
|
||
const from = renamedFromPath(fs);
|
||
if (from) plan.exclude.push(from);
|
||
}
|
||
continue;
|
||
}
|
||
if (c === "all" && fs.unstaged) plan.stage.push(path);
|
||
if (c === "staged" && !fs.staged) continue;
|
||
if (c === "hunks") plan.partial.push(path);
|
||
plan.included.push(fs);
|
||
}
|
||
return plan;
|
||
}
|
||
|
||
/**
|
||
* Inclusion overrides kept after a commit (same shape for `checks` and `excludedHunks`):
|
||
* the committed changes start over, since they either left the tree or only some of
|
||
* their blocks went in. The changes the user left out keep their override, so they stay
|
||
* excluded instead of falling back to "included" in the next commit.
|
||
*/
|
||
export function remainingOverrides<T>(
|
||
overrides: ReadonlyMap<string, T>,
|
||
committed: readonly FileStatus[],
|
||
): Map<string, T> {
|
||
const done = new Set(committed.map((f) => stripSlash(f.path)));
|
||
return new Map([...overrides].filter(([path]) => !done.has(stripSlash(path))));
|
||
}
|
||
|
||
/**
|
||
* Toggling the checkbox of one changed block (`key`, one of `all`) in the diff: the file's
|
||
* new inclusion and excluded blocks. All blocks back in → `all`; none left → `none`.
|
||
*/
|
||
export function toggledHunk(
|
||
current: CheckState,
|
||
excluded: ReadonlySet<string> | undefined,
|
||
all: readonly string[],
|
||
key: string,
|
||
): { check: CheckState; excluded: Set<string> } {
|
||
const out = new Set(
|
||
current === "none" ? all : current === "hunks" ? [...(excluded ?? [])].filter((k) => all.includes(k)) : [],
|
||
);
|
||
if (out.has(key)) out.delete(key);
|
||
else out.add(key);
|
||
if (!out.size) return { check: "all", excluded: out };
|
||
if (all.every((k) => out.has(k))) return { check: "none", excluded: new Set() };
|
||
return { check: "hunks", excluded: out };
|
||
}
|
||
|
||
/** Whether a changed block is included, given its file's inclusion. */
|
||
export function hunkIncluded(check: CheckState, excluded: ReadonlySet<string> | undefined, key: string): boolean {
|
||
if (check === "none") return false;
|
||
if (check === "hunks") return !excluded?.has(key);
|
||
return true;
|
||
}
|
||
|
||
/** Toggling a checkbox: anything included → none; none → all. */
|
||
export function toggledCheck(current: CheckState): CheckState {
|
||
return current === "none" ? "all" : "none";
|
||
}
|
||
|
||
/**
|
||
* Paths of the selection to add to an ignore file: unversioned files, and a directory
|
||
* whose files are all unversioned (one rule for it instead of a rule per file).
|
||
*/
|
||
export function ignoreTargets(nodes: readonly ChangeNode[], repos: readonly string[]): string[] {
|
||
const roots = new Set(repos.map(stripSlash));
|
||
const out = new Set<string>();
|
||
const add = (n: ChangeNode) => {
|
||
const untracked = n.files.filter((f) => f.unstaged === "untracked");
|
||
if (!untracked.length) return;
|
||
// A repository node's path is the root itself, which cannot be ignored.
|
||
if (n.type === "dir" && n.path && n.files.length === untracked.length && !roots.has(stripSlash(n.path))) {
|
||
out.add(stripSlash(n.path));
|
||
return;
|
||
}
|
||
if (n.children?.length) n.children.forEach(add);
|
||
else for (const f of untracked) out.add(stripSlash(f.path));
|
||
};
|
||
nodes.forEach(add);
|
||
return [...out];
|
||
}
|
||
|
||
/** File paths (absolute) under the given tree nodes. */
|
||
export function filesOf(nodes: readonly ChangeNode[]): FileStatus[] {
|
||
const seen = new Set<string>();
|
||
const out: FileStatus[] = [];
|
||
for (const n of nodes)
|
||
for (const f of n.files) {
|
||
if (seen.has(f.path)) continue;
|
||
seen.add(f.path);
|
||
out.push(f);
|
||
}
|
||
return out;
|
||
}
|