mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
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:
parent
59aef86e77
commit
15485d7235
@ -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[] {
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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 = {};
|
||||||
|
@ -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),
|
||||||
});
|
});
|
||||||
|
@ -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
42
src/models/contextmenu.ts
Normal 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 };
|
@ -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";
|
||||||
|
|
||||||
|
@ -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 };
|
||||||
|
@ -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
18
src/types/custom.d.ts
vendored
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user