feat(vcs): Add a modal commit mode and fill in Git log, branch popup and settings (#144)

- Settings › Commit › "Use non-modal commit interface": when off, Local
  Changes, Shelf and Stash become tabs of the Git tool window and ⌘K opens
  a Commit dialog; switches live.
- Git tool window: Worktrees tab; Show History and Show Git Log for a
  branch open their own closable tabs instead of changing the Log filters.
- Log: toolbar and context menu for a commit's changed files, vertical
  toolbar, Tags node and hiding for the branches pane, Collapse / Expand
  Linear Branches as separate actions, and a Version Control › Log page.
- Branch popup: collapsible groups, branches grouped by prefix, an options
  menu behind the gear, incoming / outgoing icons and "Pull into … Using
  Rebase / Merge"; New Branch on ⌥⌘N outside the editor.
- Git main menu regrouped with Uncommitted Changes and Current File
  submenus; rebase / merge controls enabled only while one is in progress.
- Settings: Version Control lists its pages; new options for clearing the
  initial commit message, showing Update options, confirming dropped
  commits, revealing created patches, large-file and file-name warnings,
  the Push dialog after Commit and Push, combined stashes and shelves, and
  removing applied files from the shelf.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-28 21:49:43 +08:00
1 джерело 66c36a7361
коміт eb67595e72
54 files changed
+3425 -871

No files matched your search

+74 -2
Переглянути файл
@@ -2,7 +2,7 @@
* Commit checks (Settings › Version Control › Commit / Git): before-commit
* modifications (Reformat code, Optimize imports, Cleanup) applied through
* the language servers, then the checks (Analyze code, Check TODO) and the Git
* warnings (detached HEAD / rebase, CRLF line separators).
* warnings (detached HEAD / rebase, file names, large files, CRLF line separators).
*/
import type { ReactNode } from "react";
import type { Diagnostic } from "@/bindings";
@@ -11,7 +11,7 @@ import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { maybeEditorService, toolWindowService } from "@/workbench/contributions";
import { baseName, relTo } from "./model";
import { baseName, effectiveKind, relTo, stripSlash } from "./model";
import { logConsole } from "./ops";
import { currentRepo, useVcs } from "./store";
import styles from "./common.module.css";
@@ -180,6 +180,50 @@ export interface CommitProblem {
details?: { label: string; run: () => void };
}
// ---------------------------------------------------------------------------
// Git: names invalid on Windows, large files
const WINDOWS_RESERVED = new Set(
["CON", "PRN", "AUX", "NUL"].concat([1, 2, 3, 4, 5, 6, 7, 8, 9].flatMap((n) => [`COM${n}`, `LPT${n}`])),
);
// eslint-disable-next-line no-control-regex
const WINDOWS_INVALID = /[<>:"\\|?*\u0000-\u001f]/;
/** The first path segment below `root` that Windows rejects (IDEA's GitFileNameCheckinHandler). */
export function badFileName(
paths: readonly string[],
root: string,
): { name: string; kind: "reserved" | "char" } | null {
for (const p of paths) {
for (const name of relTo(root, p).split("/")) {
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name;
if (WINDOWS_RESERVED.has(stem.toUpperCase())) return { name, kind: "reserved" };
if (WINDOWS_INVALID.test(name)) return { name, kind: "char" };
}
}
return null;
}
/** Files bigger than `limit` bytes; sizes come from listing their folders. */
async function largeFiles(paths: readonly string[], limit: number): Promise<Array<{ path: string; size: number }>> {
const byDir = new Map<string, Set<string>>();
for (const p of paths) {
const dir = p.slice(0, p.lastIndexOf("/")) || "/";
(byDir.get(dir) ?? byDir.set(dir, new Set()).get(dir)!).add(p);
}
const out: Array<{ path: string; size: number }> = [];
for (const [dir, wanted] of byDir) {
try {
for (const e of await call(commands.fsReadDir(dir)))
if (wanted.has(e.path) && e.kind === "file" && e.size > limit) out.push({ path: e.path, size: e.size });
} catch {
// unreadable folder
}
}
return out;
}
// ---------------------------------------------------------------------------
export interface CommitCandidate {
@@ -209,8 +253,36 @@ export async function runCommitChecks(files: readonly CommitCandidate[], amend:
}
const existing = files.filter((f) => f.exists).map((f) => f.path);
if (git.warnBadFileNames) {
// New paths only: a name already in the repository is not this commit's problem.
const kinds = new Map(useVcs.getState().status.map((f) => [stripSlash(f.path), effectiveKind(f)]));
const added = existing.filter((p) => ["added", "untracked", "renamed"].includes(kinds.get(p) ?? ""));
const bad = badFileName(added, useVcs.getState().root ?? "");
if (bad)
problems.push({
title: "File names",
text:
bad.kind === "reserved"
? tx`Some files use reserved names that are not allowed on Windows: '${bad.name}'. If you commit, it will not be possible to check out the Git repository on Windows.`
: tx`Some of the file names contain characters that are not allowed on Windows: '${bad.name}'. If you commit, it might not be possible to check out the Git repository on Windows.`,
});
}
if (!isTauri() || !existing.length) return problems;
if (git.warnLargeFiles) {
const large = await largeFiles(existing, git.largeFileMb * 1024 * 1024);
if (large.length) {
const mb = Math.ceil(large.reduce((n, f) => n + f.size, 0) / (1024 * 1024));
problems.push({
title: "Large files",
text:
large.length === 1
? tx`You are about to commit a large file into the repository: ${baseName(large[0]!.path)} (${mb} MB). It is not recommended to store large files in a Git repository. Consider using Git LFS.`
: tx`You are about to commit ${large.length} large files into the repository with the total size of ${mb} MB. It is not recommended to store large files in a Git repository. Consider using Git LFS.`,
});
}
}
const mods: Modifications = {
reformat: commit.reformat,
optimizeImports: commit.optimizeImports,