diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts index 9ca8e52c4f650..62d17451d2d38 100644 --- a/web/packages/teleterm/src/services/config/appConfigSchema.ts +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -35,10 +35,6 @@ export const createAppConfigSchema = (platform: Platform) => { .boolean() .default(false) .describe('Enables collecting of anonymous usage data.'), - 'feature.searchBar': z - .boolean() - .default(true) - .describe('Replaces the command bar with the new search bar'), 'keymap.tab1': shortcutSchema .default(defaultKeymap['tab1']) .describe(getShortcutDesc('open tab 1')), diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts index 471df9495ddeb..774c67aee21b9 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts +++ b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts @@ -85,6 +85,12 @@ async function startTerminalSession( documentsService: DocumentsService, doc: types.DocumentTerminal ) { + // DELETE IN 14.0.0 + // + // Logging in to an arbitrary host was removed in 13.0 together with the command bar. + // However, there's a slight chance that some users upgrading from 12.x to 13.0 still have + // documents with loginHost in the app state (e.g. if the doc failed to connect to the server). + // Let's just remove this in 14.0.0 instead to make sure those users can safely upgrade the app. if (isDocumentTshNodeWithLoginHost(doc)) { doc = await resolveLoginHost(ctx, logger, documentsService, doc); } diff --git a/web/packages/teleterm/src/ui/QuickInput/QuickInput.story.tsx b/web/packages/teleterm/src/ui/QuickInput/QuickInput.story.tsx deleted file mode 100644 index c2f5fa9c82f92..0000000000000 --- a/web/packages/teleterm/src/ui/QuickInput/QuickInput.story.tsx +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Copyright 2020 Gravitational, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; - -import Flex from 'design/Flex'; - -import AppContextProvider from 'teleterm/ui/appContextProvider'; -import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; -import { getEmptyPendingAccessRequest } from 'teleterm/ui/services/workspacesService/accessRequestsService'; -import * as types from 'teleterm/services/tshd/types'; -import { - SuggestionCmd, - SuggestionDatabase, - SuggestionServer, - SuggestionSshLogin, -} from 'teleterm/ui/services/quickInput'; - -import QuickInput from './QuickInput'; -import QuickInputList from './QuickInputList'; - -export default { - title: 'Teleterm/QuickInput', -}; - -export const Story = () => { - const appContext = new MockAppContext(); - - appContext.workspacesService.state = { - workspaces: { - '/clusters/localhost': { - documents: [], - location: undefined, - localClusterUri: '/clusters/localhost', - accessRequests: { - pending: getEmptyPendingAccessRequest(), - isBarCollapsed: true, - }, - }, - }, - rootClusterUri: '/clusters/localhost', - }; - - appContext.clustersService.getClusters = () => { - return [cluster]; - }; - - appContext.clustersService.setState(draftState => { - draftState.clusters = new Map([[cluster.uri, cluster]]); - }); - - appContext.resourcesService.fetchServers = async () => ({ - agentsList: servers, - totalCount: 3, - startKey: '', - }); - - appContext.resourcesService.fetchDatabases = async () => ({ - agentsList: databases, - totalCount: 3, - startKey: '', - }); - - return ( - -
- -
-
- ); -}; - -export const Suggestions = () => { - const commandSuggestions: SuggestionCmd[] = [ - { - kind: 'suggestion.cmd', - token: '', - data: { - displayName: 'tsh foo', - description: 'Nulla convallis lorem ut ipsum maximus venenatis.', - }, - }, - { - kind: 'suggestion.cmd', - token: '', - data: { - displayName: 'tsh bar', - description: 'Vivamus id nulla sed neque efficitur ornare nec in diam.', - }, - }, - { - kind: 'suggestion.cmd', - token: '', - data: { - displayName: 'tsh quux foo', - description: - 'Sed porta nibh eget lacus suscipit vehicula. Curabitur eget sapien in lacus blandit pretium.', - }, - }, - { - kind: 'suggestion.cmd', - token: '', - data: { - displayName: 'tsh baz quux', - description: 'Etiam cursus magna at feugiat ornare.', - }, - }, - ]; - - const loginSuggestions: SuggestionSshLogin[] = - cluster.loggedInUser.sshLoginsList.map(login => ({ - kind: 'suggestion.ssh-login', - token: '', - appendToToken: '', - data: login, - })); - - const serverSuggestions: SuggestionServer[] = servers.map(server => ({ - kind: 'suggestion.server', - token: '', - data: server, - })); - - const dbSuggestions: SuggestionDatabase[] = databases.map(db => ({ - kind: 'suggestion.database', - token: '', - data: db, - })); - - return ( - - - - - - - ); -}; - -const defaultWidth = 200; -const defaultHeight = 200; - -const QuickInputListWrapper = ({ - items, - width = defaultWidth, - height = defaultHeight, -}) => { - return ( -
- {}} - /> -
- ); -}; - -const longIdentifier = - 'lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit-quisque-elementum-nulla'; - -const servers: types.Server[] = [ - { - uri: '/clusters/localhost/servers/foo' as const, - tunnel: false, - name: '2018454d-ef3b-4b15-84f7-61ca213d37e3', - hostname: 'foo', - addr: 'foo.localhost', - labelsList: [ - { name: 'env', value: 'prod' }, - { name: 'kernel', value: '5.15.0-1023-aws' }, - ], - }, - { - uri: '/clusters/localhost/servers/bar' as const, - tunnel: false, - name: '24c7aebe-4741-4464-ab69-f076fe467ebd', - hostname: 'bar', - addr: 'bar.localhost', - labelsList: [ - { name: 'env', value: 'staging' }, - { name: 'kernel', value: '5.14.1-1058-aws' }, - ], - }, - { - uri: '/clusters/localhost/servers/lorem' as const, - tunnel: false, - name: '24c7aebe-4741-4464-ab69-f076fe467ebd', - hostname: longIdentifier, - addr: 'lorem.localhost', - labelsList: [ - { name: 'env', value: 'staging' }, - { name: 'kernel', value: '5.14.1-1058-aws' }, - { name: 'lorem', value: longIdentifier }, - { name: 'kernel2', value: '5.14.1-1058-aws' }, - { name: 'env2', value: 'staging' }, - { name: 'kernel3', value: '5.14.1-1058-aws' }, - ], - }, -]; - -const databases: types.Database[] = [ - { - uri: '/clusters/localhost/dbs/postgres' as const, - name: 'postgres', - desc: 'A PostgreSQL database', - protocol: 'postgres', - type: 'self-hosted', - hostname: 'postgres.localhost', - addr: 'postgres.localhost', - labelsList: [ - { name: 'env', value: 'prod' }, - { name: 'kernel', value: '5.15.0-1023-aws' }, - ], - }, - { - uri: '/clusters/localhost/dbs/mysql' as const, - name: 'mysql', - desc: 'A MySQL database', - protocol: 'mysql', - type: 'self-hosted', - hostname: 'mysql.localhost', - addr: 'mysql.localhost', - labelsList: [ - { name: 'env', value: 'staging' }, - { name: 'kernel', value: '5.14.1-1058-aws' }, - ], - }, - { - uri: '/clusters/localhost/dbs/lorem' as const, - name: longIdentifier, - desc: 'Vestibulum ut blandit est, sed dapibus sem. Pellentesque egestas mi eu scelerisque ultricies.', - protocol: 'mysql', - type: 'self-hosted', - hostname: 'lorem.localhost', - addr: 'lorem.localhost', - labelsList: [ - { name: 'env', value: 'staging' }, - { name: 'kernel', value: '5.14.1-1058-aws' }, - { name: 'lorem', value: longIdentifier }, - { name: 'kernel2', value: '5.14.1-1058-aws' }, - { name: 'env2', value: 'staging' }, - { name: 'kernel3', value: '5.14.1-1058-aws' }, - ], - }, -]; - -const cluster = { - uri: '/clusters/localhost' as const, - name: 'Test', - leaf: false, - connected: true, - proxyHost: 'localhost:3080', - authClusterId: '73c4746b-d956-4f16-9848-4e3469f70762', - loggedInUser: { - activeRequestsList: [], - name: 'admin', - acl: {}, - sshLoginsList: ['root', 'ubuntu', 'ansible', longIdentifier], - rolesList: [], - requestableRolesList: [], - suggestedReviewersList: [], - }, -}; diff --git a/web/packages/teleterm/src/ui/QuickInput/QuickInput.tsx b/web/packages/teleterm/src/ui/QuickInput/QuickInput.tsx deleted file mode 100644 index 29ce6c9b37618..0000000000000 --- a/web/packages/teleterm/src/ui/QuickInput/QuickInput.tsx +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Copyright 2021 Gravitational, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import styled from 'styled-components'; -import { debounce } from 'shared/utils/highbar'; -import { Box, Flex } from 'design'; -import { color, height, space, width } from 'styled-system'; -import { Spinner } from 'design/Icon'; - -import { useAppContext } from 'teleterm/ui/appContextProvider'; - -import useQuickInput from './useQuickInput'; -import QuickInputList from './QuickInputList'; - -export default function Container() { - const { workspacesService } = useAppContext(); - - workspacesService.useState(); - - if (!workspacesService.getRootClusterUri()) { - return null; - } - return ; -} - -function QuickInput() { - const props = useQuickInput(); - const { visible, activeSuggestion, suggestionsAttempt, inputValue } = props; - const hasSuggestions = - suggestionsAttempt.data?.length > 0 && - suggestionsAttempt.status === 'success'; - const refInput = useRef(); - const measuringInputRef = useRef(); - const refList = useRef(); - const refContainer = useRef(); - const [measuredInputTextWidth, setMeasuredInputTextWidth] = - useState(); - - const handleInputChange = useMemo(() => { - return debounce(() => { - props.onInputChange(refInput.current.value); - measureInputTextWidth(); - }, 100); - }, []); - - // Update input value if it changed outside of this component. This happens when the user pick an - // autocomplete suggestion. - useEffect(() => { - if (refInput.current.value !== inputValue) { - refInput.current.value = inputValue; - measureInputTextWidth(); - } - }, [inputValue]); - - function handleOnFocus(e: React.SyntheticEvent) { - // trigger a callback when focus is coming from external element - if (!refContainer.current.contains(e['relatedTarget'])) { - props.onFocus(e); - } - - // ensure that - if (!visible) { - props.onShow(); - } - } - - function handleOnBlur(e: any) { - const inside = - e?.relatedTarget?.contains(refInput.current) || - e?.relatedTarget?.contains(refList.current); - - if (inside) { - refInput.current.focus(); - return; - } - - props.onHide(); - } - - const handleArrowKey = (e: React.KeyboardEvent, nudge = 0) => { - e.stopPropagation(); - if (!hasSuggestions) { - return; - } - const next = getNext( - activeSuggestion + nudge, - suggestionsAttempt.data?.length - ); - props.onActiveSuggestion(next); - }; - - const measureInputTextWidth = () => { - const width = measuringInputRef.current?.getBoundingClientRect().width || 0; - setMeasuredInputTextWidth(width); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - const keyCode = e.which; - switch (keyCode) { - case KeyEnum.RETURN: - e.stopPropagation(); - e.preventDefault(); - - props.onEnter(activeSuggestion); - return; - case KeyEnum.ESC: - props.onEscape(); - return; - case KeyEnum.TAB: - return; - case KeyEnum.UP: - e.stopPropagation(); - e.preventDefault(); - handleArrowKey(e, -1); - return; - case KeyEnum.DOWN: - e.stopPropagation(); - e.preventDefault(); - handleArrowKey(e, 1); - return; - } - }; - - useEffect(() => { - if (visible) { - refInput.current.focus(); - } - - return () => handleInputChange.cancel(); - }, [visible]); - - return ( - props.theme.space[7]}px * 2); - height: 100%; - `} - flex={1} - ref={refContainer} - onFocus={handleOnFocus} - onBlur={handleOnBlur} - > - {inputValue} - - {suggestionsAttempt.status === 'processing' && ( - - - - )} - {!visible && {props.keyboardShortcut}} - {visible && hasSuggestions && ( - - )} - - ); -} - -const MeasuringInput = styled.span` - z-index: -1; - font-size: 14px; - padding-left: 8px; - position: absolute; - visibility: hidden; -`; - -const Input = styled.input(props => { - const { theme } = props; - return { - height: '100%', - background: 'inherit', - display: 'flex', - flex: '1', - zIndex: '0', - boxSizing: 'border-box', - color: theme.colors.text.main, - width: '100%', - fontSize: '14px', - border: `0.5px ${theme.colors.action.disabledBackground} solid`, - borderRadius: '4px', - outline: 'none', - padding: props.isOpened ? '2px 8px' : '2px 46px 2px 8px', // wider right margin makes place for a shortcut - '::placeholder': { - color: theme.colors.text.slightlyMuted, - }, - '&:hover, &:focus': { - color: theme.colors.text.main, - borderColor: theme.colors.light, - }, - '&:focus': { - borderColor: theme.colors.brand, - backgroundColor: theme.colors.levels.sunken, - '::placeholder': { - color: theme.colors.text.muted, - }, - }, - - ...space(props), - ...width(props), - ...height(props), - ...color(props), - }; -}); - -const Shortcut = styled(Box)` - position: absolute; - right: 12px; - top: 12px; - padding: 2px 3px; - color: ${({ theme }) => theme.colors.text.slightlyMuted}; - background-color: ${({ theme }) => theme.colors.levels.surface}; - line-height: 12px; - font-size: 12px; - border-radius: 2px; -`; - -const Animate = styled(Box)` - position: absolute; - right: 12px; - top: 12px; - padding: 2px 2px; - line-height: 12px; - font-size: 12px; - animation: spin 1s linear infinite; - @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } -`; - -const KeyEnum = { - BACKSPACE: 8, - TAB: 9, - RETURN: 13, - ALT: 18, - ESC: 27, - SPACE: 32, - PAGE_UP: 33, - PAGE_DOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - DELETE: 46, - COMMA: 188, - PERIOD: 190, - A: 65, - Z: 90, - ZERO: 48, - NUMPAD_0: 96, - NUMPAD_9: 105, -}; - -function getNext(selectedIndex = 0, max = 0) { - let index = selectedIndex % max; - if (index < 0) { - index += max; - } - return index; -} diff --git a/web/packages/teleterm/src/ui/QuickInput/QuickInputList/QuickInputList.tsx b/web/packages/teleterm/src/ui/QuickInput/QuickInputList/QuickInputList.tsx deleted file mode 100644 index ea0f1e2f1742f..0000000000000 --- a/web/packages/teleterm/src/ui/QuickInput/QuickInputList/QuickInputList.tsx +++ /dev/null @@ -1,223 +0,0 @@ -/* -Copyright 2018 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; -import { Box, Flex, Label, Text } from 'design'; -import { makeLabelTag } from 'teleport/components/formatters'; - -import { Cli, Server, Person, Database } from 'design/Icon'; - -import * as types from 'teleterm/ui/services/quickInput/types'; - -const QuickInputList = React.forwardRef((props, ref) => { - // Ideally, this property would be described by the suggestion object itself rather than depending - // on `kind`. But for now we need it just for a single suggestion kind anyway. - const shouldSuggestionsStayInPlace = - props.items[0]?.kind === 'suggestion.cmd'; - const activeItemRef = useRef(); - const { items, activeItem } = props; - if (items.length === 0) { - return null; - } - - useEffect(() => { - // `false` - bottom of the element will be aligned to the bottom of the visible area of the scrollable ancestor - activeItemRef.current?.scrollIntoView(false); - }, [activeItem]); - - const $items = items.map((r, index) => { - const Cmpt = ComponentMap[r.kind] || UnknownItem; - const isActive = index === activeItem; - return ( - - - - ); - }); - - function handleClick(e: React.SyntheticEvent) { - const el = e.target; - if (el instanceof Element) { - const itemEl = el.closest('[data-attr]'); - props.onPick(parseInt(itemEl.getAttribute('data-attr'))); - } - } - - return ( - - {$items} - - ); -}); - -export default QuickInputList; - -function CmdItem(props: { item: types.SuggestionCmd }) { - return ( - - - - - {/* Equivalent of flex-shrink: 0, but styled-system doesn't support flex-shrink. */} - - {props.item.data.displayName} - - {props.item.data.description} - - ); -} - -function SshLoginItem(props: { item: types.SuggestionSshLogin }) { - return ( - - - - - {props.item.data} - - ); -} - -function ServerItem(props: { item: types.SuggestionServer }) { - const { hostname, labelsList } = props.item.data; - const $labels = labelsList.map((label, index) => ( - - )); - - return ( - - - - - - {hostname} - {$labels} - - - ); -} - -function DatabaseItem(props: { item: types.SuggestionDatabase }) { - const db = props.item.data; - const $labels = db.labelsList.map((label, index) => ( - - )); - - return ( - - - - - - - {db.name} - - - {db.type}/{db.protocol} - - - - {$labels} - - - ); -} - -function UnknownItem(props: { item: types.Suggestion }) { - const { kind } = props.item; - return
unknown kind: {kind}
; -} - -const StyledItem = styled.div(({ theme, $active }) => { - return { - '&:hover, &:focus': { - cursor: 'pointer', - background: theme.colors.levels.elevated, - }, - - padding: '2px 8px', - color: theme.colors.text.main, - background: $active - ? theme.colors.levels.surface - : theme.colors.levels.sunken, - }; -}); - -const StyledGlobalSearchResults = styled.div(({ theme, position }) => { - return { - boxShadow: '8px 8px 18px rgb(0 0 0)', - color: theme.colors.text.main, - background: theme.colors.levels.surface, - boxSizing: 'border-box', - marginTop: '42px', - left: position ? position + 'px' : 0, - display: 'block', - transition: '0.12s', - position: 'absolute', - borderRadius: '4px', - fontSize: '12px', - listStyle: 'none outside none', - textShadow: 'none', - zIndex: '1000', - maxHeight: '350px', - overflow: 'auto', - }; -}); - -const ComponentMap: Record< - types.Suggestion['kind'], - React.FC<{ item: types.Suggestion }> -> = { - ['suggestion.cmd']: CmdItem, - ['suggestion.ssh-login']: SshLoginItem, - ['suggestion.server']: ServerItem, - ['suggestion.database']: DatabaseItem, -}; - -type Props = { - items: types.Suggestion[]; - activeItem: number; - position: number; - onPick(index: number): void; -}; - -const SquareIconBackground = styled(Box)` - background: ${props => props.color}; - display: flex; - align-items: center; - justify-content: center; - height: 14px; - width: 14px; - margin-right: 8px; - border-radius: 2px; - padding: 4px; -`; diff --git a/web/packages/teleterm/src/ui/QuickInput/QuickInputList/index.ts b/web/packages/teleterm/src/ui/QuickInput/QuickInputList/index.ts deleted file mode 100644 index 1c8ca8cde7b34..0000000000000 --- a/web/packages/teleterm/src/ui/QuickInput/QuickInputList/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import QuickInputList from './QuickInputList'; - -export default QuickInputList; diff --git a/web/packages/teleterm/src/ui/QuickInput/index.ts b/web/packages/teleterm/src/ui/QuickInput/index.ts deleted file mode 100644 index 672db18a6f0a7..0000000000000 --- a/web/packages/teleterm/src/ui/QuickInput/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import QuickInput from './QuickInput'; -export default QuickInput; diff --git a/web/packages/teleterm/src/ui/QuickInput/useQuickInput.ts b/web/packages/teleterm/src/ui/QuickInput/useQuickInput.ts deleted file mode 100644 index 1e49de33d2419..0000000000000 --- a/web/packages/teleterm/src/ui/QuickInput/useQuickInput.ts +++ /dev/null @@ -1,215 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useEffect } from 'react'; - -import { CanceledError, useAsync } from 'shared/hooks/useAsync'; - -import { useAppContext } from 'teleterm/ui/appContextProvider'; -import { - useKeyboardShortcuts, - useKeyboardShortcutFormatters, -} from 'teleterm/ui/services/keyboardShortcuts'; -import { - AutocompleteCommand, - AutocompleteToken, - Suggestion, -} from 'teleterm/ui/services/quickInput/types'; -import { routing } from 'teleterm/ui/uri'; -import { KeyboardShortcutAction } from 'teleterm/services/config'; - -import { assertUnreachable, retryWithRelogin } from '../utils'; - -export default function useQuickInput() { - const appContext = useAppContext(); - const { - quickInputService, - workspacesService, - commandLauncher, - usageService, - } = appContext; - workspacesService.useState(); - const documentsService = - workspacesService.getActiveWorkspaceDocumentService(); - const { visible, inputValue } = quickInputService.useState(); - const [activeSuggestion, setActiveSuggestion] = React.useState(0); - - const parseResult = React.useMemo( - () => quickInputService.parse(inputValue), - // `localClusterUri` has been added to refresh suggestions from - // `QuickSshLoginPicker` and `QuickServerPicker` when it changes - [inputValue, workspacesService.getActiveWorkspace()?.localClusterUri] - ); - - const [suggestionsAttempt, getSuggestions] = useAsync(() => - retryWithRelogin( - appContext, - workspacesService.getActiveWorkspace()?.localClusterUri, - () => parseResult.getSuggestions() - ) - ); - - useEffect(() => { - async function get() { - const [, err] = await getSuggestions(); - if (err && !(err instanceof CanceledError)) { - appContext.notificationsService.notifyError({ - title: 'Could not fetch command bar suggestions', - description: err.message, - }); - } - } - - get(); - }, [parseResult]); - - const hasSuggestions = - suggestionsAttempt.status === 'success' && - suggestionsAttempt.data.length > 0; - const openSearchBarShortcutAction: KeyboardShortcutAction = 'openSearchBar'; - const { getAccelerator } = useKeyboardShortcutFormatters(); - - const onFocus = (e: any) => { - if (e.relatedTarget) { - quickInputService.lastFocused = new WeakRef(e.relatedTarget); - } - }; - - const onActiveSuggestion = (index: number) => { - if (!hasSuggestions) { - return; - } - setActiveSuggestion(index); - }; - - const onEnter = (index?: number) => { - if (!hasSuggestions || !visible) { - executeCommand(parseResult.command); - return; - } - - pickSuggestion(parseResult.targetToken, suggestionsAttempt.data, index); - }; - - const executeCommand = (command: AutocompleteCommand) => { - switch (command.kind) { - case 'command.unknown': { - const params = routing.parseClusterUri( - workspacesService.getActiveWorkspace()?.localClusterUri - ).params; - // ugly hack but QuickInput will be removed in v13 - if (inputValue.startsWith('tsh proxy db')) { - usageService.captureProtocolUse( - workspacesService.getRootClusterUri(), - 'db', - 'search_bar' - ); - } - documentsService.openNewTerminal({ - initCommand: inputValue, - rootClusterId: routing.parseClusterUri( - workspacesService.getRootClusterUri() - ).params.rootClusterId, - leafClusterId: params.leafClusterId, - }); - break; - } - case 'command.tsh-ssh': { - const { localClusterUri } = workspacesService.getActiveWorkspace(); - - commandLauncher.executeCommand('tsh-ssh', { - loginHost: command.loginHost, - localClusterUri, - origin: 'search_bar', - }); - break; - } - case 'command.tsh-install': { - commandLauncher.executeCommand('tsh-install', undefined); - break; - } - case 'command.tsh-uninstall': { - commandLauncher.executeCommand('tsh-uninstall', undefined); - break; - } - default: { - assertUnreachable(command); - } - } - - quickInputService.clearInputValueAndHide(); - }; - - const pickSuggestion = ( - targetToken: AutocompleteToken, - suggestions: Suggestion[], - index?: number - ) => { - const suggestion = suggestions[index]; - - setActiveSuggestion(index); - quickInputService.pickSuggestion(targetToken, suggestion); - }; - - const onEscape = () => { - setActiveSuggestion(0); - - // If there are suggestions to show, the first onBack call should always just close the - // suggestions and the second call should actually go back. - if (visible && hasSuggestions) { - quickInputService.hide(); - } else { - quickInputService.goBack(); - } - }; - - useKeyboardShortcuts({ - [openSearchBarShortcutAction]: () => { - quickInputService.show(); - }, - }); - - // Reset active suggestion when the suggestion list changes. - // We extract just the tokens and stringify the list to avoid stringifying big objects. - // See https://github.com/facebook/react/issues/14476#issuecomment-471199055 - // TODO(ravicious): Remove the unnecessary effect. - // https://beta.reactjs.org/learn/you-might-not-need-an-effect#chains-of-computations - // https://beta.reactjs.org/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes - useEffect(() => { - setActiveSuggestion(0); - }, [ - // We want to reset the active suggestion only if the - // suggestions have changed. - suggestionsAttempt.data?.map(suggestion => suggestion.token).join(','), - ]); - - return { - visible, - suggestionsAttempt, - activeSuggestion, - inputValue, - onFocus, - onEscape, - onEnter, - onActiveSuggestion, - onInputChange: quickInputService.setInputValue, - onHide: quickInputService.hide, - onShow: quickInputService.show, - keyboardShortcut: getAccelerator(openSearchBarShortcutAction), - }; -} - -export type State = ReturnType; diff --git a/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx b/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx index 72f6f2e46fd1d..793d716d9f078 100644 --- a/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx +++ b/web/packages/teleterm/src/ui/TopBar/AdditionalActions.tsx @@ -40,12 +40,7 @@ type MenuItem = { function useMenuItems(): MenuItem[] { const ctx = useAppContext(); - const { - workspacesService, - mainProcessClient, - configService, - notificationsService, - } = ctx; + const { workspacesService, mainProcessClient, notificationsService } = ctx; workspacesService.useState(); ctx.clustersService.useState(); const documentsService = @@ -61,12 +56,11 @@ function useMenuItems(): MenuItem[] { const { platform } = mainProcessClient.getRuntimeSettings(); const isDarwin = platform === 'darwin'; - const isSearchBarEnabled = configService.get('feature.searchBar').value; const menuItems: MenuItem[] = [ { title: 'Open new terminal', - isVisible: isSearchBarEnabled, + isVisible: true, Icon: icons.Terminal, keyboardShortcutAction: 'newTerminalTab', onNavigate: openTerminalTab, @@ -82,7 +76,7 @@ function useMenuItems(): MenuItem[] { }, { title: 'Install tsh in PATH', - isVisible: isSearchBarEnabled && isDarwin, + isVisible: isDarwin, Icon: icons.Link, onNavigate: () => { ctx.commandLauncher.executeCommand('tsh-install', undefined); @@ -90,7 +84,7 @@ function useMenuItems(): MenuItem[] { }, { title: 'Remove tsh from PATH', - isVisible: isSearchBarEnabled && isDarwin, + isVisible: isDarwin, Icon: icons.Unlink, onNavigate: () => { ctx.commandLauncher.executeCommand('tsh-uninstall', undefined); diff --git a/web/packages/teleterm/src/ui/TopBar/TopBar.tsx b/web/packages/teleterm/src/ui/TopBar/TopBar.tsx index e61ec37bb7609..ab435bce7e278 100644 --- a/web/packages/teleterm/src/ui/TopBar/TopBar.tsx +++ b/web/packages/teleterm/src/ui/TopBar/TopBar.tsx @@ -18,9 +18,7 @@ import React from 'react'; import styled from 'styled-components'; import { Flex } from 'design'; -import QuickInput from '../QuickInput'; import { SearchBar } from '../Search'; -import { useAppContext } from '../appContextProvider'; import { Connections } from './Connections'; import { Clusters } from './Clusters'; @@ -28,9 +26,6 @@ import { Identity } from './Identity'; import { AdditionalActions } from './AdditionalActions'; export function TopBar() { - const { configService } = useAppContext(); - const isSearchBarEnabled = configService.get('feature.searchBar').value; - return ( @@ -38,7 +33,7 @@ export function TopBar() { - {isSearchBarEnabled ? : } + diff --git a/web/packages/teleterm/src/ui/appContext.ts b/web/packages/teleterm/src/ui/appContext.ts index ea1412bd06147..ae7548f93c978 100644 --- a/web/packages/teleterm/src/ui/appContext.ts +++ b/web/packages/teleterm/src/ui/appContext.ts @@ -27,7 +27,6 @@ import { ClustersService } from 'teleterm/ui/services/clusters'; import { ModalsService } from 'teleterm/ui/services/modals'; import { TerminalsService } from 'teleterm/ui/services/terminals'; import { ConnectionTrackerService } from 'teleterm/ui/services/connectionTracker'; -import { QuickInputService } from 'teleterm/ui/services/quickInput'; import { StatePersistenceService } from 'teleterm/ui/services/statePersistence'; import { KeyboardShortcutsService } from 'teleterm/ui/services/keyboardShortcuts'; import { WorkspacesService } from 'teleterm/ui/services/workspacesService/workspacesService'; @@ -48,7 +47,6 @@ export default class AppContext implements IAppContext { notificationsService: NotificationsService; terminalsService: TerminalsService; keyboardShortcutsService: KeyboardShortcutsService; - quickInputService: QuickInputService; statePersistenceService: StatePersistenceService; workspacesService: WorkspacesService; mainProcessClient: MainProcessClient; @@ -118,13 +116,6 @@ export default class AppContext implements IAppContext { this.commandLauncher = new CommandLauncher(this); - this.quickInputService = new QuickInputService( - this.commandLauncher, - this.clustersService, - this.resourcesService, - this.workspacesService - ); - this.connectionTracker = new ConnectionTrackerService( this.statePersistenceService, this.workspacesService, diff --git a/web/packages/teleterm/src/ui/commandLauncher.test.ts b/web/packages/teleterm/src/ui/commandLauncher.test.ts deleted file mode 100644 index 99ba9cf9c7f17..0000000000000 --- a/web/packages/teleterm/src/ui/commandLauncher.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; - -import { CommandLauncher } from './commandLauncher'; - -it('returns tsh install & uninstall autocomplete command on macOS', () => { - const appContext = new MockAppContext({ platform: 'darwin' }); - const commandLauncher = new CommandLauncher(appContext); - const autocompleteCommandNames = commandLauncher - .getAutocompleteCommands() - .map(c => c.displayName); - - expect(autocompleteCommandNames).toContain('tsh install'); - expect(autocompleteCommandNames).toContain('tsh uninstall'); -}); - -it('does not return tsh install & uninstall autocomplete command on Linux', () => { - const appContext = new MockAppContext({ platform: 'linux' }); - const commandLauncher = new CommandLauncher(appContext); - const autocompleteCommandNames = commandLauncher - .getAutocompleteCommands() - .map(c => c.displayName); - - expect(autocompleteCommandNames).not.toContain('tsh install'); - expect(autocompleteCommandNames).not.toContain('tsh uninstall'); -}); - -it('does not return tsh install & uninstall autocomplete command on Windows', () => { - const appContext = new MockAppContext({ platform: 'win32' }); - const commandLauncher = new CommandLauncher(appContext); - const autocompleteCommandNames = commandLauncher - .getAutocompleteCommands() - .map(c => c.displayName); - - expect(autocompleteCommandNames).not.toContain('tsh install'); - expect(autocompleteCommandNames).not.toContain('tsh uninstall'); -}); diff --git a/web/packages/teleterm/src/ui/commandLauncher.ts b/web/packages/teleterm/src/ui/commandLauncher.ts index be5780d5c8625..3e3294fbce2c3 100644 --- a/web/packages/teleterm/src/ui/commandLauncher.ts +++ b/web/packages/teleterm/src/ui/commandLauncher.ts @@ -15,41 +15,9 @@ limitations under the License. */ import { IAppContext } from 'teleterm/ui/types'; -import { ClusterUri, RootClusterUri, routing } from 'teleterm/ui/uri'; -import { Platform } from 'teleterm/mainProcess/types'; -import { DocumentOrigin } from 'teleterm/ui/services/workspacesService'; +import { ClusterUri, RootClusterUri } from 'teleterm/ui/uri'; const commands = { - // For handling "tsh ssh" executed from the command bar. - 'tsh-ssh': { - displayName: '', - description: '', - async run( - ctx: IAppContext, - args: { - loginHost: string; - localClusterUri: ClusterUri; - origin: DocumentOrigin; - } - ) { - const { loginHost, localClusterUri, origin } = args; - const rootClusterUri = routing.ensureRootClusterUri(localClusterUri); - const documentsService = - ctx.workspacesService.getWorkspaceDocumentService(rootClusterUri); - - const doc = documentsService.createTshNodeDocumentFromLoginHost( - localClusterUri, - loginHost, - { origin } - ); - - await ctx.workspacesService.setActiveWorkspace(rootClusterUri); - - documentsService.add(doc); - documentsService.setLocation(doc.uri); - }, - }, - 'tsh-install': { displayName: '', description: '', @@ -147,31 +115,6 @@ const commands = { }, }; -const autocompleteCommands: { - displayName: string; - description: string; - platforms?: Array; -}[] = [ - { - displayName: 'tsh ssh', - description: 'Run shell or execute a command on a remote SSH node', - }, - { - displayName: 'tsh proxy db', - description: 'Start a local proxy for a database connection', - }, - { - displayName: 'tsh install', - description: 'Install tsh in PATH', - platforms: ['darwin'], - }, - { - displayName: 'tsh uninstall', - description: 'Uninstall tsh from PATH', - platforms: ['darwin'], - }, -]; - export class CommandLauncher { appContext: IAppContext; @@ -183,15 +126,6 @@ export class CommandLauncher { commands[name].run(this.appContext, args as any); return undefined; } - - getAutocompleteCommands() { - const { platform } = this.appContext.mainProcessClient.getRuntimeSettings(); - - return autocompleteCommands.filter(command => { - const platforms = command.platforms; - return !command.platforms || platforms.includes(platform); - }); - } } type CommandName = keyof typeof commands; diff --git a/web/packages/teleterm/src/ui/services/quickInput/index.ts b/web/packages/teleterm/src/ui/services/quickInput/index.ts deleted file mode 100644 index 3b2ba6ee632b1..0000000000000 --- a/web/packages/teleterm/src/ui/services/quickInput/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export * from './quickInputService'; -export * from './types'; diff --git a/web/packages/teleterm/src/ui/services/quickInput/parsers.test.ts b/web/packages/teleterm/src/ui/services/quickInput/parsers.test.ts deleted file mode 100644 index 0c513f080c12f..0000000000000 --- a/web/packages/teleterm/src/ui/services/quickInput/parsers.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { QuickSshLoginSuggester, QuickServerSuggester } from './suggesters'; - -// Jest doesn't let us selectively automock classes. See https://github.com/facebook/jest/issues/11995 -// -// So instead for now we just mock all classes in the module and then do `jest.requireActual` when -// we need to have the actual class when writing tests for it. -jest.mock('./parsers'); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -test("tsh ssh picker returns unknown command if it's missing the first positional arg", async () => { - const QuickSshLoginSuggesterMock = QuickSshLoginSuggester as jest.MockedClass< - typeof QuickSshLoginSuggester - >; - const QuickServerSuggesterMock = QuickServerSuggester as jest.MockedClass< - typeof QuickServerSuggester - >; - const ActualQuickTshSshParser = - jest.requireActual('./parsers').QuickTshSshParser; - - const parser = new ActualQuickTshSshParser( - new QuickSshLoginSuggesterMock(undefined, undefined), - new QuickServerSuggesterMock(undefined, undefined) - ); - - const emptyInput = await parser.parse('', 0); - expect(emptyInput.command).toEqual({ kind: 'command.unknown' }); - - const whitespace = await parser.parse(' ', 0); - expect(whitespace.command).toEqual({ kind: 'command.unknown' }); -}); - -test('tsh ssh picker returns unknown command if the input includes any additional flags', async () => { - const QuickSshLoginSuggesterMock = QuickSshLoginSuggester as jest.MockedClass< - typeof QuickSshLoginSuggester - >; - const QuickServerSuggesterMock = QuickServerSuggester as jest.MockedClass< - typeof QuickServerSuggester - >; - const ActualQuickTshSshParser = - jest.requireActual('./parsers').QuickTshSshParser; - - const parser = new ActualQuickTshSshParser( - new QuickSshLoginSuggesterMock(undefined, undefined), - new QuickServerSuggesterMock(undefined, undefined) - ); - - const fullFlagBefore = await parser.parse('--foo user@node', 0); - expect(fullFlagBefore.command).toEqual({ kind: 'command.unknown' }); - - const shortFlagBefore = await parser.parse('-p 22 user@node', 0); - expect(shortFlagBefore.command).toEqual({ kind: 'command.unknown' }); - - const commandAfter = await parser.parse('user@node ls', 0); - expect(commandAfter.command).toEqual({ kind: 'command.unknown' }); -}); diff --git a/web/packages/teleterm/src/ui/services/quickInput/parsers.ts b/web/packages/teleterm/src/ui/services/quickInput/parsers.ts deleted file mode 100644 index f12b0bce0aef0..0000000000000 --- a/web/packages/teleterm/src/ui/services/quickInput/parsers.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { CommandLauncher } from 'teleterm/ui/commandLauncher'; - -import { - AutocompleteCommand, - AutocompleteToken, - SuggestionCmd, - QuickInputParser, - ParseResult, -} from './types'; -import * as suggesters from './suggesters'; - -// Pair of helper values to return when a given parser arrives at a situation where it knows there -// will be no suggestions to return (and hence also no useful target token to return). -const emptyTargetToken: AutocompleteToken = { value: '', startIndex: 0 }; -const noSuggestions = () => Promise.resolve([]); - -export class QuickCommandParser implements QuickInputParser { - private parserRegistry: Map; - - constructor(private launcher: CommandLauncher) { - this.parserRegistry = new Map(); - } - - registerParserForCommand(command: string, parser: QuickInputParser) { - this.parserRegistry.set(command, parser); - } - - // TODO(ravicious): Handle env vars. - parse(rawInput: string): ParseResult { - // We can safely ignore any whitespace at the start. However, `startIndex` needs to account for - // any removed whitespace. - const input = rawInput.trimStart(); - const targetToken = { - value: input, - startIndex: rawInput.indexOf(input), - }; - - // Return all commands if there's no input. - if (input === '') { - const autocompleteCommands = this.launcher.getAutocompleteCommands(); - - return { - targetToken, - command: { kind: 'command.unknown' }, - getSuggestions: () => - Promise.resolve( - this.mapAutocompleteCommandsToSuggestions(autocompleteCommands) - ), - }; - } - - const matchingAutocompleteCommands = this.launcher - .getAutocompleteCommands() - .filter(cmd => { - const completeMatchRegex = new RegExp(`^${cmd.displayName}\\b`, 'i'); - return ( - cmd.displayName.startsWith(input.toLowerCase()) || - // `completeMatchRegex` handles situations where the `input` akin to "tsh ssh foo". - // In that case, "tsh ssh" is the matching command, even though - // `cmd.displayName` ("tsh ssh") doesn't start with `input` ("tsh ssh foo"). - completeMatchRegex.test(input) - ); - }); - - if (matchingAutocompleteCommands.length === 0) { - return { - targetToken: emptyTargetToken, - command: { kind: 'command.unknown' }, - getSuggestions: noSuggestions, - }; - } - - if (matchingAutocompleteCommands.length > 1) { - return { - targetToken, - command: { kind: 'command.unknown' }, - getSuggestions: () => - Promise.resolve( - this.mapAutocompleteCommandsToSuggestions( - matchingAutocompleteCommands - ) - ), - }; - } - - // The rest of the function body handles situation in which there's only one matching - // autocomplete command. - - // Handles a complete match, for example the input is `tsh ssh`. - const soleMatch = matchingAutocompleteCommands[0]; - const commandToken = soleMatch.displayName; - const completeMatchRegex = new RegExp(`^${commandToken}\\b`, 'i'); - const isCompleteMatch = completeMatchRegex.test(input); - - if (isCompleteMatch) { - const inputWithoutCommandPrefix = input.replace(completeMatchRegex, ''); - // Add length of the command we just replaced with an empty string to startIndex, - // so that the next parser has the correct index for the target token. - const commandStartIndex = targetToken.startIndex + commandToken.length; - const nextQuickInputParser = this.parserRegistry.get(commandToken); - - return nextQuickInputParser.parse( - inputWithoutCommandPrefix, - commandStartIndex - ); - } - - // Handles a non-complete match with only a single matching command, for example if the input is - // `tsh ss` (`tsh ssh` should be the only matching command then). - return { - targetToken, - command: { kind: 'command.unknown' }, - getSuggestions: () => - Promise.resolve( - this.mapAutocompleteCommandsToSuggestions( - matchingAutocompleteCommands - ) - ), - }; - } - - private mapAutocompleteCommandsToSuggestions( - commands: { displayName: string; description: string }[] - ): SuggestionCmd[] { - return commands.map(cmd => { - const acceptsNoArguments = - this.parserRegistry.get(cmd.displayName) instanceof - QuickNoArgumentsParser; - // If a command accepts arguments, let's append a space when that suggestion is picked. - // This allows us to immediately show autocomplete for the first argument of the command. - const appendToToken = acceptsNoArguments ? '' : ' '; - - return { - kind: 'suggestion.cmd' as const, - token: cmd.displayName, - appendToToken: appendToToken, - data: cmd, - }; - }); - } -} - -export class QuickTshSshParser implements QuickInputParser { - // An SSH login doesn't start with `-`, hence the special group for the first character. - private sshLoginRegex = /[a-z0-9_][a-z0-9_-]*/i; - private totalSshLoginRegex = new RegExp( - `^${this.sshLoginRegex.source}$`, - 'i' - ); - // For now we assume there's nothing else after user@host, so if we see any space after `@`, we - // don't show any matches. - // To support that properly, we'd need to add account for the cursor index. - private totalSshLoginAndHostRegex = new RegExp( - `^(?${this.sshLoginRegex.source}@)(?\\S*)$`, - 'i' - ); - - constructor( - private sshLoginSuggester: suggesters.QuickSshLoginSuggester, - private serverSuggester: suggesters.QuickServerSuggester - ) {} - - // TODO: Support cluster arg. - parse(rawInput: string, startIndex: number): ParseResult { - // We can safely ignore any whitespace at the start. However, `startIndex` needs to account for - // any removed whitespace. - const input = rawInput.trimStart(); - if (input === '') { - // input is empty, so rawInput must include only whitespace. - // Add length of the whitespace to startIndex. - startIndex += rawInput.length; - } else { - startIndex += rawInput.indexOf(input); - } - - // Show autocomplete only after at least one space after `tsh ssh`. - if (rawInput !== '' && input === '') { - // Returning unknown command for the same reasons as outlined at the end of this function. - const command = { - kind: 'command.unknown' as const, - }; - const targetToken = { value: '', startIndex }; - return { - targetToken, - command, - getSuggestions: () => - this.sshLoginSuggester.getSuggestions(targetToken.value), - }; - } - - const hostMatch = input.match(this.totalSshLoginAndHostRegex); - - if (hostMatch) { - const command = { - kind: 'command.tsh-ssh' as const, - loginHost: hostMatch[0], - }; - const hostStartIndex = startIndex + hostMatch.groups.loginPart.length; - const targetToken = { - value: hostMatch.groups.hostPart, - startIndex: hostStartIndex, - }; - - return { - targetToken, - command, - getSuggestions: () => - this.serverSuggester.getSuggestions(targetToken.value), - }; - } - - const loginMatch = input.match(this.totalSshLoginRegex); - - if (loginMatch) { - const command = { - kind: 'command.tsh-ssh' as const, - loginHost: loginMatch[0], - }; - const targetToken = { - value: loginMatch[0], - startIndex, - }; - return { - targetToken, - command, - getSuggestions: () => - this.sshLoginSuggester.getSuggestions(targetToken.value), - }; - } - - // The code gets to this point if either `input` is empty or it has additional arguments besides - // the first positional argument. - // - // In case of the input being empty, we know that at this point we don't have enough arguments - // to successfully launch tsh ssh. But we also don't have code to handle this error case. So - // instead we return an unknown command, so that if the user presses enter at this point, we'll - // launch `tsh ssh` in a local shell and it'll show the appropriate error. - // - // In case of additional arguments, the command bar doesn't know how to handle them. This would - // require adding a real parser which we're going to do soon. In the meantime, we just run the - // command in a local shell to a similar effect (though the host to which someone tries to - // connect to won't show up in the connection tracker and so on). - return { - command: { kind: 'command.unknown' }, - targetToken: emptyTargetToken, - getSuggestions: noSuggestions, - }; - } -} - -export class QuickTshProxyDbParser implements QuickInputParser { - private totalDbNameRegex = /^\S+$/i; - - constructor(private databaseSuggester: suggesters.QuickDatabaseSuggester) {} - - parse(rawInput: string, startIndex: number): ParseResult { - // We can safely ignore any whitespace at the start. However, `startIndex` needs to account for - // any removed whitespace. - const input = rawInput.trimStart(); - if (input === '') { - // input is empty, so rawInput must include only whitespace. - // Add length of the whitespace to startIndex. - startIndex += rawInput.length; - } else { - startIndex += rawInput.indexOf(input); - } - - // Show autocomplete only after at least one space after `tsh proxy db`. - if (rawInput !== '' && input === '') { - const targetToken = { - value: '', - startIndex, - }; - return { - targetToken, - command: { kind: 'command.unknown' }, - getSuggestions: () => - this.databaseSuggester.getSuggestions(targetToken.value), - }; - } - - const dbNameMatch = input.match(this.totalDbNameRegex); - - if (dbNameMatch) { - const targetToken = { - value: dbNameMatch[0], - startIndex, - }; - return { - targetToken, - command: { kind: 'command.unknown' }, - getSuggestions: () => - this.databaseSuggester.getSuggestions(targetToken.value), - }; - } - - return { - targetToken: emptyTargetToken, - command: { kind: 'command.unknown' }, - getSuggestions: noSuggestions, - }; - } -} - -// QuickNoArgumentsParser is useful in situations where a command does not accept any arguments. -// If QuickNoArgumentsParser is registered as the parser for that command, selecting that command -// from suggestions will simply close autocomplete. Pressing Enter again will execute the command -// passed to the constructor of QuickNoArgumentsParser. -export class QuickNoArgumentsParser implements QuickInputParser { - constructor(private command: AutocompleteCommand) {} - - parse(rawInput: string, startIndex: number): ParseResult { - const targetToken = { - value: '', - startIndex, - }; - - return { - targetToken, - command: this.command, - getSuggestions: noSuggestions, - }; - } -} diff --git a/web/packages/teleterm/src/ui/services/quickInput/quickInputService.test.ts b/web/packages/teleterm/src/ui/services/quickInput/quickInputService.test.ts deleted file mode 100644 index 58e0b73879509..0000000000000 --- a/web/packages/teleterm/src/ui/services/quickInput/quickInputService.test.ts +++ /dev/null @@ -1,566 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { CommandLauncher } from 'teleterm/ui/commandLauncher'; -import { ClustersService } from 'teleterm/ui/services/clusters'; -import { WorkspacesService } from 'teleterm/ui/services/workspacesService'; -import { ResourcesService } from 'teleterm/ui/services/resources'; - -import { getEmptyPendingAccessRequest } from '../workspacesService/accessRequestsService'; - -import { QuickInputService } from './quickInputService'; -import * as parsers from './parsers'; -import * as suggestors from './suggesters'; -import { SuggestionCmd, SuggestionSshLogin } from './types'; - -afterEach(() => { - jest.restoreAllMocks(); -}); - -jest.mock('teleterm/ui/commandLauncher'); -jest.mock('teleterm/ui/services/clusters'); -jest.mock('teleterm/ui/services/workspacesService'); - -const CommandLauncherMock = CommandLauncher as jest.MockedClass< - typeof CommandLauncher ->; -const ClustersServiceMock = ClustersService as jest.MockedClass< - typeof ClustersService ->; -const ResourcesServiceMock = ResourcesService as jest.MockedClass< - typeof ResourcesService ->; -const WorkspacesServiceMock = WorkspacesService as jest.MockedClass< - typeof WorkspacesService ->; - -const onlyTshSshCommand = [ - { - name: 'autocomplete.tsh-ssh', - displayName: 'tsh ssh', - description: '', - run: () => {}, - }, -]; - -function createQuickInputService() { - return new QuickInputService( - new CommandLauncherMock(undefined), - new ClustersServiceMock(undefined, undefined, undefined, undefined), - new ResourcesServiceMock(undefined), - new WorkspacesServiceMock(undefined, undefined, undefined, undefined) - ); -} - -function mockCommandLauncherAutocompleteCommands( - commandLauncherMock: jest.MockedClass, - commands: { - name: string; - displayName: string; - description: string; - run: () => void; - }[] -) { - jest - .spyOn(commandLauncherMock.prototype, 'getAutocompleteCommands') - .mockImplementation(() => { - return commands; - }); -} - -test('parse returns correct result for a command suggestion with empty input', async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = quickInputService.parse(''); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: '', - startIndex: 0, - }); - expect(command).toEqual({ kind: 'command.unknown' }); -}); - -test('parse returns correct result for a command suggestion', async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = - quickInputService.parse('ts'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: 'ts', - startIndex: 0, - }); - expect(command).toEqual({ kind: 'command.unknown' }); -}); - -test('parse returns correct result for an SSH login suggestion', async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - jest - .spyOn(suggestors.QuickSshLoginSuggester.prototype, 'getSuggestions') - .mockImplementation(async () => { - return [ - { - kind: 'suggestion.ssh-login', - token: 'root', - appendToToken: '@', - data: 'root', - }, - ]; - }); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = - quickInputService.parse('tsh ssh roo'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: 'roo', - startIndex: 8, - }); - expect(command).toEqual({ - kind: 'command.tsh-ssh', - loginHost: 'roo', - }); -}); - -test('parse returns correct result for an SSH login suggestion with spaces between arguments', async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - jest - .spyOn(suggestors.QuickSshLoginSuggester.prototype, 'getSuggestions') - .mockImplementation(async () => { - return [ - { - kind: 'suggestion.ssh-login', - token: 'barfoo', - appendToToken: '@', - data: 'barfoo', - }, - ]; - }); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = - quickInputService.parse(' tsh ssh bar'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: 'bar', - startIndex: 14, - }); - expect(command).toEqual({ - kind: 'command.tsh-ssh', - loginHost: 'bar', - }); -}); - -test('parse returns correct result for a database name suggestion', async () => { - mockCommandLauncherAutocompleteCommands(CommandLauncherMock, [ - { - name: 'autocomplete.tsh-proxy-db', - displayName: 'tsh proxy db', - description: '', - run: () => {}, - }, - ]); - jest - .spyOn(WorkspacesServiceMock.prototype, 'getActiveWorkspace') - .mockImplementation(() => ({ - accessRequests: { - assumed: {}, - isBarCollapsed: false, - pending: getEmptyPendingAccessRequest(), - }, - localClusterUri: '/clusters/test_uri', - documents: [], - location: '/docs/1', - })); - jest - .spyOn(ResourcesServiceMock.prototype, 'fetchDatabases') - .mockImplementation(() => { - return Promise.resolve({ - agentsList: [ - { - hostname: 'foobar', - uri: '/clusters/test_uri/dbs/foobar', - name: '', - desc: '', - protocol: '', - type: '', - addr: '', - labelsList: null, - }, - ], - startKey: '', - totalCount: 1, - }); - }); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = - quickInputService.parse('tsh proxy db foo'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: 'foo', - startIndex: 13, - }); - expect(command).toEqual({ kind: 'command.unknown' }); -}); - -test("parse doesn't return any suggestions if the only suggestion completely matches the target token", async () => { - jest.mock('./parsers'); - const QuickCommandParserMock = parsers.QuickCommandParser as jest.MockedClass< - typeof parsers.QuickCommandParser - >; - jest - .spyOn(QuickCommandParserMock.prototype, 'parse') - .mockImplementation(() => { - return { - getSuggestions: () => - Promise.resolve([ - { - kind: 'suggestion.ssh-login', - token: 'foobar', - appendToToken: '@', - data: 'foobar', - }, - ]), - targetToken: { - startIndex: 0, - value: 'foobar', - }, - command: { kind: 'command.unknown' }, - }; - }); - const quickInputService = createQuickInputService(); - - const { getSuggestions, command } = quickInputService.parse('foobar'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(0); - expect(command).toEqual({ kind: 'command.unknown' }); -}); - -test('parse returns no suggestions if any of the parsers returns empty suggestions array', async () => { - jest.mock('./parsers'); - const QuickCommandParserMock = parsers.QuickCommandParser as jest.MockedClass< - typeof parsers.QuickCommandParser - >; - jest - .spyOn(QuickCommandParserMock.prototype, 'parse') - .mockImplementation(() => { - return { - getSuggestions: () => Promise.resolve([]), - targetToken: { - startIndex: 0, - value: '', - }, - command: { kind: 'command.unknown' }, - }; - }); - const quickInputService = createQuickInputService(); - - const { command, getSuggestions } = quickInputService.parse(''); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(0); - expect(command).toEqual({ kind: 'command.unknown' }); -}); - -test("the SSH login autocomplete isn't shown if there's no space after `tsh ssh`", async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - const quickInputService = createQuickInputService(); - - const { command, getSuggestions } = quickInputService.parse('tsh ssh'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(0); - expect(command).toEqual({ kind: 'command.unknown' }); -}); - -test("the SSH login autocomplete is shown only if there's at least one space after `tsh ssh`", async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - jest - .spyOn(suggestors.QuickSshLoginSuggester.prototype, 'getSuggestions') - .mockImplementation(async () => { - return [ - { - kind: 'suggestion.ssh-login', - token: 'barfoo', - appendToToken: '@', - data: 'barfoo', - }, - ]; - }); - const quickInputService = createQuickInputService(); - - const { command, targetToken, getSuggestions } = - quickInputService.parse('tsh ssh '); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: '', - startIndex: 8, - }); - expect(command).toEqual({ kind: 'command.unknown' }); -}); - -test('parse returns correct result for an SSH host suggestion right after user@', async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - jest - .spyOn(ResourcesServiceMock.prototype, 'fetchServers') - .mockImplementation(() => { - return Promise.resolve({ - agentsList: [ - { - hostname: 'bazbar', - name: '', - addr: '', - uri: '/clusters/foo/servers/bazbar', - tunnel: false, - labelsList: null, - }, - ], - startKey: '', - totalCount: 1, - }); - }); - jest - .spyOn(WorkspacesServiceMock.prototype, 'getActiveWorkspace') - .mockImplementation(() => ({ - accessRequests: { - assumed: {}, - isBarCollapsed: false, - pending: getEmptyPendingAccessRequest(), - }, - localClusterUri: '/clusters/test_uri', - documents: [], - location: '/docs/1', - })); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = - quickInputService.parse('tsh ssh user@'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: '', - startIndex: 13, - }); - expect(command).toEqual({ - kind: 'command.tsh-ssh', - loginHost: 'user@', - }); -}); - -test('parse returns correct result for a partial match on an SSH host suggestion', async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - jest - .spyOn(ResourcesServiceMock.prototype, 'fetchServers') - .mockImplementation(() => { - return Promise.resolve({ - agentsList: [ - { - hostname: 'bazbar', - name: '', - addr: '', - uri: '/clusters/foo/servers/bazbar', - tunnel: false, - labelsList: null, - }, - ], - startKey: '', - totalCount: 1, - }); - }); - jest - .spyOn(WorkspacesServiceMock.prototype, 'getActiveWorkspace') - .mockImplementation(() => ({ - accessRequests: { - assumed: {}, - isBarCollapsed: false, - pending: getEmptyPendingAccessRequest(), - }, - localClusterUri: '/clusters/test_uri', - documents: [], - location: '/docs/1', - })); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = quickInputService.parse( - ' tsh ssh foo@baz' - ); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: 'baz', - startIndex: 18, - }); - expect(command).toEqual({ - kind: 'command.tsh-ssh', - loginHost: 'foo@baz', - }); -}); - -test("parse returns the first argument as loginHost when there's no @ sign", async () => { - mockCommandLauncherAutocompleteCommands( - CommandLauncherMock, - onlyTshSshCommand - ); - jest - .spyOn(suggestors.QuickSshLoginSuggester.prototype, 'getSuggestions') - .mockImplementation(async () => { - return [ - { - kind: 'suggestion.ssh-login', - token: 'barfoo', - appendToToken: '@', - data: 'barfoo', - }, - ]; - }); - const quickInputService = createQuickInputService(); - - const { getSuggestions, targetToken, command } = - quickInputService.parse('tsh ssh bar'); - const suggestions = await getSuggestions(); - - expect(suggestions).toHaveLength(1); - expect(targetToken).toEqual({ - value: 'bar', - startIndex: 8, - }); - expect(command).toEqual({ - kind: 'command.tsh-ssh', - loginHost: 'bar', - }); -}); - -test('picking a command suggestion in an empty input autocompletes the command', () => { - const quickInputService = createQuickInputService(); - quickInputService.setState({ inputValue: '' }); - - const targetToken = { - startIndex: 0, - value: '', - }; - const cmd: SuggestionCmd = { - kind: 'suggestion.cmd', - token: 'tsh ssh', - data: { - displayName: 'tsh ssh', - description: '', - }, - }; - quickInputService.pickSuggestion(targetToken, cmd); - - expect(quickInputService.getInputValue()).toBe('tsh ssh'); -}); - -test('picking a command suggestion in an input with a single space preserves the space', () => { - const quickInputService = createQuickInputService(); - quickInputService.setState({ inputValue: ' ' }); - - const targetToken = { - startIndex: 1, - value: '', - }; - const cmd: SuggestionCmd = { - kind: 'suggestion.cmd', - token: 'tsh ssh', - data: { - displayName: 'tsh ssh', - description: '', - }, - }; - quickInputService.pickSuggestion(targetToken, cmd); - - expect(quickInputService.getInputValue()).toBe(' tsh ssh'); -}); - -test('picking an SSH login suggestion replaces target token in input value', () => { - const quickInputService = createQuickInputService(); - quickInputService.setState({ inputValue: 'tsh ssh roo --foo' }); - - const targetToken = { - value: 'roo', - startIndex: 8, - }; - const sshLogin: SuggestionSshLogin = { - kind: 'suggestion.ssh-login', - token: 'root', - appendToToken: '@', - data: 'root', - }; - quickInputService.pickSuggestion(targetToken, sshLogin); - - expect(quickInputService.getInputValue()).toBe('tsh ssh root@ --foo'); -}); - -test('pickSuggestion appends the appendToToken field to the token', () => { - const quickInputService = createQuickInputService(); - quickInputService.setState({ inputValue: 'tsh ssh foo' }); - - const targetToken = { - value: 'foo', - startIndex: 8, - }; - const sshLogin: SuggestionSshLogin = { - kind: 'suggestion.ssh-login', - token: 'foobar', - appendToToken: '@barbaz', - data: 'foobar', - }; - quickInputService.pickSuggestion(targetToken, sshLogin); - - expect(quickInputService.getInputValue()).toBe('tsh ssh foobar@barbaz'); -}); diff --git a/web/packages/teleterm/src/ui/services/quickInput/quickInputService.ts b/web/packages/teleterm/src/ui/services/quickInput/quickInputService.ts deleted file mode 100644 index 1d114c7bcabc7..0000000000000 --- a/web/packages/teleterm/src/ui/services/quickInput/quickInputService.ts +++ /dev/null @@ -1,195 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Store, useStore } from 'shared/libs/stores'; - -import { CommandLauncher } from 'teleterm/ui/commandLauncher'; -import { WorkspacesService } from 'teleterm/ui/services/workspacesService'; -import { ResourcesService } from 'teleterm/ui/services/resources'; -import { ClustersService } from 'teleterm/ui/services/clusters'; - -import * as parsers from './parsers'; -import * as suggesters from './suggesters'; -import { AutocompleteToken, ParseResult, Suggestion } from './types'; - -type State = { - inputValue: string; - visible: boolean; -}; - -export class QuickInputService extends Store { - private quickCommandParser: parsers.QuickCommandParser; - lastFocused: WeakRef; - - constructor( - launcher: CommandLauncher, - clustersService: ClustersService, - resourcesService: ResourcesService, - workspacesService: WorkspacesService - ) { - super(); - this.lastFocused = new WeakRef(document.createElement('div')); - this.quickCommandParser = new parsers.QuickCommandParser(launcher); - this.setState({ - inputValue: '', - }); - - const sshLoginSuggester = new suggesters.QuickSshLoginSuggester( - workspacesService, - clustersService - ); - const serverSuggester = new suggesters.QuickServerSuggester( - workspacesService, - resourcesService - ); - const databaseSuggester = new suggesters.QuickDatabaseSuggester( - workspacesService, - resourcesService - ); - - this.quickCommandParser.registerParserForCommand( - 'tsh ssh', - new parsers.QuickTshSshParser(sshLoginSuggester, serverSuggester) - ); - this.quickCommandParser.registerParserForCommand( - 'tsh proxy db', - new parsers.QuickTshProxyDbParser(databaseSuggester) - ); - this.quickCommandParser.registerParserForCommand( - 'tsh install', - new parsers.QuickNoArgumentsParser({ kind: 'command.tsh-install' }) - ); - this.quickCommandParser.registerParserForCommand( - 'tsh uninstall', - new parsers.QuickNoArgumentsParser({ kind: 'command.tsh-uninstall' }) - ); - } - - state: State = { - inputValue: '', - visible: false, - }; - - // TODO: There's no "back" in the new command bar. We can probably just remove this method and the - // behavior related to it? - goBack = () => { - this.setState({ - inputValue: '', - visible: false, - }); - - const el = this.lastFocused.deref(); - el?.focus(); - }; - - show = () => { - this.setState({ - visible: true, - }); - }; - - hide = () => { - this.setState({ - visible: false, - }); - }; - - // Parses the input string and returns AutocompleteResult which includes information about which - // token we currently show the autocomplete for and what are the autocomplete suggestions (items) - // to show. - parse(input: string): ParseResult { - const parseResult = this.quickCommandParser.parse(input); - - // Automatically handle a universal edge case so that each individual suggester doesn't have to - // care about it. - const getSuggestionsThenHandleEdgeCase = () => - parseResult.getSuggestions().then(suggestions => { - // Don't show suggestions if the only suggestion completely matches the target token. - const hasSingleCompleteMatch = - suggestions.length === 1 && - suggestions[0].token === parseResult.targetToken.value; - - return hasSingleCompleteMatch ? [] : suggestions; - }); - - return { - ...parseResult, - getSuggestions: getSuggestionsThenHandleEdgeCase, - }; - } - - // Replaces the token that is being autocompleted with the token from the suggestion. - // `tsh ssh roo` becomes `tsh ssh root` - // - // However, we also preserve anything after the token so that in the future we might effortlessly - // support cursor index. So `tsh ssh roo --cluster=bar` becomes `tsh ssh root --cluster=bar`. - pickSuggestion(targetToken: AutocompleteToken, suggestion: Suggestion) { - const { inputValue } = this.state; - const insertedToken = suggestion.token + (suggestion.appendToToken || ''); - const newInputValue = - inputValue.substring(0, targetToken.startIndex) + - insertedToken + - inputValue.substring(targetToken.startIndex + targetToken.value.length); - - // Keep the autocomplete visible if something was appended to the token. If nothing was appended - // to the token then we know that we don't have any further suggestions to show. - // - // Consider these situations: - // - // 1. You type "tsh s" and choose "tsh ssh" from suggestions. The input becomes "tsh ssh " and - // you see the autocomplete for the SSH login. - // - // 2. You type "tsh ssh roo" and choose "root" from suggestions. The input becomes "tsh ssh - // root@" and you see the autocomplete for the SSH host. - // - // 3. You type "tsh ssh root@foo" and choose "foobar" from suggestions. The input becomes "tsh - // ssh root@foobar". You don't see any further suggestions. - // - // In situation 3, it's crucial that we hide the autocomplete, as there might be other servers - // that match "foobar", but the user already made a conscious choice of picking a specific - // server. - const shouldRemainVisible = !!suggestion.appendToToken; - - this.setState({ - inputValue: newInputValue, - visible: shouldRemainVisible, - }); - } - - getInputValue = () => { - return this.state.inputValue; - }; - - setInputValue = (value: string) => { - this.setState({ - inputValue: value, - // Changing the input through the UI should always make the autocomplete box visible in case - // there are any suggestions. - visible: true, - }); - }; - - clearInputValueAndHide = () => { - this.setState({ - inputValue: '', - visible: false, - }); - }; - - useState() { - return useStore(this).state; - } -} diff --git a/web/packages/teleterm/src/ui/services/quickInput/suggesters.ts b/web/packages/teleterm/src/ui/services/quickInput/suggesters.ts deleted file mode 100644 index 43ab423bc89fb..0000000000000 --- a/web/packages/teleterm/src/ui/services/quickInput/suggesters.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { WorkspacesService } from 'teleterm/ui/services/workspacesService'; -import { ResourcesService } from 'teleterm/ui/services/resources'; -import { ClustersService } from 'teleterm/ui/services/clusters'; - -import { - SuggestionServer, - SuggestionSshLogin, - SuggestionDatabase, - QuickInputSuggester, -} from './types'; - -const limit = 10; - -export class QuickSshLoginSuggester - implements QuickInputSuggester -{ - constructor( - private workspacesService: WorkspacesService, - private clustersService: ClustersService - ) {} - - async getSuggestions(input: string): Promise { - // TODO(ravicious): Handle the `--cluster` tsh ssh flag. - const localClusterUri = - this.workspacesService.getActiveWorkspace()?.localClusterUri; - if (!localClusterUri) { - return []; - } - const cluster = this.clustersService.findCluster(localClusterUri); - const allLogins = cluster?.loggedInUser?.sshLoginsList || []; - let matchingLogins: typeof allLogins; - - if (!input) { - matchingLogins = allLogins; - } else { - matchingLogins = allLogins.filter(login => - login.startsWith(input.toLowerCase()) - ); - } - - return matchingLogins.map(login => ({ - kind: 'suggestion.ssh-login' as const, - token: login, - // This allows the user to immediately begin typing the hostname. - appendToToken: '@', - data: login, - })); - } -} - -export class QuickServerSuggester - implements QuickInputSuggester -{ - constructor( - private workspacesService: WorkspacesService, - private resourcesService: ResourcesService - ) {} - - async getSuggestions(input: string): Promise { - // TODO(ravicious): Handle the `--cluster` tsh ssh flag. - const localClusterUri = - this.workspacesService.getActiveWorkspace()?.localClusterUri; - if (!localClusterUri) { - return []; - } - const servers = await this.resourcesService.fetchServers({ - clusterUri: localClusterUri, - search: input, - limit, - sort: { fieldName: 'hostname', dir: 'ASC' }, - }); - - return servers.agentsList.map(server => ({ - kind: 'suggestion.server' as const, - token: server.hostname, - data: server, - })); - } -} - -export class QuickDatabaseSuggester - implements QuickInputSuggester -{ - constructor( - private workspacesService: WorkspacesService, - private resourcesService: ResourcesService - ) {} - - async getSuggestions(input: string): Promise { - const localClusterUri = - this.workspacesService.getActiveWorkspace()?.localClusterUri; - if (!localClusterUri) { - return []; - } - const databases = await this.resourcesService.fetchDatabases({ - clusterUri: localClusterUri, - search: input, - limit, - sort: { fieldName: 'name', dir: 'ASC' }, - }); - - return databases.agentsList.map(database => ({ - kind: 'suggestion.database' as const, - token: database.name, - data: database, - })); - } -} diff --git a/web/packages/teleterm/src/ui/services/quickInput/types.ts b/web/packages/teleterm/src/ui/services/quickInput/types.ts deleted file mode 100644 index df85a1042e43b..0000000000000 --- a/web/packages/teleterm/src/ui/services/quickInput/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type * as tsh from 'teleterm/services/tshd/types'; - -type SuggestionBase = { - kind: T; - token: string; - appendToToken?: string; - data: R; -}; - -export type SuggestionCmd = SuggestionBase< - 'suggestion.cmd', - { displayName: string; description: string } ->; - -export type SuggestionSshLogin = SuggestionBase< - 'suggestion.ssh-login', - string -> & { appendToToken: string }; - -export type SuggestionServer = SuggestionBase<'suggestion.server', tsh.Server>; - -export type SuggestionDatabase = SuggestionBase< - 'suggestion.database', - tsh.Database ->; - -export type Suggestion = - | SuggestionCmd - | SuggestionSshLogin - | SuggestionServer - | SuggestionDatabase; - -export type QuickInputParser = { - parse(input: string, startIndex: number): ParseResult; -}; - -export type ParseResult = { - // Command includes the result of parsing whatever was parsed so far. - // This means that in case of `tsh ssh roo`, the command will say that we want to launch `tsh ssh` - // with `roo` as `loginHost`. - command: AutocompleteCommand; - readonly targetToken: AutocompleteToken; - getSuggestions(): Promise; -}; - -export type QuickInputSuggester = { - getSuggestions(filter: string): Promise; -}; - -export type AutocompleteToken = { - value: string; - startIndex: number; -}; - -type CommandBase = { - kind: T; -}; - -export type AutocompleteUnknownCommand = CommandBase<'command.unknown'>; - -export type AutocompleteTshSshCommand = CommandBase<'command.tsh-ssh'> & { - loginHost: string; -}; - -export type AutocompleteTshInstallCommand = CommandBase<'command.tsh-install'>; -export type AutocompleteTshUninstallCommand = - CommandBase<'command.tsh-uninstall'>; - -export type AutocompleteCommand = - | AutocompleteUnknownCommand - | AutocompleteTshSshCommand - | AutocompleteTshInstallCommand - | AutocompleteTshUninstallCommand; diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index ab809037df5f3..7c96be9216212 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -15,13 +15,7 @@ limitations under the License. */ import { unique } from 'teleterm/ui/utils/uid'; -import { - ClusterUri, - DocumentUri, - ServerUri, - paths, - routing, -} from 'teleterm/ui/uri'; +import { DocumentUri, ServerUri, paths, routing } from 'teleterm/ui/uri'; import { CreateAccessRequestDocumentOpts, @@ -36,7 +30,6 @@ import { DocumentOrigin, DocumentTshKube, DocumentTshNode, - DocumentTshNodeWithLoginHost, DocumentTshNodeWithServerId, } from './types'; @@ -130,37 +123,6 @@ export class DocumentsService { }; } - /** - * createTshNodeDocumentFromLoginHost handles creation of the doc when the server URI is not - * available, for example when executing `tsh ssh user@host` from the command bar. - * - * @param clusterUri - the URI of the cluster which should be used for hostname lookup. That is, - * the command will succeed only if the given cluster has only a single server with the hostname - * matching `host`. - * @param loginHost - the "user@host" pair. - * @param params - additional parameters. - * @param params.origin - where the document was opened from. - */ - createTshNodeDocumentFromLoginHost( - clusterUri: ClusterUri, - loginHost: string, - params: { origin: DocumentOrigin } - ): DocumentTshNodeWithLoginHost { - const { params: routingParams } = routing.parseClusterUri(clusterUri); - const uri = routing.getDocUri({ docId: unique() }); - - return { - uri, - kind: 'doc.terminal_tsh_node', - title: loginHost, - status: 'connecting', - rootClusterId: routingParams.rootClusterId, - leafClusterId: routingParams.leafClusterId, - loginHost, - origin: params.origin, - }; - } - /** * If title is not present in opts, createGatewayDocument will create one based on opts. */ diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index a28ed57ba62e3..f11062b034e20 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -43,6 +43,12 @@ export interface DocumentBlank extends DocumentBase { export type DocumentTshNode = | DocumentTshNodeWithServerId + // DELETE IN 14.0.0 + // + // Logging in to an arbitrary host was removed in 13.0 together with the command bar. + // However, there's a slight chance that some users upgrading from 12.x to 13.0 still have + // documents with loginHost in the app state (e.g. if the doc failed to connect to the server). + // Let's just remove this in 14.0.0 instead to make sure those users can safely upgrade the app. | DocumentTshNodeWithLoginHost; interface DocumentTshNodeBase extends DocumentBase { diff --git a/web/packages/teleterm/src/ui/types.ts b/web/packages/teleterm/src/ui/types.ts index f1bac8b6f9ab1..7a43982074585 100644 --- a/web/packages/teleterm/src/ui/types.ts +++ b/web/packages/teleterm/src/ui/types.ts @@ -18,7 +18,6 @@ import { MainProcessClient, SubscribeToTshdEvent } from 'teleterm/types'; import { ClustersService } from 'teleterm/ui/services/clusters'; import { ModalsService } from 'teleterm/ui/services/modals'; import { TerminalsService } from 'teleterm/ui/services/terminals'; -import { QuickInputService } from 'teleterm/ui/services/quickInput'; import { StatePersistenceService } from 'teleterm/ui/services/statePersistence'; import { CommandLauncher } from 'teleterm/ui/commandLauncher'; import { KeyboardShortcutsService } from 'teleterm/ui/services/keyboardShortcuts'; @@ -38,7 +37,6 @@ export interface IAppContext { notificationsService: NotificationsService; terminalsService: TerminalsService; keyboardShortcutsService: KeyboardShortcutsService; - quickInputService: QuickInputService; statePersistenceService: StatePersistenceService; workspacesService: WorkspacesService; mainProcessClient: MainProcessClient;