app: Improve split view behavior.

**Move activity buttons to main toolbar**
This was on a vscode-style sidebar because we originally thought we would add more. It turns out we will not. Having explorer/settings with the other buttons simplifies the UI.

This also makes the buttons not jump around when you open/close the explorer.

Also improves on small screens, where the explorer would obscure all buttons. Also lets us remove the blueprint hacks we had to make this work.

**Add toggles for sideviews.**
This lets us fix several UI/UX problems. It used to be easy to lose the terminal or docs forever. Now you can toggle them back on with a button and conveniently close them rather than dragging them down.

**Improve splitters**
Switch to react-resizable-panels which lets us control the split as needed. Also when dragging to near closed, it will snap to properly closed. Toggling on then restores normal size. Otherwise it preserves the split value.

The collapsed state of this library is made for hiding the docs without destroying them, so we don't need some of the hacks we had to preserve the terminal and docs state.
This commit is contained in:
Laurens Valk
2026-04-02 13:08:25 +02:00
parent ea9af98d1f
commit 51b0988dac
23 changed files with 721 additions and 435 deletions
+1 -2
View File
@@ -31,7 +31,6 @@
"@types/node": "^18.19.14",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@types/react-splitter-layout": "^3.0.5",
"@types/react-transition-group": "^4.4.10",
"@types/redux-logger": "^3.0.12",
"@types/semver": "^7.5.6",
@@ -99,7 +98,7 @@
"react-popper": "^2.3.0",
"react-redux": "^8.1.3",
"react-refresh": "^0.14.0",
"react-splitter-layout": "^4.0.0",
"react-resizable-panels": "^4.8.0",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-saga": "^1.3.0",
+19 -48
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022-2023 The Pybricks Authors
import { act, cleanup } from '@testing-library/react';
import { cleanup } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../test';
import Activities from './Activities';
@@ -15,66 +15,37 @@ afterEach(() => {
});
describe('Activities', () => {
it('should select explorer by default', () => {
it('should render explorer by default', () => {
const [, activities] = testRender(<Activities />);
const tab = activities.getByRole('tab', { name: 'File Explorer' });
expect(tab).toHaveAttribute('aria-selected', 'true');
expect(
activities.container.querySelector('.pb-activities-tabview'),
).toBeInTheDocument();
});
it('should use localStorage for default value', () => {
localStorage.setItem(
it('should render settings when Settings activity is selected', () => {
sessionStorage.setItem(
'activities.selectedActivity',
JSON.stringify(Activity.Settings),
);
const [, activities] = testRender(<Activities />);
const tab = activities.getByRole('tab', { name: 'Settings & Help' });
expect(tab).toHaveAttribute('aria-selected', 'true');
expect(
activities.container.querySelector('.pb-activities-tabview'),
).toBeInTheDocument();
});
it('should select none when clicking already selected tab', async () => {
const [user, activities] = testRender(<Activities />);
it('should render nothing when no activity is selected', () => {
sessionStorage.setItem(
'activities.selectedActivity',
JSON.stringify(Activity.None),
);
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
const [, activities] = testRender(<Activities />);
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute(
'aria-selected',
tab === explorerTab ? 'true' : 'false',
);
}
await act(() => user.click(explorerTab));
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute('aria-selected', 'false');
}
});
it('should select new tab when clicking not already selected tab', async () => {
const [user, activities] = testRender(<Activities />);
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
const settingsTab = activities.getByRole('tab', { name: 'Settings & Help' });
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute(
'aria-selected',
tab === explorerTab ? 'true' : 'false',
);
}
await act(() => user.click(settingsTab));
for (const tab of activities.getAllByRole('tab')) {
expect(tab).toHaveAttribute(
'aria-selected',
tab === settingsTab ? 'true' : 'false',
);
}
expect(
activities.container.querySelector('.pb-activities-tabview'),
).not.toBeInTheDocument();
});
});
+18 -128
View File
@@ -2,143 +2,33 @@
// Copyright (c) 2022-2023 The Pybricks Authors
import './activities.scss';
import { Icon, Tab, Tabs } from '@blueprintjs/core';
import { Cog, Document } from '@blueprintjs/icons';
import React, { useCallback, useEffect, useRef } from 'react';
import React from 'react';
import Explorer from '../explorer/Explorer';
import Settings from '../settings/Settings';
import { Activity, useActivitiesSelectedActivity } from './hooks';
import { useI18n } from './i18n';
/**
* React component that acts as a tab control to select activities.
* React component that renders the panel content for the selected activity.
*/
const Activities: React.FunctionComponent = () => {
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
const i18n = useI18n();
const [selectedActivity] = useActivitiesSelectedActivity();
if (selectedActivity === Activity.Explorer) {
return (
<div className="pb-activities-tabview">
<Explorer />
</div>
);
}
const handleAction = useCallback(
(newActivity: Activity) => {
// if activity is already selected, select none
if (selectedActivity === newActivity) {
setSelectedActivity(Activity.None);
} else {
// otherwise select the new activity
setSelectedActivity(newActivity);
}
},
[selectedActivity, setSelectedActivity],
);
if (selectedActivity === Activity.Settings) {
return (
<div className="pb-activities-tabview">
<Settings />
</div>
);
}
// HACK: fix keyboard focus when no tab is selected
const tabsRef = useRef<Tabs>(null);
useEffect(() => {
if (selectedActivity !== Activity.None) {
// all is well
return;
}
// @ts-expect-error: using private property
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
// istanbul-ignore-if: should not happen
if (!tablist) {
return;
}
const firstTab = tablist
.getElementsByClassName('pb-activities-tablist-tab')
.item(0);
// istanbul-ignore-if: should not happen
if (!firstTab) {
return;
}
firstTab.setAttribute('tabindex', '0');
}, [tabsRef, selectedActivity]);
// HACK: hoist html title attribute from icon to tab
useEffect(() => {
// @ts-expect-error: using private property
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
// istanbul-ignore-if: should not happen
if (!tablist) {
return;
}
for (const element of tablist.getElementsByClassName(
'pb-activities-tablist-tab',
)) {
const title = element.firstElementChild?.getAttribute('title');
// istanbul-ignore-if: should not happen
if (!title) {
continue;
}
element.setAttribute('title', title);
element.firstElementChild?.removeAttribute('title');
}
}, [tabsRef]);
useEffect(() => {
// @ts-expect-error: using private property
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
// istanbul-ignore-if: should not happen
if (!tablist) {
return;
}
tablist.setAttribute('aria-label', i18n.translate('title'));
}, [i18n]);
return (
<Tabs
vertical={true}
className="pb-activities"
selectedTabId={selectedActivity}
renderActiveTabPanelOnly={true}
onChange={handleAction}
ref={tabsRef}
>
<Tab
itemID="pb-activities-explorer-tab"
aria-label={i18n.translate('explorer')}
className="pb-activities-tablist-tab"
id={Activity.Explorer}
title={
<Icon
htmlTitle={i18n.translate('explorer')}
icon={<Document size={35} />}
/>
}
panel={<Explorer />}
panelClassName="pb-activities-tabview"
onMouseDown={(e) => e.stopPropagation()}
/>
<Tab
itemID="pb-activities-settings-tab"
aria-label={i18n.translate('settings')}
className="pb-activities-tablist-tab"
id={Activity.Settings}
title={
<Icon
htmlTitle={i18n.translate('settings')}
icon={<Cog size={35} />}
/>
}
panel={<Settings />}
panelClassName="pb-activities-tabview"
onMouseDown={(e) => e.stopPropagation()}
/>
</Tabs>
);
return null;
};
export default Activities;
+14 -37
View File
@@ -1,45 +1,22 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// Copyright (c) 2022-2026 The Pybricks Authors
@use '@blueprintjs/core/lib/scss/variables' as bp;
@use '../variables' as pb;
// override bluetprintjs styles
.#{bp.$ns}-tabs.#{bp.$ns}-vertical > {
& .#{bp.$ns}-tab-list .pb-activities-tablist-tab {
margin: bp.$pt-grid-size * 0.6;
padding: unset;
width: unset;
line-height: unset;
}
.pb-activities-tabview {
display: flex;
flex-direction: column;
width: bp.$pt-grid-size * 25;
padding: bp.$pt-grid-size;
@include pb.background-contrast(0%);
overflow-y: auto;
height: 100%;
& .pb-activities-tabview {
padding: bp.$pt-grid-size * 0.5;
}
}
.pb-activities {
// TODO: submit upstream patch to allow setting tablist class
&-tablist,
& .#{bp.$ns}-tab-list {
@include pb.background-contrast(6%);
}
&-tabview {
display: flex;
flex-direction: column;
width: bp.$pt-grid-size * 25;
padding: bp.$pt-grid-size;
@include pb.background-contrast(0%);
// on small screens, make the activity view an overlay instead of inline
@media screen and (max-width: pb.$narrow-screen-limit) {
position: absolute;
top: 0px;
// FIXME: this should be a variable
left: 47px;
height: calc(100% - pb.$status-bar-height);
z-index: bp.$pt-z-index-overlay;
}
// on small screens, make the activity view an overlay instead of inline
@media screen and (max-width: pb.$narrow-screen-limit) {
position: absolute;
height: calc(100% - pb.$status-bar-height);
z-index: bp.$pt-z-index-overlay;
}
}
+4 -3
View File
@@ -42,9 +42,10 @@ it.each([false, true])('should render', (darkMode) => {
});
describe('documentation pane', () => {
it('should hide by default', () => {
it('should be collapsed by default', () => {
testRender(<App />);
expect(document.querySelector('.pb-show-docs')).toBeNull();
expect(document.querySelector('.pb-hide-docs')).not.toBeNull();
// The docs panel starts collapsed (collapsedSize="0%")
const docsPanel = document.querySelector('[data-panel][id="docs"]');
expect(docsPanel).not.toBeNull();
});
});
+128 -74
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2026 The Pybricks Authors
import 'react-splitter-layout/lib/index.css';
import './app.scss';
import { Button, Classes, Spinner } from '@blueprintjs/core';
import { Manual } from '@blueprintjs/icons';
import React, { useEffect, useState } from 'react';
type SideView = 'off' | 'docs';
import SplitterLayout from 'react-splitter-layout';
import { useLocalStorage, useTernaryDarkMode } from 'usehooks-ts';
import { Console, Manual } from '@blueprintjs/icons';
import React, { useEffect } from 'react';
import {
Panel,
Group as PanelGroup,
Separator as PanelResizeHandle,
} from 'react-resizable-panels';
import { useTernaryDarkMode } from 'usehooks-ts';
import Activities from '../activities/Activities';
import DfuWindowsDriverInstallDialog from '../firmware/dfuWindowsDriverInstallDialog/DfuWindowsDriverInstallDialog';
import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog';
@@ -19,6 +20,7 @@ import StatusBar from '../status-bar/StatusBar';
import Toolbar from '../toolbar/Toolbar';
import Tour from '../tour/Tour';
import { docsDefaultPage } from './constants';
import { useCollapsiblePanel } from './hooks';
import { useI18n } from './i18n';
const Editor = React.lazy(async () => {
@@ -73,11 +75,9 @@ const Docs: React.FunctionComponent = () => {
const App: React.FunctionComponent = () => {
const i18n = useI18n();
const { isDarkMode } = useTernaryDarkMode();
const [sideView, setSideView] = useState<SideView>('off');
const [isDragging, setIsDragging] = useState(false);
const [docsSplit, setDocsSplit] = useLocalStorage('app-docs-split', 30);
const [terminalSplit, setTerminalSplit] = useLocalStorage('app-terminal-split', 30);
const docs = useCollapsiblePanel(false, 30);
const terminal = useCollapsiblePanel(true, 20);
// Classes.DARK has to be applied to body element, otherwise it won't
// affect portals
@@ -105,80 +105,134 @@ const App: React.FunctionComponent = () => {
return () => removeEventListener('keydown', listener);
}, []);
const sideViewButtons = (
<div className="pb-app-side-view-buttons">
<Button
large
intent="primary"
icon={<Console />}
title={
terminal.visible
? i18n.translate('terminal.hide')
: i18n.translate('terminal.show')
}
onClick={() => terminal.setVisible(!terminal.visible)}
/>
<Button
large
intent="primary"
icon={<Manual />}
title={
docs.visible
? i18n.translate('docs.hide')
: i18n.translate('docs.show')
}
onClick={() => docs.setVisible(!docs.visible)}
/>
</div>
);
return (
<div className="pb-app" onContextMenu={(e) => e.preventDefault()}>
<div className="pb-app-body">
<aside
className="pb-app-activities"
aria-label={i18n.translate('landmark.activities')}
<PanelGroup
orientation="horizontal"
resizeTargetMinimumSize={{ coarse: 37, fine: 26 }}
>
<Activities />
</aside>
{/* need a container with position: relative; for SplitterLayout since it uses position: absolute; */}
<div className="pb-app-main" style={{ position: 'relative' }}>
<SplitterLayout
customClassName={
sideView === 'docs' ? 'pb-show-docs' : 'pb-hide-docs'
<Panel id="main-app">
<div className="pb-app-main">
<Toolbar />
<div className="pb-app-body-inner">
<aside
className="pb-app-activities"
aria-label={i18n.translate('landmark.activities')}
>
<Activities />
</aside>
<PanelGroup
orientation="vertical"
resizeTargetMinimumSize={{ coarse: 37, fine: 26 }}
>
<Panel>
<div className="pb-app-editor-area">
<PanelGroup orientation="horizontal">
<Panel>
<main
className="pb-app-editor"
aria-label={i18n.translate(
'landmark.editor',
)}
>
<React.Suspense
fallback={
<Spinner className="pb-editor" />
}
>
<Editor />
</React.Suspense>
</main>
</Panel>
</PanelGroup>
{sideViewButtons}
</div>
</Panel>
<PanelResizeHandle
disabled={!terminal.visible}
className={
terminal.visible
? 'pb-splitter pb-splitter--horizontal'
: undefined
}
/>
<Panel
id="terminal"
collapsible
collapsedSize="0%"
minSize="10%"
panelRef={terminal.panelRef}
onResize={terminal.onResize}
>
<aside
className="pb-app-terminal"
aria-label={i18n.translate(
'landmark.terminal',
)}
>
<React.Suspense
fallback={<Spinner className="h-100" />}
>
<Terminal />
</React.Suspense>
</aside>
</Panel>
</PanelGroup>
</div>
</div>
</Panel>
<PanelResizeHandle
disabled={!docs.visible}
className={
docs.visible
? 'pb-splitter pb-splitter--vertical'
: undefined
}
onDragStart={(): void => setIsDragging(true)}
onDragEnd={(): void => setIsDragging(false)}
percentage={true}
secondaryInitialSize={docsSplit}
onSecondaryPaneSizeChange={setDocsSplit}
/>
<Panel
id="docs"
collapsible
collapsedSize="0%"
minSize="10%"
panelRef={docs.panelRef}
onResize={docs.onResize}
>
<SplitterLayout
vertical={true}
percentage={true}
secondaryInitialSize={terminalSplit}
onSecondaryPaneSizeChange={setTerminalSplit}
>
<main
className="pb-app-editor"
aria-label={i18n.translate('landmark.editor')}
>
<Toolbar />
<React.Suspense
fallback={<Spinner className="pb-editor" />}
>
<Editor />
</React.Suspense>
<Button
className="pb-app-doc-button"
minimal
large
icon={<Manual />}
title={
sideView === 'docs'
? i18n.translate('docs.hide')
: i18n.translate('docs.show')
}
onClick={() =>
setSideView(
sideView === 'docs' ? 'off' : 'docs',
)
}
/>
</main>
<aside
className="pb-app-terminal"
aria-label={i18n.translate('landmark.terminal')}
>
<React.Suspense
fallback={<Spinner className="h-100" />}
>
<Terminal />
</React.Suspense>
</aside>
</SplitterLayout>
<aside
className="pb-app-docs"
className="pb-app-side-panel"
aria-label={i18n.translate('landmark.documentation')}
>
{isDragging && <div className="pb-app-docs-drag-helper" />}
<Docs />
</aside>
</SplitterLayout>
</div>
</Panel>
</PanelGroup>
</div>
<StatusBar />
<Tour />
+149 -119
View File
@@ -12,194 +12,224 @@
@import '@blueprintjs/icons/lib/css/blueprint-icons.css';
// Root app container: fills the viewport, stacks toolbar/body/statusbar vertically.
.pb-app {
width: 100%;
height: 100%;
background-color: bp.$pt-app-background-color;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: bp.$pt-app-background-color;
.#{bp.$ns}-dark & {
background-color: bp.$pt-dark-app-background-color;
}
&-activities {
display: flex;
}
&-terminal {
height: 100%;
padding-top: 8px;
padding-left: 8px;
background-color: white;
.#{bp.$ns}-dark & {
background-color: black;
}
}
&-docs {
height: 100%;
&-drag-helper {
height: 100%;
width: 100%;
position: absolute;
}
}
& iframe {
border: none;
}
}
// Row containing the activities sidebar and the main content area.
.pb-app-body {
flex: 1 1 0;
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: row;
.pb-app-main {
min-width: 0;
flex: 1 1 auto;
@include pb.background-contrast(4%);
}
overflow: hidden;
}
.pb-app-editor {
// Thin activities sidebar on the left, shown when an activity is selected.
.pb-app-activities {
flex: none;
display: flex;
flex-direction: column;
overflow: hidden;
@include pb.background-contrast(0%);
}
// Row containing the activities panel and the resizable editor/terminal panels.
.pb-app-body-inner {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
// Main content: toolbar on top, resizable panels below. Fills its Panel.
.pb-app-main {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
@include pb.background-contrast(4%);
}
& .pb-editor {
min-height: 0;
flex: 1 1 auto;
// Wraps the horizontal editor+camera PanelGroup. position: relative anchors
// the absolutely-positioned side-view buttons to this container instead of
// the editor panel, so they stay visible even when the camera is expanded.
.pb-app-editor-area {
position: relative;
width: 100%;
height: 100%;
}
// The editor <main> element lives inside a resizable Panel - fill it entirely.
.pb-app-editor {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
// Terminal panel fills its resizable panel.
.pb-app-terminal {
width: 100%;
height: 100%;
padding-top: 8px;
padding-left: 8px;
box-sizing: border-box;
overflow: hidden;
background-color: white;
.#{bp.$ns}-dark & {
background-color: black;
}
}
.pb-app-doc-button {
// Side panel fills its resizable panel.
.pb-app-side-panel {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.pb-app-side-view-buttons {
position: absolute;
bottom: bp.$pt-grid-size;
right: bp.$pt-grid-size;
z-index: bp.$pt-z-index-overlay;
z-index: bp.$pt-z-index-content;
display: flex;
flex-direction: row;
gap: bp.$pt-grid-size * 0.5;
// match toolbar button appearance: no box-shadow border or active-state shadow
& * {
box-shadow: unset !important;
}
}
// Splitter color variables
$splitter-background-color: bp.$light-gray2;
$splitter-background-color-hover: bp.$gray4;
$dark-splitter-background-color: bp.$dark-gray5;
$dark-splitter-background-color-hover: bp.$gray2;
$splitter-constrast: -15%;
$dark-splitter-constrast: 20%;
$splitter-contrast: -15%;
$dark-splitter-contrast: 20%;
$splitter-color: color.adjust(
$splitter-background-color,
$lightness: $splitter-constrast
$lightness: $splitter-contrast
);
$splitter-color-hover: color.adjust(
$splitter-background-color-hover,
$lightness: $splitter-constrast
$lightness: $splitter-contrast
);
$dark-splitter-color: color.adjust(
$dark-splitter-background-color,
$lightness: $dark-splitter-constrast
$lightness: $dark-splitter-contrast
);
$dark-splitter-color-hover: color.adjust(
$dark-splitter-background-color-hover,
$lightness: $dark-splitter-constrast
$lightness: $dark-splitter-contrast
);
.layout-splitter:before {
$width: 1.5;
$height: 2.7;
$radius: 0.4;
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') / '';
height: bp.$pt-grid-size * $height;
width: bp.$pt-grid-size * $width;
top: calc(50% - bp.$pt-grid-size * $height * 0.5);
left: bp.$pt-grid-size * $width * -0.5 + 2px;
.splitter-layout-vertical & {
$width: 2.7;
$height: 1.4;
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') / '';
height: bp.$pt-grid-size * $height;
width: bp.$pt-grid-size * $width;
top: bp.$pt-grid-size * $height * -0.5 + 2px;
left: calc(50% - bp.$pt-grid-size * $width * 0.5);
}
font-family: bpi.$blueprint-icons-16-font;
font-size: large;
// Panel resize handles (splitters).
.pb-splitter {
display: flex;
position: relative;
border-radius: bp.$pt-grid-size * $radius;
padding: bp.$pt-grid-size * 0.5;
align-items: center;
justify-content: center;
z-index: bp.$pt-z-index-content;
color: $splitter-color;
background-color: $splitter-background-color;
.#{bp.$ns}-dark & {
color: $dark-splitter-color;
background-color: $dark-splitter-background-color;
}
}
// make layout splitter match app color scheme
.splitter-layout > .layout-splitter {
color: $splitter-color;
background-color: $splitter-background-color;
&:hover,
&:hover:before {
color: $splitter-color-hover;
&:hover {
background-color: $splitter-background-color-hover;
}
.#{bp.$ns}-dark & {
color: $dark-splitter-color;
background-color: $dark-splitter-background-color;
&:hover,
&:hover:before {
&:hover {
background-color: $dark-splitter-background-color-hover;
}
}
// Drag-handle icon via ::before pseudo-element.
&::before {
$radius: 0.4;
font-family: bpi.$blueprint-icons-16-font;
font-size: large;
display: flex;
align-items: center;
justify-content: center;
border-radius: bp.$pt-grid-size * $radius;
padding: bp.$pt-grid-size * 0.5;
z-index: bp.$pt-z-index-content;
color: $splitter-color;
background-color: $splitter-background-color;
.#{bp.$ns}-dark & {
color: $dark-splitter-color;
background-color: $dark-splitter-background-color;
}
}
&:hover::before {
color: $splitter-color-hover;
background-color: $splitter-background-color-hover;
.#{bp.$ns}-dark & {
color: $dark-splitter-color-hover;
background-color: $dark-splitter-background-color-hover;
}
}
}
// we want overflow visible for editor overlays but it breaks resizing
// so we can only apply this when not resizing
.splitter-layout:not(.layout-changing) > .layout-pane:has(.pb-app-editor) {
overflow: visible;
}
// Vertical splitter (between left/right panels): thin column, vertical drag icon.
&--vertical {
width: 4px;
cursor: col-resize;
.splitter-layout .layout-pane {
// fix double scroll bars in terminal when resizing
overflow: hidden;
&::before {
$width: 1.5;
$height: 2.7;
&.layout-pane-primary {
// fix resizing window vertically
min-height: 0;
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') /
'';
width: bp.$pt-grid-size * $width;
height: bp.$pt-grid-size * $height;
}
}
// Horizontal splitter (between top/bottom panels): thin row, horizontal drag icon.
&--horizontal {
height: 4px;
cursor: row-resize;
&::before {
$width: 2.7;
$height: 1.4;
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') /
'';
width: bp.$pt-grid-size * $width;
height: bp.$pt-grid-size * $height;
}
}
}
// hide the docs and resize separator
// Use visibility rather than display:none so the iframe keeps its layout and
// scroll position when the docs panel is toggled.
// Collapse the secondary pane and splitter to zero width so they don't
// consume space, while keeping the iframe in the DOM.
div.pb-hide-docs > :not(.layout-pane-primary) {
visibility: hidden;
pointer-events: none;
flex-basis: 0 !important;
width: 0 !important;
min-width: 0 !important;
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 The Pybricks Authors
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PanelSize, usePanelRef } from 'react-resizable-panels';
/**
* Hook to manage a collapsible panel's size as a percentage (0100).
*
* This bridges React's declarative state with the library's imperative
* collapse/expand/resize API:
* - `size` tracks the panel's percentage when visible.
* - `visible` controls whether the panel is collapsed or expanded.
* - Setting `visible` to true restores the previous `size`.
* - Setting `size` also updates `visible` accordingly.
* - The `onResize` callback syncs both states when the user drags.
*
* The panel should set `collapsible`, `collapsedSize="0%"`, and a `minSize`
* so that dragging below the minimum auto-collapses.
*/
export function useCollapsiblePanel(initiallyVisble: boolean, initialSize: number) {
const panelRef = usePanelRef();
const [size, _setSize] = useState(initialSize);
const [visible, _setVisible] = useState(initiallyVisble);
const setSize = useCallback((s: number) => {
_setSize(s);
_setVisible(s > 0);
}, []);
const setVisible = useCallback((v: boolean) => _setVisible(v), []);
useEffect(() => {
const panel = panelRef.current;
if (!panel) {
return;
}
if (!visible) {
if (!panel.isCollapsed()) {
panel.collapse();
}
} else {
if (panel.isCollapsed()) {
panel.expand();
}
if (panel.getSize().asPercentage !== size) {
panel.resize(size + '%');
}
}
}, [size, visible, panelRef]);
const onResize = useCallback(
(panelSize: PanelSize) => {
const collapsed = panelRef.current?.isCollapsed() ?? false;
if (collapsed) {
// Only update state if the user dragged to collapse
// (visible is still true). If we collapsed programmatically,
// visible is already false and size should be preserved.
if (visible) {
_setVisible(false);
// Reset to initial size so reopening looks normal
// rather than tiny.
_setSize(initialSize);
}
} else {
_setVisible(true);
_setSize(panelSize.asPercentage);
}
},
[panelRef, initialSize, visible],
);
return useMemo(
() => ({ panelRef, size, setSize, visible, setVisible, onResize }),
[panelRef, size, setSize, visible, setVisible, onResize],
);
}
+4
View File
@@ -8,5 +8,9 @@
"docs": {
"show": "Show documentation",
"hide": "Hide documentation"
},
"terminal": {
"show": "Show terminal",
"hide": "Hide terminal"
}
}
+10 -6
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2023 The Pybricks Authors
// Copyright (c) 2020-2026 The Pybricks Authors
// Custom styling for the Editor control.
@@ -21,8 +21,12 @@
}
.pb-editor {
flex: 1 1 0;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
&-tablist {
flex: none;
@@ -58,9 +62,12 @@
}
}
// The ContextMenu/tabpanel fills all remaining vertical space.
&-tabpanel {
flex: 1 1 0;
min-height: 0;
flex: 1 1 auto;
position: relative;
overflow: hidden;
}
&-welcome {
@@ -73,13 +80,10 @@
}
}
// The monaco editor div fills the tabpanel entirely.
&-monaco {
width: 100%;
height: 100%;
.pb-editor-tabpanel.pb-empty > & {
display: none;
}
}
&-placeholder {
+37
View File
@@ -38,6 +38,43 @@ if (!Element.prototype.scrollTo) {
Element.prototype.scrollTo = jest.fn();
}
// ResizeObserver is not implemented in jsdom but is required by react-resizable-panels
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// DOMRect is not implemented in jsdom but is required by react-resizable-panels
if (typeof global.DOMRect === 'undefined') {
global.DOMRect = class DOMRect {
x: number;
y: number;
width: number;
height: number;
top: number;
right: number;
bottom: number;
left: number;
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.top = y;
this.right = x + width;
this.bottom = y + height;
this.left = x;
}
toJSON() {
return JSON.stringify(this);
}
static fromRect(rect?: DOMRectInit): DOMRect {
return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
}
} as unknown as typeof DOMRect;
}
Object.defineProperty(global.self, 'crypto', {
value: crypto.webcrypto,
});
+7 -1
View File
@@ -1,13 +1,15 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2025 The Pybricks Authors
// Copyright (c) 2020-2026 The Pybricks Authors
import { ButtonGroup } from '@blueprintjs/core';
import React from 'react';
import { useId } from 'react-aria';
import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar';
import BluetoothButton from './buttons/bluetooth/BluetoothButton';
import ExplorerButton from './buttons/explorer/ExplorerButton';
import ReplButton from './buttons/repl/ReplButton';
import RunButton from './buttons/run/RunButton';
import SettingsButton from './buttons/settings/SettingsButton';
import SponsorButton from './buttons/sponsor/SponsorButton';
import StopButton from './buttons/stop/StopButton';
import UsbButton from './buttons/usb/UsbButton';
@@ -32,6 +34,10 @@ const Toolbar: React.FunctionComponent = () => {
className="pb-toolbar"
firstFocusableItemId={bluetoothButtonId}
>
<ButtonGroup className="pb-toolbar-group pb-align-left">
<ExplorerButton id="pb-toolbar-explorer-button" />
<SettingsButton id="pb-toolbar-settings-button" />
</ButtonGroup>
<ButtonGroup className="pb-toolbar-group pb-align-left">
<UsbButton id={usbButtonId} />
<BluetoothButton id={bluetoothButtonId} />
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { act, cleanup } from '@testing-library/react';
import React from 'react';
import { FocusScope } from 'react-aria';
import { testRender } from '../../../../test';
import { Activity } from '../../../activities/hooks';
import ExplorerButton from './ExplorerButton';
afterEach(() => {
cleanup();
jest.resetAllMocks();
localStorage.clear();
sessionStorage.clear();
});
const explorerButtonId = 'test-explorer-button';
it('should open explorer activity when clicked', async () => {
sessionStorage.setItem(
'activities.selectedActivity',
JSON.stringify(Activity.None),
);
const [user, button] = testRender(
<FocusScope>
<ExplorerButton id={explorerButtonId} />
</FocusScope>,
);
await act(() => user.click(button.getByRole('button', { name: 'File Explorer' })));
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
JSON.stringify(Activity.Explorer),
);
});
it('should close explorer activity when clicked while already active', async () => {
sessionStorage.setItem(
'activities.selectedActivity',
JSON.stringify(Activity.Explorer),
);
const [user, button] = testRender(
<FocusScope>
<ExplorerButton id={explorerButtonId} />
</FocusScope>,
);
await act(() => user.click(button.getByRole('button', { name: 'File Explorer' })));
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
JSON.stringify(Activity.None),
);
});
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 The Pybricks Authors
import React from 'react';
import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks';
import ActionButton, { ActionButtonProps } from '../../ActionButton';
import { useI18n } from './i18n';
import icon from './icon.svg';
type ExplorerButtonProps = Pick<ActionButtonProps, 'id'>;
const ExplorerButton: React.FunctionComponent<ExplorerButtonProps> = ({ id }) => {
const i18n = useI18n();
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
return (
<ActionButton
id={id}
label={i18n.translate('label')}
tooltip={i18n.translate(
selectedActivity === Activity.Explorer
? 'tooltip.hide'
: 'tooltip.show',
)}
icon={icon}
onAction={() =>
setSelectedActivity(
selectedActivity === Activity.Explorer
? Activity.None
: Activity.Explorer,
)
}
/>
);
};
export default ExplorerButton;
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
import type { TypedI18n } from '../../../i18n';
type Translations = {
label: string;
tooltip: {
show: string;
hide: string;
};
};
export function useI18n(): TypedI18n<Translations> {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg">
<path d="m6.0542 2.1166c-0.71568 0-1.2917 0.55109-1.2917 1.2353v19.931h16.933v-14.679l-6.7462-6.4875zm0.92953 2.2985h6.8636v5.3885h5.6001v11.769h-12.464v-11.769z" fill="#fff" fill-opacity=".98215" stop-color="#000000" stroke-linecap="square" stroke-width="5.9999"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

@@ -0,0 +1,7 @@
{
"label": "File Explorer",
"tooltip": {
"show": "Show file explorer",
"hide": "Hide file explorer"
}
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { act, cleanup } from '@testing-library/react';
import React from 'react';
import { FocusScope } from 'react-aria';
import { testRender } from '../../../../test';
import { Activity } from '../../../activities/hooks';
import SettingsButton from './SettingsButton';
afterEach(() => {
cleanup();
jest.resetAllMocks();
localStorage.clear();
sessionStorage.clear();
});
const settingsButtonId = 'test-settings-button';
it('should open settings activity when clicked', async () => {
sessionStorage.setItem(
'activities.selectedActivity',
JSON.stringify(Activity.None),
);
const [user, button] = testRender(
<FocusScope>
<SettingsButton id={settingsButtonId} />
</FocusScope>,
);
await act(() => user.click(button.getByRole('button', { name: 'Settings' })));
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
JSON.stringify(Activity.Settings),
);
});
it('should close settings activity when clicked while already active', async () => {
sessionStorage.setItem(
'activities.selectedActivity',
JSON.stringify(Activity.Settings),
);
const [user, button] = testRender(
<FocusScope>
<SettingsButton id={settingsButtonId} />
</FocusScope>,
);
await act(() => user.click(button.getByRole('button', { name: 'Settings' })));
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
JSON.stringify(Activity.None),
);
});
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 The Pybricks Authors
import React from 'react';
import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks';
import ActionButton, { ActionButtonProps } from '../../ActionButton';
import { useI18n } from './i18n';
import icon from './icon.svg';
type SettingsButtonProps = Pick<ActionButtonProps, 'id'>;
const SettingsButton: React.FunctionComponent<SettingsButtonProps> = ({ id }) => {
const i18n = useI18n();
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
return (
<ActionButton
id={id}
label={i18n.translate('label')}
tooltip={i18n.translate(
selectedActivity === Activity.Settings
? 'tooltip.hide'
: 'tooltip.show',
)}
icon={icon}
onAction={() =>
setSelectedActivity(
selectedActivity === Activity.Settings
? Activity.None
: Activity.Settings,
)
}
/>
);
};
export default SettingsButton;
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 The Pybricks Authors
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
import type { TypedI18n } from '../../../i18n';
type Translations = {
label: string;
tooltip: {
show: string;
hide: string;
};
};
export function useI18n(): TypedI18n<Translations> {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg">
<path d="m12.061 3.175c-0.52433 0-0.94647 0.42215-0.94647 0.94649v2.1893a7.1944 7.1946 0 0 0-1.3064 0.50605l-1.5234-1.5234c-0.37076-0.37076-0.96782-0.37076-1.3386 0l-1.6522 1.6526c-0.37076 0.37076-0.37076 0.96784 0 1.3386l1.4626 1.4626a7.1944 7.1946 0 0 0-0.58294 1.3672h-2.052c-0.52433 0-0.94647 0.42175-0.94647 0.94608v2.337c0 0.52434 0.42214 0.94649 0.94647 0.94649h2.0503a7.1944 7.1946 0 0 0 0.58253 1.3688l-1.4606 1.461c-0.37076 0.37076-0.37076 0.96785 0 1.3386l1.6522 1.6522c0.37076 0.37076 0.96782 0.37076 1.3386 0l1.5197-1.5198a7.1944 7.1946 0 0 0 1.31 0.50767v2.184c0 0.52434 0.42214 0.94649 0.94647 0.94648h2.3366c0.52433 0 0.94647-0.42214 0.94647-0.94648v-2.2919a7.1944 7.1946 0 0 0 1.1848-0.52498l1.6449 1.645c0.37076 0.37076 0.96782 0.37076 1.3386 0l1.6522-1.6522c0.37076-0.37076 0.37076-0.96784 0-1.3386l-1.7057-1.7058a7.1944 7.1946 0 0 0 0.45733-1.124h2.4207c0.52433 0 0.94647-0.42215 0.94647-0.94649v-2.337c0-0.52433-0.42214-0.94608-0.94647-0.94608h-2.4179a7.1944 7.1946 0 0 0-0.45492-1.1293l1.7005-1.7005c0.37076-0.37076 0.37076-0.96784 0-1.3386l-1.6522-1.6526c-0.37076-0.37076-0.96782-0.37076-1.3386 0l-1.6445 1.6446a7.1944 7.1946 0 0 0-1.1852-0.52296v-2.2935c0-0.52434-0.42214-0.94649-0.94647-0.94649zm1.1683 5.8279a4.226 4.226 0 0 1 4.2259 4.226 4.226 4.226 0 0 1-4.2259 4.2264 4.226 4.226 0 0 1-4.2259-4.2264 4.226 4.226 0 0 1 4.2259-4.226z" fill="#fff" stop-color="#000000" stroke-linecap="square" stroke-width="5.9999"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,7 @@
{
"label": "Settings",
"tooltip": {
"show": "Show settings and help",
"hide": "Hide settings and help"
}
}
+7 -17
View File
@@ -2994,7 +2994,6 @@ __metadata:
"@types/node": ^18.19.14
"@types/react": ^18.2.47
"@types/react-dom": ^18.2.18
"@types/react-splitter-layout": ^3.0.5
"@types/react-transition-group": ^4.4.10
"@types/redux-logger": ^3.0.12
"@types/semver": ^7.5.6
@@ -3075,7 +3074,7 @@ __metadata:
react-popper: ^2.3.0
react-redux: ^8.1.3
react-refresh: ^0.14.0
react-splitter-layout: ^4.0.0
react-resizable-panels: ^4.8.0
redux: ^4.2.1
redux-logger: ^3.0.6
redux-saga: ^1.3.0
@@ -5341,15 +5340,6 @@ __metadata:
languageName: node
linkType: hard
"@types/react-splitter-layout@npm:^3.0.5":
version: 3.0.5
resolution: "@types/react-splitter-layout@npm:3.0.5"
dependencies:
"@types/react": "*"
checksum: 9c8e9cc7ac568a3f5c7a1237725ce084d4c0747f91f7da359b9d5bf2439b33c99f3b90223443854d9602af84a1fad1ffe159ac0a2b77d4a42588e17044c136ec
languageName: node
linkType: hard
"@types/react-transition-group@npm:^4.4.10":
version: 4.4.10
resolution: "@types/react-transition-group@npm:4.4.10"
@@ -14778,13 +14768,13 @@ __metadata:
languageName: node
linkType: hard
"react-splitter-layout@npm:^4.0.0":
version: 4.0.0
resolution: "react-splitter-layout@npm:4.0.0"
"react-resizable-panels@npm:^4.8.0":
version: 4.8.0
resolution: "react-resizable-panels@npm:4.8.0"
peerDependencies:
prop-types: ^15.5.0
react: ^15.5.0 || ^16.0.0
checksum: 52fa6ec01f18df0938687089d85cfdf1950f04c7b611d973edc6fc6b553d1844b414c6d92e21e7de8043a29b21faca9a31b87457328bca7c424493db2c866700
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
checksum: 387f54de46e0727539912878279046816374438f08f0ca2c31ded71a40a65bc68cbab592ba7410469737f3ae120a7c973caebebd19d9dc65ae29019e94c8b93c
languageName: node
linkType: hard