дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(vcs): Add a modal commit mode and fill in Git log, branch popup and settings (#144)
- Settings › Commit › "Use non-modal commit interface": when off, Local Changes, Shelf and Stash become tabs of the Git tool window and ⌘K opens a Commit dialog; switches live. - Git tool window: Worktrees tab; Show History and Show Git Log for a branch open their own closable tabs instead of changing the Log filters. - Log: toolbar and context menu for a commit's changed files, vertical toolbar, Tags node and hiding for the branches pane, Collapse / Expand Linear Branches as separate actions, and a Version Control › Log page. - Branch popup: collapsible groups, branches grouped by prefix, an options menu behind the gear, incoming / outgoing icons and "Pull into … Using Rebase / Merge"; New Branch on ⌥⌘N outside the editor. - Git main menu regrouped with Uncommitted Changes and Current File submenus; rebase / merge controls enabled only while one is in progress. - Settings: Version Control lists its pages; new options for clearing the initial commit message, showing Update options, confirming dropped commits, revealing created patches, large-file and file-name warnings, the Push dialog after Commit and Push, combined stashes and shelves, and removing applied files from the shelf.
Цей коміт міститься в:
1 джерело
66c36a7361
коміт
eb67595e72
54 files changed
+3425
-871
No files matched your search
@@ -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
@@ -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
@@ -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[],
|
||||
};
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }] : [];
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}'"
|
||||
}
|
||||
@@ -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": "构建模块",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача