Compare commits

...
Author SHA1 Message Date
l0ng-ai 60c6f49701 feat(vcs): Add a modal commit mode and fill in Git log, branch popup and settings
- 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.
2026-10-28 21:36:32 +08:00
54 changed files with 3425 additions and 871 deletions

No files matched your search

+48 -2
View File
@@ -17,6 +17,7 @@ pub struct VcsSettings {
pub git: GitSettings,
pub commit: CommitSettings,
pub confirmation: VcsConfirmationSettings,
pub shelf: ShelfSettings,
/// Issue Navigation: commit-message patterns turned into links.
pub issue_navigation: Vec<IssueLink>,
}
@@ -31,6 +32,7 @@ impl Default for VcsSettings {
git: GitSettings::default(),
commit: CommitSettings::default(),
confirmation: VcsConfirmationSettings::default(),
shelf: ShelfSettings::default(),
issue_navigation: Vec::new(),
}
}
@@ -53,6 +55,17 @@ pub struct GitSettings {
pub protected_branches: String,
/// Initial state of Commit Options › Sign-off Commit.
pub sign_off: bool,
/// Ask before committing files larger than `large_file_mb`.
pub warn_large_files: bool,
pub large_file_mb: u32,
/// Ask before committing names that are invalid on Windows or differ only in case.
pub warn_bad_file_names: bool,
/// Commit and Push opens the Push dialog; otherwise it pushes right away.
pub show_push_dialog: bool,
/// With `show_push_dialog`: only for commits to protected branches.
pub push_dialog_only_protected: bool,
/// Stashes and shelves share one tab.
pub combine_stash_and_shelf: bool,
}
impl Default for GitSettings {
@@ -66,6 +79,12 @@ impl Default for GitSettings {
incoming_check: "auto".into(),
protected_branches: "master; main".into(),
sign_off: false,
warn_large_files: true,
large_file_mb: 50,
warn_bad_file_names: true,
show_push_dialog: true,
push_dialog_only_protected: false,
combine_stash_and_shelf: false,
}
}
}
@@ -74,6 +93,11 @@ impl Default for GitSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CommitSettings {
/// Commit from the Commit tool window; off = Local Changes in the Git tool
/// window and a Commit dialog.
pub non_modal: bool,
/// Start with an empty message instead of the last one / the changelist's.
pub clear_initial_message: bool,
pub analyze_code: bool,
pub check_todo: bool,
pub reformat: bool,
@@ -91,6 +115,8 @@ pub struct CommitSettings {
impl Default for CommitSettings {
fn default() -> Self {
Self {
non_modal: true,
clear_initial_message: false,
analyze_code: true,
check_todo: true,
reformat: false,
@@ -107,20 +133,40 @@ impl Default for CommitSettings {
}
}
/// Version Control › Confirmation. Values: `ask` | `silently` | `never`.
/// Version Control › Confirmation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct VcsConfirmationSettings {
/// `ask` | `silently` | `never`.
pub on_create: String,
pub on_delete: String,
/// Update Project opens its options dialog first.
pub show_update_options: bool,
pub confirm_drop_commits: bool,
/// After a patch is saved: `ask` | `no` | `reveal` (show it in the file manager).
pub on_patch_created: String,
}
impl Default for VcsConfirmationSettings {
fn default() -> Self {
Self { on_create: "ask".into(), on_delete: "ask".into() }
Self {
on_create: "ask".into(),
on_delete: "ask".into(),
show_update_options: true,
confirm_drop_commits: true,
on_patch_created: "ask".into(),
}
}
}
/// Version Control › Shelf.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ShelfSettings {
/// Initial state of Unshelve's "Remove successfully applied files from the shelf".
pub remove_applied: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct IssueLink {
+100 -16
View File
@@ -568,8 +568,14 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("vcs.pull", "Pull…"),
act("vcs.fetch", "Fetch"),
SEP,
// Operation items (Abort Merge, Continue Rebase…) are enabled only while it is in progress.
act("vcs.merge", "Merge…"),
act("vcs.merge.abort", "Abort Merge"),
act("vcs.rebase", "Rebase…"),
act("vcs.rebase.abort", "Abort Rebase"),
act("vcs.rebase.continue", "Continue Rebase"),
act("vcs.rebase.skip", "Skip Commit"),
act("vcs.resolveConflicts", "Resolve Conflicts…"),
SEP,
act("vcs.branches", "Branches…"),
act("vcs.newBranch", "New Branch…"),
@@ -579,17 +585,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("vcs.newWorktree", "New Worktree…"),
act("vcs.worktrees", "Worktrees…"),
SEP,
act("vcs.showDiff", "Show Diff"),
act("vcs.rollback", "Rollback…"),
act("vcs.annotate", "Annotate with Git Blame"),
act("vcs.showLog", "Show Git Log"),
act("vcs.localHistory", "Local History"),
act("localHistory.putLabel", "Put Label…"),
SEP,
act("vcs.stash", "Stash Changes…"),
act("vcs.unstash", "Unstash Changes…"),
act("vcs.shelve", "Shelve Changes…"),
act("vcs.showShelf", "Show Shelf"),
sub(
"gitPatch",
"Patch",
@@ -599,12 +595,32 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("vcs.applyPatchFromClipboard", "Apply Patch from Clipboard…"),
],
),
SEP,
act("vcs.resolveConflicts", "Resolve Conflicts…"),
act("vcs.rebase.continue", "Continue Rebase"),
act("vcs.rebase.abort", "Abort Rebase"),
act("vcs.rebase.skip", "Skip Commit"),
act("vcs.merge.abort", "Abort Merge"),
sub(
"gitUncommittedChanges",
"Uncommitted Changes",
vec![
act("vcs.shelve", "Shelve Changes…"),
act("vcs.showShelf", "Show Shelf"),
act("vcs.stash", "Stash Changes…"),
act("vcs.unstash", "Unstash Changes…"),
act("vcs.rollback", "Rollback…"),
],
),
sub(
"gitCurrentFile",
"Current File",
vec![
act("vcs.addToVcs", "Add to VCS"),
SEP,
act("vcs.annotate", "Annotate with Git Blame"),
act("vcs.showDiff", "Show Diff"),
act("vcs.compareWithBranch", "Compare with Branch or Revision…"),
act("vcs.showHistory", "Show History"),
SEP,
act("vcs.localHistory", "Local History"),
act("localHistory.putLabel", "Put Label…"),
],
),
SEP,
sub(
"gitHub",
@@ -620,6 +636,11 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("vcs.clone", "Clone…"),
SEP,
act("vcs.operations", "VCS Operations Popup…"),
SEP,
act("vcs.revert.continue", "Continue Revert"),
act("vcs.revert.abort", "Abort Revert"),
act("vcs.cherryPick.continue", "Continue Cherry-Pick"),
act("vcs.cherryPick.abort", "Abort Cherry-Pick"),
],
));
@@ -1436,6 +1457,69 @@ mod tests {
assert!(item_enabled("submenu:refactor", &disabled));
}
#[test]
fn git_menu_follows_idea_order() {
let spec = spec(Os::Mac);
let Some(Entry::Submenu { children, .. }) = spec.iter().find(|e| matches!(e, Entry::Submenu { id: "git", .. }))
else {
panic!("Git menu missing");
};
let top: Vec<&str> = children
.iter()
.map(|e| match e {
Entry::Action { id, .. } | Entry::Submenu { id, .. } => *id,
Entry::Separator => "|",
_ => "?",
})
.collect();
assert_eq!(
top,
[
"vcs.commit",
"vcs.push",
"vcs.update",
"vcs.pull",
"vcs.fetch",
"|",
"vcs.merge",
"vcs.merge.abort",
"vcs.rebase",
"vcs.rebase.abort",
"vcs.rebase.continue",
"vcs.rebase.skip",
"vcs.resolveConflicts",
"|",
"vcs.branches",
"vcs.newBranch",
"vcs.newTag",
"vcs.resetHead",
"|",
"vcs.newWorktree",
"vcs.worktrees",
"|",
"vcs.showLog",
"gitPatch",
"gitUncommittedChanges",
"gitCurrentFile",
"|",
"gitHub",
"vcs.manageRemotes",
"vcs.clone",
"|",
"vcs.operations",
"|",
"vcs.revert.continue",
"vcs.revert.abort",
"vcs.cherryPick.continue",
"vcs.cherryPick.abort",
]
);
let ids = action_ids(children);
for moved in ["vcs.showDiff", "vcs.rollback", "vcs.annotate", "vcs.localHistory", "localHistory.putLabel"] {
assert!(ids.contains(&moved), "{moved} missing from the Git menu");
}
}
#[test]
fn idea_menu_items_are_present() {
for os in ALL_OS {
+32 -1
View File
@@ -1641,6 +1641,13 @@ export type CommitOptions = {
/** Version Control › Commit: commit checks and commit-message inspections. */
export type CommitSettings = {
/**
* Commit from the Commit tool window; off = Local Changes in the Git tool
* window and a Commit dialog.
*/
nonModal?: boolean,
/** Start with an empty message instead of the last one / the changelist's. */
clearInitialMessage?: boolean,
analyzeCode?: boolean,
checkTodo?: boolean,
reformat?: boolean,
@@ -2914,6 +2921,17 @@ export type GitSettings = {
protectedBranches?: string,
/** Initial state of Commit Options › Sign-off Commit. */
signOff?: boolean,
/** Ask before committing files larger than `large_file_mb`. */
warnLargeFiles?: boolean,
largeFileMb?: number,
/** Ask before committing names that are invalid on Windows or differ only in case. */
warnBadFileNames?: boolean,
/** Commit and Push opens the Push dialog; otherwise it pushes right away. */
showPushDialog?: boolean,
/** With `show_push_dialog`: only for commits to protected branches. */
pushDialogOnlyProtected?: boolean,
/** Stashes and shelves share one tab. */
combineStashAndShelf?: boolean,
};
export type GoSettings = {
@@ -5037,6 +5055,12 @@ export type Settings_Serialize = {
export type Severity = "error" | "warning" | "info" | "hint";
/** Version Control › Shelf. */
export type ShelfSettings = {
/** Initial state of Unshelve's "Remove successfully applied files from the shelf". */
removeApplied?: boolean,
};
/** A change list on the shelf. */
export type ShelvedChangeList = {
/** Folder name under the shelf directory. */
@@ -5609,10 +5633,16 @@ export type Variable = {
evaluateName: string | null,
};
/** Version Control › Confirmation. Values: `ask` | `silently` | `never`. */
/** Version Control › Confirmation. */
export type VcsConfirmationSettings = {
/** `ask` | `silently` | `never`. */
onCreate?: string,
onDelete?: string,
/** Update Project opens its options dialog first. */
showUpdateOptions?: boolean,
confirmDropCommits?: boolean,
/** After a patch is saved: `ask` | `no` | `reveal` (show it in the file manager). */
onPatchCreated?: string,
};
/** A repository of the project (IDEA's VCS root). */
@@ -5637,6 +5667,7 @@ export type VcsSettings = {
git?: GitSettings,
commit?: CommitSettings,
confirmation?: VcsConfirmationSettings,
shelf?: ShelfSettings,
/** Issue Navigation: commit-message patterns turned into links. */
issueNavigation?: IssueLink[],
};
+2 -1
View File
@@ -9,7 +9,7 @@ import { activate as welcome } from "./welcome";
import { activate as projectTree } from "./project-tree";
import { activate as search } from "./search";
import { activate as codeInsight } from "./code-insight";
import { activate as vcs } from "./vcs";
import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs";
import { activate as terminal } from "./terminal";
import { activate as run } from "./run";
import { activate as runAnything } from "./run-anything";
@@ -111,6 +111,7 @@ export function activateSettingsWindowFeatures(parent: string): () => void {
["plugins", pluginsPages],
["tools", toolsPages],
["vim", vimPages],
["vcs", vcsPages],
];
const disposers: Array<() => void> = [];
for (const [name, activate] of pages) {
@@ -64,6 +64,7 @@ const GROUP_DESCRIPTIONS: Record<string, string> = {
"Build, Execution, Deployment": "Configure build tools, run configurations, debugger and deployment.",
"Languages & Frameworks": "Configure language servers, interpreters, SDKs and framework support.",
Tools: "Configure external tools, terminal, database and other integrations.",
"Version Control": "Configure the settings related to version control used in your project",
};
function GroupPage({ node }: { node: PageNode }) {
+1 -1
View File
@@ -49,7 +49,7 @@ export function optionLabels(id: string): string[] {
}
function toContribution(spec: PageSpec): SettingsPageContribution {
const component = spec.component ?? (() => <OptionsPage spec={spec} />);
const component = spec.component ?? (spec.sections ? () => <OptionsPage spec={spec} /> : undefined);
return { id: spec.id, title: spec.title, group: spec.group, order: spec.order, keywords: spec.keywords, component };
}
+123 -30
View File
@@ -1,7 +1,8 @@
/**
* Version Control pages (IDEA layout): Version Control, Commit, Confirmation,
* Directory Mappings, Git, Issue Navigation. Behaviour lives in the vcs
* feature, which reads `vcs.*` through `platform/workflowSettings`.
* Directory Mappings, Issue Navigation, Shelf, Git (the vcs feature adds Log).
* Behaviour lives in the vcs feature, which reads `vcs.*` through
* `platform/workflowSettings`.
*/
import { useEffect, useId, useState } from "react";
import { call, commands, isTauri } from "@/platform/ipc";
@@ -138,6 +139,40 @@ function ProtectedBranchesRow() {
);
}
function LargeFilesRow() {
return (
<ToggleNumber
toggleKey="vcs.git.warnLargeFiles"
numberKey="vcs.git.largeFileMb"
label="Warn when committing files larger than"
unit="MB"
{...L.largeFileMb}
/>
);
}
function PushDialogRows() {
const [show] = useSetting<boolean>("vcs.git.showPushDialog");
return (
<>
<BoundToggle k="vcs.git.showPushDialog" label="Show Push dialog for Commit and Push" />
<Indented>
<BoundToggle
k="vcs.git.pushDialogOnlyProtected"
label="Show Push dialog only when committing to protected branches"
disabled={!show}
/>
</Indented>
</>
);
}
const PATCH_CREATED: SelectOption[] = [
{ value: "ask", label: "Ask" },
{ value: "no", label: "No" },
{ value: "reveal", label: "Show in Finder" },
];
const INCOMING: SelectOption[] = [
{ value: "auto", label: "Auto" },
{ value: "always", label: "Always" },
@@ -266,42 +301,24 @@ function IssuePresets() {
}
export const VCS_PAGES: PageSpec[] = [
{
id: "vcs",
title: "Version Control",
order: 300,
projectScope: true,
keywords: ["git", "vcs", "history"],
sections: [
{
options: [
{
kind: "toggle",
key: "vcs.showDirtyDirectories",
label: "Show directories with changed descendants",
hint: "Folders containing modified files are coloured in the Project tool window.",
},
{ kind: "custom", label: "Limit history to", keywords: ["rows", "file history"], component: HistoryLimitRow },
],
},
],
},
// No sections: the page links to its sub-pages.
{ id: "vcs", title: "Version Control", order: 300, keywords: ["git", "vcs", "history"] },
{
id: "vcs.commit",
title: "Commit",
order: 1,
projectScope: true,
keywords: ["commit checks", "commit message"],
keywords: ["commit checks", "commit message", "modal commit", "commit dialog"],
sections: [
{
title: "Commit Checks",
options: [
{
kind: "custom",
label: "Analyze code",
keywords: ["Check TODO", "Reformat code", "Optimize imports", "Cleanup", "before commit"],
component: CommitChecks,
kind: "toggle",
key: "vcs.commit.nonModal",
label: "Use non-modal commit interface",
hint: "When off, Local Changes and Stash are tabs of the Git tool window and Commit opens a dialog.",
},
{ kind: "toggle", key: "vcs.commit.clearInitialMessage", label: "Clear initial commit message" },
],
},
{
@@ -316,6 +333,17 @@ export const VCS_PAGES: PageSpec[] = [
},
],
},
{
title: "Commit Checks",
options: [
{
kind: "custom",
label: "Analyze code",
keywords: ["Check TODO", "Reformat code", "Optimize imports", "Cleanup", "before commit"],
component: CommitChecks,
},
],
},
],
},
{
@@ -326,7 +354,7 @@ export const VCS_PAGES: PageSpec[] = [
keywords: ["add to git", "remove from git", "ask"],
sections: [
{
title: "Files",
title: "Confirmation",
options: [
{
kind: "custom",
@@ -360,6 +388,30 @@ export const VCS_PAGES: PageSpec[] = [
/>
),
},
{ kind: "toggle", key: "vcs.confirmation.showUpdateOptions", label: "Show options before Update" },
{
kind: "toggle",
key: "vcs.confirmation.confirmDropCommits",
label: "Ask for confirmation to drop commits",
hint: "Applicable to Git",
},
],
},
{
title: "Changes",
options: [
{
kind: "toggle",
key: "vcs.showDirtyDirectories",
label: "Highlight directories that contain modified files in the Project tree",
},
{
kind: "select",
key: "vcs.confirmation.onPatchCreated",
label: "When patch is created",
options: PATCH_CREATED,
},
{ kind: "custom", label: "Limit history to", keywords: ["rows", "file history"], component: HistoryLimitRow },
],
},
],
@@ -376,7 +428,7 @@ export const VCS_PAGES: PageSpec[] = [
{
id: "vcs.git",
title: "Git",
order: 4,
order: 8,
projectScope: true,
keywords: ["git", "executable", "push", "pull", "update"],
sections: [
@@ -405,6 +457,17 @@ export const VCS_PAGES: PageSpec[] = [
label: "Sign-off commit",
hint: "Adds a Signed-off-by trailer by default; it can still be switched per commit in Commit Options.",
},
{
kind: "custom",
label: "Warn when committing files larger than",
keywords: ["MB"],
component: LargeFilesRow,
},
{
kind: "toggle",
key: "vcs.git.warnBadFileNames",
label: "Warn when committing files with names that might cause issues on other systems",
},
],
},
{
@@ -415,6 +478,12 @@ export const VCS_PAGES: PageSpec[] = [
key: "vcs.git.autoUpdateOnPushRejected",
label: "Auto-update if push of the current branch was rejected",
},
{
kind: "custom",
label: "Show Push dialog for Commit and Push",
keywords: ["Show Push dialog only when committing to protected branches"],
component: PushDialogRows,
},
{ kind: "custom", label: "Protected branches", keywords: ["force push"], component: ProtectedBranchesRow },
],
},
@@ -451,6 +520,30 @@ export const VCS_PAGES: PageSpec[] = [
},
],
},
{
title: "Stash",
options: [
{ kind: "toggle", key: "vcs.git.combineStashAndShelf", label: "Combine stashes and shelves in one tab" },
],
},
],
},
{
id: "vcs.shelf",
title: "Shelf",
order: 7,
projectScope: true,
keywords: ["shelve", "unshelve"],
sections: [
{
options: [
{
kind: "toggle",
key: "vcs.shelf.removeApplied",
label: "Remove successfully applied files from the shelf",
},
],
},
],
},
{
+12
View File
@@ -114,10 +114,22 @@
}
.syncSmall {
flex: none;
display: inline-flex;
align-items: center;
gap: 1px;
color: var(--fg-muted);
font-family: var(--font-mono);
font-size: var(--fs-tiny);
}
.syncSmall + .syncSmall {
margin-left: -4px;
}
.incoming {
color: var(--accent);
}
.outgoing {
color: var(--success);
}
.upstream {
flex: 0 4 auto;
margin-left: auto;
+192 -80
View File
@@ -5,7 +5,7 @@ import { create } from "zustand";
import type { Branch, BranchInfo } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { bindingsFor, normalizeBinding, registerKeyClaim, shortcutLabel, strokeOf } from "@/platform/keymap";
import { useEditorRegistry } from "@/editor/api";
import {
BarButton,
@@ -14,6 +14,7 @@ import {
IconButton,
List,
listNavigate,
Menu,
MenuList,
Popup,
SearchField,
@@ -21,16 +22,14 @@ import {
type MenuItem,
T,
} from "@/ui";
import { buildBranchRows, type BranchRow, type PopupAction } from "./branches";
import { buildBranchRows, withExpanded, type BranchRow, type PopupAction } from "./branches";
import { branchMenu, commonBranchMenu, inRepo, tagMenu } from "./branchMenu";
import { loadFavoriteBranches, recentBranches, toggleFavoriteBranch, useFavoriteBranches } from "./branchOps";
import { inProgress, operationTitle, type InProgress } from "./operations";
import {
buildMultiRepoRows,
currentRepoFor,
DEFAULT_EXPANDED,
repoHeadLabel,
toggleExpanded,
widgetShowsRepo,
type RepoEntry,
type RepoRow,
@@ -111,8 +110,12 @@ export function BranchWidget() {
<span className={styles.operation}>{branch.operation}</span>
)}
{/* Like IDEA's widget: icons only; counts are in the tooltip. */}
{branch.behind > 0 && <Icon name="vcs-incoming" size={12} className={styles.sync} label="Incoming commits" />}
{branch.ahead > 0 && <Icon name="vcs-outgoing" size={12} className={styles.sync} label="Outgoing commits" />}
{branch.behind > 0 && (
<Icon name="vcs-incoming" size={12} className={clsx(styles.sync, styles.incoming)} label="Incoming commits" />
)}
{branch.ahead > 0 && (
<Icon name="vcs-outgoing" size={12} className={clsx(styles.sync, styles.outgoing)} label="Outgoing commits" />
)}
<Icon name="chevron-down" size={14} className={styles.chevron} />
</BarButton>
{open && <BranchesPopup anchor={ref} onClose={() => useBranchesPopup.setState({ open: false })} />}
@@ -121,19 +124,106 @@ export function BranchWidget() {
}
const POPUP_ACTIONS: PopupAction[] = [
{ id: "vcs.update", label: "Update Project…", icon: "git-pull" },
{ id: "vcs.commit", label: "Commit…", icon: "commit" },
{ id: "vcs.push", label: "Push…", icon: "git-push" },
{ id: "vcs.newBranch", label: "New Branch…", icon: "plus" },
{ id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch" },
{ id: "vcs.update", label: "Update Project…", icon: "git-pull", group: "sync" },
{ id: "vcs.commit", label: "Commit…", icon: "commit", group: "sync" },
{ id: "vcs.push", label: "Push…", icon: "git-push", group: "sync" },
{ id: "vcs.newBranch", label: "New Branch…", icon: "plus", group: "branch" },
{ id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch", group: "branch" },
];
/** A single repository's own entries (its submenu in the popup with several repositories). */
const REPO_ACTIONS: PopupAction[] = [
{ id: "vcs.newBranch", label: "New Branch…", icon: "plus" },
{ id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch" },
{ id: "vcs.newBranch", label: "New Branch…", icon: "plus", group: "branch" },
{ id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch", group: "branch" },
];
const OPTIONS_KEY = "openidea.vcs.branchesPopup.options";
interface PopupOptions {
groupByPrefix: boolean;
showRecent: boolean;
showTags: boolean;
}
function loadOptions(): PopupOptions {
const out: PopupOptions = { groupByPrefix: true, showRecent: true, showTags: true };
try {
const v: unknown = JSON.parse(localStorage.getItem(OPTIONS_KEY) ?? "null");
if (v && typeof v === "object")
for (const k of Object.keys(out) as (keyof PopupOptions)[]) {
const x = (v as Record<string, unknown>)[k];
if (typeof x === "boolean") out[k] = x;
}
} catch {
// Storage unavailable: defaults.
}
return out;
}
/** The popup's gear options (Group by Prefix, Show Recent Branches, Show Tags), kept across sessions. */
const usePopupOptions = create<PopupOptions>(loadOptions);
function toggleOption(key: keyof PopupOptions) {
usePopupOptions.setState((s) => ({ [key]: !s[key] }));
try {
localStorage.setItem(OPTIONS_KEY, JSON.stringify(usePopupOptions.getState()));
} catch {
// Kept for this session only.
}
}
/** Groups and folders the user expanded or collapsed, remembered for the session (per popup kind). */
const useExpansion = create<{ single: ReadonlyMap<string, boolean>; multi: ReadonlyMap<string, boolean> }>(() => ({
single: new Map(),
multi: new Map(),
}));
function useExpanded(kind: "single" | "multi") {
const expanded = useExpansion((s) => s[kind]);
const setOpen = (key: string, open: boolean) =>
useExpansion.setState((s) => (s[kind].get(key) === open ? s : { [kind]: withExpanded(s[kind], key, open) }));
return [expanded, setOpen] as const;
}
/** Whether a keystroke is New Branch's shortcut (⌥⌘N, shared with Inline: the popup claims it). */
function isNewBranchStroke(stroke: string | null): boolean {
if (!stroke) return false;
const s = normalizeBinding(stroke);
return bindingsFor("vcs.newBranch").some((b) => normalizeBinding(b) === s);
}
function OptionsButton({ hasTags }: { hasTags: boolean }) {
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const options = usePopupOptions();
const items: MenuItem[] = [
{ label: "Group by Prefix", checked: options.groupByPrefix, run: () => toggleOption("groupByPrefix") },
{ label: "Show Recent Branches", checked: options.showRecent, run: () => toggleOption("showRecent") },
...(hasTags ? [{ label: "Show Tags", checked: options.showTags, run: () => toggleOption("showTags") }] : []),
];
return (
<>
<IconButton
icon="settings"
title="Options"
size="small"
aria-haspopup="menu"
aria-expanded={!!anchor}
onClick={(e) => setAnchor(anchor ? null : e.currentTarget)}
/>
{anchor && (
<Menu
open
anchor={anchor}
placement="bottom-end"
items={items}
onClose={() => setAnchor(null)}
aria-label="Branches Popup Options"
/>
)}
</>
);
}
/** Entries IDEA puts at the top of the Branches popup while an operation is stopped. */
export function operationActions(op: InProgress | null, conflicts: number): PopupAction[] {
if (!op) return [];
@@ -191,6 +281,8 @@ const caretAt = (e: KeyboardEvent, where: "start" | "end") => {
function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | null>; onClose: () => void }) {
const multi = useVcs((s) => s.roots.length > 1);
// ⌥⌘N is also Inline (enabled while a popup is open over an editor): the search field handles it here.
useEffect(() => registerKeyClaim((_e, stroke) => isNewBranchStroke(stroke)), []);
return (
<Popup
open
@@ -252,9 +344,11 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
const op = useVcs((s) => inProgress(root ? s.operations[root] : s.branch?.operation));
const conflicts = useVcs((s) => s.status.filter((f) => f.unstaged === "conflicted").length);
const actions = useMemo(() => [...operationActions(op, conflicts), ...baseActions], [op, conflicts, baseActions]);
const options = usePopupOptions();
const [expanded, setOpen] = useExpanded("single");
const rows = useMemo(
() => buildBranchRows(branches, recent, query, actions, favorites, tags),
[branches, recent, query, actions, favorites, tags],
() => buildBranchRows(branches, recent, query, actions, favorites, tags, { ...options, expanded }),
[branches, recent, query, actions, favorites, tags, options, expanded],
);
const isSection = (r: BranchRow) => r.type === "section";
const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection);
@@ -276,32 +370,47 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
);
};
const run = (id: string, arg?: unknown) => {
onClose();
void (root ? focusRepo(root) : Promise.resolve()).then(() => executeAction(id, arg));
};
const activate = (i: number) => {
const row = rows[i];
if (!row) return;
if (row.type === "action") {
onClose();
const { id, arg } = row.action;
void (root ? focusRepo(root) : Promise.resolve()).then(() => executeAction(id, arg));
run(row.action.id, row.action.arg);
} else if (row.type === "group") {
if (!query.trim()) setOpen(row.key, !row.expanded);
} else if (row.type === "branch" || row.type === "tag") {
openMenu(i);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isNewBranchStroke(strokeOf(e))) {
e.preventDefault();
run("vcs.newBranch");
return;
}
if (e.key === "Enter") {
e.preventDefault();
activate(activeIndex);
return;
}
const type = rows[activeIndex]?.type;
if (e.key === "ArrowRight" && (type === "branch" || type === "tag")) {
const row = rows[activeIndex];
if (e.key === "ArrowRight" && (row?.type === "branch" || row?.type === "tag" || row?.type === "group")) {
if (caretAt(e, "end")) {
e.preventDefault();
openMenu(activeIndex);
if (row.type !== "group") openMenu(activeIndex);
else if (!query.trim()) setOpen(row.key, true);
}
return;
}
if (e.key === "ArrowLeft" && row?.type === "group" && row.expanded && !query.trim() && caretAt(e, "start")) {
e.preventDefault();
setOpen(row.key, false);
return;
}
if (e.key === "ArrowLeft" && onBack && caretAt(e, "start")) {
e.preventDefault();
onBack();
@@ -332,13 +441,21 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
onKeyDown={onKeyDown}
/>
{!root && <IconButton icon="git-fetch" title="Fetch" size="small" action="vcs.fetch" />}
{!root && <OptionsButton hasTags={tags.length > 0} />}
</div>
<div ref={listRef} style={{ height: listHeight(rows) }} className={styles.listWrap}>
<List
items={rows}
getKey={(r) => r.key}
isSection={isSection}
renderSection={(r) => <span className={styles.section}>{r.type === "section" ? tx(r.label) : ""}</span>}
sectionHeightOf={(r) => (r.type === "section" && !r.label ? SEPARATOR_HEIGHT : undefined)}
renderSection={(r) =>
r.type === "section" && r.label ? (
<span className={styles.section}>{tx(r.label)}</span>
) : (
<span className={styles.separator} role="separator" />
)
}
activeIndex={activeIndex}
onActiveChange={setActive}
onActivate={(_r, i) => activate(i)}
@@ -348,7 +465,7 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
aria-label="Branches"
className={styles.list}
empty={<span className={styles.empty}>{loaded ? tx("No matching branches") : tx("Loading…")}</span>}
renderItem={(r) => <BranchRowView row={r} />}
renderItem={(r) => (r.type === "group" ? <GroupRowView row={r} /> : <BranchRowView row={r} />)}
/>
</div>
{menuFor && <SubMenu {...menuFor} onClose={() => setMenuFor(null)} />}
@@ -383,26 +500,6 @@ function SubMenu({
);
}
const EXPANDED_KEY = "openidea.vcs.branchesPopup.expanded";
function loadExpanded(): Set<string> {
try {
const v: unknown = JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? "null");
if (Array.isArray(v)) return new Set(v.filter((x): x is string => typeof x === "string"));
} catch {
// Storage unavailable: defaults.
}
return new Set(DEFAULT_EXPANDED);
}
function saveExpanded(expanded: ReadonlySet<string>) {
try {
localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded]));
} catch {
// Expansion is remembered for the session only.
}
}
/**
* Branches popup of a project with several repositories: actions; the repositories,
* each opening its own branches (on hover or →); then the branches they all have
@@ -419,7 +516,8 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) {
const [tags, setTags] = useState<string[]>([]);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(loadExpanded);
const [expanded, setOpen] = useExpanded("multi");
const options = usePopupOptions();
const [all, setAll] = useState<Record<string, Branch[]>>({});
const [menuFor, setMenuFor] = useState<{ items: MenuItem[]; label: string; el: HTMLElement } | null>(null);
const [repoMenu, setRepoMenu] = useState<{ root: string; el: HTMLElement; focus: boolean } | null>(null);
@@ -479,8 +577,20 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) {
[op, conflicts, roots],
);
const rows = useMemo(
() => buildMultiRepoRows({ repos, branches: all, query, actions, expanded, favorites, current, recent, tags }),
[repos, all, query, actions, expanded, favorites, current, recent, tags],
() =>
buildMultiRepoRows({
...options,
repos,
branches: all,
query,
actions,
expanded,
favorites,
current,
recent,
tags,
}),
[repos, all, query, actions, expanded, favorites, current, recent, tags, options],
);
const isSection = (r: RepoRow) => r.type === "section";
const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection);
@@ -515,14 +625,6 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) {
setMenuFor({ items: inRepo(row.repo.root, tagMenu(row.tag, closeAll)), label: tx`Tag ${row.tag}`, el });
}
};
const setOpen = (key: string, open: boolean) =>
setExpanded((prev) => {
if (prev.has(key) === open) return prev;
const next = toggleExpanded(prev, key);
saveExpanded(next);
return next;
});
const activate = (i: number) => {
const row = rows[i];
if (!row) return;
@@ -543,6 +645,12 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) {
};
const onKeyDown = (e: KeyboardEvent) => {
if (isNewBranchStroke(strokeOf(e))) {
e.preventDefault();
onClose();
void executeAction("vcs.newBranch", { repos: roots });
return;
}
if (e.key === "Enter") {
e.preventDefault();
activate(activeIndex);
@@ -588,15 +696,7 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) {
onKeyDown={onKeyDown}
/>
<IconButton icon="git-fetch" title="Fetch" size="small" action="vcs.fetch" />
<IconButton
icon="settings"
title="Git Settings"
size="small"
onClick={() => {
closeAll();
void executeAction("file.settings", "vcs.git");
}}
/>
<OptionsButton hasTags={tags.length > 0} />
</div>
<div ref={listRef} style={{ height: listHeight(rows) }} className={styles.listWrap}>
<List
@@ -676,18 +776,7 @@ function RepoRowView({ row, operation }: { row: RepoRow; operation?: string }) {
</span>
);
case "group":
return (
<span
className={clsx(styles.row, row.depth === 0 && styles.groupRow)}
data-row-key={row.key}
style={row.depth ? { paddingLeft: row.depth * INDENT } : undefined}
aria-expanded={row.expanded}
>
<Icon name={row.expanded ? "chevron-down" : "chevron-right"} size={12} className={styles.rowIcon} />
{row.depth > 0 && <Icon name="folder" size={14} className={styles.rowIcon} />}
<span className={styles.label}>{tx(row.label)}</span>
</span>
);
return <GroupRowView row={row} />;
case "common":
return (
<span className={styles.row} data-row-key={row.key} style={{ paddingLeft: row.depth * INDENT }}>
@@ -715,6 +804,22 @@ function RepoRowView({ row, operation }: { row: RepoRow; operation?: string }) {
}
}
/** A collapsible group (Recent / Local / Remote / Tags) or a prefix folder. Folder names are not translated. */
function GroupRowView({ row }: { row: { key: string; label: string; expanded: boolean; depth: number } }) {
return (
<span
className={clsx(styles.row, row.depth === 0 && styles.groupRow)}
data-row-key={row.key}
style={row.depth ? { paddingLeft: row.depth * INDENT } : undefined}
aria-expanded={row.expanded}
>
<Icon name={row.expanded ? "chevron-down" : "chevron-right"} size={12} className={styles.rowIcon} />
{row.depth > 0 && <Icon name="folder" size={14} className={styles.rowIcon} />}
<span className={styles.label}>{row.depth > 0 ? row.label : tx(row.label)}</span>
</span>
);
}
function BranchRowView({ row }: { row: BranchRow | Extract<RepoRow, { type: "action" | "branch" | "tag" }> }) {
const indent = "depth" in row && row.depth ? { paddingLeft: row.depth * INDENT } : undefined;
if (row.type === "action") {
@@ -767,7 +872,7 @@ function BranchRowView({ row }: { row: BranchRow | Extract<RepoRow, { type: "act
)}
<HighlightedText
className={clsx(styles.label, styles.branchName, b.current && styles.current)}
text={b.name}
text={row.label}
indices={row.matches}
tone="accent"
/>
@@ -776,10 +881,17 @@ function BranchRowView({ row }: { row: BranchRow | Extract<RepoRow, { type: "act
<T>current</T>
</span>
)}
{(b.ahead > 0 || b.behind > 0) && (
<span className={styles.syncSmall}>
{b.ahead > 0 && `↑${b.ahead}`}
{b.behind > 0 && ` ↓${b.behind}`}
{/* Like IDEA: incoming (↙) before outgoing (↗). */}
{b.behind > 0 && (
<span className={clsx(styles.syncSmall, styles.incoming)} title={tx`${b.behind} incoming commits`}>
<Icon name="vcs-incoming" size={12} />
{b.behind}
</span>
)}
{b.ahead > 0 && (
<span className={clsx(styles.syncSmall, styles.outgoing)} title={tx`${b.ahead} outgoing commits`}>
<Icon name="vcs-outgoing" size={12} />
{b.ahead}
</span>
)}
{!b.remote && b.upstream && <span className={styles.upstream}>{b.upstream}</span>}
+20
View File
@@ -0,0 +1,20 @@
.body {
height: min(620px, 70vh);
display: flex;
flex-direction: column;
min-height: 0;
margin: -4px 0 0;
border: 1px solid var(--border);
border-radius: var(--radius-small);
overflow: hidden;
}
.split {
flex: 1;
min-height: 0;
}
.options {
height: 100%;
overflow: auto;
padding: 4px 16px 12px;
border-left: 1px solid var(--border);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Commit dialog of the modal commit interface (Settings › Version Control › Commit ›
* "Use non-modal commit interface" off): the changes with inclusion checkboxes and
* the Commit Options beside them, the message below. Commits through the same
* pipeline as the Commit tool window.
*/
import { tx } from "@/platform/i18n";
import { Dialog, SplitGroup, SplitHandle, SplitPanel } from "@/ui";
import { CommitOptionsBody } from "./CommitOptions";
import { useCommit } from "./commitStore";
import {
ChangesTree,
CommitMessageField,
commitAndPushLabel,
commitLabel,
useCommitRunner,
useIncludedCount,
} from "./CommitView";
import { openDialog } from "./dialogs";
import { useVcs } from "./store";
import viewStyles from "./CommitView.module.css";
import styles from "./CommitDialog.module.css";
let open = false;
/** Opens the Commit dialog (or focuses the message of the one open). */
export function showCommitDialog() {
useCommit.setState((s) => ({ tab: "changes", focusTick: s.focusTick + 1 }));
if (open) return;
open = true;
openDialog((close) => (
<CommitDialog
onClose={() => {
open = false;
close();
}}
/>
));
}
function CommitDialog({ onClose }: { onClose: () => void }) {
const included = useIncludedCount();
const amend = useCommit((s) => s.amend);
const problems = useCommit((s) => s.checkProblems.length > 0);
const remoteBusy = useVcs((s) => s.remoteBusy);
const runner = useCommitRunner(included, onClose);
return (
<Dialog
open
title="Commit Changes"
onClose={onClose}
width={880}
buttons={[
{
label: commitLabel(amend, problems),
role: "default",
disabled: !runner.canCommit,
title: "⌘↩",
onClick: () => void runner.run(false),
},
{
label: commitAndPushLabel(amend),
role: "extra",
disabled: !runner.canCommit || !!remoteBusy,
title: "⌥⌘↩",
onClick: () => void runner.run(true),
},
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.body}>
<SplitGroup orientation="vertical" className={styles.split}>
<SplitPanel defaultSize="58%" minSize="25%">
<SplitGroup orientation="horizontal" className={styles.split}>
<SplitPanel defaultSize="64%" minSize="35%">
<ChangesTree mode="dialog" />
</SplitPanel>
<SplitHandle />
<SplitPanel defaultSize="36%" minSize="200px">
<div className={styles.options} role="group" aria-label={tx("Commit Options")}>
<CommitOptionsBody />
</div>
</SplitPanel>
</SplitGroup>
</SplitPanel>
<SplitHandle />
<SplitPanel defaultSize="42%" minSize="120px">
<div className={viewStyles.commit}>
<CommitMessageField included={included} runner={runner} />
</div>
</SplitPanel>
</SplitGroup>
</div>
</Dialog>
);
}
+27 -18
View File
@@ -43,13 +43,8 @@ function Configure() {
);
}
export function CommitOptionsPopup({
anchor,
onClose,
}: {
anchor: RefObject<HTMLElement | null>;
onClose: () => void;
}) {
/** The options themselves: in the popup, and inline beside the Commit dialog's message. */
export function CommitOptionsBody() {
const author = useCommit((s) => s.author);
const signOff = useCommit((s) => s.signOff);
const runHooks = useCommit((s) => s.runHooks);
@@ -61,17 +56,7 @@ export function CommitOptionsPopup({
</div>
);
return (
<Popup
open
anchor={anchor}
placement="top-start"
onClose={onClose}
role="dialog"
aria-label="Commit Options"
className={styles.options}
>
{/* IDEA opens the options without focusing the Author field. */}
<div tabIndex={-1} data-autofocus className={styles.optFocus} />
<>
<Section title="Git">
<label className={styles.optField}>
<span>
@@ -96,6 +81,30 @@ export function CommitOptionsPopup({
{check("analyzeCode", "Analyze code")}
{check("checkTodo", "Check TODO", <Configure />)}
</Section>
</>
);
}
export function CommitOptionsPopup({
anchor,
onClose,
}: {
anchor: RefObject<HTMLElement | null>;
onClose: () => void;
}) {
return (
<Popup
open
anchor={anchor}
placement="top-start"
onClose={onClose}
role="dialog"
aria-label="Commit Options"
className={styles.options}
>
{/* IDEA opens the options without focusing the Author field. */}
<div tabIndex={-1} data-autofocus className={styles.optFocus} />
<CommitOptionsBody />
</Popup>
);
}
+356 -258
View File
@@ -1,4 +1,7 @@
/** Commit tool window: change list with inclusion checkboxes, message, stashes. */
/**
* Commit tool window: change list with inclusion checkboxes, message, stashes. Its
* parts also make the Git window's Local Changes / Stash tabs and the Commit dialog.
*/
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from "react";
import { tx } from "@/platform/i18n";
@@ -22,6 +25,7 @@ import {
showContextMenu,
startPointerDrag,
type MenuItem,
type TabItem,
type TreeHandle,
T,
} from "@/ui";
@@ -41,6 +45,8 @@ import { CommitOptionsPopup } from "./CommitOptions";
import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists";
import { moveToChangelist } from "./changelistsUi";
import { messageHistory, setAmend, setGroupByDir, useCommit, type CommitTab } from "./commitStore";
import { pushAfterCommit } from "./commitUi";
import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs";
import { openDiff, withChain, workingDiffData } from "./diffInput";
import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } from "./model";
import { call, commands } from "@/platform/ipc";
@@ -52,68 +58,85 @@ import styles from "./CommitView.module.css";
/** MIME type of changed files dragged between change lists (absolute paths, newline-separated). */
const CHANGES_MIME = "application/x-openidea-changes";
/** "Commit" / "Shelf" / "Stash" content tabs; they stand in for the tool window title. */
/** Shown text of a node: group names are translated, except the names of user change lists. */
function nodeText(n: ChangeNode): string {
return n.type === "group" && (!n.changelist || n.label === DEFAULT_LIST_NAME) ? tx(n.label) : n.label;
}
export function CommitTabs() {
const available = useVcs((s) => s.available);
const tab = useCommit((s) => s.tab);
/** Content tabs as `Tabs` items, with their counts. */
export function tabItems(tabs: readonly ContentTab[]): TabItem[] {
return tabs.map((t) => ({
id: t.id,
label: t.label,
// `Tabs` treats a missing flag as closable once it has `onClose`.
closable: !!t.closable,
trailing: t.count ? <span className={styles.count}>{t.count}</span> : undefined,
}));
}
/** Shelf and stash counts for the tabs (loads the shelves once VCS is available). */
export function useStashTabCounts(available: boolean) {
const stashCount = useVcs((s) => s.stashes.length);
const shelfCount = useShelf((s) => s.shelves.length);
useEffect(() => {
if (available) void loadShelves().catch(() => undefined);
}, [available]);
return { stashCount, shelfCount };
}
/** "Commit" / "Shelf" / "Stash" content tabs; they stand in for the tool window title. */
export function CommitTabs() {
const available = useVcs((s) => s.available);
const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf);
const tab = useCommit((s) => s.tab);
const counts = useStashTabCounts(available);
return (
<Tabs
variant="compact"
className={styles.tabs}
activeId={tab}
activeId={stashTabOf(tab, combine)}
onSelect={(id) => useCommit.setState({ tab: id as CommitTab })}
aria-label="Commit"
items={[
items={tabItems([
{ id: "changes", label: "Commit" },
...(!available
? []
: shelfCount || tab === "shelf"
? [
{
id: "shelf",
label: "Shelf",
trailing: shelfCount ? <span className={styles.count}>{shelfCount}</span> : undefined,
},
]
: []),
...(available
? [
{
id: "stashes",
label: "Stash",
trailing: stashCount ? <span className={styles.count}>{stashCount}</span> : undefined,
},
]
: []),
]}
...(available ? stashTabs({ combine, tab, ...counts }) : []),
])}
/>
);
}
export function CommitToolWindow() {
const available = useVcs((s) => s.available);
const tab = useCommit((s) => s.tab);
const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf);
const tab = stashTabOf(
useCommit((s) => s.tab),
combine,
);
if (!available)
return (
<EmptyState icon="commit" title="Version control is not configured">
<T>Open a Git repository to commit changes.</T>
</EmptyState>
);
return (
<div className={styles.root}>
{tab === "changes" ? <ChangesPanel /> : tab === "shelf" ? <ShelfPanel /> : <StashesPanel />}
</div>
);
return <div className={styles.root}>{tab === "changes" ? <ChangesPanel /> : <StashContent tab={tab} />}</div>;
}
/** The Shelf, Stash or combined "Stashes and Shelves" tab. */
export function StashContent({ tab }: { tab: CommitTab }) {
const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf);
if (combine)
return (
<SplitGroup orientation="vertical" className={styles.split}>
<SplitPanel defaultSize="50%" minSize="80px">
<StashesPanel />
</SplitPanel>
<SplitHandle />
<SplitPanel defaultSize="50%" minSize="80px">
<ShelfPanel />
</SplitPanel>
</SplitGroup>
);
return tab === "shelf" ? <ShelfPanel /> : <StashesPanel />;
}
// ---------------------------------------------------------------------------
@@ -244,13 +267,50 @@ function CheckMark({
);
}
/** Inclusion state of every change (user overrides over the change list defaults). */
function useCommitChecks() {
const status = useVcs((s) => s.status);
const root = useVcs((s) => s.root) ?? "";
const userChecks = useCommit((s) => s.checks);
const lists = useChangelists((s) => s.state);
return useMemo(() => withListDefaults(status, root, lists, userChecks), [status, root, lists, userChecks]);
}
/** Number of changes the commit would include. */
export function useIncludedCount(): number {
const status = useVcs((s) => s.status);
const checks = useCommitChecks();
return useMemo(() => planCommit(status, checks).included.length, [status, checks]);
}
/** The Commit tool window's Commit tab: changes with inclusion checkboxes above the message. */
function ChangesPanel() {
const included = useIncludedCount();
return (
<SplitGroup orientation="vertical" className={styles.split}>
<SplitPanel defaultSize="62%" minSize="20%">
<ChangesTree mode="commit" />
</SplitPanel>
<SplitHandle />
<SplitPanel defaultSize="38%" minSize="120px">
<CommitPanel included={included} />
</SplitPanel>
</SplitGroup>
);
}
/**
* The change tree with its toolbar. `commit`: the Commit tool window's (inclusion
* checkboxes); `local`: the Git tool window's Local Changes tab of the modal commit
* interface (no checkboxes, a Commit… button); `dialog`: the Commit dialog's.
*/
export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
const status = useVcs((s) => s.status);
const root = useVcs((s) => s.root) ?? "";
const groupByDir = useCommit((s) => s.groupByDir);
const userChecks = useCommit((s) => s.checks);
const lists = useChangelists((s) => s.state);
const checks = useMemo(() => withListDefaults(status, root, lists, userChecks), [status, root, lists, userChecks]);
const checks = useCommitChecks();
const checkboxes = mode !== "local";
const [dropTarget, setDropTarget] = useState<string | null>(null);
// IDEA starts with Unversioned Files collapsed.
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["group:unversioned"]));
@@ -314,196 +374,186 @@ function ChangesPanel() {
const selectedNodes = () => [...selected].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n);
const plan = useMemo(() => planCommit(status, checks), [status, checks]);
return (
<SplitGroup orientation="vertical" className={styles.split}>
<SplitPanel defaultSize="62%" minSize="20%">
<div className={styles.changes}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Changes")}>
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshNow()} />
<IconButton icon="undo" action="vcs.rollback" size="small" />
<IconButton icon="git-pull" action="vcs.update" size="small" />
<IconButton
ref={viewRef}
icon="eye"
title="View Options"
size="small"
aria-haspopup="menu"
onClick={() =>
viewRef.current &&
showContextMenu(bottomLeft(viewRef.current), [
{ kind: "header", label: "Group By" },
{ label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) },
])
}
/>
<span className={styles.sep} />
<IconButton icon="locate" title="Select Opened File" size="small" onClick={selectOpened} />
<IconButton icon="expand-all" title="Expand All" size="small" onClick={() => setCollapsed(new Set())} />
<IconButton
icon="collapse-all"
title="Collapse All"
size="small"
onClick={() =>
setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key)))
}
/>
</div>
<OperationBanner />
<div
className={styles.treeWrap}
onKeyDownCapture={(e) => {
if (e.key === " " && !e.metaKey && !e.ctrlKey && selected.size) {
e.preventDefault();
e.stopPropagation();
toggle(selectedNodes());
} else if ((e.key === "Delete" || e.key === "Backspace") && !e.metaKey) {
const group = selectedNodes().find((x) => x.type === "group" && x.changelist);
if (group?.changelist) {
e.preventDefault();
e.stopPropagation();
void executeAction("vcs.changelist.delete", group.changelist);
}
} else if (e.key === "F4") {
const n = selectedNodes().find((x) => x.type === "file");
if (n?.path) {
e.preventDefault();
void editorService().openFile(n.path, { focus: true });
}
}
}}
>
<Tree<ChangeNode>
handleRef={treeRef}
roots={roots}
getKey={(n) => n.key}
getLabel={nodeText}
getChildren={(n) => n.children}
isExpandable={(n) => n.type !== "file"}
expanded={expanded}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(node.key);
else s.add(node.key);
return s;
})
}
selected={selected}
onSelectionChange={(sel) => {
setSelected(sel);
const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n);
useCommit.setState({
selection: filesOf(nodes).map((f) => f.path),
selectedList: nodes.find((n) => n.changelist)?.changelist ?? null,
});
}}
onActivate={(n) => openNode(n, roots)}
onContextMenu={(node, sel, e: MouseEvent) => {
if (!node) return;
if (node.type === "group" && node.changelist) {
const paths = node.files.map((f) => stripSlash(f.path));
showContextMenu(e, listMenu(node.changelist, !!node.active, paths));
return;
}
const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n);
showContextMenu(e, nodeMenu(nodes.length ? nodes : [node]));
}}
aria-label="Changes"
empty={
<span className={styles.empty}>
<T>No local changes</T>
</span>
}
renderNode={(n, st) => {
const agg = aggregateCheck(n.files, checks);
const label = (
<span
className={clsx(
styles.label,
n.type === "group" && styles.groupLabel,
n.type === "group" && n.changelist && !n.active && styles.inactiveList,
)}
style={n.type === "file" && n.kind ? { color: KIND_COLOR[n.kind] } : undefined}
title={n.type === "file" && n.kind ? tx(KIND_LABEL[n.kind]) : undefined}
>
{nodeText(n)}
<div className={styles.changes}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Changes")}>
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshNow()} />
{mode === "local" && <IconButton icon="commit" action="vcs.commit" size="small" />}
<IconButton icon="undo" action="vcs.rollback" size="small" />
{mode !== "dialog" && <IconButton icon="git-pull" action="vcs.update" size="small" />}
<IconButton
ref={viewRef}
icon="eye"
title="View Options"
size="small"
aria-haspopup="menu"
onClick={() =>
viewRef.current &&
showContextMenu(bottomLeft(viewRef.current), [
{ kind: "header", label: "Group By" },
{ label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) },
])
}
/>
<span className={styles.sep} />
{mode !== "dialog" && (
<IconButton icon="locate" title="Select Opened File" size="small" onClick={selectOpened} />
)}
<IconButton icon="expand-all" title="Expand All" size="small" onClick={() => setCollapsed(new Set())} />
<IconButton
icon="collapse-all"
title="Collapse All"
size="small"
onClick={() => setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key)))}
/>
</div>
{mode !== "dialog" && <OperationBanner />}
<div
className={styles.treeWrap}
onKeyDownCapture={(e) => {
if (checkboxes && e.key === " " && !e.metaKey && !e.ctrlKey && selected.size) {
e.preventDefault();
e.stopPropagation();
toggle(selectedNodes());
} else if ((e.key === "Delete" || e.key === "Backspace") && !e.metaKey) {
const group = selectedNodes().find((x) => x.type === "group" && x.changelist);
if (group?.changelist) {
e.preventDefault();
e.stopPropagation();
void executeAction("vcs.changelist.delete", group.changelist);
}
} else if (e.key === "F4") {
const n = selectedNodes().find((x) => x.type === "file");
if (n?.path) {
e.preventDefault();
void editorService().openFile(n.path, { focus: true });
}
}
}}
>
<Tree<ChangeNode>
handleRef={treeRef}
roots={roots}
getKey={(n) => n.key}
getLabel={nodeText}
getChildren={(n) => n.children}
isExpandable={(n) => n.type !== "file"}
expanded={expanded}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(node.key);
else s.add(node.key);
return s;
})
}
selected={selected}
onSelectionChange={(sel) => {
setSelected(sel);
const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n);
useCommit.setState({
selection: filesOf(nodes).map((f) => f.path),
selectedList: nodes.find((n) => n.changelist)?.changelist ?? null,
});
}}
onActivate={(n) => openNode(n, roots)}
onContextMenu={(node, sel, e: MouseEvent) => {
if (!node) return;
if (node.type === "group" && node.changelist) {
const paths = node.files.map((f) => stripSlash(f.path));
showContextMenu(e, listMenu(node.changelist, !!node.active, paths));
return;
}
const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n);
showContextMenu(e, nodeMenu(nodes.length ? nodes : [node]));
}}
aria-label="Changes"
empty={
<span className={styles.empty}>
<T>No local changes</T>
</span>
}
renderNode={(n, st) => {
const agg = aggregateCheck(n.files, checks);
const label = (
<span
className={clsx(
styles.label,
n.type === "group" && styles.groupLabel,
n.type === "group" && n.changelist && !n.active && styles.inactiveList,
)}
style={n.type === "file" && n.kind ? { color: KIND_COLOR[n.kind] } : undefined}
title={n.type === "file" && n.kind ? tx(KIND_LABEL[n.kind]) : undefined}
>
{nodeText(n)}
</span>
);
// Pointer-driven (HTML5 drag and drop never reaches the Tauri webview):
// files and folders drag; any row takes them into its changelist.
const list = n.changelist;
const dnd =
list === undefined
? {}
: {
ref: (el: HTMLSpanElement | null) =>
el
? registerDropTarget(el, {
accepts: (type) => type === CHANGES_MIME,
over: () => setDropTarget(list),
leave: () => setDropTarget(null),
drop: (data) => {
setDropTarget(null);
moveToChangelist(data.payload.split("\n"), list);
},
})
: undefined,
...(n.type !== "group" && {
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
const paths =
selected.has(n.key) && selected.size > 1
? filesOf(selectedNodes()).map((f) => f.path)
: n.files.map((f) => f.path);
startPointerDrag(e, e.currentTarget, {
type: CHANGES_MIME,
payload: paths.map(stripSlash).join("\n"),
});
},
}),
};
return (
<span
className={clsx(styles.nodeRow, n.type === "group" && dropTarget === n.changelist && styles.dropTarget)}
{...dnd}
>
{checkboxes && (
<CheckMark
checked={agg.checked}
indeterminate={agg.indeterminate}
label={tx`Include ${n.label} in commit`}
onToggle={() => toggle(selected.has(n.key) && selected.size > 1 ? selectedNodes() : [n])}
/>
)}
{n.type === "group" ? null : (
<Icon
name={n.type === "dir" ? (st.expanded ? "folder-open" : "folder") : fileIconName(n.label)}
size={16}
className={styles.icon}
/>
)}
{label}
{n.type === "group" && <span className={styles.detail}>{fileCount(n.files.length)}</span>}
{n.type === "file" && n.files[0] && isPartiallyStaged(n.files[0]) && (
<span className={styles.partial} title={tx("Partially staged: only staged hunks are committed")}>
<T>partial</T>
</span>
);
// Pointer-driven (HTML5 drag and drop never reaches the Tauri webview):
// files and folders drag; any row takes them into its changelist.
const list = n.changelist;
const dnd =
list === undefined
? {}
: {
ref: (el: HTMLSpanElement | null) =>
el
? registerDropTarget(el, {
accepts: (type) => type === CHANGES_MIME,
over: () => setDropTarget(list),
leave: () => setDropTarget(null),
drop: (data) => {
setDropTarget(null);
moveToChangelist(data.payload.split("\n"), list);
},
})
: undefined,
...(n.type !== "group" && {
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
const paths =
selected.has(n.key) && selected.size > 1
? filesOf(selectedNodes()).map((f) => f.path)
: n.files.map((f) => f.path);
startPointerDrag(e, e.currentTarget, {
type: CHANGES_MIME,
payload: paths.map(stripSlash).join("\n"),
});
},
}),
};
return (
<span
className={clsx(
styles.nodeRow,
n.type === "group" && dropTarget === n.changelist && styles.dropTarget,
)}
{...dnd}
>
<CheckMark
checked={agg.checked}
indeterminate={agg.indeterminate}
label={tx`Include ${n.label} in commit`}
onToggle={() => toggle(selected.has(n.key) && selected.size > 1 ? selectedNodes() : [n])}
/>
{n.type === "group" ? null : (
<Icon
name={n.type === "dir" ? (st.expanded ? "folder-open" : "folder") : fileIconName(n.label)}
size={16}
className={styles.icon}
/>
)}
{label}
{n.type === "group" && <span className={styles.detail}>{fileCount(n.files.length)}</span>}
{n.type === "file" && n.files[0] && isPartiallyStaged(n.files[0]) && (
<span className={styles.partial} title={tx("Partially staged: only staged hunks are committed")}>
<T>partial</T>
</span>
)}
{n.detail && <span className={styles.detail}>{n.detail}</span>}
</span>
);
}}
/>
</div>
</div>
</SplitPanel>
<SplitHandle />
<SplitPanel defaultSize="38%" minSize="120px">
<CommitMessage included={plan.included.length} />
</SplitPanel>
</SplitGroup>
)}
{n.detail && <span className={styles.detail}>{n.detail}</span>}
</span>
);
}}
/>
</div>
</div>
);
}
@@ -543,21 +593,63 @@ export function wrapAtMargin(text: string, caret: number, limit: number): { text
return { text: `${text.slice(0, at)}\n${text.slice(at + 1)}`, caret };
}
function CommitMessage({ included }: { included: number }) {
export interface CommitRunner {
busy: boolean;
/** Commit was asked for without a message: reported at the field. */
emptyError: boolean;
clearEmptyError: () => void;
/** Whether the Commit buttons are enabled. */
canCommit: boolean;
run: (push: boolean) => Promise<void>;
}
/**
* Commit / Commit and Push… of the Commit tool window and the Commit dialog: the
* shared pipeline (`commitChanges`), then the push. `onCommitted` runs after a commit.
*/
export function useCommitRunner(included: number, onCommitted?: () => void): CommitRunner {
const amend = useCommit((s) => s.amend);
const merging = useVcs((s) => s.branch?.operation === "merge");
const [emptyError, setEmptyError] = useState(false);
const [busy, setBusy] = useState(false);
const run = async (push: boolean) => {
if (busy) return;
// IDEA keeps Commit enabled and reports an empty message at the field.
if (!useCommit.getState().message.trim() && !useCommit.getState().amend) {
setEmptyError(true);
useCommit.setState((s) => ({ focusTick: s.focusTick + 1 }));
return;
}
setBusy(true);
try {
const id = await commitChanges();
if (id === undefined) return;
onCommitted?.();
if (push) void pushAfterCommit();
} finally {
setBusy(false);
}
};
return {
busy,
emptyError,
clearEmptyError: () => setEmptyError(false),
canCommit: (included > 0 || amend || merging) && !busy,
run,
};
}
/** Amend, message history and the message field with its inspections and failed checks. */
export function CommitMessageField({ included, runner }: { included: number; runner: CommitRunner }) {
const message = useCommit((s) => s.message);
const amend = useCommit((s) => s.amend);
const focusTick = useCommit((s) => s.focusTick);
const remoteBusy = useVcs((s) => s.remoteBusy);
const merging = useVcs((s) => s.branch?.operation === "merge");
const ref = useRef<HTMLTextAreaElement>(null);
const historyRef = useRef<HTMLButtonElement>(null);
const optionsRef = useRef<HTMLButtonElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [optionsOpen, setOptionsOpen] = useState(false);
const [emptyError, setEmptyError] = useState(false);
const [busy, setBusy] = useState(false);
const historyTick = useCommit((s) => s.historyTick);
const checkProblems = useCommit((s) => s.checkProblems);
const { emptyError } = runner;
// The failed checks concern what was about to be committed: editing it dismisses them.
useEffect(() => {
@@ -580,29 +672,10 @@ function CommitMessage({ included }: { included: number }) {
const problems = messageProblems(message, cfg).filter((p) => !p.startsWith("Subject line"));
const margin = cfg.limitBody ? cfg.bodyLimit : cfg.subjectLimit;
const showMargin = cfg.showRightMargin && (cfg.limitBody || cfg.limitSubject);
const commit = async (push: boolean) => {
if (busy) return;
// IDEA keeps Commit enabled and reports an empty message at the field.
if (!useCommit.getState().message.trim() && !useCommit.getState().amend) {
setEmptyError(true);
ref.current?.focus();
return;
}
setBusy(true);
try {
const id = await commitChanges();
if (id !== undefined && push) void executeAction("vcs.push");
} finally {
setBusy(false);
}
};
const history = messageHistory();
const canCommit = (included > 0 || amend || merging) && !busy;
return (
<div className={styles.commit}>
<>
<div className={styles.commitBar}>
<Checkbox checked={amend} onChange={(v) => void setAmend(v)} label="Amend" />
<IconButton
@@ -651,13 +724,13 @@ function CommitMessage({ included }: { included: number }) {
const wrapped =
cfg.limitBody && cfg.wrapAtMargin ? wrapAtMargin(el.value, el.selectionStart, cfg.bodyLimit) : null;
useCommit.setState({ message: wrapped?.text ?? el.value });
setEmptyError(false);
runner.clearEmptyError();
if (wrapped) requestAnimationFrame(() => el.setSelectionRange(wrapped.caret, wrapped.caret));
}}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit(e.altKey);
void runner.run(e.altKey);
} else if (e.key === "Escape") {
e.currentTarget.blur();
}
@@ -694,18 +767,43 @@ function CommitMessage({ included }: { included: number }) {
</div>
</div>
)}
</>
);
}
/** Label of the Commit button. */
export function commitLabel(amend: boolean, problems: boolean): string {
return problems ? "Commit Anyway" : amend ? "Amend Commit" : "Commit";
}
/** Label of the Commit and Push button. */
export const commitAndPushLabel = (amend: boolean) => (amend ? "Amend and Push…" : "Commit and Push…");
/** The message and the Commit buttons of the Commit tool window. */
function CommitPanel({ included }: { included: number }) {
const amend = useCommit((s) => s.amend);
const remoteBusy = useVcs((s) => s.remoteBusy);
const checkProblems = useCommit((s) => s.checkProblems);
const optionsRef = useRef<HTMLButtonElement>(null);
const [optionsOpen, setOptionsOpen] = useState(false);
const runner = useCommitRunner(included);
const { canCommit } = runner;
return (
<div className={styles.commit}>
<CommitMessageField included={included} runner={runner} />
<div className={styles.commitActions}>
<Button variant="primary" disabled={!canCommit} onClick={() => void commit(false)} title="⌘↩">
<T>{checkProblems.length ? "Commit Anyway" : amend ? "Amend Commit" : "Commit"}</T>
<Button variant="primary" disabled={!canCommit} onClick={() => void runner.run(false)} title="⌘↩">
<T>{commitLabel(amend, checkProblems.length > 0)}</T>
</Button>
<Button
className={styles.pushButton}
disabled={!canCommit || !!remoteBusy}
onClick={() => void commit(true)}
onClick={() => void runner.run(true)}
title="⌥⌘↩"
>
<span>
<T>{amend ? "Amend and Push…" : "Commit and Push…"}</T>
<T>{commitAndPushLabel(amend)}</T>
</span>
</Button>
<span className={styles.grow} />
@@ -726,7 +824,7 @@ function CommitMessage({ included }: { included: number }) {
// ---------------------------------------------------------------------------
function StashesPanel() {
export function StashesPanel() {
const stashes = useVcs((s) => s.stashes);
const loaded = useVcs((s) => s.stashesLoaded);
const [active, setActive] = useState(0);
+29 -1
View File
@@ -28,9 +28,23 @@
.branches {
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
}
.branchesTools {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 2px;
border-right: 1px solid var(--border);
}
.branchesMain {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.branchesBar,
.filters,
.detailsBar {
@@ -472,3 +486,17 @@
.branchFilterMore:hover {
background: var(--bg-tree-hover);
}
/* Worktrees tab. */
.worktrees {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px;
}
.worktreeList {
flex: 1;
min-height: 0;
overflow: auto;
}
+296 -113
View File
@@ -14,7 +14,7 @@ import {
} from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
import type { Branch, ChangedFile, Commit, RevisionChange } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import {
EmptyState,
@@ -34,9 +34,9 @@ import {
copyText,
} from "@/ui";
import { hasPrimaryModifier } from "@/platform/keymap";
import { commitFileDiffData, openDiff, revisionChangeDiffData, withChain } from "./diffInput";
import { collapseLinear, computeGraph, hideLongEdges, laneColor, reachableFrom, type GraphRow } from "./graph";
import {
closeLogTab,
extendCommitSelection,
goToChild,
goToParent,
@@ -47,6 +47,7 @@ import {
refsSignature,
reloadLog,
selectCommit,
selectGitTab,
selectedCommits,
setLogFilters,
toggleCommitSelection,
@@ -54,45 +55,75 @@ import {
useGitLog,
} from "./logStore";
import { refLabels, useLogOptions, type LogOptions } from "./logOptions";
import { KIND_COLOR, baseName, fileCount, formatTime, relTo, type EffectiveKind } from "./model";
import { KIND_COLOR, baseName, dirName, fileCount, formatTime, relTo, type EffectiveKind } from "./model";
import { clearConsole, useVcsConsole } from "./ops";
import { currentRepo, loadBranches, useVcs } from "./store";
import { loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
import { loadFavoriteBranches, useFavoriteBranches } from "./branchOps";
import { branchMenu } from "./branchMenu";
import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches";
import {
branchFolderKeys,
branchGroupKeys,
buildBranchTree,
filterBranchTree,
isFavoriteBranch,
type BranchTreeNode,
} from "./branches";
import { chooseDateRange, chooseMany, promptText } from "./dialogs";
import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree";
import {
absolutePath,
filesUnder,
flatFileNodes,
showChangesDiff,
toLogChanges,
useLogChanges,
type LogChange,
} from "./logChanges";
import { canDeleteBranch, canUpdateBranch, useBranchesPane, type BranchTarget } from "./logPaneActions";
import styles from "./GitLog.module.css";
import { tx } from "@/platform/i18n";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { gitTabs, shownTab } from "./gitTabs";
import { ChangesTree, StashContent, tabItems, useStashTabCounts } from "./CommitView";
import { WorktreesPanel } from "./gitMenuDialogs";
const ROW = 22;
const LANE = 12;
type GitTab = "log" | "console";
/** Tool window header: Log / Console content tabs. */
export function GitTabs() {
const tab = useGitLog((s) => s.tab);
/** The Git window's tabs (they depend on the commit interface) and the one shown. */
function useGitTabs() {
const available = useVcs((s) => s.available);
const modal = useWorkflowSettings((s) => !s.vcs.commit.nonModal);
const combine = useWorkflowSettings((s) => s.vcs.git.combineStashAndShelf);
const tab = useGitLog((s) => s.tab);
const logTabs = useGitLog((s) => s.logTabs);
const counts = useStashTabCounts(available && modal);
const tabs = gitTabs({ modal, combine, tab, logTabs, ...counts });
return { tabs, shown: shownTab(tab, tabs, combine) };
}
/** Tool window header: [Local Changes / Stash…] Log, log tabs, Console and Worktrees content tabs. */
export function GitTabs() {
const available = useVcs((s) => s.available);
const { tabs, shown } = useGitTabs();
if (!available) return null;
return (
<Tabs
variant="compact"
className={styles.headerTabs}
activeId={tab}
onSelect={(id) => useGitLog.setState({ tab: id as GitTab })}
activeId={shown}
onSelect={selectGitTab}
onClose={closeLogTab}
aria-label="Git"
panelIdPrefix="openidea-git"
items={[
{ id: "log", label: "Log" },
{ id: "console", label: "Console" },
]}
items={tabItems(tabs)}
/>
);
}
export function GitToolWindow() {
const tab = useGitLog((s) => s.tab);
const { shown } = useGitTabs();
const shownLog = useGitLog((s) => s.shownLog);
const available = useVcs((s) => s.available);
if (!available)
return (
@@ -104,10 +135,21 @@ export function GitToolWindow() {
<div
className={styles.root}
role="tabpanel"
id={`openidea-git-panel-${tab}`}
aria-labelledby={`openidea-git-tab-${tab}`}
id={`openidea-git-panel-${shown}`}
aria-labelledby={`openidea-git-tab-${shown}`}
>
{tab === "log" ? <LogView /> : <ConsoleView />}
{shown === "changes" ? (
<ChangesTree mode="local" />
) : shown === "shelf" || shown === "stashes" ? (
<StashContent tab={shown} />
) : shown === "console" ? (
<ConsoleView />
) : shown === "worktrees" ? (
<WorktreesPanel className={styles.worktrees} listClassName={styles.worktreeList} />
) : (
// Each log tab has its own view (its filters and commits are swapped into the store).
<LogView key={shownLog} />
)}
</div>
);
}
@@ -121,6 +163,7 @@ function LogView() {
const selection = useGitLog((s) => s.selection);
const root = useVcs((s) => s.root);
const repo = useVcs((s) => s.repo);
const showBranches = useLogOptions((s) => s.showBranches);
const lastRefs = useRef<string | null>(null);
// On mount, when the window switches to another project and when another of its
@@ -142,11 +185,21 @@ function LogView() {
}, [version]);
return (
<SplitGroup orientation="horizontal" id="git-log" className={styles.split}>
<SplitPanel id="branches" defaultSize="18%" minSize="120px">
<BranchesPanel />
</SplitPanel>
<SplitHandle />
// Its own layout without the Branches pane, so each keeps its sizes.
<SplitGroup
key={showBranches ? "branches" : "no-branches"}
orientation="horizontal"
id={showBranches ? "git-log" : "git-log-no-branches"}
className={styles.split}
>
{showBranches && (
<>
<SplitPanel id="branches" defaultSize="18%" minSize="120px">
<BranchesPanel />
</SplitPanel>
<SplitHandle />
</>
)}
<SplitPanel id="commits" defaultSize="54%" minSize="25%">
<div className={styles.log}>
<FilterBar />
@@ -178,8 +231,20 @@ export function BranchesPanel() {
const favorites = useFavoriteBranches((s) => s.names);
const filterBranches = useGitLog((s) => s.filters.branches);
const [query, setQuery] = useState("");
const [tags, setTags] = useState<string[]>([]);
useEffect(() => loadFavoriteBranches(), [root]);
const allRoots = useMemo(() => buildBranchTree(branches, favorites), [branches, favorites]);
// Branches reload when refs move (fetch, new tag…): the tags are re-read with them.
useEffect(() => {
let live = true;
call(commands.vcsTags(currentRepo())).then(
(t) => live && setTags(t),
() => live && setTags([]),
);
return () => {
live = false;
};
}, [root, branches]);
const allRoots = useMemo(() => buildBranchTree(branches, favorites, tags), [branches, favorites, tags]);
const roots = useMemo(() => filterBranchTree(allRoots, query), [allRoots, query]);
// While searching every match is shown expanded.
const searchExpanded = useMemo(() => (query.trim() ? new Set(branchGroupKeys(roots)) : undefined), [roots, query]);
@@ -195,24 +260,34 @@ export function BranchesPanel() {
setShownKey(filterKey);
setSelected(new Set(filterKeys));
}
// Branches in tree order, for Copy: full refs, so the folder path comes along
// Refs in tree order, for Copy: full refs, so the folder path comes along
// (`feat/x`, `origin/main`).
const refs = useMemo(() => {
const out: { key: string; ref: string }[] = [];
const out: { key: string; ref: string; branch?: Branch }[] = [];
const walk = (nodes: readonly BranchTreeNode[]) => {
for (const n of nodes) {
if (n.ref) out.push({ key: n.key, ref: n.ref });
if (n.ref) out.push({ key: n.key, ref: n.ref, branch: n.branch });
walk(n.children ?? []);
}
};
walk(allRoots);
return out;
}, [allRoots]);
const targets = useMemo(
(): BranchTarget[] => refs.filter((r) => selected.has(r.key)).map(({ ref, branch }) => ({ ref, branch })),
[refs, selected],
);
useEffect(() => {
useBranchesPane.setState({ selected: targets });
}, [targets]);
useEffect(() => () => useBranchesPane.setState({ selected: [] }), []);
const target = targets[0] ?? undefined;
const favorite = !!target?.branch && isFavoriteBranch(target.branch, favorites);
const onKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.key !== "c" && e.key !== "C") || !hasPrimaryModifier(e) || e.altKey || e.shiftKey) return;
// The branch search keeps the browser's own copy.
if ((e.target as HTMLElement).closest("input, textarea")) return;
const names = refs.filter((r) => selected.has(r.key)).map((r) => r.ref);
const names = targets.map((r) => r.ref);
if (!names.length) return;
e.preventDefault();
e.stopPropagation();
@@ -220,69 +295,109 @@ export function BranchesPanel() {
};
return (
<div className={styles.branches} onKeyDownCapture={onKeyDownCapture}>
<div className={styles.branchesBar} role="toolbar" aria-label={tx("Branches")}>
<SearchField
className={styles.branchSearch}
value={query}
onChange={setQuery}
placeholder="Branch"
aria-label="Filter branches"
<div className={styles.branchesTools} role="toolbar" aria-orientation="vertical" aria-label={tx("Branches")}>
<IconButton icon="chevron-left" title="Hide Branches" size="small" action="vcs.log.showBranches" />
<IconButton icon="plus" title="New Branch…" size="small" action="vcs.log.branches.new" actionArg={target} />
<IconButton
icon="git-pull"
size="small"
title="Update Selected"
action="vcs.log.branches.update"
actionArg={target}
disabled={!canUpdateBranch(target ?? null)}
/>
<IconButton
icon="trash"
size="small"
title="Delete Branch"
action="vcs.log.branches.delete"
actionArg={target}
disabled={!canDeleteBranch(target ?? null)}
/>
<IconButton
icon="locate"
size="small"
title="Navigate Log to Selected Branch"
action="vcs.log.branches.navigate"
actionArg={target}
disabled={!target}
/>
<IconButton
icon={favorite ? "star" : "star-outline"}
title={favorite ? "Remove from Favorites" : "Add to Favorites"}
size="small"
action="vcs.log.branches.toggleFavorite"
actionArg={target}
pressed={favorite}
disabled={!target?.branch}
/>
<IconButton icon="plus" title="New Branch…" size="small" onClick={() => void newBranch()} />
<IconButton icon="git-fetch" title="Fetch" size="small" action="vcs.fetch" />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void loadBranches()} />
</div>
<Tree<BranchTreeNode>
key={`${root ?? ""}:${branchesLoaded}`}
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "head" || n.kind === "group" ? tx(n.label) : n.label)}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
defaultExpanded={[
"g:local",
"g:remote",
...roots.flatMap((r) => r.children?.filter((c) => c.kind === "remote").map((c) => c.key) ?? []),
...(current ? branchFolderKeys(roots, current) : []),
]}
expanded={searchExpanded}
getIcon={(n) =>
n.kind === "head"
? "commit"
: n.branch?.current
? "current-branch"
: n.kind === "branch"
? n.favorite
? "star"
: "branch"
: "folder"
}
getDetail={branchDetail}
selected={selected}
onSelectionChange={(sel) => setSelected(sel)}
onActivate={(n) => {
// IDEA: double click (or Enter) switches the log to that ref, or to every
// selected one when it is part of a multi-selection; checking out stays in the context menu.
if (!n.ref) return;
const sel = selected.has(n.key) ? refs.filter((r) => selected.has(r.key)).map((r) => r.ref) : [n.ref];
setLogFilters({ branches: sel });
}}
onContextMenu={(n, _s, e) => {
const items = n?.branch ? branchMenu(n.branch, current) : [];
if (!items.length) return;
e.preventDefault();
showContextMenu(e, items);
}}
empty={
query.trim() ? (
<span className={styles.muted}>
<T>No matching branches</T>
</span>
) : undefined
}
aria-label="Branches"
className={styles.branchTree}
/>
<div className={styles.branchesMain}>
<div className={styles.branchesBar}>
<SearchField
className={styles.branchSearch}
value={query}
onChange={setQuery}
placeholder="Branch or tag"
aria-label="Filter branches and tags"
/>
</div>
<Tree<BranchTreeNode>
key={`${root ?? ""}:${branchesLoaded}`}
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "head" || n.kind === "group" ? tx(n.label) : n.label)}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
defaultExpanded={[
"g:local",
"g:remote",
...roots.flatMap((r) => r.children?.filter((c) => c.kind === "remote").map((c) => c.key) ?? []),
...(current ? branchFolderKeys(roots, current) : []),
]}
expanded={searchExpanded}
getIcon={(n) =>
n.kind === "head"
? "commit"
: n.kind === "tag"
? "tag"
: n.branch?.current
? "current-branch"
: n.kind === "branch"
? n.favorite
? "star"
: "branch"
: "folder"
}
getDetail={branchDetail}
selected={selected}
onSelectionChange={(sel) => setSelected(sel)}
onActivate={(n) => {
// IDEA: double click (or Enter) switches the log to that ref, or to every
// selected one when it is part of a multi-selection; checking out stays in the context menu.
if (!n.ref) return;
const sel = selected.has(n.key) ? targets.map((r) => r.ref) : [n.ref];
setLogFilters({ branches: sel });
}}
onContextMenu={(n, _s, e) => {
const items = n?.branch ? branchMenu(n.branch, current) : [];
if (!items.length) return;
e.preventDefault();
showContextMenu(e, items);
}}
empty={
query.trim() ? (
<span className={styles.muted}>
<T>No matching branches</T>
</span>
) : undefined
}
aria-label="Branches"
className={styles.branchTree}
/>
</div>
</div>
);
}
@@ -344,6 +459,7 @@ function FilterBar() {
const commits = useGitLog((s) => s.commits);
const branches = useVcs((s) => s.branches);
const root = useVcs((s) => s.root);
const showBranches = useLogOptions((s) => s.showBranches);
const [text, setText] = useState(filters.text);
const [branchAnchor, setBranchAnchor] = useState<HTMLElement | null>(null);
@@ -432,6 +548,9 @@ function FilterBar() {
return (
<div className={styles.filters} role="toolbar" aria-label={tx("Log filters")}>
{!showBranches && (
<IconButton icon="chevron-right" title="Show Branches" size="small" action="vcs.log.showBranches" />
)}
<SearchField
className={styles.textFilter}
value={text}
@@ -531,16 +650,8 @@ export function graphOptionsMenu(o: LogOptions): MenuItem[] {
toggle("No Merges", o, "noMerges"),
{ kind: "separator" },
{ kind: "header", label: "Branch Actions" },
{
label: "Collapse Linear Branches",
disabled: o.collapseLinear,
run: () => updateLogOptions({ collapseLinear: true }),
},
{
label: "Expand Linear Branches",
disabled: !o.collapseLinear,
run: () => updateLogOptions({ collapseLinear: false }),
},
{ kind: "action", action: "vcs.log.collapseLinear" },
{ kind: "action", action: "vcs.log.expandLinear" },
];
}
@@ -960,8 +1071,27 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
};
}, [idsKey]);
const tree = useMemo(() => buildFileTree(files ?? [], (f) => relTo(root, f.path)), [files, root]);
const groupByDirectory = useLogOptions((s) => s.groupByDirectory);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
const changes = useMemo(
() => (files && commit ? toLogChanges(files, many ? null : commit) : []),
[files, commit, many],
);
const tree = useMemo(
() =>
groupByDirectory
? buildFileTree(changes, (f) => relTo(root, f.path))
: flatFileNodes(changes, (f) => relTo(root, f.path)),
[changes, root, groupByDirectory],
);
const allDirs = useMemo(() => dirKeys(tree), [tree]);
const ordered = useMemo(() => filesUnder(tree), [tree]);
const chosenChanges = useMemo(() => filesUnder(tree, selectedKeys), [tree, selectedKeys]);
useEffect(() => {
useLogChanges.setState({ commits: chosen, all: ordered, selected: chosenChanges });
}, [chosen, ordered, chosenChanges]);
useEffect(() => () => useLogChanges.setState({ commits: [], all: [], selected: [] }), []);
if (!commit)
return (
@@ -972,14 +1102,18 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
</div>
);
const open = (f: ChangedFile) => {
const all = files ?? [f];
void openDiff(
withChain(
all.map((x) => (many ? revisionChangeDiffData(x as RevisionChange) : commitFileDiffData(commit, x))),
Math.max(0, all.indexOf(f)),
),
);
const openViewOptions = (e: ReactMouseEvent<HTMLElement>) =>
menuBelow(e, [
{ kind: "header", label: "Group By" },
{ kind: "action", action: "vcs.log.changes.groupByDirectory" },
]);
const onFileMenu = (node: FileTreeNode<LogChange> | null, sel: ReadonlySet<string>, e: ReactMouseEvent) => {
if (!node) return;
const target = sel.has(node.key) ? filesUnder(tree, sel) : filesUnder([node]);
if (!target.length) return;
e.preventDefault();
showContextMenu(e, changesMenu(target));
};
return (
@@ -988,6 +1122,23 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
<SplitPanel id="files" defaultSize="60%" minSize="60px">
<div className={styles.filesPane}>
<div className={styles.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
<IconButton
icon="diff"
size="small"
title="Show Diff"
action="vcs.log.changes.showDiff"
actionArg={chosenChanges}
disabled={!chosenChanges.length}
/>
<IconButton
icon="revert"
size="small"
title="Revert Selected Changes"
action="vcs.log.changes.revert"
actionArg={chosenChanges}
disabled={!chosenChanges.length}
/>
<IconButton icon="eye" title="View Options" size="small" aria-haspopup="menu" onClick={openViewOptions} />
<span className={styles.detailsCount}>
{files ? fileCount(files.length) : ""}
{many ? " " + tx`in ${chosen.length} commits` : ""}
@@ -997,12 +1148,14 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
icon="expand-all"
title="Expand All"
size="small"
disabled={!allDirs.length}
onClick={() => setExpanded(new Set(allDirs))}
/>
<IconButton
icon="collapse-all"
title="Collapse All"
size="small"
disabled={!allDirs.length}
onClick={() => setExpanded(new Set())}
/>
</div>
@@ -1013,7 +1166,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
</div>
)}
{files && (
<Tree<FileTreeNode<ChangedFile>>
<Tree<FileTreeNode<LogChange>>
roots={tree}
getKey={(n) => n.key}
getLabel={(n) => n.name}
@@ -1021,9 +1174,11 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
isExpandable={(n) => !n.file}
expanded={expanded ?? new Set(allDirs)}
onExpandedChange={(next) => setExpanded(next)}
multiSelect={false}
renderNode={(n) => <ChangedFileRow node={n} />}
onActivate={(n) => n.file && open(n.file)}
selected={selectedKeys}
onSelectionChange={(sel) => setSelectedKeys(sel)}
renderNode={(n) => <ChangedFileRow node={n} flat={!groupByDirectory} />}
onActivate={(n) => n.file && void showChangesDiff([n.file])}
onContextMenu={onFileMenu}
aria-label="Changed files"
className={styles.fileTree}
/>
@@ -1083,7 +1238,34 @@ function CommitMessage({ commit, compact = false }: { commit: Commit; compact?:
);
}
function ChangedFileRow({ node }: { node: FileTreeNode<ChangedFile> }) {
/** IDEA's changes browser context menu in the Git Log; `changes` = the files it acts on. */
export function changesMenu(changes: LogChange[]): MenuItem[] {
const one = changes.length === 1 ? changes[0]! : null;
return [
{ kind: "action", action: "vcs.log.changes.showDiff", arg: changes },
{ kind: "action", action: "vcs.log.changes.compareWithLocal", arg: changes, disabled: !one?.after },
{ kind: "action", action: "vcs.log.changes.compareBeforeWithLocal", arg: changes, disabled: !one?.before },
{ kind: "separator" },
{
kind: "action",
action: "vcs.log.changes.jumpToSource",
arg: changes,
disabled: !changes.some((c) => c.after),
},
{ kind: "separator" },
{ kind: "action", action: "vcs.log.changes.revert", arg: changes },
{ kind: "action", action: "vcs.log.changes.getFromRevision", arg: changes },
{ kind: "separator" },
{
kind: "action",
action: "vcs.showHistory",
arg: one ? absolutePath(one.path) : undefined,
disabled: !one?.after,
},
];
}
function ChangedFileRow({ node, flat = false }: { node: FileTreeNode<ChangedFile | LogChange>; flat?: boolean }) {
if (!node.file)
return (
<span className={styles.fileRow}>
@@ -1100,6 +1282,7 @@ function ChangedFileRow({ node }: { node: FileTreeNode<ChangedFile> }) {
<span className={styles.fileName} style={{ color: KIND_COLOR[kind] }}>
{node.name}
</span>
{flat && dirName(f.path) && <span className={styles.fileDir}>{dirName(f.path)}</span>}
{f.oldPath && <span className={styles.fileDir}>{tx`renamed from ${baseName(f.oldPath)}`}</span>}
</span>
);
@@ -0,0 +1,22 @@
.page {
display: flex;
flex-direction: column;
gap: 10px;
color: var(--fg);
font-size: var(--fs-ui);
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 6px;
}
.header {
color: var(--fg-muted);
}
.columns {
display: flex;
flex-direction: column;
gap: 8px;
padding-left: 18px;
}
+39
View File
@@ -0,0 +1,39 @@
/** Settings › Version Control › Log: how the Git Log shows references, dates and columns. */
import { Checkbox, T } from "@/ui";
import { setLogOptions, useLogOptions, type LogOptions } from "./logOptions";
import styles from "./LogSettingsPage.module.css";
import { tx } from "@/platform/i18n";
type Flag = { [K in keyof LogOptions]: LogOptions[K] extends boolean ? K : never }[keyof LogOptions];
const OPTIONS: { key: Flag; label: string }[] = [
{ key: "compactRefs", label: "Show only the first reference" },
{ key: "tagNames", label: "Show tag names" },
{ key: "commitTimestamp", label: "Show commit time rather than author time" },
{ key: "refsOnLeft", label: "Show references on the left" },
];
const COLUMNS: { key: Flag; label: string }[] = [
{ key: "showAuthor", label: "Author" },
{ key: "showHash", label: "Hash" },
{ key: "showDate", label: "Date" },
];
export function LogSettingsPage() {
const o = useLogOptions();
// Display options only: the log needs no reload.
const flag = ({ key, label }: { key: Flag; label: string }) => (
<Checkbox key={key} checked={o[key]} onChange={(v) => setLogOptions({ [key]: v })} label={label} />
);
return (
<div className={styles.page}>
{OPTIONS.map(flag)}
<section className={styles.section} aria-label={tx("Visible columns")}>
<div className={styles.header}>
<T>Visible columns:</T>
</div>
<div className={styles.columns}>{COLUMNS.map(flag)}</div>
</section>
</div>
);
}
+108 -41
View File
@@ -40,69 +40,136 @@ const actions = [
];
const summary = (rows: BranchRow[]) =>
rows.map((r) =>
r.type === "section"
? `# ${r.label}`
: r.type === "action"
? `> ${r.action.label}`
: r.type === "tag"
? `tag:${r.tag}`
: `${r.group}:${r.branch.name}${r.favorite ? " ★" : ""}`,
);
rows.map((r) => {
const indent = "depth" in r ? " ".repeat(r.depth) : "";
switch (r.type) {
case "section":
return r.label ? `# ${r.label}` : "---";
case "action":
return `> ${r.action.label}`;
case "group":
return `${indent}${r.expanded ? "v" : ">"} ${r.label}`;
case "tag":
return `${indent}tag:${r.tag}`;
default:
return `${indent}${r.group}:${r.label}${r.favorite ? " ★" : ""}`;
}
});
const open = (...keys: string[]) => new Map(keys.map((k) => [k, true]));
describe("buildBranchRows", () => {
it("groups actions, recent, local (current first, favourites next) and remote", () => {
it("shows actions, then Recent open and Local / Remote collapsed", () => {
expect(summary(buildBranchRows(branches, ["bugfix", "feature/order-paging", "gone"], "", actions))).toEqual([
"> Update Project…",
"> Push…",
"# Recent",
"recent:bugfix",
"# Local",
"local:feature/order-paging",
"local:main ★",
"local:bugfix",
"local:zeta",
"# Remote",
"remote:origin/main ★",
"remote:origin/feature/order-paging",
"---",
"v Recent",
" recent:bugfix",
"> Local",
"> Remote",
]);
});
it("filters branches and actions by fuzzy query and drops the Recent group", () => {
it("opens Local by default without recent branches", () => {
expect(summary(buildBranchRows(branches, [], "", [], null, [], { groupByPrefix: false }))).toEqual([
"v Local",
" local:feature/order-paging",
" local:main ★",
" local:bugfix",
" local:zeta",
"> Remote",
]);
expect(summary(buildBranchRows(branches, ["bugfix"], "", [], null, [], { showRecent: false })).slice(0, 1)).toEqual(
["v Local"],
);
});
it("groups names by prefix: pinned first, then folders, then the rest; remotes are folders", () => {
const rows = buildBranchRows(branches, ["bugfix"], "", [], null, [], {
expanded: open("group:local", "group:remote"),
});
expect(summary(rows).slice(2)).toEqual([
"v Local",
" local:main ★",
// Holds the current branch: open.
" v feature",
" local:order-paging",
" local:bugfix",
" local:zeta",
"v Remote",
" v origin",
" remote:main ★",
" > feature",
]);
const closed = buildBranchRows(branches, ["bugfix"], "", [], null, [], {
expanded: new Map([
["group:local", true],
["folder:local:feature", false],
]),
});
expect(summary(closed)).toContain(" > feature");
expect(summary(closed)).not.toContain(" local:order-paging");
});
it("separates actions of different groups", () => {
const grouped = [
{ id: "a", label: "Update Project…", group: "sync" },
{ id: "b", label: "Push…", group: "sync" },
{ id: "c", label: "New Branch…", group: "branch" },
];
expect(summary(buildBranchRows([], [], "", grouped))).toEqual([
"> Update Project…",
"> Push…",
"---",
"> New Branch…",
]);
expect(summary(buildBranchRows([], [], "u", grouped))).toEqual(["> Update Project…", "> Push…"]);
});
it("filters branches and actions by fuzzy query, opens every group and drops the Recent group", () => {
const rows = buildBranchRows(branches, ["main"], "main", actions);
expect(summary(rows)).toEqual(["# Local", "local:main ★", "# Remote", "remote:origin/main ★"]);
const hit = rows[1]!;
expect(hit.type === "branch" && hit.matches).toEqual([0, 1, 2, 3]);
expect(summary(rows)).toEqual(["v Local", " local:main ★", "v Remote", " v origin", " remote:main ★"]);
const local = rows[1]!;
expect(local.type === "branch" && local.matches).toEqual([0, 1, 2, 3]);
// Highlights shifted onto the shown part of `origin/main`.
const remote = rows[4]!;
expect(remote.type === "branch" && [remote.label, remote.matches]).toEqual(["main", [0, 1, 2, 3]]);
expect(summary(buildBranchRows(branches, [], "push", actions))).toEqual(["> Push…"]);
});
it("omits empty groups", () => {
expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["# Local", "local:main ★"]);
expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["v Local", " local:main ★"]);
});
it("puts the user's favorites right after the current branch", () => {
const rows = buildBranchRows(branches, [], "", [], ["zeta", "origin/feature/order-paging"]);
const rows = buildBranchRows(branches, [], "", [], ["zeta", "origin/feature/order-paging"], [], {
groupByPrefix: false,
expanded: open("group:remote"),
});
expect(summary(rows)).toEqual([
"# Local",
"local:feature/order-paging",
"local:zeta ★",
"local:bugfix",
"local:main",
"# Remote",
"remote:origin/feature/order-paging ★",
"remote:origin/main",
"v Local",
" local:feature/order-paging",
" local:zeta ★",
" local:bugfix",
" local:main",
"v Remote",
" remote:origin/feature/order-paging ★",
" remote:origin/main",
]);
});
it("lists tags last, newest version first, and filters them by the query", () => {
it("lists tags last in a collapsed group, newest version first, and filters them by the query", () => {
const tags = ["v1.2", "v1.10", "release"];
expect(summary(buildBranchRows([], [], "", [], null, tags))).toEqual([
"# Tags",
"tag:v1.10",
"tag:v1.2",
"tag:release",
expect(summary(buildBranchRows([], [], "", [], null, tags))).toEqual(["> Tags"]);
expect(summary(buildBranchRows([], [], "", [], null, tags, { expanded: open("group:tags") }))).toEqual([
"v Tags",
" tag:v1.10",
" tag:v1.2",
" tag:release",
]);
expect(summary(buildBranchRows(branches, [], "v1.1", [], null, tags))).toEqual(["# Tags", "tag:v1.10"]);
expect(summary(buildBranchRows(branches, [], "v1.1", [], null, tags))).toEqual(["v Tags", " tag:v1.10"]);
expect(summary(buildBranchRows([], [], "", [], null, tags, { showTags: false }))).toEqual([]);
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { badFileName } from "../commitChecks";
describe("badFileName", () => {
const root = "/repo";
it("flags names reserved on Windows, with or without an extension", () => {
expect(badFileName(["/repo/src/con.ts"], root)).toEqual({ name: "con.ts", kind: "reserved" });
expect(badFileName(["/repo/LPT1/a.txt"], root)).toEqual({ name: "LPT1", kind: "reserved" });
});
it("flags characters Windows rejects", () => {
expect(badFileName(["/repo/docs/what?.md"], root)).toEqual({ name: "what?.md", kind: "char" });
expect(badFileName(["/repo/a:b"], root)).toEqual({ name: "a:b", kind: "char" });
});
it("accepts ordinary names, including ones that merely contain a reserved word", () => {
expect(badFileName(["/repo/src/console.ts", "/repo/com10.txt", "/repo/.auxrc"], root)).toBeNull();
});
});
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const vcsLog = vi.fn();
const fsReadText = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsLog: (repo: unknown, query: unknown) => vcsLog(repo, query),
vcsConfigGet: async () => ({ status: "ok", data: null }),
fsReadText: (path: string) => fsReadText(path),
},
};
});
import { gitTabs, shownTab, stashTabOf, stashTabs } from "../gitTabs";
import { pushDialogNeeded } from "../commitUi";
import { installPreparedMessage, keepMessageAfterCommit, messageAfterCommit, useCommit } from "../commitStore";
import { closeLogTab, emptyFilters, openLogTab, selectGitTab, setLogFilters, useGitLog } from "../logStore";
import { useVcs } from "../store";
const ids = (tabs: { id: string }[]) => tabs.map((t) => t.id);
describe("Git and Commit tool window tabs", () => {
const base = { combine: false, shelfCount: 0, stashCount: 0, tab: "log", logTabs: [] };
it("puts Local Changes, Shelf and Stash into the Git window only with the modal commit interface", () => {
expect(ids(gitTabs({ ...base, modal: false }))).toEqual(["log", "console", "worktrees"]);
expect(ids(gitTabs({ ...base, modal: true }))).toEqual(["changes", "stashes", "log", "console", "worktrees"]);
expect(ids(gitTabs({ ...base, modal: true, shelfCount: 2 }))).toEqual([
"changes",
"shelf",
"stashes",
"log",
"console",
"worktrees",
]);
});
it("combines stashes and shelves into one tab", () => {
expect(stashTabs({ ...base, combine: true, shelfCount: 2, stashCount: 1 })).toEqual([
{ id: "stashes", label: "Stashes and Shelves", count: 3 },
]);
expect(stashTabOf("shelf", true)).toBe("stashes");
expect(stashTabOf("shelf", false)).toBe("shelf");
});
it("lists log tabs as closable after the main Log, and falls back to the Log for a missing tab", () => {
const logTabs = [{ id: "history:1", kind: "history" as const, target: "/p/src/a.ts" }];
const tabs = gitTabs({ ...base, modal: false, logTabs });
expect(tabs.find((t) => t.id === "history:1")).toEqual({ id: "history:1", label: "History: a.ts", closable: true });
expect(shownTab("changes", tabs, false)).toBe("log");
expect(shownTab("history:1", tabs, false)).toBe("history:1");
});
});
describe("Commit and Push… push dialog", () => {
const git = { showPushDialog: true, pushDialogOnlyProtected: false, protectedBranches: "master;main" };
it("shows the dialog unless turned off, or only for protected branches", () => {
expect(pushDialogNeeded(git, "feature")).toBe(true);
expect(pushDialogNeeded({ ...git, showPushDialog: false }, "main")).toBe(false);
expect(pushDialogNeeded({ ...git, pushDialogOnlyProtected: true }, "feature")).toBe(false);
expect(pushDialogNeeded({ ...git, pushDialogOnlyProtected: true }, "main")).toBe(true);
// Nothing to tell the target from (detached HEAD): always the dialog.
expect(pushDialogNeeded({ ...git, showPushDialog: false }, null)).toBe(true);
});
});
describe("commit message after a commit", () => {
beforeEach(() => fsReadText.mockReset());
it("keeps the committed message unless Clear initial commit message is on", () => {
expect(messageAfterCommit(" fix: thing \n", false)).toBe("fix: thing");
expect(messageAfterCommit("fix: thing", true)).toBe("");
});
it("lets a prepared merge message replace a kept one, but not a typed one", async () => {
useVcs.setState({ root: "/p", branch: null });
const dispose = installPreparedMessage();
keepMessageAfterCommit("fix: thing", false);
fsReadText.mockResolvedValue({ status: "ok", data: "Merge branch 'x'\n# Conflicts:\n" });
useVcs.setState({ branch: { operation: "merge" } as never });
await vi.waitFor(() => expect(useCommit.getState().message).toBe("Merge branch 'x'"));
useVcs.setState({ branch: null });
useCommit.setState({ message: "typed" });
useVcs.setState({ branch: { operation: "merge" } as never });
await Promise.resolve();
expect(useCommit.getState().message).toBe("typed");
dispose();
});
});
describe("log tabs of their own", () => {
beforeEach(() => {
vcsLog.mockReset().mockResolvedValue({ status: "ok", data: [] });
useVcs.setState({ root: "/p", repo: "/p", roots: ["/p"] });
useGitLog.setState({ tab: "log", logTabs: [], shownLog: "log", filters: emptyFilters() });
});
it("opens Show History in a closable tab and leaves the main Log's filters alone", () => {
setLogFilters({ authors: ["bob"] });
const id = openLogTab("history", "/p/src/a.ts");
expect(useGitLog.getState()).toMatchObject({ tab: id, shownLog: id });
expect(useGitLog.getState().filters).toMatchObject({ path: "/p/src/a.ts", authors: [] });
// The same file again: the same tab.
selectGitTab("log");
expect(useGitLog.getState().filters.authors).toEqual(["bob"]);
expect(openLogTab("history", "/p/src/a.ts")).toBe(id);
expect(useGitLog.getState().logTabs).toHaveLength(1);
closeLogTab(id);
expect(useGitLog.getState()).toMatchObject({ tab: "log", shownLog: "log", logTabs: [] });
expect(useGitLog.getState().filters).toMatchObject({ authors: ["bob"], path: null });
});
it("keeps a log tab's view while another content tab is shown", () => {
const id = openLogTab("branch", "feature");
selectGitTab("console");
expect(useGitLog.getState()).toMatchObject({ tab: "console", shownLog: id });
selectGitTab(id);
expect(useGitLog.getState().filters.branches).toEqual(["feature"]);
closeLogTab(id);
});
});
@@ -134,7 +134,7 @@ describe("Git Log branches pane", () => {
it("leaves Cmd/Ctrl+C in the branch search field alone", async () => {
render(<BranchesPanel />);
fireEvent.mouseDown(screen.getByText("feature"));
fireEvent.keyDown(screen.getByLabelText("Filter branches"), { key: "c", ...cmd() });
fireEvent.keyDown(screen.getByLabelText("Filter branches and tags"), { key: "c", ...cmd() });
await Promise.resolve();
expect(writeText).not.toHaveBeenCalled();
});
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Branch, Commit } from "@/bindings";
const vcsCommitFiles = vi.fn();
const vcsTags = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsCommitFiles: (repo: unknown, id: string) => vcsCommitFiles(repo, id),
vcsTags: (repo: unknown) => vcsTags(repo),
vcsLog: () => Promise.resolve({ status: "ok", data: [] }),
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
},
};
});
import { buildBranchTree, filterBranchTree } from "../branches";
import { buildFileTree } from "../fileTree";
import { filesUnder, filterPatch, flatFileNodes, toLogChanges, useLogChanges } from "../logChanges";
import { canDeleteBranch, canUpdateBranch, useBranchesPane } from "../logPaneActions";
import { BranchesPanel, CommitDetails, changesMenu, graphOptionsMenu } from "../GitLog";
import { LogSettingsPage } from "../LogSettingsPage";
import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions";
import { emptyFilters, useGitLog } from "../logStore";
import { useVcs } from "../store";
const b = (name: string, opts: Partial<Branch> = {}): Branch => ({
name,
remote: false,
current: false,
upstream: null,
ahead: 0,
behind: 0,
lastCommitMs: 0,
commit: "",
...opts,
});
const commit: Commit = {
id: "c".repeat(40),
shortId: "c".repeat(8),
parents: ["p".repeat(40)],
author: "A",
email: "[email protected]",
timeMs: 0,
summary: "Change",
message: "Change",
refs: [],
};
beforeEach(() => {
vcsTags.mockResolvedValue({ status: "ok", data: [] });
});
afterEach(() => {
useGitLog.setState({ commits: [], selection: [], selected: null, filters: emptyFilters() });
useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS });
vcsCommitFiles.mockReset();
vcsTags.mockReset();
});
describe("changed files model", () => {
it("keeps the revisions before and after each file's change", () => {
const [mod, add, del] = toLogChanges(
[
{ path: "a.ts", kind: "modified", oldPath: null },
{ path: "b.ts", kind: "added", oldPath: null },
{ path: "c.ts", kind: "deleted", oldPath: null },
],
commit,
);
expect([mod!.before, mod!.after]).toEqual([commit.parents[0], commit.id]);
expect([add!.before, add!.after]).toEqual([null, commit.id]);
expect([del!.before, del!.after]).toEqual([commit.parents[0], null]);
});
it("lists files without directories by name, and the files under selected directories", () => {
const files = ["src/b.ts", "lib/a.ts", "src/x/a.ts"];
expect(flatFileNodes(files, (f) => f).map((n) => n.key)).toEqual(["f:lib/a.ts", "f:src/x/a.ts", "f:src/b.ts"]);
const tree = buildFileTree(files, (f) => f);
expect(filesUnder(tree)).toEqual(["lib/a.ts", "src/x/a.ts", "src/b.ts"]);
expect(filesUnder(tree, new Set(["d:src"]))).toEqual(["src/x/a.ts", "src/b.ts"]);
expect(filesUnder(tree, new Set(["d:src", "f:src/b.ts"]))).toEqual(["src/x/a.ts", "src/b.ts"]);
});
it("keeps only the patch sections of the given files, renames by either name", () => {
const section = (a: string, bb: string) =>
`diff --git a/${a} b/${bb}\nindex 1..2\n--- a/${a}\n+++ b/${bb}\n@@ -1 +1 @@\n-x\n+y\n`;
const patch = section("a.ts", "a.ts") + section("old.ts", "new.ts") + section("c.ts", "c.ts");
expect(filterPatch(patch, new Set(["a.ts"]))).toBe(section("a.ts", "a.ts"));
expect(filterPatch(patch, new Set(["new.ts", "c.ts"]))).toBe(section("old.ts", "new.ts") + section("c.ts", "c.ts"));
expect(filterPatch(patch, new Set(["zzz"]))).toBe("");
});
it("offers Compare with Local only for a file that still exists after the change", () => {
const [del] = toLogChanges([{ path: "c.ts", kind: "deleted", oldPath: null }], commit);
const item = changesMenu([del!]).find((i) => "action" in i && i.action === "vcs.log.changes.compareWithLocal");
expect(item).toMatchObject({ disabled: true });
});
});
describe("changed files pane", () => {
it("shows the files flat or by directory and shares the selection with its actions", async () => {
vcsCommitFiles.mockResolvedValue({
status: "ok",
data: [
{ path: "src/a.ts", kind: "modified", oldPath: null },
{ path: "src/b.ts", kind: "added", oldPath: null },
],
});
useGitLog.setState({ commits: [commit], selected: commit.id, selection: [commit.id] });
const { unmount } = render(<CommitDetails commits={[commit]} />);
expect(await screen.findByText("src")).toBeInTheDocument();
fireEvent.mouseDown(screen.getByText("a.ts"));
expect(useLogChanges.getState().selected.map((c) => c.path)).toEqual(["src/a.ts"]);
expect(useLogChanges.getState().commits).toEqual([commit]);
expect(screen.getByRole("button", { name: "Show Diff" })).toBeEnabled();
useLogOptions.setState({ groupByDirectory: false });
expect(await screen.findByText("b.ts")).toBeInTheDocument();
// Flat: each file shows its directory instead of sitting under it.
expect(screen.getAllByText("src")).toHaveLength(2);
unmount();
expect(useLogChanges.getState().selected).toEqual([]);
});
});
describe("Branches pane", () => {
it("lists tags in their own group, newest version first, and finds them by name", () => {
const roots = buildBranchTree([b("main", { current: true })], null, ["v1.2", "v1.10", "v1.9"]);
const tags = roots.find((n) => n.key === "g:tags");
expect(tags?.children?.map((n) => n.label)).toEqual(["v1.10", "v1.9", "v1.2"]);
expect(filterBranchTree(roots, "1.9").map((n) => n.key)).toEqual(["g:tags"]);
expect(buildBranchTree([b("main")], null).some((n) => n.key === "g:tags")).toBe(false);
});
it("enables Update and Delete for the branches they apply to", () => {
const current = b("main", { current: true });
const tracked = b("feature", { upstream: "origin/feature" });
expect(canDeleteBranch({ ref: "main", branch: current })).toBe(false);
expect(canDeleteBranch({ ref: "feature", branch: tracked })).toBe(true);
expect(canDeleteBranch({ ref: "v1" })).toBe(false);
expect(canUpdateBranch({ ref: "feature", branch: tracked })).toBe(true);
expect(canUpdateBranch({ ref: "x", branch: b("x") })).toBe(false);
expect(canUpdateBranch({ ref: "origin/x", branch: b("origin/x", { remote: true }) })).toBe(false);
});
it("has a vertical toolbar acting on the selected branch, and shows tags", async () => {
vcsTags.mockResolvedValue({ status: "ok", data: ["v1"] });
useVcs.setState({
root: "/p",
repo: "/p",
branchesLoaded: true,
branches: [b("main", { current: true }), b("feature")],
branch: { head: "main", headCommit: "1", upstream: null, ahead: 0, behind: 0, operation: null },
});
render(<BranchesPanel />);
expect(screen.getByRole("toolbar", { name: "Branches" })).toHaveAttribute("aria-orientation", "vertical");
expect(screen.getByPlaceholderText("Branch or tag")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Delete Branch" })).toBeDisabled();
fireEvent.mouseDown(screen.getByText("feature"));
expect(useBranchesPane.getState().selected.map((t) => t.ref)).toEqual(["feature"]);
expect(screen.getByRole("button", { name: "Delete Branch" })).toBeEnabled();
expect(await screen.findByText("Tags")).toBeInTheDocument();
});
});
describe("Graph Options", () => {
it("offers Collapse and Expand Linear Branches as two actions", () => {
for (const collapseLinear of [false, true]) {
const items = graphOptionsMenu({ ...DEFAULT_LOG_OPTIONS, collapseLinear });
const actions = items.filter((i) => "action" in i).map((i) => ("action" in i ? [i.action, i.disabled] : []));
expect(actions).toEqual([
["vcs.log.collapseLinear", undefined],
["vcs.log.expandLinear", undefined],
]);
}
});
});
describe("Settings › Version Control › Log", () => {
it("edits the log's display options", () => {
render(<LogSettingsPage />);
fireEvent.click(screen.getByLabelText("Show tag names"));
expect(useLogOptions.getState().tagNames).toBe(!DEFAULT_LOG_OPTIONS.tagNames);
fireEvent.click(screen.getByLabelText("Hash"));
expect(useLogOptions.getState().showHash).toBe(!DEFAULT_LOG_OPTIONS.showHash);
});
});
+41 -11
View File
@@ -5,10 +5,8 @@ import {
buildMultiRepoRows,
commonBranches,
currentRepoFor,
DEFAULT_EXPANDED,
repoColorIndex,
repoHeadLabel,
toggleExpanded,
widgetShowsRepo,
type RepoEntry,
type RepoRow,
@@ -132,7 +130,7 @@ describe("commonBranches", () => {
});
describe("buildMultiRepoRows", () => {
const input = { repos, branches, actions, expanded: new Set(DEFAULT_EXPANDED) };
const input = { repos, branches, actions, expanded: new Map<string, boolean>() };
it("lists actions, repositories with their branch, then the common groups", () => {
expect(summary(buildMultiRepoRows({ ...input, query: "" }))).toEqual([
@@ -151,7 +149,10 @@ describe("buildMultiRepoRows", () => {
});
it("groups common remote branches by remote when expanded", () => {
const expanded = toggleExpanded(toggleExpanded(new Set(), "common:remote"), "common:folder:origin");
const expanded = new Map([
["common:local", false],
["common:remote", true],
]);
expect(summary(buildMultiRepoRows({ ...input, expanded, query: "" })).slice(-6)).toEqual([
"---",
"> Common Local Branches",
@@ -163,7 +164,7 @@ describe("buildMultiRepoRows", () => {
});
it("filters repositories and branches, expands every group and lists each repository's own matches", () => {
expect(summary(buildMultiRepoRows({ ...input, expanded: new Set(), query: "dev" }))).toEqual([
expect(summary(buildMultiRepoRows({ ...input, expanded: new Map(), query: "dev" }))).toEqual([
"v Common Local Branches",
" dev (current)",
"v Common Remote Branches",
@@ -224,7 +225,7 @@ describe("buildMultiRepoRows with repositories on different branches", () => {
repos: diverged,
branches: own,
actions: [],
expanded: new Set(DEFAULT_EXPANDED),
expanded: new Map<string, boolean>(),
recent: ["dev", "release/1.0", "gone"],
tags: ["v1.2", "v1.10"],
};
@@ -235,18 +236,47 @@ describe("buildMultiRepoRows with repositories on different branches", () => {
"repo ology-dependencies [dev] c2",
"repo ology-gateway [dev] c3",
"---",
"> Recent Branches in ology-collect",
"v Local Branches in ology-collect",
" ology-collect:release/1.0",
" ology-collect:main",
"v Recent Branches in ology-collect",
" ology-collect:dev",
"> Local Branches in ology-collect",
"> Remote Branches in ology-collect",
"> Tags in ology-collect",
]);
});
it("groups the current repository's branches by prefix, opening the current branch's folder", () => {
const expanded = new Map([["repo:local", true]]);
const rows = summary(buildMultiRepoRows({ ...input, expanded, current: "/p/ology-collect", query: "" }));
expect(rows.slice(6, 11)).toEqual([
"v Local Branches in ology-collect",
" ology-collect:main",
" v release",
" ology-collect:release/1.0",
" ology-collect:dev",
]);
const flat = summary(
buildMultiRepoRows({
...input,
expanded,
groupByPrefix: false,
showRecent: false,
current: "/p/ology-collect",
query: "",
}),
);
expect(flat.slice(4, 8)).toEqual([
"v Local Branches in ology-collect",
" ology-collect:release/1.0",
" ology-collect:main",
" ology-collect:dev",
]);
});
it("falls back to the first repository and expands the matching lists on search", () => {
const expanded = new Set(["repo:tags", "repo:recent"]);
const expanded = new Map([
["repo:tags", true],
["repo:recent", true],
]);
const rows = summary(buildMultiRepoRows({ ...input, expanded, current: "/elsewhere", query: "" }));
expect(rows.slice(4)).toEqual([
"v Recent Branches in ology-collect",
+26 -27
View File
@@ -1,5 +1,5 @@
/** VCS actions (ids match keymaps/intellij-macos.ts where bound). */
import { executeAction, registerActions, type Action } from "@/platform/actions";
import { registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { activeEditor, editorsFor } from "@/editor/api";
import { Menu, type MenuItem } from "@/ui";
@@ -23,7 +23,8 @@ import { openDiff, workingDiffData } from "./diffInput";
import { openDialog } from "./dialogs";
import { gotoChange, rollbackSelectedLines } from "./gutter";
import { openLocalHistory } from "./localHistoryKind";
import { revealCommit, setLogFilters, useGitLog, emptyFilters } from "./logStore";
import { openLogTab, revealCommit, selectGitTab, useGitLog } from "./logStore";
import { isModalCommit, openCommit, pushAfterCommit } from "./commitUi";
import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget } from "./remoteUi";
import { chooseRevision } from "./RevisionChooser";
@@ -55,7 +56,11 @@ function argPaths(arg: unknown): string[] | undefined {
return p ? [p] : undefined;
}
const commitWindowFocused = () => getContext().activeToolWindow === "commit";
/** The change tree is focused: the Commit window, or the Git window's Local Changes tab. */
export const commitWindowFocused = () => {
const active = getContext().activeToolWindow;
return active === "commit" || (active === "git" && useGitLog.getState().tab === "changes");
};
/** Files the current context refers to: argument, commit view selection, or the active file. */
function contextPaths(arg: unknown): string[] {
@@ -67,11 +72,6 @@ function contextPaths(arg: unknown): string[] {
return active ? [active] : [];
}
function showCommitWindow(tab: "changes" | "stashes" = "changes") {
useCommit.setState((s) => ({ tab, focusTick: tab === "changes" ? s.focusTick + 1 : s.focusTick }));
toolWindowService().show("commit", true);
}
function showGitWindow() {
toolWindowService().show("git", true);
}
@@ -90,28 +90,26 @@ async function annotate(arg: unknown) {
await toggleAnnotations(handle.view, handle.ctx.path);
}
/** VCS Operations popup: the change and file entries, then Git's own, then Local History. */
const OPERATIONS: MenuItem[] = [
{ kind: "header", label: "Git" },
{ kind: "action", action: "vcs.commit" },
{ kind: "action", action: "vcs.push" },
{ kind: "action", action: "vcs.update" },
{ kind: "action", action: "vcs.pull" },
{ kind: "action", action: "vcs.fetch" },
{ kind: "separator" },
{ kind: "action", action: "vcs.rollback" },
{ kind: "action", action: "vcs.showDiff" },
{ kind: "action", action: "vcs.compareWithBranch" },
{ kind: "action", action: "vcs.addToVcs" },
{ kind: "separator" },
{ kind: "action", action: "vcs.annotate" },
{ kind: "action", action: "vcs.showHistory" },
{ kind: "action", action: "vcs.localHistory" },
{ kind: "action", action: "vcs.annotate" },
{ kind: "action", action: "vcs.showDiff" },
{ kind: "separator" },
{ kind: "action", action: "vcs.branches" },
{ kind: "action", action: "vcs.newBranch" },
{ kind: "action", action: "vcs.push" },
{ kind: "action", action: "vcs.stash" },
{ kind: "action", action: "vcs.unstash" },
{ kind: "separator" },
{ kind: "action", action: "vcs.worktrees" },
{ kind: "action", action: "vcs.addToVcs" },
{ kind: "action", action: "vcs.resolveConflicts" },
{ kind: "separator" },
{ kind: "action", action: "vcs.localHistory" },
];
function showOperations() {
@@ -143,7 +141,7 @@ export function registerVcsActions(): () => void {
category: "Git",
icon: "commit",
when: VCS,
run: () => showCommitWindow(),
run: openCommit,
},
{
id: "vcs.commitAndPush",
@@ -153,10 +151,10 @@ export function registerVcsActions(): () => void {
when: VCS,
run: async () => {
const { message, amend } = useCommit.getState();
// Nothing to commit with yet: IDEA shows the commit window with the message field focused.
if (!message.trim() && !amend) return showCommitWindow();
// Nothing to commit with yet (or the modal interface): IDEA shows where the message is typed.
if (isModalCommit() || (!message.trim() && !amend)) return openCommit();
const id = await commitChanges();
if (id !== undefined) void executeAction("vcs.push");
if (id !== undefined) await pushAfterCommit();
},
},
{
@@ -423,8 +421,10 @@ export function registerVcsActions(): () => void {
when: VCS,
run: ({ arg }) => {
const a = (arg ?? {}) as { branch?: string; path?: string };
useGitLog.setState({ tab: "log" });
setLogFilters({ ...emptyFilters(), branches: a.branch ? [a.branch] : [], path: a.path ?? null });
// A branch or a path gets a tab of its own; the main Log keeps its filters.
if (a.path) openLogTab("history", a.path);
else if (a.branch) openLogTab("branch", a.branch);
else selectGitTab("log");
showGitWindow();
},
},
@@ -437,8 +437,7 @@ export function registerVcsActions(): () => void {
run: ({ arg }) => {
const path = contextPaths(arg)[0];
if (!path) return;
useGitLog.setState({ tab: "log" });
setLogFilters({ ...emptyFilters(), path });
openLogTab("history", path);
showGitWindow();
},
},
+19 -1
View File
@@ -26,9 +26,22 @@ import {
import { showCompareBranches } from "./CompareBranches";
import { showDiffWithWorkingTree } from "./operations";
import type { CommonBranch } from "./repoBranches";
import { focusRepo } from "./store";
import { runRemote, saveAll } from "./ops";
import { focusRepo, useVcs } from "./store";
import { tx } from "@/platform/i18n";
/** `git pull <remote> <branch>` into the current branch (`origin/feat/x` → origin, feat/x). */
async function pullInto(remoteBranch: string, rebase: boolean) {
const i = remoteBranch.indexOf("/");
if (i <= 0) return;
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runRemote(
{ op: "pullFrom", remote: remoteBranch.slice(0, i), branches: [remoteBranch.slice(i + 1)], rebase, options: {} },
`${current} ← ${remoteBranch}`,
);
}
export function branchMenu(b: Branch, current: string | null, done: () => void = () => {}): MenuItem[] {
const run = (fn: () => unknown) => () => {
done();
@@ -71,6 +84,11 @@ export function branchMenu(b: Branch, current: string | null, done: () => void =
{ label: tx`Rebase '${cur}' onto '${b.name}'`, run: run(() => rebaseOnto(b.name)) },
{ label: tx`Merge '${b.name}' into '${cur}'`, run: run(() => mergeIntoCurrent(b.name)) },
];
if (b.remote)
items.push(
{ label: tx`Pull into '${cur}' Using Rebase`, run: run(() => pullInto(b.name, true)) },
{ label: tx`Pull into '${cur}' Using Merge`, run: run(() => pullInto(b.name, false)) },
);
if (!b.remote)
items.push(
{ kind: "separator" },
+231 -49
View File
@@ -9,22 +9,150 @@ export interface PopupAction {
icon?: string;
/** Action argument passed to `executeAction`. */
arg?: unknown;
/** A separator goes between actions of different groups (Update / Commit / Push | New Branch / Checkout…). */
group?: string;
}
export type BranchGroup = "recent" | "local" | "remote";
export type BranchRow =
/** Section header; an empty label draws a separator. */
| { type: "section"; key: string; label: string }
| { type: "action"; key: string; action: PopupAction; matches: number[] | null }
/** Collapsible node: Recent / Local / Remote / Tags (depth 0) or a prefix folder (`feat`). */
| { type: "group"; key: string; label: string; expanded: boolean; depth: number }
| {
type: "branch";
key: string;
branch: Branch;
group: BranchGroup;
/** Shown name: the last segment inside a prefix folder. */
label: string;
depth: number;
matches: number[] | null;
favorite: boolean;
}
| { type: "tag"; key: string; tag: string; matches: number[] | null };
| { type: "tag"; key: string; tag: string; depth: number; matches: number[] | null };
/** Branches popup options (the popup's gear menu). */
export interface BranchTreeOptions {
/** Groups and folders the user expanded (true) or collapsed (false); the others use their defaults. */
expanded?: ReadonlyMap<string, boolean>;
/** `feat/x` under a `feat` folder; remote branches under their remote. */
groupByPrefix?: boolean;
showRecent?: boolean;
showTags?: boolean;
}
/** Copy of `expanded` with `key` set to `open`. */
export function withExpanded(expanded: ReadonlyMap<string, boolean>, key: string, open: boolean): Map<string, boolean> {
return new Map(expanded).set(key, open);
}
export interface PrefixItem<T> {
item: T;
/** Full name, split into folders at `/`. */
name: string;
/** Highlighted characters of `name`. */
matches: number[] | null;
/** Listed before the folders of its level (current branch, favorites). */
pinned: boolean;
}
/** A row of a prefix tree: a folder, or an item shown by the rest of its name. */
export type PrefixRow<T> =
| { type: "folder"; key: string; label: string; depth: number; expanded: boolean }
| { type: "leaf"; item: T; label: string; depth: number; matches: number[] | null };
/**
* Rows of `items` grouped by name prefix (`feat/x` → feat › x), as in IDEA's Branches popup:
* at each level pinned items, then folders by name, then the other items in their order.
* `isOpen(key, path, items)` decides whether the folder `key` (prefix `path`) shows its items.
* Ungrouped: the items as they are, by full name.
*/
export function prefixTreeRows<T>(
items: readonly PrefixItem<T>[],
opts: {
keyPrefix: string;
depth: number;
grouped: boolean;
isOpen: (key: string, path: string, items: readonly PrefixItem<T>[]) => boolean;
},
): PrefixRow<T>[] {
if (!opts.grouped)
return items.map((i) => ({ type: "leaf", item: i.item, label: i.name, depth: opts.depth, matches: i.matches }));
interface Entry {
it: PrefixItem<T>;
rest: string;
offset: number;
}
const level = (list: readonly Entry[], path: string, depth: number): PrefixRow<T>[] => {
const folders = new Map<string, Entry[]>();
const leaves: Entry[] = [];
for (const e of list) {
const i = e.rest.indexOf("/");
if (i > 0 && i < e.rest.length - 1) {
const seg = e.rest.slice(0, i);
const inner = folders.get(seg) ?? [];
folders.set(seg, inner);
inner.push({ it: e.it, rest: e.rest.slice(i + 1), offset: e.offset + i + 1 });
} else leaves.push(e);
}
const leaf = (e: Entry): PrefixRow<T> => ({
type: "leaf",
item: e.it.item,
label: e.rest,
depth,
// Highlights are of the full name: shift them onto the shown part.
matches: e.it.matches && e.it.matches.map((m) => m - e.offset).filter((m) => m >= 0),
});
const out = leaves.filter((e) => e.it.pinned).map(leaf);
for (const [seg, inner] of [...folders].sort(([a], [b]) => a.localeCompare(b))) {
const folderPath = `${path}${seg}`;
const key = `${opts.keyPrefix}${folderPath}`;
const expanded = opts.isOpen(
key,
folderPath,
inner.map((e) => e.it),
);
out.push({ type: "folder", key, label: seg, depth, expanded });
if (expanded) out.push(...level(inner, `${folderPath}/`, depth + 1));
}
out.push(...leaves.filter((e) => !e.it.pinned).map(leaf));
return out;
};
return level(
items.map((it) => ({ it, rest: it.name, offset: 0 })),
"",
opts.depth,
);
}
/** Default expansion of a prefix folder: in Recent, a remote (`origin`), or holding the current branch. */
export function defaultFolderOpen(group: BranchGroup, path: string, hasCurrent: boolean): boolean {
return group === "recent" || (group === "remote" && !path.includes("/")) || hasCurrent;
}
type ActionRow = Extract<BranchRow, { type: "section" | "action" }>;
/** Action rows; with `separators`, a separator between actions of different groups. */
export function actionRows(
actions: readonly PopupAction[],
match: (text: string) => number[] | null | false,
separators: boolean,
): ActionRow[] {
const rows: ActionRow[] = [];
let last: string | undefined;
for (const a of actions) {
const m = match(tx(a.label));
if (m === false) continue;
if (separators && rows.length && a.group !== last)
rows.push({ type: "section", key: `separator:action:${a.id}`, label: "" });
last = a.group;
rows.push({ type: "action", key: `action:${a.id}`, action: a, matches: m });
}
return rows;
}
/** Current branch first, then favorites, then the rest; by name within each. */
function byRank(favorites: readonly string[] | null) {
@@ -48,11 +176,27 @@ export function shortRemoteName(name: string): string {
export const MAX_RECENT = 5;
/** Recently checked-out local branches other than the current one, newest first. */
export function recentLocal(local: readonly Branch[], recent: readonly string[]): Branch[] {
const byName = new Map(local.map((b) => [b.name, b]));
return recent
.map((n) => byName.get(n))
.filter((b): b is Branch => !!b && !b.current)
.slice(0, MAX_RECENT);
}
/** Tags, newest version first. */
export const sortTags = (tags: readonly string[]) =>
[...tags].sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
/**
* Rows for the Branches popup. Without a query: actions, Recent (recently
* checked-out local branches other than the current one), Local, Remote, Tags.
* With a query: matching actions, branches and tags; the Recent group is dropped
* so each branch appears once. Favorites (★) come right after the current branch.
* Rows for the Branches popup: actions (separated by group), then the collapsible
* Recent (recently checked-out local branches other than the current one), Local,
* Remote and Tags groups. Recent is open by default, Local only without Recent,
* Remote and Tags closed; `options.expanded` holds the user's choices. Names are
* grouped by prefix into folders unless `groupByPrefix` is off. With a query:
* matching actions, branches and tags with every group open; the Recent group is
* dropped so each branch appears once. Favorites (★) come right after the current branch.
*/
export function buildBranchRows(
branches: readonly Branch[],
@@ -61,55 +205,79 @@ export function buildBranchRows(
actions: readonly PopupAction[],
favorites: readonly string[] | null = null,
tags: readonly string[] = [],
options: BranchTreeOptions = {},
): BranchRow[] {
const rows: BranchRow[] = [];
const { expanded = new Map<string, boolean>(), groupByPrefix = true, showRecent = true, showTags = true } = options;
const q = query.trim();
const match = (text: string): number[] | null | false => {
if (!q) return null;
return fuzzyMatch(text, q) ?? false;
};
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
const rows: BranchRow[] = actionRows(actions, match, !q);
for (const a of actions) {
const m = match(tx(a.label));
if (m === false) continue;
rows.push({ type: "action", key: `action:${a.id}`, action: a, matches: m });
}
const local = sortLocal(branches, favorites);
const pushGroup = (group: BranchGroup, label: string, list: readonly Branch[]) => {
const items: BranchRow[] = [];
for (const b of list) {
const tree: BranchRow[] = [];
const pushGroup = (group: BranchGroup, label: string, list: readonly Branch[], byDefault: boolean) => {
const items = list.flatMap((b): PrefixItem<Branch>[] => {
const m = match(b.name);
if (m === false) continue;
items.push({
type: "branch",
key: `${group}:${b.name}`,
branch: b,
group,
matches: m,
favorite: isFavoriteBranch(b, favorites),
});
}
return m === false
? []
: [{ item: b, name: b.name, matches: m, pinned: b.current || isFavoriteBranch(b, favorites) }];
});
if (!items.length) return;
rows.push({ type: "section", key: `section:${group}`, label }, ...items);
const key = `group:${group}`;
const expandedNow = open(key, byDefault);
tree.push({ type: "group", key, label, expanded: expandedNow, depth: 0 });
if (!expandedNow) return;
const children = prefixTreeRows(items, {
keyPrefix: `folder:${group}:`,
depth: 1,
grouped: groupByPrefix,
isOpen: (k, path, inner) =>
open(
k,
defaultFolderOpen(
group,
path,
inner.some((i) => i.item.current),
),
),
});
for (const r of children)
tree.push(
r.type === "folder"
? { type: "group", key: r.key, label: r.label, expanded: r.expanded, depth: r.depth }
: {
type: "branch",
key: `${group}:${r.item.name}`,
branch: r.item,
group,
label: r.label,
depth: r.depth,
matches: r.matches,
favorite: isFavoriteBranch(r.item, favorites),
},
);
};
if (!q) {
const byName = new Map(local.map((b) => [b.name, b]));
const rec = recent
.map((n) => byName.get(n))
.filter((b): b is Branch => !!b && !b.current)
.slice(0, MAX_RECENT);
pushGroup("recent", "Recent", rec);
const local = sortLocal(branches, favorites);
const rec = q || !showRecent ? [] : recentLocal(local, recent);
pushGroup("recent", "Recent", rec, true);
pushGroup("local", "Local", local, !rec.length);
pushGroup("remote", "Remote", sortRemote(branches, favorites), false);
if (showTags) {
const tagRows = sortTags(tags).flatMap((t): BranchRow[] => {
const m = match(t);
return m === false ? [] : [{ type: "tag", key: `tag:${t}`, tag: t, depth: 1, matches: m }];
});
if (tagRows.length) {
const expandedNow = open("group:tags", false);
tree.push({ type: "group", key: "group:tags", label: "Tags", expanded: expandedNow, depth: 0 });
if (expandedNow) tree.push(...tagRows);
}
}
pushGroup("local", "Local", local);
pushGroup("remote", "Remote", sortRemote(branches, favorites));
const tagRows: BranchRow[] = [];
for (const t of [...tags].sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) {
const m = match(t);
if (m !== false) tagRows.push({ type: "tag", key: `tag:${t}`, tag: t, matches: m });
}
if (tagRows.length) rows.push({ type: "section", key: "section:tags", label: "Tags" }, ...tagRows);
if (tree.length && rows.length) rows.push({ type: "section", key: "separator:tree", label: "" });
rows.push(...tree);
return rows;
}
@@ -129,8 +297,8 @@ export function isFavoriteBranch(b: Pick<Branch, "name" | "remote">, stored: rea
export interface BranchTreeNode {
key: string;
label: string;
kind: "head" | "group" | "remote" | "folder" | "branch";
/** Branch name for `branch` / `head` (`HEAD`). */
kind: "head" | "group" | "remote" | "folder" | "branch" | "tag";
/** Branch name for `branch` / `head` (`HEAD`), tag name for `tag`. */
ref: string | null;
branch?: Branch;
favorite?: boolean;
@@ -138,11 +306,15 @@ export interface BranchTreeNode {
}
/**
* Git Log Branches pane: HEAD, Local and Remote › <remote>. Names with `/` are
* Git Log Branches pane: HEAD, Local, Remote › <remote> and Tags. Names with `/` are
* grouped into folders (`feat/x` → feat › x); at each level the current branch and
* favorites come first, then folders, then the other branches.
* favorites come first, then folders, then the other branches. Tags: newest version first.
*/
export function buildBranchTree(branches: readonly Branch[], favorites: readonly string[] | null): BranchTreeNode[] {
export function buildBranchTree(
branches: readonly Branch[],
favorites: readonly string[] | null,
tags: readonly string[] = [],
): BranchTreeNode[] {
const level = (list: { b: Branch; rest: string }[], keyPrefix: string): BranchTreeNode[] => {
const folders = new Map<string, { b: Branch; rest: string }[]>();
const leaves: BranchTreeNode[] = [];
@@ -211,6 +383,16 @@ export function buildBranchTree(branches: readonly Branch[], favorites: readonly
),
})),
});
if (tags.length)
out.push({
key: "g:tags",
label: "Tags",
kind: "group",
ref: null,
children: [...tags]
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
.map((t): BranchTreeNode => ({ key: `t:${t}`, label: t, kind: "tag", ref: t })),
});
return out;
}
@@ -227,13 +409,13 @@ export function branchFolderKeys(nodes: readonly BranchTreeNode[], name: string)
return walk(nodes) ?? [];
}
/** Branches pane search: keeps branches whose full name contains `query` (case-insensitive) and their folders. */
/** Branches pane search: keeps branches and tags whose full name contains `query` (case-insensitive) and their folders. */
export function filterBranchTree(nodes: readonly BranchTreeNode[], query: string): BranchTreeNode[] {
const q = query.trim().toLowerCase();
if (!q) return [...nodes];
return nodes.flatMap((n): BranchTreeNode[] => {
if (n.kind === "head") return [];
if (n.kind === "branch") return n.ref?.toLowerCase().includes(q) ? [n] : [];
if (n.kind === "branch" || n.kind === "tag") return n.ref?.toLowerCase().includes(q) ? [n] : [];
const children = filterBranchTree(n.children ?? [], q);
return children.length ? [{ ...n, children }] : [];
});
+4 -2
View File
@@ -7,7 +7,8 @@ import { notifications } from "@/workbench/contributions";
import { commitRepos, groupOf, planCommit, renamedFromPath } from "./changes";
import { useChangelists, withListDefaults } from "./changelists";
import { runCommitChecks } from "./commitChecks";
import { forgetAmendDraft, rememberMessage, useCommit } from "./commitStore";
import { forgetAmendDraft, keepMessageAfterCommit, rememberMessage, useCommit } from "./commitStore";
import { workflowSettings } from "@/platform/workflowSettings";
import { confirmDialog, openDialog } from "./dialogs";
import { hunkKeys } from "./hunks";
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, rootOf, stripSlash } from "./model";
@@ -97,7 +98,8 @@ export async function commitChanges(): Promise<string | undefined> {
if (id !== undefined) {
rememberMessage(message);
forgetAmendDraft();
useCommit.setState({ message: "", amend: false, checks: new Map(), excludedHunks: new Map(), checkProblems: [] });
useCommit.setState({ amend: false, checks: new Map(), excludedHunks: new Map(), checkProblems: [] });
keepMessageAfterCommit(message, workflowSettings().vcs.commit.clearInitialMessage);
}
return id;
}
+74 -2
View File
@@ -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,
+29 -4
View File
@@ -140,6 +140,30 @@ export function forgetAmendDraft() {
amendData = null;
}
/** The message a commit left in the field (not typed since). */
let keptMessage: string | null = null;
/**
* The message field after a commit, as IDEA's: the committed message stays for the
* next commit unless Settings › Version Control › Commit › "Clear initial commit
* message" is on.
*/
export function messageAfterCommit(committed: string, clear: boolean): string {
return clear ? "" : committed.trim();
}
export function keepMessageAfterCommit(committed: string, clear: boolean) {
const message = messageAfterCommit(committed, clear);
keptMessage = message || null;
useCommit.setState({ message });
}
/** The field holds nothing the user typed: empty, or what the last commit left there. */
function messageReplaceable(): boolean {
const m = useCommit.getState().message.trim();
return !m || m === keptMessage;
}
/** Operations whose commit message git prepares in `.git/MERGE_MSG`. */
const PREPARED_MESSAGE_OPS = new Set(["merge", "cherry-pick", "revert"]);
@@ -153,16 +177,16 @@ export function preparedMessageText(raw: string): string {
}
/**
* Like IDEA, pre-fills an empty commit message with the message git prepared
* for a merge / cherry-pick / revert that stopped (e.g. on conflicts).
* Like IDEA, pre-fills the commit message (unless something was typed) with the
* message git prepared for a merge / cherry-pick / revert that stopped (e.g. on conflicts).
*/
export function installPreparedMessage(): () => void {
const load = async () => {
const root = useVcs.getState().root;
if (!root || useCommit.getState().message.trim()) return;
if (!root || !messageReplaceable()) return;
try {
const text = preparedMessageText(await call(commands.fsReadText(`${root}/.git/MERGE_MSG`)));
if (text && !useCommit.getState().message.trim()) useCommit.setState({ message: text });
if (text && messageReplaceable()) useCommit.setState({ message: text });
} catch {
// No prepared message (or `.git` is a worktree file): leave the field empty.
}
@@ -173,6 +197,7 @@ export function installPreparedMessage(): () => void {
if (s.root !== prev.root) {
// Another repository: nothing typed or ticked for the previous one carries over (Amend least of all).
amendData = null;
keptMessage = null;
useCommit.setState({
message: "",
amend: false,
+62
View File
@@ -0,0 +1,62 @@
/**
* Where committing happens: the Commit tool window (non-modal commit interface) or
* the Commit dialog with the local changes in the Git tool window (modal one,
* Settings › Version Control › Commit). Also the push that follows Commit and Push….
*/
import { workflowSettings, type GitSettingsValid } from "@/platform/workflowSettings";
import { toolWindowService } from "@/workbench/contributions";
import { useCommit, type CommitTab } from "./commitStore";
import { stashTabOf } from "./gitTabs";
import { selectGitTab } from "./logStore";
import { isProtectedBranch, pushWithAutoUpdate, showPushDialog } from "./remoteUi";
import { refreshNow, useVcs } from "./store";
export const isModalCommit = () => !workflowSettings().vcs.commit.nonModal;
/** Shows the local changes, Shelf or Stash tab wherever the commit interface puts them. */
export function showLocalChanges(tab: CommitTab = "changes", focus = true) {
const target = stashTabOf(tab, workflowSettings().vcs.git.combineStashAndShelf);
if (isModalCommit()) {
selectGitTab(target);
toolWindowService().show("git", focus);
return;
}
useCommit.setState((s) => ({ tab: target, focusTick: tab === "changes" ? s.focusTick + 1 : s.focusTick }));
toolWindowService().show("commit", focus);
}
/** Commit… (⌘K): focuses the commit message of the Commit window, or opens the Commit dialog. */
export function openCommit() {
if (!isModalCommit()) return showLocalChanges("changes");
void import("./CommitDialog").then((m) => m.showCommitDialog());
}
/**
* Commit and Push…: whether the Push dialog comes up after the commit (Settings › Git ›
* "Show Push dialog for Commit and Push", optionally only for protected branches) or
* the branch is pushed right away. `target` is the remote branch pushed to (null: none
* can be told, e.g. a detached HEAD, which always needs the dialog).
*/
export function pushDialogNeeded(
git: Pick<GitSettingsValid, "showPushDialog" | "pushDialogOnlyProtected" | "protectedBranches">,
target: string | null,
): boolean {
if (!target) return true;
if (!git.showPushDialog) return false;
return !git.pushDialogOnlyProtected || isProtectedBranch(target, git.protectedBranches);
}
/** After Commit and Push…: the Push dialog, or a push of the current branch. */
export async function pushAfterCommit() {
await refreshNow();
const { branch, roots } = useVcs.getState();
const head = branch?.head ?? null;
const upstream = branch?.upstream ?? null;
const target = upstream ? upstream.slice(upstream.indexOf("/") + 1) : head;
// Several repositories: which of them to push is chosen in the dialog.
if (roots.length > 1 || pushDialogNeeded(workflowSettings().vcs.git, target)) return showPushDialog();
await pushWithAutoUpdate(
{ op: "push", force_with_lease: false, set_upstream: !upstream, tags: null },
upstream ? `${head} → ${upstream}` : `${head} → new remote branch`,
);
}
+11 -6
View File
@@ -29,6 +29,16 @@ function matching(paths: readonly string[], kind: "untracked" | "deleted"): stri
return [...out];
}
/** Saves a "Don't ask again" answer to the global settings. */
export async function rememberSetting(key: string, value: unknown) {
try {
await call(commands.settingsSet("global", key, JSON.stringify(value)));
await loadSettings();
} catch {
// keep asking
}
}
async function confirmFiles(op: FileOperation["kind"], files: string[]): Promise<{ ok: boolean; remember: boolean }> {
const root = useVcs.getState().root ?? "";
const add = op === "created";
@@ -63,12 +73,7 @@ async function handle(op: FileOperation) {
const { ok, remember } = await confirmFiles(op.kind, files);
if (remember) {
const key = op.kind === "created" ? "vcs.confirmation.onCreate" : "vcs.confirmation.onDelete";
try {
await call(commands.settingsSet("global", key, JSON.stringify(ok ? "silently" : "never")));
await loadSettings();
} catch {
// keep asking
}
await rememberSetting(key, ok ? "silently" : "never");
}
if (!ok) return;
}
+65 -52
View File
@@ -543,6 +543,26 @@ export function showNewWorktree(onCreated?: () => void) {
}
function WorktreesDialog({ onClose }: { onClose: () => void }) {
return (
<Dialog
open
title="Worktrees"
onClose={onClose}
width={620}
buttons={[{ label: "Close", role: "default", autoFocus: true, onClick: onClose }]}
>
<WorktreesPanel className={styles.form} />
</Dialog>
);
}
/**
* The worktrees of the current repository with New / Remove / Open: the Worktrees
* dialog's content and the Git tool window's Worktrees tab. `listClassName` replaces
* the dialog's bordered list box.
*/
export function WorktreesPanel({ className, listClassName }: { className?: string; listClassName?: string }) {
const repo = useVcs((s) => s.repo);
const [list, setList] = useState<Worktree[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -555,7 +575,7 @@ function WorktreesDialog({ onClose }: { onClose: () => void }) {
.catch((e) => setError(errorMessage(e)));
useEffect(() => {
void reload();
}, []);
}, [repo]);
const current = list.find((w) => w.path === selected);
const remove = async () => {
@@ -586,58 +606,51 @@ function WorktreesDialog({ onClose }: { onClose: () => void }) {
};
return (
<Dialog
open
title="Worktrees"
onClose={onClose}
width={620}
buttons={[{ label: "Close", role: "default", autoFocus: true, onClick: onClose }]}
>
<div className={styles.form}>
<div className={styles.manageBar} role="toolbar" aria-label={tx("Worktrees")}>
<IconButton
icon="plus"
title="New Worktree…"
size="small"
onClick={() => showNewWorktree(() => void reload())}
/>
<IconButton
icon="minus"
title="Remove Worktree"
size="small"
disabled={!current || current.main}
onClick={() => void remove()}
/>
<IconButton
icon="folder"
title="Open in New Window"
size="small"
disabled={!current}
onClick={() => current && void executeAction("file.openProjectPath", current.path)}
/>
</div>
<div className={styles.choiceList} role="listbox" aria-label={tx("Worktrees")}>
{list.map((w) => (
<button
key={w.path}
type="button"
role="option"
aria-selected={w.path === selected}
className={clsx(styles.manageRow, w.path === selected && styles.manageRowSelected)}
onClick={() => setSelected(w.path)}
onDoubleClick={() => void executeAction("file.openProjectPath", w.path)}
>
<span className={styles.fileName}>{w.branch ?? (w.head ? w.head.slice(0, 8) : <T>(bare)</T>)}</span>
<span className={clsx(styles.fileDir, styles.mono)}>{w.path}</span>
{(w.main || w.locked || w.prunable) && (
<span className={styles.fileDir}>{w.main ? "main" : w.locked ? tx("locked") : tx("prunable")}</span>
)}
</button>
))}
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={className}>
<div className={styles.manageBar} role="toolbar" aria-label={tx("Worktrees")}>
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void reload()} />
<IconButton
icon="plus"
title="New Worktree…"
size="small"
onClick={() => showNewWorktree(() => void reload())}
/>
<IconButton
icon="minus"
title="Remove Worktree"
size="small"
disabled={!current || current.main}
onClick={() => void remove()}
/>
<IconButton
icon="folder"
title="Open in New Window"
size="small"
disabled={!current}
onClick={() => current && void executeAction("file.openProjectPath", current.path)}
/>
</div>
</Dialog>
<div className={listClassName ?? styles.choiceList} role="listbox" aria-label={tx("Worktrees")}>
{list.map((w) => (
<button
key={w.path}
type="button"
role="option"
aria-selected={w.path === selected}
className={clsx(styles.manageRow, w.path === selected && styles.manageRowSelected)}
onClick={() => setSelected(w.path)}
onDoubleClick={() => void executeAction("file.openProjectPath", w.path)}
>
<span className={styles.fileName}>{w.branch ?? (w.head ? w.head.slice(0, 8) : <T>(bare)</T>)}</span>
<span className={clsx(styles.fileDir, styles.mono)}>{w.path}</span>
{(w.main || w.locked || w.prunable) && (
<span className={styles.fileDir}>{w.main ? "main" : w.locked ? tx("locked") : tx("prunable")}</span>
)}
</button>
))}
</div>
{error && <div className={styles.error}>{error}</div>}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Content tabs of the Commit and Git tool windows. With the non-modal commit interface
* the Commit window has Commit | Shelf | Stash; with the modal one (no Commit window)
* the Git window takes them as Local Changes | Shelf | Stash, before Log | Console |
* Worktrees. "Combine stashes and shelves" makes Shelf and Stash one tab.
*/
import { tx } from "@/platform/i18n";
import type { CommitTab } from "./commitStore";
import type { LogTab } from "./logStore";
import { baseName } from "./model";
export interface ContentTab {
id: string;
label: string;
closable?: boolean;
/** Count shown after the label (shelves / stashes). */
count?: number;
}
export interface StashTabsInput {
combine: boolean;
shelfCount: number;
stashCount: number;
/** The selected tab: an empty Shelf tab stays while it is shown. */
tab: string;
}
/** The tab a Shelf / Stash request lands on (the combined one when they are combined). */
export function stashTabOf(tab: CommitTab, combine: boolean): CommitTab {
return combine && tab === "shelf" ? "stashes" : tab;
}
/** Shelf and Stash tabs (Shelf only when there are shelves), or the combined one. */
export function stashTabs({ combine, shelfCount, stashCount, tab }: StashTabsInput): ContentTab[] {
if (combine) return [{ id: "stashes", label: "Stashes and Shelves", count: stashCount + shelfCount || undefined }];
return [
...(shelfCount || tab === "shelf" ? [{ id: "shelf", label: "Shelf", count: shelfCount || undefined }] : []),
{ id: "stashes", label: "Stash", count: stashCount || undefined },
];
}
/** Title of a log tab of its own. */
export function logTabLabel(t: LogTab): string {
return t.kind === "history" ? tx`History: ${baseName(t.target)}` : tx`Log: ${t.target}`;
}
export interface GitTabsInput extends StashTabsInput {
/** Modal commit interface: the Git window also shows the local changes, shelves and stashes. */
modal: boolean;
logTabs: readonly LogTab[];
}
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 })),
{ id: "console", label: "Console" },
{ id: "worktrees", label: "Worktrees" },
];
}
/** The tab shown for `tab`: a tab no longer listed (the commit interface changed) falls back to the Log. */
export function shownTab(tab: string, tabs: readonly ContentTab[], combine: boolean): string {
const id = combine && tab === "shelf" ? "stashes" : tab;
return tabs.some((t) => t.id === id) ? id : "log";
}
+10 -8
View File
@@ -3,7 +3,7 @@
* and patches (ids are bound in keymaps/intellij-macos.ts where IDEA binds them).
*/
import type { Commit } from "@/bindings";
import { getContext, setContext } from "@/platform/context";
import { setContext } from "@/platform/context";
import { call, commands } from "@/platform/ipc";
import { notifications, toolWindowService } from "@/workbench/contributions";
import { executeAction, registerActions, type Action } from "@/platform/actions";
@@ -19,7 +19,9 @@ import {
showMoveToChangelist,
} from "./changelistsUi";
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 { chooseRevision } from "./RevisionChooser";
import {
@@ -101,12 +103,12 @@ function withCommit(fn: (c: Commit) => unknown) {
};
}
/** Paths an action on changes refers to: argument, else the Commit tool window selection. */
/** Paths an action on changes refers to: argument, else the change tree's selection (Commit window or Git › Local Changes). */
function changePaths(arg: unknown): string[] | undefined {
if (Array.isArray(arg)) return arg.filter((x): x is string => typeof x === "string");
if (typeof arg === "string") return [arg];
const sel = useCommit.getState().selection;
return getContext().activeToolWindow === "commit" && sel.length ? sel : undefined;
return commitWindowFocused() && sel.length ? sel : undefined;
}
/** A change list id: argument, else the list of the Commit tool window selection. */
@@ -145,21 +147,21 @@ export function registerHistoryActions(): () => void {
id: "vcs.rebase.continue",
title: "Continue Rebase",
category: "Git",
when: VCS,
when: "vcsAvailable && vcsOperation == rebase",
run: () => void controlOperation("continue", "rebase"),
},
{
id: "vcs.rebase.abort",
title: "Abort Rebase",
category: "Git",
when: VCS,
when: "vcsAvailable && vcsOperation == rebase",
run: () => void controlOperation("abort", "rebase"),
},
{
id: "vcs.rebase.skip",
title: "Skip Commit",
category: "Git",
when: VCS,
when: "vcsAvailable && vcsOperation == rebase",
keywords: ["rebase"],
run: () => void controlOperation("skip", "rebase"),
},
@@ -167,7 +169,7 @@ export function registerHistoryActions(): () => void {
id: "vcs.merge.abort",
title: "Abort Merge",
category: "Git",
when: VCS,
when: "vcsAvailable && vcsOperation == merge",
run: () => void controlOperation("abort", "merge"),
},
{
@@ -467,7 +469,7 @@ export function registerHistoryActions(): () => void {
when: VCS,
run: ({ arg }) => {
commitChangelist(listArg(arg) ?? useChangelists.getState().state.active);
toolWindowService().show("commit", true);
openCommit();
},
},
+54 -15
View File
@@ -10,6 +10,7 @@ import {
registerEditorKind,
registerMenuGroup,
registerOverlay,
registerSettingsPage,
registerTitleItem,
registerToolWindow,
} from "@/workbench/contributions";
@@ -19,6 +20,7 @@ import { lazyComponent } from "@/ui";
import { registerVcsActions } from "./actions";
import { installChangelists } from "./changelists";
import { registerHistoryActions, trackOperationContext } from "./historyActions";
import { registerLogPaneActions } from "./logPaneActions";
import { controlLabel, controlOperation, inProgress } from "./operations";
import { annotationsShown, blameSlot } from "./blame";
import { BranchWidget, useBranchesPopup } from "./BranchWidget";
@@ -36,7 +38,7 @@ import { installDiagnosticsTracking } from "./commitChecks";
import { installPreparedMessage, useCommit } from "./commitStore";
import { installFileConfirmations } from "./confirmations";
import { installIncomingCheck } from "./incoming";
import { installGitLog } from "./logStore";
import { installGitLog, selectGitTab, useGitLog } from "./logStore";
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
import { tx } from "@/platform/i18n";
@@ -67,6 +69,43 @@ function trackTitleItem(): () => void {
};
}
/**
* The Commit tool window exists with the non-modal commit interface only; with the
* modal one its tabs move to the Git tool window (and Commit… opens a dialog).
*/
function trackCommitWindow(): () => void {
let dispose: (() => void) | null = null;
const sync = (nonModal: boolean) => {
if (nonModal && !dispose) {
dispose = registerToolWindow({
id: "commit",
title: "Commit",
icon: "toolwindow-commit",
anchor: "left",
order: 1,
component: lazyComponent(() => import("./CommitView").then((m) => m.CommitToolWindow)),
headerContent: lazyComponent(() => import("./CommitView").then((m) => m.CommitTabs)),
tabsReplaceTitle: true,
useBadge: () => {
const n = useVcs((s) => s.status.length);
return n || null;
},
});
// The Git window's Local Changes / Shelf / Stash tabs are gone: back to its Log.
if (["changes", "shelf", "stashes"].includes(useGitLog.getState().tab)) selectGitTab("log");
} else if (!nonModal && dispose) {
dispose();
dispose = null;
}
};
sync(workflowSettings().vcs.commit.nonModal);
const unsub = onWorkflowSettingsChange((w) => w.vcs.commit.nonModal, sync);
return () => {
unsub();
dispose?.();
};
}
function conflictsBody(names: string[]): string {
const list = names.slice(0, 3).join(", ") + (names.length > 3 ? "…" : "");
return names.length === 1
@@ -74,6 +113,17 @@ function conflictsBody(names: string[]): string {
: tx`${names.length} files have conflicts: ${list}`;
}
/** Settings › Version Control › Log (also used by the Settings window). */
export function activateSettingsPages(): () => void {
return registerSettingsPage({
id: "vcs.log",
title: "Log",
order: 6,
keywords: ["git log", "references", "tags", "columns", "commit time"],
component: lazyComponent(() => import("./LogSettingsPage").then((m) => m.LogSettingsPage)),
});
}
export function activate(): () => void {
onConflictsAppeared((files) =>
notifications().notify({
@@ -122,6 +172,8 @@ export function activate(): () => void {
),
registerVcsActions(),
registerHistoryActions(),
registerLogPaneActions(),
activateSettingsPages(),
registerLocalHistoryActions(),
installChangelists(),
trackOperationContext(),
@@ -129,20 +181,7 @@ export function activate(): () => void {
trackTitleItem(),
installHunkPopup(),
registerOverlay({ id: "vcs.dialogs", component: VcsDialogHost }),
registerToolWindow({
id: "commit",
title: "Commit",
icon: "toolwindow-commit",
anchor: "left",
order: 1,
component: lazyComponent(() => import("./CommitView").then((m) => m.CommitToolWindow)),
headerContent: lazyComponent(() => import("./CommitView").then((m) => m.CommitTabs)),
tabsReplaceTitle: true,
useBadge: () => {
const n = useVcs((s) => s.status.length);
return n || null;
},
}),
trackCommitWindow(),
registerToolWindow({
id: "git",
title: "Git",
+215
View File
@@ -0,0 +1,215 @@
/**
* Git Log changed-files pane: the selected commits' files as one model (a single
* commit's files or the combined changes of several), and what its toolbar and
* context menu do with them.
*/
import { create } from "zustand";
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { editorService } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
import { commitFileDiffData, openDiff, revisionChangeDiffData, withChain, type DiffData } from "./diffInput";
import type { FileTreeNode } from "./fileTree";
import { baseName } from "./model";
import { readWorkingText, runOp, saveAll, writeFileText } from "./ops";
import { reportApply } from "./shelf";
import { currentRepoRoot } from "./store";
import { confirmDialog } from "./dialogs";
/** A file of the pane, with the revisions holding its content before and after the change. */
export interface LogChange {
/** Repository-relative path (after the change). */
path: string;
oldPath: string | null;
kind: ChangedFile["kind"];
/** `null`: the file did not exist before. */
before: string | null;
/** `null`: the change deleted the file. */
after: string | null;
diff: DiffData;
}
/** One commit's files (`commit` given) or several commits' combined changes. */
export function toLogChanges(files: readonly (ChangedFile | RevisionChange)[], commit: Commit | null): LogChange[] {
return files.map((f) => {
if (commit) {
const parent = commit.parents[0] ?? null;
return {
path: f.path,
oldPath: f.oldPath,
kind: f.kind,
before: f.kind === "added" ? null : parent,
after: f.kind === "deleted" ? null : commit.id,
diff: commitFileDiffData(commit, f),
};
}
const r = f as RevisionChange;
return {
path: r.path,
oldPath: r.oldPath,
kind: r.kind,
before: r.before,
after: r.after,
diff: revisionChangeDiffData(r),
};
});
}
/** Files without directory grouping: by name, each node's path shown next to it. */
export function flatFileNodes<F>(files: readonly F[], relPath: (f: F) => string): FileTreeNode<F>[] {
return files
.map((file) => {
const rel = relPath(file);
return { key: `f:${rel}`, name: baseName(rel), file, children: [], count: 1, rel };
})
.sort((a, b) => a.name.localeCompare(b.name) || a.rel.localeCompare(b.rel))
.map(({ rel: _rel, ...n }) => n);
}
/**
* Files of `nodes` in display order; with `keys`, only the selected files and the files
* under selected directories (each once).
*/
export function filesUnder<F>(nodes: readonly FileTreeNode<F>[], keys?: ReadonlySet<string>): F[] {
const out: F[] = [];
const walk = (list: readonly FileTreeNode<F>[], inSelected: boolean) => {
for (const n of list) {
const hit = inSelected || !keys || keys.has(n.key);
if (n.file) {
if (hit) out.push(n.file);
} else walk(n.children, hit);
}
};
walk(nodes, false);
return out;
}
/**
* The sections of a `git diff` patch that touch `paths` (repository-relative; a rename
* matches by either name, in either direction for a reversed patch).
*/
export function filterPatch(patch: string, paths: ReadonlySet<string>): string {
const sections = patch.split(/^(?=diff --git )/m);
const header = /^diff --git ("?)a\/(.+?)\1 ("?)b\/(.+?)\3$/;
return sections
.filter((s) => {
const first = s.slice(0, s.indexOf("\n") >>> 0);
const m = header.exec(first);
return !!m && (paths.has(m[2]!) || paths.has(m[4]!));
})
.join("");
}
/** Absolute path of a repository-relative one. */
export function absolutePath(rel: string): string {
const root = currentRepoRoot();
return root ? `${root}/${rel}` : rel;
}
/** Pane state the changed-files actions work on when they get no argument. */
export interface LogChangesState {
/** The selected commits, newest first. */
commits: Commit[];
/** Every file of the pane, in display order. */
all: LogChange[];
/** The selected files. */
selected: LogChange[];
}
export const useLogChanges = create<LogChangesState>(() => ({ commits: [], all: [], selected: [] }));
/** The files an action refers to: its argument, else the pane's selection. */
export function changesArg(arg: unknown): LogChange[] {
if (Array.isArray(arg)) return arg as LogChange[];
return useLogChanges.getState().selected;
}
/** Show Diff: the first file, with every file of the pane in the chain (F7 moves along). */
export async function showChangesDiff(changes: readonly LogChange[]) {
const first = changes[0];
if (!first) return;
const all = useLogChanges.getState().all;
const chain = all.includes(first) ? all : [...changes];
await openDiff(
withChain(
chain.map((c) => c.diff),
Math.max(0, chain.indexOf(first)),
),
);
}
/** Compare with Local (`side` after) / Compare Before with Local: the revision ↔ the working file. */
export async function compareChangeWithLocal(change: LogChange, side: "after" | "before" = "after") {
const rev = side === "after" ? change.after : change.before;
if (!rev) return;
const revPath = side === "before" && change.oldPath ? absolutePath(change.oldPath) : undefined;
await openDiff({
path: absolutePath(change.path),
left: { type: "rev", rev, path: revPath, label: rev.slice(0, 8) },
right: { type: "worktree" },
});
}
/** Jump to Source: the working file. */
export async function openChangeSource(change: LogChange) {
await editorService().openFile(absolutePath(change.path), { focus: true });
}
/**
* Get from Revision: the working files take their content after the change; a file the
* change deleted is deleted.
*/
export async function getFromRevision(changes: readonly LogChange[]) {
if (!changes.length) return;
const ok = await confirmDialog({
title: "Get from Revision",
message:
changes.length === 1
? tx`Replace '${baseName(changes[0]!.path)}' with its content from the revision? Local changes to it are lost.`
: tx`Replace ${changes.length} files with their content from the revision? Local changes to them are lost.`,
okLabel: "Get",
danger: true,
});
if (!ok) return;
await saveAll();
await runOp(
"Get from Revision",
async () => {
for (const c of changes) {
const path = absolutePath(c.path);
if (!c.after) {
if ((await readWorkingText(path)) !== null) await call(commands.fsDelete(path));
continue;
}
const text = await call(commands.vcsFileAt(path, c.after));
if (text === null) continue;
if ((await readWorkingText(path)) === null) await call(commands.fsCreateFile(path));
await writeFileText(path, text, { save: true });
}
return changes.length;
},
{ success: (n) => (n === 1 ? tx("Got 1 file from revision") : tx`Got ${n} files from revision`) },
);
}
/** 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.length || !commits.length) return;
await saveAll();
// Oldest first; the backend reverses the order for a reversed patch.
const ids = [...commits].reverse().map((c) => c.id);
const paths = new Set(changes.flatMap((c) => (c.oldPath ? [c.path, c.oldPath] : [c.path])));
const outcome = await runOp(
"Revert selected changes",
async () => {
const patch = filterPatch(
await call(commands.vcsCreatePatch({ from: "commits", commits: ids, reverse: true })),
paths,
);
if (!patch.trim()) throw new Error(tx("Nothing to revert"));
return call(commands.vcsApplyPatch(patch));
},
{ refresh: false },
);
if (outcome) await reportApply(outcome, "Changes reverted");
}
+12
View File
@@ -22,6 +22,10 @@ export interface LogOptions {
highlightMine: boolean;
highlightMerges: boolean;
highlightCurrentBranch: boolean;
/** The Branches pane on the left of the log. */
showBranches: boolean;
/** Changed files grouped by directory (else a flat list). */
groupByDirectory: boolean;
}
/** IDEA's defaults. */
@@ -41,6 +45,8 @@ export const DEFAULT_LOG_OPTIONS: LogOptions = {
highlightMine: true,
highlightMerges: true,
highlightCurrentBranch: true,
showBranches: true,
groupByDirectory: true,
};
/** Options that change which commits the backend returns (a reload is needed). */
@@ -69,6 +75,12 @@ function load(): LogOptions {
export const useLogOptions = create<LogOptions>(() => load());
// Settings › Version Control › Log edits them from the Settings window too.
if (typeof window !== "undefined")
window.addEventListener("storage", (e) => {
if (e.key === KEY) useLogOptions.setState(load());
});
export const logOptions = () => useLogOptions.getState();
export type RefKind = "head" | "local" | "remote" | "tag";
+211
View File
@@ -0,0 +1,211 @@
/**
* Actions of the Git Log's inner panes: the Branches pane toolbar, Graph Options'
* linear-branch actions and the changed-files pane (toolbar and context menu).
*/
import { create } from "zustand";
import type { Branch } from "@/bindings";
import { registerActions, type Action } from "@/platform/actions";
import { notifications, toolWindowService } from "@/workbench/contributions";
import { deleteBranch, deleteRemoteBranch, newBranch, toggleFavoriteBranch, updateBranch } from "./branchOps";
import {
changesArg,
compareChangeWithLocal,
getFromRevision,
openChangeSource,
revertSelectedChanges,
showChangesDiff,
useLogChanges,
} from "./logChanges";
import { logOptions } from "./logOptions";
import { goToRef, updateLogOptions } from "./logStore";
const VCS = "vcsAvailable";
/** A ref selected in the Branches pane. */
export interface BranchTarget {
ref: string;
branch?: Branch;
}
/** The Branches pane's selected refs (what its toolbar acts on without an argument). */
export const useBranchesPane = create<{ selected: BranchTarget[] }>(() => ({ selected: [] }));
function branchArg(arg: unknown): BranchTarget | null {
if (arg && typeof arg === "object" && "ref" in arg) return arg as BranchTarget;
return useBranchesPane.getState().selected[0] ?? null;
}
const withBranch =
(fn: (b: Branch) => unknown): Action["run"] =>
({ arg }) => {
const b = branchArg(arg)?.branch;
if (b) void fn(b);
};
/** Delete is offered for branches other than the current one. */
export function canDeleteBranch(t: BranchTarget | null): boolean {
return !!t?.branch && !t.branch.current;
}
/** Update fast-forwards a local branch with an upstream (or updates the project for the current one). */
export function canUpdateBranch(t: BranchTarget | null): boolean {
return !!t?.branch && !t.branch.remote && (t.branch.current || !!t.branch.upstream);
}
export function registerLogPaneActions(): () => void {
return registerActions([
{
id: "vcs.log.showBranches",
title: "Show Branches",
category: "Git Log",
when: VCS,
toggled: () => logOptions().showBranches,
run: () => {
updateLogOptions({ showBranches: !logOptions().showBranches });
toolWindowService().show("git", true);
},
},
{
id: "vcs.log.collapseLinear",
title: "Collapse Linear Branches",
category: "Git Log",
icon: "collapse-all",
when: VCS,
run: () => updateLogOptions({ collapseLinear: true }),
},
{
id: "vcs.log.expandLinear",
title: "Expand Linear Branches",
category: "Git Log",
icon: "expand-all",
when: VCS,
run: () => updateLogOptions({ collapseLinear: false }),
},
// --- Branches pane ---
{
id: "vcs.log.branches.new",
title: "New Branch…",
category: "Git Log",
icon: "plus",
when: VCS,
hidden: true,
run: ({ arg }) => {
const t = branchArg(arg);
void newBranch(t && t.ref !== "HEAD" && !t.branch?.current ? t.ref : undefined);
},
},
{
id: "vcs.log.branches.update",
title: "Update Selected",
category: "Git Log",
icon: "git-pull",
when: VCS,
hidden: true,
run: withBranch((b) => updateBranch(b.name)),
},
{
id: "vcs.log.branches.delete",
title: "Delete Branch",
category: "Git Log",
icon: "trash",
when: VCS,
hidden: true,
run: withBranch((b) => {
if (b.current) return;
return b.remote ? deleteRemoteBranch(b.name) : deleteBranch(b.name);
}),
},
{
id: "vcs.log.branches.toggleFavorite",
title: "Mark/Unmark As Favorite",
category: "Git Log",
icon: "star",
when: VCS,
hidden: true,
run: withBranch(toggleFavoriteBranch),
},
{
id: "vcs.log.branches.navigate",
title: "Navigate Log to Selected Branch",
category: "Git Log",
icon: "locate",
when: VCS,
hidden: true,
run: async ({ arg }) => {
const t = branchArg(arg);
if (t && !(await goToRef(t.ref)))
notifications().notify({ group: "vcs", level: "warning", title: `'${t.ref}' was not found in the log` });
},
},
// --- changed files ---
{
id: "vcs.log.changes.showDiff",
title: "Show Diff",
category: "Git Log",
icon: "diff",
when: VCS,
hidden: true,
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
},
{
id: "vcs.log.changes.compareWithLocal",
title: "Compare with Local",
category: "Git Log",
when: VCS,
hidden: true,
run: ({ arg }) => {
const c = changesArg(arg)[0];
if (c) void compareChangeWithLocal(c, "after");
},
},
{
id: "vcs.log.changes.compareBeforeWithLocal",
title: "Compare Before with Local",
category: "Git Log",
when: VCS,
hidden: true,
run: ({ arg }) => {
const c = changesArg(arg)[0];
if (c) void compareChangeWithLocal(c, "before");
},
},
{
id: "vcs.log.changes.jumpToSource",
title: "Jump to Source",
category: "Git Log",
icon: "edit",
when: VCS,
hidden: true,
run: ({ arg }) => {
for (const c of changesArg(arg)) if (c.after) void openChangeSource(c);
},
},
{
id: "vcs.log.changes.getFromRevision",
title: "Get from Revision",
category: "Git Log",
when: VCS,
hidden: true,
run: ({ arg }) => void getFromRevision(changesArg(arg)),
},
{
id: "vcs.log.changes.revert",
title: "Revert Selected Changes",
category: "Git Log",
icon: "revert",
when: VCS,
hidden: true,
run: ({ arg }) => void revertSelectedChanges(changesArg(arg), useLogChanges.getState().commits),
},
{
id: "vcs.log.changes.groupByDirectory",
title: "Group by Directory",
category: "Git Log",
when: VCS,
toggled: () => logOptions().groupByDirectory,
run: () => updateLogOptions({ groupByDirectory: !logOptions().groupByDirectory }),
},
]);
}
+106 -4
View File
@@ -46,7 +46,12 @@ interface LogState {
selection: string[];
/** Commits of HEAD not on its upstream (`@{u}..HEAD`); null without an upstream. */
outgoing: Set<string> | null;
tab: "log" | "console";
/** Content tab of the Git tool window (a `GitTab`, or the id of a log tab). */
tab: string;
/** Closable log tabs (History: file, Log: branch) next to the main Log. */
logTabs: LogTab[];
/** The log tab whose commits / filters / selection the store holds ("log" or a `logTabs` id). */
shownLog: string;
/** Bumped to ask the list to scroll the selection into view. */
revealTick: number;
/** The current user (`user.email`, else `user.name`), for View Options › Highlight › My Commits. */
@@ -54,6 +59,17 @@ interface LogState {
generation: number;
}
/** Fixed content tabs of the Git tool window. */
export type GitTab = "changes" | "shelf" | "stashes" | "log" | "console" | "worktrees";
/** A log tab of its own (IDEA's "History: file" / "Log: branch" tabs): its own filters and view. */
export interface LogTab {
id: string;
kind: "history" | "branch";
/** What it shows (the file path or the branch); opening the same again selects the tab. */
target: string;
}
export const PAGE = 300;
export const emptyFilters = (): LogFilters => ({
@@ -120,6 +136,8 @@ const initialLog = (): Omit<LogState, "generation"> => ({
selection: [],
outgoing: null,
tab: "log",
logTabs: [],
shownLog: "log",
revealTick: 0,
user: null,
});
@@ -128,6 +146,82 @@ export const useGitLog = create<LogState>(() => ({ ...initialLog(), generation:
let loadPromise: Promise<void> | null = null;
/** What a log tab shows: the store fields swapped in and out when another log tab is selected. */
type LogView = Pick<
LogState,
"filters" | "commits" | "rows" | "graph" | "loading" | "done" | "error" | "selected" | "selection" | "revealTick"
>;
function freshView(filters: LogFilters): LogView {
const { commits, rows, graph, loading, done, error, selected, selection, revealTick } = initialLog();
return { filters, commits, rows, graph, loading, done, error, selected, selection, revealTick };
}
/** Views of the log tabs not shown now. */
const parked = new Map<string, LogView>();
function currentView(): LogView {
const { filters, commits, rows, graph, done, error, selected, selection, revealTick } = useGitLog.getState();
// A page still loading is dropped (the generation changes): loaded again when shown.
return { filters, commits, rows, graph, loading: false, done, error, selected, selection, revealTick };
}
export const isLogTab = (id: string, tabs: readonly LogTab[] = useGitLog.getState().logTabs) =>
id === "log" || tabs.some((t) => t.id === id);
/** Puts the view of log tab `id` into the store, parking the one shown. */
function showLogView(id: string) {
const s = useGitLog.getState();
if (s.shownLog === id) return;
parked.set(s.shownLog, currentView());
const view = parked.get(id) ?? freshView(emptyFilters());
parked.delete(id);
loadPromise = null;
useGitLog.setState({ ...view, shownLog: id, generation: s.generation + 1 });
if (!view.commits.length) void reloadLog();
}
/** Selects a content tab of the Git tool window; a log tab brings its own filters and commits. */
export function selectGitTab(id: string) {
if (isLogTab(id)) showLogView(id);
useGitLog.setState({ tab: id });
}
let tabSeq = 0;
/**
* Opens (or selects) a log tab of its own, leaving the main Log and its filters as
* they are: Show History for a file, Show Git Log for a branch.
*/
export function openLogTab(kind: LogTab["kind"], target: string): string {
const existing = useGitLog.getState().logTabs.find((t) => t.kind === kind && t.target === target);
if (existing) {
selectGitTab(existing.id);
return existing.id;
}
const id = `${kind}:${++tabSeq}`;
const filters = { ...emptyFilters(), ...(kind === "history" ? { path: target } : { branches: [target] }) };
parked.set(id, freshView(filters));
useGitLog.setState((s) => ({ logTabs: [...s.logTabs, { id, kind, target }] }));
selectGitTab(id);
// History of a file in another repository: that repository's log.
const repo = kind === "history" ? repoOf(target) : null;
if (repo && repo !== currentRepo()) void selectRepo(repo);
return id;
}
/** Closes a log tab; when it was shown, the main Log comes back. */
export function closeLogTab(id: string) {
const s = useGitLog.getState();
if (!s.logTabs.some((t) => t.id === id)) return;
if (s.shownLog === id) showLogView("log");
parked.delete(id);
useGitLog.setState((l) => ({
logTabs: l.logTabs.filter((t) => t.id !== id),
tab: l.tab === id ? "log" : l.tab,
}));
}
/**
* Another project (the window switched projects): forget its commits, filters, selection
* and console. Another repository of the project selected: show its log.
@@ -136,9 +230,16 @@ export function installGitLog(): () => void {
return useVcs.subscribe((s, prev) => {
if (s.root !== prev.root) {
loadPromise = null;
useGitLog.setState((l) => ({ ...initialLog(), tab: l.tab, generation: l.generation + 1 }));
parked.clear();
useGitLog.setState((l) => ({
...initialLog(),
tab: isLogTab(l.tab, l.logTabs) ? "log" : l.tab,
generation: l.generation + 1,
}));
clearConsole();
} else if (s.repo !== prev.repo && prev.repo !== null) {
// The parked log tabs load again when shown.
for (const [id, view] of parked) parked.set(id, freshView(view.filters));
const l = useGitLog.getState();
if (l.generation === 0 && !l.filters.path) return;
const { path } = useGitLog.getState().filters;
@@ -317,7 +418,7 @@ const MAX_SEARCH_PAGES = 30;
/** Selects `id` in the log, clearing filters and paging until it is loaded. */
export async function revealCommit(id: string) {
useGitLog.setState({ tab: "log" });
selectGitTab("log");
const f = useGitLog.getState().filters;
const filtered = hasFilters(f);
const find = () => useGitLog.getState().commits.find((c) => c.id === id || c.id.startsWith(id));
@@ -387,7 +488,8 @@ export async function goToRef(rev: string): Promise<boolean> {
id = undefined;
}
if (!id) return false;
useGitLog.setState({ tab: "log" });
const tab = useGitLog.getState().tab;
selectGitTab(isLogTab(tab) ? tab : "log");
const find = () => useGitLog.getState().commits.some((c) => c.id === id);
if (!useGitLog.getState().commits.length) await reloadLog();
for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore();
+24 -1
View File
@@ -3,7 +3,10 @@ import { useEffect, useState } from "react";
import type { Commit, FileStatus, PatchFile, PatchSource } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { Button, Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifications } from "@/workbench/contributions";
import { ask } from "@/workbench/messages";
import { rememberSetting } from "./confirmations";
import { openDialog } from "./dialogs";
import { KIND_COLOR, baseName, dirName, stripSlash } from "./model";
import { errorMessage, logConsole, runOp, saveAll } from "./ops";
@@ -53,6 +56,24 @@ async function browseSave(current: string): Promise<string | null> {
}
}
/** Settings › Version Control › Confirmation › When patch is created: ask / no / show in Finder. */
async function afterPatchSaved(path: string) {
const mode = workflowSettings().vcs.confirmation.onPatchCreated;
if (mode === "no") return;
if (mode === "ask") {
const r = await ask({
title: "Create Patch",
body: tx("Patch file created successfully. Show it in Finder?"),
checkbox: "Don't ask again",
buttons: [{ id: "show", label: "Show", primary: true }],
});
const show = r.button === "show";
if (r.checked) await rememberSetting("vcs.confirmation.onPatchCreated", show ? "reveal" : "no");
if (!show) return;
}
void import("@tauri-apps/plugin-opener").then((m) => m.revealItemInDir(path)).catch(() => undefined);
}
function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: () => void }) {
const root = useVcs((s) => s.root) ?? "";
const listName = useChangelists((s) => activeList(s.state).name);
@@ -79,15 +100,17 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
commits: [...origin.commits].sort((a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0)).map((c) => c.id),
reverse,
};
await runOp(
const done = await runOp(
"Create patch",
async () => {
const patch = await call(commands.vcsCreatePatch(source));
if (toFile) await call(commands.vcsWritePatch(file.trim(), patch));
else await navigator.clipboard.writeText(patch);
return true;
},
{ success: toFile ? `Patch saved to ${baseName(file.trim())}` : "Patch copied to the clipboard", refresh: false },
);
if (done && toFile) await afterPatchSaved(file.trim());
};
return (
<Dialog
+29 -16
View File
@@ -6,9 +6,11 @@ import { useState } from "react";
import type { Commit, RebaseAction, RebaseEntry } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { Dialog, IconButton, Select, T } from "@/ui";
import { toolWindowService } from "@/workbench/contributions";
import { showError } from "@/workbench/messages";
import { workflowSettings } from "@/platform/workflowSettings";
import { ask, showError } from "@/workbench/messages";
import { useCommit } from "./commitStore";
import { openCommit } from "./commitUi";
import { rememberSetting } from "./confirmations";
import { confirmDialog, openDialog } from "./dialogs";
import { conflictsInstead, inProgress } from "./operations";
import { runGitOperation, runOp, saveAll } from "./ops";
@@ -146,12 +148,10 @@ export async function dropCommits(commits: Commit[]) {
const ids = new Set(commits.map((c) => c.id));
if (range.commits.every((c) => ids.has(c.id)) && !range.base)
return refuse("Cannot drop commits", "At least one commit of the branch must be kept");
const ok = await confirmDialog({
title: "Drop Commits",
message: tx`Drop ${commits.length} commits from the current branch? Their changes are removed.`,
okLabel: "Drop",
danger: true,
});
const ok = await confirmDrop(
"Drop Commits",
tx`Drop ${commits.length} commits from the current branch? Their changes are removed.`,
);
if (!ok) return;
const plan = range.commits.map((c) => ({
commit: c.id,
@@ -166,16 +166,29 @@ async function dropCommit(commit: Commit) {
if (!range) return;
if (range.commits.length === 1 && !range.base)
return refuse("Cannot drop commit", "It is the only commit of the branch");
const ok = await confirmDialog({
title: "Drop Commit",
message: tx`Drop commit ${short(commit.id)} “${commit.summary}” from the current branch? Its changes are removed.`,
okLabel: "Drop",
danger: true,
});
const ok = await confirmDrop(
"Drop Commit",
tx`Drop commit ${short(commit.id)} “${commit.summary}” from the current branch? Its changes are removed.`,
);
if (!ok) return;
await runRebase(range.base, singleCommitPlan(range, commit.id, "drop"), `Dropped commit ${short(commit.id)}`);
}
/** Settings › Version Control › Confirmation › Ask for confirmation to drop commits, with "Don't ask again". */
async function confirmDrop(title: string, body: string): Promise<boolean> {
if (!workflowSettings().vcs.confirmation.confirmDropCommits) return true;
const r = await ask({
title,
body,
icon: "warning",
checkbox: "Don't ask again",
buttons: [{ id: "drop", label: "Drop", primary: true }],
});
if (r.button !== "drop") return false;
if (r.checked) await rememberSetting("vcs.confirmation.confirmDropCommits", false);
return true;
}
/** The range from the oldest of `commits`, when all of them are on the current branch. */
async function rangeFor(commits: Commit[], verb: string): Promise<RebaseRange | null> {
// Log order is newest first: the last selected row is the oldest commit.
@@ -207,11 +220,11 @@ export async function squashCommits(commits: Commit[]) {
/**
* Fixup… / Squash Into…: prepares a `fixup!` / `squash!` commit of the local changes
* for `target` in the Commit tool window; the next interactive rebase autosquashes it.
* for `target` in the commit interface; the next interactive rebase autosquashes it.
*/
export function prepareFixup(target: Commit, kind: "fixup" | "squash") {
useCommit.setState({ message: `${kind}! ${target.summary}` });
toolWindowService().show("commit", true);
openCommit();
}
/** Interactively Rebase from Here…: IDEA's rebase dialog for `target..HEAD`. */
+17 -9
View File
@@ -13,25 +13,33 @@ import { allRepos, lastRemoteFailure, runRemote, saveAll } from "./ops";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
import styles from "./common.module.css";
/** Update Project…: the options dialog, or (Confirmation › Show options before Update off) the default method right away. */
export function showUpdateDialog() {
const { vcs } = workflowSettings();
if (!vcs.confirmation.showUpdateOptions) return void updateProject(vcs.git.updateMethod === "rebase");
openDialog((close) => <UpdateDialog onClose={close} />);
}
async function updateProject(rebase: boolean) {
await saveAll();
const branch = useVcs.getState().branch;
const repos = allRepos();
const detail =
repos.length > 1
? `${repos.length} repositories`
: branch?.upstream
? `${branch.head} ← ${branch.upstream}`
: undefined;
await runRemote({ op: "pull", rebase }, detail, repos);
}
function UpdateDialog({ onClose }: { onClose: () => void }) {
const branch = useVcs((s) => s.branch);
// Default from Settings › Version Control › Git › Update method.
const [rebase, setRebase] = useState(() => workflowSettings().vcs.git.updateMethod === "rebase");
const run = async () => {
onClose();
await saveAll();
const repos = allRepos();
const detail =
repos.length > 1
? `${repos.length} repositories`
: branch?.upstream
? `${branch.head} ← ${branch.upstream}`
: undefined;
await runRemote({ op: "pull", rebase }, detail, repos);
await updateProject(rebase);
};
return (
<Dialog
+127 -88
View File
@@ -3,7 +3,19 @@ import type { Branch } from "@/bindings";
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui/match";
import { rootOf } from "./model";
import { isFavoriteBranch, MAX_RECENT, sortLocal, sortRemote, type PopupAction } from "./branches";
import {
actionRows,
defaultFolderOpen,
isFavoriteBranch,
prefixTreeRows,
recentLocal,
sortLocal,
sortRemote,
sortTags,
type BranchTreeOptions,
type PopupAction,
type PrefixItem,
} from "./branches";
export interface RepoEntry {
root: string;
@@ -95,13 +107,13 @@ export type RepoRow =
| { type: "section"; key: string; label: string }
| { type: "action"; key: string; action: PopupAction; matches: number[] | null }
| { type: "repo"; key: string; repo: RepoEntry; color: number; matches: number[] | null }
/** Collapsible group (common branches, or one of the current repository's lists) or remote folder. */
/** Collapsible group (common branches, or one of the current repository's lists) or prefix / remote folder. */
| { type: "group"; key: string; label: string; expanded: boolean; depth: number }
| {
type: "common";
key: string;
branch: CommonBranch;
/** Shown name: remote branches without their remote (it is the folder). */
/** Shown name: the last segment inside a folder. */
label: string;
depth: number;
matches: number[] | null;
@@ -113,26 +125,21 @@ export type RepoRow =
repo: RepoEntry;
branch: Branch;
group: BranchGroupKind;
label: string;
depth: number;
matches: number[] | null;
favorite: boolean;
}
| { type: "tag"; key: string; repo: RepoEntry; tag: string; depth: number; matches: number[] | null };
/**
* Initially expanded groups: the common local branches, and the current repository's
* local branches (switching a repository's branch stays one click away).
*/
export const DEFAULT_EXPANDED: readonly string[] = ["common:local", "repo:local"];
export interface MultiRepoInput {
export interface MultiRepoInput extends Omit<BranchTreeOptions, "expanded"> {
repos: readonly RepoEntry[];
/** Root → its branches (missing while loading). */
branches: Readonly<Record<string, readonly Branch[]>>;
query: string;
actions: readonly PopupAction[];
/** Expanded group / folder keys (`common:local`, `repo:remote`, `common:folder:origin`…). */
expanded: ReadonlySet<string>;
/** Groups / folders the user expanded or collapsed (`common:local`, `repo:remote`, `repo:folder:local:feat`…). */
expanded: ReadonlyMap<string, boolean>;
favorites?: readonly string[] | null;
/** The current repository (of the active file), whose lists are shown when the repositories' branches differ. */
current?: string | null;
@@ -147,8 +154,9 @@ export interface MultiRepoInput {
* each with its current branch and opening its own branches; then, when they are all
* on the same branch, the branches common to all of them (acting on all of them),
* otherwise the current repository's recent, local and remote branches and tags, in
* collapsible groups. A query filters actions, repositories (by name) and branches,
* expands every group, and adds the other repositories' matching branches.
* collapsible groups (Recent open, Local open only without Recent). A query filters
* actions, repositories (by name) and branches, expands every group, and adds the
* other repositories' matching branches.
*/
export function buildMultiRepoRows({
repos,
@@ -160,18 +168,16 @@ export function buildMultiRepoRows({
current = null,
recent = [],
tags = [],
groupByPrefix = true,
showRecent = true,
showTags = true,
}: MultiRepoInput): RepoRow[] {
const q = query.trim();
const match = (text: string): number[] | null | false => (q ? (fuzzyMatch(text, q) ?? false) : null);
const open = (key: string) => !!q || expanded.has(key);
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
const blocks: RepoRow[][] = [];
const actionRows: RepoRow[] = [];
for (const a of actions) {
const m = match(tx(a.label));
if (m !== false) actionRows.push({ type: "action", key: `action:${a.id}`, action: a, matches: m });
}
blocks.push(actionRows);
blocks.push(actionRows(actions, match, !q));
const repoRows: RepoRow[] = [];
repos.forEach((repo, i) => {
@@ -182,30 +188,92 @@ export function buildMultiRepoRows({
blocks.push(repoRows);
/** A group row followed (when expanded) by its children; nothing when it has none. */
const group = (out: RepoRow[], key: string, label: string, depth: number, children: RepoRow[]) => {
const group = (out: RepoRow[], key: string, label: string, children: RepoRow[], byDefault: boolean) => {
if (!children.length) return;
const expandedNow = open(key);
out.push({ type: "group", key, label, expanded: expandedNow, depth });
const expandedNow = open(key, byDefault);
out.push({ type: "group", key, label, expanded: expandedNow, depth: 0 });
if (expandedNow) out.push(...children);
};
const branchRows = (repo: RepoEntry, list: readonly Branch[], kind: BranchGroupKind, depth: number) =>
list.flatMap((b): RepoRow[] => {
const folder = (r: { key: string; label: string; depth: number; expanded: boolean }): RepoRow => ({
type: "group",
key: r.key,
label: r.label,
expanded: r.expanded,
depth: r.depth,
});
const branchRows = (
repo: RepoEntry,
list: readonly Branch[],
kind: BranchGroupKind,
depth: number,
grouped = groupByPrefix,
): RepoRow[] => {
const items = list.flatMap((b): PrefixItem<Branch>[] => {
const m = match(b.name);
if (m === false) return [];
const key = `branch:${repo.root}:${kind}:${b.name}`;
return [
{
type: "branch",
key,
repo,
branch: b,
group: kind,
depth,
matches: m,
favorite: isFavoriteBranch(b, favorites),
},
];
return m === false
? []
: [{ item: b, name: b.name, matches: m, pinned: b.current || isFavoriteBranch(b, favorites) }];
});
return prefixTreeRows(items, {
keyPrefix: `repo:folder:${kind}:`,
depth,
grouped,
isOpen: (k, path, inner) =>
open(
k,
defaultFolderOpen(
kind,
path,
inner.some((i) => i.item.current),
),
),
}).map((r): RepoRow =>
r.type === "folder"
? folder(r)
: {
type: "branch",
key: `branch:${repo.root}:${kind}:${r.item.name}`,
repo,
branch: r.item,
group: kind,
label: r.label,
depth: r.depth,
matches: r.matches,
favorite: isFavoriteBranch(r.item, favorites),
},
);
};
const commonRows = (list: readonly CommonBranch[], kind: "local" | "remote"): RepoRow[] => {
const items = list.flatMap((b): PrefixItem<CommonBranch>[] => {
const m = match(b.name);
return m === false ? [] : [{ item: b, name: b.name, matches: m, pinned: b.current || b.favorite }];
});
return prefixTreeRows(items, {
keyPrefix: `common:folder:${kind}:`,
depth: 1,
grouped: groupByPrefix,
isOpen: (k, path, inner) =>
open(
k,
defaultFolderOpen(
kind,
path,
inner.some((i) => i.item.current),
),
),
}).map((r): RepoRow =>
r.type === "folder"
? folder(r)
: {
type: "common",
key: `common:${r.item.name}`,
branch: r.item,
label: r.label,
depth: r.depth,
matches: r.matches,
},
);
};
const shared = !widgetShowsRepo(repos);
const groups: RepoRow[] = [];
@@ -220,60 +288,38 @@ export function buildMultiRepoRows({
)
: { local: [], remote: [] };
shown = new Set([...common.local, ...common.remote].map((b) => `${b.remote}:${b.name}`));
const local = common.local.flatMap((b): RepoRow[] => {
const m = match(b.name);
return m === false
? []
: [{ type: "common", key: `common:${b.name}`, branch: b, label: b.name, depth: 1, matches: m }];
});
group(groups, "common:local", tx("Common Local Branches"), 0, local);
const byRemote = new Map<string, CommonBranch[]>();
for (const b of common.remote) {
const remote = b.name.slice(0, Math.max(0, b.name.indexOf("/")));
byRemote.set(remote, [...(byRemote.get(remote) ?? []), b]);
}
const remote: RepoRow[] = [];
for (const [name, items] of byRemote) {
const leaves = items.flatMap((b): RepoRow[] => {
const m = match(b.name);
if (m === false) return [];
// Highlights are of the full name: shift them onto the shown part.
const part = m && m.map((i) => i - name.length - 1).filter((i) => i >= 0);
const label = b.name.slice(name.length + 1);
return [{ type: "common", key: `common:${b.name}`, branch: b, label, depth: 2, matches: part }];
});
group(remote, `common:folder:${name}`, name, 1, leaves);
}
group(groups, "common:remote", tx("Common Remote Branches"), 0, remote);
group(groups, "common:local", tx("Common Local Branches"), commonRows(common.local, "local"), true);
group(groups, "common:remote", tx("Common Remote Branches"), commonRows(common.remote, "remote"), false);
} else if (currentRepo) {
const list = branches[currentRepo.root] ?? [];
const local = sortLocal(list, favorites);
const byName = new Map(local.map((b) => [b.name, b]));
const recentList = q
? []
: recent
.map((n) => byName.get(n))
.filter((b): b is Branch => !!b && !b.current)
.slice(0, MAX_RECENT);
const recentList = q || !showRecent ? [] : recentLocal(local, recent);
const name = currentRepo.name;
group(groups, "repo:recent", tx`Recent Branches in ${name}`, 0, branchRows(currentRepo, recentList, "recent", 1));
group(groups, "repo:local", tx`Local Branches in ${name}`, 0, branchRows(currentRepo, local, "local", 1));
const recentRows = branchRows(currentRepo, recentList, "recent", 1);
group(groups, "repo:recent", tx`Recent Branches in ${name}`, recentRows, true);
group(
groups,
"repo:local",
tx`Local Branches in ${name}`,
branchRows(currentRepo, local, "local", 1),
!recentRows.length,
);
group(
groups,
"repo:remote",
tx`Remote Branches in ${name}`,
0,
branchRows(currentRepo, sortRemote(list, favorites), "remote", 1),
false,
);
const tagRows = [...tags]
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
.flatMap((t): RepoRow[] => {
if (showTags) {
const tagRows = sortTags(tags).flatMap((t): RepoRow[] => {
const m = match(t);
return m === false
? []
: [{ type: "tag", key: `tag:${currentRepo.root}:${t}`, repo: currentRepo, tag: t, depth: 1, matches: m }];
});
group(groups, "repo:tags", tx`Tags in ${name}`, 0, tagRows);
group(groups, "repo:tags", tx`Tags in ${name}`, tagRows, false);
}
}
blocks.push(groups);
@@ -283,8 +329,8 @@ export function buildMultiRepoRows({
const list = branches[repo.root] ?? [];
const own = (l: Branch[]) => l.filter((b) => !shown.has(`${b.remote}:${b.name}`));
const rows = [
...branchRows(repo, own(sortLocal(list, favorites)), "local", 0),
...branchRows(repo, own(sortRemote(list, favorites)), "remote", 0),
...branchRows(repo, own(sortLocal(list, favorites)), "local", 0, false),
...branchRows(repo, own(sortRemote(list, favorites)), "remote", 0, false),
];
if (rows.length) blocks.push([{ type: "section", key: `section:${repo.root}`, label: repo.name }, ...rows]);
}
@@ -299,10 +345,3 @@ export function buildMultiRepoRows({
}
return rows;
}
/** Toggles a group / folder key. */
export function toggleExpanded(expanded: ReadonlySet<string>, key: string): Set<string> {
const next = new Set(expanded);
if (!next.delete(key)) next.add(key);
return next;
}
+5 -4
View File
@@ -8,11 +8,13 @@ import { create } from "zustand";
import type { ApplyOutcome, FileStatus, ShelvedChangeList } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import { Checkbox, Dialog, Icon, IconButton, Input, Select, Tree, fileIconName, showContextMenu, T } from "@/ui";
import { notifications, toolWindowService } from "@/workbench/contributions";
import { notifications } from "@/workbench/contributions";
import { groupOf } from "./changes";
import { activeList, listOf, moveFiles, relOf, updateChangelists, useChangelists } from "./changelists";
import { useCommit } from "./commitStore";
import { showLocalChanges } from "./commitUi";
import { confirmDialog, openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName, effectiveKind, fileCount, formatTime, relTo, stripSlash } from "./model";
import { errorMessage, logConsole, runOp, saveAll } from "./ops";
@@ -35,8 +37,7 @@ export async function loadShelves(): Promise<ShelvedChangeList[]> {
}
export function showShelf() {
useCommit.setState({ tab: "shelf" });
toolWindowService().show("commit", true);
showLocalChanges("shelf");
void loadShelves().catch(() => undefined);
}
@@ -195,7 +196,7 @@ export function unshelve(entry: ShelvedChangeList) {
function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose: () => void }) {
const lists = useChangelists((s) => s.state);
const [target, setTarget] = useState(lists.active);
const [remove, setRemove] = useState(true);
const [remove, setRemove] = useState(() => workflowSettings().vcs.shelf.removeApplied);
const root = useVcs((s) => s.root) ?? "";
const run = async () => {
onClose();
+5
View File
@@ -19,6 +19,9 @@ describe("IDEA keymap parity", () => {
["code.quickDocumentation", ["F1", "Ctrl+J"], ["Ctrl+Q"]],
// Git.Branches: the macOS keymaps remove ⇧⌘` (it cycles windows there).
["vcs.branches", [], ["Ctrl+Shift+`"]],
// Git.CreateNewBranch shares Inline's shortcut.
["vcs.newBranch", ["Meta+Alt+N"], ["Ctrl+Alt+N"]],
["refactor.inline", ["Meta+Alt+N"], ["Ctrl+Alt+N"]],
["debug.resume", ["Meta+Alt+R", "F9"], ["F9"]],
["code.quickDefinition", ["Alt+Space", "Meta+Y"], ["Ctrl+Shift+I"]],
["editor.copyPath", ["Meta+Shift+C"], ["Ctrl+Shift+C"]],
@@ -76,5 +79,7 @@ describe("IDEA keymap parity", () => {
order(km, "debug.setValue", "vcs.changelist.edit");
order(km, "find.replaceInFile", "find.rerun");
order(km, "find.inFile", "vcs.log.goToRef");
// Inline (text editor only) wins over New Branch.
order(km, "vcs.newBranch", "refactor.inline");
});
});
+3
View File
@@ -140,6 +140,9 @@ export const intellijMacos: Record<string, string[]> = {
"refactor.extractConstant": ["Meta+Alt+C"],
"refactor.extractField": ["Meta+Alt+F"],
"refactor.extractParameter": ["Meta+Alt+P"],
// Git.CreateNewBranch shares Inline's key in IDEA. Listed first, so Inline wins in a text
// editor; New Branch runs where Inline is disabled (tool windows) and in the Branches popup.
"vcs.newBranch": ["Meta+Alt+N"],
"refactor.inline": ["Meta+Alt+N"],
"refactor.move": ["F6"],
"refactor.safeDelete": ["Meta+Delete"],
+2
View File
@@ -141,6 +141,8 @@ export const intellijWindows: Record<string, string[]> = {
"refactor.extractConstant": ["Ctrl+Alt+C"],
"refactor.extractField": ["Ctrl+Alt+F"],
"refactor.extractParameter": ["Ctrl+Alt+P"],
// Git.CreateNewBranch shares Inline's key (see intellij-macos).
"vcs.newBranch": ["Ctrl+Alt+N"],
"refactor.inline": ["Ctrl+Alt+N"],
"refactor.move": ["F6"],
"refactor.safeDelete": ["Alt+Delete"],
+1
View File
@@ -254,6 +254,7 @@ export const vscodeMacos: Record<string, string[]> = {
"vcs.nextChange": ["Alt+F5"],
"vcs.prevChange": ["Alt+Shift+F5"],
"vcs.branches": [],
"vcs.newBranch": [],
"vcs.annotate": [],
"vcs.localHistory": [],
"localHistory.recentChanges": [],
+9
View File
@@ -314,6 +314,15 @@
"menu.vcs.rebase.abort": "Abort Rebase",
"menu.vcs.rebase.skip": "Skip Commit",
"menu.vcs.merge.abort": "Abort Merge",
"menu.vcs.cherryPick.continue": "Continue Cherry-Pick",
"menu.vcs.cherryPick.abort": "Abort Cherry-Pick",
"menu.vcs.revert.continue": "Continue Revert",
"menu.vcs.revert.abort": "Abort Revert",
"menu.gitUncommittedChanges": "Uncommitted Changes",
"menu.gitCurrentFile": "Current File",
"menu.vcs.addToVcs": "Add to VCS",
"menu.vcs.compareWithBranch": "Compare with Branch or Revision…",
"menu.vcs.showHistory": "Show History",
"menu.build": "Build",
"menu.build.buildProject": "Build Project",
"menu.build.buildModule": "Build Module",
+55 -5
View File
@@ -707,7 +707,6 @@
"Files won't change, differences won't be staged.": "文件不会更改,差异不会被暂存。",
"Filter": "过滤",
"Filter TODO Items": "过滤 TODO 项",
"Filter branches": "过滤分支",
"Filter by Branch": "按分支过滤",
"Filter by Date": "按日期过滤",
"Filter by Path": "按路径过滤",
@@ -750,7 +749,6 @@
"Fold Selection / Remove Region": "折叠所选内容/移除区域",
"Fold by default:": "默认折叠:",
"Fold console lines that contain": "折叠包含以下内容的控制台行",
"Folders containing modified files are coloured in the Project tool window.": "包含已修改文件的文件夹会在“项目”工具窗口中着色。",
"Folding": "折叠",
"Font": "字体",
"Font preview": "字体预览",
@@ -1868,7 +1866,6 @@
"Show code folding outline": "显示代码折叠轮廓",
"Show debug window on breakpoint hit": "命中断点时显示调试窗口",
"Show details": "显示详细信息",
"Show directories with changed descendants": "显示含有已更改子项的目录",
"Show edited only": "仅显示已编辑的",
"Show file extension": "显示文件扩展名",
"Show gutter icons": "显示装订区域图标",
@@ -3571,7 +3568,6 @@
"Java Settings…": "Java 设置…",
"{name} needs JDK {version} to run": "{name} 需要 JDK {version} 才能运行",
"Search for branches, actions, repositories": "搜索分支、操作、仓库",
"Git Settings": "Git 设置",
"Repositories and branches": "仓库和分支",
"Name <email>; empty uses the configured Git user": "姓名 <邮箱>;留空则使用已配置的 Git 用户",
"Amend and Push…": "修正并推送…",
@@ -4489,5 +4485,59 @@
"Automatic (first available)": "自动(第一个可用的)",
"Custom combination": "自定义组合",
"Python language server": "Python 语言服务器",
"Only one of these built-in Python analyzers runs at a time.": "这些内置 Python 分析器同一时间只运行一个。"
"Only one of these built-in Python analyzers runs at a time.": "这些内置 Python 分析器同一时间只运行一个。",
"Configure the settings related to version control used in your project": "配置项目中使用的版本控制相关设置",
"Use non-modal commit interface": "使用非模态提交界面",
"When off, Local Changes and Stash are tabs of the Git tool window and Commit opens a dialog.": "关闭时,本地更改和贮藏作为 Git 工具窗口的标签页显示,提交时打开对话框。",
"Clear initial commit message": "清除初始提交消息",
"Show options before Update": "更新前显示选项",
"Ask for confirmation to drop commits": "删除提交前请求确认",
"Applicable to Git": "适用于 Git",
"Highlight directories that contain modified files in the Project tree": "在项目树中高亮显示包含已修改文件的目录",
"When patch is created": "创建补丁后",
"Warn when committing files larger than": "提交大于以下大小的文件时发出警告",
"Warn when committing files with names that might cause issues on other systems": "提交文件名可能在其他系统上引发问题的文件时发出警告",
"Show Push dialog for Commit and Push": "提交并推送时显示推送对话框",
"Show Push dialog only when committing to protected branches": "仅在提交到受保护分支时显示推送对话框",
"Combine stashes and shelves in one tab": "在同一个标签页中合并显示贮藏和搁置",
"Patch file created successfully. Show it in Finder?": "补丁文件已创建。是否在访达中显示?",
"File names": "文件名",
"Large files": "大文件",
"Some files use reserved names that are not allowed on Windows: '{name}'. If you commit, it will not be possible to check out the Git repository on Windows.": "部分文件使用了 Windows 不允许的保留名称:'{name}'。提交后将无法在 Windows 上检出此 Git 仓库。",
"Some of the file names contain characters that are not allowed on Windows: '{name}'. If you commit, it might not be possible to check out the Git repository on Windows.": "部分文件名包含 Windows 不允许的字符:'{name}'。提交后可能无法在 Windows 上检出此 Git 仓库。",
"You are about to commit a large file into the repository: {name} ({size} MB). It is not recommended to store large files in a Git repository. Consider using Git LFS.": "即将向仓库提交一个大文件:{name}({size} MB)。不建议在 Git 仓库中存储大文件,请考虑使用 Git LFS。",
"You are about to commit {count} large files into the repository with the total size of {size} MB. It is not recommended to store large files in a Git repository. Consider using Git LFS.": "即将向仓库提交 {count} 个大文件,总大小 {size} MB。不建议在 Git 仓库中存储大文件,请考虑使用 Git LFS。",
"Hide Branches": "隐藏分支",
"Show Branches": "显示分支",
"Update Selected": "更新所选",
"Navigate Log to Selected Branch": "在日志中导航到所选分支",
"Branch or tag": "分支或标签",
"Filter branches and tags": "过滤分支和标签",
"Revert Selected Changes": "还原所选更改",
"Show only the first reference": "仅显示第一个引用",
"Show tag names": "显示标签名称",
"Show commit time rather than author time": "显示提交时间而非作者时间",
"Show references on the left": "在左侧显示引用",
"Visible columns": "可见列",
"Visible columns:": "可见列:",
"Get from Revision": "从修订获取",
"Replace '{path}' with its content from the revision? Local changes to it are lost.": "用修订中的内容替换“{path}”?其本地更改将丢失。",
"Replace {length} files with their content from the revision? Local changes to them are lost.": "用修订中的内容替换 {length} 个文件?它们的本地更改将丢失。",
"Get": "获取",
"Got 1 file from revision": "已从修订获取 1 个文件",
"Got {n} files from revision": "已从修订获取 {n} 个文件",
"Nothing to revert": "没有可还原的内容",
"Mark/Unmark As Favorite": "标记/取消标记为收藏",
"Compare Before with Local": "将之前版本与本地比较",
"Group by Directory": "按目录分组",
"Stashes and Shelves": "贮藏和搁置",
"History: {name}": "历史记录: {name}",
"Log: {name}": "日志: {name}",
"Commit Changes": "提交更改",
"Group by Prefix": "按前缀分组",
"Show Recent Branches": "显示最近分支",
"Show Tags": "显示标签",
"Branches Popup Options": "分支弹出窗口选项",
"Pull into '{cur}' Using Rebase": "使用变基拉取到 '{cur}'",
"Pull into '{cur}' Using Merge": "使用合并拉取到 '{cur}'"
}
+9
View File
@@ -314,6 +314,15 @@
"menu.vcs.rebase.abort": "中止变基",
"menu.vcs.rebase.skip": "跳过提交",
"menu.vcs.merge.abort": "中止合并",
"menu.vcs.cherryPick.continue": "继续拣选",
"menu.vcs.cherryPick.abort": "中止拣选",
"menu.vcs.revert.continue": "继续还原",
"menu.vcs.revert.abort": "中止还原",
"menu.gitUncommittedChanges": "未提交的更改",
"menu.gitCurrentFile": "当前文件",
"menu.vcs.addToVcs": "添加到 VCS",
"menu.vcs.compareWithBranch": "与分支或修订比较…",
"menu.vcs.showHistory": "显示历史记录",
"menu.build": "构建",
"menu.build.buildProject": "构建项目",
"menu.build.buildModule": "构建模块",
+6 -1
View File
@@ -8,7 +8,8 @@ describe("validateWorkflow", () => {
const v = validateWorkflow({
vcs: {
historyLimit: -5,
git: { updateMethod: "squash", incomingCheck: "never" },
git: { updateMethod: "squash", incomingCheck: "never", largeFileMb: 0 },
confirmation: { onPatchCreated: "always" },
issueNavigation: [{ pattern: "" }, { pattern: "#(\\d+)", link: "x/$1" }],
},
console: { bufferKb: 1e12, foldPatterns: ["a", 3, ""] },
@@ -18,7 +19,11 @@ describe("validateWorkflow", () => {
expect(v.vcs.historyLimit).toBe(10);
expect(v.vcs.git.updateMethod).toBe("merge");
expect(v.vcs.git.incomingCheck).toBe("never");
expect(v.vcs.git.largeFileMb).toBe(1);
expect(v.vcs.issueNavigation).toEqual([{ pattern: "#(\\d+)", link: "x/$1" }]);
expect(v.vcs.commit.nonModal).toBe(true);
expect(v.vcs.confirmation.onPatchCreated).toBe("ask");
expect(v.vcs.shelf.removeApplied).toBe(false);
expect(v.console.bufferKb).toBe(1_048_576);
expect(v.console.foldPatterns).toEqual(["a"]);
expect(v.terminal).toMatchObject({ cursorShape: "bar", env: { A: "1" }, tabName: "Local" });
+46 -2
View File
@@ -9,6 +9,7 @@ import { DEFAULT_REMOTE, validateRemote, type RemoteSettings } from "./remoteSet
export type UpdateMethod = "merge" | "rebase";
export type IncomingCheck = "auto" | "always" | "never";
export type VcsConfirm = "ask" | "silently" | "never";
export type PatchCreated = "ask" | "no" | "reveal";
export type CursorShape = "block" | "underline" | "bar";
export interface IssueLinkValid {
@@ -25,9 +26,17 @@ export interface GitSettingsValid {
incomingCheck: IncomingCheck;
protectedBranches: string;
signOff: boolean;
warnLargeFiles: boolean;
largeFileMb: number;
warnBadFileNames: boolean;
showPushDialog: boolean;
pushDialogOnlyProtected: boolean;
combineStashAndShelf: boolean;
}
export interface CommitSettingsValid {
nonModal: boolean;
clearInitialMessage: boolean;
analyzeCode: boolean;
checkTodo: boolean;
reformat: boolean;
@@ -48,7 +57,14 @@ export interface VcsSettingsValid {
historyLimit: number;
git: GitSettingsValid;
commit: CommitSettingsValid;
confirmation: { onCreate: VcsConfirm; onDelete: VcsConfirm };
confirmation: {
onCreate: VcsConfirm;
onDelete: VcsConfirm;
showUpdateOptions: boolean;
confirmDropCommits: boolean;
onPatchCreated: PatchCreated;
};
shelf: { removeApplied: boolean };
issueNavigation: IssueLinkValid[];
}
@@ -135,6 +151,7 @@ export interface WorkflowSettings extends RemoteSettings {
export const WORKFLOW_LIMITS = {
historyLimit: { min: 10, max: 100_000 },
messageLimit: { min: 20, max: 500 },
largeFileMb: { min: 1, max: 100_000 },
bufferKb: { min: 16, max: 1_048_576 },
tooltipDelay: { min: 0, max: 10_000 },
terminationTimeoutMs: { min: 0, max: 600_000 },
@@ -171,8 +188,16 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
incomingCheck: "auto",
protectedBranches: "master; main",
signOff: false,
warnLargeFiles: true,
largeFileMb: 50,
warnBadFileNames: true,
showPushDialog: true,
pushDialogOnlyProtected: false,
combineStashAndShelf: false,
},
commit: {
nonModal: true,
clearInitialMessage: false,
analyzeCode: true,
checkTodo: true,
reformat: false,
@@ -186,7 +211,14 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
showRightMargin: true,
wrapAtMargin: false,
},
confirmation: { onCreate: "ask", onDelete: "ask" },
confirmation: {
onCreate: "ask",
onDelete: "ask",
showUpdateOptions: true,
confirmDropCommits: true,
onPatchCreated: "ask",
},
shelf: { removeApplied: false },
issueNavigation: [],
},
console: {
@@ -279,8 +311,16 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
incomingCheck: oneOf(g.incomingCheck, ["auto", "always", "never"] as const, d.git.incomingCheck),
protectedBranches: text(g.protectedBranches, d.git.protectedBranches),
signOff: bool(g.signOff, d.git.signOff),
warnLargeFiles: bool(g.warnLargeFiles, d.git.warnLargeFiles),
largeFileMb: num(g.largeFileMb, d.git.largeFileMb, L.largeFileMb, true),
warnBadFileNames: bool(g.warnBadFileNames, d.git.warnBadFileNames),
showPushDialog: bool(g.showPushDialog, d.git.showPushDialog),
pushDialogOnlyProtected: bool(g.pushDialogOnlyProtected, d.git.pushDialogOnlyProtected),
combineStashAndShelf: bool(g.combineStashAndShelf, d.git.combineStashAndShelf),
},
commit: {
nonModal: bool(c.nonModal, d.commit.nonModal),
clearInitialMessage: bool(c.clearInitialMessage, d.commit.clearInitialMessage),
analyzeCode: bool(c.analyzeCode, d.commit.analyzeCode),
checkTodo: bool(c.checkTodo, d.commit.checkTodo),
reformat: bool(c.reformat, d.commit.reformat),
@@ -297,7 +337,11 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
confirmation: {
onCreate: oneOf(conf.onCreate, confirm, d.confirmation.onCreate),
onDelete: oneOf(conf.onDelete, confirm, d.confirmation.onDelete),
showUpdateOptions: bool(conf.showUpdateOptions, d.confirmation.showUpdateOptions),
confirmDropCommits: bool(conf.confirmDropCommits, d.confirmation.confirmDropCommits),
onPatchCreated: oneOf(conf.onPatchCreated, ["ask", "no", "reveal"] as const, d.confirmation.onPatchCreated),
},
shelf: { removeApplied: bool(obj(v.shelf).removeApplied, d.shelf.removeApplied) },
issueNavigation: Array.isArray(v.issueNavigation)
? v.issueNavigation
.map((x) => obj(x))
+2 -1
View File
@@ -610,7 +610,8 @@ export interface SettingsPageContribution {
order?: number;
/** Extra words matched by the settings search. */
keywords?: string[];
component: ComponentType;
/** Omitted: the page lists links to its sub-pages. */
component?: ComponentType;
/** The page edits settings that a project can override (Global / This project switch). */
projectScope?: boolean;
}