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 }) { ( Select... - me + me )} label="User" @@ -236,33 +249,13 @@ export function VcsLogCommitsView({ tabKey }: { tabKey: string }) { - } - > - - - - + }> - }> - { - return ( - -
- Compact References View -
-
- ); - }} - > - -
-
+ @@ -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 ( - -
- Compact References View -
-
- ); - }} - > - -
-
- - - - - -
- } - 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 &&
} + {line} +
+ ))} + + ), + }; +}; + +export function shortenOid(oid: string) { + return oid.slice(0, 8); +} + +const commitDateTimeFormatter = new Intl.DateTimeFormat([], { + day: "numeric", + month: "numeric", + year: "2-digit", + hour: "numeric", + minute: "numeric", + hour12: true, +}); + +const commitTimeFormatter = new Intl.DateTimeFormat([], { + hour: "numeric", + minute: "numeric", + hour12: true, +}); + +const commitDateFormatter = new Intl.DateTimeFormat([], { + day: "numeric", + month: "numeric", + year: "2-digit", +}); + +export function formatCommitDateTime(date: Date | number) { + return commitDateTimeFormatter.format(date).replace(" ", " "); +} + +export function formatCommitDate(date: Date | number) { + return commitDateFormatter.format(date); +} + +export function formatCommitTime(date: Date | number) { + return commitTimeFormatter.format(date).replace(" ", " "); +} diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/styled-components.ts b/packages/example-app/src/VersionControl/VersionControlToolWindow/styled-components.ts index 119beaf0..01a384c9 100644 --- a/packages/example-app/src/VersionControl/VersionControlToolWindow/styled-components.ts +++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/styled-components.ts @@ -6,6 +6,7 @@ export const StyledHeader = styled.div` align-items: center; height: 2.0625rem; border-bottom: 1px solid ${({ theme }) => theme.commonColors.borderColor}; + flex-shrink: 0; `; export const StyledSpacer = styled.div` flex-grow: 1; @@ -13,10 +14,10 @@ export const StyledSpacer = styled.div` export const StyledPlaceholderContainer = styled.div` color: ${({ theme }) => theme.commonColors.inactiveTextColor}; display: flex; - flex: 1; flex-direction: column; justify-content: end; align-items: center; gap: 0.25rem; min-height: 37%; + overflow: hidden; `; diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/vcs-logs.state.ts b/packages/example-app/src/VersionControl/VersionControlToolWindow/vcs-logs.state.ts index 2d99708f..2372c25c 100644 --- a/packages/example-app/src/VersionControl/VersionControlToolWindow/vcs-logs.state.ts +++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/vcs-logs.state.ts @@ -2,6 +2,7 @@ import { mapObjIndexed } from "ramda"; import { atom, atomFamily, + CallbackInterface, RecoilState, selector, selectorFamily, @@ -67,42 +68,76 @@ export const vcsTabTitleState = selectorFamily({ }, }); +const tabStats: Array<(tabKey: string) => RecoilState> = []; + +/** + * marks a recoil state as a piece of state for VCS log tabs, so that it's cleaned up when the tab is closed. + * @param someAtomFamily + */ +export const vcsLogTabState = RecoilState>( + someAtomFamily: T +): T => { + tabStats.push(someAtomFamily); + return someAtomFamily; +}; + /** * Whether branches filter is visible for a VCS log tab */ -export const vcsLogTabShowBranches = atomFamily({ - key: "vcs/logs/filter/showBranches", - default: false, -}); +export const vcsLogTabShowBranches = vcsLogTabState( + atomFamily({ + key: "vcs/logs/filter/showBranches", + default: false, + }) +); +/** + * Whether the commit details is shown (in a split view together with commit changes). + */ +export const vcsLogTabShowCommitDetails = vcsLogTabState( + atomFamily({ + key: "vcs/logs/showCommitDetails", + default: true, + }) +); + +export const CURRENT_USER_FILTER_VALUE = "*"; export const vcsLogFilter = { /** * The state of search query, for a VCS log tab */ - searchQuery: atomFamily({ - key: "vcs/logs/filter/searchQuery", - default: "", - }), + searchQuery: vcsLogTabState( + atomFamily({ + key: "vcs/logs/filter/searchQuery", + default: "", + }) + ), /** * The state of whether "match case" is on the search query, for a VCS log tab */ - matchCase: atomFamily({ - key: "vcs/logs/filter/matchCase", - default: false, - }), + matchCase: vcsLogTabState( + atomFamily({ + key: "vcs/logs/filter/matchCase", + default: false, + }) + ), /** * The state of whether "regexp" is on the search query, for a VCS log tab */ - regExp: atomFamily({ - key: "vcs/logs/filter/regExp", - default: false, - }), + regExp: vcsLogTabState( + atomFamily({ + key: "vcs/logs/filter/regExp", + default: false, + }) + ), /** * The state of user filter, for a VCS log tab */ - user: atomFamily({ - key: "vcs/logs/filter/user", - default: null, - }), + user: vcsLogTabState( + atomFamily({ + key: "vcs/logs/filter/user", + default: null, + }) + ), /** * The state of date filter, for a VCS log tab */ @@ -113,10 +148,12 @@ export const vcsLogFilter = { /** * The state of date filter, for a VCS log tab */ - branch: atomFamily({ - key: "vcs/logs/filter/branch", - default: [], - }), + branch: vcsLogTabState( + atomFamily({ + key: "vcs/logs/filter/branch", + default: [], + }) + ), }; type FamilyToAtom = F extends (param: any) => RecoilState @@ -151,3 +188,27 @@ export const useResetFilters = () => }, [] ); + +const closeTabCallback = + ({ set, snapshot, reset }: CallbackInterface) => + (tabKey: string) => { + 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)); + tabStats.forEach((state) => reset(state(tabKey))); + }; + +export function useCloseVcsTab() { + return useRecoilCallback(closeTabCallback, []); +} diff --git a/packages/example-app/src/VersionControl/file-status.state.ts b/packages/example-app/src/VersionControl/file-status.state.ts index b7967d11..bdc7c533 100644 --- a/packages/example-app/src/VersionControl/file-status.state.ts +++ b/packages/example-app/src/VersionControl/file-status.state.ts @@ -5,8 +5,7 @@ import { selector, selectorFamily, useRecoilCallback, - useRecoilRefresher_UNSTABLE, - useResetRecoilState, + useSetRecoilState, } from "recoil"; import { sampleRepos } from "../Project/project.state"; import { findRoot, status, statusMatrix } from "isomorphic-git"; @@ -33,22 +32,30 @@ const temporaryVcsMappingsDefaultState = selector({ ); }, }); + +// FIXME: since the value of this atom comes from a selector, everytime it's called in a selector (which happens in +// many places), it will cause a page blink as some component is using a state that depends on this and the loading +// is not handled locally (by either a local Suspense or using useRecoilValueLoadable) export const vcsRootsState = atom({ key: "vcsRoots", - default: temporaryVcsMappingsDefaultState, + default: [], }); /** * temporary(?) hook to refresh vcs roots */ export const useRefreshVcsRoots = () => { - const refreshTemporaryVcsMappingsDefault = useRecoilRefresher_UNSTABLE( - temporaryVcsMappingsDefaultState - ); - const refreshVcsRoots = useResetRecoilState(vcsRootsState); + const setVcsRoots = useSetRecoilState(vcsRootsState); return () => { - refreshTemporaryVcsMappingsDefault(); - refreshVcsRoots(); + asyncFilter( + ({ dir }) => fs.promises.stat(dir).then(Boolean), + Object.values(sampleRepos).map(({ path }) => ({ + dir: path, + vcs: "git", + })) + ).then((roots) => { + setVcsRoots(roots); + }); }; }; diff --git a/packages/example-app/src/VersionControl/git-users.state.ts b/packages/example-app/src/VersionControl/git-users.state.ts new file mode 100644 index 00000000..2b2aab4b --- /dev/null +++ b/packages/example-app/src/VersionControl/git-users.state.ts @@ -0,0 +1,59 @@ +import { selectorFamily } from "recoil"; +import git from "isomorphic-git"; +import { fs } from "../fs/fs"; + +export type GitUser = { name: string; email: string }; + +export function areUsersEqual( + user1: GitUser | null | undefined, + user2: GitUser | null | undefined +): boolean { + return ( + Boolean(user1) && + user1?.email === user2?.email && + user1?.name === user2?.name + ); +} +export function areSamePerson(user1: GitUser, user2: GitUser): boolean { + return ( + getNameInStandardForm(user1.name) === getNameInStandardForm(user2.name) + ); +} +const NAME_PATTERN = "(\\w+)[\\p{Punct}\\s](\\w+)"; + +function getNameInStandardForm(name: string) { + const firstAndLastName = getFirstAndLastName(name); + if (firstAndLastName != null) { + const [firstName, lastName] = firstAndLastName; + return firstName.toLowerCase() + " " + lastName.toLowerCase(); // synonyms detection is currently english-only + } + return nameToLowerCase(name); +} + +const PRINTABLE_ASCII_PATTERN = "[ -~]*"; +function nameToLowerCase(name: string) { + if (!name.match(PRINTABLE_ASCII_PATTERN)) return name; + return name.toLowerCase(); +} +function getFirstAndLastName(name: string) { + const matches = name.match(NAME_PATTERN); + if (matches) { + return [matches[1], matches[2]]; + } + return null; +} +export const gitRepoUserState = selectorFamily({ + key: "vcs/repo-user", + get: (repoRoot: string) => async () => { + const name = await git.getConfig({ fs, dir: repoRoot, path: "user.name" }); + const email = await git.getConfig({ + fs, + dir: repoRoot, + path: "user.email", + }); + return { + name: name ?? "", + email: email ?? "", + }; + }, +}); diff --git a/packages/example-app/src/VersionControl/refs.state.ts b/packages/example-app/src/VersionControl/refs.state.ts new file mode 100644 index 00000000..5a751d24 --- /dev/null +++ b/packages/example-app/src/VersionControl/refs.state.ts @@ -0,0 +1,15 @@ +import { selectorFamily } from "recoil"; +import git from "isomorphic-git"; +import { fs } from "../fs/fs"; + +export const resolvedRefState = selectorFamily< + string, + { repoRoot: string; ref: string } +>({ + key: "vcs/resolved-branche-ref", + get: + ({ repoRoot, ref }: { repoRoot: string; ref: string }) => + () => { + return git.resolveRef({ fs, dir: repoRoot, ref, depth: 3 }); + }, +}); diff --git a/packages/example-app/src/fs/browser-fs.tsx b/packages/example-app/src/fs/browser-fs.tsx index ceeee1f5..541106d6 100644 --- a/packages/example-app/src/fs/browser-fs.tsx +++ b/packages/example-app/src/fs/browser-fs.tsx @@ -3,8 +3,8 @@ import * as BrowserFS from "browserfs"; // @ts-expect-error: https://github.com/sindresorhus/pify/issues/74 import pify from "pify"; import LightningFS from "@isomorphic-git/lightning-fs"; -import { BFSCallback } from "browserfs/src/core/file_system"; import FS, { FSModule } from "browserfs/dist/node/core/FS"; +import { BFSCallback } from "browserfs/dist/node/core/file_system"; // importing the type didn't work as expected type FileSystem = Parameters[0]; diff --git a/packages/example-app/src/index.tsx b/packages/example-app/src/index.tsx index 694115e2..b2484219 100644 --- a/packages/example-app/src/index.tsx +++ b/packages/example-app/src/index.tsx @@ -2,10 +2,10 @@ import React from "react"; import ReactDOM from "react-dom"; import { App } from "./App"; import darculaThemeJson from "@intellij-platform/core/themes/darcula.theme.json"; -import { Theme, ThemeProvider } from "@intellij-platform/core"; +import { Theme, ThemeJson, ThemeProvider } from "@intellij-platform/core"; ReactDOM.render( - + , document.getElementById("app") diff --git a/packages/example-app/src/jetbrains-mono-font.css b/packages/example-app/src/jetbrains-mono-font.css new file mode 100644 index 00000000..8a881d56 --- /dev/null +++ b/packages/example-app/src/jetbrains-mono-font.css @@ -0,0 +1 @@ +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap"); diff --git a/packages/example-app/src/recoil-utils.ts b/packages/example-app/src/recoil-utils.ts index 10c246fc..6a27a47b 100644 --- a/packages/example-app/src/recoil-utils.ts +++ b/packages/example-app/src/recoil-utils.ts @@ -1,9 +1,9 @@ import { + Loadable, RecoilState, RecoilValue, useRecoilCallback, useRecoilRefresher_UNSTABLE, - useRecoilState, useRecoilValueLoadable, useSetRecoilState, } from "recoil"; @@ -55,8 +55,17 @@ export const createFocusBasedSetterHook = ( /** * Returns the latest value of a recoil value, or null, if the value is being loaded for the first time. * Useful for when stale data is ok, but may get synced soon. + * + * TODO: returning a tuple here is not the best API. Ideally we could have a fourth type of Lodable which would be + * similar to LoadingLoadale, but would hold a previous value as well. Then we could have a hook like similar to + * useRecoilValueLoadable, but returning that specific type of Loadable. The only difference with + * useRecoilValueLoadable would be calling getValue() would return the previous value if state is loading, making it + * easy to implement the common pattern of keeping the previous data plus a loading indicator, when new data is being + * loaded. */ -export function useLatestRecoilValue(recoilValue: RecoilValue): T | null { +export function useLatestRecoilValue( + recoilValue: RecoilValue +): [T | null, Loadable["state"]] { const [state, setState] = useState(null); let loadable = useRecoilValueLoadable(recoilValue); @@ -66,7 +75,10 @@ export function useLatestRecoilValue(recoilValue: RecoilValue): T | null { } }, [loadable]); - return state; + return [ + loadable.state === "hasValue" ? loadable.getValue() : state, + loadable.state, + ]; } /** diff --git a/packages/example-app/src/tree-utils/groupByDirectory.spec.ts b/packages/example-app/src/tree-utils/groupByDirectory.spec.ts index 9073cc21..fa1775ad 100644 --- a/packages/example-app/src/tree-utils/groupByDirectory.spec.ts +++ b/packages/example-app/src/tree-utils/groupByDirectory.spec.ts @@ -1,20 +1,48 @@ -import { groupByDirectory } from "./groupByDirectory"; -import { ChangeNode } from "../VersionControl/Changes/ChangesView/change-view-nodes"; +import { DirectoryNode, createGroupByDirectory } from "./groupByDirectory"; +import { ChangeNode } from "../VersionControl/Changes/ChangesTree/ChangeTreeNode"; import { readFileSync } from "fs"; import { performance } from "perf_hooks"; +import { Change } from "../VersionControl/Changes/Change"; const change = (path: string): ChangeNode => ({ key: path, type: "change", change: { - 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"); + }, + }, }, + showPath: false, +}); + +const groupByDirectory = createGroupByDirectory({ + getPath: (node) => Change.path(node.change), +}); + +const groupByDirectoryMergingPaths = createGroupByDirectory< + ChangeNode, + DirectoryNode +>({ + getPath: (node) => Change.path(node.change), + shouldCollapseDirectories: true, }); describe("groupByDirectory", () => { it("groups by directory", () => { - const groups = groupByDirectory([change("/a/b/x.js"), change("/a/c/y.js")]); + const y = change("/a/c/y.js"); + const x = change("/a/b/x.js"); + const groups = groupByDirectory([x, y]); expect(groups).toMatchObject([ { dirPath: "/a", @@ -23,12 +51,12 @@ describe("groupByDirectory", () => { expect.objectContaining({ dirPath: "/a/b", parentNodePath: "/a", - children: [change("/a/b/x.js")], + children: [x], }), expect.objectContaining({ dirPath: "/a/c", parentNodePath: "/a", - children: [change("/a/c/y.js")], + children: [y], }), ]), }, @@ -36,29 +64,29 @@ describe("groupByDirectory", () => { }); it("merges directories all the way through", () => { - const groups = groupByDirectory([change("/a/b/c/d/x.js")]); + const x = change("/a/b/c/d/x.js"); + const groups = groupByDirectoryMergingPaths([x]); expect(groups).toMatchObject([ { dirPath: "/a/b/c/d", parentNodePath: "", - children: [change("/a/b/c/d/x.js")], + children: [x], }, ]); }); it("merges directories when possible", () => { - const groups = groupByDirectory([ - change("/a/b/bc/h/g/x.js"), - change("/e/f/g/u.js"), - change("/a/b/bc/d/y.js"), - change("/a/z.js"), - ]); + const x = change("/a/b/bc/h/g/x.js"); + const u = change("/e/f/g/u.js"); + const y = change("/a/b/bc/d/y.js"); + const z = change("/a/z.js"); + const groups = groupByDirectoryMergingPaths([x, u, y, z]); expect(groups).toMatchObject([ { dirPath: "/a", parentNodePath: "", children: expect.arrayContaining([ - change("/a/z.js"), + z, expect.objectContaining({ dirPath: "/a/b/bc", parentNodePath: "/a", @@ -66,12 +94,12 @@ describe("groupByDirectory", () => { expect.objectContaining({ dirPath: "/a/b/bc/d", parentNodePath: "/a/b/bc", - children: [change("/a/b/bc/d/y.js")], + children: [y], }), expect.objectContaining({ dirPath: "/a/b/bc/h/g", parentNodePath: "/a/b/bc", - children: [change("/a/b/bc/h/g/x.js")], + children: [x], }), ]), }), @@ -80,7 +108,7 @@ describe("groupByDirectory", () => { { dirPath: "/e/f/g", parentNodePath: "", - children: [change("/e/f/g/u.js")], + children: [u], }, ]); }); @@ -106,7 +134,7 @@ describe("groupByDirectory", () => { ); const timeFor5_000 = measureTime(() => groupByDirectory(changeNodes)); expect(timeFor5_000).toBeLessThan( - 3 /* Some empirical calibration factor*/ * timeFor50 * 100 + 4 /* Some empirical calibration factor*/ * timeFor50 * 100 ); expect(timeFor5_000).toBeLessThan(relativePerformanceMeasure); diff --git a/packages/example-app/src/tree-utils/groupByDirectory.ts b/packages/example-app/src/tree-utils/groupByDirectory.ts index e8245815..cb4f9276 100644 --- a/packages/example-app/src/tree-utils/groupByDirectory.ts +++ b/packages/example-app/src/tree-utils/groupByDirectory.ts @@ -1,4 +1,4 @@ -import { uniq } from "ramda"; +import { identity, uniq } from "ramda"; import { getParentPaths } from "../file-utils"; export interface DirectoryNode { @@ -11,21 +11,30 @@ export interface DirectoryNode { type GroupByDirectorAdapter = { shouldCollapseDirectories?: boolean; getPath: (node: T) => string; + /** + * Maps each processed node. E.g. can be used to enrich nodes with grouping metadata. + * @example + * ``` + * mapNode: (node) => ({...node, isGroupedByDirectory: true}) + * ``` + */ + mapNode?: (node: T) => T; createDirectoryNode?: (dirNode: DirectoryNode) => D; }; /** * Exported only for tests. * @internal */ -export const groupByDirectory = +export const createGroupByDirectory = ({ getPath, + mapNode = identity, createDirectoryNode = (i) => i as D, shouldCollapseDirectories = false, }: GroupByDirectorAdapter) => (nodes: ReadonlyArray): readonly D[] => { const pathToNodeCache: Record = {}; - const rootDirNodes = nodes.map((node) => { + const rootDirNodes = nodes.map(mapNode).map((node) => { const filepath = getPath(node); // noinspection UnnecessaryLocalVariableJS const rootDirNode = getParentPaths( diff --git a/packages/example-app/tsconfig.app.json b/packages/example-app/tsconfig.app.json new file mode 100644 index 00000000..01bd8192 --- /dev/null +++ b/packages/example-app/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "types": [] + }, + "include": ["src"], + "exclude": ["src/**/*.cy.tsx", "src/**/*.stories.tsx", "src/**/*.spec.ts"] +} diff --git a/packages/example-app/tsconfig.jest.json b/packages/example-app/tsconfig.jest.json index 29fdc339..b615191d 100644 --- a/packages/example-app/tsconfig.jest.json +++ b/packages/example-app/tsconfig.jest.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["jest"] + "types": ["jest", "node"] }, "include": ["**/*.spec.ts"] } diff --git a/packages/example-app/tsconfig.json b/packages/example-app/tsconfig.json index 81916096..604d878e 100644 --- a/packages/example-app/tsconfig.json +++ b/packages/example-app/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "../../tsconfig.json", + // keeping references here helps with editor support. We don't compile this project directly. "references": [ + { + "path": "tsconfig.app.json" + }, { "path": "tsconfig.jest.json" } diff --git a/packages/jui/integration-tests/modal-window-and-tree.cy.tsx b/packages/jui/integration-tests/modal-window-and-tree.cy.tsx new file mode 100644 index 00000000..1e6db4ae --- /dev/null +++ b/packages/jui/integration-tests/modal-window-and-tree.cy.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { ModalWindow, styled, WindowLayout } from "@intellij-platform/core"; +import { SpeedSearchTreeSample } from "../src/story-components"; + +const StyledContainer = styled.div` + box-sizing: border-box; + height: 100%; + display: flex; + flex-direction: column; + padding: 1rem 0.75rem; +`; +const StyledFrame = styled.div` + border: 1px solid ${({ theme }) => theme.commonColors.contrastBorder}; + flex: 1; + overflow: auto; +`; + +describe("ModalWindow containing Tree", () => { + // FIXME(https://github.com/alirezamirian/jui/issues/56): unskip + it.skip("should be sized to fit tree", () => { + cy.mount( + + + + + + + } + /> + + ); + cy.findByRole("dialog").invoke("width").should("be.greaterThan", 180); + matchImageSnapshot("ModalWindow-sized-based-on-Tree"); + }); +}); + +function matchImageSnapshot(snapshotsName: string) { + cy.get("[data-loading-icon]").should("not.exist"); + cy.percySnapshot(snapshotsName); +} diff --git a/packages/jui/package.json b/packages/jui/package.json index 291a70ba..de6531b5 100644 --- a/packages/jui/package.json +++ b/packages/jui/package.json @@ -116,7 +116,7 @@ "storybook-addon-theme-provider": "^0.1.10", "stream-browserify": "^3.0.0", "ts-loader": "^8", - "typescript": "*", + "typescript": "workspace:*", "webpack": "5" }, "peerDependencies": { diff --git a/packages/jui/src/ActionSystem/ActionsProvider.tsx b/packages/jui/src/ActionSystem/ActionsProvider.tsx index d2b60327..3d4c6563 100644 --- a/packages/jui/src/ActionSystem/ActionsProvider.tsx +++ b/packages/jui/src/ActionSystem/ActionsProvider.tsx @@ -1,4 +1,4 @@ -import { pick, sortBy } from "ramda"; +import { sortBy } from "ramda"; import React, { HTMLAttributes, useContext, useEffect, useState } from "react"; import { useEventCallback } from "@intellij-platform/core/utils/useEventCallback"; import { dfsVisit } from "@intellij-platform/core/utils/tree-utils"; @@ -68,8 +68,9 @@ export function ActionsProvider(props: ActionsProviderProps): JSX.Element { recursivelyCreateActions(keymap, props.actions) ); - const actionIds = actions.map((action) => action.id); - const shortcuts = pick(actionIds, keymap || {}); + const shortcuts = Object.fromEntries( + actions.map((action) => [action.id, action.shortcuts || []]) + ); const [actionProviderId] = useState(generateId); const { shortcutHandlerProps } = useShortcuts( diff --git a/packages/jui/src/ActionSystem/CommonActionIds.ts b/packages/jui/src/ActionSystem/CommonActionIds.ts index 1b56a2f2..3a3328cc 100644 --- a/packages/jui/src/ActionSystem/CommonActionIds.ts +++ b/packages/jui/src/ActionSystem/CommonActionIds.ts @@ -8,4 +8,6 @@ export const CommonActionId = { SHOW_INTENTION_ACTIONS: "ShowIntentionActions", EDIT_SOURCE: "Documentation.EditSource", SHOW_SEARCH_HISTORY: "ShowSearchHistory", + COPY_REFERENCE: "CopyReference", + REFRESH: "Refresh", }; diff --git a/packages/jui/src/ActionSystem/defaultKeymap.tsx b/packages/jui/src/ActionSystem/defaultKeymap.tsx index d4479fb0..d69bedf5 100644 --- a/packages/jui/src/ActionSystem/defaultKeymap.tsx +++ b/packages/jui/src/ActionSystem/defaultKeymap.tsx @@ -208,7 +208,6 @@ export const defaultKeymap: Keymap = { }, }, ], - [CommonActionId.SHOW_SEARCH_HISTORY]: [ { type: "keyboard", @@ -218,4 +217,22 @@ export const defaultKeymap: Keymap = { }, }, ], + [CommonActionId.COPY_REFERENCE]: [ + { + type: "keyboard", + firstKeyStroke: { + modifiers: ["Meta", "Shift", "Alt"], + code: "KeyC", + }, + }, + ], + [CommonActionId.REFRESH]: [ + { + type: "keyboard", + firstKeyStroke: { + modifiers: ["Meta"], + code: "KeyR", + }, + }, + ], }; diff --git a/packages/jui/src/Button/Button.tsx b/packages/jui/src/Button/Button.tsx index c15c923c..53f0b8af 100644 --- a/packages/jui/src/Button/Button.tsx +++ b/packages/jui/src/Button/Button.tsx @@ -1,4 +1,8 @@ -import React, { ButtonHTMLAttributes, ForwardedRef } from "react"; +import React, { + ButtonHTMLAttributes, + CSSProperties, + ForwardedRef, +} from "react"; import { useButton } from "@react-aria/button"; import { AriaButtonProps } from "@react-types/button"; import { filterDOMProps, mergeProps, useObjectRef } from "@react-aria/utils"; @@ -17,6 +21,8 @@ export interface ButtonProps extends AriaButtonProps { preventFocusOnPress?: boolean; form?: ButtonHTMLAttributes["form"]; + style?: CSSProperties; + className?: string; } const variants: { [key in ButtonVariant]: typeof StyledButton } = { @@ -49,7 +55,7 @@ const variants: { [key in ButtonVariant]: typeof StyledButton } = { * */ export const Button: React.FC = React.forwardRef(function Button( - { variant, ...props }: ButtonProps, + { variant, style, className, ...props }: ButtonProps, forwardedRef: ForwardedRef ) { const ref = useObjectRef(forwardedRef); @@ -61,6 +67,8 @@ export const Button: React.FC = React.forwardRef(function Button( return ( {props.children} diff --git a/packages/jui/src/CollectionSpeedSearch/useCollectionSpeedSearch.ts b/packages/jui/src/CollectionSpeedSearch/useCollectionSpeedSearch.ts index 46e1b497..db29f290 100644 --- a/packages/jui/src/CollectionSpeedSearch/useCollectionSpeedSearch.ts +++ b/packages/jui/src/CollectionSpeedSearch/useCollectionSpeedSearch.ts @@ -1,7 +1,10 @@ import { HTMLAttributes, RefObject, useMemo } from "react"; import { Collection, KeyboardDelegate, Node } from "@react-types/shared"; import { SelectionManager } from "@react-stately/selection"; -import { SpeedSearchPopupProps } from "@intellij-platform/core/SpeedSearch"; +import { + SpeedSearchPopupProps, + SpeedSearchProps, +} from "@intellij-platform/core/SpeedSearch"; import { SpeedSearchState, SpeedSearchStateProps, @@ -41,7 +44,7 @@ export interface CollectionSpeedSearch { export function useCollectionSpeedSearch({ collection, selectionManager, - stickySearch, + keepSearchActiveOnBlur, keyboardDelegate, focusBestMatch, ref, @@ -51,9 +54,9 @@ export function useCollectionSpeedSearch({ selectionManager: SelectionManager; keyboardDelegate: KeyboardDelegate; ref: RefObject; - stickySearch?: boolean; focusBestMatch?: boolean; -} & SpeedSearchStateProps): CollectionSpeedSearch { +} & SpeedSearchStateProps & + Pick): CollectionSpeedSearch { const speedSearch = useSpeedSearchState(speedSearchStateProps); const { matches, selectionManager: speedSearchSelectionManager } = @@ -63,7 +66,11 @@ export function useCollectionSpeedSearch({ speedSearch, focusBestMatch, }); - const { containerProps } = useSpeedSearch({ stickySearch }, speedSearch, ref); + const { containerProps } = useSpeedSearch( + { keepSearchActiveOnBlur }, + speedSearch, + ref + ); const speedSearchKeyboardDelegate = useMemo( () => createSpeedSearchKeyboardDelegate( diff --git a/packages/jui/src/Img.tsx b/packages/jui/src/Img.tsx index 4aa71902..53f5cf30 100644 --- a/packages/jui/src/Img.tsx +++ b/packages/jui/src/Img.tsx @@ -1,8 +1,8 @@ -import React, { ComponentProps } from "react"; +import React, { ComponentProps, HTMLAttributes, HTMLProps } from "react"; import { useTheme } from "styled-components"; import { Theme } from "./Theme/Theme"; -interface Props extends ComponentProps<"img"> { +interface Props extends React.ImgHTMLAttributes { /** * src for when a dark theme is active. if `darkSrc` doesn't have a non-empty string value, src will be used both * for dark and light themes. diff --git a/packages/jui/src/List/List.cy.tsx b/packages/jui/src/List/List.cy.tsx index 95b594e6..0e0a1c56 100644 --- a/packages/jui/src/List/List.cy.tsx +++ b/packages/jui/src/List/List.cy.tsx @@ -3,11 +3,13 @@ import * as React from "react"; import * as stories from "./List.stories"; import { Item, List } from "@intellij-platform/core"; -const { Default, WithConnectedInput } = composeStories(stories); +const { SingleSelection, WithConnectedInput } = composeStories(stories); describe("List", () => { it("renders as expected", () => { - cy.mount(); + cy.mount(); + cy.findByRole("list"); + cy.findAllByRole("listitem").should("have.length", 9); matchImageSnapshot("List-default"); }); @@ -30,7 +32,7 @@ describe("List", () => { it("calls onAction for items on click or Enter", () => { const onAction = cy.stub().as("onAction"); - cy.mount(); + cy.mount(); cy.contains("Vicente Amigo").dblclick(); cy.get("@onAction").should("be.calledOnceWith", "Vicente Amigo"); @@ -39,7 +41,9 @@ describe("List", () => { }); it("auto selects the first item when empty selection is disallowed and nothing is selected", () => { - cy.mount(); + cy.mount( + + ); cy.findByRole("list").focus(); cy.findAllByRole("listitem").first().should("be.selected"); cy.realPress("ArrowDown"); diff --git a/packages/jui/src/List/List.stories.tsx b/packages/jui/src/List/List.stories.tsx index 2928e515..10a8f5d0 100644 --- a/packages/jui/src/List/List.stories.tsx +++ b/packages/jui/src/List/List.stories.tsx @@ -23,24 +23,34 @@ import { export default { title: "Components/List (Basic)", component: List, -} as Meta; + args: { + items: legends, + children: itemRenderer(renderItemText), + fillAvailableSpace: true, + }, +} as Meta>; -export const Default: StoryObj> = { - render: (props) => ( - - - {itemRenderer(renderItemText)} - - - ), - args: {}, +const renderInPane = (props: ListProps) => ( + + + +); +export const SingleSelection: StoryObj> = { + render: renderInPane, + args: { + selectionMode: "single", + }, }; +export const allowEmptySelection: StoryObj> = + { + render: renderInPane, + args: { + selectionMode: "single", + allowEmptySelection: true, + }, + }; + export const WithConnectedInput = commonListStories.withConnectedInput(List); const StyledLabel = styled.label` @@ -66,6 +76,7 @@ export const shownAsFocused: StoryFn = () => { selectionMode="single" items={legends} fillAvailableSpace + estimatedItemHeight={40} showAsFocused={shownAsFocused} > {itemRenderer(renderItemCustomUI)} diff --git a/packages/jui/src/List/List.tsx b/packages/jui/src/List/List.tsx index 8e291b8a..c1a2b0a5 100644 --- a/packages/jui/src/List/List.tsx +++ b/packages/jui/src/List/List.tsx @@ -1,13 +1,16 @@ import { AriaListBoxProps } from "@react-types/listbox"; -import { AsyncLoadable } from "@react-types/shared"; +import { AsyncLoadable, Node } from "@react-types/shared"; import React, { ForwardedRef, Key } from "react"; import { useList } from "./useList"; import { ListItem } from "./ListItem"; import { StyledList } from "./StyledList"; -import { listItemRenderer } from "./listItemRenderer"; import { useListState } from "./useListState"; import { useObjectRef } from "@react-aria/utils"; + import { CollectionRefProps } from "@intellij-platform/core/Collections/useCollectionRef"; +import { Virtualizer } from "@react-aria/virtualizer"; +import { useListVirtualizer } from "@intellij-platform/core/List/useListVirtualizer"; +import { ListContext } from "@intellij-platform/core/List/ListContext"; export type ListProps = Omit< Omit, "disallowEmptySelection">, @@ -32,24 +35,28 @@ export type ListProps = Omit< * Enter not implemented yet :D */ onAction?: (key: Key) => void; + + /** + * Useful when list items have a custom height, to hint layout calculation logic about the item height which + * increases rendering efficiency and also prevents flash of items with wrong height. + */ + estimatedItemHeight?: number; + + className?: string; }; /** - * List view with speedSearch instead of default typeahead. - * TODO: - * - Support virtualization - * - Support custom rendering - * - + * List view */ export const List = React.forwardRef(function List( { allowEmptySelection = false, - showAsFocused = false, fillAvailableSpace = false, - onAction, + estimatedItemHeight, + className, ...inputProps }: ListProps, - forwardedRef: ForwardedRef + forwardedRef: ForwardedRef ) { const props: AriaListBoxProps & CollectionRefProps = { ...inputProps, @@ -57,27 +64,35 @@ export const List = React.forwardRef(function List( }; const ref = useObjectRef(forwardedRef); const state = useListState(props); - const { listProps, focused } = useList(props, state, ref); + const { listProps, listContext } = useList( + { + ...props, + isVirtualized: true, + }, + state, + ref + ); + + const { + virtualizerProps: { children: renderNode, ...virtualizerProps }, + } = useListVirtualizer({ + state, + estimatedItemHeight, + renderItem: (item) => , + }); return ( - - {[...state.collection].map( - listItemRenderer({ - item: (item) => ( - onAction?.(item.key)} - listFocused={showAsFocused || focused} - /> - ), - }) - )} - + + , any>} + {...virtualizerProps} + {...listProps} + fillAvailableSpace={fillAvailableSpace} + className={className} + ref={ref} + > + {renderNode} + + ); }); diff --git a/packages/jui/src/List/ListContext.tsx b/packages/jui/src/List/ListContext.tsx new file mode 100644 index 00000000..04daf49b --- /dev/null +++ b/packages/jui/src/List/ListContext.tsx @@ -0,0 +1,12 @@ +import React, { Key } from "react"; +import { ListState } from "@react-stately/list"; + +export type ListContextType = { + state: ListState; + focused: boolean; + onAction: ((key: Key) => void) | undefined; +}; + +export const ListContext = React.createContext | null>( + null +); diff --git a/packages/jui/src/List/ListItem.tsx b/packages/jui/src/List/ListItem.tsx index cca625c1..024987b5 100644 --- a/packages/jui/src/List/ListItem.tsx +++ b/packages/jui/src/List/ListItem.tsx @@ -1,26 +1,18 @@ -import React from "react"; +import React, { useContext } from "react"; import { Node } from "@react-types/shared"; -import { ListState } from "@react-stately/list"; import { usePress } from "@react-aria/interactions"; import { useSelectableItem } from "@intellij-platform/core/selection"; import { ItemStateContext } from "@intellij-platform/core/Collections"; import { StyledListItem } from "./StyledListItem"; +import { ListContext } from "@intellij-platform/core/List/ListContext"; export interface ListItemProps { - listFocused: boolean; item: Node; - state: ListState; - onAction: () => void; children?: React.ReactNode; } -export function ListItem({ - listFocused, - item, - state, - onAction, - children, -}: ListItemProps) { +export function ListItem({ item, children }: ListItemProps) { + const { state, focused: listFocused, onAction } = useContext(ListContext)!; const ref = React.useRef(null); const isDisabled = state.disabledKeys.has(item.key); const isSelected = state.selectionManager.isSelected(item.key); @@ -28,7 +20,7 @@ export function ListItem({ const { itemProps } = useSelectableItem({ key: item.key, ref, - onAction, + onAction: () => onAction?.(item.key), selectionManager: state.selectionManager, }); let { pressProps } = usePress({ diff --git a/packages/jui/src/List/SpeedSearchList/SpeedSearch.cy.tsx b/packages/jui/src/List/SpeedSearchList/SpeedSearch.cy.tsx index ed3f172e..b5c13b4a 100644 --- a/packages/jui/src/List/SpeedSearchList/SpeedSearch.cy.tsx +++ b/packages/jui/src/List/SpeedSearchList/SpeedSearch.cy.tsx @@ -4,10 +4,11 @@ import * as stories from "./SpeedSearchList.stories"; const { WithHighlight, WithConnectedInput } = composeStories(stories); -describe("SpeedSearch", () => { +describe("SpeedSearchList", () => { it("renders as expected", () => { cy.mount(); cy.findByRole("list").focus().realType("g"); + cy.findAllByRole("listitem").should("have.length", 9); matchImageSnapshot("SpeedSearchList-searched"); }); diff --git a/packages/jui/src/List/SpeedSearchList/SpeedSearchList.stories.tsx b/packages/jui/src/List/SpeedSearchList/SpeedSearchList.stories.tsx index 90ee061e..69bef79b 100644 --- a/packages/jui/src/List/SpeedSearchList/SpeedSearchList.stories.tsx +++ b/packages/jui/src/List/SpeedSearchList/SpeedSearchList.stories.tsx @@ -55,6 +55,7 @@ export const HighlightInCustomUI: StoryFn = () => { {itemRenderer(renderItemCustomUI, )} diff --git a/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx b/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx index ac570a1e..abf764ff 100644 --- a/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx +++ b/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx @@ -1,24 +1,28 @@ import React, { ForwardedRef } from "react"; import { AriaListBoxProps } from "@react-types/listbox"; +import { useObjectRef } from "@react-aria/utils"; +import { Virtualizer } from "@react-aria/virtualizer"; +import { Node } from "@react-types/shared"; + import { CollectionSpeedSearchContainer, CollectionSpeedSearchContext, SpeedSearchItemHighlightsProvider, } from "@intellij-platform/core/CollectionSpeedSearch"; import { - SpeedSearchProps, - SpeedSearchPopup, SpeedSearch, + SpeedSearchPopup, + SpeedSearchProps, } from "@intellij-platform/core/SpeedSearch"; +import { CollectionRefProps } from "@intellij-platform/core/Collections/useCollectionRef"; import { StyledList } from "../StyledList"; import { ListProps } from "../List"; -import { useSpeedSearchList } from "./useSpeedSearchList"; -import { listItemRenderer } from "../listItemRenderer"; import { useListState } from "../useListState"; import { ListItem } from "../ListItem"; -import { CollectionRefProps } from "@intellij-platform/core/Collections/useCollectionRef"; -import { useObjectRef } from "@react-aria/utils"; +import { ListContext } from "../ListContext"; +import { useListVirtualizer } from "../useListVirtualizer"; +import { useSpeedSearchList } from "./useSpeedSearchList"; export interface SpeedSearchListProps extends ListProps, @@ -32,12 +36,11 @@ export const SpeedSearchList = React.forwardRef(function SpeedSearchList< >( { allowEmptySelection = false, - showAsFocused = false, fillAvailableSpace = false, - onAction, + estimatedItemHeight, ...inputProps }: SpeedSearchListProps, - forwardedRef: ForwardedRef + forwardedRef: ForwardedRef ) { const props: AriaListBoxProps & CollectionRefProps = { ...inputProps, @@ -46,35 +49,37 @@ export const SpeedSearchList = React.forwardRef(function SpeedSearchList< const ref = useObjectRef(forwardedRef); const state = useListState(props); - const { listProps, searchPopupProps, focused, speedSearchContextValue } = - useSpeedSearchList(props, state, ref); + const { listProps, listContext, searchPopupProps, speedSearchContextValue } = + useSpeedSearchList({ ...props, isVirtualized: true }, state, ref); + + const { + virtualizerProps: { children: renderNode, ...virtualizerProps }, + } = useListVirtualizer({ + state, + estimatedItemHeight, + renderItem: (item) => ( + + + + ), + }); return ( - - - - - {[...state.collection].map( - listItemRenderer({ - item: (item) => ( - - onAction?.(item.key)} - /> - - ), - }) - )} - - - + + + + + , any>} + ref={ref} + fillAvailableSpace={fillAvailableSpace} + {...virtualizerProps} + {...listProps} + > + {renderNode} + + + + ); }); diff --git a/packages/jui/src/List/SpeedSearchList/useSpeedSearchList.ts b/packages/jui/src/List/SpeedSearchList/useSpeedSearchList.ts index 589186ff..afbb493e 100644 --- a/packages/jui/src/List/SpeedSearchList/useSpeedSearchList.ts +++ b/packages/jui/src/List/SpeedSearchList/useSpeedSearchList.ts @@ -3,30 +3,31 @@ import { SelectionManager } from "@react-stately/selection"; import { HTMLProps, Key, RefObject } from "react"; import { mergeProps } from "@react-aria/utils"; import { ListKeyboardDelegate } from "@react-aria/selection"; +import { + CollectionSpeedSearchContextValue, + useCollectionSpeedSearch, +} from "@intellij-platform/core/CollectionSpeedSearch"; +import { SpeedSearchProps } from "@intellij-platform/core/SpeedSearch"; import { SpeedSearchPopupProps } from "../../SpeedSearch/SpeedSearchPopup"; import { TextRange } from "../../TextRange"; -import { useCollectionSpeedSearch } from "../../CollectionSpeedSearch/useCollectionSpeedSearch"; import { ListProps, useList } from "../useList"; -import { CollectionSpeedSearchContextValue } from "@intellij-platform/core/CollectionSpeedSearch"; interface UseListProps - extends Omit { - stickySearch?: boolean; -} + extends Omit, + Pick {} export function useSpeedSearchList( props: UseListProps, listState: ListState, ref: RefObject -): { - listProps: Omit, "as" | "ref">; +): ReturnType> & { searchPopupProps: SpeedSearchPopupProps; focused: boolean; selectionManager: SelectionManager; speedSearchContextValue: CollectionSpeedSearchContextValue; matches: Map; } { - const { stickySearch } = props; + const { keepSearchActiveOnBlur } = props; const { speedSearch, @@ -43,10 +44,10 @@ export function useSpeedSearchList( listState.disabledKeys, ref ), - stickySearch, + keepSearchActiveOnBlur, ref, }); - const { listProps, focused } = useList( + const { listProps, ...otherOutputs } = useList( { ...props, disallowTypeAhead: true, @@ -57,9 +58,9 @@ export function useSpeedSearchList( ); return { + ...otherOutputs, listProps: mergeProps(listProps, speedSearchContainerProps), matches: speedSearch.matches, - focused, selectionManager, speedSearchContextValue, searchPopupProps, diff --git a/packages/jui/src/List/StyledList.tsx b/packages/jui/src/List/StyledList.tsx index 04a9d754..8eb63ecc 100644 --- a/packages/jui/src/List/StyledList.tsx +++ b/packages/jui/src/List/StyledList.tsx @@ -1,11 +1,14 @@ import { css } from "styled-components"; import { styled } from "../styled"; -export const StyledList = styled.ul.withConfig<{ +type StyledListProps = { fillAvailableSpace?: boolean; -}>({ - shouldForwardProp: (prop) => prop !== "fillAvailableSpace", -})` +}; +export const StyledList = styled.div + .attrs({ role: "list" }) + .withConfig({ + shouldForwardProp: (prop) => prop !== "fillAvailableSpace", + })` padding: 0; margin: 0; list-style: none; diff --git a/packages/jui/src/List/StyledListItem.tsx b/packages/jui/src/List/StyledListItem.tsx index 27a32e39..ef335edc 100644 --- a/packages/jui/src/List/StyledListItem.tsx +++ b/packages/jui/src/List/StyledListItem.tsx @@ -7,8 +7,10 @@ export type StyledListItemProps = { disabled: boolean; }; -export const StyledListItem = styled.li( - ({ containerFocused, selected, disabled, theme }) => { +export const StyledListItem = styled.div.attrs({ + role: "listitem", +})` + ${({ containerFocused, selected, disabled, theme }) => { let backgroundColor; let color = disabled ? theme.color("*.disabledForeground") @@ -37,17 +39,16 @@ export const StyledListItem = styled.li( } } return { - backgroundColor, + backgroundColor: theme.asCurrentBackground(backgroundColor), color, - position: "relative", - display: "flex", - whiteSpace: "nowrap", - paddingLeft: "0.5rem", // themed? - paddingRight: "0.5rem", // themed? - lineHeight: "20px", - outline: "none", - cursor: "default", - minWidth: "min-content", // ? }; - } -); + }}; + position: relative; + display: flex; + white-space: nowrap; + padding: 0 0.5rem; // themed? + line-height: 1.25rem; + outline: none; + cursor: default; + min-width: min-content; // Needed for content sizing for when list/tree is used inside popup or modal window +`; diff --git a/packages/jui/src/List/StyledListSectionHeader.tsx b/packages/jui/src/List/StyledListSectionHeader.tsx index ba84570c..03afc1e1 100644 --- a/packages/jui/src/List/StyledListSectionHeader.tsx +++ b/packages/jui/src/List/StyledListSectionHeader.tsx @@ -1,6 +1,6 @@ import { styled } from "../styled"; -export const StyledListSectionHeader = styled.li(({ theme }) => ({ +export const StyledListSectionHeader = styled.div(({ theme }) => ({ paddingLeft: 8, fontWeight: "bold", lineHeight: "20px", diff --git a/packages/jui/src/List/listItemRenderer.tsx b/packages/jui/src/List/listItemRenderer.tsx deleted file mode 100644 index 288db9af..00000000 --- a/packages/jui/src/List/listItemRenderer.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Node } from "@react-types/shared"; -import React from "react"; -import { StyledListSectionHeader } from "./StyledListSectionHeader"; -import { ListDivider } from "./ListDivider"; - -interface SectionNode extends Node { - type: "section"; -} - -interface ItemNode extends Node { - type: "item"; -} - -interface DividerNode extends Node { - type: "divider"; -} - -const isItemNode = (node: Node): node is ItemNode => - node.type === "item"; -const isSectionNode = (node: Node): node is SectionNode => - node.type === "section"; -const isDividerNode = (node: Node): node is DividerNode => - node.type === "divider"; - -type listItemRendererArgs = { - item: (item: ItemNode) => React.ReactNode; - sectionHeader?: (item: SectionNode) => React.ReactNode; -}; -export const listItemRenderer = ({ - item: renderItem, - sectionHeader: renderSectionHeader = (item) => ( - {item.rendered} - ), -}: listItemRendererArgs) => { - return render; - - function render(item: Node): React.ReactNode { - if (isItemNode(item)) { - return renderItem(item); - } - if (isSectionNode(item)) { - return ( - - {renderSectionHeader(item)} - {[...(item.childNodes as ItemNode[])].map(render)} - - ); - } - if (isDividerNode(item)) { - return ; - } - return null; - } -}; diff --git a/packages/jui/src/List/renderWrapper.tsx b/packages/jui/src/List/renderWrapper.tsx new file mode 100644 index 00000000..f0abd6a8 --- /dev/null +++ b/packages/jui/src/List/renderWrapper.tsx @@ -0,0 +1,69 @@ +import { ReusableView } from "@react-stately/virtualizer"; +import { Node } from "@react-types/shared"; +import React, { ReactNode, useRef } from "react"; +import { + layoutInfoToStyle, + useVirtualizerItem, + VirtualizerItem, + VirtualizerProps, +} from "@react-aria/virtualizer"; +import { StyledListSectionHeader } from "@intellij-platform/core/List/StyledListSectionHeader"; + +interface SectionProps { + reusableView: ReusableView, unknown>; + header: ReusableView, unknown>; + children?: ReactNode; +} + +function ListSection({ + reusableView, + header, + children, +}: SectionProps) { + const headerRef = useRef(null); + useVirtualizerItem({ + reusableView: header, + ref: headerRef, + }); + return ( + <> + + {reusableView.content.rendered} + +
+ {children} +
+ + ); +} + +export const renderWrapper: VirtualizerProps< + Node, + unknown +>["renderWrapper"] = (parent, reusableView, children, renderChildren) => { + if (reusableView.viewType === "section") { + return ( + c.viewType === "header")!} + > + {renderChildren(children.filter((c) => c.viewType === "item"))} + + ); + } + return ( + + ); +}; diff --git a/packages/jui/src/List/story-helpers.tsx b/packages/jui/src/List/story-helpers.tsx index ebff73d9..17180256 100644 --- a/packages/jui/src/List/story-helpers.tsx +++ b/packages/jui/src/List/story-helpers.tsx @@ -56,7 +56,7 @@ export const commonListStories = { withConnectedInput: (ListCmp: typeof List) => { const WithConnectedInput: Story> = (props) => { const [isFocused, setIsFocused] = React.useState(false); - const listRef = React.useRef(null); + const listRef = React.useRef(null); const selectionManagerRef = React.useRef(null); const { collectionSearchInputProps } = useCollectionSearchInput({ collectionRef: listRef, diff --git a/packages/jui/src/List/useList.ts b/packages/jui/src/List/useList.ts index cc16feb9..fdadc60a 100644 --- a/packages/jui/src/List/useList.ts +++ b/packages/jui/src/List/useList.ts @@ -1,10 +1,12 @@ import { AriaSelectableListOptions } from "@react-aria/selection"; import { ListState } from "@react-stately/list"; -import React, { useEffect, useState } from "react"; +import React, { Key, useEffect, useMemo, useState } from "react"; import { useSelectableList } from "./useSelectableList"; import { useFocusWithin } from "@react-aria/interactions"; import { mergeProps } from "@react-aria/utils"; import { CollectionRefProps } from "@intellij-platform/core/Collections/useCollectionRef"; +import { useEventCallback } from "@intellij-platform/core/utils/useEventCallback"; +import { ListContextType } from "@intellij-platform/core/List/ListContext"; export interface ListProps extends Omit< @@ -18,12 +20,18 @@ export interface ListProps >, CollectionRefProps { allowEmptySelection?: boolean; + /** + * Called when the action for the item should be triggered, which can be by double click or pressing Enter. + * Enter not implemented yet :D + */ + onAction?: (key: Key) => void; + showAsFocused?: boolean; id?: string; } // import { useSelectableList } from "@react-aria/selection"; export function useList( - props: ListProps, + { onAction, showAsFocused, ...props }: ListProps, state: ListState, ref: React.RefObject ) { @@ -58,8 +66,19 @@ export function useList( } }, [!props.allowEmptySelection]); + const onActionCallback = useEventCallback(onAction ?? (() => {})); + const listContext: ListContextType = useMemo( + () => ({ + state, + focused: Boolean(focused || showAsFocused), + onAction: onActionCallback, + }), + [state, focused, showAsFocused] + ); + return { listProps: mergeProps(listProps, focusWithinProps), + listContext, focused, }; } diff --git a/packages/jui/src/List/useListVirtualizer.tsx b/packages/jui/src/List/useListVirtualizer.tsx new file mode 100644 index 00000000..485840e2 --- /dev/null +++ b/packages/jui/src/List/useListVirtualizer.tsx @@ -0,0 +1,74 @@ +import React, { HTMLAttributes, useMemo } from "react"; +import { VirtualizerProps } from "@react-aria/virtualizer"; +import { VariableWidthListLayout } from "@intellij-platform/core/VariableWidthListLayout"; +import { ListState } from "@react-stately/list"; +import { Node } from "@react-types/shared"; +import { renderWrapper } from "./renderWrapper"; +import { ListDivider } from "@intellij-platform/core/List/ListDivider"; + +interface ItemNode extends Node { + type: "item"; +} +export const useListVirtualizer = ({ + renderItem, + estimatedItemHeight = 20, + state, +}: { + renderItem: (item: ItemNode) => React.ReactNode; + estimatedItemHeight?: number; + state: ListState; +}): { + virtualizerProps: Omit< + VirtualizerProps, unknown>, + keyof HTMLAttributes + > & { children: (type: string, content: Node) => React.ReactNode }; +} => { + const layout = useMemo( + () => + new VariableWidthListLayout({ + /** + * there is currently no documentation available for these fields, but setting `rowHeight` enforces it, which + * wouldn't be good for custom tree UI with a different row height. + * wrong estimatedRowHeight seems to only affect small scrollbar position inaccuracy, which is a minor issue + * and not even noticeable in most cases. Also, it seems it slightly improves the performance if it exactly + * matches the real row height. But not even sure. + */ + estimatedRowHeight: estimatedItemHeight, + estimatedHeadingHeight: 20, + dividerHeight: 2, + }), + [] + ); + layout.collection = state.collection; + layout.disabledKeys = state.disabledKeys; + + return { + virtualizerProps: { + focusedKey: state.selectionManager.focusedKey, + collection: state.collection, + layout, + // Not clear how this sizeToFit is supposed to work, due to lack of documentation, but don't be tempted to + // think it solves the problem VariableWidthListLayout is trying to solve, because it doesn't :D + // Also, note that setting this to "width" prevents re-layout when container width is increased, which + // causes issues. + sizeToFit: "height", + scrollToItem: (key) => { + return layout.virtualizer.scrollToItem(key, { + shouldScrollX: false, + duration: 0, + }); + }, + children: (type, item) => { + if (type === "item") { + return renderItem(item as ItemNode); + } + if (type === "divider") { + return ; + } + }, + renderWrapper, + + scrollDirection: "both", + }, + }; +}; diff --git a/packages/jui/src/Menu/SpeedSearchMenu.tsx b/packages/jui/src/Menu/SpeedSearchMenu.tsx index 5f4c88bc..5ef552b5 100644 --- a/packages/jui/src/Menu/SpeedSearchMenu.tsx +++ b/packages/jui/src/Menu/SpeedSearchMenu.tsx @@ -109,7 +109,7 @@ function useSpeedSearchMenu( state.disabledKeys, ref ), - stickySearch: true, + keepSearchActiveOnBlur: true, focusBestMatch: true, ref, }); diff --git a/packages/jui/src/Popup/Popup.cy.tsx b/packages/jui/src/Popup/Popup.cy.tsx index 21e10a40..bbb312fe 100644 --- a/packages/jui/src/Popup/Popup.cy.tsx +++ b/packages/jui/src/Popup/Popup.cy.tsx @@ -4,8 +4,14 @@ import { composeStories } from "@storybook/react"; import * as stories from "./Popup.stories"; import { Popup } from "./Popup"; -const { Default, DefaultPosition, DefaultSize, MenuContent, ListContent } = - composeStories(stories); +const { + Default, + DefaultPosition, + DefaultSize, + MenuContent, + ListContent, + TreeContent, +} = composeStories(stories); describe("Popup", () => { describe("sizing and positioning", () => { @@ -14,12 +20,17 @@ describe("Popup", () => { matchImageSnapshot("Popup-default-bounds--simple"); cy.mount(); matchImageSnapshot("Popup-default-bounds--menu"); - cy.mount(); - matchImageSnapshot("Popup-default-bounds--list"); - // Tree uses virtualization, and sizing is flaky at the moment. FIXME - // cy.mount(); - // matchImageSnapshot("Popup-default-bounds--tree"); + // FIXME(https://github.com/alirezamirian/jui/issues/56): uncomment when the issue is solved + // cy.mount(); + // cy.findByRole("dialog").invoke("width").should("be.greaterThan", 100); + // + // matchImageSnapshot("Popup-default-bounds--list"); + // + // cy.mount(); + // cy.findByRole("dialog").invoke("width").should("be.greaterThan", 150); + // + // matchImageSnapshot("Popup-default-bounds--tree"); }); it("takes default position into account, while setting size based on content", () => { diff --git a/packages/jui/src/SpeedSearch/SpeedSearch.cy.tsx b/packages/jui/src/SpeedSearch/SpeedSearch.cy.tsx index a298eaf2..58c45f57 100644 --- a/packages/jui/src/SpeedSearch/SpeedSearch.cy.tsx +++ b/packages/jui/src/SpeedSearch/SpeedSearch.cy.tsx @@ -21,7 +21,7 @@ describe("SpeedSearch", () => { cy.mount( ); diff --git a/packages/jui/src/SpeedSearch/SpeedSearch.stories.tsx b/packages/jui/src/SpeedSearch/SpeedSearch.stories.tsx index 3536f36c..f915d8b2 100644 --- a/packages/jui/src/SpeedSearch/SpeedSearch.stories.tsx +++ b/packages/jui/src/SpeedSearch/SpeedSearch.stories.tsx @@ -20,13 +20,13 @@ const SpeedSearchContainer = styled(SpeedSearch)` export const Default: StoryObj = { render: ({ onSearchTermChange, - onActiveChange, + onIsSearchActiveChange, }: { onSearchTermChange?: (searchTerm: string) => void; - onActiveChange?: (active: boolean) => void; + onIsSearchActiveChange?: (active: boolean) => void; }) => { const [searchTerm, setSearchTerm] = useState(""); - const [active, setActive] = useState(false); + const [isActive, setIsActive] = useState(false); return ( = { setSearchTerm(searchTerm); onSearchTermChange?.(searchTerm); }} - active={active} - onActiveChange={(active) => { - setActive(active); - onActiveChange?.(active); + isSearchActive={isActive} + onIsSearchActiveChange={(active) => { + setIsActive(active); + onIsSearchActiveChange?.(active); }} match // search is done within the searchable text component in this dummy example, and we don't have information about match. - stickySearch + keepSearchActiveOnBlur >
  • diff --git a/packages/jui/src/SpeedSearch/SpeedSearch.tsx b/packages/jui/src/SpeedSearch/SpeedSearch.tsx index f1137705..dfe79c9e 100644 --- a/packages/jui/src/SpeedSearch/SpeedSearch.tsx +++ b/packages/jui/src/SpeedSearch/SpeedSearch.tsx @@ -10,7 +10,7 @@ import { interface Props extends SpeedSearchStateProps { children: React.ReactNode; - stickySearch?: boolean; + keepSearchActiveOnBlur?: boolean; match?: boolean; className?: string; containerProps?: Omit, "as" | "ref">; @@ -21,7 +21,7 @@ interface Props extends SpeedSearchStateProps { export const SpeedSearch = React.forwardRef(function SpeedSearch( { children, - stickySearch = false, + keepSearchActiveOnBlur = false, className, containerProps = {}, match, @@ -32,7 +32,7 @@ export const SpeedSearch = React.forwardRef(function SpeedSearch( const ref = useObjectRef(forwardedRef); const speedSearchState = useSpeedSearchState(otherProps); const { containerProps: speedSearchContainerProps } = useSpeedSearch( - { stickySearch }, + { keepSearchActiveOnBlur: keepSearchActiveOnBlur }, speedSearchState, ref ); diff --git a/packages/jui/src/SpeedSearch/useSpeedSearch.tsx b/packages/jui/src/SpeedSearch/useSpeedSearch.tsx index e8020cb3..c0f34c50 100644 --- a/packages/jui/src/SpeedSearch/useSpeedSearch.tsx +++ b/packages/jui/src/SpeedSearch/useSpeedSearch.tsx @@ -2,12 +2,12 @@ import { useGhostInput } from "./useGhostInput"; import { useFocusWithin, useKeyboard } from "@react-aria/interactions"; import { useControlledState } from "@react-stately/utils"; import { ControlledStateProps } from "../type-utils"; -import { RefObject } from "react"; +import React, { RefObject } from "react"; export interface SpeedSearchState { /** * Whether speed search is active. Speed search becomes active when the user starts to type and becomes inactive - * when Escape is pressed, or when the speed search container is blurred and `stickySearch` is false. + * when Escape is pressed, or when the speed search container is blurred and `keepSearchActiveOnBlur` is false. * Whenever speed search becomes inactive, search text is also cleared. * Note that speed search can be active while search term is empty. */ @@ -24,16 +24,16 @@ export interface SpeedSearchState { export interface SpeedSearchStateProps extends ControlledStateProps<{ searchTerm: string; - active: boolean; + isSearchActive: boolean; }> {} export function useSpeedSearchState( props: SpeedSearchStateProps ): SpeedSearchState { const [active, setActive] = useControlledState( - props.active!, - props.active || false, - props.onActiveChange! + props.isSearchActive!, + props.defaultIsSearchActive ?? false, + props.onIsSearchActiveChange! ); const [searchTerm, setSearchTerm] = useControlledState( props.searchTerm!, @@ -54,7 +54,7 @@ export function useSpeedSearchState( } export interface SpeedSearchProps { - stickySearch?: boolean; + keepSearchActiveOnBlur?: boolean | ((e: React.FocusEvent) => boolean); } /** @@ -66,7 +66,7 @@ export interface SpeedSearchProps { * it is NOT this hook's responsibility anymore. */ export function useSpeedSearch( - { stickySearch }: SpeedSearchProps, + { keepSearchActiveOnBlur }: SpeedSearchProps, { searchTerm, active, setActive, setSearchTerm }: SpeedSearchState, ref: RefObject ) { @@ -111,8 +111,12 @@ export function useSpeedSearch( const { focusWithinProps: { onFocus, onBlur }, } = useFocusWithin({ - onFocusWithinChange: (focused) => { - if (!focused && !stickySearch) { + onBlurWithin: (event) => { + if ( + !(typeof keepSearchActiveOnBlur === "function" + ? keepSearchActiveOnBlur(event) + : keepSearchActiveOnBlur) + ) { clear(); } }, diff --git a/packages/jui/src/Theme/Theme.ts b/packages/jui/src/Theme/Theme.ts index d014e4b2..932eb105 100644 --- a/packages/jui/src/Theme/Theme.ts +++ b/packages/jui/src/Theme/Theme.ts @@ -19,6 +19,7 @@ export type UnknownThemeProp = T extends KnownThemePropertyPath enum CssProperties { CURRENT_FOREGROUND = "--jui-foreground", + CURRENT_BACKGROUND = "--jui-background", } const defaultValues: { [key in KnownThemePropertyPath]?: string } = { @@ -125,7 +126,7 @@ export class Theme

    { /** * fallback that will take precedence over *-based fallback mechanism. */ - fallback?: T + fallback?: T | { light: T; dark: T } ): undefined extends T ? string | undefined : string { // There is a fallback mechanism that uses *.prop key if some key that ends with .prop // doesn't exist in the theme. In Intellij Platform implementation, all such fallback keys @@ -151,7 +152,9 @@ export class Theme

    { // At least for now, one should use theme.color like this, if *-based fallback is to be prioritized: // theme.color('x.y') ?? 'fallback' // then priority will be: 'x.y' -> '*.y' -> 'fallback' - (fallback as any) || + (fallback && typeof fallback === "object" + ? (fallback[this.dark ? "dark" : "light"] as any) + : fallback) || dereference(this.getFallbackFromStar(path) as string) ); } @@ -189,6 +192,39 @@ export class Theme

    { `${colorValue}; ${this.CssProperties.CURRENT_FOREGROUND}: currentColor` ); } + /** + * Returns the input color, concatenated with a css rule, setting the "current background" color as a css property. + * The returned value is a drop-in replacement for the input value, in terms of usage as css property value. + * @example + * ```ts + * const MyComponent = styled.div` + * color: ${ ({theme}) => theme.commonColors.red}; + * `; + * ``` + * results: + * ```css + * color: rgb(255,100,100); + * ``` + * + * While + * ```ts + * const MyComponent = styled.div` + * color: ${ ({theme}) => theme.asCurrentBackground(theme.commonColors.red)} + * `; + * ``` + * results: + * ```css + * color: rgb(255,100,100); + * --jui-background: currentColor; + * ``` + * @see currentBackgroundAware + */ + asCurrentBackground(colorValue: string | undefined) { + return ( + colorValue && + `${colorValue}; ${this.CssProperties.CURRENT_BACKGROUND}: ${colorValue}` + ); + } /** * Given a color, returns a css var(...) expression which resolves to that color, only if the element is not used @@ -205,6 +241,21 @@ export class Theme

    { ); } + /** + * Given a color, returns a css var(...) expression which resolves to that color, only if the element is not used + * where {@link CssProperties.CURRENT_BACKGROUND} is set. For example, items in tree/menu/list/etc., set + * {@link CssProperties.CURRENT_BACKGROUND} when in active state. That's because they have a contrasted background + * (blue by default), in such states, and the corresponding background should take precedence over other backgrounds, + * to ensure enough contrast required for accessibility. + * @param colorValue + */ + currentBackgroundAware(colorValue: string | undefined) { + return ( + colorValue && + `var(${this.CssProperties.CURRENT_BACKGROUND}, ${colorValue})` + ); + } + /** * Resolves platform icon path to svg. * by default, it fetches the svg icon from Github, but there are other Theme implementations diff --git a/packages/jui/src/ThreeViewSplitter/ThreeViewSplitter.tsx b/packages/jui/src/ThreeViewSplitter/ThreeViewSplitter.tsx index 4c511b2d..4a89d565 100644 --- a/packages/jui/src/ThreeViewSplitter/ThreeViewSplitter.tsx +++ b/packages/jui/src/ThreeViewSplitter/ThreeViewSplitter.tsx @@ -42,14 +42,18 @@ const StyledSplitterContainer = styled.div<{ orientation === "vertical" ? "column" : "row"}; `; -const StyledSplitterInnerView = styled.div` +const StyledView = styled.div` // The default overflow visible should be changed obviously. Not sure if there is any layout implication // in using 'auto' instead of hidden, to provide scroll behaviour by default, but even if we realize later // that we need to set overflow to hidden here, we can have scrollable content inside the inner view via an // intermediate element inside the inner view, with overflow set to auto and width set to 100%. overflow: auto; +`; + +const StyledSplitterInnerView = styled(StyledView)` flex: 1; `; + /** * Corresponding to * [ThreeComponentsSplitter](https://github.com/JetBrains/intellij-community/blob/58dbd93e9ea527987466072fa0bfbf70864cd35f/platform/platform-api/src/com/intellij/openapi/ui/ThreeComponentsSplitter.java#L40-L40) @@ -164,7 +168,7 @@ export const ThreeViewSplitter: React.FC = ({ > {firstView && ( <> -

    = ({ }} > {firstView} -
    + { const size = firstViewRef.current @@ -233,7 +237,7 @@ export const ThreeViewSplitter: React.FC = ({ }} {...resizerProps} /> -
    = ({ }} > {lastView} -
    + )} diff --git a/packages/jui/src/Toolbar/Toolbar.tsx b/packages/jui/src/Toolbar/Toolbar.tsx index a893354b..97ee6813 100644 --- a/packages/jui/src/Toolbar/Toolbar.tsx +++ b/packages/jui/src/Toolbar/Toolbar.tsx @@ -129,13 +129,13 @@ const StyledVerticalToolbar = styled(StyledToolbar)` `; const StyledToolbarContent = styled.div<{ - wrap?: boolean; + shouldWrap?: boolean; firstOverflowedIndex: number; }>` box-sizing: inherit; display: inherit; flex-direction: inherit; - flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "nowrap")}; + flex-wrap: ${({ shouldWrap }) => (shouldWrap ? "wrap" : "nowrap")}; gap: inherit; max-height: inherit; max-width: inherit; @@ -309,7 +309,7 @@ export const Toolbar: React.FC = ({ ref={ref} role="presentation" firstOverflowedIndex={firstOverflowedChildIndex} - wrap={overflowBehavior === "wrap"} + shouldWrap={overflowBehavior === "wrap"} > {props.children} diff --git a/packages/jui/src/Tooltip/ActionHelpTooltip.tsx b/packages/jui/src/Tooltip/ActionHelpTooltip.tsx index 285af3ac..6d5f225b 100644 --- a/packages/jui/src/Tooltip/ActionHelpTooltip.tsx +++ b/packages/jui/src/Tooltip/ActionHelpTooltip.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Tooltip } from "@intellij-platform/core/Tooltip/Tooltip"; +import { Tooltip, TooltipProps } from "@intellij-platform/core/Tooltip/Tooltip"; -export interface ActionHelpTooltipProps { +export interface ActionHelpTooltipProps + extends Omit { actionName: React.ReactNode; helpText: React.ReactNode; shortcut?: React.ReactNode; @@ -18,9 +19,10 @@ export const ActionHelpTooltip = ({ helpText, shortcut, link, + ...tooltipProps }: ActionHelpTooltipProps): JSX.Element => { return ( - + {actionName} {shortcut && {shortcut}} diff --git a/packages/jui/src/Tooltip/ActionTooltip.tsx b/packages/jui/src/Tooltip/ActionTooltip.tsx index 8300dafe..fee4ce72 100644 --- a/packages/jui/src/Tooltip/ActionTooltip.tsx +++ b/packages/jui/src/Tooltip/ActionTooltip.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Tooltip } from "@intellij-platform/core/Tooltip/Tooltip"; +import { Tooltip, TooltipProps } from "@intellij-platform/core/Tooltip/Tooltip"; -export interface ActionTooltipProps { +export interface ActionTooltipProps + extends Omit { actionName: React.ReactNode; shortcut?: React.ReactNode; } @@ -14,9 +15,10 @@ export interface ActionTooltipProps { export const ActionTooltip = ({ actionName, shortcut, + ...tooltipProps }: ActionTooltipProps): JSX.Element => { return ( - + {actionName} {shortcut && {shortcut}} diff --git a/packages/jui/src/Tooltip/HelpTooltip.stories.tsx b/packages/jui/src/Tooltip/HelpTooltip.stories.tsx index 24b86b37..fd719afa 100644 --- a/packages/jui/src/Tooltip/HelpTooltip.stories.tsx +++ b/packages/jui/src/Tooltip/HelpTooltip.stories.tsx @@ -1,6 +1,11 @@ import React from "react"; import { Meta, StoryObj } from "@storybook/react"; -import { HelpTooltip, HelpTooltipProps, Link } from "@intellij-platform/core"; +import { + HelpTooltip, + HelpTooltipProps, + Link, + TooltipPointerPosition, +} from "@intellij-platform/core"; export default { title: "Components/Tooltip/Help", @@ -32,3 +37,40 @@ export const WithShortcutAndLink: StoryObj = { link: Example, }, }; + +export const WithPointer: StoryObj< + HelpTooltipProps & { + pointerSide?: TooltipPointerPosition["side"]; + pointerOffset?: Exclude; + invertOffset?: boolean; + } +> = { + render: ({ pointerSide, pointerOffset, invertOffset, ...props }) => ( + + ), + args: { + pointerSide: undefined, + pointerOffset: undefined, + invertOffset: false, + }, + argTypes: { + pointerSide: { + type: { + name: "enum", + value: [undefined as any, "top", "bottom", "left", "right"], + }, + }, + }, +}; diff --git a/packages/jui/src/Tooltip/HelpTooltip.tsx b/packages/jui/src/Tooltip/HelpTooltip.tsx index c6aa3123..6434f627 100644 --- a/packages/jui/src/Tooltip/HelpTooltip.tsx +++ b/packages/jui/src/Tooltip/HelpTooltip.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Tooltip } from "@intellij-platform/core/Tooltip/Tooltip"; +import { Tooltip, TooltipProps } from "@intellij-platform/core/Tooltip/Tooltip"; -export interface HelpTooltipProps { +export interface HelpTooltipProps + extends Omit { helpText: React.ReactNode; shortcut?: React.ReactNode; link?: React.ReactNode; @@ -16,9 +17,10 @@ export const HelpTooltip = ({ helpText, shortcut, link, + ...tooltipProps }: HelpTooltipProps): JSX.Element => { return ( - + {helpText}
    {shortcut} diff --git a/packages/jui/src/Tooltip/PositionedTooltipTrigger.tsx b/packages/jui/src/Tooltip/PositionedTooltipTrigger.tsx index e1d73ec8..963c1ade 100644 --- a/packages/jui/src/Tooltip/PositionedTooltipTrigger.tsx +++ b/packages/jui/src/Tooltip/PositionedTooltipTrigger.tsx @@ -7,10 +7,10 @@ import React, { } from "react"; import { TooltipTriggerProps as AriaTooltipTriggerProps } from "@react-aria/tooltip"; import { useTooltipTriggerState } from "@react-stately/tooltip"; -import { TooltipTriggerAndOverlay } from "@intellij-platform/core/Tooltip/TooltipTriggerAndOverlay"; import { AriaPositionProps, useOverlayPosition } from "@react-aria/overlays"; +import { TooltipTriggerAndOverlay } from "./TooltipTriggerAndOverlay"; -interface TooltipTriggerProps +export interface PositionedTooltipTriggerProps extends Omit, Pick< AriaPositionProps, @@ -51,7 +51,7 @@ export const PositionedTooltipTrigger = ({ offset = 8, showOnFocus, ...props -}: TooltipTriggerProps): JSX.Element => { +}: PositionedTooltipTriggerProps): JSX.Element => { const state = useTooltipTriggerState({ ...props, delay, @@ -60,7 +60,7 @@ export const PositionedTooltipTrigger = ({ const overlayRef = useRef(null); const triggerRef = useRef(null); - const { overlayProps, updatePosition } = useOverlayPosition({ + const positionAria = useOverlayPosition({ ...props, overlayRef, targetRef: triggerRef, @@ -74,14 +74,14 @@ export const PositionedTooltipTrigger = ({ // FIXME: Find the explanation for why it happens, and fix it properly, if it's a legit issue. useEffect(() => { if (state.isOpen) { - requestAnimationFrame(updatePosition); + requestAnimationFrame(positionAria.updatePosition); } }, [state.isOpen]); return ( { cy.get("input").click(); cy.findByRole("tooltip"); // tooltip should not be closed }); + + it("renders the arrow in the right position", () => { + cy.mount( +
    + +
    + ); + workaroundHoverIssue(); + cy.get("button").first().realHover({ position: "bottom" }); + cy.findByRole("tooltip"); + matchImageSnapshot("TooltipTrigger-WithPointer"); // the percy snapshot is not accurate :/ + }); }); describe("PositionedTooltipTrigger", () => { it("shows/hides the tooltip on focus/blur if showOnFocus is true", () => { cy.mount( - tooltip} - showOnFocus - delay={0} - > - - + <> + + tooltip} + showOnFocus + delay={0} + > + + + ); - cy.get("input").first().focus(); + // Note: just focusing the input via .focus() didn't consistently work for some reason. + cy.get("button").focus().realPress("Tab"); cy.findByRole("tooltip").should("exist"); cy.get("input").first().blur(); cy.findByRole("tooltip").should("not.exist"); @@ -90,21 +106,24 @@ describe("PositionedTooltipTrigger", () => { const Example = () => { const [isDisabled, setIsDisabled] = useState(false); return ( - Error message} - showOnFocus - isDisabled={isDisabled} - > - { - setIsDisabled(true); - }} - /> - + <> + + Error message} + showOnFocus + isDisabled={isDisabled} + > + { + setIsDisabled(true); + }} + /> + + ); }; cy.mount(); + cy.get("button").focus().realPress("Tab"); cy.findByRole("tooltip").should("exist"); cy.realType("a"); cy.findByRole("tooltip").should("not.exist"); @@ -162,6 +181,101 @@ describe("Tooltip", () => { ); matchImageSnapshot("AllTooltips"); }); + + it("renders the arrow in the right position", () => { + cy.viewport(350, 1200); + cy.mount( + <> +
    + +
    + {(["right", "left", "top", "bottom"] as const).map((side) => ( +
    + + Pointer on {side}.
    offset unset + + } + /> +
    + ))} +
    + + Too small horizontal offset edge case. The arrow should not go + outside the tooltip boundary + + } + /> +
    +
    + + Too large horizontal offset edge case. The arrow should not go + outside the tooltip boundary + + } + /> +
    +
    + + Too small vertical offset edge case. The arrow should not go + outside the tooltip boundary + + } + /> +
    +
    + + Too large vertical offset edge case. The arrow should not go + outside the tooltip boundary + + } + /> +
    +
    + Percentage offset} + /> +
    +
    + Inverted offset} + /> +
    +
    + Inverted percentage offset} + /> +
    + + ); + matchImageSnapshot("Tooltip-WithPointer"); + }); }); function matchImageSnapshot(snapshotsName: string) { diff --git a/packages/jui/src/Tooltip/Tooltip.tsx b/packages/jui/src/Tooltip/Tooltip.tsx index 15b151ba..522c461c 100644 --- a/packages/jui/src/Tooltip/Tooltip.tsx +++ b/packages/jui/src/Tooltip/Tooltip.tsx @@ -1,25 +1,56 @@ -import React, { ForwardedRef, useContext } from "react"; +import React, { ForwardedRef, MutableRefObject, useContext } from "react"; import { AriaTooltipProps, useTooltip } from "@react-aria/tooltip"; import { useObjectRef } from "@react-aria/utils"; -import { styled } from "@intellij-platform/core/styled"; +import { PositionAria } from "@react-aria/overlays"; +import { css, styled } from "@intellij-platform/core/styled"; import { UnknownThemeProp } from "@intellij-platform/core/Theme"; import { WINDOW_SHADOW } from "@intellij-platform/core/style-constants"; import { TooltipContext } from "./TooltipContext"; +import { TooltipPointer, TooltipPointerPosition } from "./TooltipPointer"; +import { + tooltipBackground, + tooltipBorderColor, + WITH_POINTER_BORDER_RADIUS, +} from "./tooltip-styles"; export interface TooltipProps extends Omit { children: React.ReactNode; multiline?: boolean; className?: string; + /** + * Whether (and in what position) the arrow pointer should be shown. + * When using {@link TooltipTrigger} or {@link PositionedTooltipTrigger}, the position of the pointer is calculated + * based on the target element, and a boolean value to define whether the arrow should be shown or not would suffice. + * + * Tooltips with pointer have slight style difference. + * {@see https://www.figma.com/file/nfDfMAdV7j2fnQlpYNAOfP/IntelliJ-Platform-UI-Kit-(Community)?type=design&node-id=15-51&mode=design&t=7PplrxG8ZfXB4hIK-0} + * + * @example + * + * // shows the pointer in the position controlled by {@link TooltipTrigger} or {@link PositionedTooltipTrigger} + * // If there is not `TooltipTrigger` or `PositionedTooltipTrigger`, the arrow is shown on top center by default. + * + * @example + * + * // shows the pointer on the top side, with horizontal offset of 30px from the left of tooltip, regardless + * // of whether `TooltipTrigger` or `PositionedTooltipTrigger` is used. + * + * @example + * + * // shows the pointer on the left side, with vertidcal offset of 30px from the bottom of the tooltip, regardless + * // of whether `TooltipTrigger` or `PositionedTooltipTrigger` is used. + */ + withPointer?: boolean | TooltipPointerPosition; } // Providing default value for paddings, based on intellijlaf theme. In Intellij Platform, themes extend either // intellijlaf or darcula. Which means some properties can be omitted in the custom theme, relying on the values // in the base theme. This is not how theming works here, at the moment, and there are other similar issues, but // this is just a mitigation for one case, spacing in tooltip. -const DEFAULT_TEXT_BORDER_INSETS = "0.5rem 0.8125rem 0.625rem 0.625rem"; -const DEFAULT_SMALL_TEXT_BORDER_INSETS = "0.375rem 0.75rem 0.4375rem 0.625rem"; - -const StyledTooltip = styled.div<{ multiline?: boolean }>` +export const DEFAULT_TEXT_BORDER_INSETS = "0.5rem 0.8125rem 0.625rem 0.625rem"; +export const DEFAULT_SMALL_TEXT_BORDER_INSETS = + "0.375rem 0.75rem 0.4375rem 0.625rem"; +const StyledTooltip = styled.div<{ multiline?: boolean; hasPointer?: boolean }>` box-sizing: content-box; max-width: ${ /** @@ -46,8 +77,7 @@ const StyledTooltip = styled.div<{ multiline?: boolean }>` theme.value( "HelpToolTip.verticalGap" as UnknownThemeProp<"HelpToolTip.verticalGap"> ) ?? 4}px; - background: ${({ theme }) => - theme.color("ToolTip.background", !theme.dark ? "#f2f2f2" : "#3c3f41")}; + background: ${tooltipBackground}; color: ${({ theme }) => theme.color("ToolTip.foreground", !theme.dark ? "#000" : "#bfbfbf")}; padding: ${({ theme, multiline }) => @@ -58,11 +88,16 @@ const StyledTooltip = styled.div<{ multiline?: boolean }>` DEFAULT_SMALL_TEXT_BORDER_INSETS}; line-height: 1.2; border-style: solid; - border-width: ${({ theme }) => - theme.value("ToolTip.paintBorder") ? "1px" : "0px"}; - border-color: ${({ theme }) => - theme.color("ToolTip.borderColor", !theme.dark ? "#adadad" : "#636569")}; + border-width: ${({ theme, hasPointer }) => + theme.value("ToolTip.paintBorder") || hasPointer ? "1px" : "0px"}; + border-color: ${tooltipBorderColor}; ${WINDOW_SHADOW}; + ${({ hasPointer }) => + hasPointer && + css` + position: relative; // needed for absolute positioning of the pointer + border-radius: ${WITH_POINTER_BORDER_RADIUS}px; + `} `; const StyledShortcut = styled.kbd` @@ -102,6 +137,16 @@ const StyledLink = styled.div` } `; +export const placementToPointerSide: Record< + PositionAria["placement"], + TooltipPointerPosition["side"] +> = { + bottom: "top", + top: "bottom", + left: "right", + right: "left", + center: "top", // doesn't make sense :-? +}; /** * Implements the UI of a Tooltip. For tooltip to be shown for a trigger, on hover, use {@link TooltipTrigger}. * The following components can be used to compose the content of a tooltip. @@ -119,11 +164,17 @@ const StyledLink = styled.div` * in the original impl. */ const Tooltip = React.forwardRef(function Tooltip( - { children, multiline, ...props }: TooltipProps, + { children, multiline, withPointer, ...props }: TooltipProps, forwardedRef: ForwardedRef ): JSX.Element { - const ref = useObjectRef(forwardedRef); - const { state, isInteractive } = useContext(TooltipContext) || {}; + const ref: MutableRefObject = + useObjectRef(forwardedRef); + const { + state, + isInteractive, + pointerPositionStyle, + placement = "bottom", + } = useContext(TooltipContext) || {}; const { tooltipProps } = useTooltip( props, state @@ -135,13 +186,30 @@ const Tooltip = React.forwardRef(function Tooltip( : state ); + const { side, offset } = + typeof withPointer === "object" + ? withPointer + : { side: placementToPointerSide[placement], offset: undefined }; + return ( + {withPointer && ( + + )} {children} ); diff --git a/packages/jui/src/Tooltip/TooltipContext.tsx b/packages/jui/src/Tooltip/TooltipContext.tsx index 264bc24b..83708844 100644 --- a/packages/jui/src/Tooltip/TooltipContext.tsx +++ b/packages/jui/src/Tooltip/TooltipContext.tsx @@ -1,9 +1,12 @@ -import React from "react"; +import React, { CSSProperties } from "react"; import { TooltipTriggerState } from "@react-stately/tooltip"; +import { PositionAria } from "@react-aria/overlays"; interface TooltipContextObject { state: TooltipTriggerState; isInteractive: boolean; + placement: PositionAria["placement"]; + pointerPositionStyle?: CSSProperties; } export const TooltipContext = React.createContext( diff --git a/packages/jui/src/Tooltip/TooltipPointer.tsx b/packages/jui/src/Tooltip/TooltipPointer.tsx new file mode 100644 index 00000000..bf38a06b --- /dev/null +++ b/packages/jui/src/Tooltip/TooltipPointer.tsx @@ -0,0 +1,165 @@ +import { compose, identity } from "ramda"; +import React, { CSSProperties, RefObject, useEffect, useState } from "react"; +import { css, styled } from "@intellij-platform/core/styled"; + +import { + tooltipBackground, + WITH_POINTER_BORDER_RADIUS, +} from "./tooltip-styles"; + +type OffsetValue = number | `${number}%`; +export type TooltipPointerPosition = { + /** + * The side of the tooltip the pointer is shown + */ + side: "top" | "bottom" | "left" | "right"; + /** + * - When side is "top" or "bottom": + * Horizontal offset (in px) with respect to the left (or right, if negative) of the tooltip. + * - When side is "left" or "right": + * Vertical offset (in px) with respect to the top (or bottom, if negative) of the tooltip. + * + * @default: '50%' + */ + offset?: OffsetValue | { value: OffsetValue; invert?: boolean }; +}; + +const POINTER_WIDTH = 6; +const POINTER_HEIGHT = 9; +const POINTER_THICKNESS = 1.5; +const TRANSLATE = `translate(-${POINTER_WIDTH}px, -${POINTER_HEIGHT}px)`; +const sideStyles = { + top: css` + top: 0; + transform: ${TRANSLATE}; + `, + bottom: css` + bottom: 0; + transform: rotateX(180deg) ${TRANSLATE}; + `, + left: css` + left: 0; + transform: rotate(-90deg) ${TRANSLATE}; + `, + right: css` + right: 0; + transform: rotate(90deg) ${TRANSLATE}; + `, +}; +const StyledTooltipPointer = styled.span<{ + side: TooltipPointerPosition["side"]; +}>` + position: absolute; + width: 0; + height: 0; + ${({ side }) => sideStyles[side]}; + + &::before { + content: ""; + position: absolute; + border-left: ${POINTER_WIDTH + POINTER_THICKNESS}px solid transparent; + border-right: ${POINTER_WIDTH + POINTER_THICKNESS}px solid transparent; + border-bottom: ${POINTER_HEIGHT + POINTER_THICKNESS}px solid #636569; + left: -${POINTER_THICKNESS}px; + top: -${POINTER_THICKNESS}px; + } + + &::after { + content: ""; + position: absolute; + border-left: ${POINTER_WIDTH}px solid transparent; + border-right: ${POINTER_WIDTH}px solid transparent; + border-bottom: ${POINTER_HEIGHT}px solid ${tooltipBackground}; + } +`; + +function normalizeCssValue(value: string | number) { + return typeof value === "number" ? `${value}px` : value; +} + +const withMin = (min: number) => (value: string | number | undefined) => + value != undefined ? `max(${min}px, ${normalizeCssValue(value)})` : value; +const withMax = (max: number) => (value: string | number | undefined) => + value != undefined ? `min(${max}px, ${normalizeCssValue(value)})` : value; +const HEIGHT_OFFSET = POINTER_HEIGHT + WITH_POINTER_BORDER_RADIUS; +const WIDTH_OFFSET = POINTER_WIDTH + WITH_POINTER_BORDER_RADIUS; + +/** + * Ensures pointer is not offset too much or too little that would make the arrow appear + * outside the tooltip boundary. + */ +function limitPointerPositionStyles( + { width, height }: { width: number | undefined; height: number | undefined }, + { top, left, right, bottom }: CSSProperties +) { + const applyVerticalMinMax = compose( + height ? withMax(height - HEIGHT_OFFSET) : identity, + withMin(HEIGHT_OFFSET) + ); + const applyHorizontalMinMax = compose( + width ? withMax(width - WIDTH_OFFSET) : identity, + withMin(WIDTH_OFFSET) + ); + return { + top: applyVerticalMinMax(top), + bottom: applyVerticalMinMax(bottom), + left: applyHorizontalMinMax(left), + right: applyHorizontalMinMax(right), + }; +} + +const getOffsetCssProp = ( + side: TooltipPointerPosition["side"], + invert?: boolean +): "top" | "bottom" | "left" | "right" => { + if (side === "top" || side === "bottom") { + return invert ? "right" : "left"; + } + return invert ? "bottom" : "top"; +}; + +function pointerPositionToOffsetStyle({ + side, + offset = "50%", +}: TooltipPointerPosition): CSSProperties { + const { invert = false, value: offsetValue } = + typeof offset === "object" ? offset : { invert: false, value: offset }; + return { + [getOffsetCssProp(side, invert)]: offsetValue, + }; +} + +export function TooltipPointer({ + side, + offset, + tooltipRef, +}: { + side: TooltipPointerPosition["side"]; + offset: + | { type: "calculated"; value: CSSProperties } + | { type: "specific"; value: TooltipPointerPosition["offset"] }; + tooltipRef: RefObject; +}) { + const [size, setSize] = useState<{ + height: number | undefined; + width: number | undefined; + }>({ height: undefined, width: undefined }); + useEffect(() => { + const { offsetHeight, offsetWidth } = tooltipRef.current || {}; + if (offsetHeight != size?.height || offsetWidth != size?.width) { + setSize({ height: offsetHeight, width: offsetWidth }); + } + }); + + return ( + + ); +} diff --git a/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx b/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx index 1633b67f..46a252e2 100644 --- a/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx +++ b/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx @@ -56,6 +56,20 @@ export const Disabled: StoryObj = { }, }; +export const WithPointer: StoryObj = { + args: { + placement: "bottom", + tooltip: ( + + ), + }, +}; + export const All: StoryFn = () => { return (
    { /** * Tooltip content. The value should be an element of type {@link Tooltip}. */ tooltip: ReactElement; + /** + * Placement of the tooltip with respect to the cursor + * @default "bottom left" + */ + placement?: AriaPositionProps["placement"]; + /** + * The additional offset applied along the main axis between the element and its + * anchor element. + * @default theme.value("HelpTooltip.mouseCursorOffset") ?? 20 + */ + offset?: number; /** * Either a focusable component, or a render function which accepts trigger props and passes it to some component. */ @@ -43,6 +58,8 @@ export const TooltipTrigger = ({ * it's 300 by default, but it's 500 in the code currently. */ delay = 500, + offset, + placement = "bottom left", ...props }: TooltipTriggerProps): JSX.Element => { const triggerRef = useRef(null); @@ -54,19 +71,20 @@ export const TooltipTrigger = ({ const overlayRef = useRef(null); - const { overlayProps, updatePosition } = useMouseEventOverlayPosition({ + const positionAria = useMouseEventOverlayPosition({ overlayRef, isOpen: state.isOpen, - placement: "bottom left", + placement, shouldFlip: true, - offset: theme.value("HelpTooltip.mouseCursorOffset") ?? 20, + offset: + offset ?? theme.value("HelpTooltip.mouseCursorOffset") ?? 20, }); // FIXME: Find the explanation for why it happens, and fix it properly, if it's a legit issue. useEffect(() => { if (state.isOpen) { requestAnimationFrame(() => { - updatePosition(); + positionAria.updatePosition(); }); } }, [state.isOpen]); @@ -74,7 +92,7 @@ export const TooltipTrigger = ({ return ( ; /** * Either a focusable component, or a render function which accepts trigger props and passes it to some component. */ @@ -33,7 +29,7 @@ interface TooltipTriggerBaseProps { state: TooltipTriggerState; showOnFocus?: boolean; - + positionAria: PositionAria; overlayRef: RefObject; triggerRef: RefObject; isDisabled?: boolean; @@ -50,10 +46,10 @@ export const TooltipTriggerAndOverlay = ({ tooltip, trigger, state, - tooltipOverlayProps, overlayRef, triggerRef, showOnFocus, + positionAria, ...props }: TooltipTriggerBaseProps): JSX.Element => { const [isInteractive, setInteractive] = useState(false); @@ -81,10 +77,17 @@ export const TooltipTriggerAndOverlay = ({ {normalizeChildren(trigger, { ...triggerProps, ref: triggerRef })} {state.isOpen && !props.isDisabled && ( - +
    + theme.color("ToolTip.background", !theme.dark ? "#f2f2f2" : "#3c3f41"); +export const tooltipBorderColor = ({ theme }: { theme: Theme }) => + theme.color("ToolTip.borderColor", !theme.dark ? "#adadad" : "#636569"); diff --git a/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx b/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx index ae2a7186..13163bde 100644 --- a/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx +++ b/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx @@ -2,7 +2,10 @@ import React, { ForwardedRef } from "react"; import { Node } from "@react-types/shared"; import { Virtualizer } from "@react-aria/virtualizer"; import { CollectionSpeedSearchContext } from "@intellij-platform/core/CollectionSpeedSearch"; -import { SpeedSearchProps } from "@intellij-platform/core/SpeedSearch"; +import { + SpeedSearchProps, + SpeedSearchStateProps, +} from "@intellij-platform/core/SpeedSearch"; import { useCollectionRef } from "@intellij-platform/core/Collections/useCollectionRef"; import useForwardedRef from "@intellij-platform/core/utils/useForwardedRef"; import { StyledTree } from "../StyledTree"; @@ -15,11 +18,23 @@ import { useSpeedSearchTree } from "./useSpeedSearchTree"; import { SpeedSearchTreeNode } from "./SpeedSearchTreeNode"; export type SpeedSearchTreeProps = TreeProps & - SpeedSearchProps; + SpeedSearchProps & + SpeedSearchStateProps & { + /** + * Whether speed search popup should not be shown. Useful when speed search state is controlled, and + * a search input is rendered outside the tree. + */ + hideSpeedSearchPopup?: boolean; + }; export const SpeedSearchTree = React.forwardRef( ( - { fillAvailableSpace = false, treeRef, ...props }: SpeedSearchTreeProps, + { + fillAvailableSpace = false, + treeRef, + hideSpeedSearchPopup, + ...props + }: SpeedSearchTreeProps, forwardedRef: ForwardedRef ) => { const state = useTreeState( @@ -42,7 +57,7 @@ export const SpeedSearchTree = React.forwardRef( return ( - + {!hideSpeedSearchPopup && } extends SpeedSearchProps, + SpeedSearchStateProps, SelectableTreeProps {} export function useSpeedSearchTree( @@ -24,9 +28,9 @@ export function useSpeedSearchTree( speedSearch, ...collectionSpeedSearch } = useCollectionSpeedSearch({ + ...props, collection: state.collection, selectionManager: state.selectionManager, - stickySearch: props.stickySearch, keyboardDelegate: new TreeKeyboardDelegate( state.collection, state.disabledKeys, diff --git a/packages/jui/src/Tree/StyledTreeNode.tsx b/packages/jui/src/Tree/StyledTreeNode.tsx index a4546139..69c847ae 100644 --- a/packages/jui/src/Tree/StyledTreeNode.tsx +++ b/packages/jui/src/Tree/StyledTreeNode.tsx @@ -3,9 +3,12 @@ import { UnknownThemeProp } from "@intellij-platform/core/Theme"; import { StyledListItem } from "@intellij-platform/core/List/StyledListItem"; import { TREE_ICON_SIZE } from "./TreeNodeIcon"; -export const StyledTreeNode = styled(StyledListItem).attrs({ as: "div" })<{ +type StyledListItemProps = { level: number; -}>` +}; +export const StyledTreeNode = styled(StyledListItem).attrs( + { role: "treeitem" } +)` // There are some theme properties for tree node padding (theme.ui.Tree.leftChildIndent and // theme.ui.Tree.leftChildIndent), but they doesn't seem to be applicable. padding-left: ${({ level }) => `${(level + 1) * TREE_ICON_SIZE + 8}px`}; diff --git a/packages/jui/src/Tree/Tree.cy.tsx b/packages/jui/src/Tree/Tree.cy.tsx index b89feb63..b0a86365 100644 --- a/packages/jui/src/Tree/Tree.cy.tsx +++ b/packages/jui/src/Tree/Tree.cy.tsx @@ -12,6 +12,7 @@ describe("Tree", () => { cy.mount(); cy.contains("Foo").click().type("{enter}"); + cy.findByRole("treeitem", { name: "FooBar" }); matchImageSnapshot("Tree-nested-single-child-expansion"); }); diff --git a/packages/jui/src/Tree/Tree.tsx b/packages/jui/src/Tree/Tree.tsx index eedfda99..ec4339a3 100644 --- a/packages/jui/src/Tree/Tree.tsx +++ b/packages/jui/src/Tree/Tree.tsx @@ -60,14 +60,14 @@ export const Tree = React.forwardRef( return ( , unknown>} ref={ref} fillAvailableSpace={fillAvailableSpace} {...virtualizerProps} {...treeProps} > - {(itemType: string, item: object) => ( - ).key} item={item as Node} /> + {(itemType: string, item: Node) => ( + } /> )} diff --git a/packages/jui/src/Tree/useTreeVirtualizer.tsx b/packages/jui/src/Tree/useTreeVirtualizer.tsx index 2a606890..26ec10b9 100644 --- a/packages/jui/src/Tree/useTreeVirtualizer.tsx +++ b/packages/jui/src/Tree/useTreeVirtualizer.tsx @@ -1,9 +1,9 @@ -import React, { HTMLAttributes, useMemo } from "react"; +import { HTMLAttributes, useMemo } from "react"; import { LayoutNode } from "@react-stately/layout"; import { Node } from "@react-types/shared"; import { TreeState } from "@react-stately/tree"; import { VirtualizerProps } from "@react-aria/virtualizer"; -import { LayoutInfo, Rect } from "@react-stately/virtualizer"; +import { LayoutInfo, Rect, Size } from "@react-stately/virtualizer"; import { VariableWidthListLayout } from "@intellij-platform/core/VariableWidthListLayout"; class FlattenedTreeLayout extends VariableWidthListLayout { @@ -18,6 +18,62 @@ class FlattenedTreeLayout extends VariableWidthListLayout { return layoutNode; } + doBuildCollection(): LayoutNode[] { + let y = this.padding; + let nodes = []; + /** + * The only difference here is that in tree, we need to use getKeys() to get the keys for a flattened list of items + * that are currently visible. With this difference only, things seem to work as expected, but sections are not + * tested, and not even sure if they would be applicable for Tree view. + * Diff compared to ListView#BuildCollection: + * - for (let node of this.collection) { + * + const visibleNodes = [...this.collection.getKeys()].map(key => this.collection.getItem(key)); + * + for (let node of visibleNodes) { + * This needs to be maintained with version upgrades, until TreeLayout is supported: + * https://github.com/adobe/react-spectrum/issues/2396 + */ + 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); + } + + if (this.isLoading) { + let rect = new Rect( + 0, + y, + this.virtualizer.visibleRect.width, + this.loaderHeight ?? this.virtualizer.visibleRect.height + ); + let loader = new LayoutInfo("loader", "loader", rect); + this.layoutInfos.set("loader", loader); + nodes.push({ layoutInfo: loader }); + y = loader.rect.maxY; + } + + if (nodes.length === 0) { + let rect = new Rect( + 0, + y, + this.virtualizer.visibleRect.width, + this.placeholderHeight ?? this.virtualizer.visibleRect.height + ); + let placeholder = new LayoutInfo("placeholder", "placeholder", rect); + this.layoutInfos.set("placeholder", placeholder); + nodes.push({ layoutInfo: placeholder }); + y = placeholder.rect.maxY; + } + + this.contentSize = new Size( + this.virtualizer.visibleRect.width, + y + this.padding + ); + return nodes; + } + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { return super .getVisibleLayoutInfos(rect) diff --git a/packages/jui/src/VariableWidthListLayout.tsx b/packages/jui/src/VariableWidthListLayout.tsx index 5e58ebbf..d1ebc032 100644 --- a/packages/jui/src/VariableWidthListLayout.tsx +++ b/packages/jui/src/VariableWidthListLayout.tsx @@ -1,6 +1,15 @@ -import { LayoutNode, ListLayout } from "@react-stately/layout"; +import { + LayoutNode, + ListLayout, + ListLayoutOptions, +} from "@react-stately/layout"; import React, { Key } from "react"; -import { InvalidationContext, Rect, Size } from "@react-stately/virtualizer"; +import { + InvalidationContext, + LayoutInfo, + Rect, + Size, +} from "@react-stately/virtualizer"; import { Node } from "@react-types/shared"; /** @@ -19,6 +28,14 @@ export class VariableWidthListLayout extends ListLayout { */ keyToWidth = new Map(); private visibleContentWidth: number = 0; + private dividerHeight: number = 2; + + constructor(options: ListLayoutOptions & { dividerHeight?: number }) { + super(options); + if (options.dividerHeight != undefined) { + this.dividerHeight = options.dividerHeight; + } + } buildItem(node: Node, x: number, y: number): LayoutNode { const layoutNode = super.buildItem(node, x, y); @@ -28,6 +45,33 @@ export class VariableWidthListLayout extends ListLayout { return layoutNode; } + buildNode(node: Node, x: number, y: number): LayoutNode { + if (node.type === "divider") { + return this.buildDivider(node, x, y); + } + return super.buildNode(node, x, y); + } + + buildDivider(node: Node, x: number, y: number): LayoutNode { + let width = this.virtualizer.visibleRect.width; + let rectHeight = this.dividerHeight; + + let rect = new Rect(x, y, width - x, rectHeight); + let layoutInfo = new LayoutInfo(node.type, node.key, rect); + layoutInfo.estimatedSize = false; + return { + layoutInfo, + // validRect: layoutInfo.rect, + }; + } + + /** + * Allows for overriding buildCollection in a sub-class + */ + protected doBuildCollection() { + return super.buildCollection(); + } + buildCollection(): LayoutNode[] { this.visibleContentWidth = this.getVisibleContentWidth(); // in buildChild, if invalidateEverything is false and y is not changed, it will reuse the existing layoutInfo. @@ -37,7 +81,7 @@ export class VariableWidthListLayout extends ListLayout { // UPDATE: using getFinalLayoutInfo seems to be a legitimate last minute way to mutate layout infos. this.invalidateEverything = this.contentSize?.width !== this.visibleContentWidth; - const layoutNodes = super.buildCollection(); + const layoutNodes = this.doBuildCollection(); this.contentSize.width = this.visibleContentWidth; return layoutNodes; } diff --git a/packages/jui/src/utils/tree-utils.ts b/packages/jui/src/utils/tree-utils.ts index 68b06398..4e30967c 100644 --- a/packages/jui/src/utils/tree-utils.ts +++ b/packages/jui/src/utils/tree-utils.ts @@ -1,6 +1,18 @@ import { Key } from "react"; +import { Ord } from "ramda"; type TreeNodeFn = (root: T) => null | readonly T[]; +type MutableTreeNodeFn = (root: T) => null | T[]; + +type Tree = { + getChildren: TreeNodeFn; + roots: ReadonlyArray; +}; + +type MutableTree = { + getChildren: MutableTreeNodeFn; + roots: Array; +}; export const getExpandAllKeys = ( /** @@ -13,7 +25,7 @@ export const getExpandAllKeys = ( * a function that converts each node into a key */ getKey: (t: T) => Key, - roots: T[] + roots: ReadonlyArray ) => { const keys: Key[] = roots.map(getKey); const processItem = (node: T | null) => { @@ -39,7 +51,7 @@ export const getExpandedToNodesKeys = ( * a function that converts each node into a key */ getKey: (t: T) => Key, - roots: T[], + roots: ReadonlyArray, targetNodeKeys: Iterable ) => { const targetNodeKeySet = new Set(targetNodeKeys); @@ -64,7 +76,7 @@ export const getExpandedToNodesKeys = ( export const dfsVisit = ( getChildren: TreeNodeFn, visit: (node: T, childrenValues: null | R[]) => R, - roots: T[] + roots: ReadonlyArray ) => { const dfs = (node: T): R => { const children = getChildren(node); @@ -77,7 +89,7 @@ export const dfsVisit = ( export const bfsVisit = ( getChildren: TreeNodeFn, visit: (node: T, parentValue: R | null) => R, - roots: T[] + roots: ReadonlyArray ) => { const bfs: typeof visit = (node, parentValue) => { const result = visit(node, parentValue); @@ -87,3 +99,25 @@ export const bfsVisit = ( }; return roots.map((root) => bfs(root, null)); }; + +export const sortTreeNodesInPlace = ( + by: (t: T) => Ord, + tree: MutableTree +) => { + const compareFn = (a: T, b: T) => { + const aa = by(a); + const bb = by(b); + return aa < bb ? -1 : aa > bb ? 1 : 0; + }; + tree.roots.sort(compareFn); + bfsVisit( + tree.getChildren, + (node) => { + const children = tree.getChildren(node); + if (children) { + children.sort(compareFn); + } + }, + tree.roots + ); +}; diff --git a/packages/jui/src/utils/useMouseEventOverlayPosition.tsx b/packages/jui/src/utils/useMouseEventOverlayPosition.tsx index a4950ee3..cf1a046c 100644 --- a/packages/jui/src/utils/useMouseEventOverlayPosition.tsx +++ b/packages/jui/src/utils/useMouseEventOverlayPosition.tsx @@ -28,7 +28,7 @@ import { * ``` */ let globalMoveHandler: null | ((e: MouseEvent) => void) = null; -let lastMouseClientPos = { x: 0, y: 0 }; +let lastMouseClientPos = { clientX: 0, clientY: 0 }; export function useMouseEventOverlayPosition( options: Omit @@ -40,8 +40,8 @@ export function useMouseEventOverlayPosition( useLayoutEffect(() => { if (!globalMoveHandler) { // After the first use of the hook, the listener will be attached forever. Not a big deal but can be improved. - globalMoveHandler = (e) => { - lastMouseClientPos = { x: e.clientX, y: e.clientY }; + globalMoveHandler = ({ clientX, clientY }) => { + lastMouseClientPos = { clientX, clientY }; }; document.addEventListener("mousemove", globalMoveHandler); } @@ -66,8 +66,8 @@ export function useMouseEventOverlayPosition( useLayoutEffect(() => { if (options.isOpen && targetRef.current) { - targetRef.current.style.left = `${lastMouseClientPos.x}px`; - targetRef.current.style.top = `${lastMouseClientPos.y}px`; + targetRef.current.style.left = `${lastMouseClientPos.clientX}px`; + targetRef.current.style.top = `${lastMouseClientPos.clientY}px`; updatePosition(); } }, [options.isOpen, targetRef.current]); diff --git a/packages/website/docs/components/SpeedSearch.mdx b/packages/website/docs/components/SpeedSearch.mdx index c41a9fce..cfd22650 100644 --- a/packages/website/docs/components/SpeedSearch.mdx +++ b/packages/website/docs/components/SpeedSearch.mdx @@ -92,8 +92,8 @@ function FullSpeedSearchExample() { className="my-speed-search" searchTerm={searchTerm} onSearchTermChange={setSearchTerm} - active={active} - onActiveChange={setActive} + isSearchActive={active} + onIsSearchActiveChange={setActive} match={match} >
      diff --git a/packages/website/docs/components/Tree.mdx b/packages/website/docs/components/Tree.mdx index 3909c4ce..a57a0f63 100644 --- a/packages/website/docs/components/Tree.mdx +++ b/packages/website/docs/components/Tree.mdx @@ -107,7 +107,7 @@ function TreeContextMenuExample() { fillAvailableSpace selectionMode="single" defaultExpandedKeys={allKeys} - stickySearch + keepSearchActiveOnBlur selectedKeys={selectedKeys} onSelectionChange={setSelectedKeys} > diff --git a/packages/website/package.json b/packages/website/package.json index 0606cb94..76c90447 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -44,7 +44,7 @@ "path-browserify": "^1.0.1", "process": "^0.11.10", "stream-browserify": "^3.0.0", - "typescript": "*", + "typescript": "workspace:*", "webpack": "^5.73.0" }, "browserslist": { diff --git a/yarn.lock b/yarn.lock index eae31457..e1578f06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4134,7 +4134,7 @@ __metadata: storybook-addon-theme-provider: ^0.1.10 stream-browserify: ^3.0.0 ts-loader: ^8 - typescript: "*" + typescript: "workspace:*" webpack: 5 peerDependencies: react: ">=16.8" @@ -7610,21 +7610,6 @@ __metadata: languageName: node linkType: hard -"@react-stately/layout@patch:@react-stately/layout@npm:3.4.4#.yarn/patches/@react-stately-layout-npm-3.4.4-75ff8d9e5d.patch::locator=root-workspace-0b6124%40workspace%3A.": - version: 3.4.4 - resolution: "@react-stately/layout@patch:@react-stately/layout@npm%3A3.4.4#.yarn/patches/@react-stately-layout-npm-3.4.4-75ff8d9e5d.patch::version=3.4.4&hash=28a1ee&locator=root-workspace-0b6124%40workspace%3A." - dependencies: - "@babel/runtime": ^7.6.2 - "@react-stately/virtualizer": ^3.1.7 - "@react-types/grid": ^3.0.2 - "@react-types/shared": ^3.11.1 - "@react-types/table": ^3.1.2 - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 - checksum: 85c8dab7dfca21c32ecaf92e00bf6dc52bbddb9d092467a2949d647b08e9df7d6a057b9321378ae363ef39df804eb16e9eff04586dc13eb14d2359a99a966b5e - languageName: node - linkType: hard - "@react-stately/list@npm:^3.4.3": version: 3.4.3 resolution: "@react-stately/list@npm:3.4.3" @@ -9698,6 +9683,13 @@ __metadata: languageName: node linkType: hard +"@types/diff@npm:^5.0.9": + version: 5.0.9 + resolution: "@types/diff@npm:5.0.9" + checksum: fb5cb6d6407e3ebcd8883bdc9f9124b57ebc04720cfb605afc5f839f9a71878ccf895da66b09d5d8df25e684cfe57a6f6f4bf65f87ccc0ce0b58a18b1f3ce5b0 + languageName: node + linkType: hard + "@types/doctrine@npm:^0.0.3": version: 0.0.3 resolution: "@types/doctrine@npm:0.0.3" @@ -12725,6 +12717,13 @@ __metadata: languageName: node linkType: hard +"clipboard-copy@npm:^4.0.1": + version: 4.0.1 + resolution: "clipboard-copy@npm:4.0.1" + checksum: 530a09de80dfd0536793fdefb6559a2dfb6c2706b027c41c672a0f18debafd420e69111224f3265555afcd73f9cd97517dcaf494aa25996a58a8f550a3426080 + languageName: node + linkType: hard + "cliui@npm:^7.0.2": version: 7.0.4 resolution: "cliui@npm:7.0.4" @@ -14159,6 +14158,13 @@ __metadata: languageName: node linkType: hard +"diff@npm:^5.2.0": + version: 5.2.0 + resolution: "diff@npm:5.2.0" + checksum: 12b63ca9c36c72bafa3effa77121f0581b4015df18bc16bac1f8e263597735649f1a173c26f7eba17fb4162b073fee61788abe49610e6c70a2641fe1895443fd + languageName: node + linkType: hard + "diffie-hellman@npm:^5.0.0": version: 5.0.3 resolution: "diffie-hellman@npm:5.0.3" @@ -19373,10 +19379,13 @@ __metadata: "@isomorphic-git/lightning-fs": ^4.6.0 "@monaco-editor/react": ^4.2.2 "@recoiljs/refine": ^0.1.1 + "@types/diff": ^5.0.9 "@types/jest": ^29.5.2 "@types/uuid": ^9.0.7 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 @@ -19389,6 +19398,7 @@ __metadata: recoil: ^0.7.7 recoil-sync: ^0.2.0 styled-components: ^5 + typescript: "workspace:*" uuid: ^9.0.1 xterm-for-react: ^1.0.4 languageName: unknown @@ -25908,16 +25918,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:*": - version: 4.8.3 - resolution: "typescript@npm:4.8.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 8286a5edcaf3d68e65c451aa1e7150ad1cf53ee0813c07ec35b7abdfdb10f355ecaa13c6a226a694ae7a67785fd7eeebf89f845da0b4f7e4a35561ddc459aba0 - languageName: node - linkType: hard - "typescript@npm:4.7.4": version: 4.7.4 resolution: "typescript@npm:4.7.4" @@ -25928,27 +25928,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:~4.6.3": - version: 4.6.4 - resolution: "typescript@npm:4.6.4" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: e7bfcc39cd4571a63a54e5ea21f16b8445268b9900bf55aee0e02ad981be576acc140eba24f1af5e3c1457767c96cea6d12861768fb386cf3ffb34013718631a - languageName: node - linkType: hard - -"typescript@patch:typescript@*#~builtin": - version: 4.8.3 - resolution: "typescript@patch:typescript@npm%3A4.8.3#~builtin::version=4.8.3&hash=7ad353" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 0404a09c625df01934ef774b45ce1ca57ccae41cd625fcdbb82056715320d9329e70d9d75c2c732ec6ef947444ca978c189a332b71fa21f5c1437d5a83e24906 - languageName: node - linkType: hard - -"typescript@patch:typescript@4.7.4#~builtin": +"typescript@patch:typescript@npm%3A4.7.4#~builtin": version: 4.7.4 resolution: "typescript@patch:typescript@npm%3A4.7.4#~builtin::version=4.7.4&hash=7ad353" bin: @@ -25958,16 +25938,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@~4.6.3#~builtin": - version: 4.6.4 - resolution: "typescript@patch:typescript@npm%3A4.6.4#~builtin::version=4.6.4&hash=7ad353" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 1cb434fbc637d347be90e3a0c6cd05e33c38f941713c8786d3031faf1842c2c148ba91d2fac01e7276b0ae3249b8633f1660e32686cc7a8c6a8fd5361dc52c66 - languageName: node - linkType: hard - "ua-parser-js@npm:^0.7.30": version: 0.7.31 resolution: "ua-parser-js@npm:0.7.31" @@ -26979,7 +26949,7 @@ __metadata: react-dom: ^17.0.1 stream-browserify: ^3.0.0 styled-components: ^5 - typescript: "*" + typescript: "workspace:*" url-loader: ^4.1.1 webpack: ^5.73.0 languageName: unknown