diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index a5a15490..b87426ab 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -102,7 +102,7 @@ jobs: # Without this, we would need to "finalize" percy build explicitly. More info: https://docs.percy.io/docs/parallel-test-suites PERCY_PARALLEL_TOTAL: 15 # 10 for component tests, 5 for e2e # Percy's nonce is supposed to be set to run id by default, but it's not. Plus, appending run_number makes it more reliable in case of rerunning - PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_number }} + PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} run: yarn run cypress:component --record --parallel --group component @@ -147,7 +147,7 @@ jobs: # Without this, we would need to "finalize" percy build explicitly. More info: https://docs.percy.io/docs/parallel-test-suites PERCY_PARALLEL_TOTAL: 15 # 10 for component tests, 5 for e2e # Percy's nonce is supposed to be set to run id by default, but it's not. Plus, appending run_number makes it more reliable in case of rerunning - PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_number }} + PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} run: | yarn workspace jui-example-app run serve & diff --git a/packages/example-app/src/Project/actions/NewItemPopup.tsx b/packages/example-app/src/Project/actions/NewItemPopup.tsx new file mode 100644 index 00000000..a5191a93 --- /dev/null +++ b/packages/example-app/src/Project/actions/NewItemPopup.tsx @@ -0,0 +1,90 @@ +import { + Input, + Popup, + PopupLayout, + PositionedTooltipTrigger, + styled, + ValidationTooltip, +} from "@intellij-platform/core"; +import React, { ChangeEvent, useState } from "react"; + +const StyledInput = styled(Input)` + width: 20.5rem; + /** + * To have the validation box shadow not clipped by the popup. + * Maybe it should be an option on input to make sure margin is always in sync with the box-shadow thickness + */ + margin: 3px; + + input { + padding-top: 1px; + padding-bottom: 1px; + } +`; +const StyledHeader = styled(Popup.Header)` + border-bottom: 1px solid ${({ theme }) => theme.commonColors.border()}; +`; + +/** + * A simple Popup with an input, used in actions such as create file, or create directory + */ +export function NewItemPopup({ + title, + inputName = "Name", + onSubmit, + validationMessage, + validationType = "error", + value, + onChange, +}: { + inputName?: string; + title: React.ReactNode; + onSubmit: () => void; + value: string; + onChange: (newValue: string) => void; + validationMessage?: string; + validationType?: "error" | "warning"; +}) { + const [hideMessage, setHideMessage] = useState(false); + return ( + + {title}} + content={ +
{ + e.preventDefault(); + onSubmit(); + }} + onMouseDown={() => { + setHideMessage(true); + }} + > + + {validationMessage} + + } + > + ) => { + setHideMessage(false); + onChange(e.target.value); + }} + /> + +
+ } + /> +
+ ); +} diff --git a/packages/example-app/src/Project/actions/copyAbsolutePath.tsx b/packages/example-app/src/Project/actions/copyAbsolutePath.tsx new file mode 100644 index 00000000..d5c97482 --- /dev/null +++ b/packages/example-app/src/Project/actions/copyAbsolutePath.tsx @@ -0,0 +1,23 @@ +import { selector } from "recoil"; +import { ActionDefinition } from "@intellij-platform/core"; + +import { activePathExistsState, activePathsState } from "../project.state"; +import { projectActionIds } from "../projectActionIds"; +import { notImplemented } from "../notImplemented"; + +export const copyAbsolutePathActionState = selector({ + key: `action.${projectActionIds.CopyAbsolutePath}`, + get: ({ get, getCallback }): ActionDefinition => ({ + id: projectActionIds.CopyAbsolutePath, + title: "Absolute Path", + isDisabled: !get(activePathExistsState), + useShortcutsOf: "CopyPaths", + actionPerformed: getCallback(({ snapshot }) => async () => { + notImplemented(); + const activePaths = snapshot.getLoadable(activePathsState).getValue(); + if (activePaths.length === 0) { + return; + } + }), + }), +}); diff --git a/packages/example-app/src/Project/actions/copyFilename.tsx b/packages/example-app/src/Project/actions/copyFilename.tsx new file mode 100644 index 00000000..d39ddc07 --- /dev/null +++ b/packages/example-app/src/Project/actions/copyFilename.tsx @@ -0,0 +1,22 @@ +import { selector } from "recoil"; +import { ActionDefinition } from "@intellij-platform/core"; + +import { activePathExistsState, activePathsState } from "../project.state"; +import { projectActionIds } from "../projectActionIds"; +import { notImplemented } from "../notImplemented"; + +export const copyFilenameActionState = selector({ + key: `action.${projectActionIds.CopyFileName}`, + get: ({ get, getCallback }): ActionDefinition => ({ + id: projectActionIds.CopyFileName, + title: "File Name", + isDisabled: !get(activePathExistsState), + actionPerformed: getCallback(({ snapshot }) => async () => { + notImplemented(); + const activePaths = snapshot.getLoadable(activePathsState).getValue(); + if (activePaths.length === 0) { + return; + } + }), + }), +}); diff --git a/packages/example-app/src/Project/actions/createDirectoryAction.tsx b/packages/example-app/src/Project/actions/createDirectoryAction.tsx new file mode 100644 index 00000000..0eecfaac --- /dev/null +++ b/packages/example-app/src/Project/actions/createDirectoryAction.tsx @@ -0,0 +1,135 @@ +import path from "path"; +import { selector, useRecoilCallback } from "recoil"; +import React, { useEffect, useState } from "react"; +import { ActionDefinition, PlatformIcon } from "@intellij-platform/core"; + +import { fs } from "../../fs/fs"; +import { stat } from "../../fs/fs-utils"; +import { DIR_ICON } from "../../file-utils"; +import { createDirectoryCallback } from "../fs-operations"; +import { useCancelableAsyncCallback } from "../../useCancelableAsyncCallback"; +import { + activePathExistsState, + activePathsState, + projectPopupManagerRefState, +} from "../project.state"; +import { NewItemPopup } from "./NewItemPopup"; +import { projectActionIds } from "../projectActionIds"; + +// TODO: expand to and select the new directory in the project tree +export const createDirectoryActionState = selector({ + key: `action.${projectActionIds.NewDir}`, + get: ({ get, getCallback }): ActionDefinition => ({ + id: projectActionIds.NewDir, + icon: , + title: "Directory", + description: "Create new directory", + isDisabled: !get(activePathExistsState), // TODO: disable action when multiple paths are selected and none of them are directories + actionPerformed: getCallback(({ snapshot, refresh }) => async () => { + const activePaths = snapshot.getLoadable(activePathsState).getValue(); + if (activePaths.length === 0) { + return; + } + const popupManager = snapshot + .getLoadable(projectPopupManagerRefState) + .getValue().current; + + // TODO: open a dialog and let the user choose the destination if, multiple dirs are active + const destinationDir = ( + await fs.promises.stat(activePaths[0]) + ).isDirectory() + ? activePaths[0] + : path.dirname(activePaths[0]); + + if (!popupManager) { + throw new Error("Could not find popup manager"); + } + + popupManager.show(({ close }) => ( + + )); + }), + }), +}); + +function NewDirectoryPopup({ + destinationDir, + close, +}: { + close: () => void; + destinationDir: string; +}) { + const createDirectory = useRecoilCallback(createDirectoryCallback, []); + + const [dirName, setDirName] = useState(""); + + const [validationResult, setValidationResult] = useState<{ + type: "error" | "warning"; + message: string; + } | null>(null); + + const updateValidation = useCancelableAsyncCallback(function* ( + destinationDir: string, + dirName: string + ) { + setValidationResult( + (yield validate(destinationDir, dirName)) as Awaited< + ReturnType + > + ); + }); + useEffect(() => { + updateValidation(destinationDir, dirName); + }, [destinationDir, dirName]); + + const submit = async () => { + const error = await validate(destinationDir, dirName); + if (error == null) { + await createDirectory(destinationDir, dirName); + close(); + } + }; + return ( + { + if (dirName) { + submit(); // error handling? + } + }} + value={dirName} + onChange={setDirName} + validationMessage={validationResult?.message} + validationType={validationResult?.type} + /> + ); +} + +const validate = async function ( + destinationDir: string, + dirName: string +): Promise<{ type: "error" | "warning"; message: string } | null> { + const fullPath = path.join(destinationDir, dirName); + if (!dirName) { + return null; + } + if (dirName.includes(".")) { + return { + type: "warning", + message: `Note: "." in the name is treated as a regular character. Use "/" instead if you mean to create nested directories`, + }; + } + let stats = await stat(fullPath); + if (stats?.isFile()) { + return { + type: "error", + message: `A file with the name '${dirName}' already exists`, + }; + } else if (stats?.isDirectory()) { + return { + type: "error", + message: `A directory with the name '${dirName}' already exists`, + }; + } + return null; +}; diff --git a/packages/example-app/src/Project/actions/createFileAction.tsx b/packages/example-app/src/Project/actions/createFileAction.tsx index 9289930b..093e808a 100644 --- a/packages/example-app/src/Project/actions/createFileAction.tsx +++ b/packages/example-app/src/Project/actions/createFileAction.tsx @@ -1,18 +1,12 @@ import path from "path"; import { selector, useRecoilCallback } from "recoil"; -import React, { ChangeEvent, useState } from "react"; +import React, { useState } from "react"; import { ActionDefinition, Button, Checkbox, - Input, ModalWindow, PlatformIcon, - Popup, - PopupLayout, - PositionedTooltipTrigger, - styled, - ValidationTooltip, WindowLayout, } from "@intellij-platform/core"; @@ -25,14 +19,17 @@ import { windowManagerRefState, } from "../project.state"; import { vcsRootForFile } from "../../VersionControl/file-status.state"; +import { stat } from "../../fs/fs-utils"; import { createFileCallback } from "../fs-operations"; import { gitAddCallback } from "../../VersionControl/gitAddCallback"; +import { NewItemPopup } from "./NewItemPopup"; +import { projectActionIds } from "../projectActionIds"; // TODO: expand to and select the new file in the project tree, if the action is initiated from projects view. export const createFileActionState = selector({ - key: "action.NewFile", + key: `action.${projectActionIds.NewFile}`, get: ({ get, getCallback }): ActionDefinition => ({ - id: "NewFile", + id: projectActionIds.NewFile, icon: , title: "File", description: "Create new file", @@ -58,30 +55,13 @@ export const createFileActionState = selector({ } popupManager.show(({ close }) => ( - + )); }), }), }); -const StyledInput = styled(Input)` - width: 20.5rem; - /** - * To have the validation box shadow not clipped by the popup. - * Maybe it should be an option on input to make sure margin is always in sync with the box-shadow thickness - */ - margin: 3px; - input { - padding-top: 1px; - padding-bottom: 1px; - } -`; - -const StyledHeader = styled(Popup.Header)` - border-bottom: 1px solid ${({ theme }) => theme.commonColors.border()}; -`; - -function NewFileNamePopup({ +function NewFilePopup({ destinationDir, close, }: { @@ -91,7 +71,8 @@ function NewFileNamePopup({ const createFile = useRecoilCallback( (callbackInterface) => { const createFile = createFileCallback(callbackInterface); - return async (filePath: string) => { + return async (destination: string, filename: string) => { + const fullpath = path.join(destinationDir, filename); const { snapshot } = callbackInterface; const editorManager = snapshot .getLoadable(editorManagerState) @@ -99,8 +80,8 @@ function NewFileNamePopup({ const windowManager = snapshot .getLoadable(windowManagerRefState) .getValue().current; - const repoDir = await snapshot.getPromise(vcsRootForFile(filePath)); - await createFile(filePath); + const repoDir = await snapshot.getPromise(vcsRootForFile(fullpath)); + await createFile(destinationDir, filename); // TODO: select it in the Project tool window, if it was created from the Project tool window close(); editorManager.focus(); @@ -113,7 +94,7 @@ function NewFileNamePopup({ ); if (repoDir) { windowManager?.open(({ close }) => ( - + )); } }; @@ -121,74 +102,37 @@ function NewFileNamePopup({ [close] ); const [filename, setFilename] = useState(""); - const [errorMessage, setErrorMessage] = useState(""); - const [validationState, setValidationState] = useState<"valid" | "invalid">( - "valid" - ); + const [validationMessage, setValidationMessage] = useState(""); const submit = async () => { const fullPath = path.join(destinationDir, filename); - const exists = await fs.promises.exists(fullPath); - if (exists) { - const stats = await fs.promises.stat(fullPath); - setValidationState("invalid"); - if (stats.isFile()) { - setErrorMessage( - `A file with the name '${path.basename(filename)}' already exists` - ); - } else if (stats.isDirectory()) { - setErrorMessage( - `A directory with the name '${path.basename( - filename - )}' already exists` - ); - } + const stats = await stat(fullPath); + if (stats?.isFile()) { + setValidationMessage( + `A file with the name '${path.basename(filename)}' already exists` + ); + } else if (stats?.isDirectory()) { + setValidationMessage( + `A directory with the name '${path.basename(filename)}' already exists` + ); } else { - await createFile(fullPath); + await createFile(destinationDir, filename); } }; - return ( - - New File} - content={ -
{ - e.preventDefault(); - if (filename) { - submit(); // error handling? - } - }} - onMouseDown={() => { - setErrorMessage(""); - }} - > - - {errorMessage} - - } - > - ) => { - setValidationState("valid"); - setErrorMessage(""); - setFilename(e.target.value); - }} - /> - -
+ { + if (filename) { + submit(); // error handling? } - /> -
+ }} + value={filename} + onChange={(newValue) => { + setValidationMessage(""); + setFilename(newValue); + }} + validationMessage={validationMessage} + /> ); } diff --git a/packages/example-app/src/Project/fs-operations.ts b/packages/example-app/src/Project/fs-operations.ts index 751ba9c7..740ec568 100644 --- a/packages/example-app/src/Project/fs-operations.ts +++ b/packages/example-app/src/Project/fs-operations.ts @@ -49,14 +49,50 @@ export const deleteFilesCallback = return Promise.all(filePaths.map(deleteFile)); }; +export const deleteDirCallback = + (callbackInterface: CallbackInterface) => async (dir: string) => { + // TODO: use task API + const { refresh, reset, snapshot } = callbackInterface; + const deleteFile = deleteFileCallback(callbackInterface); + const releaseSnapshot = snapshot.retain(); + const recursivelyDeleteDir = async (dirname: string) => { + const pathnames = await fs.promises.readdir(dirname); + await Promise.all( + pathnames.map(async (pathname) => { + const fullpath = path.resolve(dirname, pathname); + const stat = await fs.promises.stat(fullpath); + if (stat.isFile()) { + // potential improvement: refreshing dir content could wait until all files are deleted. + await deleteFile(fullpath); + } else if (stat.isDirectory()) { + await recursivelyDeleteDir(fullpath); + } + }) + ); + await fs.promises.rmdir(dirname); + refresh(dirContentState(dir)); + }; + try { + try { + await recursivelyDeleteDir(dir); + } catch (e) { + console.error(`error in deleting directory ${dir}`, e); + } finally { + refresh(dirContentState(path.dirname(dir))); // TODO(fs.watch): better done separately using fs.watch + } + } finally { + releaseSnapshot(); + } + }; + export const createFileCallback = (callbackInterface: CallbackInterface) => { const refreshFileStatus = refreshFileStatusCallback(callbackInterface); - return async (filePath: string) => { + return async (destinationDir: string, filename: string) => { + const filePath = path.join(destinationDir, filename); const { snapshot, refresh, reset } = callbackInterface; const releaseSnapshot = snapshot.retain(); try { - const destinationDir = path.dirname(filePath); - await ensureDir(fs.promises, destinationDir); + await ensureDir(fs.promises, path.dirname(filePath)); if (await fs.promises.exists(filePath)) { throw new Error(`File ${filePath} already exists`); } @@ -70,3 +106,22 @@ export const createFileCallback = (callbackInterface: CallbackInterface) => { } }; }; + +export const createDirectoryCallback = ( + callbackInterface: CallbackInterface +) => { + return async (destination: string, dirPath: string) => { + const { snapshot, refresh, reset } = callbackInterface; + const releaseSnapshot = snapshot.retain(); + const fullpath = path.join(destination, dirPath); + try { + if (await fs.promises.exists(fullpath)) { + throw new Error(`File ${fullpath} already exists`); + } + await ensureDir(fs.promises, fullpath); + refresh(dirContentState(destination)); + } finally { + releaseSnapshot(); + } + }; +}; diff --git a/packages/example-app/src/Project/projectActionIds.ts b/packages/example-app/src/Project/projectActionIds.ts new file mode 100644 index 00000000..2607d100 --- /dev/null +++ b/packages/example-app/src/Project/projectActionIds.ts @@ -0,0 +1,11 @@ +export const projectActionIds = { + NewElement: "NewElement", + NewFile: "NewFile", + NewDir: "NewDir", + CopyReferencePopupGroup: "CopyReferencePopupGroup", + CopyPaths: "CopyPaths", + CopyFileReference: "CopyFileReference", + CopyAbsolutePath: "CopyAbsolutePath", + CopySourceRootPath: "CopySourceRootPath", + CopyFileName: "CopyFileName", +}; diff --git a/packages/example-app/src/Project/useProjectActions.tsx b/packages/example-app/src/Project/useProjectActions.tsx index 5f42505e..98b29aa1 100644 --- a/packages/example-app/src/Project/useProjectActions.tsx +++ b/packages/example-app/src/Project/useProjectActions.tsx @@ -3,10 +3,16 @@ import { ActionContext, ActionDefinition, CommonActionId, + useCreateDefaultActionGroup, } from "@intellij-platform/core"; import { createFileActionState } from "./actions/createFileAction"; import { searchEverywhereState } from "../SearchEverywhere/searchEverywhere.state"; +import { createDirectoryActionState } from "./actions/createDirectoryAction"; +import { projectActionIds } from "./projectActionIds"; +import { copyFilenameActionState } from "./actions/copyFilename"; +import { copyAbsolutePathActionState } from "./actions/copyAbsolutePath"; +import { copyPathFromRepositoryRootActionState } from "../VersionControl/actions/copyPathFromRepositoryRoot"; export function useProjectActions(): ActionDefinition[] { const openSearchEverywhere = useRecoilCallback( @@ -20,8 +26,41 @@ export function useProjectActions(): ActionDefinition[] { }, [] ); - + const createDefaultActionGroup = useCreateDefaultActionGroup(); const newFileAction = useRecoilValue(createFileActionState); + const newDirectoryAction = useRecoilValue(createDirectoryActionState); + const newElementActionGroup = createDefaultActionGroup({ + id: "NewElement", + title: "New...", + description: "Create new class, interface, file or directory", + isSearchable: true, + children: [newFileAction, newDirectoryAction], + }); + + const copyReferencePopupGroup = createDefaultActionGroup({ + id: projectActionIds.CopyReferencePopupGroup, + title: "Copy Path/Reference...", + menuPresentation: "none", + children: [ + // TODO: CopyFileReferences action group popup is not the default popup, it shows the copyable value as a hint + // segment in each menu item. A custom actionPerformed should be implemented. + createDefaultActionGroup({ + id: projectActionIds.CopyFileReference, + title: "Copy File Reference", // in the original impl, there is no title + isSearchable: false, + menuPresentation: "section", + children: [ + // FIXME: actions are not triggered via UI + useRecoilValue(copyAbsolutePathActionState), + useRecoilValue(copyFilenameActionState), + // FIXME: should be able to define divider here + // new Divider(), + useRecoilValue(copyPathFromRepositoryRootActionState), + ], + }), + ], + }); + return [ { id: CommonActionId.GO_TO_ACTION, @@ -39,6 +78,7 @@ export function useProjectActions(): ActionDefinition[] { openSearchEverywhere(event, "Files"); }, }, - newFileAction, + newElementActionGroup, + copyReferencePopupGroup, ]; } diff --git a/packages/example-app/src/ProjectView/ProjectToolWindow.tsx b/packages/example-app/src/ProjectView/ProjectToolWindow.tsx index d2a0472d..3b50abd0 100644 --- a/packages/example-app/src/ProjectView/ProjectToolWindow.tsx +++ b/packages/example-app/src/ProjectView/ProjectToolWindow.tsx @@ -17,6 +17,10 @@ import { ProjectViewPane } from "./ProjectViewPane"; import { activeEditorTabState } from "../Editor/editor.state"; import { ProjectViewActionIds } from "./ProjectViewActionIds"; import { deleteActionState } from "./actions/deleteAction"; +import { copyActionState } from "./actions/copyAction"; +import { cutActionState } from "./actions/cutAction"; +import { pasteActionState } from "./actions/pasteAction"; +import { notNull } from "@intellij-platform/core/utils/array-utils"; const { SELECT_IN_PROJECT_VIEW } = ProjectViewActionIds; @@ -55,7 +59,6 @@ function useProjectViewActions(): Array { selectPathInProjectView(activeTab.filePath); } }; - const deleteAction = useRecoilValue(deleteActionState); return [ { @@ -66,6 +69,9 @@ function useProjectViewActions(): Array { isDisabled: !activeTab, description: "Selects a context file in the Project View", }, - deleteAction, // could have been on project level, if it could handle delete in editor as well - ]; + useRecoilValue(deleteActionState), // could have been on project level, if it could handle delete in editor as well + useRecoilValue(cutActionState), + useRecoilValue(copyActionState), + useRecoilValue(pasteActionState), + ].filter(notNull); } diff --git a/packages/example-app/src/ProjectView/ProjectViewContextMenu.tsx b/packages/example-app/src/ProjectView/ProjectViewContextMenu.tsx new file mode 100644 index 00000000..97ea54e8 --- /dev/null +++ b/packages/example-app/src/ProjectView/ProjectViewContextMenu.tsx @@ -0,0 +1,38 @@ +import { + ActionContext, + ActionsMenu, + CommonActionId, + DividerItem, + useAction, +} from "@intellij-platform/core"; +import { notNull } from "@intellij-platform/core/utils/array-utils"; +import React from "react"; +import { projectActionIds } from "../Project/projectActionIds"; + +export function ProjectViewContextMenu({ + getActionContext, +}: { + getActionContext: () => ActionContext; +}) { + return ( + + ); +} diff --git a/packages/example-app/src/ProjectView/ProjectViewPane.tsx b/packages/example-app/src/ProjectView/ProjectViewPane.tsx index 5abfa3b4..f20e068d 100644 --- a/packages/example-app/src/ProjectView/ProjectViewPane.tsx +++ b/packages/example-app/src/ProjectView/ProjectViewPane.tsx @@ -1,4 +1,6 @@ import { + ActionContext, + ContextMenuContainer, HighlightedTextValue, Item, ItemLayout, @@ -30,6 +32,7 @@ import { } from "./ProjectView.state"; import { FileStatusColor } from "../VersionControl/FileStatusColor"; import { useLatestRecoilValue } from "../recoil-utils"; +import { ProjectViewContextMenu } from "./ProjectViewContextMenu"; export const ProjectViewPane = (): React.ReactElement => { const project = useRecoilValue(currentProjectState); @@ -54,56 +57,89 @@ export const ProjectViewPane = (): React.ReactElement => { ? [] // FIXME : [...selectedKeys].filter((i): i is string => typeof i === "string"); const activePathsProviderProps = useActivePathsProvider(selectedKeysArray); + const containerRef = useRef(null); + const contextMenuTargetKey = useRef(); + + /** + * A (temporary?) non-straightforward way to position popups (action groups with "none" menuPresentation") + * relative to the tree node content. + * It's implemented like this because the context menu API at the moment is too generic and disconnected from + * components like Tree. + * At minimum, this logic here can be extracted into a wrapper like TreeContextMenuContainer for reusability. + */ + const getActionContext = (): ActionContext => { + return { + element: + containerRef.current?.querySelector( + `[data-key="${contextMenuTargetKey.current}"]` + ) ?? null, + event: null, + }; + }; return ( {treeState?.root && ( - { - editor.openPath(`${path}`); + + (contextMenuTargetKey.current = + target?.closest("[data-key]")?.dataset.key) + } + renderMenu={() => { + return ( + + ); }} - fillAvailableSpace - autoFocus - selectionMode="multiple" - selectedKeys={selectedKeys} - onSelectionChange={setSelectedKeys} - expandedKeys={expandedKeys} - onExpandedChange={setExpandedKeys} > - {(item) => ( - - - {} - {item.type === "project" ? ( - <> - - - - {project.path} - - ) : ( - - )} - {/* {"loadingState" in item && + { + editor.openPath(`${path}`); + }} + fillAvailableSpace + autoFocus + selectionMode="multiple" + selectedKeys={selectedKeys} + onSelectionChange={setSelectedKeys} + expandedKeys={expandedKeys} + onExpandedChange={setExpandedKeys} + > + {(item) => ( + + + {} + {item.type === "project" ? ( + <> + + + + {project.path} + + ) : ( + + )} + {/* {"loadingState" in item && item.loadingState === "loading" && ( )}*/} - - - )} - + + + )} + + )} ); diff --git a/packages/example-app/src/ProjectView/actions/copyAction.tsx b/packages/example-app/src/ProjectView/actions/copyAction.tsx new file mode 100644 index 00000000..b4e23652 --- /dev/null +++ b/packages/example-app/src/ProjectView/actions/copyAction.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { selector } from "recoil"; +import { + ActionDefinition, + CommonActionId, + PlatformIcon, +} from "@intellij-platform/core"; + +import { notImplemented } from "../../Project/notImplemented"; +import { activePathExistsState } from "../../Project/project.state"; + +export const copyActionState = selector({ + key: `action.${CommonActionId.COPY}`, + get: ({ get, getCallback }): ActionDefinition => ({ + id: CommonActionId.COPY, + title: "Copy", + description: "Copy to clipboard", + icon: , + isDisabled: !get(activePathExistsState), + actionPerformed: getCallback(({ snapshot }) => async () => { + notImplemented(); + }), + }), +}); diff --git a/packages/example-app/src/ProjectView/actions/cutAction.tsx b/packages/example-app/src/ProjectView/actions/cutAction.tsx new file mode 100644 index 00000000..a6536bff --- /dev/null +++ b/packages/example-app/src/ProjectView/actions/cutAction.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { selector } from "recoil"; +import { + ActionDefinition, + CommonActionId, + PlatformIcon, +} from "@intellij-platform/core"; + +import { notImplemented } from "../../Project/notImplemented"; +import { activePathExistsState } from "../../Project/project.state"; + +export const cutActionState = selector({ + key: `action.${CommonActionId.CUT}`, + get: ({ get, getCallback }): ActionDefinition => ({ + id: CommonActionId.CUT, + title: "Cut", + description: "Cut to clipboard", + icon: , + isDisabled: !get(activePathExistsState), + actionPerformed: getCallback(({ snapshot }) => async () => { + notImplemented(); + }), + }), +}); diff --git a/packages/example-app/src/ProjectView/actions/deleteAction.tsx b/packages/example-app/src/ProjectView/actions/deleteAction.tsx index 47997ff0..646776ab 100644 --- a/packages/example-app/src/ProjectView/actions/deleteAction.tsx +++ b/packages/example-app/src/ProjectView/actions/deleteAction.tsx @@ -20,7 +20,11 @@ import { } from "../../Project/project.state"; import { selectedNodesState } from "../ProjectView.state"; import { findRootPaths } from "../../path-utils"; -import { deleteFilesCallback } from "../../Project/fs-operations"; +import { + deleteDirCallback, + deleteFileCallback, + deleteFilesCallback, +} from "../../Project/fs-operations"; import { IntlMessageFormat } from "intl-messageformat"; const fileCountMsg = new IntlMessageFormat( @@ -40,13 +44,15 @@ const dirCountMsg = new IntlMessageFormat( ); export const deleteActionState = selector({ - key: `action.${CommonActionId.Delete}`, + key: `action.${CommonActionId.DELETE}`, get: ({ get, getCallback }): ActionDefinition => ({ - id: CommonActionId.Delete, + id: CommonActionId.DELETE, title: "Delete", description: "Delete selected item", isDisabled: !get(activePathExistsState), actionPerformed: getCallback(({ snapshot }) => async () => { + const deleteDir = getCallback(deleteDirCallback); + const deleteFile = getCallback(deleteFileCallback); const selectedNodes = snapshot.getLoadable(selectedNodesState).getValue(); const windowManager = snapshot .getLoadable(windowManagerRefState) @@ -98,7 +104,8 @@ export const deleteActionState = selector({ ), }); if (confirmed) { - notImplemented(); + directories.forEach((pathname) => deleteDir(pathname)); + filePaths.forEach((pathname) => deleteFile(pathname)); } } else { windowManager?.open(({ close }) => ( diff --git a/packages/example-app/src/ProjectView/actions/pasteAction.tsx b/packages/example-app/src/ProjectView/actions/pasteAction.tsx new file mode 100644 index 00000000..a252108d --- /dev/null +++ b/packages/example-app/src/ProjectView/actions/pasteAction.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { selector } from "recoil"; +import { + ActionDefinition, + CommonActionId, + PlatformIcon, +} from "@intellij-platform/core"; + +import { notImplemented } from "../../Project/notImplemented"; +import { activePathExistsState } from "../../Project/project.state"; + +export const pasteActionState = selector({ + key: `action.${CommonActionId.PASTE}`, + get: ({ get, getCallback }): ActionDefinition => ({ + id: CommonActionId.PASTE, + title: "Paste", + description: "Paste from clipboard", + icon: , + isDisabled: !get(activePathExistsState), + actionPerformed: getCallback(({ snapshot }) => async () => { + notImplemented(); + }), + }), +}); diff --git a/packages/example-app/src/VersionControl/Branches/CreateNewBranchWindow.tsx b/packages/example-app/src/VersionControl/Branches/CreateNewBranchWindow.tsx index a3a6e12b..ca75e45b 100644 --- a/packages/example-app/src/VersionControl/Branches/CreateNewBranchWindow.tsx +++ b/packages/example-app/src/VersionControl/Branches/CreateNewBranchWindow.tsx @@ -61,7 +61,7 @@ export function CreateNewBranchWindow({ close }: { close: () => void }) { const error = validateBranchName(branches, branchName); const isValid = !error || (error === "EXISTING" && overwrite); - const validationState = !isValid && isErrorVisible ? "invalid" : "valid"; + const validationState = !isValid && isErrorVisible ? "error" : "valid"; const create = () => { if (isValid) { @@ -113,8 +113,8 @@ export function CreateNewBranchWindow({ close }: { close: () => void }) { setIsErrorVisible(true); }} validationState={validationState} - errorMessage={ - validationState === "invalid" && + validationMessage={ + validationState === "error" && error && ErrorMessages[error](branchName) } @@ -144,7 +144,7 @@ export function CreateNewBranchWindow({ close }: { close: () => void }) { variant="default" type="submit" form="create_branch_form" // Using form in absence of built-in support for default button - isDisabled={validationState === "invalid"} + isDisabled={validationState === "error"} > Create diff --git a/packages/example-app/src/VersionControl/Branches/RenameBranchWindow.tsx b/packages/example-app/src/VersionControl/Branches/RenameBranchWindow.tsx index 772e8c3d..8cef8194 100644 --- a/packages/example-app/src/VersionControl/Branches/RenameBranchWindow.tsx +++ b/packages/example-app/src/VersionControl/Branches/RenameBranchWindow.tsx @@ -55,7 +55,7 @@ export function RenameBranchWindow({ const renameBranch = useRenameBranch(); const error = validateBranchName(branches, newBranchName); - const validationState = error && touched ? "invalid" : "valid"; + const validationState = error && touched ? "error" : "valid"; const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -93,8 +93,8 @@ export function RenameBranchWindow({ setTouched(true); }} validationState={validationState} - errorMessage={ - error && validationState === "invalid" + validationMessage={ + error && validationState === "error" ? ErrorMessages[error]?.(branchName) : undefined } diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangeListsActionButton.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangeListsActionButton.tsx index 379ca91f..8e002c33 100644 --- a/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangeListsActionButton.tsx +++ b/packages/example-app/src/VersionControl/Changes/ChangesView/ActionButtons/ChangeListsActionButton.tsx @@ -1,11 +1,12 @@ import React from "react"; import { + ActionsMenu, IconButtonWithMenu, PlatformIcon, + SpeedSearchMenu, useAction, } from "@intellij-platform/core"; import { VcsActionIds } from "../../../VcsActionIds"; -import { ActionsMenu } from "@intellij-platform/core"; import { notNull } from "@intellij-platform/core/utils/array-utils"; export const ChangeListsActionButton = (): React.ReactElement => { @@ -20,7 +21,11 @@ export const ChangeListsActionButton = (): React.ReactElement => { return ( ( - + + {(actionMenuProps) => ( + + )} + )} > diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/useChangesViewActions.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/useChangesViewActions.tsx index 0cf43e6a..fce8df21 100644 --- a/packages/example-app/src/VersionControl/Changes/ChangesView/useChangesViewActions.tsx +++ b/packages/example-app/src/VersionControl/Changes/ChangesView/useChangesViewActions.tsx @@ -43,9 +43,9 @@ const deletablePathsUnderChangesTreeSelectionState = selector({ }); const deleteActionState = selector({ - key: `vcs.changesView.action.${CommonActionId.Delete}`, + key: `vcs.changesView.action.${CommonActionId.DELETE}`, get: ({ get, getCallback }): ActionDefinition => ({ - id: CommonActionId.Delete, + id: CommonActionId.DELETE, title: "Delete", description: "Delete selected item", isDisabled: get(deletablePathsUnderChangesTreeSelectionState).length === 0, diff --git a/packages/example-app/src/VersionControl/VcsActionIds.tsx b/packages/example-app/src/VersionControl/VcsActionIds.tsx index fe22376c..8df17af8 100644 --- a/packages/example-app/src/VersionControl/VcsActionIds.tsx +++ b/packages/example-app/src/VersionControl/VcsActionIds.tsx @@ -38,4 +38,6 @@ export const VcsActionIds = { SHOW_DIFF_PREVIEW: "Vcs.Log.ShowDiffPreview", PRESENTATION_SETTINGS: "Vcs.PresentationSettings", FILE_HISTORY_PRESENTATION_SETTINGS: "Vcs.FileHistory.PresentationSettings", + + CopyPathFromRepositoryRootProvider: "CopyPathFromRepositoryRootProvider", }; diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/VcsLogDetailsView.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/VcsLogDetailsView.tsx index d53996ab..1e4f98bd 100644 --- a/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/VcsLogDetailsView.tsx +++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/DetailsView/VcsLogDetailsView.tsx @@ -1,4 +1,4 @@ -import React, { HTMLAttributes, useMemo } from "react"; +import React, { HTMLAttributes } from "react"; import { atom, isRecoilValue, @@ -10,7 +10,6 @@ import { } from "recoil"; import { ActionButton, - ActionDefinition, ActionGroupMenu, ActionsProvider, ActionTooltip, @@ -24,7 +23,6 @@ import { Toolbar, TooltipTrigger, useActionGroup, - useActions, useCreateDefaultActionGroup, useTreeActions, } from "@intellij-platform/core"; @@ -42,6 +40,7 @@ import { } from "../vcs-logs.state"; import { VcsActionIds } from "../../VcsActionIds"; import { notNull } from "@intellij-platform/core/utils/array-utils"; +import { useRedefineAction } from "../../../useRedefineAction"; const splitViewSizeState = atom({ key: "vcs/toolwindow/splitViewSize", @@ -101,7 +100,7 @@ export function VcsLogDetailsView({ tabKey }: { tabKey: string }) { createActionGroup({ id: GROUP_BY_ACTION_GROUP_ID, title: "Group By", - presentation: "titledSection", + menuPresentation: "titledSection", icon: , children: availableGroupings.map((grouping) => ({ id: groupingActionId(grouping), @@ -114,7 +113,7 @@ export function VcsLogDetailsView({ tabKey }: { tabKey: string }) { }), createActionGroup({ id: LAYOUT_ACTION_GROUP_ID, - presentation: "titledSection", + menuPresentation: "titledSection", title: "Layout", children: [ useRedefineAction( @@ -220,39 +219,15 @@ function VcsLogsDetailsViewOptionsMenu({ const selectedKeys = useRecoilValue(selectedKeysState); return ( group && ( - + + {(actionMenuProps) => ( + + )} + ) ); } - -/** - * Currently, action groups are expected to **define** the child actions, instead of just referencing already defined - * actions. In such model, where groups are not just a grouping of existing actions, this hook allows for redefining - * existing actions to be used within a group. Kind of a temporary solution while action system evolves. - */ -function useRedefineAction( - actionId: string, - newId: string -): ActionDefinition | null { - const actions = useActions(); - - const action = useMemo(() => actions.find(({ id }) => id === actionId), []); - if (!action) { - return null; - } - const { perform, id, ...commonProperties } = action; - return { - ...commonProperties, - id: newId, - useShortcutsOf: id, - isSearchable: false, - actionPerformed: (context) => { - perform(context); - }, - }; -} diff --git a/packages/example-app/src/VersionControl/actions/copyPathFromRepositoryRoot.tsx b/packages/example-app/src/VersionControl/actions/copyPathFromRepositoryRoot.tsx new file mode 100644 index 00000000..2618cc30 --- /dev/null +++ b/packages/example-app/src/VersionControl/actions/copyPathFromRepositoryRoot.tsx @@ -0,0 +1,25 @@ +import { selector } from "recoil"; +import { ActionDefinition } from "@intellij-platform/core"; + +import { + activePathExistsState, + activePathsState, +} from "../../Project/project.state"; +import { notImplemented } from "../../Project/notImplemented"; +import { VcsActionIds } from "../VcsActionIds"; + +export const copyPathFromRepositoryRootActionState = selector({ + key: `action.${VcsActionIds.CopyPathFromRepositoryRootProvider}`, + get: ({ get, getCallback }): ActionDefinition => ({ + id: VcsActionIds.CopyPathFromRepositoryRootProvider, + title: "Path From Repository Root", + isDisabled: !get(activePathExistsState), + actionPerformed: getCallback(({ snapshot, refresh }) => async () => { + notImplemented(); + const activePaths = snapshot.getLoadable(activePathsState).getValue(); + if (activePaths.length === 0) { + return; + } + }), + }), +}); diff --git a/packages/example-app/src/exampleAppKeymap.ts b/packages/example-app/src/exampleAppKeymap.ts index 2aa89d0c..2dcd9769 100644 --- a/packages/example-app/src/exampleAppKeymap.ts +++ b/packages/example-app/src/exampleAppKeymap.ts @@ -131,4 +131,22 @@ export const exampleAppKeymap: Keymap = { }, }, ], + CopyPaths: [ + { + type: "keyboard", + firstKeyStroke: { + modifiers: ["Meta", "Shift"], + code: "KeyC", + }, + }, + ], + NewElement: [ + { + type: "keyboard", + firstKeyStroke: { + modifiers: ["Meta"], + code: "KeyN", + }, + }, + ], }; diff --git a/packages/example-app/src/fs/fs-utils.ts b/packages/example-app/src/fs/fs-utils.ts index f8fb762c..45e683e5 100644 --- a/packages/example-app/src/fs/fs-utils.ts +++ b/packages/example-app/src/fs/fs-utils.ts @@ -5,6 +5,7 @@ import FS from "browserfs/dist/node/core/FS"; import { PromisifiedFS } from "./browser-fs"; // @ts-expect-error caf doesn't have typing :/ import { CAF } from "caf"; +import { fs } from "./fs"; type CopyFsParams = { source: FS; @@ -103,3 +104,11 @@ export async function ensureDir(fs: PromisifiedFS, dirPath: string) { throw new Error(`path is not a directory, but already exists: ${dirPath}`); } } + +/** + * A wrapper around stat which doesn't throw if nothing exists at the input pathname. + * It provides a more ergonomic signature for use cases where it's required to know if + * a file or directory exists on the pathname, distinguishing the two cases. + */ +export const stat = (pathname: string) => + fs.promises.stat(pathname).catch(() => null); diff --git a/packages/example-app/src/recoil-utils.ts b/packages/example-app/src/recoil-utils.ts index 8e8cb97a..5fca68d4 100644 --- a/packages/example-app/src/recoil-utils.ts +++ b/packages/example-app/src/recoil-utils.ts @@ -36,7 +36,7 @@ export const createFocusBasedSetterHook = ( onBlur: (e: FocusEvent) => { if ( e.relatedTarget instanceof Element && - e.relatedTarget?.closest("[data-overlay-container]") + e.relatedTarget?.closest("[data-overlay-container], [role=menu]") ) { // hacky and probably not so reliable way to work around this issue: // There are actions that depend on focus-based contextual value. Like "Commit file/folder" action that diff --git a/packages/example-app/src/useCancelableAsyncCallback.ts b/packages/example-app/src/useCancelableAsyncCallback.ts new file mode 100644 index 00000000..8ecd6ff1 --- /dev/null +++ b/packages/example-app/src/useCancelableAsyncCallback.ts @@ -0,0 +1,29 @@ +import { useRef } from "react"; +// @ts-expect-error caf package doesn't come with typing, and @types/caf doesn't exist at the moment +import { CAF } from "caf"; + +/** + * A utility to avoid race condition in async flows that should be triggered based on some user interaction. + * Accepts a generator function representing a cancelable async flow. Each `yield` expression is a potential point of + * cancellation, and the last invocation always cancels previous in progress invocations (if any). + * Based on [Cancelable Async Flows](https://github.com/getify/CAF#cancelable-async-flows-caf) + */ +export const useCancelableAsyncCallback = ( + asyncCallback: (...args: A) => Generator +) => { + const cancelToken = useRef void; signal: unknown }>( + null + ); + + const callbackRef = useRef(asyncCallback); + callbackRef.current = asyncCallback; + + return (...args: any[]) => { + cancelToken.current?.abort(); + cancelToken.current = new CAF.cancelToken(); + + CAF(function (signal: unknown, ...args: A): unknown { + return callbackRef.current(...args); + })(cancelToken.current, ...args); + }; +}; diff --git a/packages/example-app/src/useRedefineAction.tsx b/packages/example-app/src/useRedefineAction.tsx new file mode 100644 index 00000000..c4e0bfaa --- /dev/null +++ b/packages/example-app/src/useRedefineAction.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import { ActionDefinition, useActions } from "@intellij-platform/core"; + +/** + * Currently, action groups are expected to **define** the child actions, instead of just referencing already defined + * actions. In such model, where groups are not just a grouping of existing actions, this hook allows for redefining + * existing actions to be used within a group. Kind of a temporary solution while action system evolves. + */ +export function useRedefineAction( + actionId: string, + newId: string +): ActionDefinition | null { + const actions = useActions(); + const action = actions.find(({ id }) => id === actionId); + + return useMemo(() => { + if (!action) { + return null; + } + const { perform, id, ...commonProperties } = action; + return { + ...commonProperties, + id: newId, + useShortcutsOf: id, + isSearchable: false, + actionPerformed: (context) => { + perform(context); + }, + }; + }, [action]); +} diff --git a/packages/jui/cypress.config.ts b/packages/jui/cypress.config.ts index ac80cb94..f7ee08b4 100644 --- a/packages/jui/cypress.config.ts +++ b/packages/jui/cypress.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ // eslint-disable-next-line no-undef process.env.CI === "true" ? "http://localhost:1234/" // example-app serve port - : "http://localhost:6008/iframe.html?id=demos-example-app--example-app", + : "http://localhost:6008/iframe.html?id=demos-example-app--example-app&viewMode=story", setupNodeEvents(on, config) { initPlugin(on, config); addPlaybackTask(on, config); diff --git a/packages/jui/cypress/e2e/file-actions.cy.ts b/packages/jui/cypress/e2e/file-actions.cy.ts index 74f2108f..ada2329d 100644 --- a/packages/jui/cypress/e2e/file-actions.cy.ts +++ b/packages/jui/cypress/e2e/file-actions.cy.ts @@ -1,4 +1,4 @@ -import { gitInit } from "../support/example-app"; +import { dir, file, gitInit } from "../support/example-app"; beforeEach(() => { // cy.playback("GET", /https:\/\/raw\.githubusercontent.com/); @@ -12,20 +12,39 @@ const deleteFile = (filename: string) => { cy.findByRole("treeitem", { name: new RegExp(filename) }).should("not.exist"); }; +function createFileWithoutVcs(filename: string) { + const basename = filename.split("/").slice(-1)[0]; + + cy.step(`Create ${filename}`); + cy.createFile(filename); + cy.findByRole("tab", { name: basename, selected: true }); // The new file opened in the editor + cy.get("textarea").should("be.focus").realType("Test content"); // Editor focused +} + +function createDirectory(pathname: string) { + cy.step(`Create ${pathname}`); + cy.findByRole("tree", { name: "Project structure tree" }) + .findAllByRole("treeitem", { name: /workspace/i }) + .first() + .click() + .should("be.focused"); + + cy.searchAndInvokeAction("Directory", "create directory"); + cy.findByPlaceholderText("Name").should("be.focused"); + cy.realType(pathname, { delay: 1 }); + cy.realPress("Enter"); +} + describe("files actions", () => { - it("can create, delete and recreate a file without vcs", () => { + beforeEach(() => { Cypress.on("uncaught:exception", (err, runnable) => { return !err.message.includes( "NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'" ); }); + }); - function createFileWithoutVcs(filename: string) { - cy.step(`Create ${filename}`); - cy.createFile(filename); - cy.findByRole("tab", { name: filename, selected: true }); // The new file opened in the editor - cy.get("textarea").should("be.focus").realType("Test content"); // Editor focused - } + it("can create, delete and recreate a file without vcs", () => { cy.initialization(); createFileWithoutVcs("test.ts"); @@ -48,12 +67,6 @@ describe("files actions", () => { }); it("file creation and deletion, with vcs enabled", () => { - Cypress.on("uncaught:exception", (err, runnable) => { - return !err.message.includes( - "NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'" - ); - }); - cy.initialization(gitInit()); cy.step(`Create test.ts`); @@ -103,4 +116,50 @@ describe("files actions", () => { cy.percySnapshot(); // To check file statuses }); + + it("can delete directories", () => { + cy.initialization( + dir("d1", [dir("d2", [dir("d3", [file("f1.ts")])])]), + dir("d4", [dir("d5", [dir("d6")])]), + file("f2.ts") + ); + cy.findByRole("button", { name: "Expand All" }).click(); + + cy.findTreeNodeInProjectView("f1.ts").ctrlClick(); + cy.findTreeNodeInProjectView("f2.ts").ctrlClick(); + cy.findTreeNodeInProjectView("d3").ctrlClick(); + cy.findTreeNodeInProjectView("d1").ctrlClick(); + cy.findTreeNodeInProjectView("d5").ctrlClick(); + cy.realPress("Backspace"); + cy.findByRole("button", { name: "Delete" }).click(); + cy.findTreeNodeInProjectView("d4").should("exist"); + cy.findTreeNodeInProjectView("d1").should("not.exist"); + cy.findTreeNodeInProjectView("d2").should("not.exist"); + cy.findTreeNodeInProjectView("d3").should("not.exist"); + cy.findTreeNodeInProjectView("d5").should("not.exist"); + cy.findTreeNodeInProjectView("f1.ts").should("not.exist"); + cy.findTreeNodeInProjectView("f2.ts").should("not.exist"); + }); + + it("can create nested directories when creating a new file", () => { + cy.initialization(); + + createFileWithoutVcs("foo/bar/baz/test.ts"); + + // project view should be updated + cy.findTreeNodeInProjectView("foo").dblclick(); // it opens all nested children since they are the only child + cy.findTreeNodeInProjectView("bar"); + cy.findTreeNodeInProjectView("baz"); + cy.findTreeNodeInProjectView("test.ts"); + }); + + it("can create nested directories when creating a new directory", () => { + cy.initialization(); + + createDirectory("foo/bar/baz"); + + cy.findTreeNodeInProjectView("foo").dblclick(); // it opens all nested children since they are the only child + cy.findTreeNodeInProjectView("bar"); + cy.findTreeNodeInProjectView("baz"); + }); }); diff --git a/packages/jui/cypress/integration/popup-and-menu.cy.tsx b/packages/jui/cypress/integration/popup-and-menu.cy.tsx index 11ac14db..7af6b6dc 100644 --- a/packages/jui/cypress/integration/popup-and-menu.cy.tsx +++ b/packages/jui/cypress/integration/popup-and-menu.cy.tsx @@ -1,6 +1,17 @@ import React from "react"; -import { Button, Popup, PopupTrigger } from "@intellij-platform/core"; +import { + Button, + IconButton, + Item, + Menu, + MenuTrigger, + PlatformIcon, + Popup, + PopupManager, + PopupTrigger, + usePopupManager, +} from "@intellij-platform/core"; import { MenuPopupContent } from "@intellij-platform/core/Popup/story-helpers"; describe("Popup and menu integration", () => { @@ -34,4 +45,55 @@ describe("Popup and menu integration", () => { .click(); cy.wrap(onAction).should("be.calledTwice"); }); + + it("supports opening a popup as the action of a menu item", () => { + const Example = () => { + const popupManager = usePopupManager(); + return ( + ( + { + popupManager.show( + + + Menu in popup + + } + header="My popup header" + /> + + ); + }} + {...menuProps} + aria-label="Test Menu" + > + Open a popup + + )} + > + {(triggerProps, ref) => ( + + + + )} + + ); + }; + cy.mount( + + + + ); + cy.findByRole("button", { name: "Open menu" }).click(); + cy.contains("Open a popup").click(); + cy.contains("My popup"); + }); }); diff --git a/packages/jui/cypress/support/example-app/initializers.ts b/packages/jui/cypress/support/example-app/initializers.ts index 259affd5..7d0c41a2 100644 --- a/packages/jui/cypress/support/example-app/initializers.ts +++ b/packages/jui/cypress/support/example-app/initializers.ts @@ -178,7 +178,7 @@ export function gitAdd(...fileChanges: FileChange[]): Change { * @param dirname directory name to create * @param changes further changes to run within the context of the created directory. */ -export function dir(dirname: string, changes: Change[]): Change { +export function dir(dirname: string, changes: Change[] = []): Change { return async (args, context) => { const { fs, path, projectDir } = args; const dir = path.join(context?.dir ?? projectDir, dirname); diff --git a/packages/jui/src/ActionSystem/ActionGroup.tsx b/packages/jui/src/ActionSystem/ActionGroup.tsx index b2099b1e..371d10b3 100644 --- a/packages/jui/src/ActionSystem/ActionGroup.tsx +++ b/packages/jui/src/ActionSystem/ActionGroup.tsx @@ -6,11 +6,22 @@ import { export type ActionInResolvedGroup = Action & { parent: ResolvedActionGroup }; /** - * - `popup`: shown as submenu (isPopup property in ActionGroup in the reference impl) - * - `section`: a section with divider, but without section title - * - `titledSection`: a section with divider and title. + * Different ways to show a group of actions in a menu. + * - `submenu`: renders children as submenu (corresponding, in the reference impl, to `isPopup` property of ActionGroup + * being set to `true` and 'SUPPRESS_SUBMENU' clientProperty not being set) + * - `none`: renders the action group as a simple menu item, not rendering its children at all. + * The action group will be performed, which typically opens a popup (see {@link useCreateDefaultActionGroup}), + * showing the children. + * (corresponding, in the reference impl, to `isPopup` property and 'SUPPRESS_SUBMENU' clientProperty being set + * to true on the ActionGroup) + * - `section`: renders children in a section with divider, but without section title + * - `titledSection`: renders children in a section with divider and title. */ -type ActionGroupPresentation = "section" | "titledSection" | "popup"; +type ActionGroupMenuPresentation = + | "section" + | "titledSection" + | "none" + | "submenu"; export interface MutableActionGroup extends Action { children: Action[]; @@ -21,7 +32,7 @@ export interface MutableActionGroup extends Action { /** * How the action group should be rendered, in menus. */ - presentation?: ActionGroupPresentation; + menuPresentation?: ActionGroupMenuPresentation; } export type ActionGroup = Readonly; @@ -32,9 +43,11 @@ export interface ResolvedActionGroup extends ActionGroup { export interface ActionGroupDefinition extends ActionDefinition { children: ActionDefinition[]; // Should DividerItem be supported first-class here? /** - * If the action group should be rendered as a popup (submenu), in menus. + * Defines how the action group should be represented in menus. + * @default 'submenu' + * @see ActionGroupMenuPresentation */ - presentation?: ActionGroupPresentation; + menuPresentation?: ActionGroupMenuPresentation; } export function isInResolvedActionGroup( diff --git a/packages/jui/src/ActionSystem/CommonActionIds.ts b/packages/jui/src/ActionSystem/CommonActionIds.ts index 651f9bd2..f57b1084 100644 --- a/packages/jui/src/ActionSystem/CommonActionIds.ts +++ b/packages/jui/src/ActionSystem/CommonActionIds.ts @@ -10,5 +10,8 @@ export const CommonActionId = { SHOW_SEARCH_HISTORY: "ShowSearchHistory", COPY_REFERENCE: "CopyReference", REFRESH: "Refresh", - Delete: "$Delete", + COPY: "$Copy", + CUT: "$Cut", + PASTE: "$Paste", + DELETE: "$Delete", }; diff --git a/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx b/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx index 0cf10ee5..158bdfea 100644 --- a/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx +++ b/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx @@ -8,6 +8,11 @@ import { import { notNull } from "@intellij-platform/core/utils/array-utils"; describe("ActionsMenu", () => { + it("passes aria-label down to the menu component", () => { + cy.mount(); + cy.findByRole("menu", { name: "My action menu" }); + }); + it("renders menu item for actions", () => { const action1 = cy.stub().as("Action 1"); const action2 = cy.stub().as("Action 1"); @@ -42,7 +47,7 @@ describe("ActionsMenu", () => { cy.findByRole("menuitem", { name: "Action 2" }); }); - it("renders nested menu for groups with presentation set to 'popup'", () => { + it("renders nested menu for groups with menuPresentation set to 'submenu'", () => { const actionGroup1 = cy.stub().as("Action Group 1"); const action1 = cy.stub().as("Action 1"); const MyActionMenu = () => { @@ -57,7 +62,7 @@ describe("ActionsMenu", () => { id: "Action Group 1", title: "Action Group 1", actionPerformed: actionGroup1, - presentation: "popup", + menuPresentation: "submenu", children: [ { id: "Action 1", @@ -77,7 +82,7 @@ describe("ActionsMenu", () => { cy.findByRole("menuitem", { name: "Action 1" }); }); - it("renders menu section for groups with presentation is not set to 'section'", () => { + it("renders menu section for groups with menuPresentation set to 'section'", () => { const actionGroup1 = cy.stub().as("Action Group 1"); const action1 = cy.stub().as("Action 1"); const MyActionMenu = () => { @@ -92,7 +97,7 @@ describe("ActionsMenu", () => { id: "Action Group 1", title: "Action Group 1", actionPerformed: actionGroup1, - presentation: "section", + menuPresentation: "section", children: [ { id: "Action 1", @@ -110,6 +115,41 @@ describe("ActionsMenu", () => { cy.findByRole("group"); }); + it("triggers the action group when menuPresentation set to 'none'", () => { + const actionGroup1 = cy.stub().as("Action Group 1"); + const action1 = cy.stub().as("Action 1"); + const MyActionMenu = () => { + return ( + + ); + }; + cy.mount( + + {() => } + + ); + cy.findByRole("menuitem", { name: "Action 1" }).should("not.exist"); + cy.findByRole("group", { name: "Action Group 1" }).should("not.exist"); + cy.findByRole("menuitem", { name: "Action Group 1" }).click(); + cy.wrap(actionGroup1).should("be.calledOnce"); + }); + it("performs selected action", () => { const action1 = cy.stub().as("Action 1"); const action2 = cy.stub().as("Action 2"); @@ -124,7 +164,7 @@ describe("ActionsMenu", () => { { id: "group 2", title: "Group 2 (not an action group)", - presentation: "popup", + menuPresentation: "submenu", children: [useAction("Action 2")], } as ActionItem, ].filter(notNull)} @@ -143,7 +183,7 @@ describe("ActionsMenu", () => { id: "Action Group 1", title: "Action Group 1", actionPerformed: actionGroup1, - presentation: "popup", + menuPresentation: "submenu", children: [ { id: "Action Group 1 - Action 1", diff --git a/packages/jui/src/ActionSystem/components/ActionsMenu.tsx b/packages/jui/src/ActionSystem/components/ActionsMenu.tsx index d22f29be..0a672ae3 100644 --- a/packages/jui/src/ActionSystem/components/ActionsMenu.tsx +++ b/packages/jui/src/ActionSystem/components/ActionsMenu.tsx @@ -4,11 +4,14 @@ import { Menu, MenuItemLayout, MenuProps } from "@intellij-platform/core/Menu"; import { Divider, Item, Section } from "@intellij-platform/core/Collections"; import { DividerItem } from "@intellij-platform/core/Collections/Divider"; // Importing from /Collections breaks the build for some reason import { type ActionGroup } from "@intellij-platform/core/ActionSystem/ActionGroup"; -import { type Action } from "@intellij-platform/core/ActionSystem/Action"; +import { + type Action, + ActionContext, +} from "@intellij-platform/core/ActionSystem/Action"; type ActionGroupAsMenuItem = Pick< ActionGroup, - "id" | "icon" | "title" | "isDisabled" | "children" | "presentation" + "id" | "icon" | "title" | "isDisabled" | "children" | "menuPresentation" >; export type ActionItem = ActionGroupAsMenuItem | Action | DividerItem; @@ -16,31 +19,44 @@ function isAction(item: ActionItem): item is Action { return "perform" in item; } +type ControlledMenuProps = Pick< + MenuProps, + "onAction" | "disabledKeys" | "items" | "children" +>; +type RenderMenu = (props: ControlledMenuProps) => React.ReactNode; export type ActionMenuProps = { - selectedKeys?: string[]; - menuProps?: React.HTMLAttributes; - menuComponent?: React.ComponentType< - Pick< - MenuProps, - | "onAction" - | "selectedKeys" - | "disabledKeys" - | "items" - | "autoFocus" - | "children" - > - >; actions: Array; -}; + /** + * Context with which actions should be performed. + * Usually the context by which the ActionsMenu itself is opened. + * Pass a function for lazy evaluation when the action is selected from the menu. + */ + actionContext?: ActionContext | (() => ActionContext); + /** + * Allows for rendering a custom menu component, e.g. {@link SpeedSearchMenu}. + * If not provided, {@link Menu} is rendered, receiving additional props that + * are passed to `ActionsMenu`. + * If it is provided, additional {@link Menu} props are not allowed, and they + * can be passed directly to the returned menu element. + */ + children?: RenderMenu; +} & ( + | { + children: RenderMenu; + } + | (Omit, keyof ControlledMenuProps> & { + children?: never; + }) +); /** * Given a nested list of resolved actions, renders a menu corresponding to them. */ export function ActionsMenu({ actions, - selectedKeys, - menuProps, - menuComponent: MenuComponent = Menu, + actionContext, + children = (actionMenuProps) => , + ...otherProps }: ActionMenuProps) { const allActions = getAllActions(actions); const disabledKeys = allActions @@ -48,26 +64,29 @@ export function ActionsMenu({ .map(({ id }) => id); return ( - { - const action = allActions.find(({ id }) => id === key); - if (action && isAction(action)) { - action.perform(); // TODO: pass context, containing the menu item as `element` - } - }} - selectedKeys={selectedKeys} // FIXME: keep isSelected on actions (toggle action)? - disabledKeys={disabledKeys} - items={actions} - autoFocus - > - {(action) => { - if (action instanceof DividerItem) { - return ; - } - return renderActionAsMenuItem(action); - }} - + <> + {children({ + onAction: (key) => { + const action = allActions.find(({ id }) => id === key); + if (action && isAction(action)) { + action.perform( + typeof actionContext === "function" + ? actionContext?.() + : actionContext + ); + } + }, + disabledKeys, + // FIXME: keep isSelected on actions (toggle action) and control selectedKeys too? + items: actions, + children: (action) => { + if (action instanceof DividerItem) { + return ; + } + return renderActionAsMenuItem(action); + }, + })} + ); } @@ -77,17 +96,21 @@ export function renderActionAsMenuItem( action: ActionAsMenuItem | ActionGroupAsMenuItem ) { const isGroup = "children" in action; - if (isGroup && action.presentation !== "popup") { + if ( + isGroup && + (action.menuPresentation === "section" || + action.menuPresentation === "titledSection") + ) { return (
@@ -99,7 +122,12 @@ export function renderActionAsMenuItem( ("children" in item ? item.children : item)) + items.map((item) => + [item].concat("children" in item ? getAllActions(item.children) : []) + ) ).filter(isAction); } diff --git a/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx b/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx index 72485bc7..bb0a1082 100644 --- a/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx +++ b/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx @@ -2,6 +2,7 @@ import { flatten } from "ramda"; import React from "react"; import { Popup, usePopupManager } from "@intellij-platform/core/Popup"; import { SpeedSearchMenu } from "@intellij-platform/core/Menu"; +import { MENU_POSITION_TARGET_DATA_ATTRIBUTE } from "@intellij-platform/core/Menu/ContextMenuContainer"; import { useEventCallback } from "@intellij-platform/core/utils/useEventCallback"; import { ActionContext, @@ -21,7 +22,22 @@ export const useCreateDefaultActionGroup = () => { context: ActionContext ) => { show(({ close }) => ( - + { groupDefinition: Omit ): ActionGroupDefinition => { return { - presentation: "popup", + menuPresentation: "submenu", ...groupDefinition, actionPerformed: (context) => openActionsInPopup(groupDefinition, context), diff --git a/packages/jui/src/ActionSystem/defaultKeymap.tsx b/packages/jui/src/ActionSystem/defaultKeymap.tsx index 2ff89712..09e823a4 100644 --- a/packages/jui/src/ActionSystem/defaultKeymap.tsx +++ b/packages/jui/src/ActionSystem/defaultKeymap.tsx @@ -235,7 +235,7 @@ export const defaultKeymap: Keymap = { }, }, ], - [CommonActionId.Delete]: [ + [CommonActionId.DELETE]: [ { type: "keyboard", firstKeyStroke: { @@ -243,4 +243,31 @@ export const defaultKeymap: Keymap = { }, }, ], + [CommonActionId.COPY]: [ + { + type: "keyboard", + firstKeyStroke: { + modifiers: ["Meta"], + code: "KeyC", + }, + }, + ], + [CommonActionId.CUT]: [ + { + type: "keyboard", + firstKeyStroke: { + modifiers: ["Meta"], + code: "KeyX", + }, + }, + ], + [CommonActionId.PASTE]: [ + { + type: "keyboard", + firstKeyStroke: { + modifiers: ["Meta"], + code: "KeyV", + }, + }, + ], }; diff --git a/packages/jui/src/Collections/ItemLayout.tsx b/packages/jui/src/Collections/ItemLayout.tsx index 26de8abb..79912050 100644 --- a/packages/jui/src/Collections/ItemLayout.tsx +++ b/packages/jui/src/Collections/ItemLayout.tsx @@ -1,6 +1,7 @@ import React from "react"; import { styled } from "../styled"; import { DOMAttributes } from "@react-types/shared"; +import { MENU_POSITION_TARGET_DATA_ATTRIBUTE } from "@intellij-platform/core/Menu/ContextMenuContainer"; const GAP = "0.1875rem"; const StyledItemLayout = styled.div` @@ -45,7 +46,12 @@ const StyledGroup = styled.span.attrs({ export const ItemLayout = ( props: { children: React.ReactNode } & DOMAttributes ) => { - return ; + return ( + + ); }; /** diff --git a/packages/jui/src/InputField/Input.stories.tsx b/packages/jui/src/InputField/Input.stories.tsx index 68a895f2..50b142ff 100644 --- a/packages/jui/src/InputField/Input.stories.tsx +++ b/packages/jui/src/InputField/Input.stories.tsx @@ -25,10 +25,17 @@ export const Default: StoryObj = { render: render, }; -export const Invalid: StoryObj = { +export const Error: StoryObj = { render: render, args: { - validationState: "invalid", + validationState: "error", + }, +}; + +export const Warning: StoryObj = { + render: render, + args: { + validationState: "warning", }, }; @@ -47,12 +54,12 @@ export const Embedded: StoryObj = { }, }; -export const EmbeddedInvalid: StoryObj = { +export const EmbeddedError: StoryObj = { render: render, args: { appearance: "embedded", placeholder: "Embedded", - validationState: "invalid", + validationState: "error", }, }; diff --git a/packages/jui/src/InputField/Input.tsx b/packages/jui/src/InputField/Input.tsx index f88f0910..cb2c759e 100644 --- a/packages/jui/src/InputField/Input.tsx +++ b/packages/jui/src/InputField/Input.tsx @@ -27,16 +27,16 @@ const StyledInputBox = styled.div<{ border: 1px solid ${({ theme, focused, disabled, validationState }) => theme.commonColors.border({ - focused: focused, - disabled: disabled, - invalid: validationState === "invalid", + focused, + disabled, + validationState, })}; box-shadow: 0 0 0 0.125rem ${({ theme, focused = false, validationState, disabled }) => disabled ? "transparent" : theme.commonColors.focusRing({ - invalid: validationState === "invalid", + validationState, focused: focused, })}; border-radius: 1px; @@ -45,8 +45,8 @@ const StyledInputBox = styled.div<{ ${({ appearance, validationState, disabled }) => appearance === "embedded" && css` - border-color: ${validationState !== "invalid" && "transparent"}; - box-shadow: ${validationState !== "invalid" && "none"}; + border-color: ${validationState !== "error" && "transparent"}; + box-shadow: ${validationState !== "error" && "none"}; background: ${!disabled && "transparent"}; `}; `; @@ -101,7 +101,10 @@ const StyledLeftAddons = styled(StyledAddons)` `; export interface InputProps extends InputHTMLAttributes { - validationState?: ValidationState; + /** + * + */ + validationState?: "valid" | "error" | "warning"; /** * Whether to auto select the value initially */ @@ -133,7 +136,7 @@ export interface InputProps extends InputHTMLAttributes { /** * Bare input, themed, and with a few extra features: - * - Support for "invalid" state ({@param validationState} + * - Support for "error" and "warning" state ({@param validationState} * - Support for autoSelect. * - Disables spell check by default. It can be overwritten. * Use {@link InputField} for more features like an associated label, error message and context help. diff --git a/packages/jui/src/InputField/InputField.cy.tsx b/packages/jui/src/InputField/InputField.cy.tsx index f8cf890c..44e3e020 100644 --- a/packages/jui/src/InputField/InputField.cy.tsx +++ b/packages/jui/src/InputField/InputField.cy.tsx @@ -5,7 +5,7 @@ import { InputField } from "@intellij-platform/core"; const { Default, - Invalid, + Error, Disabled, LabelAbove, WithPlaceholder, @@ -30,8 +30,8 @@ describe("InputField", () => { - - {/* Focused */} + + {/* Focused */} ); cy.findAllByRole("textbox").last().focus(); diff --git a/packages/jui/src/InputField/InputField.stories.tsx b/packages/jui/src/InputField/InputField.stories.tsx index 2d75b3cb..98744eb9 100644 --- a/packages/jui/src/InputField/InputField.stories.tsx +++ b/packages/jui/src/InputField/InputField.stories.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Meta, StoryObj } from "@storybook/react"; import * as inputStories from "./Input.stories"; import { InputField, InputFieldProps } from "./InputField"; +import { dir } from "../../cypress/support/example-app"; export default { title: "Components/InputField", @@ -27,10 +28,17 @@ export const LabelAbove: StoryObj = { }, }; -export const Invalid: StoryObj = { +export const Error: StoryObj = { render, args: { - validationState: "invalid", + validationState: "error", + }, +}; + +export const Warning: StoryObj = { + render, + args: { + validationState: "warning", }, }; @@ -77,8 +85,8 @@ export const WithErrorMessage: StoryObj = { {...props} value={branchName} onChange={setBranchName} - validationState={alreadyExisting ? "invalid" : "valid"} - errorMessage={ + validationState={alreadyExisting ? "error" : "valid"} + validationMessage={ alreadyExisting ? ( <> Branch name {branchName} already exists.
@@ -96,3 +104,25 @@ export const WithErrorMessage: StoryObj = { labelPlacement: "above", }, }; + +export const WithWarningMessage: StoryObj = { + render: (props) => { + const [name, setName] = React.useState(""); + + return ( +
+ Target name is not specified. : null} + /> +
+ ); + }, + + args: { + label: "Target name:", + }, +}; diff --git a/packages/jui/src/InputField/InputField.tsx b/packages/jui/src/InputField/InputField.tsx index aa6e469c..b4492bdb 100644 --- a/packages/jui/src/InputField/InputField.tsx +++ b/packages/jui/src/InputField/InputField.tsx @@ -18,9 +18,15 @@ import { Input, InputProps } from "@intellij-platform/core/InputField/Input"; type LabelPlacement = "above" | "before"; export interface InputFieldProps - extends Omit, + extends Omit< + AriaFieldProps, + "labelElementType" | "validationState" | "errorMessage" + >, FocusableProps, - Pick { + Pick< + InputProps, + "addonBefore" | "addonAfter" | "inputRef" | "validationState" + > { /** * className applied on the field's wrapper div. */ @@ -76,6 +82,12 @@ export interface InputFieldProps * Whether to auto select the value initially */ autoSelect?: boolean; + + /** + * Validation message shown as a {@link ValidationTooltip} above the field. + * {@link ValidationTooltipProps#type} is defined based on `validationState`. + */ + validationMessage?: React.ReactNode; } const StyledInputContainer = styled.div<{ labelPlacement?: LabelPlacement }>` @@ -96,7 +108,6 @@ const StyledContextHelp = styled.div` const StyledBoxAndContextHelpWrapper = styled.div` display: flex; - width: 100%; flex-direction: column; gap: 0.25rem; /* Not checked with the reference impl */ `; @@ -106,6 +117,8 @@ const StyledBoxAndContextHelpWrapper = styled.div` */ export const InputField = React.forwardRef(function InputField( { + validationState, + validationMessage, className, style, labelPlacement = "before", @@ -120,7 +133,14 @@ export const InputField = React.forwardRef(function InputField( ): JSX.Element { const ref = useObjectRef(forwardedRef); const { fieldProps, errorMessageProps, labelProps, descriptionProps } = - useField(props); + useField({ + ...props, + errorMessage: validationMessage, + validationState: + validationState === "error" || validationState === "warning" + ? "invalid" + : "valid", + }); return ( -
{props.errorMessage}
+ +
{validationMessage}
} delay={0} @@ -149,7 +171,7 @@ export const InputField = React.forwardRef(function InputField( inputRef={inputRef} placeholder={props.placeholder} disabled={props.isDisabled} - validationState={props.validationState} + validationState={validationState} autoSelect={props.autoSelect} autoFocus={props.autoFocus} addonAfter={addonAfter} diff --git a/packages/jui/src/Menu/ContextMenuContainer.tsx b/packages/jui/src/Menu/ContextMenuContainer.tsx index e742fe8e..1e45fc3d 100644 --- a/packages/jui/src/Menu/ContextMenuContainer.tsx +++ b/packages/jui/src/Menu/ContextMenuContainer.tsx @@ -1,12 +1,14 @@ -import React, { HTMLAttributes, HTMLProps } from "react"; +import React, { ForwardedRef, HTMLAttributes, HTMLProps } from "react"; import { mergeProps } from "@react-aria/utils"; import { useMenuTriggerState } from "@react-stately/menu"; import { OverlayTriggerProps } from "@react-types/overlays"; -import { useContextMenu } from "./useContextMenu"; +import { useContextMenu, UseContextMenuProps } from "./useContextMenu"; import { MenuOverlay } from "./MenuOverlay"; -interface ContextMenuContainerProps extends HTMLProps { +interface ContextMenuContainerProps + extends HTMLProps, + UseContextMenuProps { /** * Will be called to return the Menu when context menu is triggered. Use {@link Menu} component to render a menu. */ @@ -25,33 +27,53 @@ interface ContextMenuContainerProps extends HTMLProps { * to be used to render context menu, when it's triggered. * Closes the menu when a menu action is triggered. */ -export const ContextMenuContainer = ({ - children, - renderMenu, - ...props -}: ContextMenuContainerProps) => { - const state = useMenuTriggerState({} as OverlayTriggerProps); +export const ContextMenuContainer = React.forwardRef( + ( + { + children, + renderMenu, + onOpen, + isDisabled, + ...props + }: ContextMenuContainerProps, + ref: ForwardedRef + ) => { + const state = useMenuTriggerState({} as OverlayTriggerProps); - const { overlayProps, containerProps, overlayRef } = useContextMenu( - {}, - state - ); - const allProps = mergeProps(props, containerProps); - return ( - <> - {typeof children === "function" ? ( - children(allProps) - ) : ( -
{children}
- )} - - {renderMenu()} - - - ); -}; + const { overlayProps, containerProps, overlayRef } = useContextMenu( + { onOpen, isDisabled }, + state + ); + const allProps = mergeProps(props, containerProps); + return ( + <> + {typeof children === "function" ? ( + children(allProps) + ) : ( +
+ {children} +
+ )} + + {renderMenu()} + + + ); + } +); + +/** + * Data attribute name to be used to mark an element as the reference for positioning a contextual menu. + */ +export const MENU_POSITION_TARGET_DATA_ATTRIBUTE = + "data-context-menu-position-target"; diff --git a/packages/jui/src/Menu/Menu.cy.tsx b/packages/jui/src/Menu/Menu.cy.tsx index bea5a3f5..59dd71ed 100644 --- a/packages/jui/src/Menu/Menu.cy.tsx +++ b/packages/jui/src/Menu/Menu.cy.tsx @@ -729,6 +729,47 @@ describe("Menu with trigger", () => { }); describe("ContextMenu", () => { + it("opens in the right position when mouse is not moved yet at all", () => { + cy.mount(); + // cy.rightClick() command is not suitable for this test case as it triggers some mousemove events before + // triggering the contextmenu event. + cy.get("#context-menu-container").trigger("contextmenu", { + clientX: 100, + clientY: 200, + force: true, // Ensure this action is not prevented by other event listeners + }); + + cy.findByRole("menu") + .invoke("offset") + .its("left") + .should("be.approximately", 100, 10); + + cy.findByRole("menu") + .invoke("offset") + .its("top") + .should("be.approximately", 200, 10); + }); + + it("works when right clicking on the context menu trigger area while already open", () => { + cy.mount(); + cy.get("#context-menu-container").rightclick(150, 150, { + scrollBehavior: false, + }); + cy.get("#context-menu-container").rightclick(100, 100, { + scrollBehavior: false, + }); + cy.findByRole("menu").should("be.focused"); + }); + + it("doesn't position menu in a way that first item would get automatically hovered and focused", () => { + cy.mount(); + cy.get("#context-menu-container").rightclick(100, 100, { + scrollBehavior: false, + }); + // the menu is focused, not the menu item. + cy.findByRole("menu").should("be.focused"); + }); + it("opens in the right position", () => { // NOTE: currently menu positioning doesn't exactly match the reference implementation. It flips instead of move // to viewport. @@ -764,6 +805,17 @@ describe("ContextMenu", () => { cy.get("[role=menu]").should("not.exist"); }); + it("is closed when right clicking outside the context menu trigger area", () => { + cy.mount(); + cy.get("#context-menu-container").rightclick(150, 150, { + scrollBehavior: false, + }); + cy.get("body").rightclick(10, 10, { + scrollBehavior: false, + }); + cy.findByRole("menu").should("not.exist"); + }); + it("is closed after an action is triggered", () => { cy.mount(); cy.scrollTo("bottom", { duration: 0 }); diff --git a/packages/jui/src/Menu/Menu.stories.tsx b/packages/jui/src/Menu/Menu.stories.tsx index bb1d00cf..76677730 100644 --- a/packages/jui/src/Menu/Menu.stories.tsx +++ b/packages/jui/src/Menu/Menu.stories.tsx @@ -238,9 +238,10 @@ const StyledContainer = styled.div` export const ContextMenu: StoryObj<{ children?: ReactNode; + noScroll?: boolean; menuProps?: Partial>; }> = { - render: ({ children, menuProps = {} }) => { + render: ({ children, noScroll, menuProps = {} }) => { return ( <>
)} > - Right click somewhere. {children} + + Right click somewhere. {children} + ); diff --git a/packages/jui/src/Menu/Menu.tsx b/packages/jui/src/Menu/Menu.tsx index 75d4a3c3..bf3af7a6 100644 --- a/packages/jui/src/Menu/Menu.tsx +++ b/packages/jui/src/Menu/Menu.tsx @@ -74,7 +74,13 @@ export interface MenuProps * Note that MenuOverlayContext could be used directly in action handlers too, but baking it into the menu makes it * much more convenient, which seems more important than breaking the nice separation between Menu and MenuTrigger. */ -export const MenuOverlayContext = React.createContext({ close: () => {} }); +export const MenuOverlayContext = React.createContext<{ + close: () => void; + defaultAutoFocus: MenuProps["autoFocus"]; +}>({ + close: () => {}, + defaultAutoFocus: undefined, +}); export const MenuContext = React.createContext< Pick< MenuProps, @@ -97,7 +103,8 @@ export function useMenu( state: TreeState, ref: RefObject ) { - const { close } = useContext(MenuOverlayContext); + const { close, defaultAutoFocus } = useContext(MenuOverlayContext); + const autoFocus = props.autoFocus ?? defaultAutoFocus; const onClose = () => { props.onClose?.(); close(); @@ -116,12 +123,12 @@ export function useMenu( }; const menuContextValue: React.ContextType = { submenuBehavior, - autoFocus: props.autoFocus, + autoFocus, onAction, onClose, }; const { menuProps } = useMenuAria( - { ...props, onAction, onClose }, + { ...props, onAction, onClose, autoFocus }, state, ref ); diff --git a/packages/jui/src/Menu/MenuOverlay.tsx b/packages/jui/src/Menu/MenuOverlay.tsx index 17cb1a12..563fdd18 100644 --- a/packages/jui/src/Menu/MenuOverlay.tsx +++ b/packages/jui/src/Menu/MenuOverlay.tsx @@ -1,7 +1,10 @@ import React, { HTMLProps } from "react"; import { MenuTriggerState } from "@react-stately/menu"; import { FocusScope } from "@intellij-platform/core/utils/FocusScope"; -import { MenuOverlayContext } from "@intellij-platform/core/Menu/Menu"; +import { + MenuOverlayContext, + MenuProps, +} from "@intellij-platform/core/Menu/Menu"; import { Overlay } from "@intellij-platform/core/Overlay"; /** @@ -14,12 +17,17 @@ export function MenuOverlay({ restoreFocus, overlayProps, overlayRef, + defaultAutoFocus, state, }: { children: React.ReactNode; restoreFocus?: boolean; overlayProps: HTMLProps; overlayRef: React.Ref; + /** + * Sets the default value of {@link Menu}'s {@link MenuProps#autoFocus} prop. + */ + defaultAutoFocus?: MenuProps["autoFocus"]; state: MenuTriggerState; }) { if (!state.isOpen) { @@ -27,12 +35,13 @@ export function MenuOverlay({ } return ( - - + +
{children}
diff --git a/packages/jui/src/Menu/MenuTrigger.tsx b/packages/jui/src/Menu/MenuTrigger.tsx index afe6a9dc..66185837 100644 --- a/packages/jui/src/Menu/MenuTrigger.tsx +++ b/packages/jui/src/Menu/MenuTrigger.tsx @@ -1,6 +1,6 @@ import React, { HTMLAttributes, RefObject } from "react"; import { useButton } from "@react-aria/button"; -import { useMenuTrigger } from "@react-aria/menu"; +import { AriaMenuOptions, useMenuTrigger } from "@react-aria/menu"; import { useOverlay, useOverlayPosition } from "@react-aria/overlays"; import { mergeProps } from "@react-aria/utils"; import { useMenuTriggerState } from "@react-stately/menu"; @@ -28,7 +28,11 @@ export interface MenuTriggerProps */ positioningTargetRef?: React.RefObject; renderMenu: (props: { - menuProps: React.HTMLAttributes; + // AriaMenuOptions contains more properties than needed + menuProps: Pick< + AriaMenuOptions, + "id" | "aria-labelledby" | "autoFocus" | "onClose" + >; }) => React.ReactNode; } diff --git a/packages/jui/src/Menu/StyledMenuItem.tsx b/packages/jui/src/Menu/StyledMenuItem.tsx index e762086f..6d647dcf 100644 --- a/packages/jui/src/Menu/StyledMenuItem.tsx +++ b/packages/jui/src/Menu/StyledMenuItem.tsx @@ -50,7 +50,7 @@ export const StyledMenuItem = styled.li<{ ${({ isDisabled }) => isDisabled && disabledStyle}; padding-right: 1.25rem; - line-height: 1.5; // to make the item have the right height + line-height: 1.3125rem; // to make the item have the right height display: flex; align-items: center; `; diff --git a/packages/jui/src/Menu/index.ts b/packages/jui/src/Menu/index.ts index 6e8b06dc..49de9c68 100644 --- a/packages/jui/src/Menu/index.ts +++ b/packages/jui/src/Menu/index.ts @@ -1,8 +1,8 @@ export { Menu, type MenuProps, MenuOverlayContext } from "./Menu"; export { SpeedSearchMenu, type SpeedSearchMenuProps } from "./SpeedSearchMenu"; -export * from "./MenuTrigger"; -export * from "./MenuItemLayout"; -export * from "./ContextMenuContainer"; +export { MenuTrigger, type MenuTriggerProps } from "./MenuTrigger"; +export { MenuItemLayout } from "./MenuItemLayout"; +export { ContextMenuContainer } from "./ContextMenuContainer"; // Collection components are public API of Menu too, but not re-exported because of https://github.com/parcel-bundler/parcel/issues/4399 // export * from "../Collections"; diff --git a/packages/jui/src/Menu/useContextMenu.tsx b/packages/jui/src/Menu/useContextMenu.tsx index 2a412ab3..2639416a 100644 --- a/packages/jui/src/Menu/useContextMenu.tsx +++ b/packages/jui/src/Menu/useContextMenu.tsx @@ -1,17 +1,34 @@ -import React, { useRef } from "react"; +import React, { useEffect, useRef } from "react"; import { MenuTriggerState } from "@react-stately/menu"; import { useOverlay } from "@react-aria/overlays"; import { mergeProps } from "@react-aria/utils"; import { useMouseEventOverlayPosition } from "@intellij-platform/core/utils/useMouseEventOverlayPosition"; import { areInNestedOverlays } from "@intellij-platform/core/Overlay"; +export type UseContextMenuProps = { + /** + * Whether opening contextmenu is disabled. + */ + isDisabled?: boolean; + /** + * Called when contextmenu is opened. + * @param args + */ + onOpen?: (args: { + /** + * The target element on which contextmenu event was triggered. + */ + target: Element; + }) => void; +}; /** * Functionality and accessibility of context menu. */ export const useContextMenu = ( - { isDisabled = false }: { isDisabled?: boolean }, + { isDisabled = false, onOpen }: UseContextMenuProps, state: MenuTriggerState ) => { + const containerRef = useRef(null); /** * NOTE: not using useMenuTrigger because: * - There is no option to have a trigger like this: "right click + long press only by touch" which seems to be the @@ -27,13 +44,44 @@ export const useContextMenu = ( * TODO: add support for long touch */ const onContextMenu = (e: React.MouseEvent) => { + containerRef.current = e.timeStamp; + updatePosition(e); + onOpen?.({ target: e.target as Element }); e.preventDefault(); // NOTE: we can't use offsetX/offsetY, because it would depend on the exact target that was clicked. - if (!state.isOpen) { + if (state.isOpen) { + /** + * If the context menu is already open, closing and reopening makes sure the menu properly gains the focus. + * Otherwise, the focus may go back to the background. + * It also better matches the reference impl. + */ + state.close(); + setTimeout(() => { + state.open(null); + }); + } else { state.open(null); } - updatePosition(e); }; + useEffect(() => { + const onOutsideContextMenu = (e: MouseEvent) => { + // Using timestamp an easy (and hopefully reliable) way to detect if it's the same + // event being handled by onContextMenu, avoiding the overhead of requiring a ref for the container. + if (containerRef.current !== e.timeStamp) { + state.close(); + } + }; + /** + * right clicks outside are not currently captured as "outside interaction" by react-aria's useOverlay hook. + * so we set up a global listener to close the context menu when contextmenu event is triggered outside the + * context menu container. + * to not require a ref just for this, the ref is manually updated when contextmenu event is triggered + * on the container (which happens before the event propagates to the document). + */ + document.addEventListener("contextmenu", onOutsideContextMenu); + return () => + document.removeEventListener("contextmenu", onOutsideContextMenu); + }, []); const overlayRef = useRef(null); @@ -45,6 +93,7 @@ export const useContextMenu = ( // but the menu overflows from the overlay container shouldFlip: true, offset: -8, + crossOffset: 2, // to not get the first item hovered on open isOpen: state.isOpen, }); const { overlayProps } = useOverlay( @@ -65,7 +114,9 @@ export const useContextMenu = ( const containerProps: React.HTMLAttributes = isDisabled ? {} - : { onContextMenu }; + : { + onContextMenu, + }; return { /** * props to be applied on the container element which is supposed to have the context menu diff --git a/packages/jui/src/Theme/Theme.ts b/packages/jui/src/Theme/Theme.ts index 932eb105..ca535e07 100644 --- a/packages/jui/src/Theme/Theme.ts +++ b/packages/jui/src/Theme/Theme.ts @@ -343,11 +343,11 @@ export class Theme

{ border: ({ focused, disabled, - invalid, + validationState, }: { focused?: boolean; disabled?: boolean; - invalid?: boolean; + validationState?: "error" | "warning" | "valid"; } = {}) => { if (disabled) { return ( @@ -358,8 +358,11 @@ export class Theme

{ ) ); } - if (invalid) { - return this.commonColors.focusRing({ invalid: true, focused }); + if (validationState === "error" || validationState === "warning") { + return this.commonColors.focusRing({ + validationState, + focused, + }); } return focused ? theme.color("Component.focusedBorderColor") || @@ -376,13 +379,13 @@ export class Theme

{ // corresponding to JBUI.CurrentTheme.Focus https://github.com/JetBrains/intellij-community/blob/4a3c219eb390b90229bdde75e4abf11bc04e5e2a/platform/util/ui/src/com/intellij/util/ui/JBUI.java#LL1414C3-L1414C3 focusRing: ({ - invalid, + validationState, focused, }: { - invalid?: boolean; + validationState?: "error" | "warning" | "valid"; focused?: boolean; } = {}) => { - if (invalid) { + if (validationState === "error") { if (focused) { return ( theme.color("Component.errorFocusColor") || @@ -400,6 +403,24 @@ export class Theme

{ "#ebbcbc" ); } + if (validationState === "warning") { + if (focused) { + return ( + theme.color("Component.warningFocusColor") || + theme.color( + "Focus.activeWarningBorderColor" as UnknownThemeProp<"Focus.activeWarningBorderColor"> + ) || + "#e2a53a" + ); + } + return ( + theme.color("Component.inactiveWarningFocusColor") || + theme.color( + "Focus.inactiveWarningBorderColor" as UnknownThemeProp<"Focus.inactiveWarningBorderColor"> + ) || + "#ffd385" + ); + } return focused ? theme.color("Component.focusColor") || theme.color( diff --git a/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx b/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx index 296fdaa7..002e48d4 100644 --- a/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx +++ b/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx @@ -19,6 +19,7 @@ import { MAXIMIZE_TOOL_WINDOW_ACTION_ID, REMOVE_TOOL_WINDOW_FROM_SIDEBAR_ACTION_ID, } from "./ToolWindowActionIds"; +import { MenuTriggerProps } from "@intellij-platform/core/Menu"; /** * Tool window gear icon menu, with a set of default actions and some extra ones. @@ -27,7 +28,7 @@ import { export function ToolWindowSettingsIconMenu({ menuProps, }: { - menuProps: React.HTMLAttributes; + menuProps: Parameters[0]["menuProps"]; }) { const { state } = useToolWindowState(); @@ -56,7 +57,7 @@ export function ToolWindowSettingsIconMenu({ { id: "resize", title: "Resize", - presentation: "popup", + menuPresentation: "submenu", children: [...resizeActions.children, maximizeAction], }, new DividerItem(), @@ -65,8 +66,8 @@ export function ToolWindowSettingsIconMenu({ return ( ); } diff --git a/packages/jui/src/theme.stories.tsx b/packages/jui/src/theme.stories.tsx index 10f50c51..3c8cafa1 100644 --- a/packages/jui/src/theme.stories.tsx +++ b/packages/jui/src/theme.stories.tsx @@ -556,10 +556,10 @@ export const Theme: StoryFn = () => { - +

void }; /** * A version of FocusScope which also allows for imperatively moving focus to the scope. @@ -25,12 +13,11 @@ export type FocusScopeRef = { focus: (forceFocusFirst?: boolean) => void }; * It's useful for */ export const FocusScope = React.forwardRef(function BetterFocusScope( - { children, forceRestoreFocus, ...otherProps }: BetterFocusScopeProps, + { children, ...otherProps }: FocusScopeProps, ref: ForwardedRef ) { const directChildRef = useRef(null); const focusManagerRef = useRef(null); - useForceRestoreFocus(forceRestoreFocus); useImperativeHandle( ref, () => ({ @@ -74,27 +61,3 @@ const GetFocusManager = React.forwardRef(function FocusScopeHandle( useImperativeHandle(ref, () => focusManager, [focusManager]); return null; }); - -/** - * Kind of a patchy solution for focus restoration when currently focused element is in a different focus scope, but - * we still want focus restoration to work. So far the only use case is in nested menu, which is rendered as a separate - * overlay with a focus scope. If focus is within that submenu, when the menu is closed, the default `restoreFocus` - * doesn't work because there is a check in useRestoreFocus, which requires the currently focused element to be in - * the focus scope, to do the focus restoration: - * https://github.com/adobe/react-spectrum/blob/e14523fedd93ac1a4ede355aed70988af572ae74/packages/%40react-aria/focus/src/FocusScope.tsx#L460 - */ -function useForceRestoreFocus(restoreFocus?: boolean) { - useLayoutEffect(() => { - let nodeToRestore = document.activeElement as HTMLElement; - - return () => { - if (restoreFocus && nodeToRestore) { - requestAnimationFrame(() => { - if (document.body.contains(nodeToRestore)) { - focusSafely(nodeToRestore); - } - }); - } - }; - }, [restoreFocus]); -} diff --git a/packages/jui/src/utils/useMouseEventOverlayPosition.tsx b/packages/jui/src/utils/useMouseEventOverlayPosition.tsx index cf1a046c..40140872 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 = { clientX: 0, clientY: 0 }; +let lastMouseClientPos: { clientX: number; clientY: number } | undefined; export function useMouseEventOverlayPosition( options: Omit @@ -64,15 +64,25 @@ export function useMouseEventOverlayPosition( } }, []); + const updatePosition = (e?: React.MouseEvent) => { + const coordinatesSource = e || lastMouseClientPos; + if (targetRef.current && coordinatesSource) { + const { clientX, clientY } = coordinatesSource; + targetRef.current.style.left = `${ + // not sure why crossOffset passed to useOverlayPosition doesn't work, so compensating for it here. + clientX + (options.crossOffset ?? 0) + }px`; + targetRef.current.style.top = `${clientY}px`; + } + _updatePosition(); + }; useLayoutEffect(() => { - if (options.isOpen && targetRef.current) { - targetRef.current.style.left = `${lastMouseClientPos.clientX}px`; - targetRef.current.style.top = `${lastMouseClientPos.clientY}px`; + if (options.isOpen) { updatePosition(); } }, [options.isOpen, targetRef.current]); - const { updatePosition, ...result } = useOverlayPosition({ + const { updatePosition: _updatePosition, ...result } = useOverlayPosition({ ...options, targetRef, }); @@ -82,12 +92,6 @@ export function useMouseEventOverlayPosition( /** * Ref to be passed to be passed as targetRef */ - updatePosition: (e?: React.MouseEvent) => { - if (targetRef.current && e) { - targetRef.current.style.left = `${e.clientX}px`; - targetRef.current.style.top = `${e.clientY}px`; - } - updatePosition(); - }, + updatePosition, }; } diff --git a/packages/website/docs/components/Input.mdx b/packages/website/docs/components/Input.mdx index 72dea108..c0f1b0e9 100644 --- a/packages/website/docs/components/Input.mdx +++ b/packages/website/docs/components/Input.mdx @@ -31,11 +31,9 @@ See also: ```jsx live themed <> - -
-
- -
+

+

+
``` diff --git a/packages/website/docs/components/InputField.mdx b/packages/website/docs/components/InputField.mdx index 73d80bbf..bae34d3e 100644 --- a/packages/website/docs/components/InputField.mdx +++ b/packages/website/docs/components/InputField.mdx @@ -27,10 +27,17 @@ An input box with an associated label, error message, and context help. See also />
+
+
diff --git a/packages/website/docs/guides/ActionSystem.mdx b/packages/website/docs/guides/ActionSystem.mdx index 44a69020..7089c3bd 100644 --- a/packages/website/docs/guides/ActionSystem.mdx +++ b/packages/website/docs/guides/ActionSystem.mdx @@ -222,7 +222,7 @@ use cases, however, there are convenient components that interface on `Action` o ### Menu Use `ActionsMenu` to render a list of action objects as a menu. `ActionGroup` items are rendered as a section or a -submenu, depending on `presentation` property of the action group object. +submenu, depending on `menuPresentation` property of the action group object. Note that `ActionsMenu` just provides an interface based on action items, but it doesn't query any action from the actions context, and just uses action properties to create menu items from it.