Merge pull request #277 from pybricks/dlech

final beta 5 updates
This commit is contained in:
David Lechner
2021-01-28 20:52:46 -06:00
committed by GitHub
32 changed files with 723 additions and 72 deletions
+4 -4
View File
@@ -1,8 +1,4 @@
{
"[css]": {
"editor.suggest.insertMode": "replace",
"editor.formatOnSave": true
},
"[json]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
@@ -11,6 +7,10 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@pybricks/pybricks-code",
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.5",
"license": "MIT",
"author": "The Pybricks Authors",
"repository": {
@@ -11,7 +11,7 @@
"@blueprintjs/core": "^3.38.1",
"@craco/craco": "^6.0.0",
"@pybricks/firmware": "4.5.0",
"@pybricks/ide-docs": "1.0.0",
"@pybricks/ide-docs": "1.1.0",
"@pybricks/mpy-cross-v5": "^2.0.0",
"@shopify/react-i18n": "^5.2.1",
"@testing-library/dom": "^7.29.2",
+84 -3
View File
@@ -4,11 +4,24 @@
// Actions for the app in general.
import { Action } from 'redux';
import { BeforeInstallPromptEvent } from '../utils/dom';
/** App action types. */
export enum AppActionType {
/** Reload the app. */
Reload = 'app.action.reload',
/** Checks for an available update. */
CheckForUpdate = 'app.action.checkForUpdate',
/** Indicates that checking for update finished. */
DidCheckForUpdate = 'app.action.didCheckForUpdate',
/* Indicates the browser wants to prompt the use to install the app. */
DidBeforeInstallPrompt = 'app.action.didBeforeInstallPrompt',
/* Requests to prompt the user to install the app. */
InstallPrompt = 'app.action.installPrompt',
/* Indicates that the user responded to the install prompt. */
DidInstallPrompt = 'app.action.didInstallPrompt',
/* Indicates that the app was installed. */
DidInstall = 'app.action.didInstallPrompt',
/** The app has just ben started. */
DidStart = 'app.action.didStart',
/** Open settings dialog. */
@@ -26,11 +39,73 @@ export enum AppActionType {
}
/** Action that requests the app to reload. */
export type AppReloadAction = Action<AppActionType.Reload>;
export type AppReloadAction = Action<AppActionType.Reload> & {
registration: ServiceWorkerRegistration;
};
/** Creates an action that requests the app to reload. */
export function reload(): AppReloadAction {
return { type: AppActionType.Reload };
export function reload(registration: ServiceWorkerRegistration): AppReloadAction {
return { type: AppActionType.Reload, registration };
}
/** Action that requests to check for updates. */
export type AppCheckForUpdatesAction = Action<AppActionType.CheckForUpdate> & {
registration: ServiceWorkerRegistration;
};
/** Action that requests to check for updates. */
export function checkForUpdate(
registration: ServiceWorkerRegistration,
): AppCheckForUpdatesAction {
return { type: AppActionType.CheckForUpdate, registration };
}
/** Action that indicates that checking for an update has completed. */
export type AppDidCheckForUpdateAction = Action<AppActionType.DidCheckForUpdate> & {
updateFound: boolean;
};
/** Action that indicates that checking for an update has completed. */
export function didCheckForUpdate(updateFound: boolean): AppDidCheckForUpdateAction {
return { type: AppActionType.DidCheckForUpdate, updateFound };
}
/* Action that indicates the browser wants to prompt the use to install the app. */
export type AppDidBeforeInstallPromptAction = Action<AppActionType.DidBeforeInstallPrompt> & {
event: BeforeInstallPromptEvent;
};
/* Action that indicates the browser wants to prompt the use to install the app. */
export function didBeforeInstallPrompt(
event: BeforeInstallPromptEvent,
): AppDidBeforeInstallPromptAction {
return { type: AppActionType.DidBeforeInstallPrompt, event };
}
/* Action that requests to prompt the user to install the app. */
export type AppInstallPromptAction = Action<AppActionType.InstallPrompt> & {
event: BeforeInstallPromptEvent;
};
/* Action that requests to prompt the user to install the app. */
export function installPrompt(event: BeforeInstallPromptEvent): AppInstallPromptAction {
return { type: AppActionType.InstallPrompt, event };
}
/* Action that indicates that the user responded to the install prompt. */
export type AppDidInstallPromptAction = Action<AppActionType.DidInstallPrompt>;
/* Action that indicates that the user responded to the install prompt. */
export function didInstallPrompt(): AppDidInstallPromptAction {
return { type: AppActionType.DidInstallPrompt };
}
/* Action that indicates app was installed. */
export type AppDidInstallAction = Action<AppActionType.DidInstall>;
/* Action that indicates app was installed. */
export function didInstall(): AppDidInstallAction {
return { type: AppActionType.DidInstall };
}
/** Action that indicates the app has just started. */
@@ -92,6 +167,12 @@ export function closeLicenseDialog(): AppCloseLicenseDialogAction {
/** common type for all app actions. */
export type AppAction =
| AppReloadAction
| AppCheckForUpdatesAction
| AppDidCheckForUpdateAction
| AppDidBeforeInstallPromptAction
| AppInstallPromptAction
| AppDidInstallPromptAction
| AppDidInstallAction
| AppDidStartAction
| AppOpenSettingsAction
| AppCloseSettingsAction
+4
View File
@@ -51,6 +51,7 @@ export function didConnect(): BleDeviceDidConnectAction {
export enum BleDeviceFailToConnectReasonType {
NoWebBluetooth = 'ble.device.didFailToConnect.noWebBluetooth',
NoBluetooth = 'ble.device.didFailToConnect.noBluetooth',
Canceled = 'ble.device.didFailToConnect.canceled',
NoGatt = 'ble.device.didFailToConnect.noGatt',
NoService = 'ble.device.didFailToConnect.noService',
@@ -63,6 +64,8 @@ type Reason<T extends BleDeviceFailToConnectReasonType> = {
export type BleDeviceFailToConnectNoWebBluetoothReason = Reason<BleDeviceFailToConnectReasonType.NoWebBluetooth>;
export type BleDeviceFailToConnectNoBluetoothReason = Reason<BleDeviceFailToConnectReasonType.NoBluetooth>;
export type BleDeviceFailToConnectCanceledReason = Reason<BleDeviceFailToConnectReasonType.Canceled>;
export type BleDeviceFailToConnectNoGattReason = Reason<BleDeviceFailToConnectReasonType.NoGatt>;
@@ -75,6 +78,7 @@ export type BleDeviceFailToConnectUnknownReason = Reason<BleDeviceFailToConnectR
export type BleDeviceDidFailToConnectReason =
| BleDeviceFailToConnectNoWebBluetoothReason
| BleDeviceFailToConnectNoBluetoothReason
| BleDeviceFailToConnectCanceledReason
| BleDeviceFailToConnectNoGattReason
| BleDeviceFailToConnectNoServiceReason
+5
View File
@@ -79,6 +79,8 @@ export function disconnect(): BootloaderConnectionDisconnectAction {
export enum BootloaderConnectionFailureReason {
/** Web Bluetooth is not available */
NoWebBluetooth = 'no-web-bluetooth',
/** Bluetooth is not available */
NoBluetooth = 'no-bluetooth',
/** Connected but failed to find the bootloader GATT service */
GattServiceNotFound = 'gatt-service-not-found',
/** The connection was canceled */
@@ -93,6 +95,8 @@ type Reason<T extends BootloaderConnectionFailureReason> = {
export type BootloaderConnectionFailToConnectNoWebBluetoothReason = Reason<BootloaderConnectionFailureReason.NoWebBluetooth>;
export type BootloaderConnectionFailToConnectNoBluetoothReason = Reason<BootloaderConnectionFailureReason.NoBluetooth>;
export type BootloaderConnectionFailToConnectGattServiceNotFoundReason = Reason<BootloaderConnectionFailureReason.GattServiceNotFound>;
export type BootloaderConnectionFailToConnectCanceledReason = Reason<BootloaderConnectionFailureReason.Canceled>;
@@ -103,6 +107,7 @@ export type BootloaderConnectionFailToConnectUnknownReason = Reason<BootloaderCo
export type BootloaderConnectionDidFailToConnectReason =
| BootloaderConnectionFailToConnectNoWebBluetoothReason
| BootloaderConnectionFailToConnectNoBluetoothReason
| BootloaderConnectionFailToConnectGattServiceNotFoundReason
| BootloaderConnectionFailToConnectCanceledReason
| BootloaderConnectionFailToConnectUnknownReason;
+12
View File
@@ -7,6 +7,7 @@ import { SettingId } from '../settings/user';
/** Actions related to settings. */
export enum SettingsActionType {
SetBoolean = 'settings.action.setBoolean',
ToggleBoolean = 'settings.action.toggleBoolean',
DidFailToSetBoolean = 'settings.action.didFailToSetBoolean',
DidBooleanChange = 'settings.action.didBooleanChange',
}
@@ -27,6 +28,16 @@ export function setBoolean(id: SettingId, newState: boolean): SettingsSetBoolean
return { type: SettingsActionType.SetBoolean, id, newState };
}
/** Action to toggle a setting. */
export type SettingsToggleBooleanAction = Action<SettingsActionType.ToggleBoolean> & {
id: SettingId;
};
/** Creates an action to toggle a setting. */
export function toggleBoolean(id: SettingId): SettingsToggleBooleanAction {
return { type: SettingsActionType.ToggleBoolean, id };
}
/** Action that indicates setting/storing a setting failed. */
export type SettingsDidFailToSetBooleanAction = Action<SettingsActionType.DidFailToSetBoolean> & {
id: SettingId;
@@ -56,5 +67,6 @@ export function didBooleanChange(
/** Common type for all settings actions. */
export type SettingsAction =
| SettingsSetBooleanAction
| SettingsToggleBooleanAction
| SettingsDidFailToSetBooleanAction
| SettingsDidBooleanChangeAction;
+1
View File
@@ -51,6 +51,7 @@ class ActionButton extends React.Component<Props> {
<Button
ref={this.buttonRef}
intent={Intent.PRIMARY}
onMouseDown={(e) => e.preventDefault()} // prevent focus
onClick={(): void => this.props.onAction()}
disabled={this.props.enabled === false}
className="no-box-shadow"
+113 -15
View File
@@ -1,10 +1,14 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
import { Classes } from '@blueprintjs/core';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import SplitterLayout from 'react-splitter-layout';
import { toggleBoolean } from '../actions/settings';
import { RootState } from '../reducers';
import { SettingId } from '../settings/user';
import { isMacOS } from '../utils/os';
import Editor from './Editor';
import SettingsDrawer from './SettingsDrawer';
import StatusBar from './StatusBar';
@@ -17,12 +21,13 @@ import './app.scss';
function App(): JSX.Element {
const showDocs = useSelector((s: RootState): boolean => s.settings.showDocs);
const [dragging, setDragging] = useState(false);
const dispatch = useDispatch();
return (
<div className="app">
<Toolbar />
<SplitterLayout
customClassName="h-body"
customClassName={`h-body ${showDocs ? 'pb-show-docs' : 'pb-hide-docs'}`}
onDragStart={(): void => setDragging(true)}
onDragEnd={(): void => setDragging(false)}
percentage={true}
@@ -48,19 +53,112 @@ function App(): JSX.Element {
<Terminal />
</div>
</SplitterLayout>
{showDocs && (
<div className="h-100 w-100">
{dragging && <div className="h-100 w-100 p-absolute" />}
<iframe
src="static/docs/index.html"
allowFullScreen={true}
title="docs"
width="100%"
height="100%"
frameBorder="none"
/>
</div>
)}
<div className="h-100 w-100">
{dragging && <div className="h-100 w-100 p-absolute" />}
<iframe
// REVISIT: some of this could be moved to the docs repo
// so that it runs earlier to prevent flashing in the UI.
// The load event doesn't run until after the page is fully
// loaded and there doesn't seem to be a reasonable way to
// hook into the iframe to know when it has a new document.
onLoad={(e) => {
// HACK: this mess restores the scroll position when
// the documentation iframe visibility is toggled.
// The iframe will be automatically scrolled to 0 when
// CSS `display: none` is set.
const target = e.target as HTMLIFrameElement;
const contentWindow = target.contentWindow;
if (!contentWindow) {
console.error('could not get iframe content window');
return;
}
// the last "good" scrollY value of the iframe
let iframeScroll = 0;
// This bit monitors the visibility.
// https://stackoverflow.com/a/44670818/1976323
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// Restore the scroll position when the
// iframe is shown. Toggling the visibility
// prevents flashing the contents from the
// top of the page before the scroll is
// done.
if (entry.intersectionRatio > 0) {
contentWindow.scrollTo(0, iframeScroll);
contentWindow.document.documentElement.style.visibility =
'visible';
} else {
contentWindow.document.documentElement.style.visibility =
'hidden';
}
});
},
{
root: target.parentElement,
},
);
observer.observe(target);
// Have to remove he observer, otherwise we end up
// with conflicting values when a new page is loaded
// in the iframe.
contentWindow.addEventListener('unload', () => {
observer.unobserve(target);
});
// And this keeps track of the scroll position.
contentWindow.addEventListener('scroll', () => {
if (contentWindow.scrollY !== 0) {
// Record the current scroll position.
// If it is 0, it could be that the iframe
// has been hidden or the user scrolled
// there. So we have to ignore 0. But we
// don't want to be one pixel off if the
// user really did scroll there, so we
// assume that if the last scroll is 1, then
// the user probably went all the way to 0.
if (contentWindow.scrollY === 1) {
iframeScroll = 0;
} else {
iframeScroll = contentWindow.scrollY;
}
}
});
// Override browser default key bindings in iframe.
contentWindow.document.addEventListener('keydown', (e) => {
// use Ctrl-D/Cmd-D to toggle docs
if (
(isMacOS()
? e.metaKey && !e.ctrlKey
: e.ctrlKey && !e.metaKey) &&
!e.altKey &&
e.key == 'd'
) {
e.preventDefault();
dispatch(toggleBoolean(SettingId.ShowDocs));
}
});
if (document.body.classList.contains(Classes.DARK)) {
contentWindow.document.documentElement.classList.add(
Classes.DARK,
);
}
}}
src="static/docs/index.html"
allowFullScreen={true}
title="docs"
width="100%"
height="100%"
frameBorder="none"
/>
</div>
</SplitterLayout>
<StatusBar />
<SettingsDrawer />
+19 -1
View File
@@ -17,7 +17,9 @@ import { connect } from 'react-redux';
import { Action, Dispatch } from '../actions';
import { setEditSession, storageChanged } from '../actions/editor';
import { compile } from '../actions/mpy';
import { toggleBoolean } from '../actions/settings';
import { RootState } from '../reducers';
import { SettingId } from '../settings/user';
import { isMacOS } from '../utils/os';
import { EditorStringId } from './editor-i18n';
import en from './editor-i18n.en.json';
@@ -34,12 +36,14 @@ import './editor.scss';
type StateProps = {
darkMode: boolean;
showDocs: boolean;
};
type DispatchProps = {
onSessionChanged: (session?: Ace.EditSession) => void;
onProgramStorageChanged: (newValue: string) => void;
onCheck: (script: string) => void;
onToggleDocs: () => void;
};
type EditorProps = StateProps & DispatchProps & WithI18nProps;
@@ -78,7 +82,7 @@ class Editor extends React.Component<EditorProps> {
}
render(): JSX.Element {
const { darkMode, i18n, onSessionChanged, onCheck } = this.props;
const { i18n, darkMode, onSessionChanged, onCheck, onToggleDocs } = this.props;
return (
<div className="h-100">
<ResizeSensor onResize={(): void => this.editor?.resize()}>
@@ -105,6 +109,13 @@ class Editor extends React.Component<EditorProps> {
mac: 'Shift-F2',
};
// we want to use Ctrl-D for docs toggle, so change
// delete line to VSCode default
e.commands.byName['removeline'].bindKey = {
win: 'Ctrl-Shift-K',
mac: 'Cmd-Shift-K',
};
config.loadModule(
'ace/ext/menu_tools/get_editor_keyboard_shortcuts',
(m) => {
@@ -129,6 +140,11 @@ class Editor extends React.Component<EditorProps> {
bindKey: { win: 'F2', mac: 'F2' },
exec: (editor) => onCheck(editor.getValue()),
},
{
name: 'toggleDocs',
bindKey: { win: 'Ctrl-D', mac: 'Cmd-D' },
exec: () => onToggleDocs(),
},
{
name: 'save',
bindKey: { win: 'Ctrl-S', mac: 'Cmd-S' },
@@ -202,6 +218,7 @@ class Editor extends React.Component<EditorProps> {
const mapStateToProps = (state: RootState): StateProps => ({
darkMode: state.settings.darkMode,
showDocs: state.settings.showDocs,
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
@@ -210,6 +227,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
// REVISIT: the options here might need to be changed - hopefully there is
// one setting that works for all hub types for cases where we aren't connected.
onCheck: (script) => dispatch(compile(script)),
onToggleDocs: () => dispatch(toggleBoolean(SettingId.ShowDocs)),
});
export default connect(
+1
View File
@@ -114,6 +114,7 @@ class OpenFileButton extends React.Component<Props> {
? { pointerEvents: 'none' }
: undefined
}
onMouseDown={(e) => e.preventDefault()} // prevent focus
// onClick={this.props.onClick}
// breaks Dropzone when this.props.onClick is undefined
// so we have to do it the long way
+104 -9
View File
@@ -8,6 +8,9 @@ import {
Classes,
Drawer,
FormGroup,
Hotkey,
Hotkeys,
HotkeysTarget,
Position,
Switch,
Tooltip,
@@ -16,8 +19,14 @@ import { WithI18nProps, withI18n } from '@shopify/react-i18n';
import React from 'react';
import { connect } from 'react-redux';
import { Action, Dispatch } from '../actions';
import { closeSettings, openAboutDialog } from '../actions/app';
import { setBoolean } from '../actions/settings';
import {
checkForUpdate,
closeSettings,
installPrompt,
openAboutDialog,
reload,
} from '../actions/app';
import { setBoolean, toggleBoolean } from '../actions/settings';
import { RootState } from '../reducers';
import { pseudolocalize } from '../settings/i18n';
import {
@@ -28,6 +37,7 @@ import {
tooltipDelay,
} from '../settings/ui';
import { SettingId } from '../settings/user';
import { BeforeInstallPromptEvent } from '../utils/dom';
import { isMacOS } from '../utils/os';
import AboutDialog from './AboutDialog';
import ExternalLinkIcon from './ExternalLinkIcon';
@@ -39,6 +49,11 @@ type StateProps = {
showDocs: boolean;
darkMode: boolean;
flashCurrentProgram: boolean;
serviceWorker: ServiceWorkerRegistration | null;
checkingForUpdate: boolean;
updateAvailable: boolean;
beforeInstallPrompt: BeforeInstallPromptEvent | null;
promptingInstall: boolean;
};
type DispatchProps = {
@@ -47,23 +62,36 @@ type DispatchProps = {
onDarkModeChanged: (checked: boolean) => void;
onFlashCurrentProgramChanged: (checked: boolean) => void;
onAbout: () => void;
onToggleDocs: () => void;
onCheckForUpdate: (registration: ServiceWorkerRegistration) => void;
onReload: (registration: ServiceWorkerRegistration) => void;
onInstallPrompt: (event: BeforeInstallPromptEvent) => void;
};
type SettingsProps = StateProps & DispatchProps & WithI18nProps;
@HotkeysTarget
class SettingsDrawer extends React.PureComponent<SettingsProps> {
render(): JSX.Element {
const {
i18n,
open,
onClose,
showDocs,
onShowDocsChanged,
darkMode,
onDarkModeChanged,
serviceWorker,
flashCurrentProgram,
checkingForUpdate,
updateAvailable,
beforeInstallPrompt,
promptingInstall,
onClose,
onShowDocsChanged,
onDarkModeChanged,
onFlashCurrentProgramChanged,
onAbout,
onCheckForUpdate,
onReload,
onInstallPrompt: onInstall,
i18n,
} = this.props;
return (
<Drawer
@@ -195,6 +223,47 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
&nbsp;
<ExternalLinkIcon />
</AnchorButton>
<AboutDialog />
</ButtonGroup>
</FormGroup>
<FormGroup label={i18n.translate(SettingsStringId.AppTitle)}>
<ButtonGroup
minimal={true}
vertical={true}
alignText="left"
>
{beforeInstallPrompt && (
<Button
icon="add"
onClick={() => onInstall(beforeInstallPrompt)}
loading={promptingInstall}
>
{i18n.translate(
SettingsStringId.AppInstallLabel,
)}
</Button>
)}
{serviceWorker && !updateAvailable && (
<Button
icon="refresh"
onClick={() => onCheckForUpdate(serviceWorker)}
loading={checkingForUpdate}
>
{i18n.translate(
SettingsStringId.AppCheckForUpdateLabel,
)}
</Button>
)}
{serviceWorker && updateAvailable && (
<Button
icon="refresh"
onClick={() => onReload(serviceWorker)}
>
{i18n.translate(
SettingsStringId.AppRestartLabel,
)}
</Button>
)}
<Button
icon="info-sign"
onClick={() => {
@@ -202,16 +271,17 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
return true;
}}
>
{i18n.translate(SettingsStringId.HelpAboutLabel)}
{i18n.translate(SettingsStringId.AppAboutLabel)}
</Button>
<AboutDialog />
</ButtonGroup>
</FormGroup>
{process.env.NODE_ENV === 'development' && (
<FormGroup label="Developer">
<Switch
checked={i18n.pseudolocalize !== false}
onClick={() => pseudolocalize(!i18n.pseudolocalize)}
onChange={() =>
pseudolocalize(!i18n.pseudolocalize)
}
label="Pseudolocalize"
/>
</FormGroup>
@@ -221,6 +291,22 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
</Drawer>
);
}
renderHotkeys(): JSX.Element {
return (
<Hotkeys>
<Hotkey
combo="mod+d"
label={this.props.i18n.translate(
SettingsStringId.AppearanceDocumentationTooltip,
)}
global={true}
preventDefault={true}
onKeyDown={() => this.props.onToggleDocs()}
/>
</Hotkeys>
);
}
}
const mapStateToProps = (state: RootState): StateProps => ({
@@ -228,6 +314,11 @@ const mapStateToProps = (state: RootState): StateProps => ({
showDocs: state.settings.showDocs,
darkMode: state.settings.darkMode,
flashCurrentProgram: state.settings.flashCurrentProgram,
serviceWorker: state.app.serviceWorker,
checkingForUpdate: state.app.checkingForUpdate,
updateAvailable: state.app.updateAvailable,
beforeInstallPrompt: state.app.beforeInstallPrompt,
promptingInstall: state.app.promptingInstall,
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
@@ -239,6 +330,10 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
onFlashCurrentProgramChanged: (checked): Action =>
dispatch(setBoolean(SettingId.FlashCurrentProgram, checked)),
onAbout: (): Action => dispatch(openAboutDialog()),
onToggleDocs: (): Action => dispatch(toggleBoolean(SettingId.ShowDocs)),
onCheckForUpdate: (registration) => dispatch(checkForUpdate(registration)),
onReload: (registration) => dispatch(reload(registration)),
onInstallPrompt: (event) => dispatch(installPrompt(event)),
});
export default connect(
+7 -1
View File
@@ -5,7 +5,7 @@
@import '../variables.scss';
.#{$ns}-dark .splitter-layout > .layout-splitter {
.#{$ns}-dark .splitter-layout > .layout-splitter {
// make layout splitter match app color scheme
background-color: $pt-dark-app-background-color;
}
@@ -22,3 +22,9 @@
.terminal-padding {
padding-left: 10px;
}
// hide the docs and resize separator
div.pb-hide-docs > :not(.layout-pane-primary) {
display: none;
}
+3 -3
View File
@@ -24,13 +24,13 @@
// add "BETA" watermark
.pb-beta .ace_scroller::after {
content: "";
background: url("./images/beta.svg");
content: '';
background: url('./images/beta.svg');
opacity: 1;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: absolute;
pointer-events: none
pointer-events: none;
}
+4
View File
@@ -1,10 +1,14 @@
{
"copyErrorMessage": "Copy Error Message",
"reportBug": "Report Bug",
"app": {
"noUpdateFound": "{appName} is already up to date."
},
"ble": {
"gattPermission": "The web browser did not give permission to use Bluetooth Low Energy",
"gattServiceNotFound": "Connected to hub but failed to get {serviceName} service. Try removing the \"{hubName}\" device in your OS Bluetooth settings, then try again.",
"noWebBluetooth": "This web browser does not support Web Bluetooth or it is not enabled.",
"noBluetooth": "No Bluetooth adapter could be found. Bluetooth won't work.",
"unexpectedError": "Unexpected error while trying to connect: {errorMessage}"
},
"editor": {
+2
View File
@@ -4,12 +4,14 @@
// Notification translation keys.
export enum MessageId {
AppNoUpdateFound = 'app.noUpdateFound',
CopyErrorMessage = 'copyErrorMessage',
ReportBug = 'reportBug',
BleUnexpectedError = 'ble.unexpectedError',
BleGattPermission = 'ble.gattPermission',
BleGattServiceNotFound = 'ble.gattServiceNotFound',
BleNoWebBluetooth = 'ble.noWebBluetooth',
BleNoBluetooth = 'ble.noBluetooth',
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
FlashFirmwareBleError = 'flashFirmware.bleError',
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
+12
View File
@@ -35,6 +35,18 @@
},
"bugs": {
"label": "Bug Reports"
}
},
"app": {
"title": "App",
"install": {
"label": "Install as App"
},
"checkForUpdate": {
"label": "Check for Update"
},
"restart": {
"label": "Restart to Install Update"
},
"about": {
"label": "About"
+5 -1
View File
@@ -19,5 +19,9 @@ export enum SettingsStringId {
HelpSupportLabel = 'settings.help.support.label',
HelpChatLabel = 'settings.help.chat.label',
HelpBugsLabel = 'settings.help.bugs.label',
HelpAboutLabel = 'settings.help.about.label',
AppTitle = 'settings.app.title',
AppInstallLabel = 'settings.app.install.label',
AppCheckForUpdateLabel = 'settings.app.checkForUpdate.label',
AppRestartLabel = 'settings.app.restart.label',
AppAboutLabel = 'settings.app.about.label',
}
+4 -2
View File
@@ -3,7 +3,7 @@
@import './variables.scss';
@import '~normalize.css';
@import '@blueprintjs/core/src/blueprint.scss';
@import '~@blueprintjs/core/src/blueprint.scss';
:root {
--mobile-pad: 0px;
@@ -19,7 +19,9 @@ body {
.h-body {
// height makes everything fit without scrolling
height: calc(100vh - #{$pt-navbar-height} - #{$pb-status-bar-height} - var(--mobile-pad)) !important;
height: calc(
100vh - #{$pt-navbar-height} - #{$pb-status-bar-height} - var(--mobile-pad)
) !important;
}
.h-100 {
+12
View File
@@ -44,8 +44,20 @@ store.subscribe(() => {
if (newDarkMode !== oldDarkMode) {
if (newDarkMode) {
document.body.classList.add(Classes.DARK);
for (const frame of document.getElementsByTagName('iframe')) {
console.log('dark');
frame.contentWindow?.document.documentElement.classList.add(
Classes.DARK,
);
}
} else {
document.body.classList.remove(Classes.DARK);
for (const frame of document.getElementsByTagName('iframe')) {
console.log('light');
frame.contentWindow?.document.documentElement.classList.remove(
Classes.DARK,
);
}
}
oldDarkMode = newDarkMode;
}
+80 -1
View File
@@ -6,11 +6,18 @@
import { Reducer, combineReducers } from 'redux';
import { Action } from '../actions';
import { AppActionType } from '../actions/app';
import { ServiceWorkerActionType } from '../actions/service-worker';
import { BeforeInstallPromptEvent } from '../utils/dom';
export interface AppState {
readonly showSettings: boolean;
readonly showAboutDialog: boolean;
readonly showLicenseDialog: boolean;
readonly serviceWorker: ServiceWorkerRegistration | null;
readonly checkingForUpdate: boolean;
readonly updateAvailable: boolean;
readonly beforeInstallPrompt: BeforeInstallPromptEvent;
readonly promptingInstall: boolean;
}
const showSettings: Reducer<boolean, Action> = (state = false, action) => {
@@ -46,4 +53,76 @@ const showLicenseDialog: Reducer<boolean, Action> = (state = false, action) => {
}
};
export default combineReducers({ showSettings, showAboutDialog, showLicenseDialog });
const serviceWorker: Reducer<ServiceWorkerRegistration | null, Action> = (
state = null,
action,
) => {
switch (action.type) {
case ServiceWorkerActionType.DidSucceed:
return action.registration;
default:
return state;
}
};
const checkingForUpdate: Reducer<boolean, Action> = (state = false, action) => {
switch (action.type) {
case AppActionType.CheckForUpdate:
return true;
case AppActionType.DidCheckForUpdate:
if (!action.updateFound) {
return false;
}
// otherwise we wait for service worker to download everything
return state;
case ServiceWorkerActionType.DidUpdate:
return false;
default:
return state;
}
};
const updateAvailable: Reducer<boolean, Action> = (state = false, action) => {
switch (action.type) {
case ServiceWorkerActionType.DidUpdate:
return true;
default:
return state;
}
};
const beforeInstallPrompt: Reducer<BeforeInstallPromptEvent | null, Action> = (
state = null,
action,
) => {
switch (action.type) {
case AppActionType.DidBeforeInstallPrompt:
return action.event;
case AppActionType.DidInstall:
return null;
default:
return state;
}
};
const promptingInstall: Reducer<boolean, Action> = (state = false, action) => {
switch (action.type) {
case AppActionType.InstallPrompt:
return true;
case AppActionType.DidInstallPrompt:
return false;
default:
return state;
}
};
export default combineReducers({
showSettings,
showAboutDialog,
showLicenseDialog,
serviceWorker,
checkingForUpdate,
updateAvailable,
beforeInstallPrompt,
promptingInstall,
});
+74 -10
View File
@@ -2,9 +2,40 @@
// Copyright (c) 2021 The Pybricks Authors
import { AsyncSaga, delay } from '../../test';
import { reload } from '../actions/app';
import {
checkForUpdate,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstallPrompt,
installPrompt,
reload,
} from '../actions/app';
import { BeforeInstallPromptEvent } from '../utils/dom';
import app from './app';
test('monitorAppInstalled', async () => {
const saga = new AsyncSaga(app);
window.dispatchEvent(new Event('appinstalled'));
const action = await saga.take();
expect(action).toStrictEqual(didInstallPrompt());
await saga.end();
});
test('monitorBeforeInstallPrompt', async () => {
const saga = new AsyncSaga(app);
const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent;
window.dispatchEvent(event);
const action = await saga.take();
expect(action).toStrictEqual(didBeforeInstallPrompt(event));
await saga.end();
});
test('reload', async () => {
const saga = new AsyncSaga(app);
@@ -13,11 +44,6 @@ test('reload', async () => {
unregister: jest.fn(),
};
// @ts-expect-error: navigator.serviceWorker is not implemented in JSDOM
navigator.serviceWorker = {
getRegistrations: jest.fn().mockResolvedValue([registration]),
};
// @ts-expect-error: JSDOM implementation of location.reload() causes error
delete window.location;
// @ts-expect-error: JSDOM implementation of location.reload() causes error
@@ -25,13 +51,51 @@ test('reload', async () => {
reload: jest.fn(),
};
saga.put(reload());
// yield to allow generators to complete
await delay(0);
saga.put(reload(registration as ServiceWorkerRegistration));
expect(registration.unregister).toHaveBeenCalled();
expect(location.reload).toHaveBeenCalled();
await saga.end();
});
test('checkForUpdates', async () => {
const saga = new AsyncSaga(app);
// mock registration as if service worker was register on app startup
const registration: Partial<ServiceWorkerRegistration> = {
update: jest.fn(),
installing: null,
};
saga.put(checkForUpdate(registration as ServiceWorkerRegistration));
// yield to allow generators to complete
await delay(0);
expect(registration.update).toHaveBeenCalled();
const action = await saga.take();
expect(action).toStrictEqual(didCheckForUpdate(false));
await saga.end();
});
test('installPrompt', async () => {
const saga = new AsyncSaga(app);
// mock registration as if service worker was register on app startup
const event: Partial<BeforeInstallPromptEvent> = {
prompt: jest.fn(),
userChoice: Promise.resolve({ outcome: 'accepted', platform: 'web' }),
};
saga.put(installPrompt(event as BeforeInstallPromptEvent));
expect(event.prompt).toHaveBeenCalled();
const action = await saga.take();
expect(action).toStrictEqual(didInstallPrompt());
await saga.end();
});
+63 -8
View File
@@ -1,21 +1,76 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { call, takeEvery } from 'typed-redux-saga/macro';
import { AppActionType } from '../actions/app';
import { eventChannel } from 'redux-saga';
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import {
AppActionType,
AppCheckForUpdatesAction,
AppInstallPromptAction,
AppReloadAction,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstall,
didInstallPrompt,
} from '../actions/app';
import { BeforeInstallPromptEvent } from '../utils/dom';
function* reload(): Generator {
// unregister the service worker so that when the page reloads, it uses
// the new version
const registrations = yield* call(() => navigator.serviceWorker.getRegistrations());
function* monitorAppInstalled(): Generator {
const chan = eventChannel<Event>((emit) => {
const listener = (e: Event) => {
emit(e);
};
// chromium-only event
window.addEventListener('appinstalled', listener);
// istanbul ignore next: currently we don't ever stop monitoring
return () => window.removeEventListener('appinstalled', listener);
});
for (const r of registrations) {
yield* call(() => r.unregister());
while (true) {
yield* take(chan);
yield* put(didInstall());
}
}
function* monitorBeforeInstallPrompt(): Generator {
const chan = eventChannel<BeforeInstallPromptEvent>((emit) => {
const listener = (e: BeforeInstallPromptEvent) => {
emit(e);
};
// @ts-expect-error: chromium-only event
window.addEventListener('beforeinstallprompt', listener);
// istanbul ignore next: currently we don't ever stop monitoring
// @ts-expect-error: chromium-only event
return () => window.removeEventListener('beforeinstallprompt', listener);
});
while (true) {
const event = yield* take(chan);
yield* put(didBeforeInstallPrompt(event));
}
}
function* reload(action: AppReloadAction): Generator {
yield* call(() => action.registration.unregister());
location.reload();
}
function* checkForUpdate(action: AppCheckForUpdatesAction): Generator {
yield* call(() => action.registration.update());
const updateFound = action.registration.installing !== null;
yield* put(didCheckForUpdate(updateFound));
}
function* installPrompt(action: AppInstallPromptAction): Generator {
yield* call(() => action.event.prompt());
yield* call(() => action.event.userChoice);
yield* put(didInstallPrompt());
}
export default function* app(): Generator {
yield* fork(monitorAppInstalled);
yield* fork(monitorBeforeInstallPrompt);
yield* takeEvery(AppActionType.Reload, reload);
yield* takeEvery(AppActionType.CheckForUpdate, checkForUpdate);
yield* takeEvery(AppActionType.InstallPrompt, installPrompt);
}
+5 -1
View File
@@ -70,7 +70,11 @@ function* connect(_action: BleDeviceConnectAction): Generator {
return;
}
// TODO: check navigator.bluetooth.getAvailability()
const available = yield* call(() => navigator.bluetooth.getAvailability());
if (!available) {
yield* put(didFailToConnect({ reason: Reason.NoBluetooth }));
return;
}
let device: BluetoothDevice;
try {
+5 -1
View File
@@ -45,7 +45,11 @@ function* connect(_action: BootloaderConnectionAction): Generator {
return;
}
// TODO: check navigator.bluetooth.getAvailability()
const available = yield* call(() => navigator.bluetooth.getAvailability());
if (!available) {
yield* put(didFailToConnect(Reason.NoBluetooth));
return;
}
let device: BluetoothDevice;
try {
+5
View File
@@ -5,6 +5,7 @@ import { IToaster } from '@blueprintjs/core';
import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware';
import { AsyncSaga } from '../../test';
import { Action } from '../actions';
import { didCheckForUpdate } from '../actions/app';
import {
BleDeviceFailToConnectReasonType,
didFailToConnect as bleDidFailToConnect,
@@ -28,6 +29,7 @@ import notification from './notification';
test.each([
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoWebBluetooth }),
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoBluetooth }),
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoGatt }),
bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.NoService }),
bleDidFailToConnect({
@@ -38,6 +40,7 @@ test.each([
message: 'test',
}),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoWebBluetooth),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoBluetooth),
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound),
storageChanged('test'),
didFailToCompile(['reason']),
@@ -69,6 +72,7 @@ test.each([
didFailToFinish(FailToFinishReasonType.FailedToCompile),
didFailToFinish(FailToFinishReasonType.FirmwareSize),
didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')),
didCheckForUpdate(false),
])('actions that should show notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
@@ -98,6 +102,7 @@ test.each([
bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled),
didFailToFinish(FailToFinishReasonType.FailedToConnect),
didSucceed({} as ServiceWorkerRegistration),
didCheckForUpdate(true),
])('actions that should not show a notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
+33 -4
View File
@@ -14,7 +14,7 @@ import { Replacements } from '@shopify/react-i18n';
import React from 'react';
import { channel } from 'redux-saga';
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
import { reload } from '../actions/app';
import { AppActionType, AppDidCheckForUpdateAction, reload } from '../actions/app';
import {
BleDeviceActionType,
BleDeviceDidFailToConnectAction,
@@ -33,7 +33,10 @@ import {
} from '../actions/lwp3-bootloader';
import { MpyActionType, MpyDidFailToCompileAction } from '../actions/mpy';
import { NotificationActionType, NotificationAddAction } from '../actions/notification';
import { ServiceWorkerActionType } from '../actions/service-worker';
import {
ServiceWorkerAction,
ServiceWorkerActionType,
} from '../actions/service-worker';
import Notification from '../components/Notification';
import UnexpectedErrorNotification from '../components/UnexpectedErrorNotification';
import { MessageId } from '../components/notification-i18n';
@@ -171,6 +174,9 @@ function* showBleDeviceDidFailToConnectError(
hubName: 'Pybricks Hub',
});
break;
case BleDeviceFailToConnectReasonType.NoBluetooth:
yield* showSingleton(Level.Error, MessageId.BleNoBluetooth);
break;
case BleDeviceFailToConnectReasonType.NoWebBluetooth:
yield* showSingleton(
Level.Error,
@@ -207,6 +213,9 @@ function* showBootloaderDidFailToConnectError(
),
);
break;
case BootloaderConnectionFailureReason.NoBluetooth:
yield* showSingleton(Level.Error, MessageId.BleNoBluetooth);
break;
case BootloaderConnectionFailureReason.Unknown:
yield* showUnexpectedError(MessageId.BleUnexpectedError, action.err);
break;
@@ -317,7 +326,9 @@ function* addNotification(action: NotificationAddAction): Generator {
});
}
function* showServiceWorkerUpdate(): Generator {
function* showServiceWorkerUpdate(
updateAction: ServiceWorkerAction<ServiceWorkerActionType.DidUpdate>,
): Generator {
const ch = channel<React.MouseEvent<HTMLElement>>();
const action = dispatchAction(
MessageId.ServiceWorkerUpdateAction,
@@ -337,7 +348,24 @@ function* showServiceWorkerUpdate(): Generator {
yield* take(ch);
yield* put(reload());
yield* put(reload(updateAction.registration));
}
function* showNoUpdateInfo(action: AppDidCheckForUpdateAction): Generator {
if (action.updateFound) {
// this will be handled by ServiceWorkerActionType.DidUpdate action
return;
}
const { toaster } = yield* getContext<NotificationContext>('notification');
toaster.show({
intent: mapIntent(Level.Info),
icon: mapIcon(Level.Info),
message: React.createElement(Notification, {
messageId: MessageId.AppNoUpdateFound,
replacements: { appName },
}),
});
}
export default function* (): Generator {
@@ -355,4 +383,5 @@ export default function* (): Generator {
yield* takeEvery(MpyActionType.DidFailToCompile, showCompilerError);
yield* takeEvery(NotificationActionType.Add, addNotification);
yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate);
yield* takeEvery(AppActionType.DidCheckForUpdate, showNoUpdateInfo);
}
+27 -1
View File
@@ -5,7 +5,12 @@
import { AsyncSaga } from '../../test';
import { didStart } from '../actions/app';
import { didBooleanChange, didFailToSetBoolean, setBoolean } from '../actions/settings';
import {
didBooleanChange,
didFailToSetBoolean,
setBoolean,
toggleBoolean,
} from '../actions/settings';
import { SettingId } from '../settings/user';
import settings from './settings';
@@ -345,3 +350,24 @@ describe('storage monitor', () => {
await saga.end();
});
});
describe('toggle', () => {
test('showDocs', async () => {
const saga = new AsyncSaga(settings, { settings: { showDocs: false } });
const mockSetItem = jest
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
.mockImplementation((key, value) => {
expect(key).toBe('setting.showDocs');
expect(value).toBe('true');
});
saga.put(toggleBoolean(SettingId.ShowDocs));
expect(mockSetItem).toHaveBeenCalled();
const action = await saga.take();
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
await saga.end();
});
});
+8
View File
@@ -11,8 +11,10 @@ import { AppActionType } from '../actions/app';
import {
SettingsActionType,
SettingsSetBooleanAction,
SettingsToggleBooleanAction,
didBooleanChange,
didFailToSetBoolean,
setBoolean,
} from '../actions/settings';
import { RootState } from '../reducers';
import { SettingId, getDefaultBooleanValue } from '../settings/user';
@@ -97,8 +99,14 @@ function* storeSetting(action: SettingsSetBooleanAction): Generator {
}
}
function* toggleSetting(action: SettingsToggleBooleanAction): Generator {
const oldValue = yield* select((s: RootState) => s.settings[action.id]);
yield* storeSetting(setBoolean(action.id, !oldValue));
}
export default function* (): Generator {
yield* fork(monitorLocalStorage);
yield* takeEvery(AppActionType.DidStart, loadSettings);
yield* takeEvery(SettingsActionType.SetBoolean, storeSetting);
yield* takeEvery(SettingsActionType.ToggleBoolean, toggleSetting);
}
+7
View File
@@ -63,6 +63,13 @@ function registerValidSW(swUrl: string, config?: Config): void {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
if (registration.active) {
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Chromium-only API
// https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent
export interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
+1 -1
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
@import '@blueprintjs/core/lib/scss/variables.scss';
@import '~@blueprintjs/core/lib/scss/variables.scss';
// override variables here
// See https://blueprintjs.com/docs/#core/variables
+4 -4
View File
@@ -1462,10 +1462,10 @@
dependencies:
jszip "^3.5.0"
"@pybricks/ide-docs@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@pybricks/ide-docs/-/ide-docs-1.0.0.tgz#e93ff78c65730bbdd26b9deaf3110799d46de8e6"
integrity sha512-pe52fHG3S2QIx7RLX8+a+5oQo5A/svR2bxJWWImwZ2unAbPYmZ2G/25zgtYb9JF4Fec2f9mLT2zRiGqUjiCjiw==
"@pybricks/ide-docs@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@pybricks/ide-docs/-/ide-docs-1.1.0.tgz#15c46def964ad8e92805bc3746a1d418d145e5c4"
integrity sha512-NwEPoAnMSvNShtzXSSuf+zOf3COPzXb0DQiD7RV6p3edCPvcgZn53JZvZscXdyudAruOMHZciw/jW8X4dJee2g==
"@pybricks/mpy-cross-v5@^2.0.0":
version "2.0.0"