diff --git a/.yarn/patches/@react-stately-layout-npm-3.4.4-75ff8d9e5d.patch b/.yarn/patches/@react-stately-layout-npm-3.4.4-75ff8d9e5d.patch
deleted file mode 100644
index df600c06..00000000
--- a/.yarn/patches/@react-stately-layout-npm-3.4.4-75ff8d9e5d.patch
+++ /dev/null
@@ -1,14 +0,0 @@
-diff --git a/dist/module.js b/dist/module.js
-index b3e3992e52cb0f7c533ccef8ba3411d2e474b34b..45fd4651e801b0293ec9e85437d6a59271d165d0 100644
---- a/dist/module.js
-+++ b/dist/module.js
-@@ -51,7 +51,8 @@ class $279c20a4a0c8d128$export$cacbb3924155d68e extends $dqiJs$Layout {
- buildCollection() {
- let y = this.padding;
- let nodes = [];
-- for (let node of this.collection){
-+ const visibleNodes = [...this.collection.getKeys()].map(key => this.collection.getItem(key));
-+ for (let node of visibleNodes){
- let layoutNode = this.buildChild(node, 0, y);
- y = layoutNode.layoutInfo.rect.maxY;
- nodes.push(layoutNode);
diff --git a/README.md b/README.md
index e3852b3e..5883c1e5 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ https://user-images.githubusercontent.com/3150694/232305636-e8b63780-4777-4d27-8
Virtualization |
- ❌ |
+ ✅ |
Sections (with title) |
diff --git a/package.json b/package.json
index 83fd43f5..1834edef 100644
--- a/package.json
+++ b/package.json
@@ -40,9 +40,8 @@
"packageManager": "yarn@3.2.1",
"resolutions": {
"styled-components": "5.3.5",
+ "typescript": "4.7.4",
"cypress-plugin-snapshots@1.4.4": "patch:cypress-plugin-snapshots@npm:1.4.4#.yarn/patches/cypress-plugin-snapshots-npm-1.4.4-a6166116fb.patch",
- "@react-aria/overlays@3.7.5": "patch:@react-aria/overlays@npm:3.7.5#.yarn/patches/@react-aria-overlays-npm-3.7.5-7d05242971.patch",
- "@react-stately/layout@3.4.4": "patch:@react-stately/layout@npm:3.4.4#.yarn/patches/@react-stately-layout-npm-3.4.4-75ff8d9e5d.patch",
"@parcel/transformer-js@2.6.0": "patch:@parcel/transformer-js@npm:2.6.0#.yarn/patches/@parcel-transformer-js-npm-2.6.0-6caf2205a6.patch"
}
}
diff --git a/packages/example-app/babel.config.js b/packages/example-app/babel.config.js
new file mode 100644
index 00000000..75ddf995
--- /dev/null
+++ b/packages/example-app/babel.config.js
@@ -0,0 +1 @@
+module.exports = require("../../babel.config");
diff --git a/packages/example-app/fixture/git/diff-example.git/HEAD b/packages/example-app/fixture/git/diff-example.git/HEAD
new file mode 100644
index 00000000..cb089cd8
--- /dev/null
+++ b/packages/example-app/fixture/git/diff-example.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/packages/example-app/fixture/git/diff-example.git/config b/packages/example-app/fixture/git/diff-example.git/config
new file mode 100644
index 00000000..bd3b417c
--- /dev/null
+++ b/packages/example-app/fixture/git/diff-example.git/config
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ logallrefupdates = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/packages/example-app/fixture/git/diff-example.git/index b/packages/example-app/fixture/git/diff-example.git/index
new file mode 100644
index 00000000..1655b6f0
Binary files /dev/null and b/packages/example-app/fixture/git/diff-example.git/index differ
diff --git a/packages/example-app/fixture/git/diff-example.git/logs/HEAD b/packages/example-app/fixture/git/diff-example.git/logs/HEAD
new file mode 100644
index 00000000..3628376c
--- /dev/null
+++ b/packages/example-app/fixture/git/diff-example.git/logs/HEAD
@@ -0,0 +1,7 @@
+0000000000000000000000000000000000000000 39adfc50dabeea269ae5ac793bdf83317f841e17 Alireza 1710443455 +0100 commit (initial): initial state
+39adfc50dabeea269ae5ac793bdf83317f841e17 e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 Alireza 1710443674 +0100 commit (amend): initial state
+e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 4f2658865baff125a1c9fa98efdf12895105ac6e Alireza 1710443879 +0100 commit: changed
+4f2658865baff125a1c9fa98efdf12895105ac6e 4f2658865baff125a1c9fa98efdf12895105ac6e Alireza 1710446181 +0100 checkout: moving from master to master
+4f2658865baff125a1c9fa98efdf12895105ac6e e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 Alireza 1710446337 +0100 reset: moving to e99d0fad1a864fa41d6d89bfabb5e0f1c0361987
+e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 21d19576802af30b349e49ce09ff3201755a2457 Alireza 1710446428 +0100 commit (amend): initial state
+21d19576802af30b349e49ce09ff3201755a2457 7a4535a30be43ee6a9099ab3f625a3483934de35 Alireza 1710446846 +0100 commit: changes: 2 new | 2 renamed | 2 deleted
diff --git a/packages/example-app/fixture/git/diff-example.git/logs/refs/heads/master b/packages/example-app/fixture/git/diff-example.git/logs/refs/heads/master
new file mode 100644
index 00000000..b75c94a8
--- /dev/null
+++ b/packages/example-app/fixture/git/diff-example.git/logs/refs/heads/master
@@ -0,0 +1,6 @@
+0000000000000000000000000000000000000000 39adfc50dabeea269ae5ac793bdf83317f841e17 Alireza 1710443455 +0100 commit (initial): initial state
+39adfc50dabeea269ae5ac793bdf83317f841e17 e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 Alireza 1710443674 +0100 commit (amend): initial state
+e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 4f2658865baff125a1c9fa98efdf12895105ac6e Alireza 1710443879 +0100 commit: changed
+4f2658865baff125a1c9fa98efdf12895105ac6e e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 Alireza 1710446337 +0100 reset: moving to e99d0fad1a864fa41d6d89bfabb5e0f1c0361987
+e99d0fad1a864fa41d6d89bfabb5e0f1c0361987 21d19576802af30b349e49ce09ff3201755a2457 Alireza 1710446428 +0100 commit (amend): initial state
+21d19576802af30b349e49ce09ff3201755a2457 7a4535a30be43ee6a9099ab3f625a3483934de35 Alireza 1710446846 +0100 commit: changes: 2 new | 2 renamed | 2 deleted
diff --git a/packages/example-app/fixture/git/diff-example.git/objects/info/packs b/packages/example-app/fixture/git/diff-example.git/objects/info/packs
new file mode 100644
index 00000000..b35a36ea
--- /dev/null
+++ b/packages/example-app/fixture/git/diff-example.git/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-12a63299528292ad7bedaf4a7127ee5b8a2b6d3f.pack
+
diff --git a/packages/example-app/fixture/git/diff-example.git/objects/pack/pack-12a63299528292ad7bedaf4a7127ee5b8a2b6d3f.idx b/packages/example-app/fixture/git/diff-example.git/objects/pack/pack-12a63299528292ad7bedaf4a7127ee5b8a2b6d3f.idx
new file mode 100644
index 00000000..a4d902da
Binary files /dev/null and b/packages/example-app/fixture/git/diff-example.git/objects/pack/pack-12a63299528292ad7bedaf4a7127ee5b8a2b6d3f.idx differ
diff --git a/packages/example-app/fixture/git/diff-example.git/objects/pack/pack-12a63299528292ad7bedaf4a7127ee5b8a2b6d3f.pack b/packages/example-app/fixture/git/diff-example.git/objects/pack/pack-12a63299528292ad7bedaf4a7127ee5b8a2b6d3f.pack
new file mode 100644
index 00000000..a60c5344
Binary files /dev/null and b/packages/example-app/fixture/git/diff-example.git/objects/pack/pack-12a63299528292ad7bedaf4a7127ee5b8a2b6d3f.pack differ
diff --git a/packages/example-app/fixture/git/diff-example.git/refs/heads/master b/packages/example-app/fixture/git/diff-example.git/refs/heads/master
new file mode 100644
index 00000000..ca7eefe7
--- /dev/null
+++ b/packages/example-app/fixture/git/diff-example.git/refs/heads/master
@@ -0,0 +1 @@
+7a4535a30be43ee6a9099ab3f625a3483934de35
diff --git a/packages/example-app/fixture/git/example-branches.git/HEAD b/packages/example-app/fixture/git/example-branches.git/HEAD
new file mode 100644
index 00000000..cb089cd8
--- /dev/null
+++ b/packages/example-app/fixture/git/example-branches.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/packages/example-app/fixture/git/example-branches.git/objects/pack/pack-6bc9e1611d6306d1c302bf85b10ac35fcd2b45b0.idx b/packages/example-app/fixture/git/example-branches.git/objects/pack/pack-6bc9e1611d6306d1c302bf85b10ac35fcd2b45b0.idx
new file mode 100644
index 00000000..c96ac463
Binary files /dev/null and b/packages/example-app/fixture/git/example-branches.git/objects/pack/pack-6bc9e1611d6306d1c302bf85b10ac35fcd2b45b0.idx differ
diff --git a/packages/example-app/fixture/git/example-branches.git/objects/pack/pack-6bc9e1611d6306d1c302bf85b10ac35fcd2b45b0.pack b/packages/example-app/fixture/git/example-branches.git/objects/pack/pack-6bc9e1611d6306d1c302bf85b10ac35fcd2b45b0.pack
new file mode 100644
index 00000000..dfd7b236
Binary files /dev/null and b/packages/example-app/fixture/git/example-branches.git/objects/pack/pack-6bc9e1611d6306d1c302bf85b10ac35fcd2b45b0.pack differ
diff --git a/packages/example-app/fixture/git/example-branches.git/packed-refs b/packages/example-app/fixture/git/example-branches.git/packed-refs
new file mode 100644
index 00000000..574cde41
--- /dev/null
+++ b/packages/example-app/fixture/git/example-branches.git/packed-refs
@@ -0,0 +1,9 @@
+# pack-refs with: peeled fully-peeled sorted
+5b1b99986a0096c774f26a4917dbad0ac31d2d26 refs/heads/RC1.0
+8dbd80de7185d12e6b6e58fcab18900a7c88d3af refs/heads/enhancement
+9d037db880dc2e11ac4aa1d80c322aed65269089 refs/heads/featureGreen
+7be5c69bb17c73d37af6f076a9a9442b22c76a13 refs/heads/featureRed
+dd6d5a5085d7bec5850eef36b7e0b7059fc68be1 refs/heads/gh-pages
+14d63f8c757e52f7e60e13765031a7fdf0768195 refs/heads/master
+5086927860395c3a173df36eabe9f2525c357bc2 refs/heads/topic1
+d5ed0e6a098710ad9dfe08bc7039fc6e61d00fa3 refs/heads/topic2
diff --git a/packages/example-app/jest.config.js b/packages/example-app/jest.config.js
new file mode 100644
index 00000000..6746d412
--- /dev/null
+++ b/packages/example-app/jest.config.js
@@ -0,0 +1,6 @@
+// eslint-disable-next-line no-undef
+module.exports = {
+ moduleNameMapper: {
+ "@intellij-platform/core(.*)$": "/../jui/src/$1",
+ },
+};
diff --git a/packages/example-app/package.json b/packages/example-app/package.json
index c4beaa3d..b06187f2 100644
--- a/packages/example-app/package.json
+++ b/packages/example-app/package.json
@@ -4,6 +4,9 @@
"private": true,
"scripts": {
"serve": "../../node_modules/.bin/parcel serve",
+ "jest:type-check": "tsc --project tsconfig.jest.json",
+ "test": "jest",
+ "type-check": "tsc --project tsconfig.app.json && yarn run jest:type-check",
"build": "../../node_modules/.bin/parcel build"
},
"source": "src/index.html",
@@ -19,6 +22,8 @@
"@recoiljs/refine": "^0.1.1",
"browserfs": "^2.0.0",
"caf": "^15.0.0-preB",
+ "clipboard-copy": "^4.0.1",
+ "diff": "^5.2.0",
"fast-xml-parser": "^4.2.7",
"intl-messageformat": "^9.11.2",
"isomorphic-git": "^1.24.3",
@@ -34,8 +39,10 @@
"xterm-for-react": "^1.0.4"
},
"devDependencies": {
+ "@types/diff": "^5.0.9",
"@types/jest": "^29.5.2",
"@types/uuid": "^9.0.7",
- "jest": "^29.5.0"
+ "jest": "^29.5.0",
+ "typescript": "workspace:*"
}
}
diff --git a/packages/example-app/src/App.tsx b/packages/example-app/src/App.tsx
index 8b6054ea..895b08e1 100644
--- a/packages/example-app/src/App.tsx
+++ b/packages/example-app/src/App.tsx
@@ -1,3 +1,4 @@
+import * as path from "path";
import React, { CSSProperties, useRef } from "react";
import { RecoilRoot } from "recoil";
import git from "isomorphic-git";
@@ -15,7 +16,7 @@ import { SampleRepoInitializer } from "./SampleRepoInitializer";
import { fs, WaitForFs } from "./fs/fs";
import { exampleAppKeymap } from "./exampleAppKeymap";
import { ToolWindowsRefContext } from "./Project/useToolWindowManager";
-import * as path from "path";
+import "./jetbrains-mono-font.css";
// useful globals for debugging purposes
(window as any).git = git;
diff --git a/packages/example-app/src/Editor/Editor.tsx b/packages/example-app/src/Editor/Editor.tsx
index 73dc1e8c..664a12f5 100644
--- a/packages/example-app/src/Editor/Editor.tsx
+++ b/packages/example-app/src/Editor/Editor.tsx
@@ -26,15 +26,18 @@ export const StyledEditor = styled(MonacoEditor)`
*/
export const Editor = (props: Omit) => {
const editorTheme = useEditorTheme();
+ const fontSize = 13;
return (
, "src" | "darkSrc" | "srcSet">
) => ;
diff --git a/packages/example-app/src/Project/Project.tsx b/packages/example-app/src/Project/Project.tsx
index 5b733f0d..94673aac 100644
--- a/packages/example-app/src/Project/Project.tsx
+++ b/packages/example-app/src/Project/Project.tsx
@@ -1,4 +1,4 @@
-import React, { CSSProperties, RefObject } from "react";
+import React, { CSSProperties, RefObject, useEffect } from "react";
import { useRecoilState, useRecoilValue } from "recoil";
import {
ActionDefinition,
@@ -9,7 +9,10 @@ import {
useBalloonManager,
} from "@intellij-platform/core";
import { FileEditor } from "../Editor/FileEditor";
-import { useInitializeVcs } from "../VersionControl/file-status.state";
+import {
+ useInitializeVcs,
+ useRefreshVcsRoots,
+} from "../VersionControl/file-status.state";
import { toolWindows } from "./toolWindows";
import { useInitializeChanges } from "../VersionControl/Changes/change-lists.state";
import { IdeStatusBar } from "../StatusBar/IdeStatusBar";
@@ -42,7 +45,11 @@ export const Project = ({
const [state, setState] = useRecoilState(toolWindowsState);
const isRollbackWindowOpen = useRecoilValue(rollbackViewState.isOpen);
const isSearchEveryWhereOpen = useRecoilValue(searchEverywhereState.isOpen);
+ const refreshVcsRoots = useRefreshVcsRoots();
+ useEffect(() => {
+ refreshVcsRoots();
+ }, []);
useInitializeVcs();
useInitializeChanges();
usePersistenceFsNotification();
diff --git a/packages/example-app/src/SearchEverywhere/SearchEverywherePopup.tsx b/packages/example-app/src/SearchEverywhere/SearchEverywherePopup.tsx
index 0e2ce509..88aa1889 100644
--- a/packages/example-app/src/SearchEverywhere/SearchEverywherePopup.tsx
+++ b/packages/example-app/src/SearchEverywhere/SearchEverywherePopup.tsx
@@ -276,7 +276,7 @@ export function SearchEverywherePopup() {
const close = () => setOpen(false);
- const collectionRef = useRef(null);
+ const collectionRef = useRef(null);
const selectionManagerRef = useRef(null);
const { collectionSearchInputProps } = useCollectionSearchInput({
collectionRef,
diff --git a/packages/example-app/src/StatusBar/BranchPopupTrigger.tsx b/packages/example-app/src/StatusBar/BranchPopupTrigger.tsx
index 05b59743..3e31da85 100644
--- a/packages/example-app/src/StatusBar/BranchPopupTrigger.tsx
+++ b/packages/example-app/src/StatusBar/BranchPopupTrigger.tsx
@@ -1,6 +1,7 @@
import {
ActionTooltip,
PlatformIcon,
+ PopupTrigger,
StatusBarWidget,
TooltipTrigger,
} from "@intellij-platform/core";
@@ -8,37 +9,52 @@ import React from "react";
import { activeFileRepoHeadState } from "../VersionControl/active-file.state";
import { useLatestRecoilValue } from "../recoil-utils";
+import { BranchesPopup } from "../VersionControl/Branches/BranchesPopup";
+import { useShowGitTipIfNeeded } from "../VersionControl/useShowGitTipIfNeeded";
export function BranchPopupTrigger() {
- const gitRepoHead = useLatestRecoilValue(activeFileRepoHeadState);
+ const [gitRepoHead] = useLatestRecoilValue(activeFileRepoHeadState);
+ const maybeShowGitCloneTip = useShowGitTipIfNeeded();
return (
gitRepoHead && (
-
- }
+ {
+ if (!isOpen) {
+ setTimeout(maybeShowGitCloneTip, 500);
+ }
+ }}
+ popup={({ close }) => }
>
-
}
- label={gitRepoHead.head.slice(
- 0,
- gitRepoHead.detached ? 8 : undefined
- )}
- />
-
+ >
+
+ }
+ label={gitRepoHead.head.slice(
+ 0,
+ gitRepoHead.detached ? 8 : undefined
+ )}
+ />
+
+
)
);
}
diff --git a/packages/example-app/src/StatusBar/IdeStatusBar.tsx b/packages/example-app/src/StatusBar/IdeStatusBar.tsx
index aba430c1..a3efb00d 100644
--- a/packages/example-app/src/StatusBar/IdeStatusBar.tsx
+++ b/packages/example-app/src/StatusBar/IdeStatusBar.tsx
@@ -8,15 +8,12 @@ import {
MenuItemLayout,
MenuTrigger,
PlatformIcon,
- PopupTrigger,
StatusBar,
StatusBarWidget,
} from "@intellij-platform/core";
import { editorCursorPositionState } from "../Editor/editor.state";
-import { BranchesPopup } from "../VersionControl/Branches/BranchesPopup";
import { notImplemented } from "../Project/notImplemented";
-import { useShowGitTipIfNeeded } from "../VersionControl/useShowGitTipIfNeeded";
import { StatusBarTaskProgressBar } from "./StatusBarTaskProgressBar";
import { BranchPopupTrigger } from "./BranchPopupTrigger";
@@ -26,7 +23,6 @@ const StyledLastMessage = styled.div`
`;
export const IdeStatusBar = () => {
const cursorPosition = useRecoilValue(editorCursorPositionState);
- const maybeShowGitCloneTip = useShowGitTipIfNeeded();
return (
{
)}
- {
- if (!isOpen) {
- setTimeout(maybeShowGitCloneTip, 500);
- }
- }}
- popup={({ close }) => }
- >
-
-
+
} />
} />
>
diff --git a/packages/example-app/src/VersionControl/Branches/BranchesPopup.tsx b/packages/example-app/src/VersionControl/Branches/BranchesPopup.tsx
index a16749c4..975d5904 100644
--- a/packages/example-app/src/VersionControl/Branches/BranchesPopup.tsx
+++ b/packages/example-app/src/VersionControl/Branches/BranchesPopup.tsx
@@ -63,7 +63,7 @@ export const branchesPopupSizeState = atom<
});
export function BranchesPopup({ onClose }: { onClose: () => void }) {
- const repoBranches = useLatestRecoilValue(allBranchesState);
+ const [repoBranches] = useLatestRecoilValue(allBranchesState);
const [branchesPopupPersistedSize, setBranchesPopupPersistedSize] =
useRecoilState(branchesPopupSizeState);
const [branchesPopupBounds, setBranchesPopupBounds] = useState<
diff --git a/packages/example-app/src/VersionControl/Changes/Change.ts b/packages/example-app/src/VersionControl/Changes/Change.ts
new file mode 100644
index 00000000..06dcb8cc
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/Change.ts
@@ -0,0 +1,58 @@
+import { FileStatus } from "../file-status";
+
+export type Revision = {
+ path: string;
+ isDir: boolean;
+ content(): Promise;
+};
+
+export interface Change {
+ before?: Revision;
+ after?: Revision;
+}
+export type ModificationChange = Required;
+export type DeletionChange = {
+ before: Revision;
+ after: undefined;
+};
+export type AdditionChange = {
+ before: undefined;
+ after: Revision;
+};
+
+/**
+ * Experimenting a pattern of exporting both a type and a value under the same name, to collocate behavior and interface
+ * while still using plain objects, to avoid caveats of using class.
+ */
+export class Change {
+ static path(change: Change): string {
+ return (change.after ?? change.before)?.path ?? "";
+ }
+ static type(
+ change: Change
+ ): // Making sure the return value is a subset of FileStatus
+ Extract {
+ if (change.after) {
+ if (change.before) {
+ return "MODIFIED";
+ }
+ return "ADDED";
+ }
+ return "DELETED";
+ }
+ static isAddition(change: Change): change is AdditionChange {
+ return Change.type(change) === "ADDED";
+ }
+ static isModification(change: Change): change is ModificationChange {
+ return Change.type(change) === "MODIFIED";
+ }
+ static isDeletion(change: Change): change is DeletionChange {
+ return Change.type(change) === "DELETED";
+ }
+ static isRename(change: Change): change is ModificationChange {
+ return (
+ Change.isModification(change) &&
+ change.before?.path !== change.after?.path
+ );
+ }
+}
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesSummary.tsx b/packages/example-app/src/VersionControl/Changes/ChangesSummary.tsx
index 7218ba05..1fa38845 100644
--- a/packages/example-app/src/VersionControl/Changes/ChangesSummary.tsx
+++ b/packages/example-app/src/VersionControl/Changes/ChangesSummary.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import { Change } from "./change-lists.state";
import { StatusColor } from "../FileStatusColor";
+import { Change } from "./Change";
export const ChangesSummary = ({
changes,
@@ -8,13 +8,13 @@ export const ChangesSummary = ({
changes: ReadonlyArray;
}) => {
const modifiedCount = changes.filter(
- (change) => change.after.path && change.before.path
+ (change) => Change.type(change) === "MODIFIED"
).length;
const addedCount = changes.filter(
- (change) => change.after.path && !change.before.path
+ (change) => Change.type(change) === "ADDED"
).length;
const deletedCount = changes.filter(
- (change) => change.before.path && !change.after.path
+ (change) => Change.type(change) === "DELETED"
).length;
return (
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesTree/ChangeTreeNode.ts b/packages/example-app/src/VersionControl/Changes/ChangesTree/ChangeTreeNode.ts
new file mode 100644
index 00000000..0694b22f
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/ChangesTree/ChangeTreeNode.ts
@@ -0,0 +1,97 @@
+import { VcsDirectoryMapping } from "../../file-status";
+import { Change } from "../Change";
+
+export interface ChangesTreeNode {
+ type: T;
+ key: string;
+}
+
+export interface ChangesTreeGroupNode<
+ T extends string,
+ C extends ChangesTreeNode = ChangesTreeNode
+> extends ChangesTreeNode {
+ children: ReadonlyArray;
+}
+
+export type GroupNodes> = Extract<
+ T,
+ ChangesTreeGroupNode
+>;
+
+export interface ChangeNode extends ChangesTreeNode<"change"> {
+ change: Change;
+ /**
+ * Whether to show the file path in front of the file name (in muted UI)
+ */
+ showPath?: boolean;
+}
+
+export interface DirectoryNode<
+ C extends ChangesTreeNode = ChangesTreeNode
+> extends ChangesTreeGroupNode<"directory", C> {
+ dirPath: string;
+ parentNodePath: string;
+}
+
+export interface RepositoryNode<
+ C extends ChangesTreeNode = ChangesTreeNode
+> extends ChangesTreeGroupNode<"repo", C> {
+ repository: VcsDirectoryMapping;
+}
+
+/**
+ * Extends the type of changes tree nodes with additional (group) node types.
+ */
+export type ExtendedChangesTreeNode> =
+ | ChangeNode
+ | RepositoryNode
+ | DirectoryNode
+ | G;
+
+export type DefaultChangesTreeNode = ExtendedChangesTreeNode;
+export const changesTreeNodeKey = (type: string, id: string): string =>
+ `${type}_${id}`;
+
+export const getNodeKeyForChange = (change: Change) =>
+ changesTreeNodeKey("change", Change.path(change));
+export const changeNode = (change: Change, showPath = true): ChangeNode => ({
+ type: "change",
+ key: getNodeKeyForChange(change),
+ change,
+ showPath,
+});
+export const directoryNode = >(
+ dirPath: string,
+ parentNodePath: string,
+ children: readonly G[] = []
+): DirectoryNode => ({
+ type: "directory",
+ key: changesTreeNodeKey("directory", dirPath),
+ dirPath,
+ parentNodePath,
+ children,
+});
+export const repositoryNode = >(
+ repository: VcsDirectoryMapping,
+ children: G[] = []
+): RepositoryNode => ({
+ type: "repo",
+ key: changesTreeNodeKey("repo", repository.dir),
+ repository,
+ children,
+});
+export const isGroupNode = >(
+ node: T
+): node is GroupNodes => "children" in node;
+export const isChangeNode = (node: ChangesTreeNode): node is ChangeNode =>
+ node.type === "change";
+export const isDirectoryNode = >(
+ node: ChangesTreeNode
+): node is DirectoryNode => node.type === "directory";
+
+export const getChildren = <
+ T extends ChangesTreeNode = DefaultChangesTreeNode
+>(
+ node: T
+): null | GroupNodes["children"] =>
+ isGroupNode(node) ? node.children : null;
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesTree/changesGroupings.ts b/packages/example-app/src/VersionControl/Changes/ChangesTree/changesGroupings.ts
new file mode 100644
index 00000000..b1614421
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/ChangesTree/changesGroupings.ts
@@ -0,0 +1,137 @@
+import { GetRecoilValue, isRecoilValue, RecoilValue, selector } from "recoil";
+
+import { vcsRootsState } from "../../file-status.state";
+import { createGroupByDirectory } from "../../../tree-utils/groupByDirectory";
+import { Change } from "../Change";
+import {
+ changeNode,
+ ChangeNode,
+ ChangesTreeGroupNode,
+ ChangesTreeNode,
+ directoryNode,
+ DirectoryNode,
+ isChangeNode,
+ isGroupNode,
+ RepositoryNode,
+ repositoryNode,
+} from "./ChangeTreeNode";
+
+export type MaybeRecoilValue = T | RecoilValue;
+
+export type GroupFn> = (
+ nodes: ReadonlyArray
+) => ReadonlyArray;
+
+export interface ChangeGrouping<
+ T extends ChangesTreeGroupNode,
+ I = string
+> {
+ id: I;
+ title: string;
+ isAvailable: MaybeRecoilValue;
+ groupFn: MaybeRecoilValue>;
+}
+
+const repositoryGrouping: ChangeGrouping = {
+ id: "repository",
+ title: "Repository",
+ isAvailable: selector({
+ key: "repositoryGrouping/isAvailable",
+ get: ({ get }) => get(vcsRootsState).length > 1,
+ }),
+ groupFn: selector({
+ key: "repositoryGrouping/groupFn",
+ get:
+ ({ get }: { get: GetRecoilValue }) =>
+ (nodes: ReadonlyArray) => {
+ const repos = get(vcsRootsState);
+ return repos.map(
+ (repository): RepositoryNode =>
+ repositoryNode(
+ repository,
+ nodes.filter((node) =>
+ (
+ node.change.after?.path || node.change.before?.path
+ )?.startsWith(repository.dir)
+ )
+ )
+ );
+ },
+ }),
+};
+
+export const directoryGrouping: ChangeGrouping = {
+ id: "directory",
+ title: "Directory",
+ isAvailable: true,
+ groupFn: createGroupByDirectory({
+ shouldCollapseDirectories: true,
+ createDirectoryNode: ({ dirPath, parentNodePath, children }) =>
+ directoryNode(dirPath, parentNodePath, children),
+ mapNode: (node) => changeNode(node.change, false),
+ getPath: (node) => {
+ return Change.path(node.change);
+ },
+ }),
+};
+
+export const defaultChangeGroupings: ReadonlyArray<
+ ChangeGrouping
+> = [repositoryGrouping, directoryGrouping];
+
+export const defaultChangeGroupingsState = selector<
+ ReadonlyArray>
+>({
+ key: "changes.availableGroupings",
+ get: ({ get }) =>
+ defaultChangeGroupings.filter((grouping) =>
+ isRecoilValue(grouping.isAvailable)
+ ? get(grouping.isAvailable)
+ : grouping.isAvailable
+ ),
+});
+
+export const recursiveGrouping = >(
+ groupingFns: Array> /* Typing could be improved? */,
+ nodes: ReadonlyArray
+): ReadonlyArray | ReadonlyArray => {
+ if (groupingFns.length === 0 || nodes.length === 0) {
+ return nodes;
+ }
+ const nextGroupingFns = groupingFns.slice(1);
+
+ const changeNodes = (nodes as ReadonlyArray>).filter(
+ isChangeNode
+ );
+ const groupNodes = (nodes as ReadonlyArray>).filter(
+ isGroupNode
+ );
+ const groups = groupingFns[0](changeNodes);
+
+ [...groupNodes, ...groups].forEach((groupNode) => {
+ groupNode.children = recursiveGrouping(nextGroupingFns, groupNode.children);
+ });
+ return groups.filter((group) => group.children.length > 0);
+};
+
+export function getChangesGroupFn>({
+ get,
+ isActive,
+ groupings,
+}: {
+ get: GetRecoilValue;
+ isActive: (groupingId: string) => MaybeRecoilValue;
+ groupings: ReadonlyArray>;
+}) {
+ const resolve = (value: MaybeRecoilValue): T =>
+ isRecoilValue(value) ? get(value) : value;
+ const groupFns = groupings
+ .filter(
+ ({ id, isAvailable }) => resolve(isAvailable) && resolve(isActive(id))
+ )
+ .map(({ groupFn }) => {
+ return resolve(groupFn);
+ });
+ return (changes: readonly ChangeNode[]) =>
+ recursiveGrouping(groupFns, changes);
+}
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesTree/changesTreeNodeRenderers.tsx b/packages/example-app/src/VersionControl/Changes/ChangesTree/changesTreeNodeRenderers.tsx
new file mode 100644
index 00000000..94cd0a9e
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/ChangesTree/changesTreeNodeRenderers.tsx
@@ -0,0 +1,185 @@
+import { IntlMessageFormat } from "intl-messageformat";
+import path, { basename } from "path";
+import React, { Key } from "react";
+import { ItemProps } from "@react-types/shared";
+import {
+ HighlightedTextValue,
+ Item,
+ ItemLayout,
+ PlatformIcon,
+} from "@intellij-platform/core";
+
+import { DIR_ICON, getIconForFile } from "../../../file-utils";
+import { RepoCurrentBranchName } from "../../RepoCurrentBranchName";
+import { RepoColorIcon } from "../StyledRepoColorSquare";
+import { StyledCurrentBranchTag } from "../StyledCurrentBranchTag";
+import { Change } from "../Change";
+import {
+ ChangeNode,
+ ChangesTreeNode,
+ DefaultChangesTreeNode,
+ DirectoryNode,
+ isGroupNode,
+ RepositoryNode,
+} from "./ChangeTreeNode";
+import { StatusColor } from "../../FileStatusColor";
+
+/**
+ * Necessary properties for each node, to be passed to `Item`s rendered in tree.
+ * Some properties like `key` or `childItems` are not included, as they are calculated similarly
+ * for all types of nodes.
+ */
+type RenderedNode = { textValue: string; rendered: React.ReactNode };
+export type NodeRenderer> = (
+ node: T,
+ metadata: { fileCount: number; dirCount: number }
+) => RenderedNode;
+
+const fileCountMsg = new IntlMessageFormat(
+ `{fileCount, plural,
+ =0 {No files}
+ =1 {1 file}
+ other {# files}
+ }`,
+ "en-US"
+);
+
+export function formatFileCount(fileCount: number) {
+ return fileCountMsg.format({ fileCount });
+}
+const repoNodeRenderer: NodeRenderer = (
+ node,
+ { fileCount }
+) => ({
+ textValue: path.basename(node.repository.dir),
+ rendered: (
+
+
+
+ {formatFileCount(fileCount)}
+
+
+
+
+ ),
+});
+const directoryNodeRenderer: NodeRenderer = (
+ node,
+ { fileCount }
+) => ({
+ textValue: node.parentNodePath
+ ? path.relative(node.parentNodePath, node.dirPath)
+ : node.dirPath,
+ rendered: (
+
+
+
+ {formatFileCount(fileCount)}
+
+ ),
+});
+const ChangeNodeHint = ({ node }: { node: ChangeNode }): React.ReactElement => {
+ return (
+
+ {node.showPath && path.dirname(Change.path(node.change))}
+
+ );
+};
+const changeNodeRenderer: NodeRenderer = (node) => ({
+ textValue: path.basename(Change.path(node.change)),
+ rendered: (
+
+
+
+
+
+ {Change.isRename(node.change) &&
+ ` - renamed from ${basename(node.change.before.path)}`}
+
+
+ ),
+});
+
+type NodeRenderersMap> = {
+ [type in T["type"]]: NodeRenderer;
+};
+
+const defaultNodeRenderers: NodeRenderersMap = {
+ repo: repoNodeRenderer,
+ directory: directoryNodeRenderer,
+ change: changeNodeRenderer,
+};
+
+export const simpleGroupingRenderer =
+ >(
+ getText: (node: T) => string
+ ): NodeRenderer =>
+ (node, { fileCount }) => ({
+ textValue: getText(node),
+ rendered: (
+
+
+ {formatFileCount(fileCount)}
+
+ ),
+ });
+
+export const createChangesTreeNodeRenderer = >(
+ renderers: Omit<
+ NodeRenderersMap,
+ keyof NodeRenderersMap
+ >,
+ /**
+ * Not so nice signature, but couldn't make it work with a single argument in a way that:
+ * - allows for optionally overriding the default renderers
+ * - infers the type of the resulting renderer based on additional keys passed in `renderers`
+ */
+ defaultRendererOverrides?: Partial>
+) => {
+ const nodeRenderers: NodeRenderersMap = {
+ ...defaultNodeRenderers,
+ ...defaultRendererOverrides,
+ ...renderers,
+ };
+ const getItemProps = ({
+ node,
+ fileCountsMap,
+ }: {
+ node: T;
+ fileCountsMap: Map;
+ }): ItemProps & { key: Key } => {
+ // Would make sense to handle missing renderer case if implementation is changed to be extensible with regard
+ // to node types
+ const nodeRenderer: NodeRenderer =
+ nodeRenderers[node.type as T["type"] /* Why is this cast needed?!*/];
+ const { textValue, rendered } = nodeRenderer(node, {
+ fileCount: fileCountsMap.get(node.key) || 0,
+ dirCount: 0, // It seems it's only used for ignored files subtree
+ });
+ return {
+ key: node.key,
+ childItems: isGroupNode(node) ? (node.children as T[]) : [],
+ hasChildItems: isGroupNode(node) && node.children?.length > 0,
+ textValue,
+ children: rendered,
+ };
+ };
+ return {
+ getItemProps,
+ getTextValue: (
+ node: T,
+ { fileCountsMap }: { fileCountsMap: Map }
+ ) => {
+ const nodeRenderer: NodeRenderer =
+ nodeRenderers[node.type as T["type"] /* Why is this cast needed?!*/];
+ return nodeRenderer(node, {
+ fileCount: fileCountsMap.get(node.key) || 0,
+ dirCount: 0, // It seems it's only used for ignored files subtree
+ }).textValue;
+ },
+ itemRenderer:
+ ({ fileCountsMap }: { fileCountsMap: Map }) =>
+ (node: T) =>
+ ,
+ };
+};
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesTree/changesTreeNodesResult.ts b/packages/example-app/src/VersionControl/Changes/ChangesTree/changesTreeNodesResult.ts
new file mode 100644
index 00000000..89e57567
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/ChangesTree/changesTreeNodesResult.ts
@@ -0,0 +1,40 @@
+import { Key } from "react";
+import { dfsVisit } from "@intellij-platform/core/utils/tree-utils";
+
+import { ChangesTreeNode, getChildren } from "./ChangeTreeNode";
+
+export function changesTreeNodesResult<
+ T extends ReadonlyArray>
+>(
+ rootNodes: T
+): {
+ rootNodes: ReadonlyArray;
+ byKey: Map;
+ expandAllKeys: Set;
+ fileCountsMap: Map;
+} {
+ const fileCountsMap = new Map();
+ const byKey = new Map();
+ const expandAllKeys = new Set();
+
+ dfsVisit, number>(
+ getChildren,
+ (node, childrenFileCount) => {
+ byKey.set(node.key, node);
+ if ("children" in node) {
+ expandAllKeys.add(node.key);
+ }
+ if (!childrenFileCount) {
+ return 1;
+ }
+ const fileCount = childrenFileCount.reduce(
+ (total, count) => total + count,
+ 0
+ );
+ fileCountsMap.set(node.key, fileCount);
+ return fileCount;
+ },
+ rootNodes
+ );
+ return { rootNodes, fileCountsMap, byKey, expandAllKeys };
+}
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangesGroupByActionButton.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangesGroupByActionButton.tsx
index 9ef35a2c..96b9e0a5 100644
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangesGroupByActionButton.tsx
+++ b/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangesGroupByActionButton.tsx
@@ -3,28 +3,23 @@ import { useRecoilCallback, useRecoilValue } from "recoil";
import { GroupByActionButton } from "../../../../GroupByActionButton";
import {
- availableGroupingsState,
- changesGroupingState,
+ changesViewGroupingsState,
+ changesGroupingActiveState,
GroupingIds,
} from "../ChangesView.state";
export const ChangesGroupByActionButton = (): React.ReactElement => {
// Grouping is extensible in Intellij platform, but we only support grouping by directory here.
- const availableGroupings = useRecoilValue(availableGroupingsState);
+ const groupings = useRecoilValue(changesViewGroupingsState);
const toggleGroup = useRecoilCallback(
({ set }) =>
(id: GroupingIds) => {
- set(changesGroupingState(id), (value) => !value);
+ set(changesGroupingActiveState(id), (value) => !value);
},
[]
);
- return (
-
- );
+ return ;
};
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx
index c6e9a8ec..d1e72186 100644
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx
+++ b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx
@@ -9,22 +9,24 @@ import {
} from "@intellij-platform/core";
import {
changesTreeNodesState,
+ ChangesViewTreeNode,
expandedKeysState,
includedChangesNestedSelection,
selectedKeysState,
} from "./ChangesView.state";
import { ChangesViewTreeContextMenu } from "./ChangesViewTreeContextMenu";
-import { getChangeListTreeItemProps } from "./changesTreeNodeRenderers";
import { useActivePathsProvider } from "../../../Project/project.state";
import { useEditorStateManager } from "../../../Editor/editor.state";
-import { AnyNode, isGroupNode } from "./change-view-nodes";
+import { Change } from "../Change";
+import { isGroupNode } from "../ChangesTree/ChangeTreeNode";
+import { changesViewTreeNodeRenderer } from "./changesViewTreeNodeRenderer";
-function findPathsUnderNode(node: AnyNode): string[] {
+function findPathsUnderNode(node: ChangesViewTreeNode): string[] {
if (node?.type === "directory") {
return [node.dirPath];
}
if (node?.type === "change") {
- return [node.change.after.path];
+ return [Change.path(node.change)];
}
if (isGroupNode(node)) {
return node.children.flatMap(findPathsUnderNode);
@@ -51,14 +53,14 @@ export const ChangeViewTree = ({
const openChangeInEditor = useRecoilCallback(
({ snapshot }) =>
(key: Key) => {
- const change = snapshot
+ const changeNode = snapshot
.getLoadable(changesTreeNodesState)
.getValue()
.byKey.get(key);
- if (change?.type === "change") {
- openPath(change.change.after.path);
+ if (changeNode?.type === "change") {
+ openPath(Change.path(changeNode.change));
}
- return change;
+ return changeNode;
},
[]
);
@@ -92,7 +94,7 @@ export const ChangeViewTree = ({
{...activePathsProviderProps}
>
{(node) => {
- const props = getChangeListTreeItemProps({
+ const props = changesViewTreeNodeRenderer.getItemProps({
fileCountsMap,
node,
});
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/ChangesView.state.ts b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangesView.state.ts
index 30258c35..cbe553a2 100644
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/ChangesView.state.ts
+++ b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangesView.state.ts
@@ -1,25 +1,21 @@
import { sortBy } from "ramda";
import { Key } from "react";
-import {
- atom,
- atomFamily,
- CallbackInterface,
- isRecoilValue,
- RecoilValue,
- selector,
-} from "recoil";
+import { atom, atomFamily, CallbackInterface, selector } from "recoil";
import { Selection } from "@react-types/shared";
import {
allChangesState,
- Change,
ChangeListObj,
changeListsState,
} from "../change-lists.state";
-import { groupings } from "./changesGroupings";
+import {
+ defaultChangeGroupings,
+ defaultChangeGroupingsState,
+ getChangesGroupFn,
+ GroupFn,
+} from "../ChangesTree/changesGroupings";
import {
bfsVisit,
- dfsVisit,
getExpandedToNodesKeys,
} from "@intellij-platform/core/utils/tree-utils";
import { notNull } from "@intellij-platform/core/utils/array-utils";
@@ -31,32 +27,37 @@ import {
import { rollbackViewState } from "../Rollback/rollbackView.state";
import { activePathsState } from "../../../Project/project.state";
import {
- AnyGroupNode,
- AnyNode,
- ChangeBrowserNode,
- ChangeListNode,
- changeListNode,
ChangeNode,
- getChildren,
+ changeNode,
+ ChangesTreeGroupNode,
+ ChangesTreeNode,
+ changesTreeNodeKey,
+ ExtendedChangesTreeNode,
getNodeKeyForChange,
- GroupNode,
- isChangeNode,
+ GroupNodes,
isGroupNode,
-} from "./change-view-nodes";
+} from "../ChangesTree/ChangeTreeNode";
import { Task } from "../../../tasks";
+import { Change } from "../Change";
+import { changesTreeNodesResult } from "../ChangesTree/changesTreeNodesResult";
+
+export interface ChangeListNode<
+ C extends ChangesTreeNode = ChangesViewTreeNode
+> extends ChangesTreeGroupNode<"changelist", C> {
+ changeList: ChangeListObj;
+}
-type MaybeRecoilValue = T | RecoilValue;
+export type ChangesViewTreeNode = ExtendedChangesTreeNode;
-export type GroupFn = (
- nodes: ReadonlyArray
-) => ReadonlyArray;
+export const changeListNode = (
+ changeList: ChangeListObj
+): ChangeListNode => ({
+ type: "changelist",
+ key: changesTreeNodeKey("changelist", changeList.id),
+ changeList,
+ children: changeList.changes.map((change) => changeNode(change)),
+});
-export interface ChangeGrouping {
- id: I;
- title: string;
- isAvailable: MaybeRecoilValue;
- groupFn: MaybeRecoilValue>;
-}
export const showIgnoredFilesState = atom({
key: "changesView/showIgnoredFiles",
default: false,
@@ -69,10 +70,10 @@ export const showRelatedFilesState = atom({
// in a more extensible implementation, the id would be just string. but now that groupings are statically defined,
// why not have more type safety
-export type GroupingIds = typeof groupings[number]["id"];
+export type GroupingIds = typeof defaultChangeGroupings[number]["id"];
-export const changesGroupingState = atomFamily({
- key: "changesView/grouping",
+export const changesGroupingActiveState = atomFamily({
+ key: "changesView/isGroupingActive",
default: true, // it's false in IntelliJ
});
@@ -94,8 +95,8 @@ export const resolvedSelectedKeysState = selector>({
if (selection === "all") {
const { rootNodes } = get(changesTreeNodesState);
return new Set(
- bfsVisit(
- (node) => (isGroupNode(node) ? node.children : []),
+ bfsVisit(
+ (node) => ("children" in node ? node.children : []),
(node, parentValue) => {
const nodes = parentValue ?? [];
nodes.push(node);
@@ -114,7 +115,7 @@ export const resolvedSelectedKeysState = selector>({
/**
* The List of currently selected nodes in Changes tree. Based on {@link selectedKeysState}
*/
-const selectedNodesState = selector>({
+const selectedNodesState = selector>({
key: "changesView.selectedNodes",
get: ({ get }) => {
const selectedKeys = get(resolvedSelectedKeysState);
@@ -130,14 +131,14 @@ export const changesUnderSelectedKeys = selector>({
get: ({ get }) => {
const selectedNodes = get(selectedNodesState);
const allChanges: Set = new Set();
- const processNode = (node: AnyNode | undefined) => {
+ const processNode = (node: ChangesViewTreeNode | undefined) => {
if (!node) {
return;
}
if (node.type === "change") {
allChanges.add(node.change);
}
- if (isGroupNode(node)) {
+ if (isGroupNode(node)) {
node.children.forEach(processNode);
}
};
@@ -156,7 +157,7 @@ export const changeListsUnderSelection = selector>({
const allChangeLists = get(changeListsState);
const selectedChangeLists: Set = new Set();
- const processNode = (node: AnyNode | undefined) => {
+ const processNode = (node: ChangesViewTreeNode | undefined) => {
if (!node) {
return;
}
@@ -171,7 +172,7 @@ export const changeListsUnderSelection = selector>({
selectedChangeLists.add(changeList);
}
}
- if (isGroupNode(node)) {
+ if (isGroupNode(node)) {
node.children.forEach(processNode);
}
};
@@ -206,7 +207,7 @@ export const includedChangesState = selector>({
* based on the selected leaf nodes.
*/
export const includedChangesNestedSelection = selector<
- NestedSelectionState
+ NestedSelectionState
>({
key: "changesView.selectedChanges.nestedSelectionState",
get: ({ get, getCallback }) => {
@@ -224,8 +225,8 @@ export const includedChangesNestedSelection = selector<
},
{
rootNodes,
- getChildren: (item: AnyNode) =>
- isGroupNode(item) ? item.children : null,
+ getChildren: (item: ChangesViewTreeNode) =>
+ isGroupNode(item) ? item.children : null,
getKey: (item) => item.key,
}
);
@@ -249,87 +250,48 @@ export const expandedKeysState = atom({
}),
});
-export const availableGroupingsState = selector({
- key: "changes.availableGroupings",
- get: ({ get }) =>
- groupings
- .filter((grouping) =>
- isRecoilValue(grouping.isAvailable)
- ? get(grouping.isAvailable)
- : grouping.isAvailable
- )
- .map((grouping) => ({
- ...grouping,
- isActive: get(changesGroupingState(grouping.id)),
- })),
+export const changesViewGroupingsState = selector({
+ key: "changes.groupings",
+ get: ({ get, getCallback }) =>
+ get(defaultChangeGroupingsState).map((grouping) => ({
+ ...grouping,
+ isActive: get(changesGroupingActiveState(grouping.id)),
+ })),
});
-const recursiveGrouping = (
- groupingFns: Array>,
- nodes: ReadonlyArray>
-): ReadonlyArray => {
- if (groupingFns.length === 0 || nodes.length === 0) {
- return nodes as ReadonlyArray; // can we avoid explicit cast?
- }
- const nextGroupingFns = groupingFns.slice(1);
- const changeNodes = nodes.filter(isChangeNode);
- const groupNodes = nodes.filter(isGroupNode);
- const groups = groupingFns[0](changeNodes);
-
- [...groupNodes, ...groups].forEach((groupNode) => {
- groupNode.children = recursiveGrouping(nextGroupingFns, groupNode.children);
- });
- return groups.filter((group) => group.children.length > 0);
-};
+const groupChildren = <
+ T extends ChangesTreeGroupNode,
+ G extends ChangesTreeNode
+>(
+ node: T,
+ groupFn: (
+ nodes: ReadonlyArray
+ ) => ReturnType>> | ReadonlyArray
+) => ({
+ ...node,
+ children: groupFn(node.children),
+});
export const changesTreeNodesState = selector<{
- rootNodes: ChangeListNode[];
+ rootNodes: ReadonlyArray;
fileCountsMap: Map;
- byKey: Map;
+ byKey: Map;
}>({
key: "changesView.treeNodes",
- get: ({ get }) => {
- const resolve = (value: MaybeRecoilValue): T =>
- isRecoilValue(value) ? get(value) : value;
- const changeLists = get(changeListsState);
- const groupFns = get(availableGroupingsState)
- .filter(({ isActive }) => isActive)
- .map(({ groupFn }) => {
- return resolve(groupFn);
- });
-
- const groupChanges = (changes: readonly ChangeNode[]) =>
- recursiveGrouping(groupFns, changes);
-
- const rootNodes = sortBy(({ active }) => !active, changeLists).map(
- (changeList) => changeListNode(changeList, groupChanges)
- );
- const fileCountsMap = new Map();
- const byKey = new Map();
- const getFileCount = (node: GroupNode): number => {
- return node.children.reduce(
- (totalSum, child) =>
- totalSum + (isGroupNode(child) ? getFileCount(child) : 1),
- 0
- );
- };
- dfsVisit(
- getChildren,
- (node, childrenFileCount) => {
- byKey.set(node.key, node);
- if (!childrenFileCount) {
- return 1;
- }
- const fileCount = childrenFileCount.reduce(
- (total, count) => total + count,
- 0
- );
- fileCountsMap.set(node.key, fileCount);
- return fileCount;
- },
- rootNodes
+ get: ({ get, getCallback }) => {
+ const changeListNodes = sortBy(
+ ({ active }) => !active,
+ get(changeListsState)
+ ).map(changeListNode);
+ const groupFn = getChangesGroupFn({
+ get,
+ groupings: defaultChangeGroupings,
+ isActive: changesGroupingActiveState,
+ });
+ const rootNodes = changeListNodes.map((changeListNode) =>
+ groupChildren(changeListNode, groupFn)
);
- return { rootNodes, fileCountsMap, byKey };
+ return changesTreeNodesResult(rootNodes);
},
});
@@ -379,7 +341,9 @@ export const changesFromActivePaths = selector({
get: ({ get }) => {
const activePaths = get(activePathsState);
return get(allChangesState).filter((change) =>
- activePaths.some((activePath) => change.after.path.startsWith(activePath))
+ activePaths.some((activePath) =>
+ Change.path(change).startsWith(activePath)
+ )
);
},
});
@@ -397,15 +361,18 @@ export const queueCheckInCallback = ({ set, snapshot }: CallbackInterface) => {
.getLoadable(allChangesState)
.getValue()
.filter((change) =>
- paths.some((activePath) => change.after.path.startsWith(activePath))
+ paths.some((activePath) =>
+ Change.path(change).startsWith(activePath)
+ )
)
: snapshot.getLoadable(changesFromActivePaths).getValue();
const includedChangeKeys = changes.map(getNodeKeyForChange);
set(includedChangeKeysState, new Set(includedChangeKeys));
if (includedChangeKeys.length > 0) {
- const expandedKeys = getExpandedToNodesKeys(
- (node) => (isGroupNode(node) ? node.children : null),
+ const expandedKeys = getExpandedToNodesKeys(
+ (node) =>
+ isGroupNode(node) ? node.children : null,
(node) => node.key,
snapshot.getLoadable(changesTreeNodesState).getValue().rootNodes,
includedChangeKeys
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/StyledCurrentBranchTag.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/StyledCurrentBranchTag.tsx
deleted file mode 100644
index 4b42d1e6..00000000
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/StyledCurrentBranchTag.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from "react";
-import { Color, styled } from "@intellij-platform/core";
-
-export const StyledCurrentBranchTag = styled.span`
- display: inline-flex;
- align-self: center;
- height: 1.1rem;
- line-height: 1.1rem;
- padding: 0 0.25rem;
-
- background: ${({ theme }) =>
- new Color(
- theme.color(
- "VersionControl.RefLabel.backgroundBase",
- theme.dark ? "#fff" : "#000"
- )
- )
- .withTransparency(
- theme.value("VersionControl.RefLabel.backgroundBrightness") ??
- 0.08
- )
- .toString()};
- color: ${({ theme }) =>
- theme.currentForegroundAware(
- theme.color(
- "VersionControl.RefLabel.foreground",
- theme.dark ? "#909090" : "#7a7a7a"
- )
- )};
-`;
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/change-view-nodes.ts b/packages/example-app/src/VersionControl/Changes/ChangesView/change-view-nodes.ts
deleted file mode 100644
index 138cc024..00000000
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/change-view-nodes.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { VcsDirectoryMapping } from "../../file-status";
-import { Change, ChangeListObj } from "../change-lists.state";
-import { GroupFn } from "./ChangesView.state";
-
-export interface ChangeBrowserNode {
- type: T;
- key: string;
-}
-
-export interface GroupNode extends ChangeBrowserNode {
- children: ReadonlyArray;
-}
-
-export interface ChangeNode extends ChangeBrowserNode<"change"> {
- change: Change;
-}
-
-export interface ChangeListNode extends GroupNode<"changelist"> {
- changeList: ChangeListObj;
-}
-
-export interface DirectoryNode extends GroupNode<"directory"> {
- dirPath: string;
- parentNodePath: string;
-}
-
-export interface RepositoryNode extends GroupNode<"repo"> {
- repository: VcsDirectoryMapping;
-}
-
-export type AnyGroupNode = ChangeListNode | RepositoryNode | DirectoryNode;
-export type AnyNode = AnyGroupNode | ChangeNode;
-const changeBrowserNodeKey = (type: string, id: string): string =>
- `${type}_${id}`;
-export const getNodeKeyForChange = (change: Change) =>
- changeBrowserNodeKey("change", change.after.path);
-export const changeNode = (change: Change): ChangeNode => ({
- type: "change",
- key: getNodeKeyForChange(change),
- change,
-});
-export const changeListNode = (
- changeList: ChangeListObj,
- groupFn: GroupFn = (i) => i
-): ChangeListNode => ({
- type: "changelist",
- key: changeBrowserNodeKey("changelist", changeList.id),
- changeList,
- children: groupFn(changeList.changes.map((change) => changeNode(change))),
-});
-export const directoryNode = (
- dirPath: string,
- parentNodePath: string,
- children: readonly AnyNode[] = []
-): DirectoryNode => ({
- type: "directory",
- key: changeBrowserNodeKey("directory", dirPath),
- dirPath,
- parentNodePath,
- children,
-});
-export const repositoryNode = (
- repository: VcsDirectoryMapping,
- children: AnyNode[] = []
-): RepositoryNode => ({
- type: "repo",
- key: changeBrowserNodeKey("repo", repository.dir),
- repository,
- children,
-});
-export const isGroupNode = (
- node: ChangeBrowserNode
-): node is GroupNode => "children" in node;
-export const isChangeNode = (
- node: ChangeBrowserNode
-): node is ChangeNode => node.type === "change";
-export const isDirectoryNode = (node: AnyNode): node is DirectoryNode =>
- node.type === "directory";
-export const getChildren = (node: AnyNode) =>
- isGroupNode(node) ? node.children : null;
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/changesGroupings.ts b/packages/example-app/src/VersionControl/Changes/ChangesView/changesGroupings.ts
deleted file mode 100644
index 3823637f..00000000
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/changesGroupings.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { GetRecoilValue, selector } from "recoil";
-import { vcsRootsState } from "../../file-status.state";
-import { ChangeGrouping } from "./ChangesView.state";
-import { groupByDirectory } from "../../../tree-utils/groupByDirectory";
-import {
- ChangeNode,
- directoryNode,
- DirectoryNode,
- RepositoryNode,
- repositoryNode,
-} from "./change-view-nodes";
-
-const repositoryGrouping: ChangeGrouping = {
- id: "repository",
- title: "Repository",
- isAvailable: selector({
- key: "repositoryGrouping/isAvailable",
- get: ({ get }) => get(vcsRootsState).length > 0,
- }),
- groupFn: selector({
- key: "repositoryGrouping/groupFn",
- get:
- ({ get }: { get: GetRecoilValue }) =>
- (nodes: ReadonlyArray) => {
- const repos = get(vcsRootsState);
- return repos.map(
- (repository): RepositoryNode =>
- repositoryNode(
- repository,
- nodes.filter((node) =>
- (
- node.change.after?.path || node.change.before?.path
- )?.startsWith(repository.dir)
- )
- )
- );
- },
- }),
-};
-
-export const directoryGrouping: ChangeGrouping = {
- id: "directory",
- title: "Directory",
- isAvailable: true,
- groupFn: groupByDirectory({
- shouldCollapseDirectories: true,
- createDirectoryNode: ({ dirPath, parentNodePath, children }) =>
- directoryNode(dirPath, parentNodePath, children),
- getPath: (node) => {
- if (node.change.after.isDir) {
- throw new Error("isDir=true is not supported for changes ATM!");
- }
- return node.change.after.path;
- },
- }),
-};
-
-export const groupings = [repositoryGrouping, directoryGrouping];
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/changesTreeNodeRenderers.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/changesTreeNodeRenderers.tsx
deleted file mode 100644
index 0beed195..00000000
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/changesTreeNodeRenderers.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { IntlMessageFormat } from "intl-messageformat";
-import { changesGroupingState } from "./ChangesView.state";
-import React, { Key } from "react";
-import { ItemProps } from "@react-types/shared";
-import path from "path";
-import {
- HighlightedTextValue,
- ItemLayout,
- PlatformIcon,
-} from "@intellij-platform/core";
-import { RepoColorIcon } from "./StyledRepoColorSquare";
-import { StyledCurrentBranchTag } from "./StyledCurrentBranchTag";
-import { RepoCurrentBranchName } from "../../RepoCurrentBranchName";
-import { DIR_ICON, getIconForFile } from "../../../file-utils";
-import { useRecoilValue } from "recoil";
-import {
- AnyNode,
- ChangeBrowserNode,
- ChangeListNode,
- ChangeNode,
- DirectoryNode,
- isGroupNode,
- RepositoryNode,
-} from "./change-view-nodes";
-
-/**
- * Necessary properties for each node, to be passed to `Item`s rendered in tree.
- * Some properties like `key` or `childItems` are not included, as they are calculated similarly
- * for all types of nodes.
- */
-type RenderedNode = { textValue: string; rendered: React.ReactNode };
-type NodeRenderer> = (
- node: T,
- metadata: { fileCount: number; dirCount: number }
-) => RenderedNode;
-const fileCountMsg = new IntlMessageFormat(
- `{fileCount, plural,
- =0 {No files}
- =1 {1 file}
- other {# files}
- }`,
- "en-US"
-);
-const repoNodeItemProps: NodeRenderer = (
- node,
- { fileCount }
-) => ({
- textValue: path.basename(node.repository.dir),
- rendered: (
-
-
-
- {fileCountMsg.format({ fileCount })}
-
-
-
-
- ),
-});
-const directoryNodeItemProps: NodeRenderer = (
- node,
- { fileCount }
-) => ({
- textValue: node.parentNodePath
- ? path.relative(node.parentNodePath, node.dirPath)
- : node.dirPath,
- rendered: (
-
-
-
- {fileCountMsg.format({ fileCount })}
-
- ),
-});
-const changeListNodeItemProps: NodeRenderer = (
- node,
- { fileCount }
-) => ({
- textValue: node.changeList.name,
- rendered: (
-
-
-
-
-
- {fileCountMsg.format({ fileCount })}{" "}
- {/*in IntelliJ it's not shown if it's empty, but why not!*/}
-
-
- ),
-});
-const ChangeNodeHint = ({ node }: { node: ChangeNode }): React.ReactElement => {
- const isGroupedByDirectory = useRecoilValue(
- changesGroupingState("directory")
- );
- return (
-
- {!isGroupedByDirectory && path.dirname(node.change.after.path)}
-
- );
-};
-const changeNodeItemProps: NodeRenderer = (node) => ({
- textValue: path.basename(node.change.after.path),
- rendered: (
-
-
-
-
-
- ),
-});
-
-const nodeRenderers: {
- [type in AnyNode["type"]]: NodeRenderer;
-} = {
- repo: repoNodeItemProps,
- directory: directoryNodeItemProps,
- changelist: changeListNodeItemProps,
- change: changeNodeItemProps,
-};
-
-export const getChangeListTreeItemProps = ({
- node,
- fileCountsMap,
-}: {
- node: AnyNode;
- fileCountsMap: Map;
-}): ItemProps & { key: Key } => {
- // Would make sense to handle missing renderer case if implementation is changed to be extensible with regard
- // to node types
- const nodeRenderer: NodeRenderer /* FIXME: why is any needed? */ =
- nodeRenderers[node.type];
- const { textValue, rendered } = nodeRenderer(node, {
- fileCount: fileCountsMap.get(node.key) || 0,
- dirCount: 0, // It seems it's only used for ignored files subtree
- });
- return {
- key: node.key,
- childItems: isGroupNode(node) ? node.children : [],
- hasChildItems: isGroupNode(node) && node.children?.length > 0,
- textValue,
- children: rendered,
- };
-};
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/changesViewTreeNodeRenderer.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/changesViewTreeNodeRenderer.tsx
new file mode 100644
index 00000000..a8569286
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/ChangesView/changesViewTreeNodeRenderer.tsx
@@ -0,0 +1,31 @@
+import { HighlightedTextValue, ItemLayout } from "@intellij-platform/core";
+import React from "react";
+import { ChangeListNode, ChangesViewTreeNode } from "./ChangesView.state";
+import {
+ createChangesTreeNodeRenderer,
+ formatFileCount,
+ NodeRenderer,
+} from "../ChangesTree/changesTreeNodeRenderers";
+
+const changeListNodeRenderer: NodeRenderer = (
+ node,
+ { fileCount }
+) => ({
+ textValue: node.changeList.name,
+ rendered: (
+
+
+
+
+
+ {formatFileCount(fileCount)}{" "}
+ {/*in IntelliJ it's not shown if it's empty, but why not!*/}
+
+
+ ),
+});
+
+export const changesViewTreeNodeRenderer =
+ createChangesTreeNodeRenderer({
+ changelist: changeListNodeRenderer,
+ });
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/useCommitChanges.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/useCommitChanges.tsx
index 22241254..3ea94386 100644
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/useCommitChanges.tsx
+++ b/packages/example-app/src/VersionControl/Changes/ChangesView/useCommitChanges.tsx
@@ -1,6 +1,9 @@
import path from "path";
import { groupBy } from "ramda";
import { useRecoilCallback } from "recoil";
+import { IntlMessageFormat } from "intl-messageformat";
+import React from "react";
+import { useBalloonManager } from "@intellij-platform/core";
import { notNull } from "@intellij-platform/core/utils/array-utils";
import { fs } from "../../../fs/fs";
@@ -9,12 +12,10 @@ import {
useUpdateVcsFileStatuses,
vcsRootForFile,
} from "../../file-status.state";
-import { Change, useRefreshChanges } from "../change-lists.state";
+import { useRefreshChanges } from "../change-lists.state";
import { useRunTask } from "../../../tasks";
import { commitTaskIdState } from "./ChangesView.state";
-import { IntlMessageFormat } from "intl-messageformat";
-import { useBalloonManager } from "@intellij-platform/core";
-import React from "react";
+import { Change } from "../Change";
const commitSuccessfulMessage = new IntlMessageFormat(
`{count, plural,
@@ -47,7 +48,7 @@ export function useCommitChanges() {
(
await Promise.all(
changes.map(async (change) => {
- const filePath = change.after.path;
+ const filePath = Change.path(change);
const repoRoot = snapshot
.getLoadable(vcsRootForFile(filePath))
.getValue();
diff --git a/packages/example-app/src/VersionControl/Changes/Rollback/RollbackWindow.tsx b/packages/example-app/src/VersionControl/Changes/Rollback/RollbackWindow.tsx
index 774eeb98..b193102d 100644
--- a/packages/example-app/src/VersionControl/Changes/Rollback/RollbackWindow.tsx
+++ b/packages/example-app/src/VersionControl/Changes/Rollback/RollbackWindow.tsx
@@ -26,19 +26,21 @@ import {
ActionButton,
} from "@intellij-platform/core";
-import { changesTreeNodesState } from "../ChangesView/ChangesView.state";
-import { getChangeListTreeItemProps } from "../ChangesView/changesTreeNodeRenderers";
+import {
+ changesTreeNodesState,
+ ChangesViewTreeNode,
+} from "../ChangesView/ChangesView.state";
import { rollbackViewState } from "./rollbackView.state";
import { allChangesState, useRollbackChanges } from "../change-lists.state";
import { ChangesSummary } from "../ChangesSummary";
-import { groupings } from "../ChangesView/changesGroupings";
+import { defaultChangeGroupings } from "../ChangesTree/changesGroupings";
import { RollbackTreeContextMenu } from "./RollbackTreeContextMenu";
import { notImplemented } from "../../../Project/notImplemented";
import {
- AnyNode,
getNodeKeyForChange,
isGroupNode,
-} from "../ChangesView/change-view-nodes";
+} from "../ChangesTree/ChangeTreeNode";
+import { changesViewTreeNodeRenderer } from "../ChangesView/changesViewTreeNodeRenderer";
const StyledContainer = styled.div`
box-sizing: border-box;
@@ -78,7 +80,7 @@ export function RollbackWindow() {
const [includedChangeKeys, setIncludedChangeKeys] = useState(
new Set(initiallyIncludedChangeKeys)
);
- const nestedSelection = useNestedSelectionState(
+ const nestedSelection = useNestedSelectionState(
{
rootNodes,
getKey: (node) => node.key,
@@ -131,7 +133,7 @@ export function RollbackWindow() {
{
// FIXME
- groupings.map((grouping) => (
+ defaultChangeGroupings.map((grouping) => (
- {grouping.title}
))
}
@@ -166,7 +168,7 @@ export function RollbackWindow() {
fillAvailableSpace
>
{(node) => {
- const props = getChangeListTreeItemProps({
+ const props = changesViewTreeNodeRenderer.getItemProps({
node,
fileCountsMap,
});
diff --git a/packages/example-app/src/VersionControl/Changes/Rollback/rollbackView.state.ts b/packages/example-app/src/VersionControl/Changes/Rollback/rollbackView.state.ts
index 8342231c..e68c790c 100644
--- a/packages/example-app/src/VersionControl/Changes/Rollback/rollbackView.state.ts
+++ b/packages/example-app/src/VersionControl/Changes/Rollback/rollbackView.state.ts
@@ -1,16 +1,18 @@
import { atom, selector } from "recoil";
-import { Change } from "../change-lists.state";
-import { changesTreeNodesState } from "../ChangesView/ChangesView.state";
+import {
+ changesTreeNodesState,
+ ChangesViewTreeNode,
+} from "../ChangesView/ChangesView.state";
import {
getExpandAllKeys,
getExpandedToNodesKeys,
} from "@intellij-platform/core/utils/tree-utils";
import { Bounds } from "@intellij-platform/core/Overlay";
import {
- AnyNode,
getNodeKeyForChange,
isGroupNode,
-} from "../ChangesView/change-view-nodes";
+} from "../ChangesTree/ChangeTreeNode";
+import { Change } from "../Change";
const isOpen = atom({
key: "rollbackView.isOpen",
@@ -41,7 +43,7 @@ const initiallyExpandedKeys = selector({
get: ({ get }) => {
const includedChanges = get(initiallyIncludedChanges);
const nodes = get(rootNodes);
- const expandedKeys = getExpandedToNodesKeys(
+ const expandedKeys = getExpandedToNodesKeys(
(node) => (isGroupNode(node) ? node.children : null),
(node) => node.key,
nodes,
@@ -50,7 +52,7 @@ const initiallyExpandedKeys = selector({
return new Set(
expandedKeys.length
? expandedKeys
- : getExpandAllKeys(
+ : getExpandAllKeys(
(node) => (isGroupNode(node) ? node.children : null),
(node) => node.key,
nodes
diff --git a/packages/example-app/src/VersionControl/Changes/StyledCurrentBranchTag.tsx b/packages/example-app/src/VersionControl/Changes/StyledCurrentBranchTag.tsx
new file mode 100644
index 00000000..14db7a50
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/StyledCurrentBranchTag.tsx
@@ -0,0 +1,31 @@
+import { styled } from "@intellij-platform/core";
+
+export const StyledCurrentBranchTag = styled.span`
+ display: inline-flex;
+ align-self: center;
+ height: 1.1rem;
+ line-height: 1.1rem;
+ padding: 0 0.25rem;
+
+ background: ${({ theme }) =>
+ theme.currentBackgroundAware(
+ // FIXME: color-mix algorithm used here seems to be different from how colors are mixed in the reference impl.
+ `color-mix(in srgb, ${theme.color(
+ "VersionControl.Log.Commit.currentBranchBackground",
+ theme.dark ? "rgb(63, 71, 73)" : "rgb(228, 250, 255)"
+ )}, ${theme.color(
+ "VersionControl.RefLabel.backgroundBase",
+ theme.dark ? "#fff" : "#000"
+ )} ${
+ (theme.value("VersionControl.RefLabel.backgroundBrightness") ??
+ 0.08) * 100
+ }%)`
+ )};
+ color: ${({ theme }) =>
+ theme.currentForegroundAware(
+ theme.color(
+ "VersionControl.RefLabel.foreground",
+ theme.dark ? "#909090" : "#7a7a7a"
+ )
+ )};
+`;
diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/StyledRepoColorSquare.tsx b/packages/example-app/src/VersionControl/Changes/StyledRepoColorSquare.tsx
similarity index 69%
rename from packages/example-app/src/VersionControl/Changes/ChangesView/StyledRepoColorSquare.tsx
rename to packages/example-app/src/VersionControl/Changes/StyledRepoColorSquare.tsx
index d7a12838..e2c36b2c 100644
--- a/packages/example-app/src/VersionControl/Changes/ChangesView/StyledRepoColorSquare.tsx
+++ b/packages/example-app/src/VersionControl/Changes/StyledRepoColorSquare.tsx
@@ -1,7 +1,7 @@
import { styled, Theme } from "@intellij-platform/core";
-import React from "react";
+import React, { ForwardedRef, HTMLAttributes } from "react";
import { useRecoilValue } from "recoil";
-import { vcsRootsState } from "../../file-status.state";
+import { vcsRootsState } from "../file-status.state";
import { useTheme } from "styled-components";
const StyledRepoColorSquare = styled.span`
@@ -32,8 +32,20 @@ const useRepoRootColor = (rootPath: string) => {
return colors[index % colors.length] ?? colors[0];
};
-export const RepoColorIcon = ({ rootPath }: { rootPath: string }) => (
-
+export const RepoColorIcon = React.forwardRef(
+ (
+ {
+ rootPath,
+ ...props
+ }: {
+ rootPath: string;
+ } & HTMLAttributes,
+ ref: ForwardedRef
+ ) => (
+
+ )
);
diff --git a/packages/example-app/src/VersionControl/Changes/change-lists.state.ts b/packages/example-app/src/VersionControl/Changes/change-lists.state.ts
index 5e46497e..b3ea42e4 100644
--- a/packages/example-app/src/VersionControl/Changes/change-lists.state.ts
+++ b/packages/example-app/src/VersionControl/Changes/change-lists.state.ts
@@ -11,16 +11,7 @@ import {
vcsRootForFile,
vcsRootsState,
} from "../file-status.state";
-
-export type Revision = {
- path: string;
- isDir: boolean;
-};
-
-export interface Change {
- before: Revision;
- after: Revision;
-}
+import { Change } from "./Change";
export interface ChangeListObj {
id: string;
@@ -85,13 +76,25 @@ const refreshChangesCallback =
})
);
const unversionedPaths = []; // FIXME: handle unversioned files
- const changes = allStatusMatrices
- .flat()
- .map(([path, head, workdir, stage]) => ({
+ const changes = allStatusMatrices.flat().map(
+ ([path, head, workdir, stage]): Change => ({
// FIXME: change object creation doesn't cover all kind of changes.
- after: { path, isDir: false },
- before: { path, isDir: false },
- }));
+ after: {
+ path,
+ isDir: false,
+ content(): Promise {
+ throw new Error("Not implemented");
+ },
+ },
+ before: {
+ path,
+ isDir: false,
+ content(): Promise {
+ throw new Error("Not implemented");
+ },
+ },
+ })
+ );
// FIXME: changes now all go to default change list on each refresh. fix it.
set(changeListsState, (changeLists) =>
@@ -122,15 +125,15 @@ export const useRollbackChanges = () => {
const changesWithRepoRoots = await Promise.all(
changes
- .filter((change) => !change.after.isDir)
+ .filter((change) => !change.after?.isDir)
.map(async (change) => {
const repoRoot = (await snapshot.getPromise(
- vcsRootForFile(change.after.path)
+ vcsRootForFile(Change.path(change))
))!; // FIXME: handle null
return {
repoRoot,
- fullPath: change.after.path,
- relativePath: path.relative(repoRoot, change.after.path),
+ fullPath: Change.path(change),
+ relativePath: path.relative(repoRoot, Change.path(change)),
};
})
);
diff --git a/packages/example-app/src/VersionControl/Changes/detectRenames.ts b/packages/example-app/src/VersionControl/Changes/detectRenames.ts
new file mode 100644
index 00000000..efb3cca9
--- /dev/null
+++ b/packages/example-app/src/VersionControl/Changes/detectRenames.ts
@@ -0,0 +1,92 @@
+import { diffLines } from "diff";
+import { Change } from "./Change";
+import { notNull } from "@intellij-platform/core/utils/array-utils";
+
+// Read more: https://www.git-scm.com/docs/git-diff/2.6.7#:~:text=The-,similarity%20index,-is%20the%20percentage
+const DEFAULT_SIMILARITY_INDEX = 50;
+
+/**
+ * Given a list of changes, detects renames based on the change in the content of changes where only `before` or
+ * `after` is present, and consolidates them into one change, making it count as a rename.
+ * @param changes
+ */
+export async function detectRenames(
+ changes: ReadonlyArray
+): Promise {
+ const deletions: Change[] = changes.filter(
+ (change) => Change.type(change) === "DELETED"
+ );
+ const additions: Change[] = changes.filter(
+ (change) => Change.type(change) === "ADDED"
+ );
+
+ const contents = new Map();
+ await Promise.all(
+ [...deletions, ...additions].map(async (change) => {
+ contents.set(change, {
+ before: (await change.before?.content()) ?? "",
+ after: (await change.after?.content()) ?? "",
+ });
+ })
+ );
+
+ const foundRenames: Change[] = [];
+ return changes
+ .map((change): Change | null => {
+ if (foundRenames.includes(change)) {
+ return null;
+ }
+ const type = Change.type(change);
+ if (type === "DELETED") {
+ for (const candidate of additions) {
+ const similarity = computeSimilarity(
+ contents.get(candidate)?.after || "",
+ contents.get(change)?.before || ""
+ );
+ if (similarity >= DEFAULT_SIMILARITY_INDEX) {
+ foundRenames.push(candidate);
+ additions.splice(additions.indexOf(candidate), 1);
+ return {
+ before: change.before,
+ after: candidate.after,
+ };
+ }
+ }
+ }
+ if (type === "ADDED") {
+ for (const candidate of deletions) {
+ const similarity = computeSimilarity(
+ contents.get(candidate)?.before || "",
+ contents.get(change)?.after || ""
+ );
+ if (similarity >= DEFAULT_SIMILARITY_INDEX) {
+ deletions.splice(deletions.indexOf(candidate), 1);
+ foundRenames.push(candidate);
+ return {
+ before: candidate.before,
+ after: change.after,
+ };
+ }
+ }
+ }
+ return change;
+ })
+ .filter(notNull);
+}
+
+// Function to compute similarity
+function computeSimilarity(file1Content: string, file2Content: string) {
+ const changes = diffLines(file1Content, file2Content);
+ const { unchanged, total } = changes.reduce(
+ ({ total, unchanged }, change) => {
+ const count = change.count ?? 0;
+
+ return {
+ total: total + count,
+ unchanged: unchanged + (!change.added && !change.removed ? count : 0),
+ };
+ },
+ { total: 0, unchanged: 0 }
+ );
+ return 100 * (unchanged / total);
+}
diff --git a/packages/example-app/src/VersionControl/Changes/useChangesViewActionDefinitions.tsx b/packages/example-app/src/VersionControl/Changes/useChangesViewActionDefinitions.tsx
index 06426bcd..d64ed6b6 100644
--- a/packages/example-app/src/VersionControl/Changes/useChangesViewActionDefinitions.tsx
+++ b/packages/example-app/src/VersionControl/Changes/useChangesViewActionDefinitions.tsx
@@ -7,6 +7,7 @@ import {
changeListsUnderSelection,
changesTreeNodesState,
changesUnderSelectedKeys,
+ ChangesViewTreeNode,
expandedKeysState,
includedChangeKeysState,
openRollbackWindowForSelectionCallback,
@@ -18,6 +19,10 @@ import {
useRefreshChanges,
useSetActiveChangeList,
} from "./change-lists.state";
+import path from "path";
+import { IntlMessageFormat } from "intl-messageformat";
+import { getExpandedToNodesKeys } from "@intellij-platform/core/utils/tree-utils";
+
import { COMMIT_TOOLWINDOW_ID } from "../CommitToolWindow";
import { VcsActionIds } from "../VcsActionIds";
import { useToolWindowManager } from "../../Project/useToolWindowManager";
@@ -26,15 +31,9 @@ import {
activePathsState,
currentProjectFilesState,
} from "../../Project/project.state";
-import path from "path";
-import { IntlMessageFormat } from "intl-messageformat";
import { useEditorStateManager } from "../../Editor/editor.state";
-import {
- AnyNode,
- getNodeKeyForChange,
- isGroupNode,
-} from "./ChangesView/change-view-nodes";
-import { getExpandedToNodesKeys } from "@intellij-platform/core/utils/tree-utils";
+import { getNodeKeyForChange, isGroupNode } from "./ChangesTree/ChangeTreeNode";
+import { Change } from "./Change";
export const useChangesViewActionDefinitions = (): ActionDefinition[] => {
const openRollbackWindow = useRecoilCallback(
@@ -117,7 +116,7 @@ function useCheckInActionDefinition(): ActionDefinition {
);
const allChanges = useRecoilValue(allChangesState);
const containsChange = roots.some((root) =>
- allChanges.find((change) => isPathInside(root, change.after.path))
+ allChanges.find((change) => isPathInside(root, Change.path(change)))
);
return {
@@ -162,7 +161,7 @@ function useCheckInProjectAction(): ActionDefinition {
...getExpandedToNodesKeys(
(node) => (isGroupNode(node) ? node.children : null),
(node) => node.key,
- rootNodes as AnyNode[],
+ rootNodes as ChangesViewTreeNode[],
[keyToSelect]
),
])
@@ -194,9 +193,9 @@ function useJumpToSourceAction(): ActionDefinition {
icon: ,
actionPerformed: () => {
[...selectedChanges].forEach((change, index, arr) => {
- if (!change.after.isDir) {
+ if (!change.after?.isDir) {
editorStateManager.openPath(
- change.after.path,
+ Change.path(change),
index === arr.length - 1
);
}
diff --git a/packages/example-app/src/VersionControl/CommitToolWindow.tsx b/packages/example-app/src/VersionControl/CommitToolWindow.tsx
index 2513a076..8829cb7c 100644
--- a/packages/example-app/src/VersionControl/CommitToolWindow.tsx
+++ b/packages/example-app/src/VersionControl/CommitToolWindow.tsx
@@ -11,8 +11,9 @@ import { ChangesViewPane } from "./Changes/ChangesView/ChangesViewPane";
import { allChangesState } from "./Changes/change-lists.state";
import { vcsRootForFile } from "./file-status.state";
import { LocalBranch, repoBranchesState } from "./Branches/branches.state";
-import { changesGroupingState } from "./Changes/ChangesView/ChangesView.state";
+import { changesGroupingActiveState } from "./Changes/ChangesView/ChangesView.state";
import { TrackingBranchInfo } from "./TrackingBranchInfo";
+import { Change } from "./Changes/Change";
export const COMMIT_TOOLWINDOW_ID = "Commit";
@@ -20,7 +21,7 @@ const changesBranchesState = selector({
key: "vcs/commitToolWindow/changedRepos",
get: ({ get }): Array<{ repoRoot: string; branch: LocalBranch }> => {
const changesRepoRoots = get(allChangesState)
- .map((change) => get(vcsRootForFile(change.after.path)))
+ .map((change) => get(vcsRootForFile(Change.path(change))))
.filter(notNull);
return changesRepoRoots
@@ -39,7 +40,7 @@ const changesBranchesState = selector({
export const CommitToolWindow = () => {
const areChangesGroupedByRepo = useRecoilValue(
- changesGroupingState("repository")
+ changesGroupingActiveState("repository")
);
const changesBranches = useRecoilValue(changesBranchesState);
const title =
diff --git a/packages/example-app/src/VersionControl/FileStatusColor.tsx b/packages/example-app/src/VersionControl/FileStatusColor.tsx
index 219a7f81..852a83d7 100644
--- a/packages/example-app/src/VersionControl/FileStatusColor.tsx
+++ b/packages/example-app/src/VersionControl/FileStatusColor.tsx
@@ -36,6 +36,7 @@ export const StatusColor: React.FC<{ status: FileStatus }> = ({
children,
status,
}) => {
- const color = useStatusColor(status);
+ const theme = useTheme() as Theme;
+ const color = theme.currentForegroundAware(useStatusColor(status));
return {children};
};
diff --git a/packages/example-app/src/VersionControl/VcsActionIds.tsx b/packages/example-app/src/VersionControl/VcsActionIds.tsx
index 31733ce4..a652ac11 100644
--- a/packages/example-app/src/VersionControl/VcsActionIds.tsx
+++ b/packages/example-app/src/VersionControl/VcsActionIds.tsx
@@ -29,4 +29,7 @@ export const VcsActionIds = {
FOCUS_TEXT_FILTER: "Vcs.Log.FocusTextFilter",
MATCH_CASE: "Vcs.Log.MatchCaseAction",
REG_EXP: "Vcs.Log.EnableFilterByRegexAction",
+ COPY_REVISION_NUMBER: "Vcs.CopyRevisionNumberAction",
+ LOG_REFRESH: "Vcs.Log.Refresh",
+ SHOW_DETAILS: "Vcs.Log.ShowDetailsAction",
};
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.state.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.state.tsx
index 7ce83618..85a330fc 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.state.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.state.tsx
@@ -15,9 +15,10 @@ import {
} from "../../Branches/branches.state";
import {
DirectoryNode,
- groupByDirectory,
+ createGroupByDirectory,
} from "../../../tree-utils/groupByDirectory";
import { vcsRootsState } from "../../file-status.state";
+import { vcsLogTabState } from "../vcs-logs.state";
type LocalBranchNode = {
type: "localBranch";
@@ -159,7 +160,7 @@ function getRemoteBranchNodes(
}
export const branchesTreeNodeState = selector({
- key: "vcs/branchesTree/nodes",
+ key: "vcs/logs/branchesTree/nodes",
get: ({ get }): AnyBranchTreeNode[] => {
// TODO: sorting. favorite should be on top. Others should be alphabetical
const repoBranches = get(allBranchesState);
@@ -207,7 +208,7 @@ export const branchesTreeNodeState = selector({
];
},
});
-const groupNodesByDirectory = groupByDirectory<
+const groupNodesByDirectory = createGroupByDirectory<
LocalBranchNode | RemoteBranchNode,
BranchDirectoryNode
>({
@@ -218,26 +219,39 @@ export const branchTreeGroupingState = atomFamily<
boolean,
"directory" | "repository"
>({
- key: "vcs/branchesTree/grouping",
+ key: "vcs/logs/branchesTree/grouping",
default: true,
});
export const isGroupByRepositoryAvailableState = selector({
- key: "vcs/branchesTree/grouping/repo/available",
- get: ({ get }) => get(vcsRootsState).length > 0,
-});
-export const branchesTreeRefState = atom>({
- key: "vcs/branchesTree/ref",
- default: React.createRef(),
- dangerouslyAllowMutability: true,
-});
-export const selectedKeysState = atom({
- key: "vcs/branchesTree/selectedKeys",
- default: new Set(),
-});
-export const expandedKeysState = atom>({
- key: "vcs/branchesTree/expandedKeys",
- default: new Set(),
+ key: "vcs/logs/branchesTree/grouping/repo/available",
+ get: ({ get }) => get(vcsRootsState).length > 1,
});
+export const branchesTreeRefState = vcsLogTabState(
+ atomFamily, string>({
+ key: "vcs/logs/branchesTree/ref",
+ default: React.createRef(),
+ dangerouslyAllowMutability: true,
+ })
+);
+export const selectedKeysState = vcsLogTabState(
+ atomFamily({
+ key: "vcs/logs/branchesTree/selectedKeys",
+ default: new Set(),
+ })
+);
+export const expandedKeysState = vcsLogTabState(
+ atomFamily, string>({
+ key: "vcs/logs/branchesTree/expandedKeys",
+ default: new Set(),
+ })
+);
+
+export const searchInputState = vcsLogTabState(
+ atomFamily({
+ key: "vcs/logs/branchesTree/searchInput",
+ default: "",
+ })
+);
const groupings = [
{
@@ -255,7 +269,7 @@ const groupings = [
},
];
export const availableGroupingsState = selector({
- key: "vcs/branchesTree/availableGroupings",
+ key: "vcs/logs/branchesTree/availableGroupings",
get: ({ get }) =>
groupings
.filter((grouping) =>
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.tsx
index e8861700..6842fc2b 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/BranchesTree.tsx
@@ -15,6 +15,7 @@ import {
branchesTreeNodeState,
branchesTreeRefState,
expandedKeysState,
+ searchInputState,
selectedKeysState,
} from "./BranchesTree.state";
import { StyledHeader } from "../styled-components";
@@ -41,12 +42,18 @@ const StyledSearchIconContainer = styled.span`
cursor: text;
`;
-export function BranchesTree() {
+export function BranchesTree({ tabKey }: { tabKey: string }) {
const branchesTreeNodes = useRecoilValue(branchesTreeNodeState);
- const [selectedKeys, setSelectedKeys] = useRecoilState(selectedKeysState);
- const [expandedKeys, setExpandedKeys] = useRecoilState(expandedKeysState);
- const treeRef = useRecoilValue(branchesTreeRefState);
+ const [selectedKeys, setSelectedKeys] = useRecoilState(
+ selectedKeysState(tabKey)
+ );
+ const [expandedKeys, setExpandedKeys] = useRecoilState(
+ expandedKeysState(tabKey)
+ );
+ const [searchTerm, setSearchTerm] = useRecoilState(searchInputState(tabKey));
+ const treeRef = useRecoilValue(branchesTreeRefState(tabKey));
const ref = useRef(null);
+ const searchInputRef = useRef(null);
const selectionManagerRef = useRef(null);
const [isInputFocused, setInputFocused] = useState(false);
const setBranchFilter = useSetRecoilState(vcsLogFilterCurrentTab.branch);
@@ -54,6 +61,26 @@ export function BranchesTree() {
collectionRef: ref,
selectionManager: selectionManagerRef.current,
});
+ /**
+ * TODO: remaining from search:
+ * - Make the search input red when there is no match
+ * - filter non-matching tree nodes out
+ * - search in the whole tree, not just nodes that are currently visible based on expandedKeys
+ * - update expanded keys to show matches, as a side effect, every time matches are updated.
+ * The above items are postponed to think more about flexibility of SpeedSearchTree API.
+ * Some rough thoughts about different approaches:
+ * - Provide generic tree utilities to search in the whole tree. In SpeedSearchTree allow for full control over
+ * `matches`. It will be in usage side that matches are calculated based on search input value, and passed to
+ * SpeedSearchTree as an input.
+ * - In SpeedSearchTree, allow for passing a custom search function, which would accept the tree collection object,
+ * the search query, and the default match function (minusculeMatch).
+ * - Make Tree more flexible or introduce a more flexible base component, so that speed search for tree can be
+ * implementated in a more composable way. Tree (or TreeBase) would accept props for intercepting `state` creation,
+ * and it would also allow passing additional props to the tree container. Then a more generic CollectionSpeedSearch
+ * component would offer options on how to control and customize speed search, and would return props to be passed
+ * to collection components.
+ */
+
// FIXME: selectedKeys and expandedKeys can become invalid due to changes in nodes, which makes the tree view
// not react to the key events. Ideally, the tree view should be robust regarding invalid keys, and otherwise
// the keys should be validated everytime nodes change.
@@ -65,10 +92,17 @@ export function BranchesTree() {
{/* FIXME: tabIndex -1 is a workaround to not get the input focused when the toolwindow opens. Not ideal.*/}
setSearchTerm(e.target.value)}
{...mergeProps(collectionSearchInputProps, {
- onFocus: () => setInputFocused(true),
- onBlur: () => setInputFocused(false),
+ onFocus: () => {
+ setInputFocused(true);
+ },
+ onBlur: () => {
+ setInputFocused(false);
+ },
})}
/>
@@ -89,7 +123,13 @@ export function BranchesTree() {
}
}}
fillAvailableSpace
+ // speed search related props
showAsFocused={isInputFocused}
+ searchTerm={searchTerm}
+ onSearchTermChange={setSearchTerm}
+ keepSearchActiveOnBlur
+ hideSpeedSearchPopup
+ isSearchActive
>
{(node) => {
// @ts-expect-error we need to somehow infer the type of `node.type`
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/VcsBranchesView.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/VcsBranchesView.tsx
index 84b7b166..c5b97736 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/VcsBranchesView.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/VcsBranchesView.tsx
@@ -29,8 +29,8 @@ const StyledContainer = styled.div`
height: 100%;
`;
-export function VcsBranchesView() {
- const treeRef = useRecoilValue(branchesTreeRefState);
+export function VcsBranchesView({ tabKey }: { tabKey: string }) {
+ const treeRef = useRecoilValue(branchesTreeRefState(tabKey));
const actions: ActionDefinition[] = [
{
id: VcsActionIds.GIT_UPDATE_SELECTED,
@@ -90,7 +90,7 @@ export function VcsBranchesView() {
{({ shortcutHandlerProps }) => (
-
+
)}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/branchTreeNodeRenderers.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/branchTreeNodeRenderers.tsx
index b7b0e3ec..a58f9b14 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/branchTreeNodeRenderers.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/BranchesView/branchTreeNodeRenderers.tsx
@@ -12,7 +12,7 @@ import {
} from "@intellij-platform/core";
import { TrackingBranchInfo } from "../../TrackingBranchInfo";
-import { RepoColorIcon } from "../../Changes/ChangesView/StyledRepoColorSquare";
+import { RepoColorIcon } from "../../Changes/StyledRepoColorSquare";
import { AnyBranchTreeNode } from "./BranchesTree.state";
export const branchTreeNodeRenderers: {
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.state.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.state.tsx
new file mode 100644
index 00000000..b28dddc2
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.state.tsx
@@ -0,0 +1,135 @@
+import React, { Key, RefObject } from "react";
+import { atom, atomFamily, selector } from "recoil";
+import { Selection } from "@react-types/shared";
+import { TreeRefValue } from "@intellij-platform/core";
+import { sortTreeNodesInPlace } from "@intellij-platform/core/utils/tree-utils";
+
+import { fs } from "../../../fs/fs";
+import {
+ defaultChangeGroupings,
+ directoryGrouping,
+ getChangesGroupFn,
+} from "../../Changes/ChangesTree/changesGroupings";
+import {
+ changeNode,
+ ChangesTreeGroupNode,
+ changesTreeNodeKey,
+ ExtendedChangesTreeNode,
+} from "../../Changes/ChangesTree/ChangeTreeNode";
+import { changesTreeNodesResult } from "../../Changes/ChangesTree/changesTreeNodesResult";
+import { ChangeListObj } from "../../Changes/change-lists.state";
+import {
+ commitsTableRowsState,
+ selectedCommitsState,
+} from "../CommitsView/CommitsTable.state";
+import { commitChangesTreeNodeRenderer } from "./commitChangesTreeNodeRenderer";
+import { getCommitChanges } from "./getCommitChanges";
+
+export const changesGroupingActiveState = atomFamily({
+ key: "vcs/log/commits/changes/isGroupingActive",
+ default: (id) => id === directoryGrouping.id,
+});
+
+export const commitChangesTreeRefState = atom>({
+ key: "vcs/log/commits/changes/treeRef",
+ default: React.createRef(),
+ dangerouslyAllowMutability: true,
+});
+
+export const expandedKeysState = atom>({
+ key: "vcs/log/commits/changes/expandedKeys",
+ default: new Set(),
+});
+
+export const selectionState = atom({
+ key: "vcs/log/commits/changes/selectedKeys",
+ default: new Set(),
+});
+
+const COMMIT_PARENT_ID = "commitParent";
+
+export type CommitChangesTreeNode =
+ ExtendedChangesTreeNode;
+export interface CommitParentChangeTreeNode
+ extends ChangesTreeGroupNode {
+ changeList: ChangeListObj;
+ oid: string;
+ subject: string;
+}
+/**
+ * Changes corresponding to the currently selected commits.
+ * NOTE: it could be refactored into a selectorFamily, where the selected OIds are
+ * passed as parameter, which would allow for caching the result, as the selector
+ * would not be reevaluated when the only selection is changed, and the state
+ * of the table is not changed.
+ */
+export const changedFilesState = selector({
+ key: "vcs/log/commits/selection/changes",
+ get: async ({ get }) => {
+ const selectedCommits = get(selectedCommitsState);
+ const commitLogItem = selectedCommits[0]; // FIXME: take all selected commits into account
+ const { byOid } = get(commitsTableRowsState);
+ const toRef = commitLogItem?.readCommitResult?.oid;
+ if (toRef) {
+ const groupFn = getChangesGroupFn({
+ get,
+ groupings: defaultChangeGroupings,
+ isActive: changesGroupingActiveState,
+ });
+
+ const rootNodes = await Promise.all(
+ commitLogItem?.readCommitResult.commit.parent.map((parent) =>
+ getCommitChanges({
+ fs,
+ fromRef: parent,
+ toRef,
+ dir: commitLogItem.repoPath,
+ }).then(
+ (changes) =>
+ ({
+ type: COMMIT_PARENT_ID,
+ key: changesTreeNodeKey(COMMIT_PARENT_ID, parent),
+ oid: parent,
+ subject: byOid[parent]?.readCommitResult.commit.message || "",
+ children: groupFn([...changes].map((node) => changeNode(node))),
+ } as CommitParentChangeTreeNode)
+ )
+ )
+ );
+ const { expandAllKeys, fileCountsMap, ...result } =
+ changesTreeNodesResult(
+ rootNodes.length === 1
+ ? rootNodes[0].children
+ : rootNodes.filter(({ children }) => children.length > 0)
+ );
+
+ type Writeable = { -readonly [P in keyof T]: T[P] };
+ sortTreeNodesInPlace(
+ (node) =>
+ commitChangesTreeNodeRenderer
+ .getTextValue(node, { fileCountsMap })
+ .toLowerCase(),
+ {
+ // cheating with the types by removing readonly constraints to use the inplace sort tree util.
+ // it should be ok, since the ReadonlyArray is only a compile-time thing.
+ roots: result.rootNodes as Writeable,
+ getChildren: (node) =>
+ "children" in node
+ ? (node.children as Writeable)
+ : null,
+ }
+ );
+
+ return {
+ ...result,
+ fileCountsMap,
+ expandAllKeys: new Set(
+ [...expandAllKeys].filter(
+ (key) => !`${key}`.startsWith(COMMIT_PARENT_ID)
+ )
+ ),
+ };
+ }
+ return null;
+ },
+});
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.tsx
new file mode 100644
index 00000000..9a09f8fa
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.tsx
@@ -0,0 +1,144 @@
+import React, { HTMLAttributes, ReactNode, useEffect, useState } from "react";
+import { useRecoilState, useRecoilValue, useRecoilValueLoadable } from "recoil";
+
+import {
+ HelpTooltip,
+ PlatformIcon,
+ PositionedTooltipTrigger,
+ SpeedSearchTree,
+ styled,
+} from "@intellij-platform/core";
+
+import { useLatestRecoilValue } from "../../../recoil-utils";
+import { LoadingGif } from "../../../LoadingGif";
+import { StyledPlaceholderContainer } from "../styled-components";
+import { selectedCommitsState } from "../CommitsView/CommitsTable.state";
+import {
+ changedFilesState,
+ commitChangesTreeRefState,
+ expandedKeysState,
+ selectionState,
+} from "./CommitsChangedFiles.state";
+import { commitChangesTreeNodeRenderer } from "./commitChangesTreeNodeRenderer";
+
+const DEFAULT_LOADING_DELAY_MS = 500; // To be shared later in more places.
+
+const StyledLoadingWrapper = styled.div`
+ position: absolute;
+ inset: 0;
+ gap: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+`;
+/**
+ * TODO: handle multiple selected commits (check Changes.ShowChangesFromParents https://github.com/JetBrains/intellij-community/blob/ac57611a0612bd65ba2a19c841a4f95b40591134/platform/vcs-log/impl/src/com/intellij/vcs/log/ui/frame/VcsLogChangesBrowser.java#L255-L254)
+ */
+export function CommitChangedFiles({
+ treeShortcutHandlerProps,
+}: {
+ treeShortcutHandlerProps: HTMLAttributes;
+}) {
+ const [selectedCommits] = useLatestRecoilValue(selectedCommitsState);
+ const treeRef = useRecoilValue(commitChangesTreeRefState);
+ const nothingSelected = !selectedCommits?.length;
+ const stateLoadable = useRecoilValueLoadable(changedFilesState);
+ const state = stateLoadable.valueMaybe();
+ const [expandedKeys, setExpandedKeys] = useRecoilState(expandedKeysState);
+ const [selection, setSelection] = useRecoilState(selectionState);
+
+ useEffect(() => {
+ // TODO: expanded keys are supposed to be set based on selected keys
+ setExpandedKeys(state?.expandAllKeys ?? new Set());
+ setSelection(new Set());
+ // FIXME: with this being in an effect here, closing and reopening the toolwindow will
+ // reset the selection, making selection state be effectively like a local state.
+ }, [state]);
+
+ if (nothingSelected) {
+ return (
+
+ Select commits to to view changes
+
+ );
+ }
+ return (
+
+ {stateLoadable.state === "loading" && (
+
+
+
+ Loading...
+
+
+ )}
+ {state && (
+
+ {commitChangesTreeNodeRenderer.itemRenderer({
+ fileCountsMap: state.fileCountsMap,
+ })}
+
+ )}
+ {selectedCommits.length > 1 && (
+
+ Showing diff for more than one commit is not currently
+ supported. Only the first selected commit is taken into
+ account.
+ >
+ }
+ >
+ }
+ >
+ {(props) => (
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+function Delayed({ children }: { children: ReactNode }) {
+ const [waitedEnough, setWaitedEnough] = useState(false);
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setWaitedEnough(true);
+ }, DEFAULT_LOADING_DELAY_MS);
+ return () => clearTimeout(timer);
+ }, []);
+ if (waitedEnough) {
+ return <>{children}>;
+ }
+ return null;
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/commitChangesTreeNodeRenderer.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/commitChangesTreeNodeRenderer.tsx
new file mode 100644
index 00000000..65e46986
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/commitChangesTreeNodeRenderer.tsx
@@ -0,0 +1,47 @@
+import path from "path";
+import { HighlightedTextValue, ItemLayout } from "@intellij-platform/core";
+import React from "react";
+
+import {
+ createChangesTreeNodeRenderer,
+ formatFileCount,
+ NodeRenderer,
+ simpleGroupingRenderer,
+} from "../../Changes/ChangesTree/changesTreeNodeRenderers";
+import { RepositoryNode } from "../../Changes/ChangesTree/ChangeTreeNode";
+import { RepoColorIcon } from "../../Changes/StyledRepoColorSquare";
+import { shortenOid } from "../commit-utils";
+import {
+ CommitChangesTreeNode,
+ CommitParentChangeTreeNode,
+} from "./CommitsChangedFiles.state";
+
+const repoNodeRenderer: NodeRenderer = (
+ node,
+ { fileCount }
+) => ({
+ textValue: path.basename(node.repository.dir),
+ rendered: (
+
+
+
+ {formatFileCount(fileCount)}
+
+ ),
+});
+const commitParentNodeRenderer = simpleGroupingRenderer(
+ (node: CommitParentChangeTreeNode) =>
+ `Changes to ${shortenOid(path.basename(node.oid))} ${node.subject.slice(
+ 0,
+ 50
+ )}`
+);
+export const commitChangesTreeNodeRenderer =
+ createChangesTreeNodeRenderer(
+ {
+ commitParent: commitParentNodeRenderer,
+ },
+ {
+ repo: repoNodeRenderer,
+ }
+ );
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/getCommitChanges.spec.ts b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/getCommitChanges.spec.ts
new file mode 100644
index 00000000..ee909147
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/getCommitChanges.spec.ts
@@ -0,0 +1,41 @@
+import * as fs from "fs";
+import path from "path";
+
+import { getCommitChanges } from "./getCommitChanges";
+import { Change } from "../../Changes/Change";
+
+const repoPath = path.resolve(
+ // eslint-disable-next-line no-undef
+ __dirname,
+ "../../../../fixture/git/diff-example.git"
+);
+
+describe("getCommitChanges", () => {
+ it("consolidates new and deleted files if the content is more than 50% similar", async () => {
+ const changes = await getCommitChanges({
+ fs,
+ fromRef: "21d19576802af30b349e49ce09ff3201755a2457",
+ toRef: "7a4535a30be43ee6a9099ab3f625a3483934de35",
+ gitdir: repoPath,
+ });
+ expect(changes.filter(Change.isModification)).toHaveLength(10);
+ expect(changes.filter(Change.isRename)).toHaveLength(2);
+ expect(changes.filter(Change.isAddition)).toHaveLength(2);
+ expect(changes).toHaveLength(13);
+
+ expect(
+ changes
+ .filter(Change.isRename)
+ .map(({ after, before }) => ({ from: before.path, to: after.path }))
+ ).toEqual([
+ {
+ from: "PopupOnTrigger.cy.tsx",
+ to: "PopupTrigger.cy.tsx",
+ },
+ {
+ from: "PopupOnTrigger.tsx",
+ to: "PopupTrigger.tsx",
+ },
+ ]);
+ });
+});
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/getCommitChanges.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/getCommitChanges.tsx
new file mode 100644
index 00000000..30993bb0
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/getCommitChanges.tsx
@@ -0,0 +1,64 @@
+import git from "isomorphic-git";
+import path from "path";
+
+import { Change, Revision } from "../../Changes/Change";
+import { detectRenames } from "../../Changes/detectRenames";
+
+const cache = {}; // FIXME: find a better caching strategy. Per project?
+export async function getCommitChanges({
+ fromRef,
+ toRef,
+ ...params
+}: {
+ dir?: string;
+ gitdir?: string;
+ fromRef: string;
+ toRef: string;
+} & Pick[0], "fs" | "dir" | "gitdir">): Promise<
+ Change[]
+> {
+ let items: Change[] = await git.walk({
+ ...params,
+ cache,
+ trees: [git.TREE({ ref: fromRef }), git.TREE({ ref: toRef })],
+ map: async function map(
+ filepath,
+ [before, after]
+ ): Promise {
+ const afterOid = await after?.oid();
+ const beforeOid = await before?.oid();
+ const afterType = await after?.type();
+ const beforeType = await before?.type();
+ if (afterOid === beforeOid) {
+ return null;
+ }
+ const afterRevision: Revision = {
+ path: path.join(params.dir || "", filepath),
+ isDir: afterType === "tree",
+ content: async () =>
+ new TextDecoder().decode(
+ (await after?.content()) ?? new Uint8Array()
+ ),
+ };
+ const beforeRevision: Revision = {
+ path: path.join(params.dir || "" || "", filepath),
+ isDir: beforeType === "tree",
+ content: async () =>
+ new TextDecoder().decode(
+ (await before?.content()) ?? new Uint8Array()
+ ),
+ };
+ const type = afterType ?? beforeType;
+ return (
+ type === "blob" && {
+ ...(afterOid ? { after: afterRevision } : null),
+ ...(beforeOid ? { before: beforeRevision } : null),
+ }
+ );
+ },
+ });
+
+ return await detectRenames(
+ (items || []).filter((item: boolean | Change) => item)
+ );
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/BranchesFilterDropdown.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/BranchesFilterDropdown.tsx
index 28786633..6829c5f2 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/BranchesFilterDropdown.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/BranchesFilterDropdown.tsx
@@ -13,6 +13,7 @@ import {
SpeedSearchMenu,
} from "@intellij-platform/core";
+import { notImplemented } from "../../../Project/notImplemented";
import {
allBranchesState,
BranchType,
@@ -20,8 +21,7 @@ import {
} from "../../Branches/branches.state";
import { BranchFavoriteButton } from "../../Branches/BranchFavoriteButton";
import { vcsLogFilter } from "../vcs-logs.state";
-import { VcsFilterDropdown } from "../VcsLogDropdown";
-import { notImplemented } from "../../../Project/notImplemented";
+import { VcsFilterDropdown } from "./VcsLogDropdown";
export function BranchesFilterDropdown({ tabKey }: { tabKey: string }) {
const [selectedBranches, setSelectedBranches] = useRecoilState(
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTable.state.ts b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTable.state.ts
new file mode 100644
index 00000000..ec08ca0a
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTable.state.ts
@@ -0,0 +1,204 @@
+import { ReadCommitResult } from "isomorphic-git";
+import { atom, atomFamily, selector } from "recoil";
+import { Selection } from "@react-types/shared";
+
+import { fs } from "../../../fs/fs";
+import { vcsLogFilterCurrentTab } from "../vcs-logs.state";
+import { allBranchesState } from "../../Branches/branches.state";
+import { resolvedRefState } from "../../refs.state";
+import { readCommits } from "./readCommits";
+import { GitRef } from "../GitRef";
+import { indexBy } from "ramda";
+
+function match(
+ input: string,
+ query: string,
+ flags: { matchCase?: boolean; regExp?: boolean } = {}
+) {
+ if (flags.regExp) {
+ return new RegExp(query, flags.matchCase ? "" : "i").test(input);
+ }
+ if (!flags.matchCase) {
+ return input.toLowerCase().includes(query.toLowerCase());
+ }
+ return input.includes(query);
+}
+
+type CommitLogItem = {
+ readCommitResult: ReadCommitResult;
+ containingRefs: Set;
+ repoPath: string;
+};
+
+export const allCommitsState = selector>({
+ key: "vcs/allRepoCommits",
+ get: ({ get }) =>
+ // TODO: readCommit can take too long. Suspending anything that depends on it leads to poor UX. would be nicer
+ // to have the state updated (like an atom instead of selector) when commits are read, instead of query inside
+ // the selector. But the challenge is that it does depend on another piece of state, allBranchesState
+ readCommits(
+ fs,
+ ...get(allBranchesState).map(
+ ({ repoRoot, localBranches, remoteBranches }) => ({
+ repoPath: repoRoot,
+ refs: ["HEAD"]
+ .concat(localBranches.map((branch) => branch.name))
+ .concat(
+ remoteBranches.map((branch) => `${branch.remote}/${branch.name}`)
+ ),
+ })
+ )
+ ),
+});
+
+export const commitsTableRowsState = selector({
+ key: "vcs/log/rows",
+ get: ({ get }) => {
+ const searchQuery = get(vcsLogFilterCurrentTab.searchQuery);
+ const branches = get(vcsLogFilterCurrentTab.branch);
+ const flags = {
+ matchCase: get(vcsLogFilterCurrentTab.matchCase),
+ regExp: get(vcsLogFilterCurrentTab.regExp),
+ };
+ const dateFilter = get(vcsLogFilterCurrentTab.date);
+
+ const rows = get(allCommitsState)
+ .filter(
+ ({ containingRefs }) =>
+ !branches?.length ||
+ branches.some((branch) => containingRefs.has(branch))
+ )
+ .filter(
+ ({ readCommitResult: { commit } }) =>
+ (!dateFilter?.from ||
+ commit.author.timestamp * 1000 > dateFilter.from.getTime()) &&
+ (!dateFilter?.to ||
+ commit.author.timestamp * 1000 < dateFilter.to.getTime())
+ )
+ .filter(
+ ({ readCommitResult: { commit } }) =>
+ !searchQuery ||
+ match(commit.message, searchQuery, flags) ||
+ match(commit.tree, searchQuery, { matchCase: false })
+ );
+ return {
+ rows,
+ byOid: indexBy(({ readCommitResult: commit }) => commit.oid, rows),
+ };
+ },
+});
+
+export const allResolvedRefsState = selector({
+ key: "vcs/logs/all-refs",
+ get: ({ get }) => {
+ const repoBranches = get(allBranchesState);
+ const refs: Record = {};
+ const addRef = (ref: string, value: GitRef) => {
+ refs[ref] = refs[ref] || [];
+ refs[ref].push(value);
+ };
+ repoBranches.forEach(({ repoRoot }) => {
+ addRef(get(resolvedRefState({ repoRoot, ref: "HEAD" })), {
+ type: "head",
+ name: "HEAD",
+ });
+ });
+ repoBranches.forEach(({ repoRoot, localBranches, currentBranch }) => {
+ localBranches.forEach((branch) => {
+ addRef(get(resolvedRefState({ repoRoot, ref: branch.name })), {
+ type: "localBranch",
+ name: branch.name,
+ isCurrent: currentBranch?.name === branch.name,
+ trackingBranch: branch.trackingBranch,
+ });
+ });
+ });
+ repoBranches.forEach(({ repoRoot, remoteBranches }) =>
+ remoteBranches.forEach((branch) => {
+ const branchName = `${branch.remote}/${branch.name}`;
+ addRef(get(resolvedRefState({ repoRoot, ref: branchName })), {
+ type: "remoteBranch",
+ name: branchName,
+ });
+ })
+ );
+ return refs;
+ },
+});
+
+/**
+ * selection state of the commits table
+ * TODO: make it per-tab
+ */
+export const commitsSelectionState = atom({
+ key: "vcs/log/commits/selection",
+ default: new Set([]),
+});
+
+/**
+ * OIDs of the selected commits in commits table.
+ */
+export const selectedCommitOids = selector({
+ key: `${commitsSelectionState.key}/keys`,
+ get: ({ get }) => {
+ const selection = get(commitsSelectionState);
+ if (selection === "all") {
+ return get(commitsTableRowsState).rows.map(
+ ({ readCommitResult: { oid } }) => oid
+ );
+ }
+ return [...selection].map((i) => `${i}`);
+ },
+});
+
+export const selectedCommitsState = selector({
+ key: `${commitsSelectionState.key}/items`,
+ get: ({ get }) =>
+ get(selectedCommitOids).map((oid) => get(commitsTableRowsState).byOid[oid]),
+});
+
+/**
+ * Selected commit in the commits table. The first row, if multiple rows are selected
+ */
+export const selectedCommitState = selector({
+ key: "vcs/log/details/selectedCommit",
+ get: ({ get }) => {
+ const oid = get(selectedCommitOids)[0];
+ return (oid && get(commitsTableRowsState).byOid[oid]) || null;
+ },
+});
+
+export const vcsTableShowCommitTimestampState = atom({
+ key: "vcs/log/commits/showCommitTimestamp",
+ default: false,
+});
+
+export const vcsTableReferencesOnTheLeftState = atom({
+ key: "vcs/log/commits/referencesOnTheLeft",
+ default: false,
+});
+
+export const vcsTableHighlightMyCommitsState = atom({
+ key: "vcs/log/commits/highlightMyCommits",
+ default: false,
+});
+
+export const authorColumn = {
+ id: "Default.Author",
+ name: "Author",
+};
+export const hashColumn = {
+ id: "Default.Hash",
+ name: "Hash",
+};
+
+export const dateColumn = {
+ id: "Date.Date",
+ name: "Date",
+};
+
+const defaultVisibleColumns = [authorColumn.id, dateColumn.id];
+export const vcsTableColumnsVisibilityState = atomFamily({
+ key: "vcs/log/commits/highlightMyCommits",
+ default: (id) => defaultVisibleColumns.includes(id),
+});
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTable.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTable.tsx
index 8297c2c8..3a815f18 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTable.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTable.tsx
@@ -1,16 +1,98 @@
import React from "react";
-
+import { useRecoilState, useRecoilValue } from "recoil";
+import { Item, Link, List, ProgressBar, styled } from "@intellij-platform/core";
import { StyledPlaceholderContainer } from "../styled-components";
+import { useResetFilters, vcsActiveTabKeyState } from "../vcs-logs.state";
+import { useLatestRecoilValue } from "../../../recoil-utils";
+import { CommitsTableRow } from "./CommitsTableRow";
+import { GitRef } from "../GitRef";
+import {
+ allResolvedRefsState,
+ commitsSelectionState,
+ commitsTableRowsState,
+} from "./CommitsTable.state";
+import { StyledListItem } from "@intellij-platform/core/List/StyledListItem";
-export function CommitChangedFiles() {
- return (
-
- Select commits to to view changes
-
+// const StyledList = styled(List)`
+// display: grid;
+// grid-template-columns: repeat(3, auto); /* Define 3 columns */
+//
+// ${StyledListItem} {
+// display: contents;
+// }
+// ` as typeof List;
+
+const StyledProgressBar = styled(ProgressBar)`
+ position: absolute;
+ width: 100%;
+ z-index: 1;
+`;
+const StyledContainer = styled.div`
+ position: relative;
+ min-height: 0;
+ flex: 1;
+ ${StyledListItem} {
+ // the default "min-width: min-content", which is necessary for sizing overlays (e.g. Popup) containing list/tree,
+ // results in horizontally scrollable table. Probably not an issue when a proper Table component is implemented.
+ min-width: unset;
+ }
+`;
+
+const ResolvedRefsContext = React.createContext>({});
+export function CommitsTable() {
+ const currentTabKey = useRecoilValue(vcsActiveTabKeyState);
+ const resetFilters = useResetFilters();
+
+ const [result, commitRowsState] = useLatestRecoilValue(commitsTableRowsState);
+ const rows = result?.rows;
+ const [allResolvedRefs] = useLatestRecoilValue(allResolvedRefsState);
+ const [selectedCommits, setSelectedCommits] = useRecoilState(
+ commitsSelectionState
);
-}
-export function CommitDetails() {
+
return (
- No commits selected
+
+
+ {commitRowsState === "loading" && (
+
+ )}
+ {rows && rows.length > 0 && (
+
+ {({ readCommitResult, repoPath }) => (
+ -
+ {/* Using context here due to rendering optimizations of collection API */}
+
+ {(refs) => (
+
+ )}
+
+
+ )}
+
+ )}
+ {commitRowsState === "hasValue" && rows && rows.length === 0 && (
+
+ No commits matching filters
+ resetFilters(currentTabKey)}>
+ Reset filters
+
+
+ )}
+
+
);
}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTableRow.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTableRow.tsx
new file mode 100644
index 00000000..44886268
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTableRow.tsx
@@ -0,0 +1,254 @@
+import React, { CSSProperties, RefObject, useMemo } from "react";
+import { css, styled, Tooltip, TooltipTrigger } from "@intellij-platform/core";
+import { ReadCommitResult } from "isomorphic-git";
+
+import { GitRef } from "../GitRef";
+import { RefLabel, RefIcon } from "../RefLabel";
+import { useRecoilValue } from "recoil";
+import {
+ authorColumn,
+ dateColumn,
+ hashColumn,
+ vcsTableColumnsVisibilityState,
+ vcsTableHighlightMyCommitsState,
+ vcsTableReferencesOnTheLeftState,
+ vcsTableShowCommitTimestampState,
+} from "./CommitsTable.state";
+import {
+ areSamePerson,
+ gitRepoUserState,
+ GitUser,
+} from "../../git-users.state";
+import {
+ CURRENT_USER_FILTER_VALUE,
+ vcsLogFilterCurrentTab,
+} from "../vcs-logs.state";
+import {
+ formatCommitDateTime,
+ parseCommitMessage,
+ shortenOid,
+} from "../commit-utils";
+
+const StyledCommitRow = styled.div`
+ display: flex;
+ height: 1.5rem;
+ align-items: center;
+ width: 100%;
+ --column-width-1: 130px;
+ --column-width-2: 130px;
+ --column-width-3: 70px;
+`;
+const StyledCommitCell = styled.div`
+ overflow: hidden;
+ padding-left: 0.25rem;
+`;
+
+const StyledRefsContainer = styled.div<{ asOverlay?: boolean }>`
+ ${({ asOverlay }) =>
+ !asOverlay
+ ? css`
+ position: absolute;
+ right: 0;
+ top: 1px;
+ max-width: calc(100% - 100px);
+ `
+ : css`
+ flex-shrink: 0;
+ `};
+
+ display: flex;
+ gap: 0.25rem;
+ overflow: hidden;
+ background: ${({ theme }) =>
+ theme.currentBackgroundAware(theme.color("List.background"))};
+`;
+const StyledMessageContainer = styled.div`
+ position: relative;
+ display: flex;
+ gap: 0.25rem;
+`;
+
+const StyledRefTooltipRow = styled.div`
+ display: flex;
+ align-items: center;
+ margin: 0 0.25rem;
+ font-size: 0.75rem;
+`;
+const useCurrentUserHighlightStyle = ({
+ repoRoot,
+ author,
+}: {
+ repoRoot: string;
+ author: GitUser;
+}): CSSProperties => {
+ const currentUser = useRecoilValue(gitRepoUserState(repoRoot));
+ const shouldHighlightCurrentUserCommits = useRecoilValue(
+ vcsTableHighlightMyCommitsState
+ );
+ // for now assuming only a single table can be visible, and that's current tab's.
+ const userFilter = useRecoilValue(vcsLogFilterCurrentTab.user);
+ // single user case is not considered a special case, like it is in the original impl
+ const shouldHighlight =
+ shouldHighlightCurrentUserCommits &&
+ userFilter !== CURRENT_USER_FILTER_VALUE &&
+ areSamePerson(currentUser, author);
+
+ return { fontWeight: shouldHighlight ? "bold" : undefined };
+};
+
+export function CommitsTableRow({
+ refs,
+ readCommitResult: {
+ oid,
+ commit: { author, committer, message },
+ },
+ repoRoot,
+}: {
+ repoRoot: string;
+ readCommitResult: ReadCommitResult;
+ refs: GitRef[] | undefined;
+}) {
+ const showCommitTimestamp = useRecoilValue(vcsTableShowCommitTimestampState);
+ const referencesOnTheLeft = useRecoilValue(vcsTableReferencesOnTheLeftState);
+ const isAuthorVisible = useRecoilValue(
+ vcsTableColumnsVisibilityState(authorColumn.id)
+ );
+ const isDateVisible = useRecoilValue(
+ vcsTableColumnsVisibilityState(dateColumn.id)
+ );
+ const isHashVisible = useRecoilValue(
+ vcsTableColumnsVisibilityState(hashColumn.id)
+ );
+
+ const highlightStyles = useCurrentUserHighlightStyle({ author, repoRoot });
+ return (
+
+
+
+ {refs && }
+
+ {parseCommitMessage(message).subject}
+
+
+
+ {isAuthorVisible && (
+
+ {author.name}
+ {committer.name !== author.name && "*"}
+
+ )}
+ {isDateVisible && (
+
+ {formatCommitDateTime(
+ (showCommitTimestamp ? committer : author).timestamp * 1000
+ )}
+
+ )}
+ {isHashVisible && (
+
+ {shortenOid(oid)}
+
+ )}
+
+ );
+}
+
+function refKey(ref: GitRef) {
+ return `${ref.type}${ref.name}`;
+}
+
+function CommitRefs({ refs, onLeft }: { refs: GitRef[]; onLeft?: boolean }) {
+ const { filteredRefs } = useMemo(() => {
+ const headIsOnABranch = refs.some(
+ (ref) => ref.type === "localBranch" && ref.isCurrent
+ );
+ return {
+ filteredRefs: refs.filter(
+ (ref) =>
+ !(
+ (ref.type === "head" && headIsOnABranch) ||
+ (ref.type === "remoteBranch" &&
+ refs.find(
+ (aBranch) =>
+ aBranch.type === "localBranch" &&
+ aBranch.trackingBranch === ref.name
+ ))
+ )
+ ),
+ };
+ }, [refs]);
+
+ return (
+
+ {refs.map((ref) => (
+
+
+ {ref.name}
+
+ ))}
+
+ }
+ >
+ {({ ref, ...tooltipTriggerProps }) => (
+ }
+ asOverlay={onLeft}
+ {...tooltipTriggerProps}
+ >
+ {filteredRefs
+ .filter(
+ (ref) =>
+ ref.type !== "remoteBranch" ||
+ !refs.find(
+ (aBranch) =>
+ aBranch.type === "localBranch" &&
+ aBranch.trackingBranch === ref.name
+ )
+ )
+ .map((ref) => {
+ let name = ref.name;
+ const types: GitRef["type"][] = [ref.type];
+ if (ref.type === "localBranch" && ref.trackingBranch) {
+ types.unshift("remoteBranch");
+ name = `${ref.trackingBranch.split("/")[0]} & ${ref.name}`;
+ }
+ if (ref.type === "localBranch" && ref.isCurrent) {
+ types.push("head");
+ }
+ return (
+
+ {name}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTableViewOptionsMenuIconButton.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTableViewOptionsMenuIconButton.tsx
new file mode 100644
index 00000000..a35a941a
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/CommitsTableViewOptionsMenuIconButton.tsx
@@ -0,0 +1,109 @@
+import React from "react";
+import { RecoilState, useRecoilState } from "recoil";
+import {
+ ActionTooltip,
+ IconButtonWithMenu,
+ Item,
+ PlatformIcon,
+ Section,
+ SpeedSearchMenu,
+ TooltipTrigger,
+} from "@intellij-platform/core";
+
+import { notImplemented } from "../../../Project/notImplemented";
+import {
+ authorColumn,
+ dateColumn,
+ hashColumn,
+ vcsTableColumnsVisibilityState,
+ vcsTableHighlightMyCommitsState,
+ vcsTableReferencesOnTheLeftState,
+ vcsTableShowCommitTimestampState,
+} from "./CommitsTable.state";
+
+type ToggleDef = {
+ name: string;
+ state: RecoilState;
+};
+const showToggleDefs = [
+ { name: "Commit Timestamp", state: vcsTableShowCommitTimestampState },
+ { name: "References on the Left", state: vcsTableReferencesOnTheLeftState },
+];
+const highlightToggleDefs = [
+ { name: "My Commits", state: vcsTableHighlightMyCommitsState },
+];
+const columnToggleDefs = [authorColumn, hashColumn, dateColumn].map(
+ ({ name, id }) => ({
+ name,
+ state: vcsTableColumnsVisibilityState(id),
+ })
+);
+const useToggleDef = (item: ToggleDef) => {
+ const [value, set] = useRecoilState(item.state);
+ return { ...item, key: item.state.key.replace(/"/g, "_"), value, set };
+};
+
+export function CommitsTableViewOptionsMenuIconButton() {
+ const showToggles = showToggleDefs.map(useToggleDef);
+ const highlightToggles = highlightToggleDefs.map(useToggleDef);
+ const columnToggles = columnToggleDefs.map(useToggleDef);
+
+ const allToggles = showToggles.concat(highlightToggles).concat(columnToggles);
+ return (
+ }>
+ {
+ return (
+ // TODO: keep menu open when (certain) items are toggled. Search for closeOnSelect TODO in menu
+ value)
+ .map(({ key }) => key)}
+ onAction={(key) => {
+ const toggle = allToggles.find(
+ ({ key: toggleKey }) => toggleKey === key
+ );
+ if (toggle) {
+ toggle.set((value) => !value);
+ } else {
+ notImplemented();
+ }
+ }}
+ >
+
+ - Compact References View
+ - Tag Names
+ - Long Edges
+ {
+ showToggles.map(({ name, key }) => (
+ - {name}
+ )) as any /* returned type is too broad and couldn't find a quick way to narrow it properly */
+ }
+ -
+ {
+ columnToggles.map(({ name, key }) => (
+
- {name}
+ )) as any /* returned type is too broad and couldn't find a quick way to narrow it properly */
+ }
+
+
+
+ {
+ highlightToggles.map(({ name, key }) => (
+ - {name}
+ )) as any /* returned type is too broad and couldn't find a quick way to narrow it properly */
+ }
+ - Merge Commits
+ - Current Branch
+ - Not Cherry-Picked Commits
+
+
+ );
+ }}
+ >
+
+
+
+ );
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/VcsLogCommitsView.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/VcsLogCommitsView.tsx
index 11bde532..c8a8444b 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/VcsLogCommitsView.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/VcsLogCommitsView.tsx
@@ -5,13 +5,10 @@ import {
ActionTooltip,
AutoHoverPlatformIcon,
IconButton,
- IconButtonWithMenu,
Item,
- Link,
Menu,
PlatformIcon,
SearchInput,
- Section,
styled,
StyledHoverContainer,
Toolbar,
@@ -20,14 +17,11 @@ import {
useGetActionShortcut,
} from "@intellij-platform/core";
-import {
- StyledHeader,
- StyledPlaceholderContainer,
- StyledSpacer,
-} from "../styled-components";
-import { VcsFilterDropdown } from "../VcsLogDropdown";
+import { StyledHeader, StyledSpacer } from "../styled-components";
+import { VcsFilterDropdown } from "./VcsLogDropdown";
import {
atom,
+ RecoilState,
useRecoilCallback,
useRecoilState,
useRecoilValue,
@@ -36,7 +30,7 @@ import {
import { searchInputRefState } from "../VersionControlToolWindow";
import { VcsActionIds } from "../../VcsActionIds";
import {
- useResetFilters,
+ CURRENT_USER_FILTER_VALUE,
vcsActiveTabKeyState,
vcsLogFilter,
vcsTabKeysState,
@@ -44,6 +38,8 @@ import {
import { notImplemented } from "../../../Project/notImplemented";
import { DateRange, dateToString } from "../DateRange";
import { BranchesFilterDropdown } from "./BranchesFilterDropdown";
+import { CommitsTable } from "./CommitsTable";
+import { CommitsTableViewOptionsMenuIconButton } from "./CommitsTableViewOptionsMenuIconButton";
const StyledSearchInput = styled(SearchInput)`
border-radius: 2px;
@@ -54,6 +50,8 @@ const StyledSearchInput = styled(SearchInput)`
const StyledContainer = styled.div`
height: 100%;
+ display: flex;
+ flex-direction: column;
`;
const searchHistoryState = atom({
@@ -91,10 +89,24 @@ export function VcsLogCommitsView({ tabKey }: { tabKey: string }) {
}, [searchQuery]);
const createNewTab = useRecoilCallback(
- ({ set }) =>
+ ({ set, snapshot }) =>
() => {
const newTabId = uuid();
set(vcsTabKeysState, (value) => [...value, newTabId]);
+ Object.values(vcsLogFilter).forEach(
+ (filterState: (tabKey: string) => RecoilState) => {
+ set(
+ filterState(newTabId),
+ snapshot
+ .getLoadable(
+ filterState(
+ snapshot.getLoadable(vcsActiveTabKeyState).getValue()
+ )
+ )
+ .getValue()
+ );
+ }
+ );
set(vcsActiveTabKeyState, newTabId);
},
[]
@@ -107,11 +119,11 @@ export function VcsLogCommitsView({ tabKey }: { tabKey: string }) {
const predefinedDateRanges = [
{
name: "Last 24 hours",
- value: { from: sevenDaysAgo },
+ value: { from: yesterday },
},
{
name: "Last 7 days",
- value: { from: yesterday },
+ value: { from: sevenDaysAgo },
},
];
return (
@@ -131,6 +143,7 @@ export function VcsLogCommitsView({ tabKey }: { tabKey: string }) {
onChange={setSearchInputValue}
onSubmit={submitSearchQuery}
onBlur={() => submitSearchQuery()}
+ onClear={() => submitSearchQuery("")}
searchHistory={searchHistory}
addonAfter={
<>
@@ -171,7 +184,7 @@ export function VcsLogCommitsView({ tabKey }: { tabKey: string }) {
(
)}
label="User"
@@ -236,33 +249,13 @@ export function VcsLogCommitsView({ tabKey }: { tabKey: string }) {
- }
- >
-
-
-
-
+
}>
- }>
- {
- return (
-
- );
- }}
- >
-
-
-
+
@@ -279,18 +272,6 @@ export function VcsLogCommitsView({ tabKey }: { tabKey: string }) {
);
}
-function CommitsTable() {
- const currentTabKey = useRecoilValue(vcsActiveTabKeyState);
- const resetFilters = useResetFilters();
-
- return (
-
- No commits matching filters
- resetFilters(currentTabKey)}>Reset filters
-
- );
-}
-
function dateRangeToString(dateRange: DateRange): string {
if (dateRange.from && dateRange.to) {
return `Between ${dateToString(dateRange.from)} and ${dateToString(
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/VcsLogDropdown.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/VcsLogDropdown.tsx
similarity index 100%
rename from packages/example-app/src/VersionControl/VersionControlToolWindow/VcsLogDropdown.tsx
rename to packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/VcsLogDropdown.tsx
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/readCommits.spec.ts b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/readCommits.spec.ts
new file mode 100644
index 00000000..20ae96f7
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/readCommits.spec.ts
@@ -0,0 +1,147 @@
+import * as fs from "fs";
+import path from "path";
+
+import { readCommits } from "./readCommits";
+
+const repoPath = path.resolve(
+ // eslint-disable-next-line no-undef
+ __dirname,
+ "../../../../fixture/git/example-branches.git"
+);
+
+describe("readCommits", () => {
+ it("works for a single branch of a single repo", async () => {
+ const result = await readCommits(fs, {
+ repoPath,
+ isBare: true,
+ refs: ["master"],
+ });
+ expect(result).toHaveLength(12);
+ expect(result.map(({ readCommitResult: { oid } }) => oid)).toEqual([
+ "14d63f8c757e52f7e60e13765031a7fdf0768195",
+ "18c380ebf2b05773262e576946349d5d6deaa18a",
+ "5b1b99986a0096c774f26a4917dbad0ac31d2d26",
+ "5de4412a5f70cb9974f120936157e4ef81329a18",
+ "cc98a1381cf83c59c8309f1ac6be3d0c52f859f6",
+ "758af8ed2e58709512e95c4293abc6cf7395c6d7",
+ "aac9e379d0961d15bd969887d9d4c74952f3fce1",
+ "daf0079ed765c7fe3fe883fe060ab0f712d54ac6",
+ "43210db57607e3655c6259e8a395083335510a5c",
+ "2f3f4cd3e46893112aa5b7e45526da76b2fea0ce",
+ "ec7309b0e116b85bc052424f63cbda882f88ce77",
+ "f0eb272cc8f77803478c6748103a1450aa1abd37",
+ ]);
+ });
+
+ it("works for multiple branches of a single repo", async () => {
+ const result = await readCommits(fs, {
+ repoPath,
+ isBare: true,
+ refs: ["master", "topic1", "topic2"],
+ });
+ expect(result).toHaveLength(15);
+ expect(result.map(({ readCommitResult: { oid } }) => oid)).toEqual([
+ "14d63f8c757e52f7e60e13765031a7fdf0768195",
+ "18c380ebf2b05773262e576946349d5d6deaa18a",
+ "5b1b99986a0096c774f26a4917dbad0ac31d2d26",
+ "5de4412a5f70cb9974f120936157e4ef81329a18",
+ "cc98a1381cf83c59c8309f1ac6be3d0c52f859f6",
+ "758af8ed2e58709512e95c4293abc6cf7395c6d7",
+ "d5ed0e6a098710ad9dfe08bc7039fc6e61d00fa3",
+ "ca60ac707b05a28d54c4191022dacfb940305efc",
+ "5086927860395c3a173df36eabe9f2525c357bc2",
+ "aac9e379d0961d15bd969887d9d4c74952f3fce1",
+ "daf0079ed765c7fe3fe883fe060ab0f712d54ac6",
+ "43210db57607e3655c6259e8a395083335510a5c",
+ "2f3f4cd3e46893112aa5b7e45526da76b2fea0ce",
+ "ec7309b0e116b85bc052424f63cbda882f88ce77",
+ "f0eb272cc8f77803478c6748103a1450aa1abd37",
+ ]);
+ });
+
+ it("includes all refs for commits that exist in multiple branches", async () => {
+ const result = await readCommits(fs, {
+ repoPath,
+ isBare: true,
+ refs: ["topic1", "topic2"],
+ });
+ expect(
+ result.filter(({ containingRefs }) => containingRefs.has("topic2"))
+ ).toHaveLength(9);
+ expect(
+ result.map(({ containingRefs }) => containingRefs.has("topic1"))
+ ).toEqual([false, false, true, true, true, true, true, true, true]);
+ });
+
+ it("includes all refs for commits that exist in multiple branches (more complex)", async () => {
+ const almostAllBranches = new Set([
+ "HEAD",
+ "master",
+ "RC1.0",
+ "enhancement",
+ "featureGreen",
+ "featureRed",
+ "topic1",
+ "topic2",
+ ]);
+
+ const result = await readCommits(fs, {
+ repoPath,
+ isBare: true,
+ refs: [...almostAllBranches, "gh-pages"],
+ });
+
+ expect(result.map(({ containingRefs }) => containingRefs)).toEqual([
+ new Set(["HEAD", "master"]),
+ new Set(["HEAD", "master"]),
+ new Set(["enhancement"]),
+ new Set(["enhancement"]),
+ new Set(["featureGreen"]),
+ new Set(["featureGreen"]),
+ new Set(["featureGreen"]),
+ new Set(["featureGreen"]),
+ new Set(["featureGreen"]),
+ new Set(["RC1.0", "HEAD", "master", "enhancement", "featureGreen"]),
+ new Set(["featureRed"]),
+ new Set(["RC1.0", "HEAD", "master", "enhancement", "featureGreen"]),
+ new Set(["gh-pages"]),
+ new Set([
+ "RC1.0",
+ "HEAD",
+ "master",
+ "enhancement",
+ "featureGreen",
+ "featureRed",
+ ]),
+ new Set([
+ "RC1.0",
+ "HEAD",
+ "master",
+ "enhancement",
+ "featureGreen",
+ "featureRed",
+ ]),
+ new Set(["topic2"]),
+ new Set(["topic2"]),
+ new Set(["topic1", "topic2"]),
+ almostAllBranches,
+ almostAllBranches,
+ almostAllBranches,
+ almostAllBranches,
+ almostAllBranches,
+ almostAllBranches,
+ ]);
+ });
+
+ it("creates unique sets of refs", async () => {
+ const result = await readCommits(fs, {
+ repoPath,
+ isBare: true,
+ refs: ["topic1", "topic2"],
+ });
+ expect(new Set(result.map(({ containingRefs }) => containingRefs)).size)
+ // It can actually be 2 in this case, but it's three because ["topic1", "topic2"] and
+ // ["topic2", "topic1"] are created twice.
+ .toBeLessThanOrEqual(3);
+ });
+});
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/readCommits.ts b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/readCommits.ts
new file mode 100644
index 00000000..5efb9cc7
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/readCommits.ts
@@ -0,0 +1,145 @@
+import git, { ReadCommitResult } from "isomorphic-git";
+import { sort } from "ramda";
+
+type Fs = Parameters[0]["fs"];
+
+const cache = {};
+export const commitDateComparator = (
+ a: ReadCommitResult,
+ b: ReadCommitResult
+) => b.commit.committer.timestamp - a.commit.committer.timestamp;
+
+type CommitWithMeta = {
+ repoPath: string;
+ /**
+ * refs this commit contains in
+ */
+ containingRefs: Set;
+ readCommitResult: ReadCommitResult;
+};
+
+type RepoDescriptor = {
+ repoPath: string;
+ isBare?: boolean;
+};
+/**
+ * Similar to git.log(...), but:
+ * - supports multiple repos and multiple refs per repo.
+ * - for each commit, includes the list of refs the commit is a part of
+ * Commits are sorted by commit time, from new to old.
+ */
+export async function readCommits(
+ fs: Fs,
+ ...sources: Array<
+ RepoDescriptor & {
+ refs: string[];
+ }
+ >
+) {
+ const allCommits: Array = [];
+ const commitsMap: Record = {};
+ let commitsToProcess: Array = [];
+
+ commitsToProcess.forEach((item) => {
+ commitsMap[item.readCommitResult.oid] = item;
+ });
+ const initialCommits = await Promise.all(
+ sources.flatMap(({ refs, repoPath, isBare }) =>
+ refs.map(async (ref) => {
+ const commit = await git
+ .resolveRef({
+ ...getCommonArgs({ repoPath, isBare }),
+ ref,
+ depth: 3,
+ })
+ .then((oid) =>
+ git.readCommit({
+ ...getCommonArgs({ repoPath, isBare }),
+ oid,
+ })
+ );
+ return { repoPath, isBare, commit, refs: new Set([ref]) };
+ })
+ )
+ );
+
+ sort(
+ ({ commit: c1 }, { commit: c2 }) => commitDateComparator(c1, c2),
+ initialCommits
+ ).forEach(({ commit, refs, repoPath, isBare }) =>
+ addCommit({ repoPath, isBare }, commit, -1, refs)
+ );
+
+ while (commitsToProcess.length > 0) {
+ const {
+ readCommitResult: latestCommit,
+ repoPath,
+ isBare,
+ containingRefs,
+ } = commitsToProcess.shift()!; // non-null assertion because of length>0;
+ allCommits.push({
+ repoPath,
+ readCommitResult: latestCommit,
+ containingRefs: containingRefs,
+ });
+ const parentCommits = await Promise.all(
+ latestCommit.commit.parent.map((oid) =>
+ git.readCommit({
+ ...getCommonArgs({ repoPath, isBare }),
+ oid,
+ })
+ )
+ );
+ // insert new parents in the right place so that commitsToProcess remains sorted.
+ parentCommits.forEach((commit) => {
+ const index = commitsToProcess.findIndex(
+ ({ readCommitResult: aCommit }) =>
+ commitDateComparator(aCommit, commit) > 0
+ );
+ addCommit({ repoPath, isBare }, commit, index, containingRefs);
+ });
+ }
+ return allCommits;
+
+ function addCommit(
+ { repoPath, isBare }: RepoDescriptor,
+ commit: ReadCommitResult,
+ index: number,
+ refs: Set
+ ) {
+ if (commitsMap[commit.oid]) {
+ commitsMap[commit.oid].containingRefs = new Set([
+ ...commitsMap[commit.oid].containingRefs,
+ ...refs,
+ ]);
+ } else {
+ const entry: CommitWithMeta & RepoDescriptor = {
+ readCommitResult: commit,
+ containingRefs: commitsMap[commit.oid]
+ ? new Set([...commitsMap[commit.oid].containingRefs, ...refs])
+ : refs,
+ repoPath,
+ isBare,
+ };
+ commitsMap[commit.oid] = entry;
+ commitsToProcess.splice(
+ index >= 0 ? index : commitsToProcess.length,
+ 0,
+ entry
+ );
+ }
+ }
+ function getCommonArgs({
+ repoPath,
+ isBare,
+ }: {
+ repoPath: string;
+ isBare?: boolean;
+ }) {
+ return {
+ fs,
+ cache,
+ [isBare ? "gitdir" : "dir"]: repoPath,
+ };
+ }
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/useCommitsTableActions.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/useCommitsTableActions.tsx
new file mode 100644
index 00000000..80ff08ba
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitsView/useCommitsTableActions.tsx
@@ -0,0 +1,55 @@
+import { useRecoilCallback } from "recoil";
+import React from "react";
+import copyToClipboard from "clipboard-copy";
+import {
+ ActionDefinition,
+ CommonActionId,
+ PlatformIcon,
+} from "@intellij-platform/core";
+import { notImplemented } from "../../../Project/notImplemented";
+import { VcsActionIds } from "../../VcsActionIds";
+import {
+ allCommitsState,
+ commitsSelectionState,
+ selectedCommitOids,
+} from "./CommitsTable.state";
+
+export const useCommitsTableActions = (): ActionDefinition[] => {
+ const copyRevisionNumber = useRecoilCallback(
+ ({ snapshot }) =>
+ () => {
+ const selectedCommits = snapshot
+ .getLoadable(selectedCommitOids)
+ .getValue();
+ copyToClipboard(selectedCommits.join(" ")).catch(console.error);
+ },
+ []
+ );
+
+ const refreshLog = useRecoilCallback(
+ ({ refresh }) =>
+ () => {
+ refresh(allCommitsState);
+ },
+ []
+ );
+
+ return [
+ {
+ id: VcsActionIds.COPY_REVISION_NUMBER,
+ title: "Copy Revision Number",
+ icon: ,
+ useShortcutsOf: CommonActionId.COPY_REFERENCE,
+ actionPerformed: copyRevisionNumber,
+ },
+ {
+ id: VcsActionIds.LOG_REFRESH,
+ title: "Refresh",
+ icon: ,
+ useShortcutsOf: CommonActionId.REFRESH,
+ actionPerformed: () => {
+ refreshLog();
+ },
+ },
+ ];
+};
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/CommitDetails.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/CommitDetails.tsx
new file mode 100644
index 00000000..6b5731bd
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/CommitDetails.tsx
@@ -0,0 +1,160 @@
+import { groupBy } from "ramda";
+import { useRecoilValue } from "recoil";
+import React, { ReactNode } from "react";
+
+import { Link, styled, Tooltip, TooltipTrigger } from "@intellij-platform/core";
+import { StyledPlaceholderContainer } from "../styled-components";
+import { RepoColorIcon } from "../../Changes/StyledRepoColorSquare";
+import { vcsRootsState } from "../../file-status.state";
+import { useLatestRecoilValue } from "../../../recoil-utils";
+import {
+ formatCommitDate,
+ formatCommitTime,
+ parseCommitMessage,
+ shortenOid,
+} from "../commit-utils";
+import {
+ allResolvedRefsState,
+ selectedCommitState,
+} from "../CommitsView/CommitsTable.state";
+import { RefIconGroup } from "../RefLabel";
+import { GitRef } from "../GitRef";
+
+const StyledContainer = styled.div`
+ padding: 0.875rem;
+ cursor: default;
+`;
+const StyledCommitMessage = styled.div`
+ font-family: "JetBrains Mono", monospace;
+ line-height: 1.125rem;
+ margin-bottom: 1.25rem;
+ margin-right: 1.1875rem;
+`;
+
+const StyledCommitMessageHeader = styled.div`
+ font-weight: bold;
+`;
+const StyledCommitterInfo = styled.div`
+ color: ${({ theme }) => theme.commonColors.label({ disabled: true })};
+`;
+
+const StyledCommitInfoRow = styled.div`
+ line-height: 1.2;
+ display: flex;
+ gap: 0.25rem;
+ margin-bottom: 0.75rem;
+`;
+const StyledRepoColorIcon = styled(RepoColorIcon)`
+ align-self: start;
+`;
+
+const StyledRefsContainer = styled.span`
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin-bottom: 0.75rem;
+`;
+
+export function CommitDetails() {
+ const [firstSelectedCommit] = useLatestRecoilValue(selectedCommitState);
+ const [allResolvedRefs] = useLatestRecoilValue(allResolvedRefsState);
+ const isMultiRepo = useRecoilValue(vcsRootsState).length > 0;
+ if (!firstSelectedCommit) {
+ return (
+
+ No commits selected
+
+ );
+ }
+
+ const {
+ readCommitResult: { commit, oid },
+ containingRefs,
+ repoPath,
+ } = firstSelectedCommit;
+ const { subject, body } = parseCommitMessage(commit.message);
+ const committerInfo: ReactNode[] = [];
+ if (commit.committer.name !== commit.author.name) {
+ committerInfo.push(
+
+ by {commit.committer.name}
+
+ );
+ }
+ if (commit.committer.timestamp !== commit.author.timestamp) {
+ committerInfo.push(formatDateAndTime(commit.committer.timestamp * 1000));
+ }
+ const refs = allResolvedRefs?.[oid] || [];
+ const refsByType = groupBy((ref) => ref.type, refs);
+ return (
+
+
+ {subject}
+ {body}
+
+
+ {isMultiRepo && (
+ {repoPath}}>
+ {(props) => }
+
+ )}
+
+ {`${shortenOid(oid)} ${commit.author.name} `}
+ {" "}
+ {`${formatDateAndTime(new Date(commit.author.timestamp * 1000))}`}
+ {committerInfo.length > 0 && (
+ committed {committerInfo}
+ )}
+
+
+ {refs.length > 0 && (
+
+
+
+
+
+
+ )}
+
+ {/*TODO: collapsing and "Show more"*/}
+ In {containingRefs.size} branches: {[...containingRefs].join(", ")}
+
+
+ );
+}
+
+const StyledRefWithIcon = styled.span`
+ display: inline-flex;
+ align-items: center;
+`;
+function RefGroup({ refs }: { refs: undefined | GitRef[] }) {
+ return refs?.length ? (
+ <>
+ {refs.map((ref, index) => {
+ const name = `${ref.name}${index === refs.length - 1 ? "" : ","}`;
+ return index === 0 ? (
+
+ type).slice(0, 2)} />
+ {name}
+
+ ) : (
+ // span is needed for the right flex-wrap behavior
+ {name}
+ );
+ })}
+ >
+ ) : null;
+}
+
+function EmailLink({ email }: { email: string }) {
+ return (
+
+ {`<${email}>`}
+
+ );
+}
+
+function formatDateAndTime(date: Date | number) {
+ return `on ${formatCommitDate(date)} at ${formatCommitTime(date)}`;
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/VcsLogDetailsView.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/VcsLogDetailsView.tsx
new file mode 100644
index 00000000..a1ac1f6b
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/VcsLogDetailsView.tsx
@@ -0,0 +1,169 @@
+import React from "react";
+import {
+ atom,
+ useRecoilCallback,
+ useRecoilState,
+ useRecoilValue,
+} from "recoil";
+import {
+ ActionButton,
+ ActionsProvider,
+ ActionTooltip,
+ CommonActionId,
+ Divider,
+ IconButton,
+ IconButtonWithMenu,
+ Item,
+ PlatformIcon,
+ Section,
+ SpeedSearchMenu,
+ styled,
+ ThreeViewSplitter,
+ Toolbar,
+ TooltipTrigger,
+ useAction,
+ useTreeActions,
+} from "@intellij-platform/core";
+
+import { notImplemented } from "../../../Project/notImplemented";
+import { StyledHeader, StyledSpacer } from "../styled-components";
+import { CommitChangedFiles } from "../CommitChanges/CommitsChangedFiles";
+import { CommitDetails } from "./CommitDetails";
+import { defaultChangeGroupings } from "../../Changes/ChangesTree/changesGroupings";
+import {
+ changesGroupingActiveState,
+ commitChangesTreeRefState,
+} from "../CommitChanges/CommitsChangedFiles.state";
+import { vcsLogTabShowCommitDetails } from "../vcs-logs.state";
+import { VcsActionIds } from "../../VcsActionIds";
+
+const splitViewSizeState = atom({
+ key: "vcs/toolwindow/splitViewSize",
+ default: 0.5,
+});
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+`;
+
+export function VcsLogDetailsView({ tabKey }: { tabKey: string }) {
+ const [splitViewSize, setSplitViewSize] = useRecoilState(splitViewSizeState);
+ const isDetailsVisible = useRecoilValue(vcsLogTabShowCommitDetails(tabKey));
+ const toggleDetailsAction = useAction(VcsActionIds.SHOW_DETAILS);
+
+ const toggleGroupBy = useRecoilCallback(({ set }) => (id: string) => {
+ set(changesGroupingActiveState(id), (currentValue) => !currentValue);
+ });
+ const groupByMenuItems = defaultChangeGroupings
+ .filter((grouping) =>
+ useRecoilValue(changesGroupingActiveState(grouping.id))
+ )
+ .map((grouping) => ({
+ title: grouping.title,
+ key: `groupBy:${grouping.id}`,
+ }));
+ const groupBySelectedKeys = groupByMenuItems.map(({ key }) => key);
+
+ const treeRef = useRecoilValue(commitChangesTreeRefState);
+ const actions = useTreeActions({ treeRef });
+
+ const selectedKeys = [
+ ...groupBySelectedKeys,
+ ...(isDetailsVisible ? [VcsActionIds.SHOW_DETAILS] : []),
+ ];
+
+ return (
+
+ {({ shortcutHandlerProps }) => (
+
+
+
+ }
+ >
+
+
+
+
+ }
+ >
+
+
+
+
+ {
+ return (
+ {
+ const groupByItem = groupByMenuItems.find(
+ (item) => item.key === key
+ );
+ if (groupByItem) {
+ toggleGroupBy(`${key}`.split(":")[1]);
+ } else if (key === toggleDetailsAction?.id) {
+ toggleDetailsAction?.perform();
+ } else {
+ notImplemented();
+ }
+ }}
+ >
+
+ {groupByMenuItems.map((item) => (
+ - {item.title}
+ ))}
+
+
+
+ {toggleDetailsAction ? (
+ -
+ {toggleDetailsAction.title}
+
+ ) : (
+ (null as any)
+ )}
+ - Show Diff Preview
+
+
+ );
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {isDetailsVisible ? (
+
+ }
+ innerView={}
+ />
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/GitRef.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/GitRef.tsx
new file mode 100644
index 00000000..1150a3f1
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/GitRef.tsx
@@ -0,0 +1,13 @@
+export type GitRef =
+ | {
+ type: "head";
+ name: "HEAD" /* just so name consistently exist on all types which makes it more convenient to work it*/;
+ }
+ | {
+ type: "localBranch";
+ name: string;
+ trackingBranch: string | null;
+ isCurrent: boolean;
+ }
+ | { type: "remoteBranch"; name: string }
+ | { type: "tag"; name: string };
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/RefLabel.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/RefLabel.tsx
new file mode 100644
index 00000000..aac04d5e
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/RefLabel.tsx
@@ -0,0 +1,102 @@
+import { styled, Theme } from "@intellij-platform/core";
+import React from "react";
+
+import { StyledCurrentBranchTag } from "../Changes/StyledCurrentBranchTag";
+import { GitRef } from "./GitRef";
+
+const labelIcon = (
+
+);
+const vcsLogStandardColors = {
+ TIP: { light: "#ffd100", dark: "#e1c731" },
+ LEAF: { light: "#8a2d6b", dark: "#c31e8c" },
+ BRANCH: { light: "#3cb45c", dark: "#3cb45c" },
+ BRANCH_REF: { light: "#9f79b5", dark: "#9f79b5" },
+ TAG: { light: "#7a7a7a", dark: "#999999" },
+};
+const gitLogColors: Record string> =
+ {
+ head: ({ theme }: { theme: Theme }) =>
+ theme.color(
+ "VersionControl.GitLog.headIconColor",
+ vcsLogStandardColors.TIP
+ ),
+ localBranch: ({ theme }: { theme: Theme }) =>
+ theme.color(
+ "VersionControl.GitLog.localBranchIconColor",
+ vcsLogStandardColors.BRANCH
+ ),
+ remoteBranch: ({ theme }: { theme: Theme }) =>
+ theme.color(
+ "VersionControl.GitLog.remoteBranchIconColor",
+ vcsLogStandardColors.BRANCH_REF
+ ),
+ tag: ({ theme }: { theme: Theme }) =>
+ theme.color(
+ "VersionControl.GitLog.tagIconColor",
+ vcsLogStandardColors.TAG
+ ),
+ };
+
+const StyledRef = styled(StyledCurrentBranchTag)`
+ border-radius: 3px;
+ align-items: stretch;
+ font-size: 0.75rem;
+`;
+
+const StyledLabelSvg = styled.svg.attrs({ viewBox: "0 0 14.5 14.5" })<{
+ type: GitRef["type"];
+}>`
+ width: 1rem;
+ height: 1rem;
+ stroke: rgba(0, 0, 0, 0.25);
+ stroke-width: 0.5px;
+ color: ${({ type, theme }) => gitLogColors[type]({ theme })};
+ z-index: 1; // for the right vertical order of grouped labels
+ &:not(:first-child) {
+ margin-right: -11px;
+ }
+`;
+
+const StyledRefIconGroup = styled.span`
+ display: inline-flex;
+ flex-direction: row-reverse;
+`;
+
+const StyledLabelText = styled.span`
+ text-overflow: ellipsis;
+ overflow: hidden;
+`;
+
+export function RefIcon({ type }: { type: GitRef["type"] }) {
+ return {labelIcon};
+}
+
+export function RefIconGroup({ types }: { types: Array }) {
+ return (
+
+ {types.map((type, index) => (
+
+ {labelIcon}
+
+ ))}
+
+ );
+}
+export function RefLabel({
+ types,
+ children,
+}: {
+ types: Array;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/VcsLogDetailsView.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/VcsLogDetailsView.tsx
deleted file mode 100644
index b082df96..00000000
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/VcsLogDetailsView.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from "react";
-import { atom, useRecoilState } from "recoil";
-import {
- ActionButton,
- ActionTooltip,
- CommonActionId,
- IconButton,
- IconButtonWithMenu,
- Item,
- Menu,
- PlatformIcon,
- Section,
- ThreeViewSplitter,
- Toolbar,
- TooltipTrigger,
-} from "@intellij-platform/core";
-
-import { StyledHeader, StyledSpacer } from "./styled-components";
-import { CommitChangedFiles, CommitDetails } from "./CommitsView/CommitsTable";
-
-const splitViewSizeState = atom({
- key: "vcs/toolwindow/splitViewSize",
- default: 0.5,
-});
-export function VcsLogDetailsView() {
- const [splitViewSize, setSplitViewSize] = useRecoilState(splitViewSizeState);
- return (
- <>
-
-
- }
- >
-
-
-
-
- }
- >
-
-
-
-
- {
- return (
-
- );
- }}
- >
-
-
-
-
-
-
-
-
-
- }
- innerView={}
- />
- >
- );
-}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindow.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindow.tsx
index aba0aeae..591be3b3 100644
--- a/packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindow.tsx
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindow.tsx
@@ -1,6 +1,7 @@
import React, { RefObject } from "react";
import {
atom,
+ RecoilState,
useRecoilCallback,
useRecoilState,
useRecoilValue,
@@ -19,18 +20,20 @@ import {
ToolWindowTabContent,
} from "@intellij-platform/core";
-import { VcsLogDetailsView } from "./VcsLogDetailsView";
+import { VcsLogDetailsView } from "./DetailsView/VcsLogDetailsView";
import { VcsLogCommitsView } from "./CommitsView/VcsLogCommitsView";
import { VcsBranchesView } from "./BranchesView/VcsBranchesView";
import { VcsActionIds } from "../VcsActionIds";
import {
- useResetFilters,
+ useCloseVcsTab,
vcsActiveTabKeyState,
vcsLogFilter,
vcsLogTabShowBranches,
+ vcsLogTabShowCommitDetails,
vcsTabKeysState,
vcsTabTitleState,
} from "./vcs-logs.state";
+import { useCommitsTableActions } from "./CommitsView/useCommitsTableActions";
export const VERSION_CONTROL_TOOLWINDOW_ID = "Version Control";
@@ -85,7 +88,6 @@ export const VersionControlToolWindow = () => {
const StyledExpandStripeButton = styled.button.attrs({ tabIndex: -1 })`
box-sizing: border-box;
all: unset;
- height: 100%;
width: 1.71875rem;
align-items: center;
display: flex;
@@ -134,7 +136,7 @@ function VcsTab({ tabKey }: { tabKey: string }) {
const firstViewProps: Partial = showBranches
? {
- firstView: ,
+ firstView: ,
firstSize: firstViewSize,
onFirstResize: setFirstViewSize,
firstViewMinSize: 85,
@@ -145,7 +147,10 @@ function VcsTab({ tabKey }: { tabKey: string }) {
{({ shortcutHandlerProps }) => (
// tabIndex is added to make the whole container focusable, which means the focus can go away from the currently
// focused element, when background is clicked. This is to follow the original implementation.
-
+
{!showBranches && (
setShowBranches(true)} />
)}
@@ -153,7 +158,7 @@ function VcsTab({ tabKey }: { tabKey: string }) {
{...firstViewProps}
innerView={}
innerViewMinSize={200}
- lastView={}
+ lastView={}
lastSize={lastViewSize}
onLastResize={setLastViewSize}
lastViewMinSize={40}
@@ -164,48 +169,43 @@ function VcsTab({ tabKey }: { tabKey: string }) {
);
}
-function useVcsLogsToolWindowActions() {
- const textFilterRef = useRecoilValue(searchInputRefState);
- const hideBranches = useRecoilCallback(
- ({ set, snapshot }) =>
- () => {
- set(
- vcsLogTabShowBranches(
- snapshot.getLoadable(vcsActiveTabKeyState).getValue()
- ),
- // Would be nicer to have the action toggle, but prioritized matching the reference impl here.
- false
- );
- },
- []
- );
- const toggleMatchCaseAction = useRecoilCallback(
+function useToggleCurrentTabSettings(
+ toggleState: (activeTab: string) => RecoilState
+) {
+ return useRecoilCallback(
({ set, snapshot }) =>
() => {
set(
- vcsLogFilter.matchCase(
- snapshot.getLoadable(vcsActiveTabKeyState).getValue()
- ),
+ toggleState(snapshot.getLoadable(vcsActiveTabKeyState).getValue()),
(value) => !value
);
},
[]
);
+}
- const toggleRegExpAction = useRecoilCallback(
+function useVcsLogsToolWindowActions() {
+ const textFilterRef = useRecoilValue(searchInputRefState);
+ const hideBranches = useRecoilCallback(
({ set, snapshot }) =>
() => {
set(
- vcsLogFilter.regExp(
+ vcsLogTabShowBranches(
snapshot.getLoadable(vcsActiveTabKeyState).getValue()
),
- (value) => !value
+ // Would be nicer to have the action toggle, but prioritized matching the reference impl here.
+ false
);
},
[]
);
+ const toggleMatchCase = useToggleCurrentTabSettings(vcsLogFilter.matchCase);
+ const toggleRegExp = useToggleCurrentTabSettings(vcsLogFilter.regExp);
+ const toggleDetails = useToggleCurrentTabSettings(vcsLogTabShowCommitDetails);
+
const actions: ActionDefinition[] = [
+ ...useCommitsTableActions(),
{
id: VcsActionIds.FOCUS_TEXT_FILTER,
title: "Focus Text Filter",
@@ -223,49 +223,33 @@ function useVcsLogsToolWindowActions() {
id: VcsActionIds.MATCH_CASE,
title: "Match Case",
icon: ,
- actionPerformed: toggleMatchCaseAction,
+ actionPerformed: toggleMatchCase,
},
{
id: VcsActionIds.REG_EXP,
title: "Regex",
icon: ,
- actionPerformed: toggleRegExpAction,
+ actionPerformed: toggleRegExp,
+ },
+ {
+ id: VcsActionIds.SHOW_DETAILS,
+ title: "Show Details",
+ description: "Display details panel",
+ actionPerformed: toggleDetails,
},
];
return actions;
}
function VcsToolWindowTabTitle({ tabKey }: { tabKey: string }) {
- const resetFilters = useResetFilters();
- const closeTab = useRecoilCallback(
- ({ set, snapshot, reset }) =>
- () => {
- const tabs = snapshot.getLoadable(vcsTabKeysState).getValue();
- const currentActiveTabKey = snapshot
- .getLoadable(vcsActiveTabKeyState)
- .getValue();
- if (currentActiveTabKey === tabKey) {
- // make sure the active tab key remains valid, by switching to previous tab. In the reference implementation
- // the previously activated tab will be activated instead of the previous one index-wise, but it's a
- // negligible and easy-to-fix difference.
- set(
- vcsActiveTabKeyState,
- tabs[tabs.findIndex((key) => key === tabKey) - 1] || tabs[0]
- );
- }
- set(vcsTabKeysState, (keys) => keys.filter((key) => key !== tabKey));
- resetFilters(tabKey);
- reset(vcsLogTabShowBranches(tabKey));
- },
- []
- );
+ const closeTab = useCloseVcsTab();
return (
}>
-
+ closeTab(tabKey)} />
)
}
diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/commit-utils.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/commit-utils.tsx
new file mode 100644
index 00000000..fca17a8f
--- /dev/null
+++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/commit-utils.tsx
@@ -0,0 +1,57 @@
+import React from "react";
+
+export const parseCommitMessage = (commitMessage: string) => {
+ const lines = commitMessage.split("\n");
+ const bodyLines = lines.slice(1);
+ return {
+ subject: lines[0],
+ bodyAsString: bodyLines.join("\n"),
+ body: (
+ <>
+ {bodyLines.map((line, index) => (
+
+ {index > 0 &&