diff --git a/packages/design/src/ButtonIcon/ButtonIcon.jsx b/packages/design/src/ButtonIcon/ButtonIcon.jsx
index 886b254f8..dcc47035f 100644
--- a/packages/design/src/ButtonIcon/ButtonIcon.jsx
+++ b/packages/design/src/ButtonIcon/ButtonIcon.jsx
@@ -50,8 +50,9 @@ const fromProps = props => {
return {
'&:disabled': {
color: theme.colors.action.disabled,
+ cursor: 'default',
},
- '&:hover, &:focus': {
+ '&:hover:enabled, &:focus:enabled': {
background: theme.colors.action.hover,
},
};
diff --git a/packages/design/src/theme/theme.js b/packages/design/src/theme/theme.js
index 4d409e6a0..705479f31 100644
--- a/packages/design/src/theme/theme.js
+++ b/packages/design/src/theme/theme.js
@@ -32,6 +32,7 @@ const contrastThreshold = 3;
const colors = {
accent: '#651FFF',
+ progressBarColor: '#00BFA5',
dark: '#000',
diff --git a/packages/design/src/utils/testing.tsx b/packages/design/src/utils/testing.tsx
index 4d14badd3..7c584a79a 100644
--- a/packages/design/src/utils/testing.tsx
+++ b/packages/design/src/utils/testing.tsx
@@ -23,6 +23,7 @@ import {
screen,
prettyDOM,
getByTestId,
+ waitForElementToBeRemoved,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter as Router } from 'react-router-dom';
@@ -60,4 +61,5 @@ export {
getByTestId,
Router,
userEvent,
+ waitForElementToBeRemoved,
};
diff --git a/packages/shared/components/FileTransfer/FileTransfer.test.tsx b/packages/shared/components/FileTransfer/FileTransfer.test.tsx
new file mode 100644
index 000000000..60da0278d
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransfer.test.tsx
@@ -0,0 +1,170 @@
+/**
+ * Copyright 2022 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 {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitForElementToBeRemoved,
+} from 'design/utils/testing';
+
+import { FileTransfer, TransferHandlers } from './FileTransfer';
+import { FileTransferContextProvider } from './FileTransferContextProvider';
+import { FileTransferDialogDirection } from './FileTransferStateless';
+import { createFileTransferEventsEmitter } from './createFileTransferEventsEmitter';
+
+test('click opens correct dialog', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('Download Files')).toBeInTheDocument();
+});
+
+test('downloads component changes when file transfer callbacks are called', async () => {
+ const fileTransferEvents = createFileTransferEventsEmitter();
+
+ const handler: TransferHandlers = {
+ getDownloader: async () => fileTransferEvents,
+ getUploader: async () => undefined,
+ };
+ render(
+
+
+
+ );
+ fireEvent.change(screen.getByLabelText('File Path'), {
+ target: { value: '/Users/g/file.txt' },
+ });
+ fireEvent.click(screen.getByText('Download'));
+ const listItem = await screen.findByRole('listitem');
+ expect(listItem).toHaveTextContent('/Users/g/file.txt');
+
+ act(() => fileTransferEvents.emitProgress(50));
+ expect(listItem).toHaveTextContent('50%');
+
+ act(() => fileTransferEvents.emitComplete());
+ expect(listItem).toContainElement(screen.getByTitle('Transfer completed'));
+
+ act(() => fileTransferEvents.emitError(new Error('Network error')));
+ expect(listItem).toHaveTextContent('Network error');
+});
+
+test('onAbort is called when user cancels upload', async () => {
+ let abortControllerMock: AbortController;
+
+ const handler: TransferHandlers = {
+ getDownloader: async (_, abortController) => {
+ abortControllerMock = abortController;
+ return createFileTransferEventsEmitter();
+ },
+ getUploader: async () => undefined,
+ };
+ render(
+
+
+
+ );
+ fireEvent.change(screen.getByLabelText('File Path'), {
+ target: { value: '/Users/g/file.txt' },
+ });
+ fireEvent.click(screen.getByText('Download'));
+ fireEvent.click(await screen.findByTitle('Cancel'));
+ expect(abortControllerMock.signal.aborted).toBeTruthy();
+});
+
+test('file is not added when transferHandler does not return anything', async () => {
+ const handler: TransferHandlers = {
+ getDownloader: async () => undefined,
+ getUploader: async () => undefined,
+ };
+ const filePath = '/Users/g/file.txt';
+
+ render(
+
+
+
+ );
+ fireEvent.change(screen.getByLabelText('File Path'), {
+ target: { value: filePath },
+ });
+ fireEvent.click(screen.getByText('Download'));
+ expect(screen.queryByText('/Users/g/file.txt')).not.toBeInTheDocument();
+});
+
+describe('handleAfterClose', () => {
+ const getSetup = async () => {
+ const handleBeforeClose = jest.fn();
+ const handleAfterClose = jest.fn();
+ const handler: TransferHandlers = {
+ getDownloader: async () => createFileTransferEventsEmitter(),
+ getUploader: async () => undefined,
+ };
+
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByLabelText('File Path'), {
+ target: { value: '~/abc' },
+ });
+
+ fireEvent.click(screen.getByText('Download'));
+ await screen.findByRole('listitem');
+
+ return { handleBeforeClose, handleAfterClose };
+ };
+
+ test('is not called when closing the dialog has been aborted', async () => {
+ const { handleBeforeClose, handleAfterClose } = await getSetup();
+ handleBeforeClose.mockReturnValue(Promise.resolve(false));
+ fireEvent.click(screen.getByTitle('Close'));
+ expect(handleBeforeClose).toHaveBeenCalled();
+ expect(handleAfterClose).not.toHaveBeenCalled();
+ });
+
+ test('is called when closing the dialog has been confirmed', async () => {
+ const { handleBeforeClose, handleAfterClose } = await getSetup();
+ handleBeforeClose.mockReturnValue(Promise.resolve(true));
+ fireEvent.click(screen.getByTitle('Close'));
+ expect(handleBeforeClose).toHaveBeenCalled();
+
+ // wait for dialog to close
+ await waitForElementToBeRemoved(() =>
+ screen.queryByTestId('file-transfer-container')
+ );
+ expect(handleAfterClose).toHaveBeenCalled();
+ });
+});
diff --git a/packages/shared/components/FileTransfer/FileTransfer.tsx b/packages/shared/components/FileTransfer/FileTransfer.tsx
new file mode 100644
index 000000000..5ad29cd07
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransfer.tsx
@@ -0,0 +1,135 @@
+/**
+ * Copyright 2022 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 { useFileTransferContext } from './FileTransferContextProvider';
+import { useFilesStore } from './useFilesStore';
+import {
+ FileTransferDialogDirection,
+ FileTransferListeners,
+ FileTransferStateless,
+} from './FileTransferStateless';
+
+interface FileTransferProps {
+ backgroundColor?: string;
+ transferHandlers: TransferHandlers;
+
+ /**
+ * `beforeClose` is called when an attempt to close the dialog was made
+ * and there is a file transfer in progress.
+ * Returning `true` will close the dialog, returning `false` will not.
+ */
+ beforeClose?(): Promise | boolean;
+
+ afterClose?(): void;
+}
+
+/**
+ * Both `getDownloader` and `getUploader` can return a promise containing `FileTransferListeners` function or nothing.
+ * In the latter case, the file will not be added to the list and the download will not start.
+ */
+export interface TransferHandlers {
+ getDownloader: (
+ sourcePath: string,
+ abortController: AbortController
+ ) => Promise;
+ getUploader: (
+ destinationPath: string,
+ file: File,
+ abortController: AbortController
+ ) => Promise;
+}
+
+export function FileTransfer(props: FileTransferProps) {
+ const { openedDialog, closeDialog } = useFileTransferContext();
+
+ async function handleCloseDialog(
+ isAnyTransferInProgress: boolean
+ ): Promise {
+ const runCloseCallbacks = () => {
+ closeDialog();
+ props.afterClose?.();
+ };
+
+ if (!isAnyTransferInProgress || !props.beforeClose) {
+ runCloseCallbacks();
+ return;
+ }
+
+ if (await props.beforeClose()) {
+ runCloseCallbacks();
+ }
+ }
+
+ if (!openedDialog) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export function FileTransferDialog(
+ props: Pick & {
+ openedDialog: FileTransferDialogDirection;
+ onCloseDialog(isAnyTransferInProgress: boolean): void;
+ }
+) {
+ const filesStore = useFilesStore();
+
+ function handleAddDownload(sourcePath: string): void {
+ filesStore.start({
+ name: sourcePath,
+ runFileTransfer: abortController =>
+ props.transferHandlers.getDownloader(sourcePath, abortController),
+ });
+ }
+
+ function handleAddUpload(destinationPath: string, file: File): void {
+ filesStore.start({
+ name: file.name,
+ runFileTransfer: abortController =>
+ props.transferHandlers.getUploader(
+ destinationPath,
+ file,
+ abortController
+ ),
+ });
+ }
+
+ function handleClose(): void {
+ props.onCloseDialog(filesStore.isAnyTransferInProgress());
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/shared/components/FileTransfer/FileTransferActionBar.tsx b/packages/shared/components/FileTransfer/FileTransferActionBar.tsx
new file mode 100644
index 000000000..8abee0c61
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferActionBar.tsx
@@ -0,0 +1,54 @@
+/**
+ * Copyright 2022 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, ButtonIcon } from 'design';
+import * as Icons from 'design/Icon';
+
+import { useFileTransferContext } from './FileTransferContextProvider';
+
+type FileTransferActionBarProps = {
+ isConnected: boolean;
+};
+
+export function FileTransferActionBar({
+ isConnected,
+}: FileTransferActionBarProps) {
+ const fileTransferContext = useFileTransferContext();
+ const areFileTransferButtonsDisabled =
+ fileTransferContext.openedDialog || !isConnected;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx b/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx
new file mode 100644
index 000000000..837ca6ef9
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferContextProvider.tsx
@@ -0,0 +1,70 @@
+/**
+ * Copyright 2022 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, { useContext, useState, FC } from 'react';
+
+import { FileTransferDialogDirection } from './FileTransferStateless';
+
+const FileTransferContext = React.createContext<{
+ openedDialog: FileTransferDialogDirection;
+ openDownloadDialog(): void;
+ openUploadDialog(): void;
+ closeDialog(): void;
+}>(null);
+
+export const FileTransferContextProvider: FC<{
+ openedDialog?: FileTransferDialogDirection;
+}> = props => {
+ const [openedDialog, setOpenedDialog] = useState<
+ FileTransferDialogDirection | undefined
+ >(props.openedDialog);
+
+ function openDownloadDialog(): void {
+ setOpenedDialog(FileTransferDialogDirection.Download);
+ }
+
+ function openUploadDialog(): void {
+ setOpenedDialog(FileTransferDialogDirection.Upload);
+ }
+
+ function closeDialog(): void {
+ setOpenedDialog(undefined);
+ }
+
+ return (
+
+ );
+};
+
+export const useFileTransferContext = () => {
+ const context = useContext(FileTransferContext);
+
+ if (!context) {
+ throw new Error(
+ 'FileTransfer requires FileTransferContextProvider context.'
+ );
+ }
+
+ return context;
+};
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx
new file mode 100644
index 000000000..785b15c9d
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/CommonElements.tsx
@@ -0,0 +1,66 @@
+/**
+ * Copyright 2022 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, { forwardRef } from 'react';
+import styled from 'styled-components';
+
+import FieldInput from 'shared/components/FieldInput';
+import { requiredField } from 'shared/components/Validation/rules';
+import Validation from 'shared/components/Validation';
+
+export const Form = styled.form.attrs(() => ({
+ 'aria-label': 'form',
+}))``;
+
+export const PathInput = forwardRef<
+ HTMLInputElement,
+ React.ComponentProps
+>((props, ref) => {
+ function moveCaretAtEnd(e: React.ChangeEvent): void {
+ const tmp = e.target.value;
+ e.target.value = '';
+ e.target.value = tmp;
+ }
+
+ return (
+
+ {({ validator }) => (
+ validator.validate()}
+ rule={requiredField('Path is required')}
+ />
+ )}
+
+ );
+});
+
+const StyledFieldInput = styled(FieldInput)`
+ input {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: transparent;
+ color: white;
+ box-shadow: none;
+ font-size: 14px;
+ height: 32px;
+ }
+`;
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.test.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.test.tsx
new file mode 100644
index 000000000..5bc06c084
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * Copyright 2022 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 { fireEvent, render, screen } from 'design/utils/testing';
+
+import { DownloadForm } from './DownloadForm';
+
+function getFilePathInput(): HTMLElement {
+ return screen.getByLabelText('File Path');
+}
+
+test('button is disabled when path does not point to file', () => {
+ render( {}} />);
+ fireEvent.change(getFilePathInput(), {
+ target: { value: '/Users/' },
+ });
+
+ expect(screen.getByTitle('Download')).toBeDisabled();
+});
+
+test('button is enabled when path points to file', () => {
+ render( {}} />);
+ fireEvent.change(getFilePathInput(), {
+ target: { value: '/Users/file.txt' },
+ });
+
+ expect(screen.getByTitle('Download')).toBeEnabled();
+});
+
+test('onAddDownload is invoked when the from is submitted', () => {
+ const handleAddDownload = jest.fn();
+ const filePath = '/Users/file.txt';
+
+ render( );
+
+ fireEvent.change(getFilePathInput(), {
+ target: { value: filePath },
+ });
+ expect(screen.getByTitle('Download')).toBeEnabled();
+
+ fireEvent.submit(screen.getByRole('form'));
+ expect(handleAddDownload).toHaveBeenCalledWith(filePath);
+});
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.tsx
new file mode 100644
index 000000000..97d10ed9d
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/DownloadForm.tsx
@@ -0,0 +1,62 @@
+/**
+ * Copyright 2022 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, { useState } from 'react';
+import { Flex } from 'design';
+import { ButtonPrimary } from 'design/Button';
+
+import { Form, PathInput } from '../CommonElements';
+
+interface DownloadFormProps {
+ onAddDownload(sourcePath: string): void;
+}
+
+export function DownloadForm(props: DownloadFormProps) {
+ const [sourcePath, setSourcePath] = useState('~/');
+ const isSourcePathValid = !sourcePath.endsWith('/');
+
+ function download(): void {
+ props.onAddDownload(sourcePath);
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/index.ts
new file mode 100644
index 000000000..483a37508
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/DownloadForm/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright 2022 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 './DownloadForm';
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.test.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.test.tsx
new file mode 100644
index 000000000..cab3fd198
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.test.tsx
@@ -0,0 +1,58 @@
+/**
+ * Copyright 2022 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 { fireEvent, render, screen } from 'design/utils/testing';
+
+import { TransferredFile } from '../types';
+
+import { FileList } from './FileList';
+
+const files: TransferredFile[] = [
+ {
+ id: '1',
+ name: '~/mona-lisa.jpg',
+ transferState: { type: 'processing', progress: 0 },
+ },
+ {
+ id: '2',
+ name: '~/time.jpg',
+ transferState: { type: 'processing', progress: 1 },
+ },
+];
+
+test('list items are rendered', () => {
+ render( );
+ const [listItem] = screen.getAllByRole('listitem');
+ expect(listItem).toHaveTextContent('~/mona-lisa.jpg');
+ expect(listItem).toHaveTextContent('0%');
+});
+
+test('transfer is cancelled when component unmounts', () => {
+ const handleCancel = jest.fn();
+ const { unmount } = render(
+
+ );
+ unmount();
+ expect(handleCancel).toHaveBeenCalledTimes(1);
+});
+
+test('transfer is cancelled when user clicks Cancel button', () => {
+ const handleCancel = jest.fn();
+ render( );
+ fireEvent.click(screen.getByTitle('Cancel'));
+ expect(handleCancel).toHaveBeenCalledTimes(1);
+});
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.tsx
new file mode 100644
index 000000000..33fd8a65d
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileList.tsx
@@ -0,0 +1,53 @@
+/**
+ * Copyright 2022 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 styled from 'styled-components';
+
+import { TransferredFile } from '../types';
+
+import { FileListItem } from './FileListItem';
+
+interface FileListProps {
+ files: TransferredFile[];
+
+ onCancel(id: string): void;
+}
+
+export function FileList(props: FileListProps) {
+ if (!props.files.length) {
+ return null;
+ }
+
+ return (
+
+ {props.files.map(file => (
+
+ ))}
+
+ );
+}
+
+const Ul = styled.ul`
+ padding-left: 0;
+ overflow: auto;
+ max-height: 300px;
+ margin-top: 0;
+ margin-bottom: 0;
+ // scrollbars
+ padding-right: 16px;
+ margin-right: -16px;
+`;
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx
new file mode 100644
index 000000000..391141b72
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/FileListItem.tsx
@@ -0,0 +1,126 @@
+/**
+ * Copyright 2022 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, { FC, useEffect } from 'react';
+import styled from 'styled-components';
+import { Box, ButtonIcon, Flex, Text } from 'design';
+import { CircleCheck, Cross, Warning } from 'design/Icon';
+
+import { TransferredFile } from '../types';
+
+type FileListItemProps = {
+ file: TransferredFile;
+ onCancel(id: string): void;
+};
+
+export function FileListItem(props: FileListItemProps) {
+ const { name, transferState, id } = props.file;
+
+ useEffect(() => {
+ return () => props.onCancel(id);
+ }, [props.onCancel]);
+
+ return (
+
+
+
+
+
+ {name}
+
+ {transferState.type === 'completed' && (
+
+ )}
+
+ {transferState.type === 'processing' && (
+ props.onCancel(id)}
+ >
+
+
+ )}
+
+ {(transferState.type === 'processing' ||
+ transferState.type === 'error') && (
+
+
+ {transferState.progress}%
+
+
+
+
+
+ )}
+
+ {transferState.type === 'error' && (
+ {transferState.error.message}
+ )}
+
+ );
+}
+
+const Error: FC = props => {
+ return (
+
+
+ {props.children}
+
+ );
+};
+
+const ProgressPercentage = styled(Text)`
+ line-height: 14px;
+ width: 36px;
+`;
+
+const Li = styled.li`
+ list-style: none;
+ margin-top: ${props => props.theme.space[3]}px;
+ font-size: ${props => props.theme.fontSizes[1]}px;
+`;
+
+const ProgressBackground = styled.div`
+ border-radius: 50px;
+ background: rgba(255, 255, 255, 0.05);
+ width: 100%;
+`;
+
+const ProgressIndicator = styled.div`
+ border-radius: 50px;
+ background: ${props =>
+ props.isFailure
+ ? props.theme.colors.disabled
+ : props.theme.colors.progressBarColor};
+
+ height: 8px;
+ width: ${props => props.progress}%;
+`;
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileList/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/index.ts
new file mode 100644
index 000000000..87be85f3c
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileList/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright 2022 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 { FileList } from './FileList';
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx
new file mode 100644
index 000000000..b3206f599
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.story.tsx
@@ -0,0 +1,184 @@
+/**
+ * Copyright 2022 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 {
+ FileTransferStateless,
+ FileTransferStatelessProps,
+} from './FileTransferStateless';
+import { FileTransferDialogDirection, TransferredFile } from './types';
+
+export default {
+ title: 'Shared/FileTransfer',
+};
+
+const defaultFiles: TransferredFile[] = [
+ {
+ id: '1547581437406~/test',
+ name: '~/Users/grzegorz/Makefile',
+ transferState: {
+ type: 'processing',
+ progress: 10,
+ },
+ },
+ {
+ id: '1547581437406~/test',
+ name: '~Users/grzegorz/very/long/path/that/does/not/exist/but/is/very/useful/for/storybook/stories',
+ transferState: {
+ type: 'processing',
+ progress: 64,
+ },
+ },
+];
+
+function GetFileTransfer(
+ props: Pick
+) {
+ return (
+ undefined}
+ onAddDownload={() => undefined}
+ onAddUpload={() => undefined}
+ onCancel={() => undefined}
+ />
+ );
+}
+
+export const DownloadProgress = () => (
+
+);
+
+export const DownloadError = () => (
+
+);
+
+export const DownloadCompleted = () => (
+
+);
+
+export const DownloadLongList = () => (
+
+);
+
+export const UploadProgress = () => (
+
+);
+
+export const UploadError = () => (
+
+);
+
+export const UploadCompleted = () => (
+
+);
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx
new file mode 100644
index 000000000..3d4573179
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/FileTransferStateless.tsx
@@ -0,0 +1,99 @@
+/**
+ * Copyright 2022 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 styled from 'styled-components';
+import { ButtonIcon, Flex, Text } from 'design';
+import { Close as CloseIcon } from 'design/Icon';
+
+import { FileTransferDialogDirection, TransferredFile } from './types';
+import { DownloadForm } from './DownloadForm';
+import { UploadForm } from './UploadForm';
+import { FileList } from './FileList';
+
+export interface FileTransferStatelessProps {
+ openedDialog: FileTransferDialogDirection;
+ files: TransferredFile[];
+ backgroundColor?: string;
+
+ onClose(): void;
+
+ onAddDownload(sourcePath: string): void;
+
+ onAddUpload(destinationPath: string, file: File): void;
+
+ onCancel(id: string): void;
+}
+
+export function FileTransferStateless(props: FileTransferStatelessProps) {
+ const items =
+ props.openedDialog === FileTransferDialogDirection.Download
+ ? {
+ header: 'Download Files',
+ Form: ,
+ }
+ : {
+ header: 'Upload Files',
+ Form: ,
+ };
+
+ return (
+ {
+ if (e.key !== 'Escape') {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ props.onClose();
+ }}
+ >
+
+
+ {items.header}
+
+
+
+ {items.Form}
+
+
+ );
+}
+
+function ButtonClose(props: { onClick(): void }) {
+ return (
+
+
+
+ );
+}
+
+const Container = styled.div`
+ background: ${props =>
+ props.backgroundColor || props.theme.colors.primary.light};
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ box-sizing: border-box;
+ border-radius: ${props => props.theme.radii[2]}px;
+ padding: 8px 16px 16px;
+ position: absolute;
+ right: 8px;
+ top: 8px;
+ width: 500px;
+ z-index: 10;
+`;
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.test.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.test.tsx
new file mode 100644
index 000000000..e436c2bda
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.test.tsx
@@ -0,0 +1,69 @@
+/**
+ * Copyright 2022 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 { fireEvent, render, screen } from 'design/utils/testing';
+
+import { UploadForm } from './UploadForm';
+
+function getUploadDestinationInput(): HTMLElement {
+ return screen.getByLabelText('Upload destination');
+}
+
+function getFileInput(): HTMLElement {
+ return screen.getByTestId('file-input');
+}
+
+const files = [
+ new File(['(⌐□_□)'], 'chuck-norris.png', { type: 'image/png' }),
+ new File(['(⌐□_□)'], 'tommy-lee.png', { type: 'image/png' }),
+];
+
+test('file input is disabled when path is empty', () => {
+ render( {}} />);
+ fireEvent.change(getUploadDestinationInput(), {
+ target: { value: '' },
+ });
+ expect(getFileInput()).toBeDisabled();
+});
+
+test('files can be selected using input', () => {
+ const handleAddUpload = jest.fn();
+
+ render( );
+
+ fireEvent.change(getFileInput(), {
+ target: { files },
+ });
+
+ expect(handleAddUpload).toHaveBeenCalledTimes(2);
+ expect(handleAddUpload).toHaveBeenCalledWith('~/', files[0]);
+ expect(handleAddUpload).toHaveBeenCalledWith('~/', files[1]);
+});
+
+test('files can be dropped into upload area', () => {
+ const handleAddUpload = jest.fn();
+
+ render( );
+
+ fireEvent.drop(screen.getByText('Drag your files here'), {
+ dataTransfer: { files },
+ });
+
+ expect(handleAddUpload).toHaveBeenCalledTimes(2);
+ expect(handleAddUpload).toHaveBeenCalledWith('~/', files[0]);
+ expect(handleAddUpload).toHaveBeenCalledWith('~/', files[1]);
+});
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.tsx b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.tsx
new file mode 100644
index 000000000..516b607b4
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/UploadForm.tsx
@@ -0,0 +1,141 @@
+/**
+ * Copyright 2022 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, { useRef, useState } from 'react';
+import styled from 'styled-components';
+import { Text } from 'design';
+import { NoteAdded } from 'design/Icon';
+
+import { PathInput, Form } from '../CommonElements';
+
+interface UploadFormProps {
+ onAddUpload(destinationPath: string, file: File): void;
+}
+
+export function UploadForm(props: UploadFormProps) {
+ const dropzoneRef = useRef();
+ const fileSelectorRef = useRef();
+ const [destinationPath, setDestinationPath] = useState('~/');
+
+ function onFileSelected(e: React.ChangeEvent): void {
+ upload(Array.from(e.target.files));
+ }
+
+ function upload(files: File[]): void {
+ files.forEach(file => {
+ props.onAddUpload(destinationPath, file);
+ });
+ }
+
+ function openFilePicker(): void {
+ // reset all selected files
+ fileSelectorRef.current.value = '';
+ fileSelectorRef.current.click();
+ }
+
+ function handleDrop(e: React.DragEvent): void {
+ removeDropzoneStyle(e);
+
+ const { files } = e.dataTransfer;
+ e.preventDefault();
+ e.stopPropagation();
+ upload(Array.from(files));
+ }
+
+ function handleKeyDown(event: React.KeyboardEvent): void {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.stopPropagation();
+ openFilePicker();
+ }
+ }
+
+ function addDropzoneStyle(e: React.DragEvent): void {
+ e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
+ }
+
+ function removeDropzoneStyle(e: React.DragEvent): void {
+ e.currentTarget.style.removeProperty('background-color');
+ }
+
+ const isUploadDisabled = !destinationPath;
+
+ return (
+
+ );
+}
+
+const Dropzone = styled.button`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ color: inherit;
+ background-color: rgba(255, 255, 255, 0.05);
+ margin-top: ${props => props.theme.space[3]}px;
+ border: 1px dashed rgba(255, 255, 255, 0.1);
+ height: 128px;
+ text-align: center;
+ cursor: pointer;
+ opacity: ${props => (props.disabled ? 0.7 : 1)};
+ pointer-events: ${props => (props.disabled ? 'none' : 'unset')};
+ border-radius: ${props => props.theme.radii[2]}px;
+
+ :focus {
+ border-color: ${props => props.theme.colors.action.selected};
+ }
+`;
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/index.ts
new file mode 100644
index 000000000..9136c0af9
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/UploadForm/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright 2022 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 './UploadForm';
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/index.ts b/packages/shared/components/FileTransfer/FileTransferStateless/index.ts
new file mode 100644
index 000000000..7aee508d0
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/index.ts
@@ -0,0 +1,18 @@
+/**
+ * Copyright 2022 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 './types';
+export * from './FileTransferStateless';
diff --git a/packages/shared/components/FileTransfer/FileTransferStateless/types.ts b/packages/shared/components/FileTransfer/FileTransferStateless/types.ts
new file mode 100644
index 000000000..4c9ec5ea3
--- /dev/null
+++ b/packages/shared/components/FileTransfer/FileTransferStateless/types.ts
@@ -0,0 +1,37 @@
+/**
+ * Copyright 2022 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 type TransferState =
+ | { type: 'processing'; progress: number }
+ | { type: 'error'; error: Error; progress: number }
+ | { type: 'completed' };
+
+export type TransferredFile = {
+ id: string;
+ name: string;
+ transferState: TransferState;
+};
+
+export type FileTransferListeners = {
+ onProgress(callback: (percentage: number) => void): void;
+ onError(callback: (error: Error) => void): void;
+ onComplete(callback: () => void): void;
+};
+
+export enum FileTransferDialogDirection {
+ Download = 'Download',
+ Upload = 'Upload',
+}
diff --git a/packages/shared/components/FileTransfer/createFileTransferEventsEmitter.ts b/packages/shared/components/FileTransfer/createFileTransferEventsEmitter.ts
new file mode 100644
index 000000000..efae1cfe6
--- /dev/null
+++ b/packages/shared/components/FileTransfer/createFileTransferEventsEmitter.ts
@@ -0,0 +1,55 @@
+/**
+ * Copyright 2022 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 { EventEmitter } from 'events';
+
+import { FileTransferListeners } from './FileTransferStateless/types';
+
+export interface FileTransferEventsEmitter extends FileTransferListeners {
+ emitProgress(progress: number): void;
+
+ emitError(error: Error): void;
+
+ emitComplete(): void;
+}
+
+/**
+ * `createFileTransferEventsEmitter` is a utility function that helps with
+ * generating events that can be consumed by a function expecting `FileTransferListeners`.
+ */
+export function createFileTransferEventsEmitter(): FileTransferEventsEmitter {
+ const events = new EventEmitter();
+ return {
+ emitProgress: progress => {
+ events.emit('progress', progress);
+ },
+ emitComplete: () => {
+ events.emit('complete');
+ },
+ emitError: error => {
+ events.emit('error', error);
+ },
+ onProgress: callback => {
+ events.on('progress', callback);
+ },
+ onComplete: callback => {
+ events.on('complete', callback);
+ },
+ onError: callback => {
+ events.on('error', callback);
+ },
+ };
+}
diff --git a/packages/shared/components/FileTransfer/index.ts b/packages/shared/components/FileTransfer/index.ts
new file mode 100644
index 000000000..01be5f8f4
--- /dev/null
+++ b/packages/shared/components/FileTransfer/index.ts
@@ -0,0 +1,24 @@
+/**
+ * Copyright 2022 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 { FileTransfer } from './FileTransfer';
+export {
+ FileTransferContextProvider,
+ useFileTransferContext,
+} from './FileTransferContextProvider';
+export type { FileTransferListeners } from './FileTransferStateless/types';
+export { FileTransferActionBar } from './FileTransferActionBar';
+export * from './createFileTransferEventsEmitter';
diff --git a/packages/shared/components/FileTransfer/useFilesStore.ts b/packages/shared/components/FileTransfer/useFilesStore.ts
new file mode 100644
index 000000000..2931121e3
--- /dev/null
+++ b/packages/shared/components/FileTransfer/useFilesStore.ts
@@ -0,0 +1,166 @@
+/**
+ * Copyright 2022 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 { useCallback, useMemo, useReducer, useRef } from 'react';
+
+import {
+ FileTransferListeners,
+ TransferredFile,
+ TransferState,
+} from './FileTransferStateless';
+
+type FilesStoreState = {
+ ids: string[];
+ filesById: Record;
+};
+
+type FilesStoreActions =
+ | {
+ type: 'add';
+ payload: Pick;
+ }
+ | {
+ type: 'updateTransferState';
+ payload: {
+ id: string;
+ transferState: TransferState;
+ };
+ };
+
+const initialState: FilesStoreState = {
+ ids: [],
+ filesById: {},
+};
+
+function reducer(
+ state: typeof initialState,
+ action: FilesStoreActions
+): typeof initialState {
+ switch (action.type) {
+ case 'add': {
+ return {
+ ids: [...state.ids, action.payload.id],
+ filesById: {
+ ...state.filesById,
+ [action.payload.id]: {
+ ...action.payload,
+ transferState: { type: 'processing', progress: 0 },
+ },
+ },
+ };
+ }
+ case 'updateTransferState': {
+ const getNextTransferState = (): TransferState => {
+ if (action.payload.transferState.type === 'error') {
+ const { transferState: currentTransferState } =
+ state.filesById[action.payload.id];
+ return {
+ ...action.payload.transferState,
+ progress:
+ currentTransferState.type === 'processing'
+ ? currentTransferState.progress
+ : 0,
+ };
+ }
+ return action.payload.transferState;
+ };
+
+ return {
+ ...state,
+ filesById: {
+ ...state.filesById,
+ [action.payload.id]: {
+ ...state.filesById[action.payload.id],
+ transferState: getNextTransferState(),
+ },
+ },
+ };
+ }
+ default:
+ throw new Error('Unhandled action', action);
+ }
+}
+
+export const useFilesStore = () => {
+ const [state, dispatch] = useReducer(reducer, initialState);
+ const abortControllers = useRef(new Map());
+
+ const start = async (options: {
+ name: string;
+ runFileTransfer(
+ abortController: AbortController
+ ): Promise;
+ }) => {
+ const abortController = new AbortController();
+ const fileTransfer = await options.runFileTransfer(abortController);
+
+ if (!fileTransfer) {
+ return;
+ }
+
+ const id = new Date().getTime() + options.name;
+
+ dispatch({ type: 'add', payload: { id, name: options.name } });
+ abortControllers.current.set(id, abortController);
+
+ fileTransfer.onProgress(progress => {
+ updateTransferState(id, {
+ type: 'processing',
+ progress,
+ });
+ });
+ fileTransfer.onError(error => {
+ updateTransferState(id, {
+ type: 'error',
+ progress: undefined,
+ error,
+ });
+ });
+ fileTransfer.onComplete(() => {
+ updateTransferState(id, {
+ type: 'completed',
+ });
+ });
+ };
+
+ const updateTransferState = useCallback(
+ (id: string, transferState: TransferState) => {
+ dispatch({ type: 'updateTransferState', payload: { id, transferState } });
+ },
+ []
+ );
+
+ const cancel = useCallback((id: string) => {
+ abortControllers.current?.get(id).abort();
+ }, []);
+
+ const files = useMemo(
+ () => state.ids.map(id => state.filesById[id]),
+ [state.ids, state.filesById]
+ );
+
+ const isAnyTransferInProgress = useCallback(
+ () => files.some(file => file.transferState.type === 'processing'),
+ [files]
+ );
+
+ return {
+ files,
+ start,
+ cancel,
+ isAnyTransferInProgress,
+ };
+};
diff --git a/packages/teleport/src/Console/DocumentSsh/ActionBar/ActionBar.tsx b/packages/teleport/src/Console/DocumentSsh/ActionBar/ActionBar.tsx
deleted file mode 100644
index 61d57cbcf..000000000
--- a/packages/teleport/src/Console/DocumentSsh/ActionBar/ActionBar.tsx
+++ /dev/null
@@ -1,58 +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 from 'react';
-import * as Icons from 'design/Icon';
-import { Flex, ButtonIcon } from 'design';
-
-export default function ActionBar({
- isConnected,
- isDownloadOpen,
- isUploadOpen,
- onOpenDownload,
- onOpenUpload,
-}: Props) {
- const isScpDisabled = isDownloadOpen || isUploadOpen || !isConnected;
-
- return (
-
-
-
-
-
-
-
-
- );
-}
-
-type Props = {
- isConnected: boolean;
- isDownloadOpen: boolean;
- isUploadOpen: boolean;
- onOpenDownload: () => void;
- onOpenUpload: () => void;
-};
diff --git a/packages/teleport/src/Console/DocumentSsh/ActionBar/index.ts b/packages/teleport/src/Console/DocumentSsh/ActionBar/index.ts
deleted file mode 100644
index 5053904b0..000000000
--- a/packages/teleport/src/Console/DocumentSsh/ActionBar/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.
-*/
-
-import ActionBar from './ActionBar';
-export default ActionBar;
diff --git a/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx b/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx
index 7b3fd91cc..a30dd2611 100644
--- a/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx
+++ b/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx
@@ -20,8 +20,15 @@ import * as Icons from 'design/Icon';
import { Indicator, Text, Box, ButtonPrimary } from 'design';
import * as Alerts from 'design/Alert';
+import {
+ FileTransferActionBar,
+ FileTransfer,
+ FileTransferContextProvider,
+} from 'shared/components/FileTransfer';
+
import cfg from 'teleport/config';
import * as stores from 'teleport/Console/stores';
+import { colors } from 'teleport/Console/colors';
import AuthnDialog from 'teleport/components/AuthnDialog';
import useWebAuthn from 'teleport/lib/useWebAuthn';
@@ -29,18 +36,15 @@ import useWebAuthn from 'teleport/lib/useWebAuthn';
import Document from '../Document';
import Terminal from './Terminal';
-import FileTransfer, { useFileTransferDialogs } from './../FileTransfer';
import useSshSession from './useSshSession';
-import ActionBar from './ActionBar';
+import { getHttpFileTransferHandlers } from './httpFileTransferHandlers';
export default function DocumentSsh({ doc, visible }: PropTypes) {
const refTerminal = useRef();
- const scpDialogs = useFileTransferDialogs();
const { tty, status, statusText, closeDocument } = useSshSession(doc);
const webauthn = useWebAuthn(tty);
- function onCloseScpDialogs() {
- scpDialogs.close();
+ function handleCloseFileTransfer() {
refTerminal.current.terminal.term.focus();
}
@@ -53,42 +57,62 @@ export default function DocumentSsh({ doc, visible }: PropTypes) {
return (
-
- {status === 'loading' && (
-
-
-
- )}
- {status === 'error' && (
-
- Connection error: {statusText}
-
- )}
- {status === 'notfound' && (
-
- )}
- {webauthn.requested && (
-
+
+ {status === 'loading' && (
+
+
+
+ )}
+ {status === 'error' && (
+
+ Connection error: {statusText}
+
+ )}
+ {status === 'notfound' && (
+
+ )}
+ {webauthn.requested && (
+
+ )}
+ {status === 'initialized' && }
+
+ window.confirm('Are you sure you want to cancel file transfers?')
+ }
+ afterClose={handleCloseFileTransfer}
+ backgroundColor={colors.terminalDark}
+ transferHandlers={{
+ getDownloader: async (location, abortController) =>
+ getHttpFileTransferHandlers().download(
+ cfg.getScpUrl({
+ location,
+ clusterId: doc.clusterId,
+ serverId: doc.serverId,
+ login: doc.login,
+ filename: location,
+ }),
+ abortController
+ ),
+ getUploader: async (location, file, abortController) =>
+ getHttpFileTransferHandlers().upload(
+ cfg.getScpUrl({
+ location,
+ clusterId: doc.clusterId,
+ serverId: doc.serverId,
+ login: doc.login,
+ filename: file.name,
+ }),
+ file,
+ abortController
+ ),
+ }}
/>
- )}
- {status === 'initialized' && }
-
+
);
}
diff --git a/packages/teleport/src/Console/DocumentSsh/httpFileTransferHandlers.ts b/packages/teleport/src/Console/DocumentSsh/httpFileTransferHandlers.ts
new file mode 100644
index 000000000..9b66f8cd3
--- /dev/null
+++ b/packages/teleport/src/Console/DocumentSsh/httpFileTransferHandlers.ts
@@ -0,0 +1,197 @@
+import {
+ FileTransferEventsEmitter,
+ FileTransferListeners,
+ createFileTransferEventsEmitter,
+} from 'shared/components/FileTransfer';
+
+import { getAuthHeaders, getNoCacheHeaders } from 'teleport/services/api';
+
+export function getHttpFileTransferHandlers() {
+ return {
+ upload(
+ url: string,
+ file: File,
+ abortController?: AbortController
+ ): FileTransferListeners {
+ const eventEmitter = createFileTransferEventsEmitter();
+ const xhr = getBaseXhrRequest({
+ method: 'post',
+ url,
+ eventEmitter,
+ abortController,
+ transformFailedResponse: () => getErrorText(xhr.response),
+ });
+
+ xhr.upload.addEventListener('progress', e => {
+ eventEmitter.emitProgress(calculateProgress(e));
+ });
+ xhr.send(file);
+ return eventEmitter;
+ },
+ download(
+ url: string,
+ abortController?: AbortController
+ ): FileTransferListeners {
+ const eventEmitter = createFileTransferEventsEmitter();
+ const xhr = getBaseXhrRequest({
+ method: 'get',
+ url,
+ eventEmitter,
+ abortController,
+ transformSuccessfulResponse: () => {
+ const fileName = getDispositionFileName(xhr);
+ if (!fileName) {
+ throw new Error('Bad response');
+ } else {
+ saveOnDisk(fileName, xhr.response);
+ }
+ },
+ transformFailedResponse: () => getFileReaderErrorAsText(xhr.response),
+ });
+
+ xhr.onprogress = e => {
+ if (xhr.status === 200) {
+ eventEmitter.emitProgress(calculateProgress(e));
+ }
+ };
+ xhr.responseType = 'blob';
+ xhr.send();
+ return eventEmitter;
+ },
+ };
+}
+
+function getBaseXhrRequest({
+ method,
+ url,
+ abortController,
+ eventEmitter,
+ transformSuccessfulResponse,
+ transformFailedResponse,
+}: {
+ method: string;
+ url: string;
+ eventEmitter: FileTransferEventsEmitter;
+ abortController: AbortController;
+ transformSuccessfulResponse?(): void;
+ transformFailedResponse?(): Promise | string;
+}): XMLHttpRequest {
+ function setHeaders(): void {
+ const headers = {
+ ...getAuthHeaders(),
+ ...getNoCacheHeaders(),
+ };
+
+ Object.keys(headers).forEach(key => {
+ xhr.setRequestHeader(key, headers[key]);
+ });
+ }
+
+ function attachHandlers(): void {
+ if (abortController) {
+ abortController.signal.onabort = () => {
+ xhr.abort();
+ };
+ }
+
+ xhr.onload = async () => {
+ if (xhr.status !== 200) {
+ eventEmitter.emitError(new Error(await transformFailedResponse()));
+ return;
+ }
+
+ try {
+ transformSuccessfulResponse?.();
+ eventEmitter.emitComplete();
+ } catch (error) {
+ eventEmitter.emitError(error);
+ }
+ };
+
+ xhr.onerror = async () => {
+ eventEmitter.emitError(new Error(await transformFailedResponse()));
+ };
+
+ xhr.ontimeout = () => {
+ eventEmitter.emitError(new Error('Request timed out.'));
+ };
+
+ xhr.onabort = () => {
+ eventEmitter.emitError(new DOMException('Aborted', 'AbortError'));
+ };
+ }
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(method, url, true);
+ setHeaders();
+ attachHandlers();
+
+ return xhr;
+}
+
+function getFileReaderErrorAsText(xhrResponse: Blob): Promise {
+ return new Promise(resolve => {
+ const reader = new FileReader();
+
+ reader.onerror = () => {
+ resolve(reader.error.message);
+ };
+
+ reader.onload = () => {
+ const text = getErrorText(reader.result as string);
+ resolve(text);
+ };
+
+ reader.readAsText(xhrResponse);
+ });
+}
+
+function saveOnDisk(fileName: string, blob: Blob): void {
+ const a = document.createElement('a');
+ a.href = window.URL.createObjectURL(blob);
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+}
+
+// backend may return errors in different formats,
+// look at different JSON structures to retrieve the error message
+function getErrorText(response: string | undefined): string {
+ const badRequest = 'Bad request';
+ if (!response) {
+ return badRequest;
+ }
+
+ try {
+ const json = JSON.parse(response);
+ return json.error?.message || json.message || badRequest;
+ } catch (err) {
+ return 'Bad request, failed to parse error message.';
+ }
+}
+
+function calculateProgress(e: ProgressEvent): number {
+ // if Content-Length is present
+ if (e.lengthComputable) {
+ return Math.round((e.loaded / e.total) * 100);
+ } else {
+ const done = e.loaded;
+ const total = e.total;
+ return Math.floor((done / total) * 1000) / 10;
+ }
+}
+
+function getDispositionFileName(xhr: XMLHttpRequest) {
+ let fileName = '';
+ const disposition = xhr.getResponseHeader('Content-Disposition');
+ if (disposition) {
+ const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
+ const matches = filenameRegex.exec(disposition);
+ if (matches != null && matches[1]) {
+ fileName = matches[1].replace(/['"]/g, '');
+ }
+ }
+
+ return decodeURIComponent(fileName);
+}
diff --git a/packages/teleport/src/Console/FileTransfer/DownloadForm/DownloadForm.tsx b/packages/teleport/src/Console/FileTransfer/DownloadForm/DownloadForm.tsx
deleted file mode 100644
index da999ad41..000000000
--- a/packages/teleport/src/Console/FileTransfer/DownloadForm/DownloadForm.tsx
+++ /dev/null
@@ -1,86 +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 from 'react';
-
-import { Flex } from 'design';
-
-import * as Elements from './../Elements';
-
-export default class FileDownloadSelector extends React.Component<{
- onDownload: (path: string) => void;
-}> {
- inputRef = React.createRef();
-
- state = {
- path: '~/',
- };
-
- onChangePath = e => {
- this.setState({
- path: e.target.value,
- });
- };
-
- isValidPath(path: string) {
- return path && path[path.length - 1] !== '/';
- }
-
- onDownload = () => {
- if (this.isValidPath(this.state.path)) {
- this.props.onDownload(this.state.path);
- }
- };
-
- onKeyDown = (event: React.KeyboardEvent) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.stopPropagation();
- this.onDownload();
- }
- };
-
- moveCaretAtEnd(e: React.FocusEvent) {
- const tmp = e.target.value;
- e.target.value = '';
- e.target.value = tmp;
- }
-
- render() {
- const { path } = this.state;
- const isBtnDisabled = !this.isValidPath(path);
- return (
-
- (SCP) Download Files
- File Path
-
- (this.inputRef = e)}
- value={path}
- mb={0}
- autoFocus
- onFocus={this.moveCaretAtEnd}
- onKeyDown={this.onKeyDown}
- />
-
- Download
-
-
-
- );
- }
-}
diff --git a/packages/teleport/src/Console/FileTransfer/DownloadForm/index.ts b/packages/teleport/src/Console/FileTransfer/DownloadForm/index.ts
deleted file mode 100644
index 231c8ee1a..000000000
--- a/packages/teleport/src/Console/FileTransfer/DownloadForm/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.
-*/
-
-import DownloadForm from './DownloadForm';
-export default DownloadForm;
diff --git a/packages/teleport/src/Console/FileTransfer/Elements/Button.jsx b/packages/teleport/src/Console/FileTransfer/Elements/Button.jsx
deleted file mode 100644
index 34b83ea40..000000000
--- a/packages/teleport/src/Console/FileTransfer/Elements/Button.jsx
+++ /dev/null
@@ -1,47 +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 styled from 'styled-components';
-import { space } from 'design/system';
-
-import { colors } from '../../colors';
-
-const Button = styled.button`
- background: none;
- border-color: ${colors.terminal};
- border: 1px solid;
- box-sizing: border-box;
- cursor: pointer;
- text-transform: uppercase;
-
- &:disabled {
- border: 1px solid ${colors.subtle};
- color: ${colors.subtle};
- opacity: 0.24;
- }
-
- color: ${colors.terminal};
- background-color: none;
- ${space}
-`;
-
-Button.defaultProps = {
- px: '8fdpx',
- py: '4px',
- border: 1,
-};
-
-export default Button;
diff --git a/packages/teleport/src/Console/FileTransfer/Elements/ButtonClose.jsx b/packages/teleport/src/Console/FileTransfer/Elements/ButtonClose.jsx
deleted file mode 100644
index 0f0aaa766..000000000
--- a/packages/teleport/src/Console/FileTransfer/Elements/ButtonClose.jsx
+++ /dev/null
@@ -1,55 +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 from 'react';
-import styled from 'styled-components';
-import { space } from 'design/system';
-import { Close as CloseIcon } from 'design/Icon';
-
-export default function ButtonClose(props) {
- return (
-
-
-
- );
-}
-
-const StyledCloseButton = styled.button`
- background: #0000;
- border-radius: 2px;
- border: none;
- color: #fff;
- cursor: pointer;
- height: 20px;
- opacity: 0.56;
- outline: none;
- padding: 0;
- position: absolute;
- right: 8px;
- top: 8px;
- transition: all 0.3s;
- width: 20px;
- &:hover {
- opacity: 1;
- }
-
- &:hover {
- background: ${props => props.theme.colors.error};
- }
- font-size: ${props => props.theme.fontSizes[4]}px;
-
- ${space}
-`;
diff --git a/packages/teleport/src/Console/FileTransfer/Elements/Input.jsx b/packages/teleport/src/Console/FileTransfer/Elements/Input.jsx
deleted file mode 100644
index b9dff964a..000000000
--- a/packages/teleport/src/Console/FileTransfer/Elements/Input.jsx
+++ /dev/null
@@ -1,40 +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 styled from 'styled-components';
-import { width, space } from 'styled-system';
-
-import { colors } from 'teleport/Console/colors';
-
-const Input = styled.input`
- border: none;
- box-sizing: border-box;
- outline: none;
- width: 360px;
- background-color: ${colors.bgTerminal};
- color: ${colors.terminal};
- ${space}
- ${width}
-`;
-
-Input.defaultProps = {
- mb: 3,
- mr: 2,
- px: 2,
- py: '4px',
-};
-
-export default Input;
diff --git a/packages/teleport/src/Console/FileTransfer/Elements/Label.jsx b/packages/teleport/src/Console/FileTransfer/Elements/Label.jsx
deleted file mode 100644
index be025bf56..000000000
--- a/packages/teleport/src/Console/FileTransfer/Elements/Label.jsx
+++ /dev/null
@@ -1,33 +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 styled from 'styled-components';
-import { Text } from 'design';
-
-import { colors } from 'teleport/Console/colors';
-
-const Label = styled(Text)`
- display: block;
-`;
-
-Label.defaultProps = {
- caps: true,
- color: colors.terminal,
- mb: 2,
- mt: 2,
-};
-
-export default Label;
diff --git a/packages/teleport/src/Console/FileTransfer/Elements/index.js b/packages/teleport/src/Console/FileTransfer/Elements/index.js
deleted file mode 100644
index 3bfbd4f9a..000000000
--- a/packages/teleport/src/Console/FileTransfer/Elements/index.js
+++ /dev/null
@@ -1,38 +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 from 'react';
-import styled from 'styled-components';
-import { Text, Box } from 'design';
-
-import { colors } from '../../colors';
-
-import Button from './Button';
-import ButtonClose from './ButtonClose';
-import Input from './Input';
-import Label from './Label';
-
-export { Button, ButtonClose, Input, Label };
-
-export const Header = ({ children }) => (
-
-);
-
-export const Form = styled(Box)`
- font-size: ${props => props.theme.fontSizes[0]}px;
- background-color: ${colors.dark};
- color: ${colors.terminal};
-`;
diff --git a/packages/teleport/src/Console/FileTransfer/FileList/FileList.tsx b/packages/teleport/src/Console/FileTransfer/FileList/FileList.tsx
deleted file mode 100644
index d10e8fe78..000000000
--- a/packages/teleport/src/Console/FileTransfer/FileList/FileList.tsx
+++ /dev/null
@@ -1,73 +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 from 'react';
-import styled from 'styled-components';
-import { Box } from 'design';
-
-import FileListItem from './../FileListItem';
-import { File } from './../types';
-
-type FileListProps = {
- files: File[];
- onUpdate: (partial: Partial) => void;
- onRemove: (id: number) => void;
-};
-
-export default function FileList({ files, onUpdate, onRemove }: FileListProps) {
- if (files.length === 0) {
- return null;
- }
-
- const $files = files.map(file => {
- const key = file.id;
- const props = {
- onUpdate,
- key,
- file,
- onRemove,
- };
-
- return ;
- });
-
- return (
-
-
- File
-
- Status
-
-
- {$files}
-
- );
-}
-
-const ListHeaders = styled.div`
- display: flex;
- justify-content: space-between;
- text-transform: uppercase;
- font-weight: ${props => props.theme.bold};
-`;
-
-const ListItems = styled.div`
- overflow: auto;
- max-height: 300px;
- // scrollbars
- padding-right: 16px;
- margin-right: -16px;
-`;
diff --git a/packages/teleport/src/Console/FileTransfer/FileList/index.ts b/packages/teleport/src/Console/FileTransfer/FileList/index.ts
deleted file mode 100644
index aece99a77..000000000
--- a/packages/teleport/src/Console/FileTransfer/FileList/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.
-*/
-
-import FileList from './FileList';
-export default FileList;
diff --git a/packages/teleport/src/Console/FileTransfer/FileListItem/FileListItem.tsx b/packages/teleport/src/Console/FileTransfer/FileListItem/FileListItem.tsx
deleted file mode 100644
index b542211f8..000000000
--- a/packages/teleport/src/Console/FileTransfer/FileListItem/FileListItem.tsx
+++ /dev/null
@@ -1,162 +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, useRef } from 'react';
-import styled from 'styled-components';
-import * as Icons from 'design/Icon';
-import { Box } from 'design';
-
-import { colors } from 'teleport/Console/colors';
-import useHttpTransfer from 'teleport/Console/FileTransfer/useHttpTransfer';
-
-import { File } from './../types';
-
-type FileListItemProps = {
- file: File;
- onUpdate: (partial: Partial) => void;
- onRemove: (id: number) => void;
-};
-
-export default function FileListItem(props: FileListItemProps) {
- const { file, onUpdate } = props;
- const { name, id, isUpload, error, url, blob, status } = file;
-
- const saved = useRef(false);
- const httpStatus = useHttpTransfer({
- blob,
- url,
- isUpload,
- });
-
- useEffect(() => {
- const { state, response } = httpStatus;
- if (state === 'completed' && !isUpload) {
- if (!saved.current) {
- saved.current = true;
- saveOnDisk(response.fileName, response.blob);
- }
- }
-
- onUpdate({ id, status: httpStatus.state, error: httpStatus.error });
- }, [httpStatus.state]);
-
- function onRemove() {
- props.onRemove(id);
- }
-
- const completed = status === 'completed';
- const failed = status === 'error';
- const processing = status === 'processing';
-
- let statusText = `${httpStatus.progress}%`;
- if (failed) {
- statusText = 'failed';
- } else if (completed) {
- statusText = 'completed';
- }
-
- return (
-
-
-
- {name}
-
- {processing && }
- {statusText}
-
- {failed && {error} }
-
- );
-}
-
-const CancelButton = ({ onClick }) => {
- return (
-
-
-
- );
-};
-
-function saveOnDisk(fileName: string, blob: any) {
- const a = document.createElement('a');
- a.href = window.URL.createObjectURL(blob);
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
-}
-
-const StyledError = styled.div`
- line-height: 1.4;
- width: 360px;
- color: ${colors.error};
-`;
-
-const Progress = styled.div`
- display: flex;
- justify-content: space-between;
- align-items: center;
- width: 100%;
-`;
-
-const ProgressStatus = styled.div`
- font-size: 12px;
- height: 24px;
- line-height: 24px;
- width: 80px;
- text-align: right;
- color: ${props => (props.isFailed ? colors.error : colors.terminal)};
-`;
-
-const ProgressIndicator = styled.div`
- display: flex;
- align-items: center;
- word-break: break-word;
- background-image: linear-gradient(
- to right,
- ${colors.terminalDark} 0%,
- ${colors.terminalDark} ${props => props.progress}%,
- ${colors.bgTerminal} 0%,
- ${colors.bgTerminal} 100%
- );
-
- background: ${props => (props.isCompleted ? 'none' : '')};
- color: ${props => (props.isCompleted ? colors.inverse : colors.terminal)};
-
- min-height: 24px;
- line-height: 1.4;
-
- width: 360px;
-`;
-
-const StyledButton = styled.button`
- background: ${colors.error};
- border-radius: 2px;
- border: none;
- color: ${colors.light};
- cursor: pointer;
- font-size: 12px;
- height: 12px;
- outline: none;
- padding: 0;
- width: 12px;
- &:hover {
- background: ${colors.error};
- }
-`;
diff --git a/packages/teleport/src/Console/FileTransfer/FileListItem/index.ts b/packages/teleport/src/Console/FileTransfer/FileListItem/index.ts
deleted file mode 100644
index b84c8adfa..000000000
--- a/packages/teleport/src/Console/FileTransfer/FileListItem/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.
-*/
-
-import FileListItem from './FileListItem';
-export default FileListItem;
diff --git a/packages/teleport/src/Console/FileTransfer/FileTransfer.story.tsx b/packages/teleport/src/Console/FileTransfer/FileTransfer.story.tsx
deleted file mode 100644
index 7037d78bc..000000000
--- a/packages/teleport/src/Console/FileTransfer/FileTransfer.story.tsx
+++ /dev/null
@@ -1,112 +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 from 'react';
-
-import { Uploader, Downloader } from 'teleport/Console/services/fileTransfer';
-
-import { FileTransfer } from './FileTransfer';
-import { ScpContext } from './scpContextProvider';
-import { Scp } from './scpContext';
-
-export default {
- title: 'Teleport/Console/FileTransfer',
-};
-
-const props = {
- onClose: () => null,
-};
-
-export const DownloadError = () => {
- const context = makeContext({
- ...defaultFile,
- status: 'error',
- error: 'stat /root/test: no such file or directory',
- });
-
- return (
-
-
-
- );
-};
-
-export const DownloadProgress = () => {
- const context = makeContext({
- ...defaultFile,
- status: 'processing',
- });
-
- return (
-
-
-
- );
-};
-export const DownloadCompleted = () => {
- const context = makeContext({
- ...defaultFile,
- status: 'completed',
- });
-
- return (
-
-
-
- );
-};
-
-export const Upload = () => {
- const context = makeContext({
- ...defaultFile,
- status: 'completed',
- fileName: 'test',
- });
-
- return (
-
-
-
- );
-};
-
-function makeContext(file) {
- const context = new Scp({} as any);
-
- context.updateFile = () => null;
- context.store.state.files = [file];
- context.createUploader = () => {
- const uploader = new Uploader();
- uploader.do = () => null;
- return uploader;
- };
-
- context.createDownloader = () => {
- const downloader = new Downloader();
- downloader.do = () => null;
- return downloader;
- };
-
- return context;
-}
-
-const defaultFile = {
- location: '~test',
- id: '1547581437406~/test',
- url: '/v1/webapi/sites/one/nodes/',
- name: '~/test~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf~/mamaffsdfsdfdssdf',
- blob: [],
-};
diff --git a/packages/teleport/src/Console/FileTransfer/FileTransfer.tsx b/packages/teleport/src/Console/FileTransfer/FileTransfer.tsx
deleted file mode 100644
index c0f2c92e7..000000000
--- a/packages/teleport/src/Console/FileTransfer/FileTransfer.tsx
+++ /dev/null
@@ -1,141 +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 from 'react';
-import styled from 'styled-components';
-
-import { colors } from 'teleport/Console/colors';
-
-import DownloadForm from './DownloadForm';
-import UploadForm from './UploadForm';
-import FileList from './FileList';
-import { ButtonClose } from './Elements';
-import useScpContext, { ScpContextProvider } from './scpContextProvider';
-import { Scp } from './scpContext';
-
-export default function FileTransferDialogs(props: FileTransferDialogsProps) {
- const { isDownloadOpen, isUploadOpen, onClose, clusterId, serverId, login } =
- props;
- const isOpen = isDownloadOpen || isUploadOpen;
- if (!isOpen) {
- return null;
- }
-
- const ctx = React.useMemo(
- () => new Scp({ clusterId, serverId, login }),
- [clusterId, serverId, login]
- );
-
- return (
-
-
-
- );
-}
-
-export function FileTransfer({
- isDownloadOpen = false,
- isUploadOpen = false,
- onClose,
-}: FileTransferProps) {
- const scpContext = useScpContext();
- const { files } = scpContext.store.state;
-
- function onRemove(id: number) {
- scpContext.removeFile(id);
- }
-
- function onUpdate(json) {
- scpContext.updateFile(json);
- }
-
- function onDownload(location: string) {
- scpContext.addDownload(location);
- }
-
- function onUpload(location: string, filename: string, blob: any) {
- scpContext.addUpload(location, filename, blob);
- }
-
- function onBeforeClose() {
- const isTransfering = scpContext.isTransfering();
- if (!isTransfering) {
- onClose();
- }
-
- if (
- isTransfering &&
- window.confirm('Are you sure you want to cancel file transfers?')
- ) {
- onClose();
- }
- }
-
- function onKeyDown(e) {
- if (e.key !== 'Escape') {
- return;
- }
-
- e.preventDefault();
- e.stopPropagation();
- onBeforeClose();
- }
-
- return (
-
- {isDownloadOpen && }
- {isUploadOpen && }
-
-
-
- );
-}
-
-type FileTransferDialogsProps = {
- clusterId: string;
- isDownloadOpen: boolean;
- isUploadOpen: boolean;
- onClose: () => void;
- login: string;
- serverId: string;
-};
-
-type FileTransferProps = {
- isDownloadOpen?: boolean;
- isUploadOpen?: boolean;
- onClose: () => void;
-};
-
-const StyledFileTransfer = styled.div`
- background: ${colors.dark};
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
- box-sizing: border-box;
- border: 1px dashed #263238;
- font-size: ${props => props.theme.fontSizes[0]}px;
- color: #28fe14;
-
- padding: 16px;
- // replace it with the Portal component
- position: absolute;
- right: 0;
- top: 0;
- width: 496px;
- z-index: 3;
-`;
diff --git a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.story.tsx b/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.story.tsx
deleted file mode 100644
index 19235d66b..000000000
--- a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.story.tsx
+++ /dev/null
@@ -1,33 +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 from 'react';
-
-import UploadForm from './UploadForm';
-
-export default {
- title: 'Teleport/Console/FileTransfer/UploadWithFiles',
-};
-
-export const UploadWithFiles = () => {
- const ref = React.useRef();
- React.useEffect(() => {
- const blobs = [{ length: 4343 }, { length: 4343 }];
- ref.current.addFiles([], blobs);
- }, []);
-
- return null} />;
-};
diff --git a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.tsx b/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.tsx
deleted file mode 100644
index e850036bc..000000000
--- a/packages/teleport/src/Console/FileTransfer/UploadForm/UploadForm.tsx
+++ /dev/null
@@ -1,186 +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 from 'react';
-import styled from 'styled-components';
-
-import { colors } from 'teleport/Console/colors';
-
-import * as Elements from './../Elements';
-
-type PropType = {
- onUpload: (location: string, filename: string, blob: Blob) => void;
-};
-
-export default class UploadForm extends React.Component {
- refDropzone = React.createRef();
- refInput = React.createRef();
- refFileSelector = React.createRef();
-
- state = {
- files: [],
- remoteLocation: '~/',
- };
-
- componentWillUnmount() {
- document.removeEventListener('drop', this.onDocumentDrop);
- document.removeEventListener('dragover', this.preventDefault);
- }
-
- componentDidMount() {
- document.addEventListener('dragover', this.preventDefault, false);
- document.addEventListener('drop', this.onDocumentDrop, false);
- }
-
- preventDefault(e) {
- e.preventDefault();
- }
-
- onDocumentDrop(e) {
- if (
- this.refDropzone.current &&
- this.refDropzone.current.contains(e.target)
- ) {
- return;
- }
-
- e.preventDefault();
- e.dataTransfer.effectAllowed = 'none';
- e.dataTransfer.dropEffect = 'none';
- }
-
- onFileSelected = e => {
- this.addFiles([], e.target.files);
- this.refInput.current.focus();
- };
-
- onFilePathChanged = e => {
- this.setState({
- remoteLocation: e.target.value,
- });
- };
-
- onUpload = () => {
- const { files, remoteLocation } = this.state;
- for (var i = 0; i < files.length; i++) {
- this.props.onUpload(remoteLocation, files[i].name, files[i]);
- }
-
- this.setState({ files: [] });
- this.setFocus();
- };
-
- onOpenFilePicker = () => {
- // reset all selected files
- this.refFileSelector.current.value = '';
- this.refFileSelector.current.click();
- };
-
- onDrop = e => {
- e.preventDefault();
- e.stopPropagation();
- this.addFiles(this.state.files, e.dataTransfer.files);
- this.setFocus();
- };
-
- onKeyDown = event => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.stopPropagation();
- this.onOpenFilePicker();
- }
- };
-
- setFocus() {
- this.refInput.current.focus();
- }
-
- moveCaretAtEnd(e) {
- const tmp = e.target.value;
- e.target.value = '';
- e.target.value = tmp;
- }
-
- addFiles(files, blobs = []) {
- for (var i = 0; i < blobs.length; i++) {
- files.push(blobs[i]);
- }
-
- this.setState({
- files,
- });
- }
-
- render() {
- const { remoteLocation, files } = this.state;
- const isDldBtnDisabled = !remoteLocation || files.length === 0;
- const hasFiles = files.length > 0;
- const dropZoneText = hasFiles
- ? `${files.length} files selected`
- : `Select files to upload or drag & drop them here`;
-
- return (
-
- (SCP) UPLOAD Files
- Upload destination
-
-
- e.preventDefault()}
- onDrop={this.onDrop}
- onClick={this.onOpenFilePicker}
- >
- {dropZoneText}
-
-
- Upload
-
-
- );
- }
-}
-
-const Dropzone = styled.div`
- background: ${colors.bgTerminal};
- border: 1px dashed ${colors.text};
- color: ${colors.terminal};
- display: block;
- margin: 16px 0;
- height: 72px;
- line-height: 72px;
- text-align: center;
- text-transform: uppercase;
- cursor: pointer;
-`;
diff --git a/packages/teleport/src/Console/FileTransfer/UploadForm/index.ts b/packages/teleport/src/Console/FileTransfer/UploadForm/index.ts
deleted file mode 100644
index 6327efd4b..000000000
--- a/packages/teleport/src/Console/FileTransfer/UploadForm/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.
-*/
-
-import UploadForm from './UploadForm';
-export default UploadForm;
diff --git a/packages/teleport/src/Console/FileTransfer/index.ts b/packages/teleport/src/Console/FileTransfer/index.ts
deleted file mode 100644
index 89fad5177..000000000
--- a/packages/teleport/src/Console/FileTransfer/index.ts
+++ /dev/null
@@ -1,20 +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 FileTransfer from './FileTransfer';
-import useFileTransferDialogs from './useFileTransferDialogs';
-export default FileTransfer;
-export { useFileTransferDialogs };
diff --git a/packages/teleport/src/Console/FileTransfer/scpContext.tsx b/packages/teleport/src/Console/FileTransfer/scpContext.tsx
deleted file mode 100644
index 6ed35e4bf..000000000
--- a/packages/teleport/src/Console/FileTransfer/scpContext.tsx
+++ /dev/null
@@ -1,75 +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 { Uploader, Downloader } from 'teleport/Console/services/fileTransfer';
-
-import StoreFiles from './storeFiles';
-
-export class Scp {
- store = new StoreFiles();
-
- constructor({ clusterId, serverId, login }: ScpParams) {
- this.store = new StoreFiles({
- clusterId,
- serverId,
- login,
- });
- }
-
- removeFile(id: number) {
- this.store.remove(id);
- }
-
- updateFile(partial: Partial) {
- this.store.update(partial);
- }
-
- addDownload(location: string) {
- this.store.add({
- location,
- name: location,
- isUpload: false,
- blob: [],
- });
- }
-
- addUpload(location, filename, blob) {
- this.store.add({
- location,
- name: filename,
- isUpload: true,
- blob,
- });
- }
-
- isTransfering() {
- return this.store.state.files.some(f => f.status === 'processing');
- }
-
- createUploader() {
- return new Uploader();
- }
-
- createDownloader() {
- return new Downloader();
- }
-}
-
-type ScpParams = {
- clusterId: string;
- serverId: string;
- login: string;
-};
diff --git a/packages/teleport/src/Console/FileTransfer/scpContextProvider.tsx b/packages/teleport/src/Console/FileTransfer/scpContextProvider.tsx
deleted file mode 100644
index 9cdaee18b..000000000
--- a/packages/teleport/src/Console/FileTransfer/scpContextProvider.tsx
+++ /dev/null
@@ -1,39 +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 from 'react';
-import { useStore } from 'shared/libs/stores';
-
-import { Scp } from './scpContext';
-
-export const ScpContext = React.createContext(null);
-
-export const ScpContextProvider: React.FC<{ value: Scp }> = props => {
- return ;
-};
-
-export default function useScpContext() {
- const context = React.useContext(ScpContext);
-
- // subscribe to store updates
- useStore(context.store);
-
- if (!context) {
- throw new Error('ScpContext is missing a context');
- }
-
- return context;
-}
diff --git a/packages/teleport/src/Console/FileTransfer/storeFiles.ts b/packages/teleport/src/Console/FileTransfer/storeFiles.ts
deleted file mode 100644
index 528e023bb..000000000
--- a/packages/teleport/src/Console/FileTransfer/storeFiles.ts
+++ /dev/null
@@ -1,97 +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 } from 'shared/libs/stores';
-
-import cfg from 'teleport/config';
-
-import { File } from './types';
-
-const defaultState = {
- files: [] as File[],
- clusterId: '',
- serverId: '',
- login: '',
-};
-
-type State = typeof defaultState;
-
-export default class StoreFiles extends Store {
- state = {
- ...defaultState,
- };
-
- constructor(json?: Partial) {
- super();
- json && this.setState(json);
- }
-
- makeUrl(location: string, filename: string) {
- const { clusterId, serverId, login } = this.state;
- return cfg.getScpUrl({
- clusterId,
- serverId,
- login,
- location,
- filename,
- });
- }
-
- remove(id: number) {
- const files = this.state.files.filter(f => f.id !== id);
- return this.setState({ files });
- }
-
- add({ location, name, blob, isUpload }) {
- const url = this.makeUrl(location, name);
- const file = makeFile({
- url,
- name,
- isUpload,
- blob,
- });
-
- return this.setState({
- files: [...this.state.files, file],
- });
- }
-
- update(partial: Partial) {
- const index = this.state.files.findIndex(f => f.id === partial.id);
- const file = this.state.files[index];
- this.state.files[index] = {
- ...file,
- ...partial,
- };
-
- this.setState({
- files: [...this.state.files],
- });
- }
-}
-
-export function makeFile(json): File {
- const { url, name, isUpload, blob } = json;
- return {
- id: new Date().getTime() + name,
- url,
- name,
- isUpload,
- blob,
- status: 'processing',
- error: '',
- };
-}
diff --git a/packages/teleport/src/Console/FileTransfer/types.ts b/packages/teleport/src/Console/FileTransfer/types.ts
deleted file mode 100644
index dec3a20ed..000000000
--- a/packages/teleport/src/Console/FileTransfer/types.ts
+++ /dev/null
@@ -1,27 +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 type FileState = 'processing' | 'completed' | 'error';
-
-export type File = {
- id: number;
- url: string;
- name: string;
- isUpload: boolean;
- blob: Blob;
- status: FileState;
- error: string;
-};
diff --git a/packages/teleport/src/Console/FileTransfer/useFileTransferDialogs.ts b/packages/teleport/src/Console/FileTransfer/useFileTransferDialogs.ts
deleted file mode 100644
index 47d375564..000000000
--- a/packages/teleport/src/Console/FileTransfer/useFileTransferDialogs.ts
+++ /dev/null
@@ -1,43 +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 from 'react';
-
-export default function useFileTransferDialogs() {
- const [isUploadOpen, setUploadOpen] = React.useState(false);
- const [isDownloadOpen, setDownloadOpen] = React.useState(false);
-
- function openDownload() {
- setDownloadOpen(true);
- }
-
- function openUpload() {
- setUploadOpen(true);
- }
-
- function close() {
- setUploadOpen(false);
- setDownloadOpen(false);
- }
-
- return {
- isUploadOpen,
- isDownloadOpen,
- close,
- openDownload,
- openUpload,
- };
-}
diff --git a/packages/teleport/src/Console/FileTransfer/useHttpTransfer.ts b/packages/teleport/src/Console/FileTransfer/useHttpTransfer.ts
deleted file mode 100644
index db3cd3213..000000000
--- a/packages/teleport/src/Console/FileTransfer/useHttpTransfer.ts
+++ /dev/null
@@ -1,73 +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 from 'react';
-
-import useScpContext from './scpContextProvider';
-import { FileState } from './types';
-
-export default function useHttpTransfer({ blob, url, isUpload }) {
- const { createDownloader, createUploader } = useScpContext();
- const [http] = React.useState(() => {
- return isUpload ? createUploader() : createDownloader();
- });
-
- const [status, setStatus] = React.useState({
- response: null,
- progress: 0,
- state: 'processing' as FileState,
- error: '',
- });
-
- React.useEffect(() => {
- function handleProgress(progress: number) {
- setStatus({
- ...status,
- progress,
- });
- }
-
- function handleCompleted(response) {
- setStatus({
- ...status,
- response,
- state: 'completed',
- });
- }
-
- function handleFailed(err) {
- setStatus({
- ...status,
- error: err.message,
- state: 'error',
- });
- }
-
- http.onProgress(handleProgress);
- http.onCompleted(handleCompleted);
- http.onError(handleFailed);
- http.do(url, blob);
-
- function cleanup() {
- http.removeAllListeners();
- http.abort();
- }
-
- return cleanup;
- }, []);
-
- return status;
-}
diff --git a/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap b/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap
index 6f4dd04b6..e82f60faf 100644
--- a/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap
+++ b/packages/teleport/src/Console/Tabs/__snapshots__/Tabs.story.test.tsx.snap
@@ -45,10 +45,11 @@ exports[`render ConsoleTabs 1`] = `
.c9:disabled {
color: rgba(255,255,255,0.3);
+ cursor: default;
}
-.c9:hover,
-.c9:focus {
+.c9:hover:enabled,
+.c9:focus:enabled {
background: rgba(255,255,255,0.1);
}
diff --git a/packages/teleport/src/Console/services/fileTransfer.ts b/packages/teleport/src/Console/services/fileTransfer.ts
deleted file mode 100644
index 6c4ee95c6..000000000
--- a/packages/teleport/src/Console/services/fileTransfer.ts
+++ /dev/null
@@ -1,206 +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 { EventEmitter } from 'events';
-import Logger from 'shared/libs/logger';
-
-import { getAuthHeaders, getNoCacheHeaders } from 'teleport/services/api';
-
-const logger = Logger.create('console/services/fileTransfer');
-
-const REQ_FAILED_TXT = 'Network request failed';
-
-class Transfer extends EventEmitter {
- _xhr: XMLHttpRequest;
-
- constructor() {
- super();
- this._xhr = new XMLHttpRequest();
- const xhr = this._xhr;
-
- xhr.onload = () => {
- const { status } = xhr;
- if (status === 200) {
- this.handleSuccess(xhr);
- return;
- }
-
- this.handleError(xhr);
- };
-
- xhr.onerror = () => {
- this.emit('error', new Error(REQ_FAILED_TXT));
- };
-
- xhr.ontimeout = () => {
- this.emit('error', new Error(REQ_FAILED_TXT));
- };
-
- xhr.onabort = () => {
- this.emit('error', new DOMException('Aborted', 'AbortError'));
- };
- }
-
- abort() {
- this._xhr.abort();
- }
-
- onProgress(cb: (progress: number) => void) {
- this.on('progress', cb);
- }
-
- onCompleted(cb: (response: any) => void) {
- this.on('completed', cb);
- }
-
- onError(cb: (err: Error) => void) {
- this.on('error', cb);
- }
-
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- handleSuccess(xhr?: XMLHttpRequest) {
- throw Error('not implemented');
- }
-
- handleError(xhr: XMLHttpRequest) {
- const errText = getErrorText(xhr.response);
- this.emit('error', new Error(errText));
- }
-
- handleProgress(e) {
- let progress = 0;
- // if Content-Length is present
- if (e.lengthComputable) {
- progress = Math.round((e.loaded / e.total) * 100);
- } else {
- const done = e.position || e.loaded;
- const total = e.totalSize || e.total;
- progress = Math.floor((done / total) * 1000) / 10;
- }
-
- this.emit('progress', progress);
- }
-}
-
-export class Uploader extends Transfer {
- constructor() {
- super();
- }
-
- handleSuccess() {
- this.emit('completed');
- }
-
- do(url: string, blob: any) {
- this._xhr.upload.addEventListener('progress', e => {
- this.handleProgress(e);
- });
-
- this._xhr.open('post', url, true);
- setHeaders(this._xhr);
- this._xhr.send(blob);
- }
-}
-
-export class Downloader extends Transfer {
- constructor() {
- super();
- }
-
- do(url: string) {
- this._xhr.open('get', url, true);
- this._xhr.onprogress = e => {
- this.handleProgress(e);
- };
-
- setHeaders(this._xhr);
- this._xhr.responseType = 'blob';
- this._xhr.send();
- }
-
- handleSuccess(xhr: XMLHttpRequest) {
- const fileName = getDispositionFileName(xhr);
- if (!fileName) {
- this.emit('error', new Error('Bad response'));
- } else {
- this.emit('completed', {
- fileName: fileName,
- blob: xhr.response,
- });
- }
- }
-
- // parses blob response to get an error text
- handleError(xhr: XMLHttpRequest) {
- const reader = new FileReader();
-
- reader.onerror = err => {
- this.emit('error', err);
- };
-
- reader.onload = () => {
- const text = getErrorText(reader.result as string);
- this.emit('error', new Error(text));
- };
-
- reader.readAsText(xhr.response);
- }
-}
-
-function getDispositionFileName(xhr: XMLHttpRequest) {
- let fileName = '';
- const disposition = xhr.getResponseHeader('Content-Disposition');
- if (disposition) {
- const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
- const matches = filenameRegex.exec(disposition);
- if (matches != null && matches[1]) {
- fileName = matches[1].replace(/['"]/g, '');
- }
- }
-
- return decodeURIComponent(fileName);
-}
-
-// TODO: as backend may return errors in different
-// formats, look at different JSON structures to retrieve the error message
-function getErrorText(response: string) {
- const errText = 'Bad request';
- if (!response) {
- return errText;
- }
-
- try {
- const json = JSON.parse(response);
- if (json.message) {
- return json.message;
- }
- } catch (err) {
- logger.error('failed to parse error message', err);
- }
-
- return errText;
-}
-
-function setHeaders(xhr: XMLHttpRequest) {
- const headers = {
- ...getAuthHeaders(),
- ...getNoCacheHeaders(),
- };
-
- Object.keys(headers).forEach(key => {
- xhr.setRequestHeader(key, headers[key]);
- });
-}
diff --git a/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap b/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap
index b3da4a124..3f1ff175b 100644
--- a/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap
+++ b/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap
@@ -242,10 +242,11 @@ exports[`connected settings false 1`] = `
.c9:disabled {
color: rgba(255,255,255,0.3);
+ cursor: default;
}
-.c9:hover,
-.c9:focus {
+.c9:hover:enabled,
+.c9:focus:enabled {
background: rgba(255,255,255,0.1);
}
@@ -402,10 +403,11 @@ exports[`connected settings true 1`] = `
.c9:disabled {
color: rgba(255,255,255,0.3);
+ cursor: default;
}
-.c9:hover,
-.c9:focus {
+.c9:hover:enabled,
+.c9:focus:enabled {
background: rgba(255,255,255,0.1);
}
@@ -764,10 +766,11 @@ exports[`disconnected 1`] = `
.c9:disabled {
color: rgba(255,255,255,0.3);
+ cursor: default;
}
-.c9:hover,
-.c9:focus {
+.c9:hover:enabled,
+.c9:focus:enabled {
background: rgba(255,255,255,0.1);
}
diff --git a/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/packages/teleterm/src/mainProcess/fixtures/mocks.ts
index 483f4e797..b92c6960e 100644
--- a/packages/teleterm/src/mainProcess/fixtures/mocks.ts
+++ b/packages/teleterm/src/mainProcess/fixtures/mocks.ts
@@ -34,6 +34,10 @@ export class MockMainProcessClient implements MainProcessClient {
openTabContextMenu() {}
+ showFileSaveDialog() {
+ return Promise.resolve({ canceled: false, filePath: '' });
+ }
+
configService = {
get: () => ({
keyboardShortcuts: {},
diff --git a/packages/teleterm/src/mainProcess/mainProcess.ts b/packages/teleterm/src/mainProcess/mainProcess.ts
index 80d7500c3..5c0d55a8f 100644
--- a/packages/teleterm/src/mainProcess/mainProcess.ts
+++ b/packages/teleterm/src/mainProcess/mainProcess.ts
@@ -7,6 +7,7 @@ import {
app,
ipcMain,
shell,
+ dialog,
Menu,
MenuItemConstructorOptions,
} from 'electron';
@@ -173,6 +174,12 @@ export default class MainProcess {
}
);
+ ipcMain.handle('main-process-show-file-save-dialog', (_, filePath) =>
+ dialog.showSaveDialog({
+ defaultPath: path.basename(filePath),
+ })
+ );
+
subscribeToTerminalContextMenuEvent();
subscribeToTabContextMenuEvent();
subscribeToConfigServiceEvents(this.configService);
diff --git a/packages/teleterm/src/mainProcess/mainProcessClient.ts b/packages/teleterm/src/mainProcess/mainProcessClient.ts
index 9e8ff017a..17e029a07 100644
--- a/packages/teleterm/src/mainProcess/mainProcessClient.ts
+++ b/packages/teleterm/src/mainProcess/mainProcessClient.ts
@@ -18,6 +18,9 @@ export default function createMainProcessClient(): MainProcessClient {
'main-process-get-resolved-child-process-addresses'
);
},
+ showFileSaveDialog(filePath: string) {
+ return ipcRenderer.invoke('main-process-show-file-save-dialog', filePath);
+ },
openTerminalContextMenu,
openTabContextMenu,
configService: createConfigServiceClient(),
diff --git a/packages/teleterm/src/mainProcess/types.ts b/packages/teleterm/src/mainProcess/types.ts
index 0f13c0f0a..50431cc80 100644
--- a/packages/teleterm/src/mainProcess/types.ts
+++ b/packages/teleterm/src/mainProcess/types.ts
@@ -29,6 +29,9 @@ export type MainProcessClient = {
getResolvedChildProcessAddresses(): Promise;
openTerminalContextMenu(): void;
openTabContextMenu(options: TabContextMenuOptions): void;
+ showFileSaveDialog(
+ filePath: string
+ ): Promise<{ canceled: boolean; filePath: string | undefined }>;
configService: ConfigService;
fileStorage: FileStorage;
removeKubeConfig(options: {
diff --git a/packages/teleterm/src/services/tshd/createClient.ts b/packages/teleterm/src/services/tshd/createClient.ts
index d92c686fe..d02787db8 100644
--- a/packages/teleterm/src/services/tshd/createClient.ts
+++ b/packages/teleterm/src/services/tshd/createClient.ts
@@ -1,11 +1,12 @@
import { ChannelCredentials, ClientDuplexStream } from '@grpc/grpc-js';
-import { TerminalServiceClient } from 'teleterm/services/tshd/v1/service_grpc_pb';
-import * as api from 'teleterm/services/tshd/v1/service_pb';
-import * as types from 'teleterm/services/tshd/types';
import Logger from 'teleterm/logger';
+import * as api from './v1/service_pb';
+import { TerminalServiceClient } from './v1/service_grpc_pb';
+import { createFileTransferStream } from './createFileTransferStream';
import middleware, { withLogging } from './middleware';
+import * as types from './types';
import createAbortController from './createAbortController';
export default function createClient(
@@ -413,8 +414,22 @@ export default function createClient(
});
});
},
- };
+ transferFile(
+ options: types.FileTransferRequest,
+ abortSignal: types.TshAbortSignal
+ ) {
+ const req = new api.FileTransferRequest()
+ .setLogin(options.login)
+ .setSource(options.source)
+ .setDestination(options.destination)
+ .setHostname(options.hostname)
+ .setClusterUri(options.clusterUri)
+ .setDirection(options.direction);
+
+ return createFileTransferStream(tshd.transferFile(req), abortSignal);
+ },
+ };
return client;
}
diff --git a/packages/teleterm/src/services/tshd/createFileTransferStream.ts b/packages/teleterm/src/services/tshd/createFileTransferStream.ts
new file mode 100644
index 000000000..9b765263e
--- /dev/null
+++ b/packages/teleterm/src/services/tshd/createFileTransferStream.ts
@@ -0,0 +1,37 @@
+import { ClientReadableStream } from '@grpc/grpc-js';
+import { FileTransferListeners } from 'shared/components/FileTransfer';
+
+import { FileTransferProgress } from './v1/service_pb';
+import * as api from './v1/service_pb';
+import { TshAbortSignal } from './types';
+
+export function createFileTransferStream(
+ stream: ClientReadableStream,
+ abortSignal?: TshAbortSignal
+): FileTransferListeners {
+ abortSignal.addEventListener(() => stream.cancel());
+
+ return {
+ onProgress(callback: (progress: number) => void) {
+ stream.on('data', (data: api.FileTransferProgress) =>
+ callback(data.getPercentage())
+ );
+ },
+ onComplete(callback: () => void) {
+ stream.on('end', () => {
+ callback();
+ // When stream ends, all listeners can be removed.
+ stream.removeAllListeners();
+ });
+ },
+ onError(callback: (error: Error) => void) {
+ stream.on('error', err => {
+ callback(err);
+ // Due to a bug in grpc-js, the `error` event is also emitted after the `end` event.
+ // This behavior is not correct, only one of them should be emitted.
+ // To fix this, we remove all listeners after the stream ended with an error.
+ stream.removeAllListeners();
+ });
+ },
+ };
+}
diff --git a/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/packages/teleterm/src/services/tshd/fixtures/mocks.ts
index 2bc2281de..178bbf4a7 100644
--- a/packages/teleterm/src/services/tshd/fixtures/mocks.ts
+++ b/packages/teleterm/src/services/tshd/fixtures/mocks.ts
@@ -55,4 +55,5 @@ export class MockTshClient implements TshClient {
abortSignal?: TshAbortSignal
) => Promise;
logout: (clusterUri: string) => Promise;
+ transferFile: () => undefined;
}
diff --git a/packages/teleterm/src/services/tshd/types.ts b/packages/teleterm/src/services/tshd/types.ts
index 90cbee954..567d36294 100644
--- a/packages/teleterm/src/services/tshd/types.ts
+++ b/packages/teleterm/src/services/tshd/types.ts
@@ -1,3 +1,5 @@
+import { FileTransferListeners } from 'shared/components/FileTransfer';
+
import apiCluster from './v1/cluster_pb';
import apiDb from './v1/database_pb';
import apigateway from './v1/gateway_pb';
@@ -28,6 +30,8 @@ export type LoggedInUser = apiCluster.LoggedInUser.AsObject;
export type AuthProvider = apiAuthSettings.AuthProvider.AsObject;
export type AuthSettings = apiAuthSettings.AuthSettings.AsObject;
+export type FileTransferRequest = apiService.FileTransferRequest.AsObject;
+
export type WebauthnCredentialInfo = apiService.CredentialInfo.AsObject;
export type WebauthnLoginPrompt =
| WebauthnLoginTapPrompt
@@ -88,6 +92,10 @@ export type TshClient = {
abortSignal?: TshAbortSignal
) => Promise;
logout: (clusterUri: string) => Promise;
+ transferFile: (
+ options: FileTransferRequest,
+ abortSignal?: TshAbortSignal
+ ) => FileTransferListeners;
};
export type TshAbortController = {
diff --git a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts
index 8d15635b2..6e8e9b14b 100644
--- a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts
+++ b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.d.ts
@@ -35,6 +35,7 @@ interface ITerminalServiceService extends grpc.ServiceDefinition {
@@ -217,6 +218,15 @@ interface ITerminalServiceService_ILogout extends grpc.MethodDefinition;
responseDeserialize: grpc.deserialize;
}
+interface ITerminalServiceService_ITransferFile extends grpc.MethodDefinition {
+ path: "/teleport.terminal.v1.TerminalService/TransferFile";
+ requestStream: false;
+ responseStream: true;
+ requestSerialize: grpc.serialize;
+ requestDeserialize: grpc.deserialize;
+ responseSerialize: grpc.serialize;
+ responseDeserialize: grpc.deserialize;
+}
export const TerminalServiceService: ITerminalServiceService;
@@ -241,6 +251,7 @@ export interface ITerminalServiceServer {
login: grpc.handleUnaryCall;
loginPasswordless: grpc.handleBidiStreamingCall;
logout: grpc.handleUnaryCall;
+ transferFile: grpc.handleServerStreamingCall;
}
export interface ITerminalServiceClient {
@@ -304,6 +315,8 @@ export interface ITerminalServiceClient {
logout(request: v1_service_pb.LogoutRequest, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall;
logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall;
logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall;
+ transferFile(request: v1_service_pb.FileTransferRequest, options?: Partial): grpc.ClientReadableStream;
+ transferFile(request: v1_service_pb.FileTransferRequest, metadata?: grpc.Metadata, options?: Partial): grpc.ClientReadableStream;
}
export class TerminalServiceClient extends grpc.Client implements ITerminalServiceClient {
@@ -367,4 +380,6 @@ export class TerminalServiceClient extends grpc.Client implements ITerminalServi
public logout(request: v1_service_pb.LogoutRequest, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall;
public logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall;
public logout(request: v1_service_pb.LogoutRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: v1_service_pb.EmptyResponse) => void): grpc.ClientUnaryCall;
+ public transferFile(request: v1_service_pb.FileTransferRequest, options?: Partial): grpc.ClientReadableStream;
+ public transferFile(request: v1_service_pb.FileTransferRequest, metadata?: grpc.Metadata, options?: Partial): grpc.ClientReadableStream;
}
diff --git a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js
index 5c2658121..dbd6044c9 100644
--- a/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js
+++ b/packages/teleterm/src/services/tshd/v1/service_grpc_pb.js
@@ -81,6 +81,28 @@ function deserialize_teleport_terminal_v1_EmptyResponse(buffer_arg) {
return v1_service_pb.EmptyResponse.deserializeBinary(new Uint8Array(buffer_arg));
}
+function serialize_teleport_terminal_v1_FileTransferProgress(arg) {
+ if (!(arg instanceof v1_service_pb.FileTransferProgress)) {
+ throw new Error('Expected argument of type teleport.terminal.v1.FileTransferProgress');
+ }
+ return Buffer.from(arg.serializeBinary());
+}
+
+function deserialize_teleport_terminal_v1_FileTransferProgress(buffer_arg) {
+ return v1_service_pb.FileTransferProgress.deserializeBinary(new Uint8Array(buffer_arg));
+}
+
+function serialize_teleport_terminal_v1_FileTransferRequest(arg) {
+ if (!(arg instanceof v1_service_pb.FileTransferRequest)) {
+ throw new Error('Expected argument of type teleport.terminal.v1.FileTransferRequest');
+ }
+ return Buffer.from(arg.serializeBinary());
+}
+
+function deserialize_teleport_terminal_v1_FileTransferRequest(buffer_arg) {
+ return v1_service_pb.FileTransferRequest.deserializeBinary(new Uint8Array(buffer_arg));
+}
+
function serialize_teleport_terminal_v1_Gateway(arg) {
if (!(arg instanceof v1_gateway_pb.Gateway)) {
throw new Error('Expected argument of type teleport.terminal.v1.Gateway');
@@ -642,6 +664,18 @@ logout: {
responseSerialize: serialize_teleport_terminal_v1_EmptyResponse,
responseDeserialize: deserialize_teleport_terminal_v1_EmptyResponse,
},
+ // TransferFile downloads/uploads a file
+transferFile: {
+ path: '/teleport.terminal.v1.TerminalService/TransferFile',
+ requestStream: false,
+ responseStream: true,
+ requestType: v1_service_pb.FileTransferRequest,
+ responseType: v1_service_pb.FileTransferProgress,
+ requestSerialize: serialize_teleport_terminal_v1_FileTransferRequest,
+ requestDeserialize: deserialize_teleport_terminal_v1_FileTransferRequest,
+ responseSerialize: serialize_teleport_terminal_v1_FileTransferProgress,
+ responseDeserialize: deserialize_teleport_terminal_v1_FileTransferProgress,
+ },
};
exports.TerminalServiceClient = grpc.makeGenericClientConstructor(TerminalServiceService);
diff --git a/packages/teleterm/src/services/tshd/v1/service_pb.d.ts b/packages/teleterm/src/services/tshd/v1/service_pb.d.ts
index 23f8e87e8..0138f2003 100644
--- a/packages/teleterm/src/services/tshd/v1/service_pb.d.ts
+++ b/packages/teleterm/src/services/tshd/v1/service_pb.d.ts
@@ -241,6 +241,68 @@ export namespace LoginPasswordlessRequest {
}
+export class FileTransferRequest extends jspb.Message {
+ getClusterUri(): string;
+ setClusterUri(value: string): FileTransferRequest;
+
+ getLogin(): string;
+ setLogin(value: string): FileTransferRequest;
+
+ getHostname(): string;
+ setHostname(value: string): FileTransferRequest;
+
+ getSource(): string;
+ setSource(value: string): FileTransferRequest;
+
+ getDestination(): string;
+ setDestination(value: string): FileTransferRequest;
+
+ getDirection(): FileTransferDirection;
+ setDirection(value: FileTransferDirection): FileTransferRequest;
+
+
+ serializeBinary(): Uint8Array;
+ toObject(includeInstance?: boolean): FileTransferRequest.AsObject;
+ static toObject(includeInstance: boolean, msg: FileTransferRequest): FileTransferRequest.AsObject;
+ static extensions: {[key: number]: jspb.ExtensionFieldInfo};
+ static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo};
+ static serializeBinaryToWriter(message: FileTransferRequest, writer: jspb.BinaryWriter): void;
+ static deserializeBinary(bytes: Uint8Array): FileTransferRequest;
+ static deserializeBinaryFromReader(message: FileTransferRequest, reader: jspb.BinaryReader): FileTransferRequest;
+}
+
+export namespace FileTransferRequest {
+ export type AsObject = {
+ clusterUri: string,
+ login: string,
+ hostname: string,
+ source: string,
+ destination: string,
+ direction: FileTransferDirection,
+ }
+}
+
+export class FileTransferProgress extends jspb.Message {
+ getPercentage(): number;
+ setPercentage(value: number): FileTransferProgress;
+
+
+ serializeBinary(): Uint8Array;
+ toObject(includeInstance?: boolean): FileTransferProgress.AsObject;
+ static toObject(includeInstance: boolean, msg: FileTransferProgress): FileTransferProgress.AsObject;
+ static extensions: {[key: number]: jspb.ExtensionFieldInfo};
+ static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo};
+ static serializeBinaryToWriter(message: FileTransferProgress, writer: jspb.BinaryWriter): void;
+ static deserializeBinary(bytes: Uint8Array): FileTransferProgress;
+ static deserializeBinaryFromReader(message: FileTransferProgress, reader: jspb.BinaryReader): FileTransferProgress;
+}
+
+export namespace FileTransferProgress {
+ export type AsObject = {
+ percentage: number,
+ }
+}
+
export class LoginRequest extends jspb.Message {
getClusterUri(): string;
setClusterUri(value: string): LoginRequest;
@@ -861,3 +923,9 @@ export enum PasswordlessPrompt {
PASSWORDLESS_PROMPT_TAP = 2,
PASSWORDLESS_PROMPT_CREDENTIAL = 3,
}
+
+export enum FileTransferDirection {
+ FILE_TRANSFER_DIRECTION_UNSPECIFIED = 0,
+ FILE_TRANSFER_DIRECTION_DOWNLOAD = 1,
+ FILE_TRANSFER_DIRECTION_UPLOAD = 2,
+}
diff --git a/packages/teleterm/src/services/tshd/v1/service_pb.js b/packages/teleterm/src/services/tshd/v1/service_pb.js
index 6a8d8a65f..f76c00a0c 100644
--- a/packages/teleterm/src/services/tshd/v1/service_pb.js
+++ b/packages/teleterm/src/services/tshd/v1/service_pb.js
@@ -33,6 +33,9 @@ goog.exportSymbol('proto.teleport.terminal.v1.AddClusterRequest', null, global);
goog.exportSymbol('proto.teleport.terminal.v1.CreateGatewayRequest', null, global);
goog.exportSymbol('proto.teleport.terminal.v1.CredentialInfo', null, global);
goog.exportSymbol('proto.teleport.terminal.v1.EmptyResponse', null, global);
+goog.exportSymbol('proto.teleport.terminal.v1.FileTransferDirection', null, global);
+goog.exportSymbol('proto.teleport.terminal.v1.FileTransferProgress', null, global);
+goog.exportSymbol('proto.teleport.terminal.v1.FileTransferRequest', null, global);
goog.exportSymbol('proto.teleport.terminal.v1.GetAuthSettingsRequest', null, global);
goog.exportSymbol('proto.teleport.terminal.v1.GetClusterRequest', null, global);
goog.exportSymbol('proto.teleport.terminal.v1.ListAppsRequest', null, global);
@@ -256,6 +259,48 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.teleport.terminal.v1.LoginPasswordlessRequest.LoginPasswordlessCredentialResponse.displayName = 'proto.teleport.terminal.v1.LoginPasswordlessRequest.LoginPasswordlessCredentialResponse';
}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.teleport.terminal.v1.FileTransferRequest = function(opt_data) {
+ jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.teleport.terminal.v1.FileTransferRequest, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+ /**
+ * @public
+ * @override
+ */
+ proto.teleport.terminal.v1.FileTransferRequest.displayName = 'proto.teleport.terminal.v1.FileTransferRequest';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.teleport.terminal.v1.FileTransferProgress = function(opt_data) {
+ jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.teleport.terminal.v1.FileTransferProgress, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+ /**
+ * @public
+ * @override
+ */
+ proto.teleport.terminal.v1.FileTransferProgress.displayName = 'proto.teleport.terminal.v1.FileTransferProgress';
+}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -2183,6 +2228,416 @@ proto.teleport.terminal.v1.LoginPasswordlessRequest.prototype.hasCredential = fu
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ * net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ * JSPB instance for transitional soy proto support:
+ * http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.toObject = function(opt_includeInstance) {
+ return proto.teleport.terminal.v1.FileTransferRequest.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ * the JSPB instance for transitional soy proto support:
+ * http://goto/soy-param-migration
+ * @param {!proto.teleport.terminal.v1.FileTransferRequest} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.teleport.terminal.v1.FileTransferRequest.toObject = function(includeInstance, msg) {
+ var f, obj = {
+ clusterUri: jspb.Message.getFieldWithDefault(msg, 1, ""),
+ login: jspb.Message.getFieldWithDefault(msg, 2, ""),
+ hostname: jspb.Message.getFieldWithDefault(msg, 3, ""),
+ source: jspb.Message.getFieldWithDefault(msg, 4, ""),
+ destination: jspb.Message.getFieldWithDefault(msg, 5, ""),
+ direction: jspb.Message.getFieldWithDefault(msg, 6, 0)
+ };
+
+ if (includeInstance) {
+ obj.$jspbMessageInstance = msg;
+ }
+ return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.deserializeBinary = function(bytes) {
+ var reader = new jspb.BinaryReader(bytes);
+ var msg = new proto.teleport.terminal.v1.FileTransferRequest;
+ return proto.teleport.terminal.v1.FileTransferRequest.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.teleport.terminal.v1.FileTransferRequest} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.deserializeBinaryFromReader = function(msg, reader) {
+ while (reader.nextField()) {
+ if (reader.isEndGroup()) {
+ break;
+ }
+ var field = reader.getFieldNumber();
+ switch (field) {
+ case 1:
+ var value = /** @type {string} */ (reader.readString());
+ msg.setClusterUri(value);
+ break;
+ case 2:
+ var value = /** @type {string} */ (reader.readString());
+ msg.setLogin(value);
+ break;
+ case 3:
+ var value = /** @type {string} */ (reader.readString());
+ msg.setHostname(value);
+ break;
+ case 4:
+ var value = /** @type {string} */ (reader.readString());
+ msg.setSource(value);
+ break;
+ case 5:
+ var value = /** @type {string} */ (reader.readString());
+ msg.setDestination(value);
+ break;
+ case 6:
+ var value = /** @type {!proto.teleport.terminal.v1.FileTransferDirection} */ (reader.readEnum());
+ msg.setDirection(value);
+ break;
+ default:
+ reader.skipField();
+ break;
+ }
+ }
+ return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.serializeBinary = function() {
+ var writer = new jspb.BinaryWriter();
+ proto.teleport.terminal.v1.FileTransferRequest.serializeBinaryToWriter(this, writer);
+ return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.teleport.terminal.v1.FileTransferRequest} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.teleport.terminal.v1.FileTransferRequest.serializeBinaryToWriter = function(message, writer) {
+ var f = undefined;
+ f = message.getClusterUri();
+ if (f.length > 0) {
+ writer.writeString(
+ 1,
+ f
+ );
+ }
+ f = message.getLogin();
+ if (f.length > 0) {
+ writer.writeString(
+ 2,
+ f
+ );
+ }
+ f = message.getHostname();
+ if (f.length > 0) {
+ writer.writeString(
+ 3,
+ f
+ );
+ }
+ f = message.getSource();
+ if (f.length > 0) {
+ writer.writeString(
+ 4,
+ f
+ );
+ }
+ f = message.getDestination();
+ if (f.length > 0) {
+ writer.writeString(
+ 5,
+ f
+ );
+ }
+ f = message.getDirection();
+ if (f !== 0.0) {
+ writer.writeEnum(
+ 6,
+ f
+ );
+ }
+};
+
+
+/**
+ * optional string cluster_uri = 1;
+ * @return {string}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.getClusterUri = function() {
+ return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.setClusterUri = function(value) {
+ return jspb.Message.setProto3StringField(this, 1, value);
+};
+
+
+/**
+ * optional string login = 2;
+ * @return {string}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.getLogin = function() {
+ return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.setLogin = function(value) {
+ return jspb.Message.setProto3StringField(this, 2, value);
+};
+
+
+/**
+ * optional string hostname = 3;
+ * @return {string}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.getHostname = function() {
+ return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.setHostname = function(value) {
+ return jspb.Message.setProto3StringField(this, 3, value);
+};
+
+
+/**
+ * optional string source = 4;
+ * @return {string}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.getSource = function() {
+ return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.setSource = function(value) {
+ return jspb.Message.setProto3StringField(this, 4, value);
+};
+
+
+/**
+ * optional string destination = 5;
+ * @return {string}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.getDestination = function() {
+ return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 5, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.setDestination = function(value) {
+ return jspb.Message.setProto3StringField(this, 5, value);
+};
+
+
+/**
+ * optional FileTransferDirection direction = 6;
+ * @return {!proto.teleport.terminal.v1.FileTransferDirection}
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.getDirection = function() {
+ return /** @type {!proto.teleport.terminal.v1.FileTransferDirection} */ (jspb.Message.getFieldWithDefault(this, 6, 0));
+};
+
+
+/**
+ * @param {!proto.teleport.terminal.v1.FileTransferDirection} value
+ * @return {!proto.teleport.terminal.v1.FileTransferRequest} returns this
+ */
+proto.teleport.terminal.v1.FileTransferRequest.prototype.setDirection = function(value) {
+ return jspb.Message.setProto3EnumField(this, 6, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ * net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ * JSPB instance for transitional soy proto support:
+ * http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.teleport.terminal.v1.FileTransferProgress.prototype.toObject = function(opt_includeInstance) {
+ return proto.teleport.terminal.v1.FileTransferProgress.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ * the JSPB instance for transitional soy proto support:
+ * http://goto/soy-param-migration
+ * @param {!proto.teleport.terminal.v1.FileTransferProgress} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.teleport.terminal.v1.FileTransferProgress.toObject = function(includeInstance, msg) {
+ var f, obj = {
+ percentage: jspb.Message.getFieldWithDefault(msg, 1, 0)
+ };
+
+ if (includeInstance) {
+ obj.$jspbMessageInstance = msg;
+ }
+ return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.teleport.terminal.v1.FileTransferProgress}
+ */
+proto.teleport.terminal.v1.FileTransferProgress.deserializeBinary = function(bytes) {
+ var reader = new jspb.BinaryReader(bytes);
+ var msg = new proto.teleport.terminal.v1.FileTransferProgress;
+ return proto.teleport.terminal.v1.FileTransferProgress.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.teleport.terminal.v1.FileTransferProgress} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.teleport.terminal.v1.FileTransferProgress}
+ */
+proto.teleport.terminal.v1.FileTransferProgress.deserializeBinaryFromReader = function(msg, reader) {
+ while (reader.nextField()) {
+ if (reader.isEndGroup()) {
+ break;
+ }
+ var field = reader.getFieldNumber();
+ switch (field) {
+ case 1:
+ var value = /** @type {number} */ (reader.readUint32());
+ msg.setPercentage(value);
+ break;
+ default:
+ reader.skipField();
+ break;
+ }
+ }
+ return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.teleport.terminal.v1.FileTransferProgress.prototype.serializeBinary = function() {
+ var writer = new jspb.BinaryWriter();
+ proto.teleport.terminal.v1.FileTransferProgress.serializeBinaryToWriter(this, writer);
+ return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.teleport.terminal.v1.FileTransferProgress} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.teleport.terminal.v1.FileTransferProgress.serializeBinaryToWriter = function(message, writer) {
+ var f = undefined;
+ f = message.getPercentage();
+ if (f !== 0) {
+ writer.writeUint32(
+ 1,
+ f
+ );
+ }
+};
+
+
+/**
+ * optional uint32 percentage = 1;
+ * @return {number}
+ */
+proto.teleport.terminal.v1.FileTransferProgress.prototype.getPercentage = function() {
+ return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.teleport.terminal.v1.FileTransferProgress} returns this
+ */
+proto.teleport.terminal.v1.FileTransferProgress.prototype.setPercentage = function(value) {
+ return jspb.Message.setProto3IntField(this, 1, value);
+};
+
+
+
/**
* Oneof group definitions for this message. Each group defines the field
* numbers belonging to that group. When of these fields' value is set, all
@@ -6114,4 +6569,13 @@ proto.teleport.terminal.v1.PasswordlessPrompt = {
PASSWORDLESS_PROMPT_CREDENTIAL: 3
};
+/**
+ * @enum {number}
+ */
+proto.teleport.terminal.v1.FileTransferDirection = {
+ FILE_TRANSFER_DIRECTION_UNSPECIFIED: 0,
+ FILE_TRANSFER_DIRECTION_DOWNLOAD: 1,
+ FILE_TRANSFER_DIRECTION_UPLOAD: 2
+};
+
goog.object.extend(exports, proto.teleport.terminal.v1);
diff --git a/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx b/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx
index 16482fe43..7d9a35a84 100644
--- a/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx
+++ b/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx
@@ -16,11 +16,19 @@ limitations under the License.
import React from 'react';
+import {
+ FileTransferActionBar,
+ FileTransfer,
+ FileTransferContextProvider,
+} from 'shared/components/FileTransfer';
+
import Document from 'teleterm/ui/Document';
+import { useAppContext } from 'teleterm/ui/appContextProvider';
-import useDocTerminal, { Props } from './useDocumentTerminal';
import Terminal from './Terminal';
import DocumentReconnect from './DocumentReconnect';
+import useDocTerminal, { Props } from './useDocumentTerminal';
+import { useTshFileTransferHandlers } from './useTshFileTransferHandlers';
export default function DocumentTerminalContainer({ doc, visible }: Props) {
if (doc.kind === 'doc.terminal_tsh_node' && doc.status === 'disconnected') {
@@ -31,9 +39,13 @@ export default function DocumentTerminalContainer({ doc, visible }: Props) {
}
export function DocumentTerminal(props: Props & { visible: boolean }) {
+ const ctx = useAppContext();
const { visible, doc } = props;
const state = useDocTerminal(doc);
const ptyProcess = state.data?.ptyProcess;
+ const { upload, download } = useTshFileTransferHandlers({
+ originatingDocumentUri: doc.uri,
+ });
return (
{ptyProcess && (
-
+ <>
+ {doc.kind === 'doc.terminal_tsh_node' && (
+ <>
+
+
+
+ // TODO (gzdunek): replace with a native dialog
+ window.confirm(
+ 'Are you sure you want to cancel file transfers?'
+ )
+ }
+ transferHandlers={{
+ getDownloader: async (sourcePath, abortController) => {
+ const fileDialog =
+ await ctx.mainProcessClient.showFileSaveDialog(
+ sourcePath
+ );
+ if (fileDialog.canceled) {
+ return;
+ }
+ return download(
+ {
+ serverUri: doc.serverUri,
+ login: doc.login,
+ source: sourcePath,
+ destination: fileDialog.filePath,
+ },
+ abortController
+ );
+ },
+ getUploader: async (
+ destinationPath,
+ file,
+ abortController
+ ) =>
+ upload(
+ {
+ serverUri: doc.serverUri,
+ login: doc.login,
+ source: file.path,
+ destination: destinationPath,
+ },
+ abortController
+ ),
+ }}
+ />
+
+ >
+ )}
+
+ >
)}
);
diff --git a/packages/teleterm/src/ui/DocumentTerminal/useTshFileTransferHandlers.ts b/packages/teleterm/src/ui/DocumentTerminal/useTshFileTransferHandlers.ts
new file mode 100644
index 000000000..7cd120be9
--- /dev/null
+++ b/packages/teleterm/src/ui/DocumentTerminal/useTshFileTransferHandlers.ts
@@ -0,0 +1,96 @@
+import {
+ FileTransferListeners,
+ createFileTransferEventsEmitter,
+} from 'shared/components/FileTransfer';
+
+import { routing } from 'teleterm/ui/uri';
+import { FileTransferDirection } from 'teleterm/services/tshd/v1/service_pb';
+import { retryWithRelogin } from 'teleterm/ui/utils';
+import { useAppContext } from 'teleterm/ui/appContextProvider';
+import { IAppContext } from 'teleterm/ui/types';
+
+export function useTshFileTransferHandlers(options: {
+ originatingDocumentUri: string;
+}) {
+ const appContext = useAppContext();
+
+ return {
+ upload(
+ file: FileTransferRequestObject,
+ abortController: AbortController
+ ): FileTransferListeners {
+ return transferFile(
+ appContext,
+ options.originatingDocumentUri,
+ file,
+ abortController,
+ FileTransferDirection.FILE_TRANSFER_DIRECTION_UPLOAD
+ );
+ },
+ download(
+ file: FileTransferRequestObject,
+ abortController: AbortController
+ ): FileTransferListeners {
+ return transferFile(
+ appContext,
+ options.originatingDocumentUri,
+ file,
+ abortController,
+ FileTransferDirection.FILE_TRANSFER_DIRECTION_DOWNLOAD
+ );
+ },
+ };
+}
+
+function transferFile(
+ appContext: IAppContext,
+ originatingDocumentUri: string,
+ file: FileTransferRequestObject,
+ abortController: AbortController,
+ direction: FileTransferDirection
+): FileTransferListeners {
+ const server = appContext.clustersService.getServer(file.serverUri);
+ const eventsEmitter = createFileTransferEventsEmitter();
+ const getFileTransferActionAsPromise = () =>
+ new Promise((resolve, reject) => {
+ const callbacks = appContext.fileTransferService.transferFile(
+ {
+ source: file.source,
+ destination: file.destination,
+ login: file.login,
+ clusterUri: routing.ensureClusterUri(file.serverUri),
+ hostname: server.hostname,
+ direction,
+ },
+ abortController
+ );
+
+ callbacks.onProgress((percentage: number) => {
+ eventsEmitter.emitProgress(percentage);
+ });
+ callbacks.onError((error: Error) => {
+ reject(error);
+ });
+ callbacks.onComplete(() => {
+ resolve(undefined);
+ });
+ });
+
+ retryWithRelogin(
+ appContext,
+ originatingDocumentUri,
+ file.serverUri,
+ getFileTransferActionAsPromise
+ )
+ .then(eventsEmitter.emitComplete)
+ .catch(eventsEmitter.emitError);
+
+ return eventsEmitter;
+}
+
+type FileTransferRequestObject = {
+ source: string;
+ destination: string;
+ login: string;
+ serverUri: string;
+};
diff --git a/packages/teleterm/src/ui/appContext.ts b/packages/teleterm/src/ui/appContext.ts
index 27f2673e1..139d5736d 100644
--- a/packages/teleterm/src/ui/appContext.ts
+++ b/packages/teleterm/src/ui/appContext.ts
@@ -22,9 +22,9 @@ 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';
import { NotificationsService } from 'teleterm/ui/services/notifications';
+import { FileTransferService } from 'teleterm/ui/services/fileTransferClient';
import { CommandLauncher } from './commandLauncher';
import { IAppContext } from './types';
@@ -41,10 +41,12 @@ export default class AppContext implements IAppContext {
mainProcessClient: MainProcessClient;
commandLauncher: CommandLauncher;
connectionTracker: ConnectionTrackerService;
+ fileTransferService: FileTransferService;
constructor(config: ElectronGlobals) {
const { tshClient, ptyServiceClient, mainProcessClient } = config;
this.mainProcessClient = mainProcessClient;
+ this.fileTransferService = new FileTransferService(tshClient);
this.statePersistenceService = new StatePersistenceService(
this.mainProcessClient.fileStorage
);
diff --git a/packages/teleterm/src/ui/services/fileTransferClient/fileTransferService.ts b/packages/teleterm/src/ui/services/fileTransferClient/fileTransferService.ts
new file mode 100644
index 000000000..23c92f371
--- /dev/null
+++ b/packages/teleterm/src/ui/services/fileTransferClient/fileTransferService.ts
@@ -0,0 +1,22 @@
+import { FileTransferListeners } from 'shared/components/FileTransfer';
+
+import { TshClient, FileTransferRequest } from 'teleterm/services/tshd/types';
+
+export class FileTransferService {
+ constructor(private tshClient: TshClient) {}
+
+ transferFile(
+ options: FileTransferRequest,
+ abortController: AbortController
+ ): FileTransferListeners {
+ const abortSignal = {
+ addEventListener: (cb: (...args: any[]) => void) => {
+ abortController.signal.addEventListener('abort', cb);
+ },
+ removeEventListener: (cb: (...args: any[]) => void) => {
+ abortController.signal.removeEventListener('abort', cb);
+ },
+ };
+ return this.tshClient.transferFile(options, abortSignal);
+ }
+}
diff --git a/packages/teleterm/src/ui/services/fileTransferClient/index.ts b/packages/teleterm/src/ui/services/fileTransferClient/index.ts
new file mode 100644
index 000000000..e16ad26f4
--- /dev/null
+++ b/packages/teleterm/src/ui/services/fileTransferClient/index.ts
@@ -0,0 +1 @@
+export * from './fileTransferService';
diff --git a/packages/teleterm/src/ui/types.ts b/packages/teleterm/src/ui/types.ts
index f23206e5c..30f25cea1 100644
--- a/packages/teleterm/src/ui/types.ts
+++ b/packages/teleterm/src/ui/types.ts
@@ -25,6 +25,7 @@ import { KeyboardShortcutsService } from 'teleterm/ui/services/keyboardShortcuts
import { WorkspacesService } from 'teleterm/ui/services/workspacesService';
import { NotificationsService } from 'teleterm/ui/services/notifications';
import { ConnectionTrackerService } from 'teleterm/ui/services/connectionTracker';
+import { FileTransferService } from 'teleterm/ui/services/fileTransferClient';
export interface IAppContext {
clustersService: ClustersService;
@@ -38,6 +39,7 @@ export interface IAppContext {
mainProcessClient: MainProcessClient;
commandLauncher: CommandLauncher;
connectionTracker: ConnectionTrackerService;
+ fileTransferService: FileTransferService;
init(): Promise;
}