command-dropdown.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  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 '@/shared/components/dropdown/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. <CommandSectionContent
  64. section={section}
  65. includeDivider={index > 0}
  66. />
  67. </Fragment>
  68. )
  69. })}
  70. </MenuBarDropdown>
  71. )
  72. }
  73. export const CommandSection = ({
  74. section: sectionStructure,
  75. includeDivider = true,
  76. }: {
  77. section: MenuSectionStructure<CommandId>
  78. includeDivider?: boolean
  79. }) => {
  80. const { registry, shortcuts } = useCommandRegistry()
  81. const section = populateSectionOrGroup(sectionStructure, registry, shortcuts)
  82. return (
  83. <CommandSectionContent section={section} includeDivider={includeDivider} />
  84. )
  85. }
  86. function CommandSectionContent({
  87. section,
  88. includeDivider = true,
  89. }: {
  90. section: MenuSectionStructure<TaggedCommand>
  91. includeDivider?: boolean
  92. }) {
  93. if (section.children.length === 0) {
  94. return null
  95. }
  96. return (
  97. <>
  98. {includeDivider && <DropdownDivider />}
  99. {section.title && <DropdownHeader>{section.title}</DropdownHeader>}
  100. {section.children.map(child => (
  101. <CommandDropdownChild item={child} key={child.id} />
  102. ))}
  103. </>
  104. )
  105. }
  106. const CommandDropdownChild = ({ item }: { item: Entry<TaggedCommand> }) => {
  107. const onClickHandler = useCallback(() => {
  108. if (isTaggedCommand(item)) {
  109. item.handler?.({ location: 'menu-bar' })
  110. }
  111. }, [item])
  112. if (isTaggedCommand(item)) {
  113. return (
  114. <MenuBarOption
  115. eventKey={item.id}
  116. key={item.id}
  117. title={item.label}
  118. // eslint-disable-next-line react/jsx-handler-names
  119. onClick={onClickHandler}
  120. href={item.href}
  121. disabled={item.disabled}
  122. trailingIcon={
  123. item.shortcuts && <span>{formatShortcut(item.shortcuts[0])}</span>
  124. }
  125. />
  126. )
  127. } else {
  128. return (
  129. <NestedMenuBarDropdown title={item.title} id={item.id} key={item.id}>
  130. {item.children.map(subChild => {
  131. return <CommandDropdownChild item={subChild} key={subChild.id} />
  132. })}
  133. </NestedMenuBarDropdown>
  134. )
  135. }
  136. }
  137. export default CommandDropdown
  138. function populateSectionOrGroup<
  139. T extends { children: Array<Entry<CommandId>> },
  140. >(
  141. section: T,
  142. registry: Map<string, Command>,
  143. shortcuts: Shortcuts
  144. ): Omit<T, 'children'> & {
  145. children: Array<Entry<TaggedCommand>>
  146. } {
  147. const { children, ...rest } = section
  148. return {
  149. ...rest,
  150. children: children
  151. .map(child => {
  152. if (typeof child !== 'string') {
  153. const populatedChild = populateSectionOrGroup(
  154. child,
  155. registry,
  156. shortcuts
  157. )
  158. if (populatedChild.children.length === 0) {
  159. // Skip empty groups
  160. return undefined
  161. }
  162. return populatedChild
  163. }
  164. const command = registry.get(child)
  165. if (command) {
  166. return {
  167. ...command,
  168. shortcuts: shortcuts[command.id],
  169. type: 'command' as const,
  170. }
  171. }
  172. return undefined
  173. })
  174. .filter(x => x !== undefined),
  175. }
  176. }
  177. function isTaggedCommand(item: Entry<TaggedCommand>): item is TaggedCommand {
  178. return 'type' in item && item.type === 'command'
  179. }