diff --git a/package.json b/package.json index 4c2930aa6..8fbe88457 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 bc99bf5ea..6b4cb5bfc 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(); - - const explorerTab = activities.getByRole('tab', { name: 'File Explorer' }); - - 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', - ); - } + it('should render nothing when no activity is selected', () => { + sessionStorage.setItem( + 'activities.selectedActivity', + JSON.stringify(Activity.None), + ); - await act(() => user.click(settingsTab)); + const [, activities] = testRender(); - 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 a2b94eceb..51feb9a36 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 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], - ); - - // 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()} - /> - - ); + const [selectedActivity] = useActivitiesSelectedActivity(); + if (selectedActivity === Activity.Explorer) { + return ( +
+ +
+ ); + } + + if (selectedActivity === Activity.Settings) { + return ( +
+ +
+ ); + } + + return null; }; export default Activities; diff --git a/src/activities/activities.scss b/src/activities/activities.scss index 091c657e6..ffe4cc45a 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 { - 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%); +.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%; - // 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 d86e7b32f..39e8ee22c 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 fbe67409c..dbb0b5ebc 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 = ( +
+
+ ); + return (
e.preventDefault()}>
- - {/* need a container with position: relative; for SplitterLayout since it uses position: absolute; */} -
- setIsDragging(true)} - onDragEnd={(): void => setIsDragging(false)} - percentage={true} - secondaryInitialSize={docsSplit} - onSecondaryPaneSizeChange={setDocsSplit} - > - -
- - } + +
+ +
+
- + - - - -
+ +
+ + +
+ + } + > + + +
+
+
+ {sideViewButtons} +
+
+ + + + + +
+
+ + + - -
+ + diff --git a/src/app/app.scss b/src/app/app.scss index 97c7318fa..bd95a6193 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; + overflow: hidden; +} +// 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; +} - .pb-app-main { - min-width: 0; - flex: 1 1 auto; - @include pb.background-contrast(4%); - } +// 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; + overflow: hidden; + @include pb.background-contrast(4%); } +// 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; - position: relative; + 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; - & .pb-editor { - min-height: 0; - flex: 1 1 auto; + .#{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; + &:hover { + background-color: $splitter-background-color-hover; + } + .#{bp.$ns}-dark & { - color: $dark-splitter-color; background-color: $dark-splitter-background-color; + + &:hover { + background-color: $dark-splitter-background-color-hover; + } } -} -// make layout splitter match app color scheme -.splitter-layout > .layout-splitter { - color: $splitter-color; - background-color: $splitter-background-color; + // Drag-handle icon via ::before pseudo-element. + &::before { + $radius: 0.4; - &:hover, - &:hover:before { - color: $splitter-color-hover; - background-color: $splitter-background-color-hover; + 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; + } } - .#{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; - &:hover, - &:hover:before { + .#{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; + } } -} -// 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; + // 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; + } + } } diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 000000000..65e9c5d34 --- /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 e5f384f17..93d96d72b 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 aa12a1ee7..42b7e659c 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 0d2fb2a4e..c3fa3851c 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 67400c265..dae47ef73 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 000000000..baefbf9cc --- /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 000000000..c36b0b61d --- /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 000000000..43b1db152 --- /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 000000000..208867271 --- /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 000000000..a9c553ec6 --- /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 000000000..b20e07ca9 --- /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 000000000..ed2f0dc66 --- /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 000000000..43b1db152 --- /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 000000000..2de9db518 --- /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 000000000..ad5a6fa15 --- /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 4f49ce27c..c60a9b5a0 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