From b08b14e5272ab03f1ed7b2589f6dca8820392715 Mon Sep 17 00:00:00 2001 From: Jasdeep Singh Date: Wed, 26 Aug 2020 23:59:27 +0530 Subject: [PATCH] Feature - CLI Tutor Mode (#1572) * [WIP] Feature - CLI Tutor Mode Signed-off-by: Jasdeep Singh * Update src/components/cli-tutor-mode/CliTutorMode.js Co-authored-by: Jessica Schilling * Update src/components/cli-tutor-mode/CliTutorMode.js Co-authored-by: Jessica Schilling * Align fix: cli tutor modal buttons Signed-off-by: Jasdeep Singh * Fix: added compatibility with ApiAddressForm component Signed-off-by: Jasdeep Singh * Fix: added bundle-reactx pattern and en translation keys Signed-off-by: Jasdeep Singh * i18n text change * Standardize i18n and visual style on Settings page * Text tweak; replace CliTutorMode.css with tachyons equivalents * Left-justify shell content in modals for consistency * Tidy visual presentation of modals * Size tweak for icon on Files page * Use StrokeCode instead of CopyIcon, adjust text accordingly * Lighten icon on settings page explainer text * Margin consistency * Update src/bundles/files/consts.js Co-authored-by: Marcin Rataj * Move lh-copy so doesn't fubar checkbox styling * Fix: Close modal on copy to clipboard Signed-off-by: Jasdeep Singh * Fix: rename file command Signed-off-by: Jasdeep Singh * Fix: delete file by filepath and added type information for cliCommandList Signed-off-by: Jasdeep Singh * fix: whitespace in ipfs files rm Without this below will fail: ipfs files rm -r /test/white space/flowers.jpeg * fix: remove UPDATE_API_SERVER_ADDRESS This removed CLI tutor from "API Adddress" selector because it does not make sense: "API ADDRESS" section does not change the config of IPFS node, but defines the API which WebUI will use when connecting to the node. * fix: eslint * fix: remove angle brackets from i18n strings text between < and > won't be translated because Transifex blackboxes HTML tags, it is better to remove it * Move cliModal i18n keys into app.json See https://react.i18next.com/guides/multiple-translation-files * Move copyCommand & relevant close i18n keys into app.json * fix: include files cp step The way import via Webui works is: 1. `ipfs add` produces CID 2. we create a reference to that CID in MFS We do this to produce the same CID as regular ipfs add from CLI would do, mainly to avoid issue described in: https://github.com/ipfs-shipyard/ipfs-webui/issues/676 * refactor: make it easier to support path Right now CLI tutor defaults to MFS root at /, this makes it easier to include current path in the future. Co-authored-by: Jessica Schilling Co-authored-by: Marcin Rataj --- .gitignore | 1 + public/locales/en/app.json | 8 ++ public/locales/en/peers.json | 6 +- public/locales/en/settings.json | 5 + src/bundles/cli-tutor-mode.js | 74 ++++++++++++ src/bundles/files/actions.js | 2 +- src/bundles/files/consts.js | 55 +++++++++ src/bundles/index.js | 2 + .../api-address-form/ApiAddressForm.js | 1 + src/components/button/Button.js | 2 +- src/components/cli-tutor-mode/CliTutorMode.js | 107 ++++++++++++++++++ src/files/FilesPage.js | 22 +++- src/files/context-menu/ContextMenu.js | 22 ++-- src/files/dropdown/Dropdown.js | 18 ++- src/files/file-input/FileInput.js | 24 +++- src/files/header/Header.js | 4 +- src/files/modals/Modals.js | 58 +++++++++- src/peers/PeersPage.js | 8 +- src/settings/SettingsPage.js | 33 +++++- 19 files changed, 417 insertions(+), 35 deletions(-) create mode 100644 src/bundles/cli-tutor-mode.js create mode 100644 src/components/cli-tutor-mode/CliTutorMode.js diff --git a/.gitignore b/.gitignore index 248ff0906..72099f4f5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ yarn-debug.log* yarn-error.log* .vscode +.idea diff --git a/public/locales/en/app.json b/public/locales/en/app.json index 171374c2b..68ae77d73 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -1,4 +1,12 @@ { + "actions": { + "close": "Close", + "copyCommand": "Copy" + }, + "cliModal": { + "description": "Paste the following into your terminal to do this task in IPFS via the command line. Remember that you'll need to replace placeholders with your specific parameters.", + "extraNotes": "If you've made changes to the config in this page's code editor that you'd like to save, click the download icon next to the copy button to download it as a JSON file." + }, "tour": { "back": "Back", "close": "Close", diff --git a/public/locales/en/peers.json b/public/locales/en/peers.json index c3b5db8e5..3ceed91d8 100644 --- a/public/locales/en/peers.json +++ b/public/locales/en/peers.json @@ -14,7 +14,7 @@ "insertPeerAddress": "Insert the peer address you want to connect to.", "add": "Add", "example": "Example:", - "plusPeers": "+ {number} more peers", + "plusPeers": "+ {number} more peers", "tour": { "back": "Back", "close": "Close", @@ -34,5 +34,9 @@ "title": "Peers table", "paragraph1": "Check the IDs of the connected peers, their address and approximate location." } + }, + "actions": { + "edit": "Change", + "close": "Close" } } diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index ddb783094..c011bd11e 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -6,6 +6,7 @@ "language": "Language", "analytics": "Analytics", "api": "API Address", + "cliTutorMode": "CLI Tutor Mode", "config": "IPFS Config", "languageModal": { "title": "Change language", @@ -18,6 +19,7 @@ "close": "Close" }, "apiDescription": "<0>If your node is configured with a <1>custom API address, including a port other than the default 5001, enter it here to update your config file.", + "cliDescription": "<0>Enable this option to display a \"view code\" <1> icon next to common IPFS commands. Clicking it opens a modal with that command's CLI code, so you can paste it into the IPFS command-line interface in your terminal.", "fetchingSettings": "Fetching settings...", "configApiNotAvailable": "The IPFS config API is not available. Please disable the \"IPFS Companion\" Web Extension and try again.", "ipfsDaemonOffline": "The IPFS daemon is offline. Please turn it on and try again.", @@ -62,6 +64,9 @@ "details": "Records JavaScript error messages and stack traces that occur while using the app, where possible. It is very helpful to know when the app is not working for you, but <1>error messages may include identifiable information like CIDs or file paths, so only enable this if you are comfortable sharing that information with us." } }, + "cliToggle": { + "label": "Enable command-line interface (CLI) tutor mode" + }, "tour": { "back": "Back", "close": "Close", diff --git a/src/bundles/cli-tutor-mode.js b/src/bundles/cli-tutor-mode.js new file mode 100644 index 000000000..d5b7e4e5c --- /dev/null +++ b/src/bundles/cli-tutor-mode.js @@ -0,0 +1,74 @@ +import { createAsyncResourceBundle, createSelector } from 'redux-bundler' + +const bundle = createAsyncResourceBundle({ + name: 'cliTutorMode', + actionBaseType: 'CLI_TUTOR_MODE_TOGGLE', + persist: true, + checkIfOnline: false, + getPromise: () => {} +}) + +bundle.reactIsCliTutorModeEnabled = createSelector( + 'selectIsCliTutorModeEnabled', + (isCliTutorModeEnabled) => { + const isEnabled = Boolean(JSON.parse(localStorage.getItem('isCliTutorModeEnabled'))) + + if (isCliTutorModeEnabled !== undefined && isCliTutorModeEnabled !== isEnabled) { + localStorage.setItem('isCliTutorModeEnabled', isCliTutorModeEnabled) + } + } +) + +bundle.selectIsCliTutorModeEnabled = state => state.cliTutorMode.isCliTutorModeEnabled + +bundle.selectIsCliTutorModalOpen = state => !!state.cliTutorMode.showCliTutorModal + +bundle.selectCliOptions = state => state.cliTutorMode.cliOptions + +bundle.reducer = (state = {}, action) => { + if (action.type === 'CLI_TUTOR_MODE_TOGGLE') { + return { ...state, isCliTutorModeEnabled: action.payload } + } + if (action.type === 'CLI_TUTOR_MODAL_ENABLE') { + return { ...state, showCliTutorModal: action.payload } + } + if (action.type === 'CLI_OPTIONS') { + return { ...state, cliOptions: action.payload } + } + + return state +} + +bundle.doToggleCliTutorMode = key => ({ dispatch }) => { + dispatch({ + type: 'CLI_TUTOR_MODE_TOGGLE', + payload: key + }) +} + +bundle.doSetCliOptions = cliOptions => ({ dispatch }) => { + dispatch({ + type: 'CLI_OPTIONS', + payload: cliOptions + }) +} + +bundle.doOpenCliTutorModal = openModal => ({ dispatch }) => { + dispatch({ + type: 'CLI_TUTOR_MODAL_ENABLE', + payload: openModal + }) +} + +bundle.doOpenCliTutorModal = openModal => ({ dispatch }) => { + dispatch({ + type: 'CLI_TUTOR_MODAL_ENABLE', + payload: openModal + }) +} + +bundle.init = store => { + const isEnabled = Boolean(JSON.parse(localStorage.getItem('isCliTutorModeEnabled'))) + return store.doToggleCliTutorMode(isEnabled) +} +export default bundle diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index df9858602..4fec02382 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -54,7 +54,7 @@ const fileFromStats = ({ cumulativeSize, type, size, cid, name, path, pinned, is * @returns {string} */ // TODO: use sth else -const realMfsPath = (path) => { +export const realMfsPath = (path) => { if (path.startsWith('/files')) { return path.substr('/files'.length) || '/' } diff --git a/src/bundles/files/consts.js b/src/bundles/files/consts.js index 62f64221b..3e97d2db1 100644 --- a/src/bundles/files/consts.js +++ b/src/bundles/files/consts.js @@ -40,3 +40,58 @@ export const DEFAULT_STATE = { finished: [], failed: [] } + +export const cliCmdKeys = { + DOWNLOAD_OBJECT_COMMAND: 'downloadObjectCommand', + DELETE_FILE_FROM_IPFS: 'deleteFileFromIpfs', + UPDATE_IPFS_CONFIG: 'updateIpfsConfig', + PIN_OBJECT: 'pinObject', + RENAME_IPFS_OBJECT: 'renameObject', + ADD_FILE: 'addNewFile', + ADD_DIRECTORY: 'addNewDirectory', + CREATE_NEW_DIRECTORY: 'createNewDirectory', + FROM_IPFS: 'fromIpfs', + ADD_NEW_PEER: 'addNewPeer' +} + +export const cliCommandList = { + [cliCmdKeys.UPDATE_IPFS_CONFIG]: () => 'ipfs config replace ', + /** + * @param {string} filePath + */ + [cliCmdKeys.DELETE_FILE_FROM_IPFS]: (filePath) => `ipfs files rm -r "${filePath}"`, + /** + * @param {string} cid + */ + [cliCmdKeys.DOWNLOAD_OBJECT_COMMAND]: (cid) => `ipfs get ${cid}`, + /** + * @param {string} cid + * @param {string} op + */ + [cliCmdKeys.PIN_OBJECT]: (cid, op) => `ipfs pin ${op} ${cid}`, + /** + * @param {string} filePath + * @param {string} fileName + */ + [cliCmdKeys.RENAME_IPFS_OBJECT]: (filePath, fileName) => { + const prefix = filePath.replace(fileName, '').trim() + return `ipfs files mv "${filePath}" "${prefix}"` + }, + /** + * @param {string} path + */ + [cliCmdKeys.ADD_FILE]: (path) => `ipfs files cp /ipfs/$(ipfs add -Q ) "${path}/"`, + /** + * @param {string} path + */ + [cliCmdKeys.ADD_DIRECTORY]: (path) => `ipfs files cp /ipfs/$(ipfs add -r -Q ) "${path}/"`, + /** + * @param {string} path + */ + [cliCmdKeys.CREATE_NEW_DIRECTORY]: (path) => `ipfs files mkdir "${path}/"`, + /** + * @param {string} path + */ + [cliCmdKeys.FROM_IPFS]: (path) => `ipfs cp /ipfs/ "${path}/"`, + [cliCmdKeys.ADD_NEW_PEER]: () => 'ipfs swarm connect ' +} diff --git a/src/bundles/index.js b/src/bundles/index.js index 4c044cf6d..5ebd945b4 100644 --- a/src/bundles/index.js +++ b/src/bundles/index.js @@ -21,6 +21,7 @@ import ipfsDesktop from './ipfs-desktop' import repoStats from './repo-stats' import createAnalyticsBundle from './analytics' import experimentsBundle from './experiments' +import cliTutorModeBundle from './cli-tutor-mode' export default composeBundles( createCacheBundle({ @@ -46,5 +47,6 @@ export default composeBundles( experimentsBundle, ipfsDesktop, repoStats, + cliTutorModeBundle, createAnalyticsBundle({}) ) diff --git a/src/components/api-address-form/ApiAddressForm.js b/src/components/api-address-form/ApiAddressForm.js index 53db0c31d..daed70f42 100644 --- a/src/components/api-address-form/ApiAddressForm.js +++ b/src/components/api-address-form/ApiAddressForm.js @@ -18,6 +18,7 @@ const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress = '' }) => { onSubmit(event) } } + return (
diff --git a/src/components/button/Button.js b/src/components/button/Button.js index 15464118e..34541bce6 100644 --- a/src/components/button/Button.js +++ b/src/components/button/Button.js @@ -1,7 +1,7 @@ import React from 'react' import './Button.css' -const Button = ({ bg = 'bg-teal', color = 'white', fill = 'white', className = '', disabled, danger, minWidth = 140, children, style, ...props }) => { +const Button = ({ bg = 'bg-teal', color = 'white', fill = 'white', className = '', disabled, danger, minWidth = 86, children, style, ...props }) => { const bgClass = danger ? 'bg-red' : disabled ? 'bg-gray-muted' : bg const fillClass = danger ? 'fill-white' : disabled ? 'fill-snow' : fill const colorClass = danger ? 'white' : disabled ? 'light-gray' : color diff --git a/src/components/cli-tutor-mode/CliTutorMode.js b/src/components/cli-tutor-mode/CliTutorMode.js new file mode 100644 index 000000000..f67ade4aa --- /dev/null +++ b/src/components/cli-tutor-mode/CliTutorMode.js @@ -0,0 +1,107 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'redux-bundler-react' + +// Components +import { Modal, ModalBody, ModalActions } from '../modal/Modal' +import StrokeCode from '../../icons/StrokeCode' +import Button from '../button/Button' +import Overlay from '../overlay/Overlay' +import Shell from '../shell/Shell' +import StrokeDownload from '../../icons/StrokeDownload' +import { cliCmdKeys, cliCommandList } from '../../bundles/files/consts' + +export const CliTutorialModal = ({ command, t, onLeave, className, downloadConfig, ...props }) => { + const onClickCopyToClipboard = (cmd) => { + navigator.clipboard.writeText(cmd).then(() => { + onLeave() + }) + } + + return ( + + +

+ {t('app:cliModal.description')} +

+

+ { command && command === cliCommandList[cliCmdKeys.UPDATE_IPFS_CONFIG]() ? t('app:cliModal.extraNotes') : ''} +

+
+ + $ {command} + +
+
+ + +
+ +
+
+ { + command && command === cliCommandList[cliCmdKeys.UPDATE_IPFS_CONFIG]() + ? :
+ } + +
+ + + ) +} + +const CliTutorMode = ({ + t, filesPage, isCliTutorModeEnabled, onLeave, isCliTutorModalOpen, command, config, showIcon, doOpenCliTutorModal +}) => { + const downloadConfig = (config) => { + const url = window.URL.createObjectURL(new Blob([config])) + const link = document.createElement('a') + link.style.display = 'none' + link.href = url + link.download = 'settings.json' + document.body.appendChild(link) + link.click() + window.URL.revokeObjectURL(url) + } + + if (isCliTutorModeEnabled) { + if (filesPage) { + return + } + return ( + + { + showIcon + ? doOpenCliTutorModal(true)} className='dib fill-link pointer mh2' style={{ height: 44 }}/> + :
+ } + doOpenCliTutorModal(false)}> + doOpenCliTutorModal(false)} t={t} command={command} + downloadConfig={() => downloadConfig(config)}/> + + + ) + } + + return null +} + +CliTutorialModal.propTypes = { + onLeave: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + command: PropTypes.string.isRequired +} + +CliTutorialModal.defaultProps = { + className: '' +} + +export default connect( + 'doOpenCliTutorModal', + 'selectIsCliTutorModalOpen', + 'selectIsCliTutorModeEnabled', + CliTutorMode +) diff --git a/src/files/FilesPage.js b/src/files/FilesPage.js index 8728c2a13..af95d170b 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.js @@ -17,7 +17,7 @@ import FilesList from './files-list/FilesList' import { getJoyrideLocales } from '../helpers/i8n' // Icons -import Modals, { DELETE, NEW_FOLDER, SHARE, RENAME, ADD_BY_PATH } from './modals/Modals' +import Modals, { DELETE, NEW_FOLDER, SHARE, RENAME, ADD_BY_PATH, CLI_TUTOR_MODE } from './modals/Modals' import Header from './header/Header' import FileImportStatus from './file-import-status/FileImportStatus' @@ -144,7 +144,7 @@ class FilesPage extends React.Component { const { t, files, doExploreUserProvidedPath } = this.props if (!files) { - return (
) + return (
) } if (files.type === 'unknown') { @@ -206,7 +206,10 @@ class FilesPage extends React.Component { } render () { - const { t, files, filesPathInfo, toursEnabled, handleJoyrideCallback } = this.props + const { + t, files, filesPathInfo, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, + doSetCliOptions, cliOptions + } = this.props const { contextMenu } = this.state return ( @@ -233,7 +236,11 @@ class FilesPage extends React.Component { onInspect={() => this.onInspect(contextMenu.file.cid)} onDownload={() => this.onDownload([contextMenu.file])} onPin={() => this.props.doFilesPin(contextMenu.file.cid)} - onUnpin={() => this.props.doFilesUnpin(contextMenu.file.cid)} /> + onUnpin={() => this.props.doFilesUnpin(contextMenu.file.cid)} + isCliTutorModeEnabled={isCliTutorModeEnabled} + onCliTutorMode={() => this.showModal(CLI_TUTOR_MODE, [contextMenu.file])} + doSetCliOptions={doSetCliOptions} + />
this.showModal(ADD_BY_PATH, files)} onNewFolder={(files) => this.showModal(NEW_FOLDER, files)} + onCliTutorMode={() => this.showModal(CLI_TUTOR_MODE)} handleContextMenu={(...args) => this.handleContextMenu(...args, true)} /> { this.mainView } @@ -257,6 +265,7 @@ class FilesPage extends React.Component { onShareLink={this.props.doFilesShareLink} onDelete={this.props.doFilesDelete} onAddByPath={this.onAddByPath} + cliOptions={cliOptions} { ...this.state.modals } /> @@ -322,5 +331,10 @@ export default connect( 'doFilesDownloadLink', 'doExploreUserProvidedPath', 'doFilesSizeGet', + 'selectIsCliTutorModeEnabled', + 'selectIsCliTutorModalOpen', + 'doOpenCliTutorModal', + 'doSetCliOptions', + 'selectCliOptions', withTour(withTranslation('files')(FilesPage)) ) diff --git a/src/files/context-menu/ContextMenu.js b/src/files/context-menu/ContextMenu.js index f59646be7..e3bf67295 100644 --- a/src/files/context-menu/ContextMenu.js +++ b/src/files/context-menu/ContextMenu.js @@ -10,6 +10,7 @@ import StrokeIpld from '../../icons/StrokeIpld' import StrokeTrash from '../../icons/StrokeTrash' import StrokeDownload from '../../icons/StrokeDownload' import StrokePin from '../../icons/StrokePin' +import { cliCmdKeys } from '../../bundles/files/consts' class ContextMenu extends React.Component { constructor (props) { @@ -21,7 +22,10 @@ class ContextMenu extends React.Component { dropdown: false } - wrap = (name) => () => { + wrap = (name, cliOptions) => () => { + if (name === 'onCliTutorMode' && cliOptions) { + this.props.doSetCliOptions(cliOptions) + } this.props.handleClick() this.props[name]() } @@ -41,9 +45,8 @@ class ContextMenu extends React.Component { const { t, onRename, onDelete, onDownload, onInspect, onShare, translateX, translateY, className, - isUpperDir, isMfs, isUnknown, pinned + isUpperDir, isMfs, isUnknown, pinned, isCliTutorModeEnabled } = this.props - return ( } - { !isUpperDir && !isUnknown && onDownload && - } { !isUpperDir && !isUnknown && isMfs && onRename && - } { !isUpperDir && !isUnknown && isMfs && onDelete && - diff --git a/src/files/dropdown/Dropdown.js b/src/files/dropdown/Dropdown.js index 42bc8e072..9bfe78b7c 100644 --- a/src/files/dropdown/Dropdown.js +++ b/src/files/dropdown/Dropdown.js @@ -1,10 +1,20 @@ import React, { forwardRef } from 'react' import { Dropdown as Drop, DropdownMenu as Menu } from '@tableflip/react-dropdown' +import StrokeCode from '../../icons/StrokeCode' -export const Option = ({ children, onClick, className = '', ...props }) => ( - +export const Option = ({ children, onClick, className = '', isCliTutorModeEnabled, onCliTutorMode, ...props }) => ( + isCliTutorModeEnabled + ?
+ + +
+ : ) export const DropdownMenu = forwardRef((props, ref) => { diff --git a/src/files/file-input/FileInput.js b/src/files/file-input/FileInput.js index df5f6d6bc..97c2fb138 100644 --- a/src/files/file-input/FileInput.js +++ b/src/files/file-input/FileInput.js @@ -11,6 +11,7 @@ import DecentralizationIcon from '../../icons/StrokeDecentralization' // Components import { Dropdown, DropdownMenu, Option } from '../dropdown/Dropdown' import Button from '../../components/button/Button' +import { cliCmdKeys } from '../../bundles/files/consts' const AddButton = withTranslation('files')( ({ t, onClick }) => ( @@ -64,8 +65,14 @@ class FileInput extends React.Component { this.toggleDropdown() } + onCliTutorMode = async (cliOptions) => { + await this.props.doSetCliOptions(cliOptions) + this.props.onCliTutorMode() + this.toggleDropdown() + } + render () { - const { t } = this.props + const { t, isCliTutorModeEnabled } = this.props return (
@@ -75,19 +82,23 @@ class FileInput extends React.Component { top={3} open={this.state.dropdown} onDismiss={this.toggleDropdown} > - - - - @@ -127,5 +138,8 @@ FileInput.propTypes = { export default connect( 'selectIsIpfsDesktop', 'doDesktopSelectDirectory', + 'selectIsCliTutorModeEnabled', + 'doOpenCliTutorModal', + 'doSetCliOptions', withTranslation('files')(FileInput) ) diff --git a/src/files/header/Header.js b/src/files/header/Header.js index edfcad183..87ae220d0 100644 --- a/src/files/header/Header.js +++ b/src/files/header/Header.js @@ -81,7 +81,9 @@ class Header extends React.Component { ? + onAddByPath={this.props.onAddByPath} + onCliTutorMode={this.props.onCliTutorMode} + /> :
{ this.dotsWrapper = el }}>