mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
A successful commit cleared every inclusion override in the Commit tool window, so files the user had unchecked fell back to included and could go into the next commit by accident. commitChanges now drops the overrides (checks and per-file block exclusions) only for the changes that went into the commit; files left out stay excluded. Closes #1539 Co-authored-by: xtr-hub <242808782+xtr-hub@users.noreply.github.com>
464 lines
16 KiB
TypeScript
464 lines
16 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;
|
||
}
|
||
|
||
/** 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 {
|
||
stage: string[];
|
||
unstage: 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 to stage / unstage so the index holds exactly the included changes. */
|
||
export function planCommit(status: readonly FileStatus[], checks: ReadonlyMap<string, CheckState>): CommitPlan {
|
||
const plan: CommitPlan = { stage: [], unstage: [], 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.unstage.push(path);
|
||
// A staged rename is a deletion + addition: leaving the deletion staged would commit it.
|
||
const from = renamedFromPath(fs);
|
||
if (from) plan.unstage.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;
|
||
}
|