mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 17:45:22 +00:00
@@ -8,6 +8,7 @@
|
||||
- Added better error message when no files to backup ([support#681]).
|
||||
- Added multi-step firmware flashing dialog.
|
||||
- Added support for flashing firmware via USB DFU.
|
||||
- Added an interactive introductory tour of the app.
|
||||
|
||||
### Fixed
|
||||
- Fixed deleting files that are not open in the editor.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
|
||||
<g id="chevron_right">
|
||||
<g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.71,7.29l-4-4C6.53,3.11,6.28,3,6,3C5.45,3,5,3.45,5,4 c0,0.28,0.11,0.53,0.29,0.71L8.59,8l-3.29,3.29C5.11,11.47,5,11.72,5,12c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l4-4 C10.89,8.53,11,8.28,11,8C11,7.72,10.89,7.47,10.71,7.29z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 672 B |
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
|
||||
<g id="more_3_">
|
||||
<circle cx="2" cy="8.03" r="2"/>
|
||||
<circle cx="14" cy="8.03" r="2"/>
|
||||
<circle cx="8" cy="8.03" r="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 566 B |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
|
||||
<g id="small_minus_2_">
|
||||
<g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11,7H5C4.45,7,4,7.45,4,8c0,0.55,0.45,1,1,1h6c0.55,0,1-0.45,1-1 C12,7.45,11.55,7,11,7z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 526 B |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
|
||||
<g id="small_tick_1_">
|
||||
<g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12,5c-0.28,0-0.53,0.11-0.71,0.29L7,9.59L4.71,7.29C4.53,7.11,4.28,7,4,7 C3.45,7,3,7.45,3,8c0,0.28,0.11,0.53,0.29,0.71l3,3C6.47,11.89,6.72,12,7,12s0.53-0.11,0.71-0.29l5-5C12.89,6.53,13,6.28,13,6 C13,5.45,12.55,5,12,5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 658 B |
@@ -1,3 +0,0 @@
|
||||
Icons copied from: https://github.com/palantir/blueprint/tree/develop/resources/icons/16px
|
||||
|
||||
Needed to work around: https://github.com/palantir/blueprint/issues/4759
|
||||
@@ -91,6 +91,7 @@
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-dropzone": "^14.2.2",
|
||||
"react-joyride": "^2.5.0",
|
||||
"react-monaco-editor": "^0.49.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-redux": "^8.0.2",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { testRender } from '../../test';
|
||||
import Activities, { Activity } from './Activities';
|
||||
import Activities from './Activities';
|
||||
import { Activity } from './hooks';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
||||
@@ -4,32 +4,18 @@
|
||||
import './activities.scss';
|
||||
import { Icon, Tab, Tabs } from '@blueprintjs/core';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import Explorer from '../explorer/Explorer';
|
||||
import Settings from '../settings/Settings';
|
||||
import { Activity, useActivitiesSelectedActivity } from './hooks';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
/** Indicates the selected activity. */
|
||||
export enum Activity {
|
||||
/** No activity is selected. */
|
||||
None = 'activity.none',
|
||||
/** The explorer activity is selected. */
|
||||
Explorer = 'activity.explorer',
|
||||
/** The settings activity is selected. */
|
||||
Settings = 'activity.settings',
|
||||
}
|
||||
|
||||
/**
|
||||
* React component that acts as a tab control to select activities.
|
||||
*/
|
||||
const Activities: React.VoidFunctionComponent = () => {
|
||||
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
|
||||
const i18n = useI18n();
|
||||
|
||||
const [selectedActivity, setSelectedActivity] = useLocalStorage(
|
||||
'activities.selectedActivity',
|
||||
Activity.Explorer,
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(newActivity: Activity) => {
|
||||
// if activity is already selected, select none
|
||||
@@ -110,6 +96,7 @@ const Activities: React.VoidFunctionComponent = () => {
|
||||
ref={tabsRef}
|
||||
>
|
||||
<Tab
|
||||
itemID="pb-activities-explorer-tab"
|
||||
aria-label={i18n.translate('explorer')}
|
||||
className="pb-activities-tablist-tab"
|
||||
id={Activity.Explorer}
|
||||
@@ -125,6 +112,7 @@ const Activities: React.VoidFunctionComponent = () => {
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tab
|
||||
itemID="pb-activities-settings-tab"
|
||||
aria-label={i18n.translate('settings')}
|
||||
className="pb-activities-tablist-tab"
|
||||
id={Activity.Settings}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
|
||||
/** Indicates the selected activity. */
|
||||
export enum Activity {
|
||||
/** No activity is selected. */
|
||||
None = 'activity.none',
|
||||
/** The explorer activity is selected. */
|
||||
Explorer = 'activity.explorer',
|
||||
/** The settings activity is selected. */
|
||||
Settings = 'activity.settings',
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for getting and setting the selected activity in the activity panel.
|
||||
* @returns a tuple of the current state and the setter function (like useState()).
|
||||
*/
|
||||
export function useActivitiesSelectedActivity() {
|
||||
return useLocalStorage('activities.selectedActivity', Activity.Explorer);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useSettingIsShowDocsEnabled } from '../settings/hooks';
|
||||
import StatusBar from '../status-bar/StatusBar';
|
||||
import Terminal from '../terminal/Terminal';
|
||||
import Toolbar from '../toolbar/Toolbar';
|
||||
import Tour from '../tour/Tour';
|
||||
import { isMacOS } from '../utils/os';
|
||||
|
||||
const Docs: React.VFC = () => {
|
||||
@@ -196,6 +197,7 @@ const App: React.VFC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<Tour />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,12 @@ export const pybricksBluetoothTroubleshootingUrl =
|
||||
export const pybricksUsbDfuTroubleshootingUrl =
|
||||
'https://github.com/pybricks/support/discussions/688';
|
||||
|
||||
export const pybricksUsbDfuWindowsDriverInstallUrl =
|
||||
'https://github.com/pybricks/support/discussions/688#discussioncomment-3201466';
|
||||
|
||||
export const pybricksUsbLinuxUdevRulesUrl =
|
||||
'https://github.com/pybricks/support/discussions/688#discussioncomment-3239099';
|
||||
|
||||
/** Pybricks copyright statement. */
|
||||
export const pybricksCopyright = 'Copyright (c) 2020-2022 The Pybricks Authors';
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import React, { useRef } from 'react';
|
||||
import { FocusRing, useButton } from 'react-aria';
|
||||
|
||||
type ButtonProps = {
|
||||
/** Optional DOM ID for the button. */
|
||||
id?: string;
|
||||
/** The label for the button. */
|
||||
label: string;
|
||||
/** If true, the label will not be visible (will use aria-label instead). */
|
||||
@@ -28,6 +30,7 @@ type ButtonProps = {
|
||||
|
||||
/** Similar to Blueprint.js button with better accessibility. */
|
||||
export const Button: React.VoidFunctionComponent<ButtonProps> = ({
|
||||
id,
|
||||
label,
|
||||
hideLabel,
|
||||
description,
|
||||
@@ -59,6 +62,7 @@ export const Button: React.VoidFunctionComponent<ButtonProps> = ({
|
||||
{/* useButton() breaks the native browser :focus-visible :-/ */}
|
||||
<FocusRing focusRingClass="pb-focus-ring">
|
||||
<button
|
||||
id={id}
|
||||
className={classNames(Classes.BUTTON, 'pb-focus-managed', {
|
||||
[Classes.ACTIVE]: isPressed,
|
||||
[Classes.LOADING]: loading,
|
||||
|
||||
@@ -3,21 +3,16 @@
|
||||
|
||||
import { Radio, RadioGroup } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { useHubPickerSelectedHub } from './hooks';
|
||||
import { Hub } from '.';
|
||||
|
||||
type HubPickerProps = {
|
||||
hubType: Hub;
|
||||
onChange: (hubType: Hub) => void;
|
||||
};
|
||||
export const HubPicker: React.VoidFunctionComponent = () => {
|
||||
const [selectedHub, setSelectedHub] = useHubPickerSelectedHub();
|
||||
|
||||
export const HubPicker: React.VoidFunctionComponent<HubPickerProps> = ({
|
||||
hubType,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<RadioGroup
|
||||
selectedValue={hubType}
|
||||
onChange={(e) => onChange(e.currentTarget.value as Hub)}
|
||||
selectedValue={selectedHub}
|
||||
onChange={(e) => setSelectedHub(e.currentTarget.value as Hub)}
|
||||
>
|
||||
<Radio value={Hub.Move}>BOOST Move Hub</Radio>
|
||||
<Radio value={Hub.City}>City Hub</Radio>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { Hub } from '.';
|
||||
|
||||
/**
|
||||
* Hook for {@link HubPicker} state backed by local storage.
|
||||
* @returns Tuple of the current state and setter (like useState()).
|
||||
*/
|
||||
export function useHubPickerSelectedHub() {
|
||||
return useLocalStorage('hubPicker.selectedHub', Hub.Move);
|
||||
}
|
||||
@@ -156,10 +156,12 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
|
||||
);
|
||||
};
|
||||
|
||||
// matches ID in tour component
|
||||
const archiveButtonId = 'pb-explorer-archive-button';
|
||||
const newButtonId = 'pb-explorer-add-button';
|
||||
|
||||
const Header: React.VoidFunctionComponent = () => {
|
||||
const archiveButtonId = useId();
|
||||
const exportButtonId = useId();
|
||||
const newButtonId = useId();
|
||||
const dispatch = useDispatch();
|
||||
const i18n = useI18n();
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('accept', () => {
|
||||
|
||||
await user.click(button);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
newFileWizardDidAccept('test', '.py', Hub.Technic),
|
||||
newFileWizardDidAccept('test', '.py', Hub.Move),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('accept', () => {
|
||||
);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
newFileWizardDidAccept('test', '.py', Hub.Technic),
|
||||
newFileWizardDidAccept('test', '.py', Hub.Move),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Button, Classes, Dialog, FormGroup } from '@blueprintjs/core';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useId } from 'react-aria';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Hub } from '../../components/hubPicker';
|
||||
import { HubPicker } from '../../components/hubPicker/HubPicker';
|
||||
import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
|
||||
import { useFileStorageMetadata } from '../../fileStorage/hooks';
|
||||
import {
|
||||
FileNameValidationResult,
|
||||
@@ -18,9 +18,6 @@ import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup';
|
||||
import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
// This should be set to the most commonly used hub.
|
||||
const defaultHub = Hub.Technic;
|
||||
|
||||
const NewFileWizard: React.VoidFunctionComponent = () => {
|
||||
const i18n = useI18n();
|
||||
const dispatch = useDispatch();
|
||||
@@ -33,8 +30,8 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
|
||||
pythonFileExtension,
|
||||
files.map((f) => f.path),
|
||||
);
|
||||
const [hubType, setHubType] = useState(defaultHub);
|
||||
|
||||
const [hubType] = useHubPickerSelectedHub();
|
||||
const fileNameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = useCallback<React.FormEventHandler>(
|
||||
@@ -70,7 +67,7 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
|
||||
onChange={setFileName}
|
||||
/>
|
||||
<FormGroup label={i18n.translate('smartHub.label')}>
|
||||
<HubPicker hubType={hubType} onChange={setHubType} />
|
||||
<HubPicker />
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Intent, ProgressBar } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { CreateToast } from '../../i18nToaster';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
type FlashProgressProps = {
|
||||
action: 'erase' | 'flash';
|
||||
progress: number | undefined;
|
||||
};
|
||||
|
||||
const FlashProgress: React.VoidFunctionComponent<FlashProgressProps> = ({
|
||||
action,
|
||||
progress,
|
||||
}) => {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
{action === 'erase' && (
|
||||
<p>
|
||||
{i18n.translate('flashProgress.erasing', {
|
||||
percent: progress ? i18n.formatPercentage(progress) : '',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{action === 'flash' && (
|
||||
<p>
|
||||
{i18n.translate('flashProgress.flashing', {
|
||||
percent: progress ? i18n.formatPercentage(progress) : '',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p>
|
||||
<ProgressBar value={progress} />
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const flashProgress: CreateToast<FlashProgressProps> = (onAction, props) => {
|
||||
return {
|
||||
message: <FlashProgress {...props} />,
|
||||
icon: 'download',
|
||||
intent: Intent.PRIMARY,
|
||||
// close one second after progress is complete
|
||||
timeout: (props.progress ?? 0) < 1 ? 0 : 1000,
|
||||
onDismiss: () => onAction('dismiss'),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { CreateToast } from '../../i18nToaster';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
const ReleaseButton: React.VoidFunctionComponent = () => {
|
||||
const i18n = useI18n();
|
||||
return <p>{i18n.translate('releaseButton.message')}</p>;
|
||||
};
|
||||
|
||||
export const releaseButton: CreateToast = (onAction) => {
|
||||
return {
|
||||
message: <ReleaseButton />,
|
||||
icon: 'info-sign',
|
||||
intent: Intent.PRIMARY,
|
||||
onDismiss: () => onAction('dismiss'),
|
||||
};
|
||||
};
|
||||
@@ -5,10 +5,12 @@ import { firmwareMismatch } from './FirmwareMismatch';
|
||||
import { noDfuHub } from './NoDfuHub';
|
||||
import { noDfuInterface } from './NoDfuInterface';
|
||||
import { noWebUsb } from './NoWebUsb';
|
||||
import { releaseButton } from './ReleaseButton';
|
||||
|
||||
export default {
|
||||
firmwareMismatch,
|
||||
noDfuHub,
|
||||
noDfuInterface,
|
||||
noWebUsb,
|
||||
releaseButton,
|
||||
};
|
||||
|
||||
@@ -17,5 +17,12 @@
|
||||
},
|
||||
"firmwareMismatch": {
|
||||
"message": "Cannot flash firmware. The firmware file is for a different kind of hub."
|
||||
},
|
||||
"flashProgress": {
|
||||
"erasing": "Erasing internal flash memory: {percent}",
|
||||
"flashing": "Writing new firmware: {percent}"
|
||||
},
|
||||
"releaseButton": {
|
||||
"message": "You may release the power button on the hub now."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import './installPybricksDialog.scss';
|
||||
import {
|
||||
Button,
|
||||
Callout,
|
||||
Checkbox,
|
||||
Classes,
|
||||
ControlGroup,
|
||||
@@ -24,7 +25,11 @@ import { Select2 } from '@blueprintjs/select';
|
||||
import classNames from 'classnames';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { appName } from '../../app/constants';
|
||||
import {
|
||||
appName,
|
||||
pybricksUsbDfuWindowsDriverInstallUrl,
|
||||
pybricksUsbLinuxUdevRulesUrl,
|
||||
} from '../../app/constants';
|
||||
import HelpButton from '../../components/HelpButton';
|
||||
import {
|
||||
Hub,
|
||||
@@ -34,9 +39,12 @@ import {
|
||||
hubHasUSB,
|
||||
} from '../../components/hubPicker';
|
||||
import { HubPicker } from '../../components/hubPicker/HubPicker';
|
||||
import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
|
||||
import { FileMetadata } from '../../fileStorage';
|
||||
import { useFileStorageMetadata } from '../../fileStorage/hooks';
|
||||
import { useSelector } from '../../reducers';
|
||||
import ExternalLinkIcon from '../../utils/ExternalLinkIcon';
|
||||
import { isLinux, isWindows } from '../../utils/os';
|
||||
import {
|
||||
firmwareInstallPybricksDialogAccept,
|
||||
firmwareInstallPybricksDialogCancel,
|
||||
@@ -50,21 +58,13 @@ const dialogBody = classNames(
|
||||
'pb-firmware-installPybricksDialog-body',
|
||||
);
|
||||
|
||||
type SelectHubPanelProps = {
|
||||
hubType: Hub;
|
||||
onChange: (hubType: Hub) => void;
|
||||
};
|
||||
|
||||
const SelectHubPanel: React.VoidFunctionComponent<SelectHubPanelProps> = ({
|
||||
hubType,
|
||||
onChange,
|
||||
}) => {
|
||||
const SelectHubPanel: React.VoidFunctionComponent = () => {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
<p>{i18n.translate('selectHubPanel.message')}</p>
|
||||
<HubPicker hubType={hubType} onChange={onChange} />
|
||||
<HubPicker />
|
||||
<Popover2
|
||||
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
|
||||
placement="right-end"
|
||||
@@ -348,6 +348,37 @@ const BootloaderModePanel: React.VoidFunctionComponent<BootloaderModePanelProps>
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
{hubHasUSB(hubType) && isLinux() && (
|
||||
<p>
|
||||
<Callout intent={Intent.WARNING} icon="warning-sign">
|
||||
{i18n.translate('bootloaderPanel.warning.linux')}{' '}
|
||||
<a
|
||||
href={pybricksUsbLinuxUdevRulesUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i18n.translate('bootloaderPanel.warning.learnMore')}
|
||||
</a>
|
||||
<ExternalLinkIcon />
|
||||
</Callout>
|
||||
</p>
|
||||
)}
|
||||
{hubHasUSB(hubType) && isWindows() && (
|
||||
<p>
|
||||
<Callout intent={Intent.WARNING} icon="warning-sign">
|
||||
{i18n.translate('bootloaderPanel.warning.windows')}{' '}
|
||||
<a
|
||||
href={pybricksUsbDfuWindowsDriverInstallUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{i18n.translate('bootloaderPanel.warning.learnMore')}
|
||||
</a>
|
||||
<ExternalLinkIcon />
|
||||
</Callout>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p>{i18n.translate('bootloaderPanel.instruction1')}</p>
|
||||
<ol>
|
||||
{hubHasUSB(hubType) && (
|
||||
@@ -398,16 +429,14 @@ const BootloaderModePanel: React.VoidFunctionComponent<BootloaderModePanelProps>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultHubType = Hub.Technic;
|
||||
|
||||
export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog);
|
||||
const dispatch = useDispatch();
|
||||
const [hubType, setHubType] = useState(defaultHubType);
|
||||
const [hubName, setHubName] = useState('');
|
||||
const [includeProgram, setIncludeProgram] = useState(false);
|
||||
const [selectedIncludeFile, setSelectedIncludeFile] = useState<FileMetadata>();
|
||||
const [licenseAccepted, setLicenseAccepted] = useState(false);
|
||||
const [hubType] = useHubPickerSelectedHub();
|
||||
const { data } = useFirmware(hubType);
|
||||
const i18n = useI18n();
|
||||
|
||||
@@ -432,7 +461,7 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
<DialogStep
|
||||
id="hub"
|
||||
title={i18n.translate('selectHubPanel.title')}
|
||||
panel={<SelectHubPanel hubType={hubType} onChange={setHubType} />}
|
||||
panel={<SelectHubPanel />}
|
||||
nextButtonProps={{ text: i18n.translate('nextButton.label') }}
|
||||
/>
|
||||
<DialogStep
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.pb-firmware-installPybricksDialog {
|
||||
&-body {
|
||||
min-height: bp.$pt-grid-size * 25;
|
||||
min-height: bp.$pt-grid-size * 35;
|
||||
}
|
||||
|
||||
&-license {
|
||||
@@ -17,11 +17,11 @@
|
||||
&-text {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
max-height: bp.$pt-grid-size * 20;
|
||||
max-height: bp.$pt-grid-size * 30;
|
||||
overflow: auto;
|
||||
|
||||
& .#{bp.$ns}-non-ideal-state {
|
||||
min-height: bp.$pt-grid-size * 20;
|
||||
min-height: bp.$pt-grid-size * 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
},
|
||||
"bootloaderPanel": {
|
||||
"title": "Place hub in bootloader mode",
|
||||
"warning": {
|
||||
"linux": "If you have never used Pybricks with USB on Linux, you will need to configure udev rules to allow permission before you can flash the hub firmware.",
|
||||
"windows": " If you have never used Pybricks with USB on Windows, you may need to manually install a USB driver before you can flash the hub firmware.",
|
||||
"learnMore": "Learn more."
|
||||
},
|
||||
"instruction1": "To flash the firmware, the hub must be placed in bootloader mode. Follow the steps below to do this:",
|
||||
"button": {
|
||||
"bluetooth": "Bluetooth button",
|
||||
@@ -87,6 +92,6 @@
|
||||
"label": "Next"
|
||||
},
|
||||
"flashFirmwareButton": {
|
||||
"label": "Flash Firmware"
|
||||
"label": "Connect and flash"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import { IToaster } from '@blueprintjs/core';
|
||||
import {
|
||||
FirmwareMetadata,
|
||||
FirmwareReaderError,
|
||||
FirmwareReaderErrorCode,
|
||||
} from '@pybricks/firmware';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import JSZip from 'jszip';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { alertsShowAlert } from '../alerts/actions';
|
||||
import {
|
||||
BootloaderConnectionFailureReason,
|
||||
checksumRequest,
|
||||
@@ -78,6 +81,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -130,6 +134,9 @@ describe('flashFirmware', () => {
|
||||
|
||||
// erase first
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
|
||||
|
||||
@@ -224,6 +231,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -273,6 +281,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -340,6 +349,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -403,6 +413,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -469,6 +480,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -528,6 +540,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -593,6 +606,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -675,6 +689,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -740,6 +755,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -790,6 +806,9 @@ describe('flashFirmware', () => {
|
||||
|
||||
// erase first
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
|
||||
|
||||
@@ -835,6 +854,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -885,6 +905,9 @@ describe('flashFirmware', () => {
|
||||
|
||||
// erase first
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
|
||||
|
||||
@@ -939,6 +962,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -989,6 +1013,9 @@ describe('flashFirmware', () => {
|
||||
|
||||
// erase first
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
|
||||
|
||||
@@ -1083,6 +1110,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1133,6 +1161,9 @@ describe('flashFirmware', () => {
|
||||
|
||||
// erase first
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
|
||||
|
||||
@@ -1225,6 +1256,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1283,6 +1315,9 @@ describe('flashFirmware', () => {
|
||||
|
||||
// erase first
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
|
||||
|
||||
@@ -1374,6 +1409,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1421,6 +1457,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1467,6 +1504,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1527,6 +1565,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1588,6 +1627,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1652,6 +1692,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1741,6 +1782,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1791,12 +1833,14 @@ describe('flashFirmware', () => {
|
||||
|
||||
// erase first
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
action = await saga.take();
|
||||
expect(action).toEqual(eraseRequest(1, /* isCityHub */ false));
|
||||
|
||||
saga.put(didRequest(1));
|
||||
saga.put(eraseResponse(Result.OK));
|
||||
|
||||
// then write the new firmware
|
||||
|
||||
const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8;
|
||||
|
||||
+58
-4
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
|
||||
import { IToaster } from '@blueprintjs/core';
|
||||
import {
|
||||
FirmwareReader,
|
||||
FirmwareReaderError,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
firmwareInstallPybricks,
|
||||
flashFirmware,
|
||||
} from './actions';
|
||||
import { flashProgress } from './alerts/FlashProgress';
|
||||
import {
|
||||
firmwareInstallPybricksDialogAccept,
|
||||
firmwareInstallPybricksDialogCancel,
|
||||
@@ -92,6 +94,10 @@ const firmwareZipMap = new Map<HubType, string>([
|
||||
* parent task).
|
||||
*/
|
||||
function* disconnectAndCancel(): SagaGenerator<void> {
|
||||
const toaster = yield* getContext<IToaster>('toaster');
|
||||
|
||||
toaster.dismiss('firmware.ble.progress');
|
||||
|
||||
const connection = yield* select((s: RootState) => s.bootloader.connection);
|
||||
|
||||
if (connection === BootloaderConnectionState.Connected) {
|
||||
@@ -326,6 +332,8 @@ function* loadFirmware(
|
||||
* @param action The action that triggered this saga.
|
||||
*/
|
||||
function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generator {
|
||||
const toaster = yield* getContext<IToaster>('toaster');
|
||||
|
||||
try {
|
||||
let firmware: Uint8Array | undefined = undefined;
|
||||
let deviceId: HubType | undefined = undefined;
|
||||
@@ -416,6 +424,16 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
|
||||
|
||||
yield* put(didStart());
|
||||
|
||||
toaster.show(
|
||||
flashProgress(() => undefined, {
|
||||
action: 'erase',
|
||||
progress: undefined,
|
||||
}),
|
||||
'firmware.ble.progress',
|
||||
);
|
||||
|
||||
yield* put(alertsShowAlert('firmware', 'releaseButton'));
|
||||
|
||||
const eraseAction = yield* put(
|
||||
eraseRequest(nextMessageId(), deviceId === HubType.CityHub),
|
||||
);
|
||||
@@ -467,6 +485,14 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
|
||||
|
||||
yield* put(didProgress(offset / firmware.length));
|
||||
|
||||
toaster.show(
|
||||
flashProgress(() => undefined, {
|
||||
action: 'flash',
|
||||
progress: offset / firmware.length,
|
||||
}),
|
||||
'firmware.ble.progress',
|
||||
);
|
||||
|
||||
// we don't want to request checksum if this is the last packet since
|
||||
// the bootloader will send a response to the program request already.
|
||||
offset += maxDataSize;
|
||||
@@ -540,6 +566,14 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
|
||||
|
||||
yield* put(didProgress(1));
|
||||
|
||||
toaster.show(
|
||||
flashProgress(() => undefined, {
|
||||
action: 'flash',
|
||||
progress: 1,
|
||||
}),
|
||||
'firmware.ble.progress',
|
||||
);
|
||||
|
||||
// this will cause the remote device to disconnect and reboot
|
||||
const rebootAction = yield* put(rebootRequest(nextMessageId()));
|
||||
yield* waitForDidRequest(rebootAction.id);
|
||||
@@ -615,12 +649,10 @@ function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): Gen
|
||||
// forceInterfacesName is needed to get the flash layout map
|
||||
{ forceInterfacesName: true },
|
||||
{
|
||||
// NB: info and progress are never called in dfu v0.1.5
|
||||
info: console.debug,
|
||||
warning: console.warn,
|
||||
progress: (progress, total) => {
|
||||
// TODO: bind to eventChannel and dispatch progress actions
|
||||
console.log(progress, total);
|
||||
},
|
||||
progress: console.debug,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -656,6 +688,28 @@ function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): Gen
|
||||
dfu.dfuseStartAddress = dfuFirmwareStartAddress;
|
||||
const writeProc = dfu.write(1024, firmware, true);
|
||||
|
||||
const toaster = yield* getContext<IToaster>('toaster');
|
||||
|
||||
writeProc.events.on('erase/process', (sent, total) => {
|
||||
toaster.show(
|
||||
flashProgress(() => undefined, {
|
||||
action: 'erase',
|
||||
progress: sent / total,
|
||||
}),
|
||||
'firmware.dfu.progress',
|
||||
);
|
||||
});
|
||||
|
||||
writeProc.events.on('write/process', (sent, total) => {
|
||||
toaster.show(
|
||||
flashProgress(() => undefined, {
|
||||
action: 'flash',
|
||||
progress: sent / total,
|
||||
}),
|
||||
'firmware.dfu.progress',
|
||||
);
|
||||
});
|
||||
|
||||
writeProc.events.on('error', console.error);
|
||||
|
||||
// REVISIT: we could possibly race the 'write/end' and 'error' events
|
||||
|
||||
@@ -52,6 +52,8 @@ const store = configureStore({
|
||||
// contain ArrayBuffer or DataView
|
||||
'data',
|
||||
'firmwareZip',
|
||||
// Error is not serializable
|
||||
'error',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import fileStorage from './fileStorage/reducers';
|
||||
import firmware from './firmware/reducers';
|
||||
import hub from './hub/reducers';
|
||||
import bootloader from './lwp3-bootloader/reducers';
|
||||
import tour from './tour/reducers';
|
||||
|
||||
/**
|
||||
* Root reducer for redux store.
|
||||
@@ -24,6 +25,7 @@ export const rootReducer = combineReducers({
|
||||
fileStorage,
|
||||
firmware,
|
||||
hub,
|
||||
tour,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -96,6 +96,7 @@ const Settings: React.VoidFunctionComponent = () => {
|
||||
</FormGroup>
|
||||
<FormGroup label={i18n.translate('firmware.title')}>
|
||||
<Button
|
||||
id="pb-settings-flash-pybricks-button"
|
||||
minimal={true}
|
||||
icon="download"
|
||||
label={i18n.translate('firmware.flashPybricksButton.label')}
|
||||
@@ -103,6 +104,7 @@ const Settings: React.VoidFunctionComponent = () => {
|
||||
/>
|
||||
<InstallPybricksDialog />
|
||||
<Button
|
||||
id="pb-settings-flash-official-button"
|
||||
minimal={true}
|
||||
icon="download"
|
||||
label={i18n.translate('firmware.flashLegoButton.label')}
|
||||
|
||||
@@ -9,13 +9,17 @@ import BluetoothButton from './buttons/bluetooth/BluetoothButton';
|
||||
import ReplButton from './buttons/repl/ReplButton';
|
||||
import RunButton from './buttons/run/RunButton';
|
||||
import StopButton from './buttons/stop/StopButton';
|
||||
import TourButton from './buttons/tour/TourButton';
|
||||
|
||||
import './toolbar.scss';
|
||||
|
||||
// matches ID in tour component
|
||||
const bluetoothButtonId = 'pb-toolbar-bluetooth-button';
|
||||
const runButtonId = 'pb-toolbar-run-button';
|
||||
const tourButtonId = 'pb-toolbar-tour-button';
|
||||
|
||||
const Toolbar: React.VFC = () => {
|
||||
const flashButtonId = useId();
|
||||
const bluetoothButtonId = useId();
|
||||
const runButtonId = useId();
|
||||
const stopButtonId = useId();
|
||||
const replButtonId = useId();
|
||||
|
||||
@@ -29,6 +33,9 @@ const Toolbar: React.VFC = () => {
|
||||
<StopButton id={stopButtonId} />
|
||||
<ReplButton id={replButtonId} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className="pb-toolbar-group pb-align-right">
|
||||
<TourButton id={tourButtonId} />
|
||||
</ButtonGroup>
|
||||
</UtilsToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { testRender } from '../../../../test';
|
||||
import { HubRuntimeState } from '../../../hub/reducers';
|
||||
import { tourStart } from '../../../tour/actions';
|
||||
import TourButton from './TourButton';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should dispatch action when clicked', async () => {
|
||||
const [user, button, dispatch] = testRender(<TourButton id="test-tour-button" />, {
|
||||
hub: { runtime: HubRuntimeState.Running },
|
||||
});
|
||||
|
||||
await user.click(button.getByRole('button', { name: 'Tour Pybricks Code' }));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(tourStart());
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { appName } from '../../../app/constants';
|
||||
import { tourStart } from '../../../tour/actions';
|
||||
import ActionButton, { ActionButtonProps } from '../../ActionButton';
|
||||
import { useI18n } from './i18n';
|
||||
import icon from './icon.svg';
|
||||
|
||||
type TourButtonProps = Pick<ActionButtonProps, 'id'>;
|
||||
|
||||
const TourButton: React.VoidFunctionComponent<TourButtonProps> = ({ id }) => {
|
||||
const i18n = useI18n();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
id={id}
|
||||
label={i18n.translate('label', { appName })}
|
||||
tooltip={i18n.translate('tooltip', { appName })}
|
||||
icon={icon}
|
||||
onAction={() => dispatch(tourStart())}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TourButton;
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
|
||||
import type { TypedI18n } from '../../../i18n';
|
||||
import type translations from './translations/en.json';
|
||||
|
||||
export function useI18n(): TypedI18n<typeof translations> {
|
||||
// istanbul ignore next: babel-loader rewrites this line
|
||||
const [i18n] = useShopifyI18n();
|
||||
return i18n;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -270.54)"><path d="m1.9234 276.13c-1.0655 0-1.9234 0.85655-1.9234 1.9213v10.588c0 1.0648 0.85787 1.9213 1.9234 1.9213h3.3755c1.0655 0 1.9234-0.85653 1.9234-1.9213v-0.47646h12.014v0.47646c0 1.0648 0.85787 1.9213 1.9234 1.9213h3.3755c1.0655 0 1.9234-0.85653 1.9234-1.9213v-10.588c0-1.0648-0.85786-1.9213-1.9234-1.9213h-3.3755c-5.4826 0.0358-11.132 0.0125-15.861 0zm0.49609 2.4123h21.644v9.617h-2.3978v-2.3916h-2.0691v-0.89193h-0.74311v0.89193h-1.664v-0.89193h-0.74001v0.89193h-1.664v-0.89193h-0.74001v0.89193h-1.664v-0.89193h-0.74311v0.89193h-1.664v-0.89193h-0.74104v0.89193h-1.664v-0.89193h-0.68316v0.89193h-2.0691v2.3916h-2.3978v-3.2835z" fill="#fcfcfc"/><ellipse cx="8.4499" cy="281.03" rx="1.7076" ry="1.7064" fill="#fff"/><ellipse cx="18.033" cy="281.03" rx="1.7076" ry="1.7064" fill="#fff"/></g></svg>
|
||||
|
After Width: | Height: | Size: 937 B |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Tour {appName}",
|
||||
"tooltip": "Take a quick tour of {appName}"
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Icon } from '@blueprintjs/core';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Joyride, {
|
||||
ACTIONS,
|
||||
CallBackProps,
|
||||
EVENTS,
|
||||
Locale,
|
||||
STATUS,
|
||||
Step,
|
||||
Styles,
|
||||
} from 'react-joyride';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useEffectOnce, useLocalStorage, useTernaryDarkMode } from 'usehooks-ts';
|
||||
import { Activity, useActivitiesSelectedActivity } from '../activities/hooks';
|
||||
import { appName } from '../app/constants';
|
||||
import { useSelector } from '../reducers';
|
||||
import { tourStart, tourStop } from './actions';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
const WelcomeStep = React.memo(function WelcomeStep() {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{i18n.translate('steps.welcome.message', { appName })}</p>
|
||||
<p>
|
||||
{i18n.translate('steps.welcome.action', {
|
||||
next: <strong>{i18n.translate('next')}</strong>,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const AddFileStep = React.memo(function AddFileStep() {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<p>
|
||||
{i18n.translate('steps.newFile.message', {
|
||||
icon: <Icon icon="plus" style={{ verticalAlign: 'text-top' }} />,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
|
||||
const BackupFilesStep = React.memo(function BackupFilesStep() {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{i18n.translate('steps.backupFiles.message')}</p>
|
||||
<p>
|
||||
{i18n.translate('steps.backupFiles.action', {
|
||||
icon: <Icon icon="archive" style={{ verticalAlign: 'text-top' }} />,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const FlashPybricksFirmwareStep = React.memo(function FlashPybricksFirmwareStep() {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<p>
|
||||
{i18n.translate('steps.flashPybricksFirmware.message', {
|
||||
appName,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
|
||||
const RestoreOfficialFirmwareStep = React.memo(function RestoreOfficialFirmwareStep() {
|
||||
const i18n = useI18n();
|
||||
|
||||
return <p>{i18n.translate('steps.restoreOfficialFirmware.message')}</p>;
|
||||
});
|
||||
|
||||
const ConnectToHubStep = React.memo(function ConnectToHubStep() {
|
||||
const i18n = useI18n();
|
||||
|
||||
return <p>{i18n.translate('steps.connectToHub.message')}</p>;
|
||||
});
|
||||
|
||||
const DownloadAndRunStep = React.memo(function DownloadAndRunStep() {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<p>
|
||||
{i18n.translate('steps.downloadAndRun.message', {
|
||||
icon: <Icon icon="play" style={{ verticalAlign: 'text-top' }} />,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
|
||||
const Tour: React.VoidFunctionComponent = () => {
|
||||
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
|
||||
const [showOnStartup, setShowOnStartup] = useLocalStorage(
|
||||
'tour.showOnStartup',
|
||||
true,
|
||||
);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const { isRunning } = useSelector((s) => s.tour);
|
||||
const { isDarkMode } = useTernaryDarkMode();
|
||||
const dispatch = useDispatch();
|
||||
const i18n = useI18n();
|
||||
|
||||
const steps = useMemo<Step[]>(
|
||||
() => [
|
||||
{
|
||||
target: '#pb-toolbar-tour-button',
|
||||
content: <WelcomeStep />,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target:
|
||||
selectedActivity === Activity.Explorer
|
||||
? '#pb-explorer-add-button'
|
||||
: '[itemId=pb-activities-explorer-tab]',
|
||||
content: <AddFileStep />,
|
||||
disableBeacon: selectedActivity === Activity.Explorer,
|
||||
},
|
||||
{
|
||||
target:
|
||||
selectedActivity === Activity.Explorer
|
||||
? '#pb-explorer-archive-button'
|
||||
: '[itemId=pb-activities-explorer-tab]',
|
||||
content: <BackupFilesStep />,
|
||||
disableBeacon: selectedActivity === Activity.Explorer,
|
||||
},
|
||||
{
|
||||
target:
|
||||
selectedActivity === Activity.Settings
|
||||
? '#pb-settings-flash-pybricks-button'
|
||||
: '[itemId=pb-activities-settings-tab]',
|
||||
content: <FlashPybricksFirmwareStep />,
|
||||
disableBeacon: selectedActivity === Activity.Settings,
|
||||
},
|
||||
{
|
||||
target:
|
||||
selectedActivity === Activity.Settings
|
||||
? '#pb-settings-flash-official-button'
|
||||
: '[itemId=pb-activities-settings-tab]',
|
||||
content: <RestoreOfficialFirmwareStep />,
|
||||
disableBeacon: selectedActivity === Activity.Settings,
|
||||
},
|
||||
{
|
||||
target: '#pb-toolbar-bluetooth-button',
|
||||
content: <ConnectToHubStep />,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '#pb-toolbar-run-button',
|
||||
content: <DownloadAndRunStep />,
|
||||
disableBeacon: true,
|
||||
},
|
||||
],
|
||||
[selectedActivity, i18n],
|
||||
);
|
||||
|
||||
const styles = useMemo<Styles>(
|
||||
() => ({
|
||||
// colors come from variables.scss
|
||||
options: {
|
||||
// $pybricks-blue
|
||||
primaryColor: '#0088ce',
|
||||
// $pt-dark-text-color / $pt-text-color
|
||||
textColor: isDarkMode ? '#f6f7f9' : '#1c2127',
|
||||
// $pt-dark-app-background-color / $pt-app-background-color
|
||||
backgroundColor: isDarkMode ? '#252a31' : '#f6f7f9',
|
||||
arrowColor: isDarkMode ? '#252a31' : '#f6f7f9',
|
||||
},
|
||||
}),
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
const locale = useMemo<Locale>(
|
||||
() => ({
|
||||
back: i18n.translate('back'),
|
||||
close: i18n.translate('close'),
|
||||
last: i18n.translate('last'),
|
||||
next: i18n.translate('next'),
|
||||
open: i18n.translate('open'),
|
||||
skip: i18n.translate('skip'),
|
||||
}),
|
||||
[i18n],
|
||||
);
|
||||
|
||||
const callback = useCallback(
|
||||
(event: CallBackProps) => {
|
||||
if (event.action === ACTIONS.CLOSE || event.status === STATUS.FINISHED) {
|
||||
dispatch(tourStop());
|
||||
setStepIndex(0);
|
||||
}
|
||||
|
||||
if (event.type === EVENTS.STEP_AFTER) {
|
||||
const nextIndex = stepIndex + (event.action === ACTIONS.PREV ? -1 : 1);
|
||||
const nextStep = steps.at(nextIndex);
|
||||
|
||||
// Some components may not be mounted when the next/back button
|
||||
// is pressed. If this is the case, first target the tab, then
|
||||
// request to activate that tab. When the tab panel is mounted,
|
||||
// the target will automatically update.
|
||||
|
||||
if (typeof nextStep?.target === 'string') {
|
||||
if (nextStep.target.startsWith('[itemId=pb-activities-explorer-')) {
|
||||
setSelectedActivity(Activity.Explorer);
|
||||
} else if (
|
||||
nextStep.target.startsWith('[itemId=pb-activities-settings-')
|
||||
) {
|
||||
setSelectedActivity(Activity.Settings);
|
||||
}
|
||||
}
|
||||
|
||||
setStepIndex(nextIndex);
|
||||
}
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
selectedActivity,
|
||||
setSelectedActivity,
|
||||
stepIndex,
|
||||
setStepIndex,
|
||||
steps,
|
||||
],
|
||||
);
|
||||
|
||||
// automatically show the tour on the first run only
|
||||
useEffectOnce(() => {
|
||||
if (showOnStartup) {
|
||||
dispatch(tourStart());
|
||||
setShowOnStartup(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Joyride
|
||||
run={isRunning}
|
||||
stepIndex={stepIndex}
|
||||
continuous={true}
|
||||
showProgress={true}
|
||||
steps={steps}
|
||||
styles={styles}
|
||||
locale={locale}
|
||||
callback={callback}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tour;
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
export { start as tourStart, stop as tourStop } from './redux/tour';
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
|
||||
import type { TypedI18n } from '../i18n';
|
||||
import type translations from './translations/en.json';
|
||||
|
||||
export function useI18n(): TypedI18n<typeof translations> {
|
||||
// istanbul ignore next: babel-loader rewrites this line
|
||||
const [i18n] = useShopifyI18n();
|
||||
return i18n;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import tour from './redux/tour';
|
||||
|
||||
export default tour;
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
type State = {
|
||||
isRunning: boolean;
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
isRunning: false,
|
||||
};
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'tour',
|
||||
initialState,
|
||||
reducers: {
|
||||
start(state) {
|
||||
state.isRunning = true;
|
||||
},
|
||||
stop(state) {
|
||||
state.isRunning = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { start, stop } = slice.actions;
|
||||
export default slice.reducer;
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"last": "Last",
|
||||
"next": "Next",
|
||||
"open": "Open the dialog",
|
||||
"skip": "Skip",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"message": "Welcome to {appName}.",
|
||||
"action": "Click {next} to discover key features."
|
||||
},
|
||||
"newFile": {
|
||||
"message": "Click the {icon} button to create a new program."
|
||||
},
|
||||
"backupFiles": {
|
||||
"message": "Don't lose your work.",
|
||||
"action": "Click the {icon} button to backup all of your programs."
|
||||
},
|
||||
"flashPybricksFirmware": {
|
||||
"message": "{appName} requires a special firmware to be flashed to your hub in order to run the program on the hub."
|
||||
},
|
||||
"restoreOfficialFirmware": {
|
||||
"message": "You can always restore the official LEGO® firmware after using the special Pybricks firmware."
|
||||
},
|
||||
"connectToHub": {
|
||||
"message": "One you have flashed the Pybricks firmware, click the Bluetooth button to connect to the hub."
|
||||
},
|
||||
"downloadAndRun": {
|
||||
"message": "After you are connected to the hub, click the {icon} button to download and run your program."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1802,6 +1802,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@gilbarbara/deep-equal@npm:^0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "@gilbarbara/deep-equal@npm:0.1.1"
|
||||
checksum: d904761847320587cbb2a76310acc3344d241201e4e6521e9f88b2f4eb7d8a13aed2081fca1cad625ef78390651e5bbfe914ff5d6925a9f40cf305676a9e1f20
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@humanwhocodes/config-array@npm:^0.9.2":
|
||||
version: 0.9.5
|
||||
resolution: "@humanwhocodes/config-array@npm:0.9.5"
|
||||
@@ -2410,6 +2417,7 @@ __metadata:
|
||||
react-dev-utils: ^12.0.1
|
||||
react-dom: ^16.13.1
|
||||
react-dropzone: ^14.2.2
|
||||
react-joyride: ^2.5.0
|
||||
react-monaco-editor: ^0.49.0
|
||||
react-popper: ^2.3.0
|
||||
react-redux: ^8.0.2
|
||||
@@ -8000,6 +8008,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"exenv@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "exenv@npm:1.2.2"
|
||||
checksum: a894f3b60ab8419e0b6eec99c690a009c8276b4c90655ccaf7d28faba2de3a6b93b3d92210f9dc5efd36058d44f04098f6bbccef99859151104bfd49939904dc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"exit@npm:^0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "exit@npm:0.1.2"
|
||||
@@ -9271,6 +9286,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-lite@npm:^0.8.2":
|
||||
version: 0.8.2
|
||||
resolution: "is-lite@npm:0.8.2"
|
||||
checksum: 0ee62cb238c2a044f58d1cd139fb0b48026c407ec8625ee6572b417f164e17ec937f0a0785f466e320749a796c316a3b78dcb4b520f7ddd4b9de38ad5a23d70f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-module@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "is-module@npm:1.0.0"
|
||||
@@ -11478,7 +11500,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"popper.js@npm:^1.14.4, popper.js@npm:^1.16.1":
|
||||
"popper.js@npm:^1.14.4, popper.js@npm:^1.16.0, popper.js@npm:^1.16.1":
|
||||
version: 1.16.1
|
||||
resolution: "popper.js@npm:1.16.1"
|
||||
checksum: c56ae5001ec50a77ee297a8061a0221d99d25c7348d2e6bcd3e45a0d0f32a1fd81bca29d46cb0d4bdf13efb77685bd6a0ce93f9eb3c608311a461f945fffedbe
|
||||
@@ -12699,6 +12721,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-floater@npm:^0.7.5":
|
||||
version: 0.7.5
|
||||
resolution: "react-floater@npm:0.7.5"
|
||||
dependencies:
|
||||
deepmerge: ^4.2.2
|
||||
exenv: ^1.2.2
|
||||
is-lite: ^0.8.2
|
||||
popper.js: ^1.16.0
|
||||
react-proptype-conditional-require: ^1.0.4
|
||||
tree-changes: ^0.9.1
|
||||
peerDependencies:
|
||||
prop-types: ^15.7.2
|
||||
react: 15 - 18
|
||||
react-dom: 15 - 18
|
||||
checksum: 72d0ccd15d93e08885e9d5ff59be757df198fa48e796b3ce48138e8ba0f393df4b246c18e4887ad62214607f75041366b34c0d4f0edc7e70e774eb236c377698
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
|
||||
version: 16.13.1
|
||||
resolution: "react-is@npm:16.13.1"
|
||||
@@ -12720,6 +12760,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-joyride@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "react-joyride@npm:2.5.0"
|
||||
dependencies:
|
||||
deepmerge: ^4.2.2
|
||||
exenv: ^1.2.2
|
||||
is-lite: ^0.8.2
|
||||
react-floater: ^0.7.5
|
||||
react-is: ^16.13.1
|
||||
scroll: ^3.0.1
|
||||
scrollparent: ^2.0.1
|
||||
tree-changes: ^0.9.1
|
||||
peerDependencies:
|
||||
prop-types: ^15.0.0
|
||||
react: 15 - 18
|
||||
react-dom: 15 - 18
|
||||
checksum: 2d15d1b6a6e6e830af0e594b53b5318dd91a3b57f183157404375d1760a36b4f91acf89c69e73fed7ba5d9d0c13bdc23b2ea426a64259635321a4aaa020aae9d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-monaco-editor@npm:^0.49.0":
|
||||
version: 0.49.0
|
||||
resolution: "react-monaco-editor@npm:0.49.0"
|
||||
@@ -12764,6 +12824,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-proptype-conditional-require@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "react-proptype-conditional-require@npm:1.0.4"
|
||||
checksum: 78f82d15b2c77c14fd8fbcbbed279850df3a856984aacd519ee6c2162e034b114b8ac47c00157b84ef7c98c0711d933a0177d9d54555629cf381f54341bb0e8f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:^8.0.2":
|
||||
version: 8.0.2
|
||||
resolution: "react-redux@npm:8.0.2"
|
||||
@@ -13385,6 +13452,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scroll@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "scroll@npm:3.0.1"
|
||||
checksum: e6b045347adace30035073882e6ef2af7e1c81dd611faf3a578ca8cd0d1a3a9da54932dd97ed6fd99c9573351c758fa50e6d8ed4afb5bd3a33794b6c48d25922
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scrollparent@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "scrollparent@npm:2.0.1"
|
||||
checksum: 9ff2b29c1233431ebd0910e5f8db4a058836ee0292d1bb18042fbb4d2175c3ed73fa28a71a96074ac6848173185238ad1139bdefc3863be4808df981175cb807
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"select-hose@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "select-hose@npm:2.0.0"
|
||||
@@ -14415,6 +14496,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tree-changes@npm:^0.9.1":
|
||||
version: 0.9.1
|
||||
resolution: "tree-changes@npm:0.9.1"
|
||||
dependencies:
|
||||
"@gilbarbara/deep-equal": ^0.1.1
|
||||
is-lite: ^0.8.2
|
||||
checksum: 6749f0213dbbfda592326250cdbbc9bb0f03c6c22e042e7089b1c4d6ab2fc1aa5cde0e02d31d5d7a32c901301146a01ed0c9b90a94007798681aa6af76bd6428
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tryer@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "tryer@npm:1.0.1"
|
||||
|
||||
Reference in New Issue
Block a user