From eb67595e72b6fb3657df78d44c0f1a3e59e71c52 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:49:43 +0800 Subject: [PATCH] feat(vcs): Add a modal commit mode and fill in Git log, branch popup and settings (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- crates/ide-core/src/workflow_settings.rs | 50 +- src-tauri/src/menu.rs | 116 +++- src/bindings.ts | 33 +- src/features/index.ts | 3 +- .../settings/dialog/SettingsDialog.tsx | 1 + src/features/settings/pages/registry.tsx | 2 +- src/features/settings/pages/vcs.tsx | 153 ++++- src/features/vcs/BranchWidget.module.css | 12 + src/features/vcs/BranchWidget.tsx | 272 +++++--- src/features/vcs/CommitDialog.module.css | 20 + src/features/vcs/CommitDialog.tsx | 96 +++ src/features/vcs/CommitOptions.tsx | 45 +- src/features/vcs/CommitView.tsx | 614 ++++++++++-------- src/features/vcs/GitLog.module.css | 30 +- src/features/vcs/GitLog.tsx | 409 ++++++++---- src/features/vcs/LogSettingsPage.module.css | 22 + src/features/vcs/LogSettingsPage.tsx | 39 ++ src/features/vcs/__tests__/branches.test.ts | 149 +++-- .../vcs/__tests__/commitChecks.test.ts | 20 + src/features/vcs/__tests__/commitMode.test.ts | 129 ++++ .../vcs/__tests__/logBranches.test.tsx | 2 +- src/features/vcs/__tests__/logPanes.test.tsx | 194 ++++++ .../vcs/__tests__/repoBranches.test.ts | 52 +- src/features/vcs/actions.tsx | 53 +- src/features/vcs/branchMenu.ts | 20 +- src/features/vcs/branches.ts | 280 ++++++-- src/features/vcs/changeOps.tsx | 6 +- src/features/vcs/commitChecks.tsx | 76 ++- src/features/vcs/commitStore.ts | 33 +- src/features/vcs/commitUi.ts | 62 ++ src/features/vcs/confirmations.tsx | 17 +- src/features/vcs/gitMenuDialogs.tsx | 117 ++-- src/features/vcs/gitTabs.ts | 67 ++ src/features/vcs/historyActions.tsx | 18 +- src/features/vcs/index.ts | 69 +- src/features/vcs/logChanges.ts | 215 ++++++ src/features/vcs/logOptions.ts | 12 + src/features/vcs/logPaneActions.ts | 211 ++++++ src/features/vcs/logStore.ts | 110 +++- src/features/vcs/patches.tsx | 25 +- src/features/vcs/rebase.tsx | 45 +- src/features/vcs/remoteUi.tsx | 26 +- src/features/vcs/repoBranches.ts | 215 +++--- src/features/vcs/shelf.tsx | 9 +- src/platform/keymaps/ideaParity.test.ts | 5 + src/platform/keymaps/intellij-macos.ts | 3 + src/platform/keymaps/intellij-windows.ts | 2 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/en.json | 9 + src/platform/locales/text/zh.json | 60 +- src/platform/locales/zh.json | 9 + src/platform/workflowSettings.test.ts | 7 +- src/platform/workflowSettings.ts | 48 +- src/workbench/contributions.ts | 3 +- 54 files changed, 3425 insertions(+), 871 deletions(-) create mode 100644 src/features/vcs/CommitDialog.module.css create mode 100644 src/features/vcs/CommitDialog.tsx create mode 100644 src/features/vcs/LogSettingsPage.module.css create mode 100644 src/features/vcs/LogSettingsPage.tsx create mode 100644 src/features/vcs/__tests__/commitChecks.test.ts create mode 100644 src/features/vcs/__tests__/commitMode.test.ts create mode 100644 src/features/vcs/__tests__/logPanes.test.tsx create mode 100644 src/features/vcs/commitUi.ts create mode 100644 src/features/vcs/gitTabs.ts create mode 100644 src/features/vcs/logChanges.ts create mode 100644 src/features/vcs/logPaneActions.ts diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 03053995c..66440cec1 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -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, } @@ -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 { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 4b1fc5f6d..16176d772 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -568,8 +568,14 @@ pub fn spec(os: Os) -> Vec { 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 { 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 { 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 { 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 { diff --git a/src/bindings.ts b/src/bindings.ts index 6349469d0..adf9b052b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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[], }; diff --git a/src/features/index.ts b/src/features/index.ts index 07c46bde0..38612359f 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -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) { diff --git a/src/features/settings/dialog/SettingsDialog.tsx b/src/features/settings/dialog/SettingsDialog.tsx index a7da54d08..99dc66d56 100644 --- a/src/features/settings/dialog/SettingsDialog.tsx +++ b/src/features/settings/dialog/SettingsDialog.tsx @@ -64,6 +64,7 @@ const GROUP_DESCRIPTIONS: Record = { "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 }) { diff --git a/src/features/settings/pages/registry.tsx b/src/features/settings/pages/registry.tsx index 3e3eb1bdc..650c69caa 100644 --- a/src/features/settings/pages/registry.tsx +++ b/src/features/settings/pages/registry.tsx @@ -49,7 +49,7 @@ export function optionLabels(id: string): string[] { } function toContribution(spec: PageSpec): SettingsPageContribution { - const component = spec.component ?? (() => ); + const component = spec.component ?? (spec.sections ? () => : undefined); return { id: spec.id, title: spec.title, group: spec.group, order: spec.order, keywords: spec.keywords, component }; } diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index b7657ea46..9bb511d1f 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -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 ( + + ); +} + +function PushDialogRows() { + const [show] = useSetting("vcs.git.showPushDialog"); + return ( + <> + + + + + + ); +} + +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", + }, + ], + }, ], }, { diff --git a/src/features/vcs/BranchWidget.module.css b/src/features/vcs/BranchWidget.module.css index 0a2825c2e..a274be507 100644 --- a/src/features/vcs/BranchWidget.module.css +++ b/src/features/vcs/BranchWidget.module.css @@ -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; diff --git a/src/features/vcs/BranchWidget.tsx b/src/features/vcs/BranchWidget.tsx index 95d33ba4a..a2592de8c 100644 --- a/src/features/vcs/BranchWidget.tsx +++ b/src/features/vcs/BranchWidget.tsx @@ -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() { {branch.operation} )} {/* Like IDEA's widget: icons only; counts are in the tooltip. */} - {branch.behind > 0 && } - {branch.ahead > 0 && } + {branch.behind > 0 && ( + + )} + {branch.ahead > 0 && ( + + )} {open && 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)[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(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; multi: ReadonlyMap }>(() => ({ + 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(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 ( + <> + setAnchor(anchor ? null : e.currentTarget)} + /> + {anchor && ( + 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; 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 ( 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 && } + {!root && 0} />}
r.key} isSection={isSection} - renderSection={(r) => {r.type === "section" ? tx(r.label) : ""}} + sectionHeightOf={(r) => (r.type === "section" && !r.label ? SEPARATOR_HEIGHT : undefined)} + renderSection={(r) => + r.type === "section" && r.label ? ( + {tx(r.label)} + ) : ( + + ) + } 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={{loaded ? tx("No matching branches") : tx("Loading…")}} - renderItem={(r) => } + renderItem={(r) => (r.type === "group" ? : )} />
{menuFor && setMenuFor(null)} />} @@ -383,26 +500,6 @@ function SubMenu({ ); } -const EXPANDED_KEY = "openidea.vcs.branchesPopup.expanded"; - -function loadExpanded(): Set { - 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) { - 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([]); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); - const [expanded, setExpanded] = useState>(loadExpanded); + const [expanded, setOpen] = useExpanded("multi"); + const options = usePopupOptions(); const [all, setAll] = useState>({}); 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} /> - { - closeAll(); - void executeAction("file.settings", "vcs.git"); - }} - /> + 0} />
); case "group": - return ( - - - {row.depth > 0 && } - {tx(row.label)} - - ); + return ; case "common": return ( @@ -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 ( + + + {row.depth > 0 && } + {row.depth > 0 ? row.label : tx(row.label)} + + ); +} + function BranchRowView({ row }: { row: BranchRow | Extract }) { 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 @@ -776,10 +881,17 @@ function BranchRowView({ row }: { row: BranchRow | Extractcurrent )} - {(b.ahead > 0 || b.behind > 0) && ( - - {b.ahead > 0 && `↑${b.ahead}`} - {b.behind > 0 && ` ↓${b.behind}`} + {/* Like IDEA: incoming (↙) before outgoing (↗). */} + {b.behind > 0 && ( + + + {b.behind} + + )} + {b.ahead > 0 && ( + + + {b.ahead} )} {!b.remote && b.upstream && {b.upstream}} diff --git a/src/features/vcs/CommitDialog.module.css b/src/features/vcs/CommitDialog.module.css new file mode 100644 index 000000000..241a20270 --- /dev/null +++ b/src/features/vcs/CommitDialog.module.css @@ -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); +} diff --git a/src/features/vcs/CommitDialog.tsx b/src/features/vcs/CommitDialog.tsx new file mode 100644 index 000000000..8e68e9e4e --- /dev/null +++ b/src/features/vcs/CommitDialog.tsx @@ -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) => ( + { + 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 ( + void runner.run(false), + }, + { + label: commitAndPushLabel(amend), + role: "extra", + disabled: !runner.canCommit || !!remoteBusy, + title: "⌥⌘↩", + onClick: () => void runner.run(true), + }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+ + + + + + + + +
+ +
+
+
+
+ + +
+ +
+
+
+
+
+ ); +} diff --git a/src/features/vcs/CommitOptions.tsx b/src/features/vcs/CommitOptions.tsx index 01da41d37..255469fbb 100644 --- a/src/features/vcs/CommitOptions.tsx +++ b/src/features/vcs/CommitOptions.tsx @@ -43,13 +43,8 @@ function Configure() { ); } -export function CommitOptionsPopup({ - anchor, - onClose, -}: { - anchor: RefObject; - 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({
); return ( - - {/* IDEA opens the options without focusing the Author field. */} -
+ <>
+ + ); +} + +export function CommitOptionsPopup({ + anchor, + onClose, +}: { + anchor: RefObject; + onClose: () => void; +}) { + return ( + + {/* IDEA opens the options without focusing the Author field. */} +
+ ); } diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 3b5545d84..b30ca92d8 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -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 ? {t.count} : 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 ( 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 ? {shelfCount} : undefined, - }, - ] - : []), - ...(available - ? [ - { - id: "stashes", - label: "Stash", - trailing: stashCount ? {stashCount} : 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 ( Open a Git repository to commit changes. ); - return ( -
- {tab === "changes" ? : tab === "shelf" ? : } -
- ); + return
{tab === "changes" ? : }
; +} + +/** 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 ( + + + + + + + + + + ); + return tab === "shelf" ? : ; } // --------------------------------------------------------------------------- @@ -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 ( + + + + + + + + + + ); +} + +/** + * 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(null); // IDEA starts with Unversioned Files collapsed. const [collapsed, setCollapsed] = useState>(() => 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 ( - - -
-
- void refreshNow()} /> - - - - viewRef.current && - showContextMenu(bottomLeft(viewRef.current), [ - { kind: "header", label: "Group By" }, - { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, - ]) - } - /> - - - setCollapsed(new Set())} /> - - setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key))) - } - /> -
- -
{ - 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 }); - } - } - }} - > - - 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={ - - No local changes - - } - renderNode={(n, st) => { - const agg = aggregateCheck(n.files, checks); - const label = ( - - {nodeText(n)} +
+
+ void refreshNow()} /> + {mode === "local" && } + + {mode !== "dialog" && } + + viewRef.current && + showContextMenu(bottomLeft(viewRef.current), [ + { kind: "header", label: "Group By" }, + { label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) }, + ]) + } + /> + + {mode !== "dialog" && ( + + )} + setCollapsed(new Set())} /> + setCollapsed(new Set([...byKey.values()].filter((n) => n.type !== "file").map((n) => n.key)))} + /> +
+ {mode !== "dialog" && } +
{ + 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 }); + } + } + }} + > + + 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={ + + No local changes + + } + renderNode={(n, st) => { + const agg = aggregateCheck(n.files, checks); + const label = ( + + {nodeText(n)} + + ); + // 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) => { + 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 ( + + {checkboxes && ( + toggle(selected.has(n.key) && selected.size > 1 ? selectedNodes() : [n])} + /> + )} + {n.type === "group" ? null : ( + + )} + {label} + {n.type === "group" && {fileCount(n.files.length)}} + {n.type === "file" && n.files[0] && isPartiallyStaged(n.files[0]) && ( + + partial - ); - // 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) => { - 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 ( - - toggle(selected.has(n.key) && selected.size > 1 ? selectedNodes() : [n])} - /> - {n.type === "group" ? null : ( - - )} - {label} - {n.type === "group" && {fileCount(n.files.length)}} - {n.type === "file" && n.files[0] && isPartiallyStaged(n.files[0]) && ( - - partial - - )} - {n.detail && {n.detail}} - - ); - }} - /> -
-
- - - - - - + )} + {n.detail && {n.detail}} +
+ ); + }} + /> +
+
); } @@ -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; +} + +/** + * 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(null); const historyRef = useRef(null); - const optionsRef = useRef(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 ( -
+ <>
void setAmend(v)} label="Amend" /> 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 }) {
)} + + ); +} + +/** 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(null); + const [optionsOpen, setOptionsOpen] = useState(false); + const runner = useCommitRunner(included); + const { canCommit } = runner; + + return ( +
+
- @@ -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); diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index a1d11a165..010b36a02 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -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; +} diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 6e364171b..5770b1d82 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -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 ( 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() {
- {tab === "log" ? : } + {shown === "changes" ? ( + + ) : shown === "shelf" || shown === "stashes" ? ( + + ) : shown === "console" ? ( + + ) : shown === "worktrees" ? ( + + ) : ( + // Each log tab has its own view (its filters and commits are swapped into the store). + + )}
); } @@ -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(null); // On mount, when the window switches to another project and when another of its @@ -142,11 +185,21 @@ function LogView() { }, [version]); return ( - - - - - + // Its own layout without the Branches pane, so each keeps its sizes. + + {showBranches && ( + <> + + + + + + )}
@@ -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([]); 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) => { 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 (
-
- + + + + + + - void newBranch()} /> void loadBranches()} />
- - 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() ? ( - - No matching branches - - ) : undefined - } - aria-label="Branches" - className={styles.branchTree} - /> +
+
+ +
+ + 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() ? ( + + No matching branches + + ) : undefined + } + aria-label="Branches" + className={styles.branchTree} + /> +
); } @@ -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(null); @@ -432,6 +548,9 @@ function FilterBar() { return (
+ {!showBranches && ( + + )} 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>(() => 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[] }) {
); - 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) => + menuBelow(e, [ + { kind: "header", label: "Group By" }, + { kind: "action", action: "vcs.log.changes.groupByDirectory" }, + ]); + + const onFileMenu = (node: FileTreeNode | null, sel: ReadonlySet, 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[] }) {
+ + + {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))} /> setExpanded(new Set())} />
@@ -1013,7 +1166,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
)} {files && ( - > + > 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) => } - onActivate={(n) => n.file && open(n.file)} + selected={selectedKeys} + onSelectionChange={(sel) => setSelectedKeys(sel)} + renderNode={(n) => } + 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 }) { +/** 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; flat?: boolean }) { if (!node.file) return ( @@ -1100,6 +1282,7 @@ function ChangedFileRow({ node }: { node: FileTreeNode }) { {node.name} + {flat && dirName(f.path) && {dirName(f.path)}} {f.oldPath && {tx`renamed from ${baseName(f.oldPath)}`}} ); diff --git a/src/features/vcs/LogSettingsPage.module.css b/src/features/vcs/LogSettingsPage.module.css new file mode 100644 index 000000000..ba2c35aef --- /dev/null +++ b/src/features/vcs/LogSettingsPage.module.css @@ -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; +} diff --git a/src/features/vcs/LogSettingsPage.tsx b/src/features/vcs/LogSettingsPage.tsx new file mode 100644 index 000000000..05823ec57 --- /dev/null +++ b/src/features/vcs/LogSettingsPage.tsx @@ -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 }) => ( + setLogOptions({ [key]: v })} label={label} /> + ); + return ( +
+ {OPTIONS.map(flag)} +
+
+ Visible columns: +
+
{COLUMNS.map(flag)}
+
+
+ ); +} diff --git a/src/features/vcs/__tests__/branches.test.ts b/src/features/vcs/__tests__/branches.test.ts index a47acf426..9e83092fd 100644 --- a/src/features/vcs/__tests__/branches.test.ts +++ b/src/features/vcs/__tests__/branches.test.ts @@ -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([]); }); }); diff --git a/src/features/vcs/__tests__/commitChecks.test.ts b/src/features/vcs/__tests__/commitChecks.test.ts new file mode 100644 index 000000000..3a3e958f9 --- /dev/null +++ b/src/features/vcs/__tests__/commitChecks.test.ts @@ -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(); + }); +}); diff --git a/src/features/vcs/__tests__/commitMode.test.ts b/src/features/vcs/__tests__/commitMode.test.ts new file mode 100644 index 000000000..103842eaa --- /dev/null +++ b/src/features/vcs/__tests__/commitMode.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/features/vcs/__tests__/logBranches.test.tsx b/src/features/vcs/__tests__/logBranches.test.tsx index 676190c4a..95ea1ac05 100644 --- a/src/features/vcs/__tests__/logBranches.test.tsx +++ b/src/features/vcs/__tests__/logBranches.test.tsx @@ -134,7 +134,7 @@ describe("Git Log branches pane", () => { it("leaves Cmd/Ctrl+C in the branch search field alone", async () => { render(); 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(); }); diff --git a/src/features/vcs/__tests__/logPanes.test.tsx b/src/features/vcs/__tests__/logPanes.test.tsx new file mode 100644 index 000000000..e61e700ab --- /dev/null +++ b/src/features/vcs/__tests__/logPanes.test.tsx @@ -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(); + 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 => ({ + 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: "a@example.com", + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/src/features/vcs/__tests__/repoBranches.test.ts b/src/features/vcs/__tests__/repoBranches.test.ts index 23b61b769..f38f2d756 100644 --- a/src/features/vcs/__tests__/repoBranches.test.ts +++ b/src/features/vcs/__tests__/repoBranches.test.ts @@ -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() }; 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(), 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", diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 19571405a..b9218b189 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -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(); }, }, diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index e665b9d81..cdf4cc9c1 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -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 ` 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" }, diff --git a/src/features/vcs/branches.ts b/src/features/vcs/branches.ts index bc839f6d2..ca53f03f3 100644 --- a/src/features/vcs/branches.ts +++ b/src/features/vcs/branches.ts @@ -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; + /** `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, key: string, open: boolean): Map { + return new Map(expanded).set(key, open); +} + +export interface PrefixItem { + 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 = + | { 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( + items: readonly PrefixItem[], + opts: { + keyPrefix: string; + depth: number; + grouped: boolean; + isOpen: (key: string, path: string, items: readonly PrefixItem[]) => boolean; + }, +): PrefixRow[] { + 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; + rest: string; + offset: number; + } + const level = (list: readonly Entry[], path: string, depth: number): PrefixRow[] => { + const folders = new Map(); + 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 => ({ + 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; + +/** 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(), 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[] => { 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, 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 › . Names with `/` are + * Git Log Branches pane: HEAD, Local, 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(); 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 }] : []; }); diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index 7365f53b4..3e7d95ac5 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -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 { 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; } diff --git a/src/features/vcs/commitChecks.tsx b/src/features/vcs/commitChecks.tsx index 8f3ebc0ca..9c7437745 100644 --- a/src/features/vcs/commitChecks.tsx +++ b/src/features/vcs/commitChecks.tsx @@ -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> { + const byDir = new Map>(); + 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, diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index d73b6e873..a0d3d682c 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -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, diff --git a/src/features/vcs/commitUi.ts b/src/features/vcs/commitUi.ts new file mode 100644 index 000000000..978ae133c --- /dev/null +++ b/src/features/vcs/commitUi.ts @@ -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, + 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`, + ); +} diff --git a/src/features/vcs/confirmations.tsx b/src/features/vcs/confirmations.tsx index aad9ec46c..e71a546c6 100644 --- a/src/features/vcs/confirmations.tsx +++ b/src/features/vcs/confirmations.tsx @@ -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; } diff --git a/src/features/vcs/gitMenuDialogs.tsx b/src/features/vcs/gitMenuDialogs.tsx index 943aac3e8..e105be01c 100644 --- a/src/features/vcs/gitMenuDialogs.tsx +++ b/src/features/vcs/gitMenuDialogs.tsx @@ -543,6 +543,26 @@ export function showNewWorktree(onCreated?: () => void) { } function WorktreesDialog({ onClose }: { onClose: () => void }) { + return ( + + + + ); +} + +/** + * 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([]); const [selected, setSelected] = useState(null); const [error, setError] = useState(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 ( - -
-
- showNewWorktree(() => void reload())} - /> - void remove()} - /> - current && void executeAction("file.openProjectPath", current.path)} - /> -
-
- {list.map((w) => ( - - ))} -
- {error &&
{error}
} +
+
+ void reload()} /> + showNewWorktree(() => void reload())} + /> + void remove()} + /> + current && void executeAction("file.openProjectPath", current.path)} + />
-
+
+ {list.map((w) => ( + + ))} +
+ {error &&
{error}
} +
); } diff --git a/src/features/vcs/gitTabs.ts b/src/features/vcs/gitTabs.ts new file mode 100644 index 000000000..4e5de84c6 --- /dev/null +++ b/src/features/vcs/gitTabs.ts @@ -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"; +} diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 2ffcb2e66..8915fa157 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -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(); }, }, diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 4764885ae..801985175 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -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", diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts new file mode 100644 index 000000000..aeed6a650 --- /dev/null +++ b/src/features/vcs/logChanges.ts @@ -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(files: readonly F[], relPath: (f: F) => string): FileTreeNode[] { + 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(nodes: readonly FileTreeNode[], keys?: ReadonlySet): F[] { + const out: F[] = []; + const walk = (list: readonly FileTreeNode[], 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 { + 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(() => ({ 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"); +} diff --git a/src/features/vcs/logOptions.ts b/src/features/vcs/logOptions.ts index b8704265d..bb56ad2a1 100644 --- a/src/features/vcs/logOptions.ts +++ b/src/features/vcs/logOptions.ts @@ -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(() => 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"; diff --git a/src/features/vcs/logPaneActions.ts b/src/features/vcs/logPaneActions.ts new file mode 100644 index 000000000..900dbb8c9 --- /dev/null +++ b/src/features/vcs/logPaneActions.ts @@ -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 }), + }, + ]); +} diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index 0da03ac31..a6656cc8f 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -46,7 +46,12 @@ interface LogState { selection: string[]; /** Commits of HEAD not on its upstream (`@{u}..HEAD`); null without an upstream. */ outgoing: Set | 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 => ({ selection: [], outgoing: null, tab: "log", + logTabs: [], + shownLog: "log", revealTick: 0, user: null, }); @@ -128,6 +146,82 @@ export const useGitLog = create(() => ({ ...initialLog(), generation: let loadPromise: Promise | 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(); + +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 { 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(); diff --git a/src/features/vcs/patches.tsx b/src/features/vcs/patches.tsx index ea3d93ba9..182d025cd 100644 --- a/src/features/vcs/patches.tsx +++ b/src/features/vcs/patches.tsx @@ -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 { } } +/** 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 ( 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 { + 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 { // 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`. */ diff --git a/src/features/vcs/remoteUi.tsx b/src/features/vcs/remoteUi.tsx index f691a022c..412bb799e 100644 --- a/src/features/vcs/remoteUi.tsx +++ b/src/features/vcs/remoteUi.tsx @@ -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) => ); } +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 ( { repos: readonly RepoEntry[]; /** Root → its branches (missing while loading). */ branches: Readonly>; query: string; actions: readonly PopupAction[]; - /** Expanded group / folder keys (`common:local`, `repo:remote`, `common:folder:origin`…). */ - expanded: ReadonlySet; + /** Groups / folders the user expanded or collapsed (`common:local`, `repo:remote`, `repo:folder:local:feat`…). */ + expanded: ReadonlyMap; 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[] => { 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[] => { + 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(); - 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, key: string): Set { - const next = new Set(expanded); - if (!next.delete(key)) next.add(key); - return next; -} diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index b7fa3a676..67b43ad3d 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -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 { } 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(); diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index 51f9600f6..fd36aeb80 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -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"); }); }); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 1cacab62a..2477c0cdd 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -140,6 +140,9 @@ export const intellijMacos: Record = { "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"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 1dfc79226..4afa0c4d4 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -141,6 +141,8 @@ export const intellijWindows: Record = { "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"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 3fdd15305..e396d9fe4 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -254,6 +254,7 @@ export const vscodeMacos: Record = { "vcs.nextChange": ["Alt+F5"], "vcs.prevChange": ["Alt+Shift+F5"], "vcs.branches": [], + "vcs.newBranch": [], "vcs.annotate": [], "vcs.localHistory": [], "localHistory.recentChanges": [], diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index ba42766c4..7471f4eaa 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 06a0e36df..e69a2ec3b 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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 ; 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}'" } diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 1afaa7856..8a1625ce2 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -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": "构建模块", diff --git a/src/platform/workflowSettings.test.ts b/src/platform/workflowSettings.test.ts index 6f5518191..1721e2ca7 100644 --- a/src/platform/workflowSettings.test.ts +++ b/src/platform/workflowSettings.test.ts @@ -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" }); diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 08cac9995..2424ce995 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -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)) diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 713796a4f..509f6715f 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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; }