New Context Menu Model (and implement custom block context menu) (#569)

* starting work on new dynamic context menu system

* untested contextmenu model integrated with electron api

* implement custom line context menu, copy visible output + copy full output

* implement minimize/maximize, restart, and delete
This commit is contained in:
Mike Sawka 2024-04-10 23:47:33 -07:00 committed by GitHub
parent 59aef86e77
commit 15485d7235
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 190 additions and 20 deletions

View File

@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react";
import * as mobx from "mobx"; import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components"; import { If } from "tsx-control-statements/components";
import { GlobalModel, GlobalCommandRunner, RemotesModel, getApi } from "@/models"; import { GlobalModel, GlobalCommandRunner, RemotesModel } from "@/models";
import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "@/common/elements"; import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "@/common/elements";
import { commandRtnHandler, isBlank } from "@/util/util"; import { commandRtnHandler, isBlank } from "@/util/util";
import { getTermThemes } from "@/util/themeutil"; import { getTermThemes } from "@/util/themeutil";
@ -70,7 +70,7 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
return; return;
} }
const prtn = GlobalCommandRunner.setTheme(themeSource, false); const prtn = GlobalCommandRunner.setTheme(themeSource, false);
getApi().setNativeThemeSource(themeSource); GlobalModel.getElectronApi().setNativeThemeSource(themeSource);
commandRtnHandler(prtn, this.errorMessage); commandRtnHandler(prtn, this.errorMessage);
} }
@ -107,7 +107,7 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
prtn = GlobalCommandRunner.releaseCheckAutoOff(false); prtn = GlobalCommandRunner.releaseCheckAutoOff(false);
} }
commandRtnHandler(prtn, this.errorMessage); commandRtnHandler(prtn, this.errorMessage);
getApi().changeAutoUpdate(val); GlobalModel.getElectronApi().changeAutoUpdate(val);
} }
getFontSizes(): DropdownItem[] { getFontSizes(): DropdownItem[] {

View File

@ -5,7 +5,7 @@ import * as React from "react";
import * as mobxReact from "mobx-react"; import * as mobxReact from "mobx-react";
import * as mobx from "mobx"; import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import { GlobalModel, getApi } from "@/models"; import { GlobalModel } from "@/models";
import { Modal, LinkButton } from "@/elements"; import { Modal, LinkButton } from "@/elements";
import * as util from "@/util/util"; import * as util from "@/util/util";
import * as appconst from "@/app/appconst"; import * as appconst from "@/app/appconst";
@ -26,7 +26,7 @@ class AboutModal extends React.Component<{}, {}> {
@boundMethod @boundMethod
updateApp(): void { updateApp(): void {
getApi().installAppUpdate(); GlobalModel.getElectronApi().installAppUpdate();
} }
@boundMethod @boundMethod

View File

@ -665,6 +665,66 @@ class LineCmd extends React.Component<
}; };
}; };
@boundMethod
handleContextMenu(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
let { line, screen } = this.props;
const containerType = screen.getContainerType();
const isMainContainer = containerType == appconst.LineContainer_Main;
let menu: ContextMenuItem[] = [
{ role: "copy", label: "Copy", type: "normal" },
{ role: "paste", label: "Paste", type: "normal" },
{ type: "separator" },
{ label: "Copy Command", click: () => this.copyCommandStr() },
];
const isTerminal = isBlank(line.renderer) || line.renderer == "terminal";
if (isTerminal) {
menu.push({ label: "Copy Visible Output", click: () => this.copyOutput(false) });
menu.push({ label: "Copy Full Output", click: () => this.copyOutput(true) });
}
if (isMainContainer) {
menu.push({ type: "separator" });
const isMinimized = line.linestate["wave:min"];
if (isMinimized) {
menu.push({
label: "Show Block Output",
click: () => GlobalCommandRunner.lineMinimize(line.lineid, false, true),
});
} else {
menu.push({
label: "Hide Block Output",
click: () => GlobalCommandRunner.lineMinimize(line.lineid, true, true),
});
}
menu.push({ type: "separator" });
menu.push({ label: "Restart Line", click: () => GlobalCommandRunner.lineRestart(line.lineid, true) });
menu.push({ type: "separator" });
menu.push({ label: "Delete Block", click: () => GlobalCommandRunner.lineDelete(line.lineid, true) });
}
GlobalModel.contextMenuModel.showContextMenu(menu, { x: e.clientX, y: e.clientY });
}
copyCommandStr() {
const { line, screen } = this.props;
const cmd: Cmd = screen.getCmd(line);
if (cmd != null) {
navigator.clipboard.writeText(cmd.getCmdStr());
}
}
copyOutput(fullOutput: boolean) {
const { line, screen } = this.props;
let termWrap = screen.getTermWrap(line.lineid);
if (termWrap == null) {
return;
}
let outputStr = termWrap.getOutput(fullOutput);
if (fullOutput != null) {
navigator.clipboard.writeText(outputStr);
}
}
render() { render() {
const { screen, line, width, staticRender, visible } = this.props; const { screen, line, width, staticRender, visible } = this.props;
const isVisible = visible.get(); const isVisible = visible.get();
@ -750,6 +810,7 @@ class LineCmd extends React.Component<
data-lineid={line.lineid} data-lineid={line.lineid}
data-linenum={line.linenum} data-linenum={line.linenum}
data-screenid={line.screenid} data-screenid={line.screenid}
onContextMenu={this.handleContextMenu}
> >
<If condition={isSelected || cmdError}> <If condition={isSelected || cmdError}>
<div key="mask" className={cn("line-mask", { "error-mask": cmdError })}></div> <div key="mask" className={cn("line-mask", { "error-mask": cmdError })}></div>

View File

@ -470,6 +470,27 @@ electron.ipcMain.on("toggle-developer-tools", (event) => {
event.returnValue = true; event.returnValue = true;
}); });
electron.ipcMain.on("contextmenu-show", (event, menuDefArr, { x, y }) => {
if (menuDefArr == null || menuDefArr.length == 0) {
return;
}
const menu = new electron.Menu();
for (const menuDef of menuDefArr) {
const menuItemTemplate = {
role: menuDef.role,
label: menuDef.label,
type: menuDef.type,
click: () => {
MainWindow?.webContents.send("contextmenu-click", menuDef.id);
},
};
const menuItem = new electron.MenuItem(menuItemTemplate);
menu.append(menuItem);
}
menu.popup({ x, y });
event.returnValue = true;
});
electron.ipcMain.on("hide-window", (event) => { electron.ipcMain.on("hide-window", (event) => {
if (MainWindow != null) { if (MainWindow != null) {
MainWindow.hide(); MainWindow.hide();
@ -676,12 +697,6 @@ function runWaveSrv() {
return rtnPromise; return rtnPromise;
} }
electron.ipcMain.on("context-screen", (_, { screenId }, { x, y }) => {
console.log("context-screen", screenId);
const menu = getContextMenu();
menu.popup({ x, y });
});
electron.ipcMain.on("context-editmenu", (_, { x, y }, opts) => { electron.ipcMain.on("context-editmenu", (_, { x, y }, opts) => {
if (opts == null) { if (opts == null) {
opts = {}; opts = {};

View File

@ -27,8 +27,9 @@ contextBridge.exposeInMainWorld("api", {
onAppUpdateStatus: (callback) => ipcRenderer.on("app-update-status", (_, val) => callback(val)), onAppUpdateStatus: (callback) => ipcRenderer.on("app-update-status", (_, val) => callback(val)),
onZoomChanged: (callback) => ipcRenderer.on("zoom-changed", callback), onZoomChanged: (callback) => ipcRenderer.on("zoom-changed", callback),
onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback),
contextScreen: (screenOpts, position) => ipcRenderer.send("context-screen", screenOpts, position),
contextEditMenu: (position, opts) => ipcRenderer.send("context-editmenu", position, opts), contextEditMenu: (position, opts) => ipcRenderer.send("context-editmenu", position, opts),
onWaveSrvStatusChange: (callback) => ipcRenderer.on("wavesrv-status-change", callback), onWaveSrvStatusChange: (callback) => ipcRenderer.on("wavesrv-status-change", callback),
onToggleDevUI: (callback) => ipcRenderer.on("toggle-devui", callback), onToggleDevUI: (callback) => ipcRenderer.on("toggle-devui", callback),
showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position),
onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", callback),
}); });

View File

@ -9,7 +9,6 @@ import { App } from "@/app/app";
import * as DOMPurify from "dompurify"; import * as DOMPurify from "dompurify";
import { loadFonts } from "@/util/fontutil"; import { loadFonts } from "@/util/fontutil";
import * as textmeasure from "@/util/textmeasure"; import * as textmeasure from "@/util/textmeasure";
import { getApi } from "@/models";
// @ts-ignore // @ts-ignore
let VERSION = __WAVETERM_VERSION__; let VERSION = __WAVETERM_VERSION__;

42
src/models/contextmenu.ts Normal file
View File

@ -0,0 +1,42 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import type { Model } from "./model";
import { v4 as uuidv4 } from "uuid";
class ContextMenuModel {
globalModel: Model;
handlers: Map<string, () => void> = new Map(); // id -> handler
constructor(globalModel: Model) {
this.globalModel = globalModel;
this.globalModel.getElectronApi().onContextMenuClick(this.handleContextMenuClick.bind(this));
}
handleContextMenuClick(e: any, id: string): void {
let handler = this.handlers.get(id);
if (handler) {
handler();
}
}
showContextMenu(menu: ContextMenuItem[], position: { x: number; y: number }): void {
this.handlers.clear();
let electronMenuItems: ElectronContextMenuItem[] = [];
for (let item of menu) {
let electronItem: ElectronContextMenuItem = {
role: item.role,
type: item.type,
label: item.label,
id: uuidv4(),
};
if (item.click) {
this.handlers.set(electronItem.id, item.click);
}
electronMenuItems.push(electronItem);
}
this.globalModel.getElectronApi().showContextMenu(electronMenuItems, position);
}
}
export { ContextMenuModel };

View File

@ -6,7 +6,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import { isBlank } from "@/util/util"; import { isBlank } from "@/util/util";
import * as appconst from "@/app/appconst"; import * as appconst from "@/app/appconst";
import { Model } from "./model"; import type { Model } from "./model";
import { GlobalCommandRunner } from "./global"; import { GlobalCommandRunner } from "./global";
import { app } from "electron"; import { app } from "electron";

View File

@ -32,6 +32,7 @@ import { MainSidebarModel } from "./mainsidebar";
import { RightSidebarModel } from "./rightsidebar"; import { RightSidebarModel } from "./rightsidebar";
import { Screen } from "./screen"; import { Screen } from "./screen";
import { Cmd } from "./cmd"; import { Cmd } from "./cmd";
import { ContextMenuModel } from "./contextmenu";
import { GlobalCommandRunner } from "./global"; import { GlobalCommandRunner } from "./global";
import { clearMonoFontCache, getMonoFontSize } from "@/util/textmeasure"; import { clearMonoFontCache, getMonoFontSize } from "@/util/textmeasure";
import type { TermWrap } from "@/plugins/terminal/term"; import type { TermWrap } from "@/plugins/terminal/term";
@ -121,6 +122,7 @@ class Model {
modalsModel: ModalsModel; modalsModel: ModalsModel;
mainSidebarModel: MainSidebarModel; mainSidebarModel: MainSidebarModel;
rightSidebarModel: RightSidebarModel; rightSidebarModel: RightSidebarModel;
contextMenuModel: ContextMenuModel;
isDarkTheme: OV<boolean> = mobx.observable.box(getApi().getShouldUseDarkColors(), { isDarkTheme: OV<boolean> = mobx.observable.box(getApi().getShouldUseDarkColors(), {
name: "isDarkTheme", name: "isDarkTheme",
}); });
@ -177,6 +179,7 @@ class Model {
this.modalsModel = new ModalsModel(); this.modalsModel = new ModalsModel();
this.mainSidebarModel = new MainSidebarModel(this); this.mainSidebarModel = new MainSidebarModel(this);
this.rightSidebarModel = new RightSidebarModel(this); this.rightSidebarModel = new RightSidebarModel(this);
this.contextMenuModel = new ContextMenuModel(this);
const isWaveSrvRunning = getApi().getWaveSrvStatus(); const isWaveSrvRunning = getApi().getWaveSrvStatus();
this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, { this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, {
name: "model-wavesrv-running", name: "model-wavesrv-running",
@ -378,7 +381,7 @@ class Model {
refocus() { refocus() {
// givefocus() give back focus to cmd or input // givefocus() give back focus to cmd or input
const activeScreen = this.getActiveScreen(); const activeScreen = this.getActiveScreen();
if (screen == null) { if (activeScreen == null) {
return; return;
} }
activeScreen.giveFocus(); activeScreen.giveFocus();
@ -707,10 +710,6 @@ class Model {
GlobalCommandRunner.setTermUsedRows(context, height); GlobalCommandRunner.setTermUsedRows(context, height);
} }
contextScreen(e: any, screenId: string) {
getApi().contextScreen({ screenId: screenId }, { x: e.x, y: e.y });
}
contextEditMenu(e: any, opts: ContextMenuOpts) { contextEditMenu(e: any, opts: ContextMenuOpts) {
getApi().contextEditMenu({ x: e.x, y: e.y }, opts); getApi().contextEditMenu({ x: e.x, y: e.y }, opts);
} }
@ -1801,6 +1800,10 @@ class Model {
this.appUpdateStatus.set(status); this.appUpdateStatus.set(status);
})(); })();
} }
getElectronApi(): ElectronApi {
return getApi();
}
} }
export { Model, getApi }; export { Model, getApi };

View File

@ -287,6 +287,39 @@ class TermWrap {
return usedRows; return usedRows;
} }
// gets the text output of the terminal (respects line wrapping)
// if fullOutput is true, returns all output, otherwise only the visible output
getOutput(fullOutput: boolean): string {
let activeBuf = this.terminal?.buffer?.active;
if (activeBuf == null) {
return null;
}
const totalLines = activeBuf.length;
let output = [];
let emptyStart = -1;
let startLine = fullOutput ? 0 : activeBuf.viewportY;
for (let i = startLine; i < totalLines; i++) {
const line = activeBuf.getLine(i);
const lineStr = line?.translateToString(true) ?? "";
if (lineStr == "") {
if (emptyStart == -1) {
emptyStart = output.length;
}
} else {
emptyStart = -1;
}
if (line?.isWrapped) {
output[output.length - 1] += lineStr;
} else {
output.push(lineStr);
}
}
if (emptyStart != -1) {
output = output.slice(0, emptyStart);
}
return output.join("\n");
}
updateUsedRows(forceFull: boolean, reason: string) { updateUsedRows(forceFull: boolean, reason: string) {
if (this.terminal == null) { if (this.terminal == null) {
return; return;

18
src/types/custom.d.ts vendored
View File

@ -942,11 +942,27 @@ declare global {
onAppUpdateStatus: (callback: (status: AppUpdateStatusType) => void) => void; onAppUpdateStatus: (callback: (status: AppUpdateStatusType) => void) => void;
onZoomChanged: (callback: () => void) => void; onZoomChanged: (callback: () => void) => void;
onMenuItemAbout: (callback: () => void) => void; onMenuItemAbout: (callback: () => void) => void;
contextScreen: (screenOpts: { screenId: string }, position: { x: number; y: number }) => void;
contextEditMenu: (position: { x: number; y: number }, opts: ContextMenuOpts) => void; contextEditMenu: (position: { x: number; y: number }, opts: ContextMenuOpts) => void;
onWaveSrvStatusChange: (callback: (status: boolean, pid: number) => void) => void; onWaveSrvStatusChange: (callback: (status: boolean, pid: number) => void) => void;
getLastLogs: (numOfLines: number, callback: (logs: any) => void) => void; getLastLogs: (numOfLines: number, callback: (logs: any) => void) => void;
onToggleDevUI: (callback: () => void) => void; onToggleDevUI: (callback: () => void) => void;
showContextMenu: (menu: ElectronContextMenuItem[], position: { x: number; y: number }) => void;
onContextMenuClick: (callback: (id: string) => void) => void;
};
type ElectronContextMenuItem = {
id: string; // unique id, used for communication
label: string;
role?: string; // electron role (optional)
type?: "separator" | "normal";
};
// possible to add support for submenus when needed
type ContextMenuItem = {
label?: string;
type?: "separator" | "normal";
role?: string; // electron role (optional)
click?: () => void; // not required if role is set
}; };
} }