diff --git a/src/app/clientsettings/clientsettings.tsx b/src/app/clientsettings/clientsettings.tsx index 92b328259..fa3325d11 100644 --- a/src/app/clientsettings/clientsettings.tsx +++ b/src/app/clientsettings/clientsettings.tsx @@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; 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 { commandRtnHandler, isBlank } from "@/util/util"; import { getTermThemes } from "@/util/themeutil"; @@ -70,7 +70,7 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove return; } const prtn = GlobalCommandRunner.setTheme(themeSource, false); - getApi().setNativeThemeSource(themeSource); + GlobalModel.getElectronApi().setNativeThemeSource(themeSource); commandRtnHandler(prtn, this.errorMessage); } @@ -107,7 +107,7 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove prtn = GlobalCommandRunner.releaseCheckAutoOff(false); } commandRtnHandler(prtn, this.errorMessage); - getApi().changeAutoUpdate(val); + GlobalModel.getElectronApi().changeAutoUpdate(val); } getFontSizes(): DropdownItem[] { diff --git a/src/app/common/modals/about.tsx b/src/app/common/modals/about.tsx index 363d3d0ed..e6fff8ccb 100644 --- a/src/app/common/modals/about.tsx +++ b/src/app/common/modals/about.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; -import { GlobalModel, getApi } from "@/models"; +import { GlobalModel } from "@/models"; import { Modal, LinkButton } from "@/elements"; import * as util from "@/util/util"; import * as appconst from "@/app/appconst"; @@ -26,7 +26,7 @@ class AboutModal extends React.Component<{}, {}> { @boundMethod updateApp(): void { - getApi().installAppUpdate(); + GlobalModel.getElectronApi().installAppUpdate(); } @boundMethod diff --git a/src/app/line/linecomps.tsx b/src/app/line/linecomps.tsx index d50e65ed9..6d45777da 100644 --- a/src/app/line/linecomps.tsx +++ b/src/app/line/linecomps.tsx @@ -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() { const { screen, line, width, staticRender, visible } = this.props; const isVisible = visible.get(); @@ -750,6 +810,7 @@ class LineCmd extends React.Component< data-lineid={line.lineid} data-linenum={line.linenum} data-screenid={line.screenid} + onContextMenu={this.handleContextMenu} >
diff --git a/src/electron/emain.ts b/src/electron/emain.ts index dd421b6c9..805d67e03 100644 --- a/src/electron/emain.ts +++ b/src/electron/emain.ts @@ -470,6 +470,27 @@ electron.ipcMain.on("toggle-developer-tools", (event) => { 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) => { if (MainWindow != null) { MainWindow.hide(); @@ -676,12 +697,6 @@ function runWaveSrv() { 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) => { if (opts == null) { opts = {}; diff --git a/src/electron/preload.js b/src/electron/preload.js index 819610776..04163c7b6 100644 --- a/src/electron/preload.js +++ b/src/electron/preload.js @@ -27,8 +27,9 @@ contextBridge.exposeInMainWorld("api", { onAppUpdateStatus: (callback) => ipcRenderer.on("app-update-status", (_, val) => callback(val)), onZoomChanged: (callback) => ipcRenderer.on("zoom-changed", 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), onWaveSrvStatusChange: (callback) => ipcRenderer.on("wavesrv-status-change", callback), onToggleDevUI: (callback) => ipcRenderer.on("toggle-devui", callback), + showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), + onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", callback), }); diff --git a/src/index.ts b/src/index.ts index 1443eed24..8c99a11e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,6 @@ import { App } from "@/app/app"; import * as DOMPurify from "dompurify"; import { loadFonts } from "@/util/fontutil"; import * as textmeasure from "@/util/textmeasure"; -import { getApi } from "@/models"; // @ts-ignore let VERSION = __WAVETERM_VERSION__; diff --git a/src/models/contextmenu.ts b/src/models/contextmenu.ts new file mode 100644 index 000000000..50264d6fd --- /dev/null +++ b/src/models/contextmenu.ts @@ -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 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 }; diff --git a/src/models/input.ts b/src/models/input.ts index 3957fa2e2..9f352bfae 100644 --- a/src/models/input.ts +++ b/src/models/input.ts @@ -6,7 +6,7 @@ import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import { isBlank } from "@/util/util"; import * as appconst from "@/app/appconst"; -import { Model } from "./model"; +import type { Model } from "./model"; import { GlobalCommandRunner } from "./global"; import { app } from "electron"; diff --git a/src/models/model.ts b/src/models/model.ts index 898871b79..6e2a6b9e3 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -32,6 +32,7 @@ import { MainSidebarModel } from "./mainsidebar"; import { RightSidebarModel } from "./rightsidebar"; import { Screen } from "./screen"; import { Cmd } from "./cmd"; +import { ContextMenuModel } from "./contextmenu"; import { GlobalCommandRunner } from "./global"; import { clearMonoFontCache, getMonoFontSize } from "@/util/textmeasure"; import type { TermWrap } from "@/plugins/terminal/term"; @@ -121,6 +122,7 @@ class Model { modalsModel: ModalsModel; mainSidebarModel: MainSidebarModel; rightSidebarModel: RightSidebarModel; + contextMenuModel: ContextMenuModel; isDarkTheme: OV = mobx.observable.box(getApi().getShouldUseDarkColors(), { name: "isDarkTheme", }); @@ -177,6 +179,7 @@ class Model { this.modalsModel = new ModalsModel(); this.mainSidebarModel = new MainSidebarModel(this); this.rightSidebarModel = new RightSidebarModel(this); + this.contextMenuModel = new ContextMenuModel(this); const isWaveSrvRunning = getApi().getWaveSrvStatus(); this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, { name: "model-wavesrv-running", @@ -378,7 +381,7 @@ class Model { refocus() { // givefocus() give back focus to cmd or input const activeScreen = this.getActiveScreen(); - if (screen == null) { + if (activeScreen == null) { return; } activeScreen.giveFocus(); @@ -707,10 +710,6 @@ class Model { GlobalCommandRunner.setTermUsedRows(context, height); } - contextScreen(e: any, screenId: string) { - getApi().contextScreen({ screenId: screenId }, { x: e.x, y: e.y }); - } - contextEditMenu(e: any, opts: ContextMenuOpts) { getApi().contextEditMenu({ x: e.x, y: e.y }, opts); } @@ -1801,6 +1800,10 @@ class Model { this.appUpdateStatus.set(status); })(); } + + getElectronApi(): ElectronApi { + return getApi(); + } } export { Model, getApi }; diff --git a/src/plugins/terminal/term.ts b/src/plugins/terminal/term.ts index d9862a2ae..486a466b7 100644 --- a/src/plugins/terminal/term.ts +++ b/src/plugins/terminal/term.ts @@ -287,6 +287,39 @@ class TermWrap { 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) { if (this.terminal == null) { return; diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index 30003e0a2..b5c0d1a6f 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -942,11 +942,27 @@ declare global { onAppUpdateStatus: (callback: (status: AppUpdateStatusType) => void) => void; onZoomChanged: (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; onWaveSrvStatusChange: (callback: (status: boolean, pid: number) => void) => void; getLastLogs: (numOfLines: number, callback: (logs: any) => 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 }; }