diff --git a/packages/business-features/dock-old/close-dock-tab.injectable.ts b/packages/business-features/dock-old/close-dock-tab.injectable.ts new file mode 100644 index 0000000000..6281c5b448 --- /dev/null +++ b/packages/business-features/dock-old/close-dock-tab.injectable.ts @@ -0,0 +1,17 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import type { TabId } from "./store"; +import dockStoreInjectable from "./store.injectable"; + +const closeDockTabInjectable = getInjectable({ + id: "close-dock-tab", + + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (tabId: TabId): void => { + dockStore.closeTab(tabId); + }; + }, +}); + +export default closeDockTabInjectable; diff --git a/packages/business-features/dock-old/close-dock/close-dock-shortcut.injectable.ts b/packages/business-features/dock-old/close-dock/close-dock-shortcut.injectable.ts new file mode 100644 index 0000000000..979519f3c0 --- /dev/null +++ b/packages/business-features/dock-old/close-dock/close-dock-shortcut.injectable.ts @@ -0,0 +1,23 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { keyboardShortcutInjectionToken } from "@k8slens/keyboard-shortcuts"; + +const closeDockShortcutInjectable = getInjectable({ + id: "close-dock-shortcut", + + instantiate: () => ({ + scope: "dock", + + binding: { + shift: true, + code: "Escape", + }, + + invoke: () => { + // Close Dock + }, + }), + + injectionToken: keyboardShortcutInjectionToken, +}); + +export default closeDockShortcutInjectable; diff --git a/packages/business-features/dock-old/close-tab/close-tab-shortcut.injectable.ts b/packages/business-features/dock-old/close-tab/close-tab-shortcut.injectable.ts new file mode 100644 index 0000000000..066628642f --- /dev/null +++ b/packages/business-features/dock-old/close-tab/close-tab-shortcut.injectable.ts @@ -0,0 +1,24 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { keyboardShortcutInjectionToken } from "@k8slens/keyboard-shortcuts"; + +const closeTabShortcutInjectable = getInjectable({ + id: "close-tab-shortcut", + + instantiate: () => ({ + scope: "dock", + + binding: { + ctrlOrCommand: true, + code: "KeyW", + }, + + invoke: () => { + // Close Tab + // Focus the Dock to avoid losing the focus + }, + }), + + injectionToken: keyboardShortcutInjectionToken, +}); + +export default closeTabShortcutInjectable; diff --git a/packages/business-features/dock-old/create-dock-tab.injectable.ts b/packages/business-features/dock-old/create-dock-tab.injectable.ts new file mode 100644 index 0000000000..2f72245994 --- /dev/null +++ b/packages/business-features/dock-old/create-dock-tab.injectable.ts @@ -0,0 +1,16 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import dockStoreInjectable from "./store.injectable"; +import type { DockTab, DockTabCreate } from "./store"; + +const createDockTabInjectable = getInjectable({ + id: "create-dock-tab", + + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (rawTabDesc: DockTabCreate, addNumber?: boolean): DockTab => + dockStore.createTab(rawTabDesc, addNumber); + }, +}); + +export default createDockTabInjectable; diff --git a/packages/business-features/dock-old/dock-storage.injectable.ts b/packages/business-features/dock-old/dock-storage.injectable.ts new file mode 100644 index 0000000000..0b380bce2e --- /dev/null +++ b/packages/business-features/dock-old/dock-storage.injectable.ts @@ -0,0 +1,26 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; +import type { DockStorageState } from "./store"; + +const dockStorageInjectable = getInjectable({ + id: "dock-storage", + + instantiate: (di) => { + const createStorage = di.inject(createStorageInjectable); + + return createStorage("dock", { + height: 300, + tabs: [ + // { + // id: "terminal", + // kind: TabKind.TERMINAL, + // title: "Terminal", + // pinned: false, + // }, + ], + isOpen: false, + }); + }, +}); + +export default dockStorageInjectable; diff --git a/packages/business-features/dock-old/dock-tab-store/create-dock-tab-store.injectable.ts b/packages/business-features/dock-old/dock-tab-store/create-dock-tab-store.injectable.ts new file mode 100644 index 0000000000..b5747357ea --- /dev/null +++ b/packages/business-features/dock-old/dock-tab-store/create-dock-tab-store.injectable.ts @@ -0,0 +1,18 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import type { DockTabStoreOptions } from "./dock-tab.store"; +import { DockTabStore } from "./dock-tab.store"; +import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; + +const createDockTabStoreInjectable = getInjectable({ + id: "create-dock-tab-store", + + instantiate: (di) => { + const dependencies = { + createStorage: di.inject(createStorageInjectable), + }; + + return (options: DockTabStoreOptions = {}) => new DockTabStore(dependencies, options); + }, +}); + +export default createDockTabStoreInjectable; diff --git a/packages/business-features/dock-old/dock-tab-store/dock-tab.store.ts b/packages/business-features/dock-old/dock-tab-store/dock-tab.store.ts new file mode 100644 index 0000000000..d048eb05b2 --- /dev/null +++ b/packages/business-features/dock-old/dock-tab-store/dock-tab.store.ts @@ -0,0 +1,95 @@ +import { action, observable, reaction } from "mobx"; +import type { StorageLayer } from "../../../utils/storage-helper"; +import type { CreateStorage } from "../../../utils/create-storage/create-storage.injectable"; +import type { TabId } from "../store"; +import autoBind from "auto-bind"; +import { toJS } from "../../../../common/utils"; + +export interface DockTabStoreOptions { + autoInit?: boolean; // load data from storage when `storageKey` is provided and bind events, default: true + storageKey?: string; // save data to persistent storage under the key +} + +export type DockTabStorageState = Record; + +export interface DockTabStoreDependencies { + createStorage: CreateStorage; +} + +export class DockTabStore { + protected readonly storage?: StorageLayer>; + + private readonly data = observable.map(); + + constructor( + protected readonly dependencies: DockTabStoreDependencies, + protected readonly options: DockTabStoreOptions, + ) { + autoBind(this); + this.options.autoInit ??= true; + + const { storageKey, autoInit } = this.options; + + if (autoInit && storageKey) { + const storage = (this.storage = this.dependencies.createStorage(storageKey, {})); + + this.data.replace(storage.get()); + reaction( + () => this.toJSON(), + (data) => storage.set(data), + ); + } + } + + protected finalizeDataForSave(data: T): T { + return data; + } + + protected toJSON(): DockTabStorageState { + const deepCopy = toJS(this.data); + + deepCopy.forEach((tabData, key) => { + deepCopy.set(key, this.finalizeDataForSave(tabData)); + }); + + return Object.fromEntries(deepCopy); + } + + protected getAllData() { + return this.data.toJSON(); + } + + findTabIdFromData(inspecter: (val: T) => any): TabId | undefined { + for (const [tabId, data] of this.data) { + if (inspecter(data)) { + return tabId; + } + } + + return undefined; + } + + isReady(tabId: TabId): boolean { + return this.getData(tabId) !== undefined; + } + + getData(tabId: TabId) { + return this.data.get(tabId); + } + + setData(tabId: TabId, data: T) { + this.data.set(tabId, data); + } + + clearData(tabId: TabId) { + this.data.delete(tabId); + } + + @action + reset() { + for (const tabId of this.data.keys()) { + this.clearData(tabId); + } + this.storage?.reset(); + } +} diff --git a/packages/business-features/dock-old/dock-tab.module.scss b/packages/business-features/dock-old/dock-tab.module.scss new file mode 100644 index 0000000000..275fe72a11 --- /dev/null +++ b/packages/business-features/dock-old/dock-tab.module.scss @@ -0,0 +1,93 @@ +.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: 4ch; + 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: calc(var(--margin) * 3); +} diff --git a/packages/business-features/dock-old/dock-tab.tsx b/packages/business-features/dock-old/dock-tab.tsx new file mode 100644 index 0000000000..58e07e2268 --- /dev/null +++ b/packages/business-features/dock-old/dock-tab.tsx @@ -0,0 +1,128 @@ +import styles from "./dock-tab.module.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { cssNames, prevDefault, isMiddleClick } from "@k8slens/utilities"; +import type { DockStore, DockTab as DockTabModel } from "./store"; +import type { TabProps } from "../tabs"; +import { Tab } from "../tabs"; +import { Icon } from "../icon"; +import { Menu, MenuItem } from "../menu"; +import { observable } from "mobx"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "./store.injectable"; +import { Tooltip, TooltipPosition } from "../tooltip"; +import isMacInjectable from "../../../common/vars/is-mac.injectable"; +import autoBindReact from "auto-bind/react"; + +export interface DockTabProps extends TabProps { + moreActions?: React.ReactNode; +} + +interface Dependencies { + dockStore: DockStore; + isMac: boolean; +} + +@observer +class NonInjectedDockTab extends React.Component { + private readonly menuVisible = observable.box(false); + + constructor(props: DockTabProps & Dependencies) { + super(props); + autoBindReact(this); + } + + close(id: string) { + this.props.dockStore.closeTab(id); + } + + renderMenu(tabId: string) { + const { closeTab, closeAllTabs, closeOtherTabs, closeTabsToTheRight, tabs, getTabIndex } = + this.props.dockStore; + const closeAllDisabled = tabs.length === 1; + const closeOtherDisabled = tabs.length === 1; + const closeRightDisabled = getTabIndex(tabId) === tabs.length - 1; + + return ( + this.menuVisible.set(true)} + close={() => this.menuVisible.set(false)} + toggleEvent="contextmenu" + > + closeTab(tabId)}>Close + closeAllTabs()} disabled={closeAllDisabled}> + Close all tabs + + closeOtherTabs(tabId)} disabled={closeOtherDisabled}> + Close other tabs + + closeTabsToTheRight(tabId)} disabled={closeRightDisabled}> + Close tabs to the right + + + ); + } + + render() { + const { className, moreActions, dockStore, active, isMac, ...tabProps } = this.props; + + if (!tabProps.value) { + return; + } + + const { title, pinned, id } = tabProps.value; + const close = prevDefault(() => this.close(id)); + + return ( + <> + this.menuVisible.set(true)} + label={ +
+ {title} + {moreActions} + {!pinned && ( +
+ +
+ )} + + {title} + +
+ } + data-testid={`dock-tab-for-${id}`} + /> + {this.renderMenu(id)} + + ); + } +} + +export const DockTab = withInjectables(NonInjectedDockTab, { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + isMac: di.inject(isMacInjectable), + ...props, + }), +}); diff --git a/packages/business-features/dock-old/dock-tabs.module.scss b/packages/business-features/dock-old/dock-tabs.module.scss new file mode 100644 index 0000000000..447756b095 --- /dev/null +++ b/packages/business-features/dock-old/dock-tabs.module.scss @@ -0,0 +1,55 @@ +.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/packages/business-features/dock-old/dock-tabs.tsx b/packages/business-features/dock-old/dock-tabs.tsx new file mode 100644 index 0000000000..02c60a5989 --- /dev/null +++ b/packages/business-features/dock-old/dock-tabs.tsx @@ -0,0 +1,81 @@ +import React, { Fragment, useEffect, useRef, useState } from "react"; +import { Tabs } from "../tabs/tabs"; +import type { DockTab as DockTabModel } from "./store"; +import { cssVar } from "@k8slens/utilities"; +import { useResizeObserver } from "../../hooks"; + +export interface DockTabsProps { + tabs: DockTabModel[]; + autoFocus: boolean; + selectedTab: DockTabModel | undefined; + onChangeTab: (tab: DockTabModel) => void; +} + +export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: DockTabsProps) => { + const elem = useRef(null); + 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; + } + + switch (tab.kind) { + } + }; + + 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(() => { + if (elem.current) { + const cssVars = cssVar(elem.current); + + minTabSize.current = cssVars.get("--min-tab-width").valueOf(); + } + }); + + useResizeObserver(elem.current, () => { + scrollActiveTabIntoView(); + updateScrollbarVisibility(); + }); + + return ( +
+ + {tabs.map((tab) => ( + {renderTab(tab)} + ))} + +
+ ); +}; diff --git a/packages/business-features/dock-old/dock.tsx b/packages/business-features/dock-old/dock.tsx new file mode 100644 index 0000000000..b3f7929713 --- /dev/null +++ b/packages/business-features/dock-old/dock.tsx @@ -0,0 +1,150 @@ +import "../dock/src/dock/dock.scss"; +import React from "react"; +import { observer } from "mobx-react"; +import { cssNames } from "@k8slens/utilities"; +import { Icon } from "../icon"; +import { MenuActions } from "../menu/menu-actions"; +import { ResizeDirection, ResizingAnchor } from "../resizing-anchor"; +import { DockTabs } from "./dock-tabs"; +import type { DockStore, DockTab } from "./store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import dockStoreInjectable from "./store.injectable"; +import { ErrorBoundary } from "../error-boundary"; + +export interface DockProps { + className?: string; +} + +interface Dependencies { + dockStore: DockStore; +} + +enum Direction { + NEXT = 1, + PREV = -1, +} + +@observer +class NonInjectedDock extends React.Component { + private readonly element = React.createRef(); + + onChangeTab = (tab: DockTab) => { + const { open, selectTab } = this.props.dockStore; + + open(); + selectTab(tab.id); + this.element.current?.focus(); + }; + + switchToNextTab = (selectedTab: DockTab, direction: Direction) => { + const { tabs } = 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) { + } + } + + renderTabContent() { + const { isOpen, height, selectedTab } = this.props.dockStore; + + if (!isOpen || !selectedTab) { + return null; + } + + return ( +
+ {this.renderTab(selectedTab)} +
+ ); + } + + render() { + const { className, dockStore } = this.props; + const { isOpen, toggle, tabs, toggleFillSize, selectedTab, hasTabs, fullSize } = + this.props.dockStore; + + return ( +
+ dockStore.height} + minExtent={dockStore.minHeight} + maxExtent={dockStore.maxHeight} + direction={ResizeDirection.VERTICAL} + onStart={dockStore.open} + onMinExtentSubceed={dockStore.close} + onMinExtentExceed={dockStore.open} + onDrag={(extent) => (dockStore.height = extent)} + /> +
+ +
+
+ +
+ {hasTabs() && ( + <> + + + + )} +
+
+ {this.renderTabContent()} +
+ ); + } +} + +export const Dock = withInjectables( + NonInjectedDock, + + { + getProps: (di, props) => ({ + dockStore: di.inject(dockStoreInjectable), + ...props, + }), + }, +); diff --git a/packages/business-features/dock-old/navigate-tabs/switch-to-next-tab-shortcut.injectable.ts b/packages/business-features/dock-old/navigate-tabs/switch-to-next-tab-shortcut.injectable.ts new file mode 100644 index 0000000000..ead3918595 --- /dev/null +++ b/packages/business-features/dock-old/navigate-tabs/switch-to-next-tab-shortcut.injectable.ts @@ -0,0 +1,23 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { keyboardShortcutInjectionToken } from "@k8slens/keyboard-shortcuts"; + +const switchToNextTabShortcutInjectable = getInjectable({ + id: "switch-to-next-tab-shortcut", + + instantiate: () => ({ + scope: "dock", + + binding: { + ctrl: true, + code: "Period", + }, + + invoke: () => { + // Next tab + }, + }), + + injectionToken: keyboardShortcutInjectionToken, +}); + +export default switchToNextTabShortcutInjectable; diff --git a/packages/business-features/dock-old/navigate-tabs/switch-to-previous-tab-shortcut.injectable.ts b/packages/business-features/dock-old/navigate-tabs/switch-to-previous-tab-shortcut.injectable.ts new file mode 100644 index 0000000000..e2d60b5a84 --- /dev/null +++ b/packages/business-features/dock-old/navigate-tabs/switch-to-previous-tab-shortcut.injectable.ts @@ -0,0 +1,23 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { keyboardShortcutInjectionToken } from "@k8slens/keyboard-shortcuts"; + +const switchToPreviousTabShortcutInjectable = getInjectable({ + id: "switch-to-previous-tab-shortcut", + + instantiate: () => ({ + scope: "dock", + + binding: { + ctrl: true, + code: "Comma", + }, + + invoke: () => { + // Previous tab + }, + }), + + injectionToken: keyboardShortcutInjectionToken, +}); + +export default switchToPreviousTabShortcutInjectable; diff --git a/packages/business-features/dock-old/rename-tab.injectable.ts b/packages/business-features/dock-old/rename-tab.injectable.ts new file mode 100644 index 0000000000..dc0801f756 --- /dev/null +++ b/packages/business-features/dock-old/rename-tab.injectable.ts @@ -0,0 +1,17 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import dockStoreInjectable from "./store.injectable"; +import type { TabId } from "./store"; + +const renameTabInjectable = getInjectable({ + id: "rename-tab", + + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (tabId: TabId, title: string): void => { + dockStore.renameTab(tabId, title); + }; + }, +}); + +export default renameTabInjectable; diff --git a/packages/business-features/dock-old/select-dock-tab.injectable.ts b/packages/business-features/dock-old/select-dock-tab.injectable.ts new file mode 100644 index 0000000000..b018905180 --- /dev/null +++ b/packages/business-features/dock-old/select-dock-tab.injectable.ts @@ -0,0 +1,17 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import type { TabId } from "./store"; +import dockStoreInjectable from "./store.injectable"; + +const selectDockTabInjectable = getInjectable({ + id: "select-dock-tab", + + instantiate: (di) => { + const dockStore = di.inject(dockStoreInjectable); + + return (tabId: TabId): void => { + dockStore.selectTab(tabId); + }; + }, +}); + +export default selectDockTabInjectable; diff --git a/packages/business-features/dock-old/store.injectable.ts b/packages/business-features/dock-old/store.injectable.ts new file mode 100644 index 0000000000..0d626b5a46 --- /dev/null +++ b/packages/business-features/dock-old/store.injectable.ts @@ -0,0 +1,25 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import { DockStore } from "./store"; +import dockStorageInjectable from "./dock-storage.injectable"; + +const dockStoreInjectable = getInjectable({ + id: "dock-store", + + instantiate: (di) => + new DockStore({ + storage: di.inject(dockStorageInjectable), + tabDataClearers: { + // [TabKind.POD_LOGS]: di.inject(clearLogTabDataInjectable), + // [TabKind.UPGRADE_CHART]: di.inject(clearUpgradeChartTabDataInjectable), + // [TabKind.CREATE_RESOURCE]: di.inject(clearCreateResourceTabDataInjectable), + // [TabKind.EDIT_RESOURCE]: di.inject(clearEditResourceTabDataInjectable), + // [TabKind.INSTALL_CHART]: di.inject(clearInstallChartTabDataInjectable), + // [TabKind.TERMINAL]: di.inject(clearTerminalTabDataInjectable), + }, + tabDataValidator: { + // [TabKind.POD_LOGS]: di.inject(isLogsTabDataValidInjectable), + }, + }), +}); + +export default dockStoreInjectable; diff --git a/packages/business-features/dock-old/store.ts b/packages/business-features/dock-old/store.ts new file mode 100644 index 0000000000..c6ec93b103 --- /dev/null +++ b/packages/business-features/dock-old/store.ts @@ -0,0 +1,406 @@ +import * as uuid from "uuid"; +import { + action, + comparer, + computed, + makeObservable, + observable, + reaction, + runInAction, +} from "mobx"; +import throttle from "lodash/throttle"; +import type { StorageLayer } from "../../../utils/storage-helper"; +import autoBind from "auto-bind"; + +export type TabId = string; +export type TabKind = string; + +/** + * This is the storage model for dock tabs. + * + * All fields are required. + */ +export type DockTab = Required; + +/** + * These are the arguments for creating a new Tab on the dock + */ +export interface DockTabCreate { + /** + * The ID of the tab for reference purposes. + */ + id?: TabId; + + /** + * What kind of dock tab it is + */ + kind: TabKind; + + /** + * The tab's title, defaults to `kind` + */ + title?: string; + + /** + * If true then the dock entry will take up the whole view and will not be + * closable. + */ + pinned?: boolean; + + /** + * Extra fields are supported. + */ + [key: string]: any; +} + +/** + * This type is for function which specifically create a single type of dock tab. + * + * That way users should get a type error if they try and specify a `kind` + * themselves. + */ +export type DockTabCreateSpecific = Omit; + +export interface DockStorageState { + height: number; + tabs: DockTab[]; + selectedTabId?: TabId; + isOpen: boolean; +} + +export interface DockTabChangeEvent { + tab: DockTab; + tabId: TabId; + prevTab?: DockTab; +} + +export interface DockTabChangeEventOptions { + /** + * apply a callback right after initialization + */ + fireImmediately?: boolean; + /** + * filter: by dockStore.selectedTab.kind == tabKind + */ + tabKind?: TabKind; + /** + * filter: dock and selected tab should be visible (default: true) + */ + dockIsVisible?: boolean; +} + +export interface DockTabCloseEvent { + tabId: TabId; // closed tab id +} + +interface Dependencies { + readonly storage: StorageLayer; + readonly tabDataClearers: Record void>; + readonly tabDataValidator: Partial boolean>>; +} + +export class DockStore implements DockStorageState { + constructor(private readonly dependencies: Dependencies) { + makeObservable(this); + autoBind(this); + + // adjust terminal height if window size changes + window.addEventListener("resize", throttle(this.adjustHeight, 250)); + + for (const tab of this.tabs) { + const tabDataIsValid = this.dependencies.tabDataValidator[tab.kind] ?? (() => true); + + if (!tabDataIsValid(tab.id)) { + this.closeTab(tab.id); + } + } + } + + readonly minHeight = 100; + + @observable fullSize = false; + + @computed + get isOpen(): boolean { + return this.dependencies.storage.get().isOpen; + } + + set isOpen(isOpen: boolean) { + this.dependencies.storage.merge({ isOpen }); + } + + @computed + get height(): number { + return this.dependencies.storage.get().height; + } + + set height(height: number) { + this.dependencies.storage.merge({ + height: Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight)), + }); + } + + @computed + get tabs(): DockTab[] { + return this.dependencies.storage.get().tabs; + } + + set tabs(tabs: DockTab[]) { + this.dependencies.storage.merge({ tabs }); + } + + @computed + get selectedTabId(): TabId | undefined { + const storageData = this.dependencies.storage.get(); + + return storageData.selectedTabId || (this.tabs.length > 0 ? this.tabs[0]?.id : undefined); + } + + set selectedTabId(tabId: TabId | undefined) { + if (tabId && !this.getTabById(tabId)) { + return; + } // skip invalid ids + + 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); + } + + get maxHeight() { + const mainLayoutHeader = 40; + const mainLayoutTabs = 33; + const mainLayoutMargin = 16; + const dockTabs = 33; + const preferredMax = + window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs; + + return Math.max(preferredMax, this.minHeight); // don't let max < min + } + + protected adjustHeight() { + if (this.height < this.minHeight) { + this.height = this.minHeight; + } + + if (this.height > this.maxHeight) { + this.height = this.maxHeight; + } + } + + onResize(callback: () => void, opts: { fireImmediately?: boolean } = {}) { + return reaction(() => [this.height, this.fullSize], callback, { + fireImmediately: opts.fireImmediately, + }); + } + + onTabClose(callback: (evt: DockTabCloseEvent) => void, opts: { fireImmediately?: boolean } = {}) { + return reaction( + () => this.tabs.map((tab) => tab.id), + (tabs: TabId[], prevTabs?: TabId[]) => { + if (!Array.isArray(prevTabs)) { + return; // tabs not yet modified + } + + const closedTabs: TabId[] = prevTabs.filter((id) => !tabs.includes(id)); + + if (closedTabs.length > 0) { + runInAction(() => { + closedTabs.forEach((tabId) => callback({ tabId })); + }); + } + }, + { + equals: comparer.structural, + fireImmediately: opts.fireImmediately, + }, + ); + } + + onTabChange( + callback: (evt: DockTabChangeEvent) => void, + options: DockTabChangeEventOptions = {}, + ) { + const { tabKind, dockIsVisible = true, ...reactionOpts } = options; + + return reaction( + () => this.selectedTab, + (tab, prevTab) => { + if (!tab) { + return; + } // skip when dock is empty + + if (tabKind && tabKind !== tab.kind) { + return; + } // handle specific tab.kind only + + if (dockIsVisible && !this.isOpen) { + return; + } + + callback({ + tab, + prevTab, + tabId: tab.id, + }); + }, + reactionOpts, + ); + } + + hasTabs() { + return this.tabs.length > 0; + } + + @action + open(fullSize?: boolean) { + this.isOpen = true; + + if (typeof fullSize === "boolean") { + this.fullSize = fullSize; + } + } + + @action + close() { + this.isOpen = false; + } + + @action + toggle() { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + @action + toggleFillSize() { + if (!this.isOpen) { + this.open(); + } + this.fullSize = !this.fullSize; + } + + getTabById(tabId: TabId) { + return this.tabs.find((tab) => tab.id === tabId); + } + + getTabIndex(tabId: TabId) { + return this.tabs.findIndex((tab) => tab.id === tabId); + } + + protected getNewTabNumber(kind: TabKind) { + const tabNumbers = this.tabs + .filter((tab) => tab.kind === kind) + .map((tab) => { + const tabNumber = Number(tab.title.match(/\d+/)); + + return tabNumber === 0 ? 1 : tabNumber; // tab without a number is first + }); + + for (let i = 1; ; i++) { + if (!tabNumbers.includes(i)) { + return i; + } + } + } + + createTab = action((rawTabDesc: DockTabCreate, addNumber = true): DockTab => { + const { id = uuid.v4(), kind, pinned = false, ...restOfTabFields } = rawTabDesc; + let { title = kind } = rawTabDesc; + + if (addNumber) { + const tabNumber = this.getNewTabNumber(kind); + + if (tabNumber > 1) { + title += ` (${tabNumber})`; + } + } + + const tab: DockTab = { + ...restOfTabFields, + id, + kind, + pinned, + title, + }; + + this.tabs.push(tab); + this.selectTab(tab.id); + this.open(); + + return tab; + }); + + @action + closeTab(tabId: TabId) { + const tab = this.getTabById(tabId); + const tabIndex = this.getTabIndex(tabId); + + if (!tab || tab.pinned) { + return; + } + + this.tabs = this.tabs.filter((tab) => tab.id !== tabId); + this.dependencies.tabDataClearers[tab.kind](tab.id); + + if (this.selectedTabId === tab.id) { + if (this.tabs.length) { + const newTab = tabIndex < this.tabsNumber ? this.tabs[tabIndex] : this.tabs[tabIndex - 1]; + + this.selectTab(newTab.id); + } else { + this.selectedTabId = undefined; + this.close(); + } + } + } + + @action + closeTabs(tabs: DockTab[]) { + tabs.forEach((tab) => this.closeTab(tab.id)); + } + + closeAllTabs() { + this.closeTabs([...this.tabs]); + } + + closeOtherTabs(tabId: TabId) { + const index = this.getTabIndex(tabId); + const tabs = [...this.tabs.slice(0, index), ...this.tabs.slice(index + 1)]; + + this.closeTabs(tabs); + } + + closeTabsToTheRight(tabId: TabId) { + const index = this.getTabIndex(tabId); + const tabs = this.tabs.slice(index + 1); + + this.closeTabs(tabs); + } + + renameTab(tabId: TabId, title: string) { + const tab = this.getTabById(tabId); + + if (tab) { + tab.title = title; + } + } + + @action + selectTab(tabId: TabId) { + this.selectedTabId = this.getTabById(tabId)?.id; + } + + @action + reset() { + this.dependencies.storage?.reset(); + } +} diff --git a/packages/business-features/dock/jest.config.js b/packages/business-features/dock/jest.config.js index c6074967eb..38d54ab7b6 100644 --- a/packages/business-features/dock/jest.config.js +++ b/packages/business-features/dock/jest.config.js @@ -1 +1 @@ -module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode; +module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact; diff --git a/packages/business-features/dock/package.json b/packages/business-features/dock/package.json index 48697a680c..b286ec8fd5 100644 --- a/packages/business-features/dock/package.json +++ b/packages/business-features/dock/package.json @@ -5,7 +5,7 @@ "description": "Highly extendable dock in the Lens.", "type": "commonjs", "files": [ - "agnostic/dist" + "dist" ], "publishConfig": { "access": "public", @@ -15,7 +15,7 @@ "type": "git", "url": "git+https://github.com/lensapp/lens.git" }, - "main": "agnostic/dist/index.js", + "main": "dist/index.js", "types": "dist/index.d.ts", "author": { "name": "OpenLens Authors", @@ -24,8 +24,8 @@ "license": "MIT", "homepage": "https://github.com/lensapp/lens", "scripts": { - "build": "webpack", - "dev": "webpack --mode=development --watch", + "buildasd": "webpack", + "devasdasd": "webpack --mode=development --watch", "test:unit": "jest --coverage --runInBand", "lint": "lens-lint", "lint:fix": "lens-lint --fix" @@ -33,10 +33,13 @@ "peerDependencies": { "@k8slens/feature-core": "^6.5.0-alpha.0", "@ogre-tools/injectable": "^15.1.2", - "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2" + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "@ogre-tools/fp": "^15.1.2", + "lodash": "^4.17.21" }, "devDependencies": { "@async-fn/jest": "^1.6.4", - "@k8slens/eslint-config": "6.5.0-alpha.1" + "@k8slens/eslint-config": "6.5.0-alpha.1", + "@k8slens/react-testing-library-discovery": "^1.0.0-alpha.0" } } diff --git a/packages/business-features/dock/src/dock/dock.scss b/packages/business-features/dock/src/dock/dock.scss new file mode 100644 index 0000000000..dfe11b1139 --- /dev/null +++ b/packages/business-features/dock/src/dock/dock.scss @@ -0,0 +1,77 @@ +.Dock { + position: relative; + background: var(--dockHeadBackground); + display: flex; + flex-direction: column; + + &:not(:focus-within) .DockTab.active { + &::after { + color: var(--halfGray); + } + + &:hover::after { + color: var(--line-color-active); + } + } + + &.isOpen { + &.fullSize { + position: fixed; + top: 0; + right: 0; + left: 0; + bottom: 0; + z-index: 100; + } + } + + &:not(.isOpen) { + height: auto !important; + + .Tab { + --color-active: var(--colorVague); + --color-text-active: inherit; + --color-border-active: transparent; + + &:not(:focus):after { + display: none; + } + } + } + + .tabs-container { + padding: 0 $padding * 2; + border-top: 1px solid var(--borderColor); + flex-shrink: 0; + + .Tabs:empty + .toolbar { + padding-left: 0; + } + + .toolbar { + min-height: $unit * 4; + padding-left: $padding; + user-select: none; + + &.pl-0 { + padding-left: 0; + } + } + } + + .tab-content { + position: relative; + flex: 1; + overflow: hidden; + transition: flex-basis 25ms ease-in; + background: var(--dockInfoBackground); + + > *:not(.Spinner) { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + } + } +} diff --git a/packages/business-features/dock/tsconfig.json b/packages/business-features/dock/tsconfig.json index ec29a8f75f..9e140d79da 100644 --- a/packages/business-features/dock/tsconfig.json +++ b/packages/business-features/dock/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@k8slens/typescript/config/base.json", - "include": ["**/*.ts", "**/*.tsx"] + "include": ["**/*.ts", "**/*.tsx"], } diff --git a/packages/business-features/dock/webpack.config.js b/packages/business-features/dock/webpack.config.js index 3183f30179..1cda407f5a 100644 --- a/packages/business-features/dock/webpack.config.js +++ b/packages/business-features/dock/webpack.config.js @@ -1 +1 @@ -module.exports = require("@k8slens/webpack").configForNode; +module.exports = require("@k8slens/webpack").configForReact;