diff --git a/packages/core-browser/src/common/common.command.ts b/packages/core-browser/src/common/common.command.ts index 31a588f13c..1e4600eb60 100644 --- a/packages/core-browser/src/common/common.command.ts +++ b/packages/core-browser/src/common/common.command.ts @@ -898,6 +898,12 @@ export namespace TERMINAL_COMMANDS { category: CATEGORY, }; + export const OPEN_TERMINAL_INTELL = { + id: 'terminal.intell', + label: '%terminal.intell%', + category: CATEGORY, + }; + export const SEARCH_NEXT = { id: 'terminal.search.next', label: '%terminal.search.next%', diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 58069606ca..3e20294079 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -474,6 +474,14 @@ export const localizationBundle = { 'preference.terminal.integrated.copyOnSelection': 'Terminal > Copy On Selection', 'preference.terminal.integrated.copyOnSelectionDesc': 'Controls whether text selected in the terminal will be copied to the clipboard.', + + 'preference.terminal.integrated.enableTerminalIntellComplete': 'Enable Terminal Intell Complete', + 'preference.terminal.integrated.terminalIntell': 'Terminal Intelligent Completion', + 'preference.terminal.integrated.terminalIntellDesc': + 'Automatically pop up subcommands, options, and context-related parameter completions during terminal input (Currently supports Bash only)', + 'preference.terminal.integrated.terminalIntellUsage': + 'Use Tab to trigger and select terminal suggestions, Esc to cancel, Enter to confirm', + // Local Echo 'preference.terminal.integrated.localEchoEnabled': 'Terminal > Local Echo', 'preference.terminal.integrated.localEchoDesc': 'When local echo should be enabled.', @@ -1026,6 +1034,7 @@ export const localizationBundle = { 'terminal.or': 'Or', 'terminal.search': 'Search', 'terminal.search.next': 'Search Next', + 'terminal.intell': 'Terminal IntelliSense', 'terminal.openWithPath': 'Open In Integrated Terminal', 'terminal.remove': 'Kill terminal', 'terminal.menu.search': 'Search', diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index 5bc1d22778..fba8ae738e 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -458,6 +458,12 @@ export const localizationBundle = { 'preference.terminal.integrated.copyOnSelection': '终端选中复制', 'preference.terminal.integrated.copyOnSelectionDesc': '将终端中选中的文本立即复制到剪贴板。', + 'preference.terminal.integrated.enableTerminalIntellComplete': '启用终端智能补全', + 'preference.terminal.integrated.terminalIntell': '终端智能补全', + 'preference.terminal.integrated.terminalIntellDesc': + '终端输入时,自动弹出弹出子命令、选项和上下文相关的参数的补全 (暂时仅支持 Bash)', + 'preference.terminal.integrated.terminalIntellUsage': '使用 Tab 触发和选择终端提示,Esc 取消,Enter 键确认', + 'preference.terminal.integrated.localEchoEnabled': '终端本地回显', 'preference.terminal.integrated.localEchoDesc': '何时应启用本地回显', 'preference.terminal.integrated.localEchoLatencyThreshold': '终端本地回显触发延时', @@ -685,6 +691,7 @@ export const localizationBundle = { 'terminal.or': '或者', 'terminal.search': '搜索', 'terminal.search.next': '搜索下一个匹配项', + 'terminal.intell': '终端智能', 'terminal.openWithPath': '在终端中打开', 'terminal.remove': '终止终端', 'terminal.relaunch': '重启终端', diff --git a/packages/preferences/src/browser/preference-settings.service.ts b/packages/preferences/src/browser/preference-settings.service.ts index 193a76d4ba..054e7b9290 100644 --- a/packages/preferences/src/browser/preference-settings.service.ts +++ b/packages/preferences/src/browser/preference-settings.service.ts @@ -848,6 +848,10 @@ export const defaultSettingSections: { // 命令行参数 { id: 'terminal.integrated.shellArgs.linux', localized: 'preference.terminal.integrated.shellArgs.linux' }, { id: 'terminal.integrated.copyOnSelection', localized: 'preference.terminal.integrated.copyOnSelection' }, + { + id: 'terminal.integrated.enableTerminalIntellComplete', + localized: 'preference.terminal.integrated.enableTerminalIntellComplete', + }, // Local echo { id: 'terminal.integrated.localEchoEnabled', localized: 'preference.terminal.integrated.localEchoEnabled' }, { diff --git a/packages/terminal-next/package.json b/packages/terminal-next/package.json index de76a25e71..d47d807aed 100644 --- a/packages/terminal-next/package.json +++ b/packages/terminal-next/package.json @@ -21,6 +21,7 @@ "@opensumi/ide-core-common": "workspace:*", "@opensumi/ide-core-node": "workspace:*", "@opensumi/ide-file-service": "workspace:*", + "@withfig/autocomplete": "^2.657.0", "node-pty": "1.0.0", "os-locale": "^4.0.0", "xterm": "5.3.0", @@ -41,6 +42,7 @@ "@opensumi/ide-variable": "workspace:*", "@opensumi/ide-workspace": "workspace:*", "@types/http-proxy": "^1.17.2", + "@withfig/autocomplete-types": "^1.28.0", "http-proxy": "^1.18.0" } } diff --git a/packages/terminal-next/src/browser/component/terminal-intell-command-controller.module.less b/packages/terminal-next/src/browser/component/terminal-intell-command-controller.module.less new file mode 100644 index 0000000000..0e7dd0435b --- /dev/null +++ b/packages/terminal-next/src/browser/component/terminal-intell-command-controller.module.less @@ -0,0 +1,38 @@ +.suggestions { + display: flex; + flex-direction: column; + background-color: var(--editorGroupHeader-tabsBackground); + color: var(--ai-native-text-color-common); + max-height: 350px; + width: 500px; + overflow-y: auto; + position: absolute; + bottom: 100%; + left: 0; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.suggestionItem { + padding: 6px 10px 6px 16px; + cursor: pointer; +} + +.suggestionItemContainer { + display: flex; + flex-direction: column; +} + +.suggestionDesc { + font-size: 12px; +} + +.suggestionCmd { + font-size: 12px; + opacity: 0.6; +} + +.suggestionItem:hover { + filter: brightness(110%); + background-color: var(--selection-background); +} \ No newline at end of file diff --git a/packages/terminal-next/src/browser/component/terminal-intell-command-controller.tsx b/packages/terminal-next/src/browser/component/terminal-intell-command-controller.tsx new file mode 100644 index 0000000000..a66d4801b8 --- /dev/null +++ b/packages/terminal-next/src/browser/component/terminal-intell-command-controller.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react'; + +import { Emitter } from '@opensumi/ide-core-common'; + +import styles from './terminal-intell-command-controller.module.less'; + +export interface SmartCommandDesc { + description: string; + command: string; +} + +// 支持键盘选择的列表 +export const KeyboardSelectableList = (props: { + items: { description: string; command: string }[]; + handleSuggestionClick: (command: string) => void; + controller?: Emitter; + noListen?: boolean; +}) => { + const { items, handleSuggestionClick, noListen = false, controller } = props; + // 选中项的索引,默认为最后一个 + const [selectedIndex, setSelectedIndex] = useState(-1); + + // 处理键盘事件 + const handleKeyPress = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowUp': // 上键 + setSelectedIndex((prevIndex) => Math.max(prevIndex - 1, 0)); + break; + case 'ArrowDown': // 下键 + setSelectedIndex((prevIndex) => + Math.min(prevIndex + 1, items.length - 1), + ); + break; + case 'Enter': // 回车键 + if (items[selectedIndex]) { + handleSuggestionClick(items[selectedIndex].command); + } + break; + default: + break; + } + }; + + // 添加全局键盘事件监听器 + useEffect(() => { + if (noListen) {return;} + window.addEventListener('keydown', handleKeyPress); + return () => { + window.removeEventListener('keydown', handleKeyPress); + }; + }, [items, selectedIndex]); + + useEffect(() => { + if (!controller) {return;} + const disposable = controller.event((e: string) => { + if (e === 'ArrowUp') { + setSelectedIndex((prevIndex) => Math.max(prevIndex - 1, 0)); + } + if (e === 'ArrowDown' || e === 'Tab') { + setSelectedIndex((prevIndex) => + Math.min(prevIndex + 1, items.length - 1), + ); + } + if (e === 'Enter') { + if (items[selectedIndex]) { + handleSuggestionClick(items[selectedIndex].command); + } + } + }); + + return () => { + disposable.dispose(); + }; + }, [controller, selectedIndex, items]); + + useEffect(() => { + // HACK 定位到顶部 + setSelectedIndex(0); + }, [items]); + + return ( +
+ {items.map((cmd, index) => ( +
handleSuggestionClick(cmd.command)} + > +
+
{cmd.description}
+
{cmd.command}
+
+
+ ))} +
+ ); +}; + +export const TerminalIntellCommandController = (props: { + suggestions: SmartCommandDesc[]; + controller: Emitter; + onSuggestion: (suggestion: string) => void; +}) => { + const { suggestions, controller, onSuggestion } = props; + + return ( +
+ {suggestions.length > 0 && ( + + )} +
+ ); +}; diff --git a/packages/terminal-next/src/browser/component/terminal-intell-complete-controller.module.less b/packages/terminal-next/src/browser/component/terminal-intell-complete-controller.module.less new file mode 100644 index 0000000000..f5d3324d69 --- /dev/null +++ b/packages/terminal-next/src/browser/component/terminal-intell-complete-controller.module.less @@ -0,0 +1,91 @@ +.suggestions { + display: flex; + background-color: transparent; + position: absolute; + bottom: 100%; + left: 0; + + // TODO 考虑动态设置字体 + font-family: Menlo, Monaco, 'Courier New', monospace; + z-index: 12; +} + +.suggestionList { + display: flex; + flex-direction: column; + max-height: 200px; + width: 450px; + overflow-y: scroll; + border-radius: 8px; + background-color: var(--vscode-editorSuggestWidget-background); + color: var(--vscode-editorSuggestWidget-foreground); + // TODO 适配白色主题 + box-shadow: rgba(0, 0, 0, 0.133) 0px 3.2px 7.2px 0px, rgba(0, 0, 0, 0.11) 0px 0.6px 1.8px 0px; +} + +.suggestionItem { + padding: 8px 10px 8px 8px; + cursor: pointer; +} + +.suggestionItemContainer { + display: flex; + align-items: center; +} + +.suggestionDesc { + font-size: 11px; + opacity: 0.6; + margin-left: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.suggestionIcon { + margin-right: 8px; +} + +.suggestionCmd { + font-size: 12px; + white-space: nowrap; + flex: 1; +} + +.suggestionItem:hover { + // filter: brightness(110%); + // background-color: var(--selection-background); +} + +// 选中时旁边展示详情的容器 +.suggestionItemExtraContainer { + background-color: #333; + color: #fff; + padding: 10px; + border-radius: 4px; + width: 300px; + + position: fixed; /* 全局定位 */ + top: 0; + transform: translateY(0); /* 修正浮动层位置 */ + + &.extraContainerLeft { + left: 100%; /* 在列表项的右侧 */ + margin-left: 10px; + } + + &.extraContainerRight { + right: 100%; /* 在列表项的左侧 */ + margin-right: 10px; + } +} + +.suggestionItemExtraCommand { + font-weight: bold; + margin-bottom: 8px; + font-size: 14px; +} + +.suggestionItemExtraDescription { + opacity: 0.6; +} diff --git a/packages/terminal-next/src/browser/component/terminal-intell-complete-controller.tsx b/packages/terminal-next/src/browser/component/terminal-intell-complete-controller.tsx new file mode 100644 index 0000000000..7221e4e9c1 --- /dev/null +++ b/packages/terminal-next/src/browser/component/terminal-intell-complete-controller.tsx @@ -0,0 +1,196 @@ +import cls from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; + +import { Emitter } from '@opensumi/ide-core-common'; + +import styles from './terminal-intell-complete-controller.module.less'; + +// 定义 SuggestionViewModel 接口 +export interface SuggestionViewModel { + description: string; + command: string; + insertValue: string; + icon: string; +} + +interface PopupPosition { + top: number; + left: number; +} + +// 支持键盘选择的列表 +const SelectableList = (props: { + items: SuggestionViewModel[]; + handleSuggestionClick: (command: string) => void; + controller?: Emitter; + noListen?: boolean; +}) => { + const { items, handleSuggestionClick, controller } = props; + // 选中项的索引,默认为最后一个 + const [selectedIndex, setSelectedIndex] = useState(-1); + const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + const listRef = useRef(null); + const selectedItemRef = useRef(null); + const isScrollIntoViewTriggeredRef = useRef(false); // 滚动是否是通过编程触发 (非用户滚动) + + useEffect(() => { + if (!controller) { + return; + } + const disposable = controller.event((e: string) => { + if (e === 'ArrowUp') { + setSelectedIndex((prevIndex) => { + if (prevIndex === 0) { + return items.length - 1; + } + return Math.max(prevIndex - 1, 0); + }); + } + if (e === 'ArrowDown' || e === 'Tab') { + // 走到最下面之后按一下返回顶部 + setSelectedIndex((prevIndex) => { + if (prevIndex + 1 >= items.length) { + return 0; + } + return Math.min(prevIndex + 1, items.length - 1); + }); + } + if (e === 'Enter') { + if (items[selectedIndex]) { + handleSuggestionClick(items[selectedIndex].insertValue || items[selectedIndex].command); + } + } + }); + + return () => { + disposable.dispose(); + }; + }, [controller, selectedIndex, items]); + + // 确保选中项用户可见,比如说通过方向键上下选到了列表 overflow 的部分,此时就需要触发一个自动滚动 + useEffect(() => { + if (selectedIndex > -1 && selectedItemRef.current && listRef.current) { + // smooth 滚动会导致高度计算的延迟,所以使用 instant 滚动 + selectedItemRef.current.scrollIntoView({ behavior: 'auto', block: 'nearest' }); + + // 对于这种编程触发的滚动的情况下,做一个标记 + isScrollIntoViewTriggeredRef.current = true; + setTimeout(() => { + isScrollIntoViewTriggeredRef.current = false; + }, 100); + + // 等待滚动渲染后计算 + setTimeout(() => { + const itemRect = selectedItemRef.current?.getBoundingClientRect(); + if (itemRect) { + // Calculate the position of the popup + setPopupPosition({ + top: itemRect.top, + left: itemRect.right + 10, // 向右偏移 10 px 作为 padding 的补偿 + }); + } + }, 0); + } + }, [selectedIndex, items]); + + // 用户滚动 Complete 列表时,隐藏右侧提示框 + useEffect(() => { + if (selectedIndex > -1 && listRef.current && popupPosition) { + const handleScroll = () => { + const { top, left } = popupPosition; + // 两种情况: + // 1. 如果提示框已经被隐藏,也就是 left top 是 0,那就不必再次触发 + // 2. 如果是 scrollIntoView 编程触发的滚动,那么这种滚动不要隐藏右侧提示框 + if (top === 0 || left === 0 || isScrollIntoViewTriggeredRef.current) { + return; + } + setPopupPosition({ top: 0, left: 0 }); + }; + + listRef.current?.addEventListener('scroll', handleScroll); + + return () => { + listRef.current?.removeEventListener('scroll', handleScroll); + }; + } + }, [selectedIndex, popupPosition]); + + useEffect(() => { + // HACK 定位到顶部 + // TODO 这里需要考虑做一下稳定性,比如说 items 变化时,寻找相同的 commands,保证用户视觉上的稳定 + setSelectedIndex(0); + }, [items]); + + const selectedItem = items[selectedIndex]; + + return ( +
+
+ {items.map((cmd, index) => ( +
handleSuggestionClick(cmd.command)} + > +
+
{cmd.icon}
+
{cmd.command}
+
{cmd.description}
+
+
+ ))} +
+
+ {selectedItem && popupPosition.top > 0 && popupPosition.left > 0 && ( +
+
{selectedItem.command}
+
{selectedItem.description}
+
+ )} +
+
+ ); +}; + +export const TerminalIntellCompleteController = (props: { + suggestions: SuggestionViewModel[]; + controller: Emitter; + onSuggestion: (suggestion: string) => void; + onClose: () => void; +}) => { + const { suggestions, controller, onSuggestion, onClose } = props; + const modalRef = useRef(null); + + // 点击弹框之外的区域关闭弹框 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + onClose(); // 调用传入的 onClose 函数来关闭弹框 + } + }; + + // 监听点击事件 + document.addEventListener('mousedown', handleClickOutside); + + // 清理函数 + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + return ( +
+ {suggestions.length > 0 && ( + + )} +
+ ); +}; diff --git a/packages/terminal-next/src/browser/component/terminal.module.less b/packages/terminal-next/src/browser/component/terminal.module.less index 8de088bceb..a25c1da3e1 100644 --- a/packages/terminal-next/src/browser/component/terminal.module.less +++ b/packages/terminal-next/src/browser/component/terminal.module.less @@ -68,6 +68,68 @@ } } +.terminalIntell { + display: flex; + justify-content: center; + align-items: flex-start; + flex-direction: column; + position: absolute; + z-index: 999; + right: 0; + width: 420px; + border-radius: 2px; + box-shadow: rgba(0, 0, 0, 0.133) 0px 3.2px 7.2px 0px, rgba(0, 0, 0, 0.11) 0px 0.6px 1.8px 0px; + background: var(--kt-panelTitle-background); + padding: 12px; + + .intellTitleContainer { + display: flex; + width: 100%; + + .intellTitle { + margin-left: 8px; + font-size: 14px; + flex: 1; + } + + .intellTitleIcon { + color: white; + } + + .closeBtn { + padding: 0px 6px; + cursor: pointer; + } + } + + .intellSampleImage { + width: 400px; + height: 140px; + border-radius: 4px; + margin-top: 8px; + background-image: url('https://mdn.alipayobjects.com/huamei_aj2sia/afts/img/A*loDkSbLhLwAAAAAAAAAAAAAADoSNAQ/original'); + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + + .intellUsage { + margin-top: 8px; + font-size: 12px; + } + + .intellCheckContainer { + display: flex; + margin-top: 8px; + + .intellDesc { + opacity: 0.9; + color: var(--descriptionForeground); + font-size: 12px; + } + } +} + .terminalFake { position: absolute; z-index: -999; diff --git a/packages/terminal-next/src/browser/component/terminal.view.tsx b/packages/terminal-next/src/browser/component/terminal.view.tsx index 313cfcf105..bd6696aeff 100644 --- a/packages/terminal-next/src/browser/component/terminal.view.tsx +++ b/packages/terminal-next/src/browser/component/terminal.view.tsx @@ -3,7 +3,8 @@ import debounce from 'lodash/debounce'; import { observer } from 'mobx-react-lite'; import React from 'react'; -import { getIcon, localize, useEventEffect, useInjectable } from '@opensumi/ide-core-browser'; +import { CheckBox, Icon } from '@opensumi/ide-components'; +import { PreferenceService, getIcon, localize, useEventEffect, useInjectable } from '@opensumi/ide-core-browser'; import { ITerminalController, @@ -14,6 +15,8 @@ import { ITerminalSearchService, IWidget, } from '../../common'; +import { CodeTerminalSettingId } from '../../common/preference'; +import { IntellTerminalService } from '../intell/intell-terminal.service'; import ResizeView, { ResizeDirection } from './resize.view'; import styles from './terminal.module.less'; @@ -27,6 +30,8 @@ export default observer(() => { const searchService = useInjectable(ITerminalSearchService); const errorService = useInjectable(ITerminalErrorService); const network = useInjectable(ITerminalNetwork); + const intellService = useInjectable(IntellTerminalService); + const preference = useInjectable(PreferenceService); const { groups, currentGroupIndex, currentGroupId } = view; const inputRef = React.useRef(null); const wrapperRef = React.useRef(null); @@ -75,6 +80,15 @@ export default observer(() => { setIsVisible(visible); }); + const [intellSettingsVisible, setIntellSettingsVisible] = React.useState(false); + useEventEffect(intellService.onIntellSettingsVisibleChange, (visible) => { + setIntellSettingsVisible(visible); + }); + + const [enableTerminalIntell, setEnableTerminalIntell] = React.useState( + preference.get(CodeTerminalSettingId.EnableTerminalIntellComplete, false), + ); + const [inputText, setInputText] = React.useState(''); const searchInput = React.useCallback( @@ -129,6 +143,31 @@ export default observer(() => {
searchService.close()}>
)} + {intellSettingsVisible && ( +
+
+ +
{localize('preference.terminal.integrated.terminalIntell')}
+
intellService.closeIntellSettingsPopup()} + >
+
+
+
{localize('preference.terminal.integrated.terminalIntellUsage')}
+
+ { + const checked = (event.target as HTMLInputElement).checked; + setEnableTerminalIntell(checked); + preference.set(CodeTerminalSettingId.EnableTerminalIntellComplete, checked); + }} + /> +
{localize('preference.terminal.integrated.terminalIntellDesc')}
+
+
+ )} {groups.map((group, index) => { if (!group.activated) { return; diff --git a/packages/terminal-next/src/browser/contribution/index.ts b/packages/terminal-next/src/browser/contribution/index.ts index 50a09868a2..bb00a7b451 100644 --- a/packages/terminal-next/src/browser/contribution/index.ts +++ b/packages/terminal-next/src/browser/contribution/index.ts @@ -5,3 +5,4 @@ export * from './terminal.view'; export * from './terminal.keybinding'; export * from './terminal.network'; export * from './terminal.preference'; +export * from './terminal.intell'; diff --git a/packages/terminal-next/src/browser/contribution/terminal.command.ts b/packages/terminal-next/src/browser/contribution/terminal.command.ts index f7bcdd08c0..82f7c6a4fa 100644 --- a/packages/terminal-next/src/browser/contribution/terminal.command.ts +++ b/packages/terminal-next/src/browser/contribution/terminal.command.ts @@ -26,6 +26,7 @@ import { ITerminalRestore, ITerminalSearchService, } from '../../common'; +import { IntellTerminalService } from '../intell/intell-terminal.service'; import { TerminalEnvironmentService } from '../terminal.environment.service'; import { TerminalKeyBoardInputService } from '../terminal.input'; @@ -45,6 +46,9 @@ export class TerminalCommandContribution implements CommandContribution, ClientA @Autowired(ITerminalSearchService) protected readonly search: ITerminalSearchService; + @Autowired(IntellTerminalService) + protected readonly intellTerminalService: IntellTerminalService; + @Autowired(ITerminalRestore) protected readonly store: ITerminalRestore; @@ -89,6 +93,22 @@ export class TerminalCommandContribution implements CommandContribution, ClientA } registerCommands(registry: CommandRegistry) { + // 终端智能快捷设置框 + registry.registerCommand( + { + ...TERMINAL_COMMANDS.OPEN_TERMINAL_INTELL, + }, + { + execute: () => { + if (this.intellTerminalService.intellSettingPopupVisible) { + this.intellTerminalService.closeIntellSettingsPopup(); + return; + } + this.intellTerminalService.openIntellSettingsPopup(); + }, + }, + ); + // 搜索 registry.registerCommand( { diff --git a/packages/terminal-next/src/browser/contribution/terminal.intell.ts b/packages/terminal-next/src/browser/contribution/terminal.intell.ts new file mode 100644 index 0000000000..8dac80719f --- /dev/null +++ b/packages/terminal-next/src/browser/contribution/terminal.intell.ts @@ -0,0 +1,18 @@ +import { Autowired } from '@opensumi/di'; +import { ClientAppContribution, Domain } from '@opensumi/ide-core-browser'; +import { + MaybePromise, +} from '@opensumi/ide-core-common'; + +import { IntellTerminalService } from '../intell/intell-terminal.service'; + +@Domain(ClientAppContribution) +export class TerminalIntellContribution implements ClientAppContribution { + + @Autowired(IntellTerminalService) + intellTerminalService: IntellTerminalService; + + onDidStart(): MaybePromise { + this.intellTerminalService.active(); + } +} diff --git a/packages/terminal-next/src/browser/contribution/terminal.view.ts b/packages/terminal-next/src/browser/contribution/terminal.view.ts index 6623103426..e9c7cbf98f 100644 --- a/packages/terminal-next/src/browser/contribution/terminal.view.ts +++ b/packages/terminal-next/src/browser/contribution/terminal.view.ts @@ -5,6 +5,7 @@ import { TERMINAL_COMMANDS, TabBarToolbarContribution, ToolbarRegistry, + getIcon, localize, } from '@opensumi/ide-core-browser'; import { TERMINAL_CONTAINER_ID } from '@opensumi/ide-core-browser/lib/common/container-id'; @@ -17,6 +18,13 @@ export class TerminalRenderContribution implements ComponentContribution, TabBar static viewId = TERMINAL_CONTAINER_ID; registerToolbarItems(registry: ToolbarRegistry) { + registry.registerItem({ + id: TERMINAL_COMMANDS.OPEN_TERMINAL_INTELL.id, + command: TERMINAL_COMMANDS.OPEN_TERMINAL_INTELL.id, + viewId: TerminalRenderContribution.viewId, + tooltip: localize('terminal.intell'), + iconClass: getIcon('magic-wand'), + }); registry.registerItem({ id: TERMINAL_COMMANDS.OPEN_SEARCH.id, command: TERMINAL_COMMANDS.OPEN_SEARCH.id, diff --git a/packages/terminal-next/src/browser/index.ts b/packages/terminal-next/src/browser/index.ts index d77d015636..013ba2f1a6 100644 --- a/packages/terminal-next/src/browser/index.ts +++ b/packages/terminal-next/src/browser/index.ts @@ -21,10 +21,12 @@ import { ITerminalTheme, } from '../common'; import { EnvironmentVariableServiceToken } from '../common/environmentVariable'; +import { ITerminalSuggestionProviderPath } from '../common/intell/runtime'; import { ITerminalPreference } from '../common/preference'; import { TerminalCommandContribution, + TerminalIntellContribution, TerminalKeybindingContribution, TerminalLifeCycleContribution, TerminalMenuContribution, @@ -60,6 +62,7 @@ export class TerminalNextModule extends BrowserModule { TerminalKeybindingContribution, TerminalNetworkContribution, TerminalPreferenceContribution, + TerminalIntellContribution, { token: ITerminalApiService, useClass: TerminalApiService, @@ -139,5 +142,8 @@ export class TerminalNextModule extends BrowserModule { servicePath: ITerminalProcessPath, clientToken: EnvironmentVariableServiceToken, }, + { + servicePath: ITerminalSuggestionProviderPath, + }, ]; } diff --git a/packages/terminal-next/src/browser/intell/intell-terminal.service.tsx b/packages/terminal-next/src/browser/intell/intell-terminal.service.tsx new file mode 100644 index 0000000000..bbce5beaf4 --- /dev/null +++ b/packages/terminal-next/src/browser/intell/intell-terminal.service.tsx @@ -0,0 +1,409 @@ +import React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { IDecoration, IMarker, Terminal } from 'xterm'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { PreferenceService } from '@opensumi/ide-core-browser'; +import { Disposable, Emitter, Event, IDisposable, runWhenIdle } from '@opensumi/ide-core-common'; + +import { ITerminalController } from '../../common/controller'; +import { ITerminalConnection } from '../../common/index'; +import { ITerminalSuggestionProvider, ITerminalSuggestionProviderPath } from '../../common/intell/runtime'; +import { CodeTerminalSettingId } from '../../common/preference'; +import { + SuggestionViewModel, + TerminalIntellCompleteController, +} from '../component/terminal-intell-complete-controller'; + +enum IstermOscPt { + PromptStarted = 'PS', + PromptEnded = 'PE', + CurrentWorkingDirectory = 'CWD', +} + +export const OSC_MAGIC_NUMBER = 6973; + +@Injectable() +export class IntellTerminalService extends Disposable { + @Autowired(ITerminalController) + private terminalController: ITerminalController; + + @Autowired(ITerminalSuggestionProviderPath) + private suggestionProvider: ITerminalSuggestionProvider; + + @Autowired(PreferenceService) + private preferenceService: PreferenceService; + + public intellSettingPopupVisible = false; + + protected _onVisibleChange = new Emitter(); + public onIntellSettingsVisibleChange: Event = this._onVisibleChange.event; + + private controlEmitter = new Emitter(); + + private popupContainer: HTMLDivElement; // AI 终端下拉补全的弹出框容器 + + private promptEndMarker: IMarker | undefined; + private promptEndDecoration: IDecoration | undefined; // 终端输入 Prompt 结束时的 decoration + private onDataDisposable: IDisposable; + private cwd: string = ''; + + // 基于 终端输入末尾 + Prompt End 位置定位的弹出框 + private completePopupRoot: Root | undefined; + private completePopupDisposeTimeoutHandler: IDisposable | undefined; + + private lastPromptLineString: string; + private isShellIntellActive: boolean; + + private promptEndDecorationObserver: MutationObserver | undefined; + + public active() { + this.initContainer(); + this.disposables.push(this.terminalController.onDidOpenTerminal(({ id }) => this.listenTerminalEvent(id))); + } + + private initContainer() { + this.popupContainer = document.createElement('div'); + this.popupContainer.style.zIndex = '12'; + document.body.appendChild(this.popupContainer); + } + + private listenTerminalEvent(clientId: string) { + const client = this.terminalController.clients.get(clientId); + + if (client) { + try { + this.listenPromptState(client.term); + } catch (e) { + // eslint-disable-next-line no-console + console.error('listenTerminalEvent', e); + } + } + } + + private listenPromptState(xterm: Terminal) { + xterm.parser.registerOscHandler(OSC_MAGIC_NUMBER, (data) => { + const argsIndex = data.indexOf(';'); + const sequence = argsIndex === -1 ? data : data.substring(0, argsIndex); + + switch (sequence) { + case IstermOscPt.PromptStarted: + break; + case IstermOscPt.PromptEnded: + this.handlePromptEnd(xterm); + break; + case IstermOscPt.CurrentWorkingDirectory: + this.handleCwdUpdate(data); + break; + default: + return false; + } + return false; + }); + } + + private handleCwdUpdate(data) { + this.cwd = data.split(';').at(1); + } + + private handlePromptEnd(xterm: Terminal) { + const connection = this.getConnection(xterm); + if (this.onDataDisposable) { + this.onDataDisposable.dispose(); + } + this.disposePreviousPromptEnd(); + + const enable = this.preferenceService.get(CodeTerminalSettingId.EnableTerminalIntellComplete, false); + if (!enable) { + connection.readonly = false; // HACK: 取消 Hack 逻辑,恢复原有的终端数据链路 + return; + } else { + connection.readonly = true; // HACK: 避免原有链路自动发送终端的操作 + } + + this.promptEndMarker = xterm.registerMarker(0); + const xOffset2 = xterm.buffer.active.cursorX; + + let lastData = ''; + this.onDataDisposable = xterm.onData(async (e) => { + const xtermFullScreenMode = xterm.buffer.active === xterm.buffer.alternate; + + // 如果是终端全屏模式的话,比如说 vim 或者 tmux 等,就不要智能补全,遵循原始行为 + if (xtermFullScreenMode) { + connection.sendData(e); + return; + } + + // 稍微 settimeout 一下,等待终端渲染 + runWhenIdle(async () => { + const notRender = this.handleKeyPress(e, lastData, connection); + lastData = e; + + if (e === '\x1b' && this.promptEndDecoration) { + this.promptEndDecoration.dispose(); + return; + } + + const buffer = xterm.buffer.active; + const cursorX = buffer.cursorX; + const lineData = buffer.getLine(this.promptEndMarker?.line || 0); + const lineDataString = lineData?.translateToString(false, xOffset2, cursorX); + + if (notRender || !lineDataString || !this.promptEndMarker) { + return; + } + + await this.renderSuggestions(xterm, connection, lineDataString, cursorX); + }, 50); + }); + } + + private handleKeyPress(inputData: string, lastInputData: string, connection: ITerminalConnection): boolean { + let notRender = false; + + switch (inputData) { + case '\x1b': + this.controlEmitter.fire('Escape'); + break; + case '\x1b[A': + this.controlEmitter.fire('ArrowUp'); + notRender = true; + if (!this.isShellIntellActive) { + connection.sendData(inputData); + } + break; + case '\x1b[B': + this.controlEmitter.fire('ArrowDown'); + notRender = true; + if (!this.isShellIntellActive) { + connection.sendData(inputData); + } + break; + case '\t': + case '\x09': // 或者使用 '\t' + this.controlEmitter.fire('Tab'); + notRender = this.isShellIntellActive; + break; + case '\r': + case '\x0D': // Enter 被按下 + if (this.isShellIntellActive) { + this.controlEmitter.fire('Enter'); + notRender = true; + } else { + notRender = true; + connection.sendData(inputData); + } + break; + case ' ': + notRender = false; + if (lastInputData === ' ') { + notRender = true; // 如果连续多次输入空格,那就不要渲染补全框了 + this.promptEndDecoration?.dispose(); + } + connection.sendData(inputData); + break; + default: + notRender = !this.isShellIntellActive; + connection.sendData(inputData); + } + + return notRender; + } + + private async renderSuggestions( + xterm: Terminal, + connection: ITerminalConnection, + lineDataString: string, + cursorX: number, + ) { + this.promptEndDecoration?.dispose(); + + const suggestionBlob = await this.suggestionProvider.getSuggestions(lineDataString, this.cwd); + + if (!suggestionBlob || !suggestionBlob.suggestions || suggestionBlob.suggestions.length < 1) { + return; + } + + this.lastPromptLineString = JSON.stringify(lineDataString); + this.promptEndDecoration = xterm.registerDecoration({ + marker: this.promptEndMarker!, + width: 1, + height: 1, + x: cursorX, + }); + + const suggestionsViewModel: SuggestionViewModel[] = [ + ...suggestionBlob.suggestions.map((suggestion) => ({ + description: suggestion.description || '', + command: suggestion.name, + insertValue: suggestion.insertValue || '', + icon: suggestion.icon, + })), + ]; + + this.promptEndDecoration?.onRender((element) => + this.renderCompletePopup(xterm, element, suggestionsViewModel, suggestionBlob.charactersToDrop || 0, connection), + ); + + this.promptEndDecoration?.onDispose(() => { + this.isShellIntellActive = false; + + if (this.completePopupRoot) { + /** + * 取消 CompleteList 悬浮框的 React 渲染 + * 目前还需要思考一个更好的方案 + * ---- + * 此处做了一个延时 Dispose 逻辑,为什么要做这个逻辑呢?它背后是有我的良苦用心的。 + * 如果不做延时销毁而是实时销毁的话,每次终端的字符输入都会导致 Decoration 的 Dispose 和重建 + * 此时基于 Decoration 位置定位的补全列表也会经历一次 unmount 和重绘 + * 在终端快速输入字符时,用户的体感就是补全列表在快速闪烁。 + * ---- + * 因此需要再终端快速输入的场景下,不要立即 Dispose 之前的弹框,设置一个 Timeout + * 如果上一个 Decoration Dispose 之后,立即就有新的 Decoration Render,那么就取消这次 Dispose + * 这样就可以做到 CompleteList 弹框的复用,在终端快速输入的时候,只做位置的偏移 + */ + this.completePopupDisposeTimeoutHandler?.dispose(); + this.completePopupDisposeTimeoutHandler = runWhenIdle(() => { + this.completePopupRoot?.unmount(); + this.completePopupRoot = undefined; + this.isShellIntellActive = false; + }, 50); + } + // 断开 MutationObserver 的观察 + if (this.promptEndDecorationObserver) { + this.promptEndDecorationObserver.disconnect(); + this.promptEndDecorationObserver = undefined; + } + }); + } + + private renderCompletePopup( + xterm: Terminal, + element: HTMLElement, + suggestionsViewModel: SuggestionViewModel[], + dropCharNum: number, + connection: ITerminalConnection, + ) { + const isElementVisible = (element: HTMLElement | null): boolean => { + if (!element) { + return false; + } + + // 检查元素是否在视口中可见 + const rect = element.getBoundingClientRect(); + const isInViewport = + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth); + + if (!isInViewport) { + return false; + } + + // 检查元素的 offsetParent,如果为 null 则表示被隐藏 + if (element.offsetParent === null) { + return false; + } + + return true; + }; + + const alignAndCheckVisibility = () => { + // const sourceStyle = window.getComputedStyle(element); + // if (sourceStyle.display === 'none' || sourceStyle.visibility === 'hidden') { + // this.disposeCompletePopup(); + // return; + // } + + const isVisible = isElementVisible(element); + if (!isVisible) { + this.disposeCompletePopup(); + return; + } + + const sourceRect = element.getBoundingClientRect(); + const left = sourceRect.left - element.clientWidth + 6; + const top = sourceRect.bottom - element.clientHeight; + + this.popupContainer.style.position = 'fixed'; + this.popupContainer.style.left = `${left}px`; + this.popupContainer.style.top = `${top}px`; + }; + + alignAndCheckVisibility(); + + this.completePopupDisposeTimeoutHandler?.dispose(); + + if (!this.completePopupRoot) { + this.completePopupRoot = createRoot(this.popupContainer); + } + + this.completePopupRoot.render( + { + const insertStr = suggestion.substring(dropCharNum); + this.promptEndDecoration?.dispose(); + this.completePopupRoot?.unmount(); + this.completePopupRoot = undefined; + this.isShellIntellActive = false; + xterm.focus(); + connection.sendData(insertStr); + }} + onClose={() => { + this.disposeCompletePopup(); + }} + />, + ); + + this.isShellIntellActive = true; + + this.observeElementChanges(element, alignAndCheckVisibility); + } + + private observeElementChanges(element: HTMLElement, callback: () => void) { + if (this.promptEndDecorationObserver) { + this.promptEndDecorationObserver.disconnect(); + } + + const observer = new MutationObserver(callback); + observer.observe(element, { attributes: true, childList: true, subtree: true }); + this.promptEndDecorationObserver = observer; + } + + private disposePreviousPromptEnd() { + if (this.promptEndMarker) { + this.promptEndMarker.dispose(); + } + if (this.promptEndDecoration) { + this.promptEndDecoration.dispose(); + } + } + + // HACK 从 xterm addon 里面拿到终端和后端服务的 stdio 通信链路 + // TODO OpenSumi 提供一个更标准的实现 + private getConnection(xterm: Terminal): ITerminalConnection { + // @ts-ignore + // 目前需要强制取用 Addon 的 connection 能力 + const attachAddon = xterm._addonManager._addons.find((addon) => !!addon?.instance?.connection); + return attachAddon?.instance?.connection as ITerminalConnection; + } + + private disposeCompletePopup() { + this.completePopupRoot?.unmount(); + this.completePopupRoot = undefined; + this.isShellIntellActive = false; + } + + public closeIntellSettingsPopup() { + this.intellSettingPopupVisible = false; + this._onVisibleChange.fire(false); + } + + public openIntellSettingsPopup() { + this.intellSettingPopupVisible = true; + this._onVisibleChange.fire(true); + } +} diff --git a/packages/terminal-next/src/common/intell/README.md b/packages/terminal-next/src/common/intell/README.md new file mode 100644 index 0000000000..c5c6d7446c --- /dev/null +++ b/packages/terminal-next/src/common/intell/README.md @@ -0,0 +1,15 @@ +## 终端的智能补全能力 + +终端的智能补全能力是指终端在输入命令时,能够根据用户的输入,自动提示可能的命令或参数,交互方式类似于编程时的语言服务。 + +此功能可以增加用户使用终端时的易用性。 + +## 功能建设 + + +## 开源项目依赖 +感谢开源项目提供的灵感和相关能力支持: + +https://github.com/withfig/autocomplete + +https://github.com/microsoft/inshellisense \ No newline at end of file diff --git a/packages/terminal-next/src/common/intell/environment.ts b/packages/terminal-next/src/common/intell/environment.ts new file mode 100644 index 0000000000..cddddd6a08 --- /dev/null +++ b/packages/terminal-next/src/common/intell/environment.ts @@ -0,0 +1,54 @@ +import { CommandToken } from './parser'; + +interface Dirent { + /** + * Returns `true` if the `fs.Dirent` object describes a regular file. + * @since v10.10.0 + */ + isFile(): boolean; + /** + * Returns `true` if the `fs.Dirent` object describes a file system + * directory. + * @since v10.10.0 + */ + isDirectory(): boolean; + name: string; +} + +export interface TerminalIntellFileSystem { + readdir(path: string, options: { withFileTypes: boolean }): Promise; +} + +export enum Shell { + Bash = 'bash', + Powershell = 'powershell', + Pwsh = 'pwsh', + Zsh = 'zsh', + Fish = 'fish', + Cmd = 'cmd', + Xonsh = 'xonsh', + Nushell = 'nu', +} + +export interface ITerminalIntellLogger { + debug(...args: any[]): void; +} + +/** + * 用于尽可能抹除平台限制的 Terminal 智能补全环境 + * Node.js 可以提供完整实现 + * 未来若要在 Browser 上使用,需要根据使用场景对接该接口 + */ +export interface ITerminalIntellEnvironment { + getFileSystem(): Promise; + buildExecuteShellCommand(timeout: number): Fig.ExecuteCommandFunction; + resolveCwd( + cmdToken: CommandToken | undefined, + cwd: string, + shell: Shell, + ): Promise<{ cwd: string; pathy: boolean; complete: boolean }>; + getEnv(): Promise>; + getLogger(): ITerminalIntellLogger; +} + +export const ITerminalIntellEnvironment = Symbol('TokenITerminalIntellEnvironment'); diff --git a/packages/terminal-next/src/common/intell/generator.ts b/packages/terminal-next/src/common/intell/generator.ts new file mode 100644 index 0000000000..a04a65d3e3 --- /dev/null +++ b/packages/terminal-next/src/common/intell/generator.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// based on https://github.com/microsoft/inshellisense/blob/c4ab6cfa8d5b4d447d9d554282e501573dbfe473/src/runtime/generator.ts + +// 对源文件额外进行了面向对象 + 依赖注入的修改和优化,使其脱离 Node.js/Browser 单一环境的限制 +// GeneratorRunner 类,负责对于 Fig Generator 的处理 + +import { Autowired, Injectable } from '@opensumi/di'; + +import { ITerminalIntellEnvironment } from './environment'; +import { ITemplateRunner } from './template'; + +export interface IGeneratorRunner { + runGenerator(generator: Fig.Generator, tokens: string[], cwd: string): Promise; +} + +export const IGeneratorRunner = Symbol('IGeneratorRunner'); + +@Injectable() +export class GeneratorRunner implements IGeneratorRunner { + @Autowired(ITerminalIntellEnvironment) + protected readonly terminalIntellEnv: ITerminalIntellEnvironment; + + @Autowired(ITemplateRunner) + protected readonly templateRunner: ITemplateRunner; + + private getGeneratorContext(cwd: string, env: Record): Fig.GeneratorContext { + return { + environmentVariables: Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] != null), + ), + currentWorkingDirectory: cwd, + currentProcess: '', // TODO: define current process + sshPrefix: '', // deprecated, should be empty + isDangerous: false, + searchTerm: '', // TODO: define search term + }; + } + + public async runGenerator(generator: Fig.Generator, tokens: string[], cwd: string): Promise { + const { script, postProcess, scriptTimeout, splitOn, custom, template, filterTemplateSuggestions } = generator; + + const executeShellCommand = this.terminalIntellEnv.buildExecuteShellCommand(scriptTimeout ?? 5000); + const suggestions: Fig.Suggestion[] = []; + try { + if (script) { + const shellInput = typeof script === 'function' ? script(tokens) : script; + const scriptOutput = Array.isArray(shellInput) + ? await executeShellCommand({ command: shellInput.at(0) ?? '', args: shellInput.slice(1), cwd }) + : await executeShellCommand({ ...shellInput, cwd }); + + const scriptStdout = scriptOutput.stdout.trim(); + if (postProcess) { + suggestions.push(...postProcess(scriptStdout, tokens)); + } else if (splitOn) { + suggestions.push(...scriptStdout.split(splitOn).map((s) => ({ name: s }))); + } + } + + if (custom) { + const env = await this.terminalIntellEnv.getEnv(); + suggestions.push(...(await custom(tokens, executeShellCommand, this.getGeneratorContext(cwd, env)))); + } + + if (template != null) { + const templateSuggestions = await this.templateRunner.runTemplates(template, cwd); + if (filterTemplateSuggestions) { + suggestions.push(...filterTemplateSuggestions(templateSuggestions)); + } else { + suggestions.push(...templateSuggestions); + } + } + return suggestions; + } catch (e) { + const err = typeof e === 'string' ? e : e instanceof Error ? e.message : e; + this.terminalIntellEnv.getLogger().debug({ msg: 'generator failed', err, script, splitOn, template }); + } + return suggestions; + } +} + +export default GeneratorRunner; diff --git a/packages/terminal-next/src/common/intell/index.ts b/packages/terminal-next/src/common/intell/index.ts new file mode 100644 index 0000000000..40bf07bf9f --- /dev/null +++ b/packages/terminal-next/src/common/intell/index.ts @@ -0,0 +1,29 @@ +import { Provider } from '@opensumi/di'; + +import { GeneratorRunner, IGeneratorRunner } from './generator'; +import { ITerminalSuggestionRuntime, TerminalSuggestionRuntime } from './runtime'; +import { ISuggestionProcessor, SuggestionProcessor } from './suggestion'; +import { ITemplateRunner, TemplateRunner } from './template'; + +/** + * Terminal 终端智能中,前后端通用的模块。使用时,额外实现两个平台相关的模块即可。 + * 目前 OpenSumi 标准版采用 Node.js 实现功能,前端 RPC 调用的方式。 + */ +export const terminalIntellCommonDeps: Provider[] = [ + { + token: IGeneratorRunner, + useClass: GeneratorRunner, + }, + { + token: ITerminalSuggestionRuntime, + useClass: TerminalSuggestionRuntime, + }, + { + token: ISuggestionProcessor, + useClass: SuggestionProcessor, + }, + { + token: ITemplateRunner, + useClass: TemplateRunner, + }, +]; diff --git a/packages/terminal-next/src/common/intell/model.ts b/packages/terminal-next/src/common/intell/model.ts new file mode 100644 index 0000000000..0ac402a067 --- /dev/null +++ b/packages/terminal-next/src/common/intell/model.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// based on https://github.com/microsoft/inshellisense/blob/ef837d4f738533da7e1a3845231bd5965e025bf1/src/runtime/model.ts + +export interface Suggestion { + name: string; + allNames: string[]; + description?: string; + icon: string; + priority: number; + insertValue?: string; + pathy?: boolean; +} + +export interface SuggestionBlob { + suggestions: Suggestion[]; + argumentDescription?: string; + charactersToDrop?: number; +} diff --git a/packages/terminal-next/src/common/intell/parser.ts b/packages/terminal-next/src/common/intell/parser.ts new file mode 100644 index 0000000000..a272608c6c --- /dev/null +++ b/packages/terminal-next/src/common/intell/parser.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// based on https://github.com/microsoft/inshellisense/blob/ef837d4f738533da7e1a3845231bd5965e025bf1/src/runtime/parser.ts + +export interface CommandToken { + token: string; + complete: boolean; + isOption: boolean; + isPersistent?: boolean; + isPath?: boolean; + isPathComplete?: boolean; + isQuoted?: boolean; +} + +const cmdDelim = /(\|\|)|(&&)|(;)|(\|)/; +const spaceRegex = /\s/; + +export const parseCommand = (command: string): CommandToken[] => { + const lastCommand = command.split(cmdDelim).at(-1)?.trimStart(); + return lastCommand ? lex(lastCommand) : []; +}; + +const lex = (command: string): CommandToken[] => { + const tokens: CommandToken[] = []; + let [readingQuotedString, readingFlag, readingCmd] = [false, false, false]; + let readingIdx = 0; + let readingQuoteChar = ''; + + [...command].forEach((char, idx) => { + const reading = readingQuotedString || readingFlag || readingCmd; + if (!reading && (char === "'" || char === '"')) { + [readingQuotedString, readingIdx, readingQuoteChar] = [true, idx, char]; + return; + } else if (!reading && char === '-') { + [readingFlag, readingIdx] = [true, idx]; + return; + } else if (!reading && !spaceRegex.test(char)) { + [readingCmd, readingIdx] = [true, idx]; + return; + } + + if (readingQuotedString && char === readingQuoteChar && command.at(idx - 1) !== '\\') { + readingQuotedString = false; + const complete = idx + 1 < command.length && spaceRegex.test(command[idx + 1]); + tokens.push({ + token: command.slice(readingIdx + 1, idx), + complete, + isOption: false, + isQuoted: true, + }); + } else if ((readingFlag && spaceRegex.test(char)) || char === '=') { + readingFlag = false; + tokens.push({ + token: command.slice(readingIdx, idx), + complete: true, + isOption: true, + }); + } else if (readingCmd && spaceRegex.test(char) && command.at(idx - 1) !== '\\') { + readingCmd = false; + tokens.push({ + token: command.slice(readingIdx, idx), + complete: true, + isOption: false, + }); + } + }); + + const reading = readingQuotedString || readingFlag || readingCmd; + if (reading) { + if (readingQuotedString) { + tokens.push({ + token: command.slice(readingIdx + 1), + complete: false, + isOption: false, + isQuoted: true, + }); + } else { + tokens.push({ + token: command.slice(readingIdx), + complete: false, + isOption: readingFlag, + }); + } + } + + return tokens; +}; diff --git a/packages/terminal-next/src/common/intell/runtime.ts b/packages/terminal-next/src/common/intell/runtime.ts new file mode 100644 index 0000000000..8ce23f15ab --- /dev/null +++ b/packages/terminal-next/src/common/intell/runtime.ts @@ -0,0 +1,449 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// based on https://github.com/microsoft/inshellisense/blob/057465448a032fe8f85cc7a9cfd23cc6224236a1/src/runtime/runtime.ts + +// SpecLoader 是加载 Fig Spec 的抽象层,做了 面向对象 + DI 二次修改 + +import { Autowired, Injectable } from '@opensumi/di'; +import { path } from '@opensumi/ide-core-common'; + +import { ITerminalIntellEnvironment, Shell } from './environment'; +import { SuggestionBlob } from './model'; +import { CommandToken, parseCommand } from './parser'; +import { ISuggestionProcessor } from './suggestion'; + +export const IFigSpecLoader = Symbol('IFigSpecLoader'); + +// SpecLoader 用于加载 Fig 的 Specs +export interface IFigSpecLoader { + loadSpec(cmd: CommandToken[]): Promise; + lazyLoadSpec(key: string): Promise; + lazyLoadSpecLocation(location: Fig.SpecLocation): Promise; + getSpecSet(): any; +} + +/** + * 标准 Terminal Suggestion 接口 + */ +export interface ITerminalSuggestionProvider { + getSuggestions(cmd: string, cwd: string): Promise; +} + +export const ITerminalSuggestionProviderPath = 'ITerminalSuggestionProviderPath'; +export const ITerminalSuggestionRuntime = Symbol('ITerminalSuggestionRuntime'); + +/** + * TerminalSuggestionRuntime 负责提供给 Terminal 前端 Decoration 的 Suggestion 数据 + */ +@Injectable() +export class TerminalSuggestionRuntime implements ITerminalSuggestionProvider { + @Autowired(IFigSpecLoader) + private specLoader: IFigSpecLoader; + + @Autowired(ISuggestionProcessor) + private suggestionProcessor: ISuggestionProcessor; + + @Autowired(ITerminalIntellEnvironment) + private terminalIntellEnvironment: ITerminalIntellEnvironment; + + public async getSuggestions(cmd: string, cwd: string): Promise { + const activeCmd = parseCommand(cmd); + const rootToken = activeCmd.at(0); + if (activeCmd.length === 0 || !rootToken?.complete) { + return; + } + + const spec = await this.specLoader.loadSpec(activeCmd); + if (spec == null) { + return; + } + const subcommand = this.getSubcommand(spec); + if (subcommand == null) { + return; + } + + const lastCommand = activeCmd.at(-1); + const { + cwd: resolvedCwd, + pathy, + complete: pathyComplete, + } = await this.terminalIntellEnvironment.resolveCwd(lastCommand, cwd, Shell.Bash); + if (pathy && lastCommand) { + lastCommand.isPath = true; + lastCommand.isPathComplete = pathyComplete; + } + const result = await this.runSubcommand(activeCmd.slice(1), subcommand, resolvedCwd); + if (result == null) { + return; + } + + // TODO 目前只是粗暴的限制了返回 100 条终端补全数据,后面看看有没有更好的方案 + result.suggestions = result.suggestions.slice(0, 100); + + let charactersToDrop = lastCommand?.complete ? 0 : lastCommand?.token.length ?? 0; + if (pathy) { + charactersToDrop = pathyComplete ? 0 : path.basename(lastCommand?.token ?? '').length; + } + return { ...result, charactersToDrop }; + } + + public getSpecNames(): string[] { + return Object.keys(this.specLoader.getSpecSet()).filter((spec) => !spec.startsWith('@') && spec !== '-'); + } + + private getPersistentOptions(persistentOptions: Fig.Option[], options?: Fig.Option[]): Fig.Option[] { + const persistentOptionNames = new Set( + persistentOptions.map((o) => (typeof o.name === 'string' ? [o.name] : o.name)).flat(), + ); + return persistentOptions.concat( + (options ?? []).filter( + (o) => + (typeof o.name == 'string' + ? !persistentOptionNames.has(o.name) + : o.name.some((n) => !persistentOptionNames.has(n))) && o.isPersistent === true, + ), + ); + } + + private getSubcommand(spec?: Fig.Spec): Fig.Subcommand | undefined { + if (spec == null) { + return; + } + if (typeof spec === 'function') { + const potentialSubcommand = spec(); + if (Object.prototype.hasOwnProperty.call(potentialSubcommand, 'name')) { + return potentialSubcommand as Fig.Subcommand; + } + return; + } + return spec; + } + + get executeShellCommand() { + return this.terminalIntellEnvironment.buildExecuteShellCommand(5000); + } + + private async genSubcommand(command: string, parentCommand: Fig.Subcommand): Promise { + if (!parentCommand.subcommands || parentCommand.subcommands.length === 0) { + return; + } + + const subcommandIdx = parentCommand.subcommands.findIndex((s) => + Array.isArray(s.name) ? s.name.includes(command) : s.name === command, + ); + + if (subcommandIdx === -1) { + return; + } + const subcommand = parentCommand.subcommands[subcommandIdx]; + + switch (typeof subcommand.loadSpec) { + case 'function': { + const partSpec = await subcommand.loadSpec(command, this.executeShellCommand); + if (partSpec instanceof Array) { + const locationSpecs = ( + await Promise.all(partSpec.map((s) => this.specLoader.lazyLoadSpecLocation(s))) + ).filter((s) => s != null) as Fig.Spec[]; + const subcommands = locationSpecs + .map((s) => this.getSubcommand(s)) + .filter((s) => s != null) as Fig.Subcommand[]; + (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx] = { + ...subcommand, + ...(subcommands.find((s) => s?.name === command) ?? []), + loadSpec: undefined, + }; + return (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx]; + } else if (Object.prototype.hasOwnProperty.call(partSpec, 'type')) { + const locationSingleSpec = await this.specLoader.lazyLoadSpecLocation(partSpec as Fig.SpecLocation); + (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx] = { + ...subcommand, + ...(this.getSubcommand(locationSingleSpec) ?? []), + loadSpec: undefined, + }; + return (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx]; + } else { + (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx] = { + ...subcommand, + ...partSpec, + loadSpec: undefined, + }; + return (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx]; + } + } + case 'string': { + const spec = await this.specLoader.lazyLoadSpec(subcommand.loadSpec as string); + (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx] = { + ...subcommand, + ...(this.getSubcommand(spec) ?? []), + loadSpec: undefined, + }; + return (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx]; + } + case 'object': { + (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx] = { + ...subcommand, + ...(subcommand.loadSpec ?? {}), + loadSpec: undefined, + }; + return (parentCommand.subcommands as Fig.Subcommand[])[subcommandIdx]; + } + case 'undefined': { + return subcommand; + } + } + } + + private getOption(activeToken: CommandToken, options: Fig.Option[]): Fig.Option | undefined { + return options.find((o) => + typeof o.name === 'string' ? o.name === activeToken.token : o.name.includes(activeToken.token), + ); + } + + private getPersistentTokens(tokens: CommandToken[]): CommandToken[] { + return tokens.filter((t) => t.isPersistent === true); + } + + private getArgs(args: Fig.SingleOrArray | undefined): Fig.Arg[] { + return args instanceof Array ? args : args != null ? [args] : []; + } + + private async runOption( + tokens: CommandToken[], + option: Fig.Option, + subcommand: Fig.Subcommand, + cwd: string, + persistentOptions: Fig.Option[], + acceptedTokens: CommandToken[], + ): Promise { + if (tokens.length === 0) { + throw new Error('invalid state reached, option expected but no tokens found'); + } + const activeToken = tokens[0]; + const isPersistent = persistentOptions.some((o) => + typeof o.name === 'string' ? o.name === activeToken.token : o.name.includes(activeToken.token), + ); + if ((option.args instanceof Array && option.args.length > 0) || option.args != null) { + const args = option.args instanceof Array ? option.args : [option.args]; + return this.runArg( + tokens.slice(1), + args, + subcommand, + cwd, + persistentOptions, + acceptedTokens.concat(activeToken), + true, + false, + ); + } + return this.runSubcommand( + tokens.slice(1), + subcommand, + cwd, + persistentOptions, + acceptedTokens.concat({ + ...activeToken, + isPersistent, + }), + ); + } + + private async runArg( + tokens: CommandToken[], + args: Fig.Arg[], + subcommand: Fig.Subcommand, + cwd: string, + persistentOptions: Fig.Option[], + acceptedTokens: CommandToken[], + fromOption: boolean, + fromVariadic: boolean, + ): Promise { + if (args.length === 0) { + return this.runSubcommand(tokens, subcommand, cwd, persistentOptions, acceptedTokens, true, !fromOption); + } else if (tokens.length === 0) { + return await this.getArgDrivenRecommendation( + args, + subcommand, + persistentOptions, + undefined, + acceptedTokens, + fromVariadic, + cwd, + ); + } else if (!tokens.at(0)?.complete) { + return await this.getArgDrivenRecommendation( + args, + subcommand, + persistentOptions, + tokens[0], + acceptedTokens, + fromVariadic, + cwd, + ); + } + + const activeToken = tokens[0]; + if (args.every((a) => a.isOptional)) { + if (activeToken.isOption) { + const option = this.getOption(activeToken, persistentOptions.concat(subcommand.options ?? [])); + if (option != null) { + return this.runOption(tokens, option, subcommand, cwd, persistentOptions, acceptedTokens); + } + return; + } + + const nextSubcommand = await this.genSubcommand(activeToken.token, subcommand); + if (nextSubcommand != null) { + return this.runSubcommand( + tokens.slice(1), + nextSubcommand, + cwd, + persistentOptions, + this.getPersistentTokens(acceptedTokens.concat(activeToken)), + ); + } + } + + const activeArg = args[0]; + if (activeArg.isVariadic) { + return this.runArg( + tokens.slice(1), + args, + subcommand, + cwd, + persistentOptions, + acceptedTokens.concat(activeToken), + fromOption, + true, + ); + } else if (activeArg.isCommand) { + if (tokens.length <= 0) { + return; + } + const spec = await this.specLoader.loadSpec(tokens); + if (spec == null) { + return; + } + const subcommand = this.getSubcommand(spec); + if (subcommand == null) { + return; + } + return this.runSubcommand(tokens.slice(1), subcommand, cwd); + } + return this.runArg( + tokens.slice(1), + args.slice(1), + subcommand, + cwd, + persistentOptions, + acceptedTokens.concat(activeToken), + fromOption, + false, + ); + } + + private async runSubcommand( + tokens: CommandToken[], + subcommand: Fig.Subcommand, + cwd: string, + persistentOptions: Fig.Option[] = [], + acceptedTokens: CommandToken[] = [], + argsDepleted = false, + argsUsed = false, + ): Promise { + if (tokens.length === 0) { + return this.getSubcommandDrivenRecommendation( + subcommand, + persistentOptions, + undefined, + argsDepleted, + argsUsed, + acceptedTokens, + cwd, + ); + } else if (!tokens.at(0)?.complete) { + return this.getSubcommandDrivenRecommendation( + subcommand, + persistentOptions, + tokens[0], + argsDepleted, + argsUsed, + acceptedTokens, + cwd, + ); + } + + const activeToken = tokens[0]; + const activeArgsLength = subcommand.args instanceof Array ? subcommand.args.length : 1; + const allOptions = [...persistentOptions, ...(subcommand.options ?? [])]; + + if (activeToken.isOption) { + const option = this.getOption(activeToken, allOptions); + if (option != null) { + return this.runOption(tokens, option, subcommand, cwd, persistentOptions, acceptedTokens); + } + return; + } + + const nextSubcommand = await this.genSubcommand(activeToken.token, subcommand); + if (nextSubcommand != null) { + return this.runSubcommand( + tokens.slice(1), + nextSubcommand, + cwd, + this.getPersistentOptions(persistentOptions, subcommand.options), + this.getPersistentTokens(acceptedTokens.concat(activeToken)), + ); + } + + if (activeArgsLength <= 0) { + return; + } + + const args = this.getArgs(subcommand.args); + if (args.length !== 0) { + return this.runArg(tokens, args, subcommand, cwd, allOptions, acceptedTokens, false, false); + } + return this.runSubcommand(tokens.slice(1), subcommand, cwd, persistentOptions, acceptedTokens.concat(activeToken)); + } + + private async getSubcommandDrivenRecommendation( + subcommand: Fig.Subcommand, + persistentOptions: Fig.Option[], + token: CommandToken | undefined, + argsDepleted: boolean, + argsUsed: boolean, + acceptedTokens: CommandToken[], + cwd: string, + ): Promise { + return this.suggestionProcessor.getSubcommandDrivenRecommendation( + subcommand, + persistentOptions, + token, + argsDepleted, + argsUsed, + acceptedTokens, + cwd, + ); + } + + private async getArgDrivenRecommendation( + args: Fig.Arg[], + subcommand: Fig.Subcommand, + persistentOptions: Fig.Option[], + token: CommandToken | undefined, + acceptedTokens: CommandToken[], + fromVariadic: boolean, + cwd: string, + ): Promise { + return this.suggestionProcessor.getArgDrivenRecommendation( + args, + subcommand, + persistentOptions, + token, + acceptedTokens, + fromVariadic, + cwd, + ); + } +} diff --git a/packages/terminal-next/src/common/intell/suggestion.ts b/packages/terminal-next/src/common/intell/suggestion.ts new file mode 100644 index 0000000000..aa963b0c4d --- /dev/null +++ b/packages/terminal-next/src/common/intell/suggestion.ts @@ -0,0 +1,422 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// based on https://github.com/microsoft/inshellisense/blob/ef837d4f738533da7e1a3845231bd5965e025bf1/src/runtime/suggestion.ts + +// 面向对象 + DI 依赖解耦 重构 + +import { Autowired, Injectable } from '@opensumi/di'; +import { path } from '@opensumi/ide-core-common'; + +import { ITerminalIntellEnvironment } from './environment'; +import { IGeneratorRunner } from './generator'; +import { Suggestion, SuggestionBlob } from './model'; +import { CommandToken } from './parser'; +import { ITemplateRunner } from './template'; + +type FilterStrategy = 'fuzzy' | 'prefix' | 'default'; + +enum SuggestionIcons { + File = '📄', + Folder = '📁', + Subcommand = '📦', + Option = '🔗', + Argument = '💲', + Mixin = '🏝️', + Shortcut = '🔥', + Special = '⭐', + Default = '📀', +} + +export const ISuggestionProcessor = Symbol('TokenSuggestionProcessor'); + +export interface ISuggestionProcessor { + getSubcommandDrivenRecommendation( + subcommand: Fig.Subcommand, + persistentOptions: Fig.Option[], + partialToken: CommandToken | undefined, + argsDepleted: boolean, + argsFromSubcommand: boolean, + acceptedTokens: CommandToken[], + cwd: string, + ): Promise; + getArgDrivenRecommendation( + args: Fig.Arg[], + subcommand: Fig.Subcommand, + persistentOptions: Fig.Option[], + partialToken: CommandToken | undefined, + acceptedTokens: CommandToken[], + variadicArgBound: boolean, + cwd: string, + ): Promise; +} + +@Injectable() +export class SuggestionProcessor implements ISuggestionProcessor { + @Autowired(ITemplateRunner) + protected readonly templateRunner: ITemplateRunner; + + @Autowired(IGeneratorRunner) + protected readonly generatorRunner: IGeneratorRunner; + + @Autowired(ITerminalIntellEnvironment) + protected readonly terminalIntellEnv: ITerminalIntellEnvironment; + + private getIcon(icon: string | undefined, suggestionType: Fig.SuggestionType | undefined): string { + // TODO: enable fig icons once spacing is better + // if (icon && /[^\u0000-\u00ff]/.test(icon)) { + // return icon; + // } + switch (suggestionType) { + case 'arg': + return SuggestionIcons.Argument; + case 'file': + return SuggestionIcons.File; + case 'folder': + return SuggestionIcons.Folder; + case 'option': + return SuggestionIcons.Option; + case 'subcommand': + return SuggestionIcons.Subcommand; + case 'mixin': + return SuggestionIcons.Mixin; + case 'shortcut': + return SuggestionIcons.Shortcut; + case 'special': + return SuggestionIcons.Special; + } + return SuggestionIcons.Default; + } + + private getLong(suggestion: Fig.SingleOrArray): string { + return suggestion instanceof Array ? suggestion.reduce((p, c) => (p.length > c.length ? p : c)) : suggestion; + } + + private getPathy(type: Fig.SuggestionType | undefined): boolean { + return type === 'file' || type === 'folder'; + } + + private toSuggestion(suggestion: Fig.Suggestion, name?: string, type?: Fig.SuggestionType): Suggestion | undefined { + if (suggestion.name == null) { + return; + } + return { + name: name ?? this.getLong(suggestion.name), + description: suggestion.description, + icon: this.getIcon(suggestion.icon, type ?? suggestion.type), + allNames: suggestion.name instanceof Array ? suggestion.name : [suggestion.name], + priority: suggestion.priority ?? 50, + insertValue: suggestion.insertValue, + pathy: this.getPathy(suggestion.type), + }; + } + + private filter< + T extends Fig.BaseSuggestion & { name?: Fig.SingleOrArray; type?: Fig.SuggestionType | undefined }, + >( + suggestions: T[], + filterStrategy: FilterStrategy | undefined, + partialCmd: string | undefined, + suggestionType: Fig.SuggestionType | undefined, + ): Suggestion[] { + if (!partialCmd) { + return suggestions + .map((s) => this.toSuggestion(s, undefined, suggestionType)) + .filter((s) => s != null) as Suggestion[]; + } + + switch (filterStrategy) { + case 'fuzzy': + return suggestions + .map((s) => { + if (s.name == null) { + return; + } + if (s.name instanceof Array) { + const matchedName = s.name.find((n) => n.toLowerCase().includes(partialCmd.toLowerCase())); + return matchedName != null + ? { + name: matchedName, + description: s.description, + icon: this.getIcon(s.icon, s.type ?? suggestionType), + allNames: s.name, + priority: s.priority ?? 50, + insertValue: s.insertValue, + pathy: this.getPathy(s.type), + } + : undefined; + } + return s.name.toLowerCase().includes(partialCmd.toLowerCase()) + ? { + name: s.name, + description: s.description, + icon: this.getIcon(s.icon, s.type ?? suggestionType), + allNames: [s.name], + priority: s.priority ?? 50, + insertValue: s.insertValue, + pathy: this.getPathy(s.type), + } + : undefined; + }) + .filter((s) => s != null) as Suggestion[]; + default: + return suggestions + .map((s) => { + if (s.name == null) { + return; + } + if (s.name instanceof Array) { + const matchedName = s.name.find((n) => n.toLowerCase().startsWith(partialCmd.toLowerCase())); + return matchedName != null + ? { + name: matchedName, + description: s.description, + icon: this.getIcon(s.icon, s.type ?? suggestionType), + allNames: s.name, + insertValue: s.insertValue, + priority: s.priority ?? 50, + pathy: this.getPathy(s.type), + } + : undefined; + } + return s.name.toLowerCase().startsWith(partialCmd.toLowerCase()) + ? { + name: s.name, + description: s.description, + icon: this.getIcon(s.icon, s.type ?? suggestionType), + allNames: [s.name], + insertValue: s.insertValue, + priority: s.priority ?? 50, + pathy: this.getPathy(s.type), + } + : undefined; + }) + .filter((s) => s != null) as Suggestion[]; + } + } + + private getEscapedPath(value?: string): string | undefined { + return value?.replaceAll(' ', '\\ '); + } + + private adjustPathSuggestions(suggestions: Suggestion[], partialToken?: CommandToken): Suggestion[] { + if (partialToken == null || partialToken.isQuoted) { + return suggestions; + } + return suggestions.map((s) => + s.pathy + ? { + ...s, + insertValue: this.getEscapedPath(s.insertValue), + name: s.insertValue == null ? this.getEscapedPath(s.name)! : s.name, + } + : s, + ); + } + + private removeAcceptedSuggestions(suggestions: Suggestion[], acceptedTokens: CommandToken[]): Suggestion[] { + const seen = new Set(acceptedTokens.map((t) => t.token)); + return suggestions.filter((s) => s.allNames.every((n) => !seen.has(n))); + } + + private removeDuplicateSuggestion(suggestions: Suggestion[]): Suggestion[] { + const seen = new Set(); + return suggestions + .map((s) => { + if (seen.has(s.name)) { + return null; + } + seen.add(s.name); + return s; + }) + .filter((s): s is Suggestion => s != null); + } + + private removeEmptySuggestion(suggestions: Suggestion[]): Suggestion[] { + return suggestions.filter((s) => s.name.length > 0); + } + + // 使用 generator 生成的函数,这种情况下是需要 Node.js/ Runtime 的 + public async generatorSuggestions( + generator: Fig.SingleOrArray | undefined, + acceptedTokens: CommandToken[], + filterStrategy: FilterStrategy | undefined, + partialCmd: string | undefined, + cwd: string, + ): Promise { + const generators = generator instanceof Array ? generator : generator ? [generator] : []; + const tokens = acceptedTokens.map((t) => t.token); + if (partialCmd) { + tokens.push(partialCmd); + } + const suggestions = ( + await Promise.all(generators.map((gen) => this.generatorRunner.runGenerator(gen, tokens, cwd))) + ).flat(); + return this.filter( + suggestions.map((suggestion) => ({ ...suggestion, priority: suggestion.priority ?? 60 })), + filterStrategy, + partialCmd, + undefined, + ); + } + + public async templateSuggestions( + templates: Fig.Template | undefined, + filterStrategy: FilterStrategy | undefined, + partialCmd: string | undefined, + cwd: string, + ): Promise { + return this.filter( + await this.templateRunner.runTemplates(templates ?? [], cwd), + filterStrategy, + partialCmd, + undefined, + ); + } + + public suggestionSuggestions( + suggestions: (string | Fig.Suggestion)[] | undefined, + filterStrategy: FilterStrategy | undefined, + partialCmd: string | undefined, + ): Suggestion[] { + const cleanedSuggestions = suggestions?.map((s) => (typeof s === 'string' ? { name: s } : s)) ?? []; + return this.filter(cleanedSuggestions ?? [], filterStrategy, partialCmd, undefined); + } + + public subcommandSuggestions( + subcommands: Fig.Subcommand[] | undefined, + filterStrategy: FilterStrategy | undefined, + partialCmd: string | undefined, + ): Suggestion[] { + return this.filter(subcommands ?? [], filterStrategy, partialCmd, 'subcommand'); + } + + public optionSuggestions( + options: Fig.Option[] | undefined, + acceptedTokens: CommandToken[], + filterStrategy: FilterStrategy | undefined, + partialCmd: string | undefined, + ): Suggestion[] { + const usedOptions = new Set(acceptedTokens.filter((t) => t.isOption).map((t) => t.token)); + const validOptions = options?.filter( + (o) => o.exclusiveOn?.every((exclusiveOption) => !usedOptions.has(exclusiveOption)) ?? true, + ); + return this.filter(validOptions ?? [], filterStrategy, partialCmd, 'option'); + } + + public async getSubcommandDrivenRecommendation( + subcommand: Fig.Subcommand, + persistentOptions: Fig.Option[], + partialToken: CommandToken | undefined, + argsDepleted: boolean, + argsFromSubcommand: boolean, + acceptedTokens: CommandToken[], + cwd: string, + ): Promise { + this.terminalIntellEnv.getLogger().debug({ + msg: 'suggestion point', + subcommandShorten: JSON.stringify(subcommand).substring(0, 400), + persistentOptions, + partialToken, + argsDepleted, + argsFromSubcommand, + acceptedTokens, + cwd, + }); + if (argsDepleted && argsFromSubcommand) { + return; + } + let partialCmd = partialToken?.token; + if (partialToken?.isPath) { + partialCmd = partialToken.isPathComplete ? '' : path.basename(partialCmd ?? ''); + } + + const suggestions: Suggestion[] = []; + const argLength = subcommand.args instanceof Array ? subcommand.args.length : subcommand.args ? 1 : 0; + const allOptions = persistentOptions.concat(subcommand.options ?? []); + + if (!argsFromSubcommand) { + suggestions.push(...this.subcommandSuggestions(subcommand.subcommands, subcommand.filterStrategy, partialCmd)); + suggestions.push(...this.optionSuggestions(allOptions, acceptedTokens, subcommand.filterStrategy, partialCmd)); + } + if (argLength !== 0) { + const activeArg = subcommand.args instanceof Array ? subcommand.args[0] : subcommand.args; + suggestions.push( + ...(await this.generatorSuggestions( + activeArg?.generators, + acceptedTokens, + activeArg?.filterStrategy, + partialCmd, + cwd, + )), + ); + suggestions.push(...this.suggestionSuggestions(activeArg?.suggestions, activeArg?.filterStrategy, partialCmd)); + suggestions.push( + ...(await this.templateSuggestions(activeArg?.template, activeArg?.filterStrategy, partialCmd, cwd)), + ); + } + + return { + suggestions: this.removeDuplicateSuggestion( + this.removeEmptySuggestion( + this.removeAcceptedSuggestions( + this.adjustPathSuggestions( + suggestions.sort((a, b) => b.priority - a.priority), + partialToken, + ), + acceptedTokens, + ), + ), + ), + }; + } + + public async getArgDrivenRecommendation( + args: Fig.Arg[], + subcommand: Fig.Subcommand, + persistentOptions: Fig.Option[], + partialToken: CommandToken | undefined, + acceptedTokens: CommandToken[], + variadicArgBound: boolean, + cwd: string, + ): Promise { + let partialCmd = partialToken?.token; + if (partialToken?.isPath) { + partialCmd = partialToken.isPathComplete ? '' : path.basename(partialCmd ?? ''); + } + + const activeArg = args[0]; + const allOptions = persistentOptions.concat(subcommand.options ?? []); + const suggestions = [ + ...(await this.generatorSuggestions( + args[0].generators, + acceptedTokens, + activeArg?.filterStrategy, + partialCmd, + cwd, + )), + ...this.suggestionSuggestions(args[0].suggestions, activeArg?.filterStrategy, partialCmd), + ...(await this.templateSuggestions(args[0].template, activeArg?.filterStrategy, partialCmd, cwd)), + ]; + + if (activeArg.isOptional || (activeArg.isVariadic && variadicArgBound)) { + suggestions.push(...this.subcommandSuggestions(subcommand.subcommands, activeArg?.filterStrategy, partialCmd)); + suggestions.push(...this.optionSuggestions(allOptions, acceptedTokens, activeArg?.filterStrategy, partialCmd)); + } + + return { + suggestions: this.removeDuplicateSuggestion( + this.removeEmptySuggestion( + this.removeAcceptedSuggestions( + this.adjustPathSuggestions( + suggestions.sort((a, b) => b.priority - a.priority), + partialToken, + ), + acceptedTokens, + ), + ), + ), + argumentDescription: activeArg.description ?? activeArg.name, + }; + } +} diff --git a/packages/terminal-next/src/common/intell/template.ts b/packages/terminal-next/src/common/intell/template.ts new file mode 100644 index 0000000000..397e5eb40c --- /dev/null +++ b/packages/terminal-next/src/common/intell/template.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// based on https://github.com/microsoft/inshellisense/blob/34049e906e4dd8f9cf6edabe0769c9254665cdb7/src/runtime/template.ts + +// 对源文件额外进行了面向对象 + 依赖注入的修改和优化,使其脱离 Node.js/Browser 单一环境的限制 +// TemplateRunner 类,负责对于 Fig Template 的处理 + +import { Autowired, Injectable } from '@opensumi/di'; + +import { ITerminalIntellEnvironment } from './environment'; + +export interface ITemplateRunner { + runTemplates(template: Fig.TemplateStrings[] | Fig.Template, cwd: string): Promise; +} + +export const ITemplateRunner = Symbol('TokenITemplateRunner'); + +/** + * 解耦 FS 的依赖 + */ +@Injectable() +export class TemplateRunner implements ITemplateRunner { + @Autowired(ITerminalIntellEnvironment) + protected terminalIntellEnv: ITerminalIntellEnvironment; + + getFileSystem() { + return this.terminalIntellEnv.getFileSystem(); + } + + private async filepathsTemplate(cwd: string): Promise { + const fileSystem = await this.getFileSystem(); + const files = await fileSystem.readdir(cwd, { withFileTypes: true }); + return files + .filter((f) => f.isFile() || f.isDirectory()) + .map((f) => ({ + name: f.name, + priority: 55, + context: { templateType: 'filepaths' }, + type: f.isDirectory() ? 'folder' : 'file', + })); + } + + private async foldersTemplate(cwd: string): Promise { + const fileSystem = await this.getFileSystem(); + const files = await fileSystem.readdir(cwd, { withFileTypes: true }); + return files + .filter((f) => f.isDirectory()) + .map((f) => ({ + name: f.name, + priority: 55, + context: { templateType: 'folders' }, + type: 'folder', + })); + } + + private historyTemplate(): Fig.TemplateSuggestion[] { + return []; + } + + private helpTemplate(): Fig.TemplateSuggestion[] { + return []; + } + + public async runTemplates( + template: Fig.TemplateStrings[] | Fig.Template, + cwd: string, + ): Promise { + const templates = template instanceof Array ? template : [template]; + return ( + await Promise.all( + templates.map(async (t) => { + try { + switch (t) { + case 'filepaths': + return await this.filepathsTemplate(cwd); + case 'folders': + return await this.foldersTemplate(cwd); + case 'history': + return this.historyTemplate(); + case 'help': + return this.helpTemplate(); + } + } catch (e) { + this.terminalIntellEnv.getLogger().debug({ msg: 'template failed', e, template: t, cwd }); + return []; + } + }), + ) + ).flat(); + } +} diff --git a/packages/terminal-next/src/common/intell/utils/ansi.ts b/packages/terminal-next/src/common/intell/utils/ansi.ts new file mode 100644 index 0000000000..36990765e6 --- /dev/null +++ b/packages/terminal-next/src/common/intell/utils/ansi.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// based on https://github.com/microsoft/inshellisense/blob/bf7a832b75a90e35a5a58062029eed62b02128bc/src/utils/ansi.ts + +const ESC = '\u001B'; +const CSI = ESC + '['; +const OSC = '\u001B]'; +const BEL = '\u0007'; + +export const IsTermOscPs = 6973; +const IS_OSC = OSC + IsTermOscPs + ';'; + +export enum IstermOscPt { + PromptStarted = 'PS', + PromptEnded = 'PE', + CurrentWorkingDirectory = 'CWD', +} + +export const IstermPromptStart = IS_OSC + IstermOscPt.PromptStarted + BEL; +export const IstermPromptEnd = IS_OSC + IstermOscPt.PromptEnded + BEL; +export const cursorHide = CSI + '?25l'; +export const cursorShow = CSI + '?25h'; +export const cursorNextLine = CSI + 'E'; +export const eraseLine = CSI + '2K'; +export const cursorBackward = (count = 1) => CSI + count + 'D'; +export const cursorTo = ({ x, y }: { x?: number; y?: number }) => CSI + (y ?? '') + ';' + (x ?? '') + 'H'; +export const deleteLinesBelow = (count = 1) => [...Array(count).keys()].map(() => CSI + 'B' + CSI + 'M').join(''); +export const deleteLine = (count = 1) => CSI + count + 'M'; +export const scrollUp = (count = 1) => CSI + count + 'S'; +export const scrollDown = (count = 1) => CSI + count + 'T'; +export const eraseLinesBelow = (count = 1) => [...Array(count).keys()].map(() => cursorNextLine + eraseLine).join(''); diff --git a/packages/terminal-next/src/common/preference.ts b/packages/terminal-next/src/common/preference.ts index 85a2a9dba5..5ba5cdda60 100644 --- a/packages/terminal-next/src/common/preference.ts +++ b/packages/terminal-next/src/common/preference.ts @@ -119,6 +119,7 @@ export const enum CodeTerminalSettingId { EnableFileLinks = 'terminal.integrated.enableFileLinks', UnicodeVersion = 'terminal.integrated.unicodeVersion', ExperimentalLinkProvider = 'terminal.integrated.experimentalLinkProvider', + EnableTerminalIntellComplete = 'terminal.integrated.enableTerminalIntellComplete', LocalEchoLatencyThreshold = 'terminal.integrated.localEchoLatencyThreshold', LocalEchoEnabled = 'terminal.integrated.localEchoEnabled', LocalEchoExcludePrograms = 'terminal.integrated.localEchoExcludePrograms', @@ -310,6 +311,11 @@ export const terminalPreferenceSchema: PreferenceSchema = { description: '%preference.terminal.integrated.copyOnSelectionDesc%', default: false, }, + [CodeTerminalSettingId.EnableTerminalIntellComplete]: { + type: 'boolean', + description: '%preference.terminal.integrated.enableTerminalIntellComplete%', + default: false, + }, [CodeTerminalSettingId.LocalEchoEnabled]: { type: 'boolean', description: '%preference.terminal.integrated.localEchoDesc%', diff --git a/packages/terminal-next/src/node/index.ts b/packages/terminal-next/src/node/index.ts index 572d612603..274ee11543 100644 --- a/packages/terminal-next/src/node/index.ts +++ b/packages/terminal-next/src/node/index.ts @@ -8,15 +8,21 @@ import { ITerminalServiceClient, ITerminalServicePath, } from '../common'; +import { terminalIntellCommonDeps } from '../common/intell'; +import { ITerminalIntellEnvironment } from '../common/intell/environment'; +import { IFigSpecLoader, ITerminalSuggestionProviderPath, ITerminalSuggestionRuntime } from '../common/intell/runtime'; import { IPtyService, PtyService } from './pty'; import { PtyServiceManager, PtyServiceManagerToken } from './pty.manager'; import { IShellIntegrationService, ShellIntegrationService } from './shell-integration.service'; +import { TerminalIntellEnviromentNode } from './terminal.intell.enviroment'; +import { SpecLoaderNodeImpl } from './terminal.intell.spec.loader'; import { TerminalProcessServiceImpl } from './terminal.process.service'; import { ITerminalProfileServiceNode, TerminalProfileServiceNode } from './terminal.profile.service'; import { TerminalServiceImpl } from './terminal.service'; import { TerminalServiceClientImpl } from './terminal.service.client'; + @Injectable() export class TerminalNodePtyModule extends NodeModule { providers: Provider[] = [ @@ -48,6 +54,15 @@ export class TerminalNodePtyModule extends NodeModule { token: PtyServiceManagerToken, useClass: PtyServiceManager, }, + { + token: ITerminalIntellEnvironment, // 提供终端智能需要的 Node.js 抽象环境 + useClass: TerminalIntellEnviromentNode, + }, + { + token: IFigSpecLoader, // 动态的 Fig Spec 加载逻辑 + useClass: SpecLoaderNodeImpl, + }, + ...terminalIntellCommonDeps, ]; backServices = [ @@ -59,5 +74,9 @@ export class TerminalNodePtyModule extends NodeModule { servicePath: ITerminalProcessPath, token: ITerminalProcessService, }, + { + servicePath: ITerminalSuggestionProviderPath, // Fig Suggestion 终端智能补全能力,暴露给前端 + token: ITerminalSuggestionRuntime, + }, ]; } diff --git a/packages/terminal-next/src/node/shell-integration.service.ts b/packages/terminal-next/src/node/shell-integration.service.ts index bbf77503ea..fc00f558d8 100644 --- a/packages/terminal-next/src/node/shell-integration.service.ts +++ b/packages/terminal-next/src/node/shell-integration.service.ts @@ -53,6 +53,7 @@ export class ShellIntegrationService implements IShellIntegrationService { } __is_prompt_end() { + builtin printf '\e]6973;CWD;$PWD\a' # 记录当前目录 builtin printf '\e]6973;PE\a' # 用于标记 Shell Prompt End,作用同上 } diff --git a/packages/terminal-next/src/node/stupid-shell-intergration.ts b/packages/terminal-next/src/node/stupid-shell-intergration.ts new file mode 100644 index 0000000000..fb1aecce70 --- /dev/null +++ b/packages/terminal-next/src/node/stupid-shell-intergration.ts @@ -0,0 +1,53 @@ +import os from 'os'; +import path from 'path'; + +import fs from 'fs-extra'; + +// 未来适配 OpenSumi 的时候需要调整 +export const shellIntergrationDirPath = path.join(os.tmpdir(), '.sumi-shell', 'shell-intergration'); + +export const bashIntergrationPath = path.join(shellIntergrationDirPath, 'bash-intergration.bash'); + +/** + 注入的 bash initfile,用于 ShellIntergration 功能的搭建 + 后续会针对 ShellIntergation 做整体的架构设计,目前满足基础功能需求 + */ +export const bashIntergrationContent = String.raw` + +if [ -r /etc/profile ]; then + . /etc/profile +fi +if [ -r ~/.bashrc ]; then + . ~/.bashrc +fi +if [ -r ~/.bash_profile ]; then + . ~/.bash_profile +elif [ -r ~/.bash_login ]; then + . ~/.bash_login +elif [ -r ~/.profile ]; then + . ~/.profile +fi + +__is_prompt_start() { + builtin printf '\e]6973;PS\a' +} + +__is_prompt_end() { + builtin printf '\e]6973;PE\a' +} + +__is_update_prompt() { + if [[ "$__is_custom_PS1" == "" || "$__is_custom_PS1" != "$PS1" ]]; then + __is_original_PS1=$PS1 + __is_custom_PS1="\[$(__is_prompt_start)\]$__is_original_PS1\[$(__is_prompt_end)\]" + export PS1="$__is_custom_PS1" + fi +} + +__is_update_prompt +`; + +export const initShellIntergrationFile = async () => { + await fs.mkdirp(shellIntergrationDirPath); + await fs.writeFile(bashIntergrationPath, bashIntergrationContent); +}; diff --git a/packages/terminal-next/src/node/terminal.intell.enviroment.ts b/packages/terminal-next/src/node/terminal.intell.enviroment.ts new file mode 100644 index 0000000000..761af95eb5 --- /dev/null +++ b/packages/terminal-next/src/node/terminal.intell.enviroment.ts @@ -0,0 +1,101 @@ +import { spawn } from 'node:child_process'; +import fsAsync from 'node:fs/promises'; +import path from 'node:path'; + +import { Autowired, Injectable } from '@opensumi/di'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { + ITerminalIntellEnvironment, + ITerminalIntellLogger, + Shell, + TerminalIntellFileSystem, +} from '../common/intell/environment'; +import { CommandToken } from '../common/intell/parser'; + +export const getPathSeperator = (shell: Shell) => + shell === Shell.Bash || shell === Shell.Xonsh || shell === Shell.Nushell ? '/' : path.sep; + +// Terminal 智能补全所需要的环境 +@Injectable() +export class TerminalIntellEnviromentNode implements ITerminalIntellEnvironment { + @Autowired(INodeLogger) + protected readonly logger: INodeLogger; + + private nodeFS = { + readdir: fsAsync.readdir, + stat: fsAsync.stat, + }; + + async getFileSystem(): Promise { + return this.nodeFS; + } + + buildExecuteShellCommand(timeout: number): Fig.ExecuteCommandFunction { + return async ({ command, env, args, cwd }: Fig.ExecuteCommandInput): Promise => { + const realEnv = env || process.env; + const child = spawn(command, args, { cwd, env: { ...realEnv, ISTERM: '1' } }); + setTimeout(() => child.kill('SIGKILL'), timeout); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => (stdout += data)); + child.stderr.on('data', (data) => (stderr += data)); + child.on('error', (err) => { + this.logger.debug({ msg: 'shell command failed', e: err.message }); + }); + return new Promise((resolve) => { + child.on('close', (code) => { + this.logger.debug({ + msg: 'shell command done', + command, + args, + stdout: stdout.substring(0, 1000), + stderr, + code, + }); + resolve({ + status: code ?? 0, + stderr, + stdout, + }); + }); + }); + }; + } + async resolveCwd( + cmdToken: CommandToken | undefined, + cwd: string, + shell: Shell, + ): Promise<{ cwd: string; pathy: boolean; complete: boolean }> { + if (cmdToken == null) { + return { cwd, pathy: false, complete: false }; + } + const { token: rawToken, isQuoted } = cmdToken; + const token = !isQuoted ? rawToken.replaceAll('\\ ', ' ') : rawToken; + const sep = getPathSeperator(shell); + if (!token.includes(sep)) { + return { cwd, pathy: false, complete: false }; + } + const resolvedCwd = path.isAbsolute(token) ? token : path.join(cwd, token); + try { + await fsAsync.access(resolvedCwd, fsAsync.constants.R_OK); + return { cwd: resolvedCwd, pathy: true, complete: token.endsWith(sep) }; + } catch { + // fallback to the parent folder if possible + const baselessCwd = resolvedCwd.substring(0, resolvedCwd.length - path.basename(resolvedCwd).length); + try { + await fsAsync.access(baselessCwd, fsAsync.constants.R_OK); + return { cwd: baselessCwd, pathy: true, complete: token.endsWith(sep) }; + } catch { + // empty + } + return { cwd, pathy: false, complete: false }; + } + } + async getEnv(): Promise> { + return process.env; + } + getLogger(): ITerminalIntellLogger { + return this.logger; + } +} diff --git a/packages/terminal-next/src/node/terminal.intell.spec.loader.ts b/packages/terminal-next/src/node/terminal.intell.spec.loader.ts new file mode 100644 index 0000000000..136e60ef08 --- /dev/null +++ b/packages/terminal-next/src/node/terminal.intell.spec.loader.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@opensumi/di'; + +import { CommandToken } from '../common/intell/parser'; +import { IFigSpecLoader } from '../common/intell/runtime'; + +const versionedSpeclist = ''; + +// SpecLoaderImpl 类在 Node.js 层实现了 SpecLoader接口 +// TODO Node 层直接读取 node_modules +@Injectable() +export class SpecLoaderNodeImpl implements IFigSpecLoader { + private specSet: any = {}; // TODO 更好的 Type + private loadedSpecs: { [key: string]: Fig.Spec } = {}; + + constructor() { + this.loadSpecSet(); + } + + private async loadSpecSet() { + const speclist = (await import('@withfig/autocomplete/build/index.js')).default; + (speclist as string[]).forEach((s) => { + let activeSet = this.specSet; + const specRoutes = s.split('/'); + specRoutes.forEach((route, idx) => { + if (typeof activeSet !== 'object') { + return; + } + if (idx === specRoutes.length - 1) { + const prefix = versionedSpeclist.includes(s) ? '/index.js' : '.js'; + // HACK: 看了一下 bundle 的补全数据,这里都是 .js,为了暂先不引入 fig 的完整依赖,这里先写死了 + // const prefix = `.js`; + activeSet[route] = `@withfig/autocomplete/build/${s}${prefix}`; + } else { + activeSet[route] = activeSet[route] || {}; + activeSet = activeSet[route]; + } + }); + }); + } + + public getSpecSet(): any { + return this.specSet; + } + + public async loadSpec(cmd: CommandToken[]): Promise { + const rootToken = cmd.at(0); + if (!rootToken?.complete) { + return; + } + + if (this.loadedSpecs[rootToken.token]) { + return this.loadedSpecs[rootToken.token]; + } + if (this.specSet[rootToken.token]) { + const spec = (await import(this.specSet[rootToken.token])).default; + this.loadedSpecs[rootToken.token] = spec; + return spec; + } + } + + public async lazyLoadSpec(key: string): Promise { + return (await import(`@withfig/autocomplete/build/${key}.js`)).default; + } + + public async lazyLoadSpecLocation(location: Fig.SpecLocation): Promise { + return; + } +} diff --git a/typings/fig/index.d.ts b/typings/fig/index.d.ts new file mode 100644 index 0000000000..398ab3814d --- /dev/null +++ b/typings/fig/index.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 7db7055427..dbb65b1cf8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1001,6 +1001,23 @@ __metadata: languageName: node linkType: hard +"@fig/autocomplete-generators@npm:^2.4.0": + version: 2.4.0 + resolution: "@fig/autocomplete-generators@npm:2.4.0" + checksum: 10/eeb3959dccb2fb6da01f55e72dcdc5023e5215d64a3544cde62988bb9510bc32cb4fa71cb8292088c97f42a6cbe4a563e9bdc3631aae4e4ee83ac9c03487c414 + languageName: node + linkType: hard + +"@fig/autocomplete-helpers@npm:^1.0.7": + version: 1.0.7 + resolution: "@fig/autocomplete-helpers@npm:1.0.7" + dependencies: + semver: "npm:^7.3.5" + typescript: "npm:^4.6.3" + checksum: 10/da3b964c1a492da90fa5d69aff2bd40cf3600a3576b003010e81ef8e6c4b8e8b9d3104a9ec654205c5ce467c86674a5c5e345b67ea110dcaf2112c2dbd82e75b + languageName: node + linkType: hard + "@furyjs/fury@npm:0.5.9-beta": version: 0.5.9-beta resolution: "@furyjs/fury@npm:0.5.9-beta" @@ -2961,6 +2978,8 @@ __metadata: "@opensumi/ide-variable": "workspace:*" "@opensumi/ide-workspace": "workspace:*" "@types/http-proxy": "npm:^1.17.2" + "@withfig/autocomplete": "npm:^2.657.0" + "@withfig/autocomplete-types": "npm:^1.28.0" http-proxy: "npm:^1.18.0" node-pty: "npm:1.0.0" os-locale: "npm:^4.0.0" @@ -4746,6 +4765,26 @@ __metadata: languageName: node linkType: hard +"@withfig/autocomplete-types@npm:^1.28.0": + version: 1.31.0 + resolution: "@withfig/autocomplete-types@npm:1.31.0" + checksum: 10/9468d9022f397934eb42b0c1ea485e71dbb90da127c1b27005c43dc15a63d77ff2202ce98f80ef027ea78e9c27f655ad91f9120dc0492c77f0604ab4687efde6 + languageName: node + linkType: hard + +"@withfig/autocomplete@npm:^2.657.0": + version: 2.657.0 + resolution: "@withfig/autocomplete@npm:2.657.0" + dependencies: + "@fig/autocomplete-generators": "npm:^2.4.0" + "@fig/autocomplete-helpers": "npm:^1.0.7" + semver: "npm:^7.6.2" + strip-json-comments: "npm:^5.0.1" + yaml: "npm:^2.4.2" + checksum: 10/a1ec89704b1bfdc174017d1979eb66e4b6294e8dc9660617288b1683e57fd7b6129c04b9ba6883b7340914f78a0ac3be48a6e0c338d001a815752a65caf2e663 + languageName: node + linkType: hard + "@xmldom/xmldom@npm:^0.8.8": version: 0.8.10 resolution: "@xmldom/xmldom@npm:0.8.10" @@ -17550,6 +17589,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.2": + version: 7.6.2 + resolution: "semver@npm:7.6.2" + bin: + semver: bin/semver.js + checksum: 10/296b17d027f57a87ef645e9c725bff4865a38dfc9caf29b26aa084b85820972fbe7372caea1ba6857162fa990702c6d9c1d82297cecb72d56c78ab29070d2ca2 + languageName: node + linkType: hard + "semver@npm:~7.0.0": version: 7.0.0 resolution: "semver@npm:7.0.0" @@ -18476,6 +18524,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:^5.0.1": + version: 5.0.1 + resolution: "strip-json-comments@npm:5.0.1" + checksum: 10/b314af70c6666a71133e309a571bdb87687fc878d9fd8b38ebed393a77b89835b92f191aa6b0bc10dfd028ba99eed6b6365985001d64c5aef32a4a82456a156b + languageName: node + linkType: hard + "strip-json-comments@npm:~2.0.1": version: 2.0.1 resolution: "strip-json-comments@npm:2.0.1" @@ -19362,6 +19417,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^4.6.3": + version: 4.9.5 + resolution: "typescript@npm:4.9.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/458f7220ab11e0fc191514cc41be1707645ec9a8c2d609448a448e18c522cef9646f58728f6811185a4c35613dacdf6c98cf8965c88b3541d0288c47291e4300 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A4.9.3#optional!builtin": version: 4.9.3 resolution: "typescript@patch:typescript@npm%3A4.9.3#optional!builtin::version=4.9.3&hash=a66ed4" @@ -19382,6 +19447,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^4.6.3#optional!builtin": + version: 4.9.5 + resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin::version=4.9.5&hash=289587" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/5659316360b5cc2d6f5931b346401fa534107b68b60179cf14970e27978f0936c1d5c46f4b5b8175f8cba0430f522b3ce355b4b724c0ea36ce6c0347fab25afd + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.17.4 resolution: "uglify-js@npm:3.17.4" @@ -20547,6 +20622,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.4.2": + version: 2.4.3 + resolution: "yaml@npm:2.4.3" + bin: + yaml: bin.mjs + checksum: 10/a618d3b968e3fb601cf7266db6e250e5cdd3b81853039a59108145202d5055b47c2d23a8e1ab661f8ba3ba095dcf6b4bb55cea2c14b97a418e5b059d27f8814e + languageName: node + linkType: hard + "yargs-parser@npm:21.1.1, yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1"