diff --git a/src/app/app.less b/src/app/app.less index aab2875f..52aca9d7 100644 --- a/src/app/app.less +++ b/src/app/app.less @@ -476,17 +476,26 @@ a.a-block { } } +.icon.color-default, +.icon.color-green { + path, + circle { + fill: @tab-green; + } + + i { + color: @tab-green; + } +} + .icon.color-red { path, circle { fill: @tab-red; } -} -.icon.color-green { - path, - circle { - fill: @tab-green; + i { + color: @tab-red; } } @@ -495,6 +504,10 @@ a.a-block { circle { fill: @tab-orange; } + + i { + color: @tab-orange; + } } .icon.color-blue { @@ -502,6 +515,10 @@ a.a-block { circle { fill: @tab-blue; } + + i { + color: @tab-blue; + } } .icon.color-yellow { @@ -509,6 +526,10 @@ a.a-block { circle { fill: @tab-yellow; } + + i { + color: @tab-yellow; + } } .icon.color-pink { @@ -516,6 +537,10 @@ a.a-block { circle { fill: @tab-pink; } + + i { + color: @tab-pink; + } } .icon.color-mint { @@ -523,6 +548,10 @@ a.a-block { circle { fill: @tab-mint; } + + i { + color: @tab-mint; + } } .icon.color-cyan { @@ -530,6 +559,10 @@ a.a-block { circle { fill: @tab-cyan; } + + i { + color: @tab-cyan; + } } .icon.color-violet { @@ -537,6 +570,10 @@ a.a-block { circle { fill: @tab-violet; } + + i { + color: @tab-violet; + } } .icon.color-white { @@ -544,6 +581,10 @@ a.a-block { circle { fill: @tab-white; } + + i { + color: @tab-white; + } } .status-icon.status-connected { diff --git a/src/app/app.tsx b/src/app/app.tsx index 7b67a4cd..690b6f08 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -16,12 +16,6 @@ import { PluginsView } from "./pluginsview/pluginsview"; import { BookmarksView } from "./bookmarks/bookmarks"; import { HistoryView } from "./history/history"; import { ConnectionsView } from "./connections/connections"; -import { - ScreenSettingsModal, - SessionSettingsModal, - LineSettingsModal, - ClientSettingsModal, -} from "./common/modals/settings"; import { MainSideBar } from "./sidebar/sidebar"; import { DisconnectedModal, ClientStopModal, ModalsProvider } from "./common/modals/modals"; import { ErrorBoundary } from "./common/error/errorboundary"; diff --git a/src/app/appconst.ts b/src/app/appconst.ts index 172bbf98..aa4c29a0 100644 --- a/src/app/appconst.ts +++ b/src/app/appconst.ts @@ -7,6 +7,7 @@ export const SCREEN_SETTINGS = "screenSettings"; export const SESSION_SETTINGS = "sessionSettings"; export const LINE_SETTINGS = "lineSettings"; export const CLIENT_SETTINGS = "clientSettings"; +export const TAB_SWITCHER = "tabSwitcher"; export const LineContainer_Main = "main"; export const LineContainer_History = "history"; diff --git a/src/app/common/common.less b/src/app/common/common.less index e4a46ae0..8b4b832a 100644 --- a/src/app/common/common.less +++ b/src/app/common/common.less @@ -837,6 +837,14 @@ } } } + + &.no-label { + height: 34px; + + input { + height: 32px; + } + } } .wave-input-decoration { diff --git a/src/app/common/common.tsx b/src/app/common/common.tsx index 87dbccce..c6042382 100644 --- a/src/app/common/common.tsx +++ b/src/app/common/common.tsx @@ -332,7 +332,7 @@ interface TextFieldDecorationProps { endDecoration?: React.ReactNode; } interface TextFieldProps { - label: string; + label?: string; value?: string; className?: string; onChange?: (value: string) => void; @@ -445,10 +445,11 @@ class TextField extends React.Component { return (
{ > {decoration?.startDecoration && <>{decoration.startDecoration}}
- + + + + {props.children} ); @@ -1141,7 +1144,7 @@ class Modal extends React.Component { } render() { - return ReactDOM.createPortal(this.renderModal(), document.getElementById("app") ); + return ReactDOM.createPortal(this.renderModal(), document.getElementById("app")); } } diff --git a/src/app/common/modals/modals.less b/src/app/common/modals/modals.less index 3bdd24c4..f4074539 100644 --- a/src/app/common/modals/modals.less +++ b/src/app/common/modals/modals.less @@ -453,6 +453,89 @@ } } +.tabswitcher-modal { + width: 452px; + min-height: 384px; + + .wave-modal-content { + .wave-modal-body { + display: flex; + padding: 0px; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + width: 100%; + + .textfield-wrapper { + padding: 20px 20px 0px; + + .wave-input-decoration.start-position { + height: 100%; + + .tabswitcher-search-prefix { + opacity: 0.5; + font-size: 13px; + } + } + } + + .list-container { + overflow: hidden; + padding: 10px 0 20px; + width: 100%; + } + + .list-container-inner { + width: 100%; + max-height: 300px; + overflow-y: scroll; + padding: 0 16px 0 20px; + + &::-webkit-scrollbar-thumb { + display: none; + } + + &:hover::-webkit-scrollbar-thumb { + display: block; + } + + .options-list { + width: 100%; + + .search-option { + padding: 5px 5px 5px 8px; + display: flex; + align-items: center; + border: 1px solid transparent; + width: 100%; + overflow: hidden; + + div.tabname { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 5px; + } + + div.icon { + flex-shrink: 0; + width: 20px; + margin-right: 6px; + } + } + + .focused-option { + border: 1px solid rgba(241, 246, 243, 0.15); + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + } + } + } + } + } +} + .screen-settings-tooltip .wave-tooltip-icon { i { font-size: 13px; diff --git a/src/app/common/modals/modals.tsx b/src/app/common/modals/modals.tsx index 19f27376..bbf1df33 100644 --- a/src/app/common/modals/modals.tsx +++ b/src/app/common/modals/modals.tsx @@ -27,6 +27,8 @@ import { import * as util from "../../../util/util"; import * as textmeasure from "../../../util/textmeasure"; import { ClientDataType } from "../../../types/types"; +import { Session, Screen } from "../../../model/model"; +import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg"; import { ReactComponent as WarningIcon } from "../../assets/icons/line/triangle-exclamation.svg"; import shield from "../../assets/icons/shield_check.svg"; @@ -42,6 +44,7 @@ const VERSION = __WAVETERM_VERSION__; let BUILD = __WAVETERM_BUILD__; type OV = mobx.IObservableValue; +type OArr = mobx.IObservableArray; const RemotePtyRows = 9; const RemotePtyCols = 80; @@ -1090,7 +1093,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
- {getName(remote)}  {getImportTooltip(remote)} + {util.getRemoteName(remote)}  {getImportTooltip(remote)}
{this.renderHeaderBtns(remote)}
@@ -1343,7 +1346,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
-
{getName(this.selectedRemote)}
+
{util.getRemoteName(this.selectedRemote)}
{ } } -const getName = (remote: T.RemoteType): string => { - if (remote == null) { - return ""; - } - const { remotealias, remotecanonicalname } = remote; - return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname; +type SwitcherDataType = { + sessionId: string; + sessionName: string; + sessionIdx: number; + screenId: string; + screenIdx: number; + screenName: string; + icon: string; + color: string; }; +const MaxOptionsToDisplay = 100; + +@mobxReact.observer +class TabSwitcherModal extends React.Component<{}, {}> { + screens: Map>[]; + sessions: Map>[]; + options: SwitcherDataType[] = []; + sOptions: OArr = mobx.observable.array(null, { + name: "TabSwitcherModal-sOptions", + }); + focusedIdx: OV = mobx.observable.box(0, { name: "TabSwitcherModal-selectedIdx" }); + activeSessionIdx: number; + optionRefs = []; + listWrapperRef = React.createRef(); + prevFocusedIdx = 0; + + componentDidMount() { + this.activeSessionIdx = GlobalModel.getActiveSession().sessionIdx.get(); + let oSessions = GlobalModel.sessionList; + let oScreens = GlobalModel.screenMap; + oScreens.forEach((oScreen) => { + // Find the matching session in the observable array + let foundSession = oSessions.find((s) => { + if (s.sessionId === oScreen.sessionId && s.archived.get() == false) { + return true; + } + return false; + }); + + if (foundSession) { + let data: SwitcherDataType = { + sessionName: foundSession.name.get(), + sessionId: foundSession.sessionId, + sessionIdx: foundSession.sessionIdx.get(), + screenName: oScreen.name.get(), + screenId: oScreen.screenId, + screenIdx: oScreen.screenIdx.get(), + icon: this.getTabIcon(oScreen), + color: this.getTabColor(oScreen), + }; + this.options.push(data); + } + }); + + mobx.action(() => { + this.sOptions.replace(this.sortOptions(this.options).slice(0, MaxOptionsToDisplay)); + })(); + + document.addEventListener("keydown", this.handleKeyDown); + } + + componentWillUnmount() { + document.removeEventListener("keydown", this.handleKeyDown); + } + + componentDidUpdate() { + let currFocusedIdx = this.focusedIdx.get(); + + // Check if selectedIdx has changed + if (currFocusedIdx !== this.prevFocusedIdx) { + let optionElement = this.optionRefs[currFocusedIdx]?.current; + + if (optionElement) { + optionElement.scrollIntoView({ block: "nearest" }); + } + + // Update prevFocusedIdx for the next update cycle + this.prevFocusedIdx = currFocusedIdx; + } + if (currFocusedIdx >= this.sOptions.length && this.sOptions.length > 0) { + this.setFocusedIndex(this.sOptions.length - 1); + } + } + + @boundMethod + getTabIcon(screen: Screen): string { + let tabIcon = "default"; + let screenOpts = screen.opts.get(); + if (screenOpts != null && !util.isBlank(screenOpts.tabicon)) { + tabIcon = screenOpts.tabicon; + } + return tabIcon; + } + + @boundMethod + getTabColor(screen: Screen): string { + let tabColor = "default"; + let screenOpts = screen.opts.get(); + if (screenOpts != null && !util.isBlank(screenOpts.tabcolor)) { + tabColor = screenOpts.tabcolor; + } + return tabColor; + } + + @boundMethod + handleKeyDown(e) { + if (e.key === "Escape") { + this.closeModal(); + } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + let newIndex = this.calculateNewIndex(e.key === "ArrowUp"); + this.setFocusedIndex(newIndex); + } else if (e.key === "Enter") { + e.preventDefault(); + this.handleSelect(this.focusedIdx.get()); + } + } + + @boundMethod + calculateNewIndex(isUpKey) { + let currentIndex = this.focusedIdx.get(); + if (isUpKey) { + return Math.max(currentIndex - 1, 0); + } else { + return Math.min(currentIndex + 1, this.sOptions.length - 1); + } + } + + @boundMethod + setFocusedIndex(index) { + mobx.action(() => { + this.focusedIdx.set(index); + })(); + } + + @boundMethod + closeModal(): void { + GlobalModel.modalsModel.popModal(); + } + + @boundMethod + handleSelect(index: number): void { + const selectedOption = this.sOptions[index]; + if (selectedOption) { + GlobalCommandRunner.switchScreen(selectedOption.screenId, selectedOption.sessionId); + this.closeModal(); + } + } + + @boundMethod + handleSearch(val: string): void { + let sOptions: SwitcherDataType[]; + if (val == "") { + sOptions = this.sortOptions(this.options).slice(0, MaxOptionsToDisplay); + } else { + sOptions = this.filterOptions(val); + sOptions = this.sortOptions(sOptions); + if (sOptions.length > MaxOptionsToDisplay) { + sOptions = sOptions.slice(0, MaxOptionsToDisplay); + } + } + mobx.action(() => { + this.sOptions.replace(sOptions); + this.focusedIdx.set(0); + })(); + } + + @mobx.computed + @boundMethod + filterOptions(searchInput: string): SwitcherDataType[] { + let filteredScreens = []; + + for (let i = 0; i < this.options.length; i++) { + let tab = this.options[i]; + let match = false; + + if (searchInput.includes("/")) { + let [sessionFilter, screenFilter] = searchInput.split("/").map((s) => s.trim().toLowerCase()); + match = + tab.sessionName.toLowerCase().includes(sessionFilter) && + tab.screenName.toLowerCase().includes(screenFilter); + } else { + match = + tab.sessionName.toLowerCase().includes(searchInput) || + tab.screenName.toLowerCase().includes(searchInput); + } + + // Add tab to filtered list if it matches the criteria + if (match) { + filteredScreens.push(tab); + } + } + + return filteredScreens; + } + + @mobx.computed + @boundMethod + sortOptions(options: SwitcherDataType[]): SwitcherDataType[] { + return options.sort((a, b) => { + let aInCurrentSession = a.sessionIdx === this.activeSessionIdx; + let bInCurrentSession = b.sessionIdx === this.activeSessionIdx; + + // Tabs in the current session are sorted by screenIdx + if (aInCurrentSession && bInCurrentSession) { + return a.screenIdx - b.screenIdx; + } + // a is in the current session and b is not, so a comes first + else if (aInCurrentSession) { + return -1; + } + // b is in the current session and a is not, so b comes first + else if (bInCurrentSession) { + return 1; + } + // Both are in different, non-current sessions - sort by sessionIdx and then by screenIdx + else { + if (a.sessionIdx === b.sessionIdx) { + return a.screenIdx - b.screenIdx; + } else { + return a.sessionIdx - b.sessionIdx; + } + } + }); + } + + @boundMethod + renderIcon(option: SwitcherDataType): React.ReactNode { + let tabIcon = option.icon; + if (tabIcon === "default" || tabIcon === "square") { + return ; + } + return ; + } + + @boundMethod + renderOption(option: SwitcherDataType, index: number): JSX.Element { + if (!this.optionRefs[index]) { + this.optionRefs[index] = React.createRef(); + } + return ( +
this.handleSelect(index)} + > +
{this.renderIcon(option)}
+
+ #{option.sessionName} / {option.screenName} +
+
+ ); + } + + render() { + let option: SwitcherDataType; + let index: number; + return ( + +
+
+ +
Switch to Tab:
+ + ), + endDecoration: ( + + } + > + + + + ), + }} + /> +
+
+
+
+ + {this.renderOption(option, index)} + +
+
+
+
+
+ ); + } +} + const getImportTooltip = (remote: T.RemoteType): React.ReactElement => { if (remote.sshconfigsrc == "sshconfig-import") { return ( @@ -1493,4 +1791,5 @@ export { ViewRemoteConnDetailModal, EditRemoteConnModal, ModalsProvider, + TabSwitcherModal, }; diff --git a/src/app/common/modals/modalsRegistry.tsx b/src/app/common/modals/modalsRegistry.tsx index 833d99d4..36cedf7e 100644 --- a/src/app/common/modals/modalsRegistry.tsx +++ b/src/app/common/modals/modalsRegistry.tsx @@ -8,6 +8,7 @@ import { ViewRemoteConnDetailModal, EditRemoteConnModal, AlertModal, + TabSwitcherModal, } from "./modals"; import { ScreenSettingsModal, SessionSettingsModal, LineSettingsModal, ClientSettingsModal } from "./settings"; import * as constants from "../../appconst"; @@ -22,6 +23,7 @@ const modalsRegistry: { [key: string]: () => React.ReactElement } = { [constants.SESSION_SETTINGS]: () => , [constants.LINE_SETTINGS]: () => , [constants.CLIENT_SETTINGS]: () => , + [constants.TAB_SWITCHER]: () => , }; export { modalsRegistry }; diff --git a/src/electron/emain.ts b/src/electron/emain.ts index d3066d48..d4819add 100644 --- a/src/electron/emain.ts +++ b/src/electron/emain.ts @@ -173,10 +173,10 @@ let menuTemplate = [ role: "appMenu", submenu: [ { - label: 'About Wave Terminal', + label: "About Wave Terminal", click: () => { - MainWindow?.webContents.send('menu-item-about'); - } + MainWindow?.webContents.send("menu-item-about"); + }, }, { type: "separator" }, { role: "services" }, @@ -250,7 +250,7 @@ function createMainWindow(clientData) { minWidth: 800, minHeight: 600, transparent: true, - icon: (unamePlatform == "linux") ? "public/logos/wave-logo-dark.png" : undefined, + icon: unamePlatform == "linux" ? "public/logos/wave-logo-dark.png" : undefined, webPreferences: { preload: path.join(getAppBasePath(), DistDir, "preload.js"), }, @@ -302,6 +302,11 @@ function createMainWindow(clientData) { e.preventDefault(); return; } + if (input.code == "KeyP" && input.meta) { + win.webContents.send("p-cmd", mods); + e.preventDefault(); + return; + } if (input.meta && (input.code == "ArrowUp" || input.code == "ArrowDown")) { if (input.code == "ArrowUp") { win.webContents.send("meta-arrowup"); diff --git a/src/electron/preload.js b/src/electron/preload.js index 2b07a1e9..6dc8f108 100644 --- a/src/electron/preload.js +++ b/src/electron/preload.js @@ -13,6 +13,7 @@ contextBridge.exposeInMainWorld("api", { onLCmd: (callback) => ipcRenderer.on("l-cmd", callback), onHCmd: (callback) => ipcRenderer.on("h-cmd", callback), onWCmd: (callback) => ipcRenderer.on("w-cmd", callback), + onPCmd: (callback) => ipcRenderer.on("p-cmd", callback), onMetaArrowUp: (callback) => ipcRenderer.on("meta-arrowup", callback), onMetaArrowDown: (callback) => ipcRenderer.on("meta-arrowdown", callback), onMetaPageUp: (callback) => ipcRenderer.on("meta-pageup", callback), diff --git a/src/model/model.ts b/src/model/model.ts index aeebc424..a4cec535 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -197,6 +197,7 @@ type ElectronApi = { onICmd: (callback: (mods: KeyModsType) => void) => void; onLCmd: (callback: (mods: KeyModsType) => void) => void; onHCmd: (callback: (mods: KeyModsType) => void) => void; + onPCmd: (callback: (mods: KeyModsType) => void) => void; onMenuItemAbout: (callback: () => void) => void; onMetaArrowUp: (callback: () => void) => void; onMetaArrowDown: (callback: () => void) => void; @@ -3207,6 +3208,7 @@ class Model { getApi().onICmd(this.onICmd.bind(this)); getApi().onLCmd(this.onLCmd.bind(this)); getApi().onHCmd(this.onHCmd.bind(this)); + getApi().onPCmd(this.onPCmd.bind(this)); getApi().onMenuItemAbout(this.onMenuItemAbout.bind(this)); getApi().onMetaArrowUp(this.onMetaArrowUp.bind(this)); getApi().onMetaArrowDown(this.onMetaArrowDown.bind(this)); @@ -3535,6 +3537,10 @@ class Model { GlobalModel.historyViewModel.reSearch(); } + onPCmd(e: any, mods: KeyModsType) { + GlobalModel.modalsModel.pushModal(appconst.TAB_SWITCHER); + } + getFocusedLine(): LineFocusType { if (this.inputModel.hasFocus()) { return { cmdInputFocus: true }; @@ -4268,11 +4274,15 @@ class CommandRunner { GlobalModel.submitCommand("session", null, [session], { nohist: "1" }, false); } - switchScreen(screen: string) { + switchScreen(screen: string, session?: string) { mobx.action(() => { GlobalModel.activeMainView.set("session"); })(); - GlobalModel.submitCommand("screen", null, [screen], { nohist: "1" }, false); + let kwargs = { nohist: "1" }; + if (session != null) { + kwargs["session"] = session; + } + GlobalModel.submitCommand("screen", null, [screen], kwargs, false); } lineView(sessionId: string, screenId: string, lineNum?: number) { diff --git a/src/util/util.ts b/src/util/util.ts index 252ba3c6..b5d9ee62 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -404,6 +404,14 @@ function commandRtnHandler(prtn: Promise, errorMessage: OV