diff --git a/src/renderer/components/dock/__test__/dock-store.test.ts b/src/renderer/components/dock/__test__/dock-store.test.ts new file mode 100644 index 0000000000..28c3bdf6e8 --- /dev/null +++ b/src/renderer/components/dock/__test__/dock-store.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import { DockStore, DockTab, TabKind } from "../dock/store"; +import dockStoreInjectable from "../dock/store.injectable"; +import fse from "fs-extra"; + +const initialTabs: DockTab[] = [ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource", pinned: false }, + { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart", pinned: false }, + { id: "logs", kind: TabKind.POD_LOGS, title: "Logs", pinned: false }, +]; + +describe("DockStore", () => { + let dockStore: DockStore; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override( + directoryForUserDataInjectable, + () => "some-test-suite-specific-directory-for-user-data", + ); + await di.runSetups(); + + dockStore = di.inject(dockStoreInjectable); + + await dockStore.whenReady; + }); + + afterEach(() => { + fse.remove("some-test-suite-specific-directory-for-user-data"); + dockStore.closeAllTabs(); + }); + + it("closes tab and selects one from right", () => { + dockStore.tabs = initialTabs; + dockStore.closeTab(dockStore.tabs[0].id); + + expect(dockStore.selectedTabId).toBe("create"); + + dockStore.selectTab("edit"); + dockStore.closeTab("edit"); + + expect(dockStore.selectedTabId).toBe("install"); + }); + + it("closes last tab and selects one from right", () => { + dockStore.tabs = initialTabs; + dockStore.selectTab("logs"); + dockStore.closeTab("logs"); + + expect(dockStore.selectedTabId).toBe("install"); + }); + + it("closes tab and selects the last one", () => { + dockStore.tabs = [ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + ]; + dockStore.closeTab("terminal"); + + expect(dockStore.selectedTabId).toBe("create"); + }); + + it("closes last tab and selects none", () => { + dockStore.tabs = [ + { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, + ]; + dockStore.closeTab("create"); + + expect(dockStore.selectedTabId).toBeUndefined(); + }); + + it("doesn't change selected tab if other tab closed", () => { + dockStore.tabs = initialTabs; + dockStore.closeTab("install"); + + expect(dockStore.selectedTabId).toBe("terminal"); + }); +}); diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index e3c6218909..87c0dd5c58 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -39,6 +39,14 @@ jest.mock("electron", () => ({ }, })); +Object.defineProperty(window, "ResizeObserver", { + writable: true, + value: jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + disconnect: jest.fn(), + })), +}); + const initialTabs: DockTab[] = [ { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal", pinned: false }, { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource", pinned: false }, diff --git a/src/renderer/components/dock/dock-tab.module.scss b/src/renderer/components/dock/dock-tab.module.scss new file mode 100644 index 0000000000..3093260ae0 --- /dev/null +++ b/src/renderer/components/dock/dock-tab.module.scss @@ -0,0 +1,98 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +.DockTab { + --color-active: var(--dockTabActiveBackground); + --color-text-active: var(--textColorAccent); + --color-border-active: var(--primary); + + padding: var(--padding); + height: 32px; + position: relative; + border-right: 1px solid var(--dockTabBorderColor); + background-size: 1px 3ch; + overflow: hidden; + + /* Allow tabs to shrink and take all parent space */ + min-width: var(--min-tab-width); + flex-grow: 1; + flex-basis: 0; + max-width: fit-content; + + &:last-child { + border-right: none; + } + + &.pinned { + padding-right: var(--padding); + } + + &:last-child { + padding-right: var(--padding); + } + + &:global(.active) { + background-color: var(--color-active); + background-image: none; + border-bottom: 1px solid var(--color-border-active); + color: var(--color-text-active)!important; + + .close { + opacity: 1; + } + + &::before { + display: none; + } + } + + &::before { + content: " "; + display: block; + position: absolute; + width: 8px; + height: 100%; + right: 0; + background: linear-gradient(90deg, transparent 0%, var(--dockHeadBackground) 65%); + } + + &::after { + display: none; + } + + &:not(:global(.active)):hover { + background-color: var(--dockTabActiveBackground); + background-image: none; + color: var(--textColorAccent); + + .close { + opacity: 1; + background: linear-gradient(90deg, transparent 0%, var(--dockTabActiveBackground) 25%); + } + + &::before { + display: none; + } + } +} + +.close { + position: absolute; + right: 0px; + width: 5ch; + opacity: 0; + text-align: center; + background: linear-gradient(90deg, transparent 0%, var(--color-active) 25%); +} + +.tabIcon { + opacity: 0; +} + +.title { + overflow: hidden; + text-overflow: ellipsis; + margin-right: 2.5rem; +} diff --git a/src/renderer/components/dock/dock-tab.scss b/src/renderer/components/dock/dock-tab.scss deleted file mode 100644 index 845a66c794..0000000000 --- a/src/renderer/components/dock/dock-tab.scss +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -.DockTab { - padding: $padding; - padding-right: 0; - - .Icon { - &.material { - --size: var(--small-size); - } - - &.svg { - --size: 15px; - } - } - - .label { - .title { - max-width: 250px; - overflow: hidden; - text-overflow: ellipsis; - } - } - - &.pinned { - padding-right: $padding; - } - - &:first-child { - padding-left: 0; - } - - &:last-child { - padding-right: $padding; - } -} diff --git a/src/renderer/components/dock/dock-tab.tsx b/src/renderer/components/dock/dock-tab.tsx index b49cd96f82..699cec40e0 100644 --- a/src/renderer/components/dock/dock-tab.tsx +++ b/src/renderer/components/dock/dock-tab.tsx @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import "./dock-tab.scss"; +import styles from "./dock-tab.module.scss"; import React from "react"; import { observer } from "mobx-react"; @@ -16,6 +16,7 @@ import { observable, makeObservable } from "mobx"; import { isMac } from "../../../common/vars"; import { withInjectables } from "@ogre-tools/injectable-react"; import dockStoreInjectable from "./dock/store.injectable"; +import { Tooltip, TooltipPosition } from "../tooltip"; export interface DockTabProps extends TabProps { moreActions?: React.ReactNode; @@ -76,19 +77,26 @@ class NonInjectedDockTab extends React.Component { } render() { - const { className, moreActions, dockStore, ...tabProps } = this.props; + const { className, moreActions, dockStore, active, ...tabProps } = this.props; const { title, pinned } = tabProps.value; const label = ( -
- {title} +
+ {title} {moreActions} {!pinned && ( - +
+ +
)} + {title}
); @@ -97,7 +105,9 @@ class NonInjectedDockTab extends React.Component { this.menuVisible = true} label={label} /> diff --git a/src/renderer/components/dock/dock-tabs.module.scss b/src/renderer/components/dock/dock-tabs.module.scss new file mode 100644 index 0000000000..4a68a00c8a --- /dev/null +++ b/src/renderer/components/dock/dock-tabs.module.scss @@ -0,0 +1,60 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +.dockTabs { + --min-tab-width: 120px; + + overflow: hidden; +} + +.tabs { + width: 100%; + + display: flex; + overflow: hidden; + + &:empty { + display: none; + } + + &:global(.scrollable) { + overflow: auto; + overflow-x: overlay; /* Set scrollbar inside content area */ + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + + &:hover { + &::-webkit-scrollbar { + width: 100%; + height: 3px; + } + + &::-webkit-scrollbar-thumb { + border-radius: 0; + height: 3px; + background-color: rgba(106, 115, 125, 0.2); + } + } + + &::before, &::after { + content: "\00A0"; + position: sticky; + min-width: 8px; + z-index: 1; + } + + &::before { + left: 0; + background: linear-gradient(270deg, transparent 0%, var(--dockHeadBackground) 65%); + } + + &::after { + right: 0; + background: linear-gradient(90deg, transparent 0%, var(--dockHeadBackground) 65%); + } + } +} diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index f988ae426c..dbf7f3ad56 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -3,14 +3,16 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import React, { Fragment } from "react"; +import styles from "./dock-tabs.module.scss"; -import { Icon } from "../icon"; +import React, { Fragment, useEffect, useRef, useState } from "react"; import { Tabs } from "../tabs/tabs"; import { DockTab } from "./dock-tab"; import type { DockTab as DockTabModel } from "./dock/store"; import { TabKind } from "./dock/store"; import { TerminalTab } from "./terminal/dock-tab"; +import { useResizeObserver } from "../../hooks"; +import { cssVar } from "../../utils"; export interface DockTabsProps { tabs: DockTabModel[]; @@ -20,6 +22,14 @@ export interface DockTabsProps { } export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabsProps) => { + const elem = useRef(); + const minTabSize = useRef(0); + const [showScrollbar, setShowScrollbar] = useState(false); + + const getTabElements = (): HTMLDivElement[] => { + return Array.from(elem.current?.querySelectorAll(".Tabs .Tab")); + }; + const renderTab = (tab?: DockTabModel) => { if (!tab) { return null; @@ -31,7 +41,7 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabs return ; case TabKind.INSTALL_CHART: case TabKind.UPGRADE_CHART: - return } />; + return ; case TabKind.POD_LOGS: return ; case TabKind.TERMINAL: @@ -39,14 +49,49 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabs } }; + const scrollActiveTabIntoView = () => { + const tab = elem.current?.querySelector(".Tab.active"); + + tab?.scrollIntoView(); + }; + + const updateScrollbarVisibility = () => { + const allTabsShrunk = getTabElements().every(tab => tab.offsetWidth == minTabSize.current); + + setShowScrollbar(allTabsShrunk); + }; + + const scrollTabsWithMouseWheel = (left: number) => { + elem.current?.children[0]?.scrollBy({ left }); + }; + + const onMouseWheel = (event: React.WheelEvent) => { + scrollTabsWithMouseWheel(event.deltaY); + }; + + useEffect(() => { + const cssVars = cssVar(elem.current); + + minTabSize.current = cssVars.get("--min-tab-width").valueOf(); + }); + + useResizeObserver(elem.current, () => { + scrollActiveTabIntoView(); + updateScrollbarVisibility(); + }); + return ( - - {tabs.map(tab => {renderTab(tab)})} - +
+ + {tabs.map(tab => {renderTab(tab)})} + +
); }; diff --git a/src/renderer/components/dock/dock.scss b/src/renderer/components/dock/dock.scss index 7a543ce934..27f8d8004b 100644 --- a/src/renderer/components/dock/dock.scss +++ b/src/renderer/components/dock/dock.scss @@ -34,7 +34,9 @@ height: auto !important; .Tab { - --color-active: inherit; + --color-active: var(--colorVague); + --color-text-active: inherit; + --color-border-active: transparent; &:not(:focus):after { display: none; @@ -55,6 +57,10 @@ min-height: $unit * 4; padding-left: $padding; user-select: none; + + &.pl-0 { + padding-left: 0; + } } } diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index ca61b3ba93..1a2177c88f 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -4,11 +4,9 @@ */ import "./dock.scss"; - import React from "react"; import { observer } from "mobx-react"; - -import { cssNames, prevDefault } from "../../utils"; +import { cssNames } from "../../utils"; import { Icon } from "../icon"; import { MenuItem } from "../menu"; import { MenuActions } from "../menu/menu-actions"; @@ -37,6 +35,11 @@ interface Dependencies { dockStore: DockStore; } +enum Direction { + NEXT = 1, + PREV = -1, +} + @observer class NonInjectedDock extends React.Component { private element = React.createRef(); @@ -52,6 +55,7 @@ class NonInjectedDock extends React.Component { onKeyDown = (evt: KeyboardEvent) => { const { close, selectedTab, closeTab } = this.props.dockStore; const { code, ctrlKey, metaKey, shiftKey } = evt; + // Determine if user working inside or using any other areas in app const dockIsFocused = this.element?.current.contains(document.activeElement); @@ -65,6 +69,14 @@ class NonInjectedDock extends React.Component { closeTab(selectedTab.id); this.element?.current.focus(); // Avoid loosing focus when closing tab } + + if(ctrlKey && code === "Period") { + this.switchToNextTab(); + } + + if(ctrlKey && code === "Comma") { + this.switchToNextTab(Direction.PREV); + } }; onChangeTab = (tab: DockTab) => { @@ -75,6 +87,19 @@ class NonInjectedDock extends React.Component { this.element?.current.focus(); }; + switchToNextTab = (direction: Direction = Direction.NEXT) => { + const { tabs, selectedTab } = this.props.dockStore; + const currentIndex = tabs.indexOf(selectedTab); + const nextIndex = currentIndex + direction; + + // check if moving to the next or previous tab is possible. + if (nextIndex >= tabs.length || nextIndex < 0) return; + + const nextElement = tabs[nextIndex]; + + this.onChangeTab(nextElement); + }; + renderTab(tab: DockTab) { switch (tab.kind) { case TabKind.CREATE_RESOURCE: @@ -125,14 +150,14 @@ class NonInjectedDock extends React.Component { onMinExtentExceed={dockStore.open} onDrag={extent => dockStore.height = extent} /> -
+
-
+
this.props.createTerminalTab()}> diff --git a/src/renderer/components/dock/dock/store.ts b/src/renderer/components/dock/dock/store.ts index ed117e79c8..4e3252f99d 100644 --- a/src/renderer/components/dock/dock/store.ts +++ b/src/renderer/components/dock/dock/store.ts @@ -162,6 +162,10 @@ export class DockStore implements DockStorageState { this.dependencies.storage.merge({ selectedTabId: tabId }); } + @computed get tabsNumber() : number { + return this.tabs.length; + } + @computed get selectedTab() { return this.tabs.find(tab => tab.id === this.selectedTabId); } @@ -323,6 +327,7 @@ export class DockStore implements DockStorageState { @action closeTab(tabId: TabId) { const tab = this.getTabById(tabId); + const tabIndex = this.getTabIndex(tabId); if (!tab || tab.pinned) { return; @@ -333,7 +338,7 @@ export class DockStore implements DockStorageState { if (this.selectedTabId === tab.id) { if (this.tabs.length) { - const newTab = this.tabs.slice(-1)[0]; // last + const newTab = tabIndex < this.tabsNumber ? this.tabs[tabIndex] : this.tabs[tabIndex - 1]; this.selectTab(newTab.id); } else { diff --git a/src/renderer/components/dock/terminal/dock-tab.tsx b/src/renderer/components/dock/terminal/dock-tab.tsx index dda2a37aca..7c32d6a950 100644 --- a/src/renderer/components/dock/terminal/dock-tab.tsx +++ b/src/renderer/components/dock/terminal/dock-tab.tsx @@ -46,7 +46,7 @@ class NonInjectedTerminalTab extends React.Component; + const tabIcon = ; const className = cssNames("TerminalTab", this.props.className, { disconnected: this.isDisconnected, }); diff --git a/src/renderer/components/fonts.scss b/src/renderer/components/fonts.scss index 0bfc761c58..239f6dce45 100644 --- a/src/renderer/components/fonts.scss +++ b/src/renderer/components/fonts.scss @@ -13,7 +13,6 @@ font-style: normal; font-weight: 400; font-display: block; - src: url("./fonts/MaterialIcons-Regular.woff2") format("woff"); src: url("./fonts/MaterialIcons-Regular.ttf") format("truetype"); } diff --git a/src/renderer/components/fonts/MaterialIcons-Regular.ttf b/src/renderer/components/fonts/MaterialIcons-Regular.ttf index 453b3e4cbc..40a8f457f4 100644 Binary files a/src/renderer/components/fonts/MaterialIcons-Regular.ttf and b/src/renderer/components/fonts/MaterialIcons-Regular.ttf differ diff --git a/src/renderer/components/fonts/MaterialIcons-Regular.woff2 b/src/renderer/components/fonts/MaterialIcons-Regular.woff2 deleted file mode 100644 index 34cdd2afba..0000000000 Binary files a/src/renderer/components/fonts/MaterialIcons-Regular.woff2 and /dev/null differ diff --git a/src/renderer/components/tabs/tabs.tsx b/src/renderer/components/tabs/tabs.tsx index 7c198ca08f..3913886365 100644 --- a/src/renderer/components/tabs/tabs.tsx +++ b/src/renderer/components/tabs/tabs.tsx @@ -81,10 +81,7 @@ export class Tab extends React.PureComponent { } scrollIntoView() { - this.ref.current?.scrollIntoView?.({ - behavior: "smooth", - inline: "center", - }); + this.ref.current?.scrollIntoViewIfNeeded(); } @boundMethod @@ -137,6 +134,7 @@ export class Tab extends React.PureComponent { onClick={this.onClick} onFocus={this.onFocus} onKeyDown={this.onKeyDown} + role="tab" ref={this.ref} > {typeof icon === "string" ? : icon} diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index d790ddf145..852f4d64d0 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -8,4 +8,5 @@ export * from "./useOnUnmount"; export * from "./useInterval"; export * from "./useMutationObserver"; +export * from "./useResizeObserver"; export * from "./use-toggle"; diff --git a/src/renderer/hooks/useResizeObserver.ts b/src/renderer/hooks/useResizeObserver.ts new file mode 100644 index 0000000000..1fe48ee9bb --- /dev/null +++ b/src/renderer/hooks/useResizeObserver.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { useEffect } from "react"; + +export function useResizeObserver( + element: Element, + callback: ResizeObserverCallback, +) { + + useEffect(() => { + if (element) { + const observer = new ResizeObserver(callback); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + } + + return undefined; + }, [element, callback]); +} diff --git a/src/renderer/themes/lens-dark.json b/src/renderer/themes/lens-dark.json index b48ed84cc7..f30d48197e 100644 --- a/src/renderer/themes/lens-dark.json +++ b/src/renderer/themes/lens-dark.json @@ -73,6 +73,8 @@ "dockEditorComment": "#808080", "dockEditorActiveLineBackground": "#3a3d41", "dockBadgeBackground": "#36393e", + "dockTabBorderColor": "#43424d", + "dockTabActiveBackground": "#3a3e45", "logsBackground": "#000000", "logsForeground": "#ffffff", "logRowHoverBackground": "#35373a", diff --git a/src/renderer/themes/lens-light.json b/src/renderer/themes/lens-light.json index 7891f53321..06ef6d79b2 100644 --- a/src/renderer/themes/lens-light.json +++ b/src/renderer/themes/lens-light.json @@ -73,6 +73,8 @@ "dockEditorComment": "#808080", "dockEditorActiveLineBackground": "#3a3d41", "dockBadgeBackground": "#dedede", + "dockTabBorderColor": "#d5d4de", + "dockTabActiveBackground": "#ffffff", "logsBackground": "#24292e", "logsForeground": "#ffffff", "logRowHoverBackground": "#35373a",