command-dropdown.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import {
  2. Command,
  3. formatShortcut,
  4. Shortcuts,
  5. useCommandRegistry,
  6. } from '@/features/ide-react/context/command-registry-context'
  7. import {
  8. DropdownDivider,
  9. DropdownHeader,
  10. } from '@/features/ui/components/bootstrap-5/dropdown-menu'
  11. import {
  12. MenuBarDropdown,
  13. NestedMenuBarDropdown,
  14. } from '@/shared/components/menu-bar/menu-bar-dropdown'
  15. import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
  16. import { Fragment, useCallback, useMemo } from 'react'
  17. type CommandId = string
  18. type TaggedCommand = Command & {
  19. type: 'command'
  20. shortcuts?: Shortcuts[CommandId]
  21. }
  22. type Entry<T> = T | GroupStructure<T>
  23. type GroupStructure<T> = {
  24. id: string
  25. title: string
  26. children: Array<Entry<T>>
  27. }
  28. export type MenuSectionStructure<T = CommandId> = {
  29. title?: string
  30. id: string
  31. children: Array<Entry<T>>
  32. }
  33. export type MenuStructure<T = CommandId> = Array<MenuSectionStructure<T>>
  34. const CommandDropdown = ({
  35. menu,
  36. title,
  37. id,
  38. }: {
  39. menu: MenuStructure<CommandId>
  40. title: string
  41. id: string
  42. }) => {
  43. const { registry, shortcuts } = useCommandRegistry()
  44. const populatedSections = useMemo(
  45. () =>
  46. menu
  47. .map(section => populateSectionOrGroup(section, registry, shortcuts))
  48. .filter(x => x.children.length > 0),
  49. [menu, registry, shortcuts]
  50. )
  51. if (populatedSections.length === 0) {
  52. return null
  53. }
  54. return (
  55. <MenuBarDropdown
  56. title={title}
  57. id={id}
  58. className="ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued"
  59. >
  60. {populatedSections.map((section, index) => {
  61. return (
  62. <Fragment key={section.id}>
  63. {index > 0 && <DropdownDivider />}
  64. {section.title && <DropdownHeader>{section.title}</DropdownHeader>}
  65. {section.children.map(child => (
  66. <CommandDropdownChild item={child} key={child.id} />
  67. ))}
  68. </Fragment>
  69. )
  70. })}
  71. </MenuBarDropdown>
  72. )
  73. }
  74. export const CommandSection = ({
  75. section: sectionStructure,
  76. }: {
  77. section: MenuSectionStructure<CommandId>
  78. }) => {
  79. const { registry, shortcuts } = useCommandRegistry()
  80. const section = populateSectionOrGroup(sectionStructure, registry, shortcuts)
  81. if (section.children.length === 0) {
  82. return null
  83. }
  84. return (
  85. <>
  86. {section.title && <DropdownHeader>{section.title}</DropdownHeader>}
  87. {section.children.map(child => (
  88. <CommandDropdownChild item={child} key={child.id} />
  89. ))}
  90. </>
  91. )
  92. }
  93. const CommandDropdownChild = ({ item }: { item: Entry<TaggedCommand> }) => {
  94. const onClickHandler = useCallback(() => {
  95. if (isTaggedCommand(item)) {
  96. item.handler?.({ location: 'menu-bar' })
  97. }
  98. }, [item])
  99. if (isTaggedCommand(item)) {
  100. return (
  101. <MenuBarOption
  102. eventKey={item.id}
  103. key={item.id}
  104. title={item.label}
  105. // eslint-disable-next-line react/jsx-handler-names
  106. onClick={onClickHandler}
  107. href={item.href}
  108. disabled={item.disabled}
  109. trailingIcon={
  110. item.shortcuts && <span>{formatShortcut(item.shortcuts[0])}</span>
  111. }
  112. />
  113. )
  114. } else {
  115. return (
  116. <NestedMenuBarDropdown title={item.title} id={item.id} key={item.id}>
  117. {item.children.map(subChild => {
  118. return <CommandDropdownChild item={subChild} key={subChild.id} />
  119. })}
  120. </NestedMenuBarDropdown>
  121. )
  122. }
  123. }
  124. export default CommandDropdown
  125. function populateSectionOrGroup<
  126. T extends { children: Array<Entry<CommandId>> },
  127. >(
  128. section: T,
  129. registry: Map<string, Command>,
  130. shortcuts: Shortcuts
  131. ): Omit<T, 'children'> & {
  132. children: Array<Entry<TaggedCommand>>
  133. } {
  134. const { children, ...rest } = section
  135. return {
  136. ...rest,
  137. children: children
  138. .map(child => {
  139. if (typeof child !== 'string') {
  140. const populatedChild = populateSectionOrGroup(
  141. child,
  142. registry,
  143. shortcuts
  144. )
  145. if (populatedChild.children.length === 0) {
  146. // Skip empty groups
  147. return undefined
  148. }
  149. return populatedChild
  150. }
  151. const command = registry.get(child)
  152. if (command) {
  153. return {
  154. ...command,
  155. shortcuts: shortcuts[command.id],
  156. type: 'command' as const,
  157. }
  158. }
  159. return undefined
  160. })
  161. .filter(x => x !== undefined),
  162. }
  163. }
  164. function isTaggedCommand(item: Entry<TaggedCommand>): item is TaggedCommand {
  165. return 'type' in item && item.type === 'command'
  166. }