diff --git a/package.json b/package.json
index 4c2930aa..8fbe8845 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/activities/Activities.test.tsx b/src/activities/Activities.test.tsx
index bc99bf5e..6b4cb5bf 100644
--- a/src/activities/Activities.test.tsx
+++ b/src/activities/Activities.test.tsx
@@ -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();
- 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();
- 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();
+ 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();
- 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();
-
- 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();
});
});
diff --git a/src/activities/Activities.tsx b/src/activities/Activities.tsx
index a2b94ece..51feb9a3 100644
--- a/src/activities/Activities.tsx
+++ b/src/activities/Activities.tsx
@@ -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 (
+
+
+
+ );
+ }
- 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 (
+
+
+
+ );
+ }
- // HACK: fix keyboard focus when no tab is selected
-
- const tabsRef = useRef(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 (
-
- }
- />
- }
- panel={}
- panelClassName="pb-activities-tabview"
- onMouseDown={(e) => e.stopPropagation()}
- />
- }
- />
- }
- panel={}
- panelClassName="pb-activities-tabview"
- onMouseDown={(e) => e.stopPropagation()}
- />
-
- );
+ return null;
};
export default Activities;
diff --git a/src/activities/activities.scss b/src/activities/activities.scss
index 091c657e..ffe4cc45 100644
--- a/src/activities/activities.scss
+++ b/src/activities/activities.scss
@@ -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;
}
}
diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx
index d86e7b32..39e8ee22 100644
--- a/src/app/App.test.tsx
+++ b/src/app/App.test.tsx
@@ -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();
- 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();
});
});
diff --git a/src/app/App.tsx b/src/app/App.tsx
index fbe67409..dbb0b5eb 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -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('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 = (
+
+ }
+ title={
+ terminal.visible
+ ? i18n.translate('terminal.hide')
+ : i18n.translate('terminal.show')
+ }
+ onClick={() => terminal.setVisible(!terminal.visible)}
+ />
+ }
+ title={
+ docs.visible
+ ? i18n.translate('docs.hide')
+ : i18n.translate('docs.show')
+ }
+ onClick={() => docs.setVisible(!docs.visible)}
+ />
+
+ );
+
return (
e.preventDefault()}>
-
- {/* need a container with position: relative; for SplitterLayout since it uses position: absolute; */}
-
-
+
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ {sideViewButtons}
+
+
+
+
+
+
+
+
+
+
+ setIsDragging(true)}
- onDragEnd={(): void => setIsDragging(false)}
- percentage={true}
- secondaryInitialSize={docsSplit}
- onSecondaryPaneSizeChange={setDocsSplit}
+ />
+
-
-
-
- }
- >
-
-
- }
- title={
- sideView === 'docs'
- ? i18n.translate('docs.hide')
- : i18n.translate('docs.show')
- }
- onClick={() =>
- setSideView(
- sideView === 'docs' ? 'off' : 'docs',
- )
- }
- />
-
-
-
-
-
+
+
diff --git a/src/app/app.scss b/src/app/app.scss
index 97c7318f..bd95a619 100644
--- a/src/app/app.scss
+++ b/src/app/app.scss
@@ -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
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;
-}
diff --git a/src/app/hooks.ts b/src/app/hooks.ts
new file mode 100644
index 00000000..65e9c5d3
--- /dev/null
+++ b/src/app/hooks.ts
@@ -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 (0–100).
+ *
+ * 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],
+ );
+}
diff --git a/src/app/translations/en.json b/src/app/translations/en.json
index e5f384f1..93d96d72 100644
--- a/src/app/translations/en.json
+++ b/src/app/translations/en.json
@@ -8,5 +8,9 @@
"docs": {
"show": "Show documentation",
"hide": "Hide documentation"
+ },
+ "terminal": {
+ "show": "Show terminal",
+ "hide": "Hide terminal"
}
}
diff --git a/src/editor/editor.scss b/src/editor/editor.scss
index aa12a1ee..42b7e659 100644
--- a/src/editor/editor.scss
+++ b/src/editor/editor.scss
@@ -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 {
diff --git a/src/setupTests.ts b/src/setupTests.ts
index 0d2fb2a4..c3fa3851 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -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,
});
diff --git a/src/toolbar/Toolbar.tsx b/src/toolbar/Toolbar.tsx
index 67400c26..dae47ef7 100644
--- a/src/toolbar/Toolbar.tsx
+++ b/src/toolbar/Toolbar.tsx
@@ -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}
>
+
+
+
+
diff --git a/src/toolbar/buttons/explorer/ExplorerButton.test.tsx b/src/toolbar/buttons/explorer/ExplorerButton.test.tsx
new file mode 100644
index 00000000..baefbf9c
--- /dev/null
+++ b/src/toolbar/buttons/explorer/ExplorerButton.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ await act(() => user.click(button.getByRole('button', { name: 'File Explorer' })));
+
+ expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
+ JSON.stringify(Activity.None),
+ );
+});
diff --git a/src/toolbar/buttons/explorer/ExplorerButton.tsx b/src/toolbar/buttons/explorer/ExplorerButton.tsx
new file mode 100644
index 00000000..c36b0b61
--- /dev/null
+++ b/src/toolbar/buttons/explorer/ExplorerButton.tsx
@@ -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;
+
+const ExplorerButton: React.FunctionComponent = ({ id }) => {
+ const i18n = useI18n();
+ const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
+
+ return (
+
+ setSelectedActivity(
+ selectedActivity === Activity.Explorer
+ ? Activity.None
+ : Activity.Explorer,
+ )
+ }
+ />
+ );
+};
+
+export default ExplorerButton;
diff --git a/src/toolbar/buttons/explorer/i18n.ts b/src/toolbar/buttons/explorer/i18n.ts
new file mode 100644
index 00000000..43b1db15
--- /dev/null
+++ b/src/toolbar/buttons/explorer/i18n.ts
@@ -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 {
+ // istanbul ignore next: babel-loader rewrites this line
+ const [i18n] = useShopifyI18n();
+ return i18n;
+}
diff --git a/src/toolbar/buttons/explorer/icon.svg b/src/toolbar/buttons/explorer/icon.svg
new file mode 100644
index 00000000..20886727
--- /dev/null
+++ b/src/toolbar/buttons/explorer/icon.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/src/toolbar/buttons/explorer/translations/en.json b/src/toolbar/buttons/explorer/translations/en.json
new file mode 100644
index 00000000..a9c553ec
--- /dev/null
+++ b/src/toolbar/buttons/explorer/translations/en.json
@@ -0,0 +1,7 @@
+{
+ "label": "File Explorer",
+ "tooltip": {
+ "show": "Show file explorer",
+ "hide": "Hide file explorer"
+ }
+}
diff --git a/src/toolbar/buttons/settings/SettingsButton.test.tsx b/src/toolbar/buttons/settings/SettingsButton.test.tsx
new file mode 100644
index 00000000..b20e07ca
--- /dev/null
+++ b/src/toolbar/buttons/settings/SettingsButton.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ await act(() => user.click(button.getByRole('button', { name: 'Settings' })));
+
+ expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
+ JSON.stringify(Activity.None),
+ );
+});
diff --git a/src/toolbar/buttons/settings/SettingsButton.tsx b/src/toolbar/buttons/settings/SettingsButton.tsx
new file mode 100644
index 00000000..ed2f0dc6
--- /dev/null
+++ b/src/toolbar/buttons/settings/SettingsButton.tsx
@@ -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;
+
+const SettingsButton: React.FunctionComponent = ({ id }) => {
+ const i18n = useI18n();
+ const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
+
+ return (
+
+ setSelectedActivity(
+ selectedActivity === Activity.Settings
+ ? Activity.None
+ : Activity.Settings,
+ )
+ }
+ />
+ );
+};
+
+export default SettingsButton;
diff --git a/src/toolbar/buttons/settings/i18n.ts b/src/toolbar/buttons/settings/i18n.ts
new file mode 100644
index 00000000..43b1db15
--- /dev/null
+++ b/src/toolbar/buttons/settings/i18n.ts
@@ -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 {
+ // istanbul ignore next: babel-loader rewrites this line
+ const [i18n] = useShopifyI18n();
+ return i18n;
+}
diff --git a/src/toolbar/buttons/settings/icon.svg b/src/toolbar/buttons/settings/icon.svg
new file mode 100644
index 00000000..2de9db51
--- /dev/null
+++ b/src/toolbar/buttons/settings/icon.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/src/toolbar/buttons/settings/translations/en.json b/src/toolbar/buttons/settings/translations/en.json
new file mode 100644
index 00000000..ad5a6fa1
--- /dev/null
+++ b/src/toolbar/buttons/settings/translations/en.json
@@ -0,0 +1,7 @@
+{
+ "label": "Settings",
+ "tooltip": {
+ "show": "Show settings and help",
+ "hide": "Hide settings and help"
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 4f49ce27..c60a9b5a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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