Cmd-P feature -- Quick Tab Selector (#200)

* register cmd+p shortcut

* tab switcher modal

* initial implementation

* tab switcher modal content

* fix styles

* fix scroll bugs

* set selected index when clicking option

* hover effect for options

* switch when Enter key is pressed

* remove fuse.js

* only use switchscreen for switching sessions and tabs

* var naming changes

* fix multiple focused options on mouse hover

* fix duplicate focused options and scrollbar

* clean imports

* fix wrong function name

* merge color styles in app.less

* remove debugging code

* use For component when iterating thru options

* minor style fix

* remove mouse interaction, keep focusedIdx in bounds, increase max number of tabs shown, small layout adjustment for big tab names (and more space for icon)
This commit is contained in:
Red J Adaya 2023-12-31 14:34:05 +08:00 committed by GitHub
parent 8d88e2cf94
commit 452836bffc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 494 additions and 38 deletions

View File

@ -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 {

View File

@ -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";

View File

@ -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";

View File

@ -837,6 +837,14 @@
}
}
}
&.no-label {
height: 34px;
input {
height: 32px;
}
}
}
.wave-input-decoration {

View File

@ -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<TextFieldProps, TextFieldState> {
return (
<div
className={cn(`wave-textfield ${className || ""}`, {
className={cn("wave-textfield", className, {
focused: focused,
error: error,
disabled: disabled,
"no-label": !label,
})}
onFocus={this.handleComponentFocus}
onBlur={this.handleComponentBlur}
@ -456,15 +457,17 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div className="wave-textfield-inner">
<label
className={cn("wave-textfield-inner-label", {
float: this.state.hasContent || this.state.focused || placeholder,
"offset-left": decoration?.startDecoration,
})}
htmlFor={label}
>
{label}
</label>
<If condition={label}>
<label
className={cn("wave-textfield-inner-label", {
float: this.state.hasContent || this.state.focused || placeholder,
"offset-left": decoration?.startDecoration,
})}
htmlFor={label}
>
{label}
</label>
</If>
<input
className={cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration })}
ref={this.inputRef}
@ -774,7 +777,7 @@ class InfoMessage extends React.Component<{ width: number; children: React.React
function LinkRenderer(props: any): any {
let newUrl = "https://extern?" + encodeURIComponent(props.href);
return (
<a href={newUrl} target="_blank" rel={"noopener"}>
<a href={newUrl} target="_blank" rel={"noopener"}>
{props.children}
</a>
);
@ -1141,7 +1144,7 @@ class Modal extends React.Component<ModalProps> {
}
render() {
return ReactDOM.createPortal(this.renderModal(), document.getElementById("app") );
return ReactDOM.createPortal(this.renderModal(), document.getElementById("app"));
}
}

View File

@ -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;

View File

@ -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<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>;
const RemotePtyRows = 9;
const RemotePtyCols = 80;
@ -1090,7 +1093,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
<div className="wave-modal-body">
<div className="name-header-actions-wrapper">
<div className="name text-primary name-wrapper">
{getName(remote)}&nbsp; {getImportTooltip(remote)}
{util.getRemoteName(remote)}&nbsp; {getImportTooltip(remote)}
</div>
<div className="header-actions">{this.renderHeaderBtns(remote)}</div>
</div>
@ -1343,7 +1346,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
<div className="wave-modal-body">
<div className="name-actions-section">
<div className="name text-primary">{getName(this.selectedRemote)}</div>
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
</div>
<div className="alias-section">
<TextField
@ -1459,14 +1462,309 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
}
}
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<string, OV<string>>[];
sessions: Map<string, OV<string>>[];
options: SwitcherDataType[] = [];
sOptions: OArr<SwitcherDataType> = mobx.observable.array(null, {
name: "TabSwitcherModal-sOptions",
});
focusedIdx: OV<number> = mobx.observable.box(0, { name: "TabSwitcherModal-selectedIdx" });
activeSessionIdx: number;
optionRefs = [];
listWrapperRef = React.createRef<HTMLDivElement>();
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 <SquareIcon className="left-icon" />;
}
return <i className={`fa-sharp fa-solid fa-${tabIcon}`}></i>;
}
@boundMethod
renderOption(option: SwitcherDataType, index: number): JSX.Element {
if (!this.optionRefs[index]) {
this.optionRefs[index] = React.createRef();
}
return (
<div
key={option.sessionId + "/" + option.screenId}
ref={this.optionRefs[index]}
className={cn("search-option unselectable", {
"focused-option": this.focusedIdx.get() === index,
})}
onClick={() => this.handleSelect(index)}
>
<div className={cn("icon", "color-" + option.color)}>{this.renderIcon(option)}</div>
<div className="tabname">
#{option.sessionName} / {option.screenName}
</div>
</div>
);
}
render() {
let option: SwitcherDataType;
let index: number;
return (
<Modal className="tabswitcher-modal">
<div className="wave-modal-body">
<div className="textfield-wrapper">
<TextField
onChange={this.handleSearch}
maxLength={400}
autoFocus={true}
decoration={{
startDecoration: (
<InputDecoration position="start">
<div className="tabswitcher-search-prefix">Switch to Tab:</div>
</InputDecoration>
),
endDecoration: (
<InputDecoration>
<Tooltip
message={`Type to filter workspaces and tabs.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="list-container">
<div ref={this.listWrapperRef} className="list-container-inner">
<div className="options-list">
<For each="option" index="index" of={this.sOptions}>
{this.renderOption(option, index)}
</For>
</div>
</div>
</div>
</div>
</Modal>
);
}
}
const getImportTooltip = (remote: T.RemoteType): React.ReactElement<any, any> => {
if (remote.sshconfigsrc == "sshconfig-import") {
return (
@ -1493,4 +1791,5 @@ export {
ViewRemoteConnDetailModal,
EditRemoteConnModal,
ModalsProvider,
TabSwitcherModal,
};

View File

@ -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]: () => <SessionSettingsModal />,
[constants.LINE_SETTINGS]: () => <LineSettingsModal />,
[constants.CLIENT_SETTINGS]: () => <ClientSettingsModal />,
[constants.TAB_SWITCHER]: () => <TabSwitcherModal />,
};
export { modalsRegistry };

View File

@ -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");

View File

@ -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),

View File

@ -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) {

View File

@ -404,6 +404,14 @@ function commandRtnHandler(prtn: Promise<CommandRtnType>, errorMessage: OV<strin
});
}
function getRemoteName(remote: RemoteType): string {
if (remote == null) {
return "";
}
let { remotealias, remotecanonicalname } = remote;
return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname;
}
export {
handleJsonFetchResponse,
base64ToArray,
@ -428,4 +436,5 @@ export {
getColorRGB,
commandRtnHandler,
getRemoteConnVal,
getRemoteName,
};