mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
Workspace app menu (#1423)
Adds a new app menu for creating a new workspace or switching to an existing one. This required adding a new WPS event any time a workspace gets updated, since the Electron app menus are static. This also fixes a bug where closing a workspace could delete it if it didn't have both a pinned and an unpinned tab.
This commit is contained in:
parent
66d1686e84
commit
72ea58267d
@ -1,14 +1,21 @@
|
|||||||
// Copyright 2024, Command Line Inc.
|
// Copyright 2024, Command Line Inc.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
import { ClientService, FileService, WindowService, WorkspaceService } from "@/app/store/services";
|
import { ClientService, FileService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services";
|
||||||
import { fireAndForget } from "@/util/util";
|
import { fireAndForget } from "@/util/util";
|
||||||
import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron";
|
import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { debounce } from "throttle-debounce";
|
import { debounce } from "throttle-debounce";
|
||||||
import { getGlobalIsQuitting, getGlobalIsRelaunching, setWasActive, setWasInFg } from "./emain-activity";
|
import {
|
||||||
|
getGlobalIsQuitting,
|
||||||
|
getGlobalIsRelaunching,
|
||||||
|
setGlobalIsRelaunching,
|
||||||
|
setWasActive,
|
||||||
|
setWasInFg,
|
||||||
|
} from "./emain-activity";
|
||||||
import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview";
|
import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview";
|
||||||
import { delay, ensureBoundsAreVisible } from "./emain-util";
|
import { delay, ensureBoundsAreVisible } from "./emain-util";
|
||||||
|
import { log } from "./log";
|
||||||
import { getElectronAppBasePath, unamePlatform } from "./platform";
|
import { getElectronAppBasePath, unamePlatform } from "./platform";
|
||||||
import { updater } from "./updater";
|
import { updater } from "./updater";
|
||||||
export type WindowOpts = {
|
export type WindowOpts = {
|
||||||
@ -272,6 +279,10 @@ export class WaveBrowserWindow extends BaseWindow {
|
|||||||
|
|
||||||
async switchWorkspace(workspaceId: string) {
|
async switchWorkspace(workspaceId: string) {
|
||||||
console.log("switchWorkspace", workspaceId, this.waveWindowId);
|
console.log("switchWorkspace", workspaceId, this.waveWindowId);
|
||||||
|
if (workspaceId == this.workspaceId) {
|
||||||
|
console.log("switchWorkspace already on this workspace", this.waveWindowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId);
|
const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId);
|
||||||
if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) {
|
if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) {
|
||||||
const choice = dialog.showMessageBoxSync(this, {
|
const choice = dialog.showMessageBoxSync(this, {
|
||||||
@ -603,19 +614,100 @@ ipcMain.on("close-tab", async (event, workspaceId, tabId) => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on("switch-workspace", async (event, workspaceId) => {
|
ipcMain.on("switch-workspace", (event, workspaceId) => {
|
||||||
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
fireAndForget(async () => {
|
||||||
console.log("switch-workspace", workspaceId, ww?.waveWindowId);
|
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
||||||
await ww?.switchWorkspace(workspaceId);
|
console.log("switch-workspace", workspaceId, ww?.waveWindowId);
|
||||||
|
await ww?.switchWorkspace(workspaceId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on("delete-workspace", async (event, workspaceId) => {
|
export async function createWorkspace(window: WaveBrowserWindow) {
|
||||||
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
if (!window) {
|
||||||
console.log("delete-workspace", workspaceId, ww?.waveWindowId);
|
return;
|
||||||
await WorkspaceService.DeleteWorkspace(workspaceId);
|
|
||||||
console.log("delete-workspace done", workspaceId, ww?.waveWindowId);
|
|
||||||
if (ww?.workspaceId == workspaceId) {
|
|
||||||
console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId);
|
|
||||||
ww.destroy();
|
|
||||||
}
|
}
|
||||||
|
const newWsId = await WorkspaceService.CreateWorkspace();
|
||||||
|
if (newWsId) {
|
||||||
|
await window.switchWorkspace(newWsId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.on("create-workspace", (event) => {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
||||||
|
console.log("create-workspace", ww?.waveWindowId);
|
||||||
|
await createWorkspace(ww);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on("delete-workspace", (event, workspaceId) => {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
||||||
|
console.log("delete-workspace", workspaceId, ww?.waveWindowId);
|
||||||
|
await WorkspaceService.DeleteWorkspace(workspaceId);
|
||||||
|
console.log("delete-workspace done", workspaceId, ww?.waveWindowId);
|
||||||
|
if (ww?.workspaceId == workspaceId) {
|
||||||
|
console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId);
|
||||||
|
ww.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createNewWaveWindow() {
|
||||||
|
log("createNewWaveWindow");
|
||||||
|
const clientData = await ClientService.GetClientData();
|
||||||
|
const fullConfig = await FileService.GetFullConfig();
|
||||||
|
let recreatedWindow = false;
|
||||||
|
const allWindows = getAllWaveWindows();
|
||||||
|
if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {
|
||||||
|
console.log("no windows, but clientData has windowids, recreating first window");
|
||||||
|
// reopen the first window
|
||||||
|
const existingWindowId = clientData.windowids[0];
|
||||||
|
const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
|
||||||
|
if (existingWindowData != null) {
|
||||||
|
const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform });
|
||||||
|
await win.waveReadyPromise;
|
||||||
|
win.show();
|
||||||
|
recreatedWindow = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (recreatedWindow) {
|
||||||
|
console.log("recreated window, returning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("creating new window");
|
||||||
|
const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform });
|
||||||
|
await newBrowserWindow.waveReadyPromise;
|
||||||
|
newBrowserWindow.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function relaunchBrowserWindows() {
|
||||||
|
console.log("relaunchBrowserWindows");
|
||||||
|
setGlobalIsRelaunching(true);
|
||||||
|
const windows = getAllWaveWindows();
|
||||||
|
for (const window of windows) {
|
||||||
|
console.log("relaunch -- closing window", window.waveWindowId);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
setGlobalIsRelaunching(false);
|
||||||
|
|
||||||
|
const clientData = await ClientService.GetClientData();
|
||||||
|
const fullConfig = await FileService.GetFullConfig();
|
||||||
|
const wins: WaveBrowserWindow[] = [];
|
||||||
|
for (const windowId of clientData.windowids.slice().reverse()) {
|
||||||
|
const windowData: WaveWindow = await WindowService.GetWindow(windowId);
|
||||||
|
if (windowData == null) {
|
||||||
|
console.log("relaunch -- window data not found, closing window", windowId);
|
||||||
|
await WindowService.CloseWindow(windowId, true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log("relaunch -- creating window", windowId, windowData);
|
||||||
|
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
|
||||||
|
wins.push(win);
|
||||||
|
}
|
||||||
|
for (const win of wins) {
|
||||||
|
await win.waveReadyPromise;
|
||||||
|
console.log("show window", win.waveWindowId);
|
||||||
|
win.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -55,6 +55,16 @@ export class ElectronWshClientType extends WshClient {
|
|||||||
}
|
}
|
||||||
ww.focus();
|
ww.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// async handle_workspaceupdate(rh: RpcResponseHelper) {
|
||||||
|
// console.log("workspaceupdate");
|
||||||
|
// fireAndForget(async () => {
|
||||||
|
// console.log("workspace menu clicked");
|
||||||
|
// const updatedWorkspaceMenu = await getWorkspaceMenu();
|
||||||
|
// const workspaceMenu = Menu.getApplicationMenu().getMenuItemById("workspace-menu");
|
||||||
|
// workspaceMenu.submenu = Menu.buildFromTemplate(updatedWorkspaceMenu);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
export let ElectronWshClient: ElectronWshClientType;
|
export let ElectronWshClient: ElectronWshClientType;
|
||||||
|
112
emain/emain.ts
112
emain/emain.ts
@ -10,8 +10,6 @@ import * as path from "path";
|
|||||||
import { PNG } from "pngjs";
|
import { PNG } from "pngjs";
|
||||||
import { sprintf } from "sprintf-js";
|
import { sprintf } from "sprintf-js";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import * as util from "util";
|
|
||||||
import winston from "winston";
|
|
||||||
import * as services from "../frontend/app/store/services";
|
import * as services from "../frontend/app/store/services";
|
||||||
import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil";
|
import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil";
|
||||||
import { getWebServerEndpoint } from "../frontend/util/endpoints";
|
import { getWebServerEndpoint } from "../frontend/util/endpoints";
|
||||||
@ -25,7 +23,6 @@ import {
|
|||||||
getGlobalIsRelaunching,
|
getGlobalIsRelaunching,
|
||||||
setForceQuit,
|
setForceQuit,
|
||||||
setGlobalIsQuitting,
|
setGlobalIsQuitting,
|
||||||
setGlobalIsRelaunching,
|
|
||||||
setGlobalIsStarting,
|
setGlobalIsStarting,
|
||||||
setWasActive,
|
setWasActive,
|
||||||
setWasInFg,
|
setWasInFg,
|
||||||
@ -35,16 +32,19 @@ import { handleCtrlShiftState } from "./emain-util";
|
|||||||
import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv";
|
import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv";
|
||||||
import {
|
import {
|
||||||
createBrowserWindow,
|
createBrowserWindow,
|
||||||
|
createNewWaveWindow,
|
||||||
focusedWaveWindow,
|
focusedWaveWindow,
|
||||||
getAllWaveWindows,
|
getAllWaveWindows,
|
||||||
getWaveWindowById,
|
getWaveWindowById,
|
||||||
getWaveWindowByWebContentsId,
|
getWaveWindowByWebContentsId,
|
||||||
getWaveWindowByWorkspaceId,
|
getWaveWindowByWorkspaceId,
|
||||||
|
relaunchBrowserWindows,
|
||||||
WaveBrowserWindow,
|
WaveBrowserWindow,
|
||||||
} from "./emain-window";
|
} from "./emain-window";
|
||||||
import { ElectronWshClient, initElectronWshClient } from "./emain-wsh";
|
import { ElectronWshClient, initElectronWshClient } from "./emain-wsh";
|
||||||
import { getLaunchSettings } from "./launchsettings";
|
import { getLaunchSettings } from "./launchsettings";
|
||||||
import { getAppMenu } from "./menu";
|
import { log } from "./log";
|
||||||
|
import { instantiateAppMenu, makeAppMenu } from "./menu";
|
||||||
import {
|
import {
|
||||||
getElectronAppBasePath,
|
getElectronAppBasePath,
|
||||||
getElectronAppUnpackedBasePath,
|
getElectronAppUnpackedBasePath,
|
||||||
@ -65,30 +65,7 @@ electron.nativeTheme.themeSource = "dark";
|
|||||||
|
|
||||||
let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused)
|
let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused)
|
||||||
let webviewKeys: string[] = []; // the keys to trap when webview has focus
|
let webviewKeys: string[] = []; // the keys to trap when webview has focus
|
||||||
const oldConsoleLog = console.log;
|
|
||||||
|
|
||||||
const loggerTransports: winston.transport[] = [
|
|
||||||
new winston.transports.File({ filename: path.join(waveDataDir, "waveapp.log"), level: "info" }),
|
|
||||||
];
|
|
||||||
if (isDev) {
|
|
||||||
loggerTransports.push(new winston.transports.Console());
|
|
||||||
}
|
|
||||||
const loggerConfig = {
|
|
||||||
level: "info",
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
|
|
||||||
winston.format.printf((info) => `${info.timestamp} ${info.message}`)
|
|
||||||
),
|
|
||||||
transports: loggerTransports,
|
|
||||||
};
|
|
||||||
const logger = winston.createLogger(loggerConfig);
|
|
||||||
function log(...msg: any[]) {
|
|
||||||
try {
|
|
||||||
logger.info(util.format(...msg));
|
|
||||||
} catch (e) {
|
|
||||||
oldConsoleLog(...msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log = log;
|
console.log = log;
|
||||||
console.log(
|
console.log(
|
||||||
sprintf(
|
sprintf(
|
||||||
@ -375,34 +352,6 @@ electron.ipcMain.on("open-native-path", (event, filePath: string) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createNewWaveWindow(): Promise<void> {
|
|
||||||
log("createNewWaveWindow");
|
|
||||||
const clientData = await services.ClientService.GetClientData();
|
|
||||||
const fullConfig = await services.FileService.GetFullConfig();
|
|
||||||
let recreatedWindow = false;
|
|
||||||
const allWindows = getAllWaveWindows();
|
|
||||||
if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {
|
|
||||||
console.log("no windows, but clientData has windowids, recreating first window");
|
|
||||||
// reopen the first window
|
|
||||||
const existingWindowId = clientData.windowids[0];
|
|
||||||
const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
|
|
||||||
if (existingWindowData != null) {
|
|
||||||
const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform });
|
|
||||||
await win.waveReadyPromise;
|
|
||||||
win.show();
|
|
||||||
recreatedWindow = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (recreatedWindow) {
|
|
||||||
console.log("recreated window, returning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("creating new window");
|
|
||||||
const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform });
|
|
||||||
await newBrowserWindow.waveReadyPromise;
|
|
||||||
newBrowserWindow.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => {
|
electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => {
|
||||||
const tabView = getWaveTabViewByWebContentsId(event.sender.id);
|
const tabView = getWaveTabViewByWebContentsId(event.sender.id);
|
||||||
if (tabView == null || tabView.initResolve == null) {
|
if (tabView == null || tabView.initResolve == null) {
|
||||||
@ -481,10 +430,10 @@ electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenu
|
|||||||
if (menuDefArr?.length === 0) {
|
if (menuDefArr?.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu();
|
fireAndForget(async () => {
|
||||||
// const { x, y } = electron.screen.getCursorScreenPoint();
|
const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : await instantiateAppMenu();
|
||||||
// const windowPos = window.getPosition();
|
menu.popup();
|
||||||
menu.popup();
|
});
|
||||||
event.returnValue = true;
|
event.returnValue = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -561,18 +510,6 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro
|
|||||||
return electron.Menu.buildFromTemplate(menuItems);
|
return electron.Menu.buildFromTemplate(menuItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function instantiateAppMenu(): electron.Menu {
|
|
||||||
return getAppMenu({
|
|
||||||
createNewWaveWindow,
|
|
||||||
relaunchBrowserWindows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAppMenu() {
|
|
||||||
const menu = instantiateAppMenu();
|
|
||||||
electron.Menu.setApplicationMenu(menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideWindowWithCatch(window: WaveBrowserWindow) {
|
function hideWindowWithCatch(window: WaveBrowserWindow) {
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
return;
|
return;
|
||||||
@ -649,37 +586,6 @@ process.on("uncaughtException", (error) => {
|
|||||||
electronApp.quit();
|
electronApp.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function relaunchBrowserWindows(): Promise<void> {
|
|
||||||
console.log("relaunchBrowserWindows");
|
|
||||||
setGlobalIsRelaunching(true);
|
|
||||||
const windows = getAllWaveWindows();
|
|
||||||
for (const window of windows) {
|
|
||||||
console.log("relaunch -- closing window", window.waveWindowId);
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
setGlobalIsRelaunching(false);
|
|
||||||
|
|
||||||
const clientData = await services.ClientService.GetClientData();
|
|
||||||
const fullConfig = await services.FileService.GetFullConfig();
|
|
||||||
const wins: WaveBrowserWindow[] = [];
|
|
||||||
for (const windowId of clientData.windowids.slice().reverse()) {
|
|
||||||
const windowData: WaveWindow = await services.WindowService.GetWindow(windowId);
|
|
||||||
if (windowData == null) {
|
|
||||||
console.log("relaunch -- window data not found, closing window", windowId);
|
|
||||||
await services.WindowService.CloseWindow(windowId, true);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
console.log("relaunch -- creating window", windowId, windowData);
|
|
||||||
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
|
|
||||||
wins.push(win);
|
|
||||||
}
|
|
||||||
for (const win of wins) {
|
|
||||||
await win.waveReadyPromise;
|
|
||||||
console.log("show window", win.waveWindowId);
|
|
||||||
win.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function appMain() {
|
async function appMain() {
|
||||||
// Set disableHardwareAcceleration as early as possible, if required.
|
// Set disableHardwareAcceleration as early as possible, if required.
|
||||||
const launchSettings = getLaunchSettings();
|
const launchSettings = getLaunchSettings();
|
||||||
@ -694,7 +600,6 @@ async function appMain() {
|
|||||||
electronApp.quit();
|
electronApp.quit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
makeAppMenu();
|
|
||||||
try {
|
try {
|
||||||
await runWaveSrv(handleWSEvent);
|
await runWaveSrv(handleWSEvent);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -715,6 +620,7 @@ async function appMain() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("error initializing wshrpc", e);
|
console.log("error initializing wshrpc", e);
|
||||||
}
|
}
|
||||||
|
makeAppMenu();
|
||||||
await configureAutoUpdater();
|
await configureAutoUpdater();
|
||||||
setGlobalIsStarting(false);
|
setGlobalIsStarting(false);
|
||||||
if (fullConfig?.settings?.["window:maxtabcachesize"] != null) {
|
if (fullConfig?.settings?.["window:maxtabcachesize"] != null) {
|
||||||
|
31
emain/log.ts
Normal file
31
emain/log.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { format } from "util";
|
||||||
|
import winston from "winston";
|
||||||
|
import { getWaveDataDir, isDev } from "./platform";
|
||||||
|
|
||||||
|
const oldConsoleLog = console.log;
|
||||||
|
|
||||||
|
const loggerTransports: winston.transport[] = [
|
||||||
|
new winston.transports.File({ filename: path.join(getWaveDataDir(), "waveapp.log"), level: "info" }),
|
||||||
|
];
|
||||||
|
if (isDev) {
|
||||||
|
loggerTransports.push(new winston.transports.Console());
|
||||||
|
}
|
||||||
|
const loggerConfig = {
|
||||||
|
level: "info",
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
|
||||||
|
winston.format.printf((info) => `${info.timestamp} ${info.message}`)
|
||||||
|
),
|
||||||
|
transports: loggerTransports,
|
||||||
|
};
|
||||||
|
const logger = winston.createLogger(loggerConfig);
|
||||||
|
function log(...msg: any[]) {
|
||||||
|
try {
|
||||||
|
logger.info(format(...msg));
|
||||||
|
} catch (e) {
|
||||||
|
oldConsoleLog(...msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { log };
|
@ -1,10 +1,19 @@
|
|||||||
// Copyright 2024, Command Line Inc.
|
// Copyright 2024, Command Line Inc.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { waveEventSubscribe } from "@/app/store/wps";
|
||||||
|
import { RpcApi } from "@/app/store/wshclientapi";
|
||||||
import * as electron from "electron";
|
import * as electron from "electron";
|
||||||
import { fireAndForget } from "../frontend/util/util";
|
import { fireAndForget } from "../frontend/util/util";
|
||||||
import { clearTabCache } from "./emain-tabview";
|
import { clearTabCache } from "./emain-tabview";
|
||||||
import { focusedWaveWindow, WaveBrowserWindow } from "./emain-window";
|
import {
|
||||||
|
createNewWaveWindow,
|
||||||
|
createWorkspace,
|
||||||
|
focusedWaveWindow,
|
||||||
|
relaunchBrowserWindows,
|
||||||
|
WaveBrowserWindow,
|
||||||
|
} from "./emain-window";
|
||||||
|
import { ElectronWshClient } from "./emain-wsh";
|
||||||
import { unamePlatform } from "./platform";
|
import { unamePlatform } from "./platform";
|
||||||
import { updater } from "./updater";
|
import { updater } from "./updater";
|
||||||
|
|
||||||
@ -27,7 +36,35 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
async function getWorkspaceMenu(): Promise<Electron.MenuItemConstructorOptions[]> {
|
||||||
|
const workspaceList = await RpcApi.WorkspaceListCommand(ElectronWshClient);
|
||||||
|
console.log("workspaceList:", workspaceList);
|
||||||
|
const workspaceMenu: Electron.MenuItemConstructorOptions[] = [
|
||||||
|
{
|
||||||
|
label: "Create New Workspace",
|
||||||
|
click: (_, window) => {
|
||||||
|
const ww = window as WaveBrowserWindow;
|
||||||
|
fireAndForget(() => createWorkspace(ww));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
workspaceList?.length &&
|
||||||
|
workspaceMenu.push(
|
||||||
|
{ type: "separator" },
|
||||||
|
...workspaceList.map<Electron.MenuItemConstructorOptions>((workspace) => {
|
||||||
|
return {
|
||||||
|
label: `Switch to ${workspace.workspacedata.name} (${workspace.workspacedata.oid.slice(0, 5)})`,
|
||||||
|
click: (_, window) => {
|
||||||
|
const ww = window as WaveBrowserWindow;
|
||||||
|
ww.switchWorkspace(workspace.workspacedata.oid);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return workspaceMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAppMenu(callbacks: AppMenuCallbacks): Promise<Electron.Menu> {
|
||||||
const fileMenu: Electron.MenuItemConstructorOptions[] = [
|
const fileMenu: Electron.MenuItemConstructorOptions[] = [
|
||||||
{
|
{
|
||||||
label: "New Window",
|
label: "New Window",
|
||||||
@ -224,6 +261,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
|||||||
role: "togglefullscreen",
|
role: "togglefullscreen",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const workspaceMenu = await getWorkspaceMenu();
|
||||||
|
|
||||||
const windowMenu: Electron.MenuItemConstructorOptions[] = [
|
const windowMenu: Electron.MenuItemConstructorOptions[] = [
|
||||||
{ role: "minimize", accelerator: "" },
|
{ role: "minimize", accelerator: "" },
|
||||||
{ role: "zoom" },
|
{ role: "zoom" },
|
||||||
@ -249,6 +289,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
|||||||
role: "viewMenu",
|
role: "viewMenu",
|
||||||
submenu: viewMenu,
|
submenu: viewMenu,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Workspace",
|
||||||
|
id: "workspace-menu",
|
||||||
|
submenu: workspaceMenu,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
role: "windowMenu",
|
role: "windowMenu",
|
||||||
submenu: windowMenu,
|
submenu: windowMenu,
|
||||||
@ -257,4 +302,23 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
|||||||
return electron.Menu.buildFromTemplate(menuTemplate);
|
return electron.Menu.buildFromTemplate(menuTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function instantiateAppMenu(): Promise<electron.Menu> {
|
||||||
|
return getAppMenu({
|
||||||
|
createNewWaveWindow,
|
||||||
|
relaunchBrowserWindows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAppMenu() {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
const menu = await instantiateAppMenu();
|
||||||
|
electron.Menu.setApplicationMenu(menu);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
waveEventSubscribe({
|
||||||
|
eventType: "workspace:update",
|
||||||
|
handler: makeAppMenu,
|
||||||
|
});
|
||||||
|
|
||||||
export { getAppMenu };
|
export { getAppMenu };
|
||||||
|
@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld("api", {
|
|||||||
registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys),
|
registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys),
|
||||||
onControlShiftStateUpdate: (callback) =>
|
onControlShiftStateUpdate: (callback) =>
|
||||||
ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)),
|
ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)),
|
||||||
|
createWorkspace: () => ipcRenderer.send("create-workspace"),
|
||||||
switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId),
|
switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId),
|
||||||
deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId),
|
deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId),
|
||||||
setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId),
|
setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId),
|
||||||
|
@ -183,6 +183,11 @@ class WorkspaceServiceType {
|
|||||||
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
|
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @returns workspaceId
|
||||||
|
CreateWorkspace(): Promise<string> {
|
||||||
|
return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments))
|
||||||
|
}
|
||||||
|
|
||||||
// @returns object updates
|
// @returns object updates
|
||||||
DeleteWorkspace(workspaceId: string): Promise<void> {
|
DeleteWorkspace(workspaceId: string): Promise<void> {
|
||||||
return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments))
|
return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments))
|
||||||
|
@ -235,16 +235,23 @@ const WorkspaceSwitcher = () => {
|
|||||||
</ExpandableMenu>
|
</ExpandableMenu>
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
|
|
||||||
{!isActiveWorkspaceSaved && (
|
<div className="actions">
|
||||||
<div className="actions">
|
{isActiveWorkspaceSaved ? (
|
||||||
|
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
|
||||||
|
<ExpandableMenuItemLeftElement>
|
||||||
|
<i className="fa-sharp fa-solid fa-plus"></i>
|
||||||
|
</ExpandableMenuItemLeftElement>
|
||||||
|
<div className="content">Create new workspace</div>
|
||||||
|
</ExpandableMenuItem>
|
||||||
|
) : (
|
||||||
<ExpandableMenuItem onClick={() => saveWorkspace()}>
|
<ExpandableMenuItem onClick={() => saveWorkspace()}>
|
||||||
<ExpandableMenuItemLeftElement>
|
<ExpandableMenuItemLeftElement>
|
||||||
<i className="fa-sharp fa-solid fa-floppy-disk"></i>
|
<i className="fa-sharp fa-solid fa-floppy-disk"></i>
|
||||||
</ExpandableMenuItemLeftElement>
|
</ExpandableMenuItemLeftElement>
|
||||||
<div className="content">Save workspace</div>
|
<div className="content">Save workspace</div>
|
||||||
</ExpandableMenuItem>
|
</ExpandableMenuItem>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
1
frontend/types/custom.d.ts
vendored
1
frontend/types/custom.d.ts
vendored
@ -89,6 +89,7 @@ declare global {
|
|||||||
setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview
|
setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview
|
||||||
registerGlobalWebviewKeys: (keys: string[]) => void;
|
registerGlobalWebviewKeys: (keys: string[]) => void;
|
||||||
onControlShiftStateUpdate: (callback: (state: boolean) => void) => void;
|
onControlShiftStateUpdate: (callback: (state: boolean) => void) => void;
|
||||||
|
createWorkspace: () => void;
|
||||||
switchWorkspace: (workspaceId: string) => void;
|
switchWorkspace: (workspaceId: string) => void;
|
||||||
deleteWorkspace: (workspaceId: string) => void;
|
deleteWorkspace: (workspaceId: string) => void;
|
||||||
setActiveTab: (tabId: string) => void;
|
setActiveTab: (tabId: string) => void;
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
|
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
|
||||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||||
|
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -174,6 +175,10 @@ func (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj wave
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error updating object: %w", err)
|
return nil, fmt.Errorf("error updating object: %w", err)
|
||||||
}
|
}
|
||||||
|
if (waveObj.GetOType() == waveobj.OType_Workspace) && (waveObj.(*waveobj.Workspace).Name != "") {
|
||||||
|
wps.Broker.Publish(wps.WaveEvent{
|
||||||
|
Event: wps.Event_WorkspaceUpdate})
|
||||||
|
}
|
||||||
if returnUpdates {
|
if returnUpdates {
|
||||||
return waveobj.ContextGetUpdatesRtn(ctx), nil
|
return waveobj.ContextGetUpdatesRtn(ctx), nil
|
||||||
}
|
}
|
||||||
|
@ -50,23 +50,14 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error creating window: %w", err)
|
return nil, fmt.Errorf("error creating window: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws, err := wcore.GetWorkspace(ctx, window.WorkspaceId)
|
ws, err := wcore.GetWorkspace(ctx, window.WorkspaceId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error getting workspace: %w", err)
|
return window, fmt.Errorf("error getting workspace: %w", err)
|
||||||
}
|
}
|
||||||
if len(ws.TabIds) == 0 {
|
err = wlayout.BootstrapNewWorkspaceLayout(ctx, ws)
|
||||||
_, err = wcore.CreateTab(ctx, ws.OID, "", true, false)
|
if err != nil {
|
||||||
if err != nil {
|
return window, fmt.Errorf("error bootstrapping new workspace layout: %w", err)
|
||||||
return window, fmt.Errorf("error creating tab: %w", err)
|
|
||||||
}
|
|
||||||
ws, err = wcore.GetWorkspace(ctx, window.WorkspaceId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error getting updated workspace: %w", err)
|
|
||||||
}
|
|
||||||
err = wlayout.BootstrapNewWorkspaceLayout(ctx, ws)
|
|
||||||
if err != nil {
|
|
||||||
return window, fmt.Errorf("error bootstrapping new workspace layout: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return window, nil
|
return window, nil
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,25 @@ const DefaultTimeout = 2 * time.Second
|
|||||||
|
|
||||||
type WorkspaceService struct{}
|
type WorkspaceService struct{}
|
||||||
|
|
||||||
|
func (svc *WorkspaceService) CreateWorkspace_Meta() tsgenmeta.MethodMeta {
|
||||||
|
return tsgenmeta.MethodMeta{
|
||||||
|
ReturnDesc: "workspaceId",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *WorkspaceService) CreateWorkspace(ctx context.Context) (string, error) {
|
||||||
|
newWS, err := wcore.CreateWorkspace(ctx, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error creating workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = wlayout.BootstrapNewWorkspaceLayout(ctx, newWS)
|
||||||
|
if err != nil {
|
||||||
|
return newWS.OID, fmt.Errorf("error bootstrapping new workspace layout: %w", err)
|
||||||
|
}
|
||||||
|
return newWS.OID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta {
|
func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta {
|
||||||
return tsgenmeta.MethodMeta{
|
return tsgenmeta.MethodMeta{
|
||||||
ArgNames: []string{"workspaceId"},
|
ArgNames: []string{"workspaceId"},
|
||||||
|
@ -152,21 +152,18 @@ func DeleteBlock(ctx context.Context, blockId string, recursive bool) error {
|
|||||||
log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount)
|
log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount)
|
||||||
parentORef := waveobj.ParseORefNoErr(block.ParentORef)
|
parentORef := waveobj.ParseORefNoErr(block.ParentORef)
|
||||||
|
|
||||||
if parentORef.OType == waveobj.OType_Tab {
|
if recursive && parentORef.OType == waveobj.OType_Tab && parentBlockCount == 0 {
|
||||||
if parentBlockCount == 0 && recursive {
|
// if parent tab has no blocks, delete the tab
|
||||||
// if parent tab has no blocks, delete the tab
|
log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID)
|
||||||
log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID)
|
parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID)
|
||||||
parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("error finding workspace for tab to delete %s: %w", parentORef.OID, err)
|
||||||
return fmt.Errorf("error finding workspace for tab to delete %s: %w", parentORef.OID, err)
|
|
||||||
}
|
|
||||||
newActiveTabId, err := DeleteTab(ctx, parentWorkspaceId, parentORef.OID, true)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error deleting tab %s: %w", parentORef.OID, err)
|
|
||||||
}
|
|
||||||
SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)
|
|
||||||
}
|
}
|
||||||
|
newActiveTabId, err := DeleteTab(ctx, parentWorkspaceId, parentORef.OID, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error deleting tab %s: %w", parentORef.OID, err)
|
||||||
|
}
|
||||||
|
SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)
|
||||||
}
|
}
|
||||||
go blockcontroller.StopBlockController(blockId)
|
go blockcontroller.StopBlockController(blockId)
|
||||||
sendBlockCloseEvent(blockId)
|
sendBlockCloseEvent(blockId)
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/wavetermdev/waveterm/pkg/telemetry"
|
"github.com/wavetermdev/waveterm/pkg/telemetry"
|
||||||
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
||||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||||
|
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||||
)
|
)
|
||||||
@ -25,7 +26,21 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string
|
|||||||
Icon: icon,
|
Icon: icon,
|
||||||
Color: color,
|
Color: color,
|
||||||
}
|
}
|
||||||
wstore.DBInsert(ctx, ws)
|
err := wstore.DBInsert(ctx, ws)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error inserting workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = CreateTab(ctx, ws.OID, "", true, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating tab: %w", err)
|
||||||
|
}
|
||||||
|
ws, err = GetWorkspace(ctx, ws.OID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting updated workspace: %w", err)
|
||||||
|
}
|
||||||
|
wps.Broker.Publish(wps.WaveEvent{
|
||||||
|
Event: wps.Event_WorkspaceUpdate})
|
||||||
return ws, nil
|
return ws, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,7 +53,7 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("error getting workspace: %w", err)
|
return false, fmt.Errorf("error getting workspace: %w", err)
|
||||||
}
|
}
|
||||||
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 && len(workspace.PinnedTabIds) > 0 {
|
if workspace.Name != "" && workspace.Icon != "" && !force && (len(workspace.TabIds) > 0 || len(workspace.PinnedTabIds) > 0) {
|
||||||
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
|
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@ -56,6 +71,8 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
|
|||||||
return false, fmt.Errorf("error deleting workspace: %w", err)
|
return false, fmt.Errorf("error deleting workspace: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("deleted workspace %s\n", workspaceId)
|
log.Printf("deleted workspace %s\n", workspaceId)
|
||||||
|
wps.Broker.Publish(wps.WaveEvent{
|
||||||
|
Event: wps.Event_WorkspaceUpdate})
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +180,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
|
|||||||
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
|
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
|
||||||
|
|
||||||
// if no tabs remaining, close window
|
// if no tabs remaining, close window
|
||||||
if newActiveTabId == "" && recursive {
|
if recursive && newActiveTabId == "" {
|
||||||
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
|
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
|
||||||
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
|
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -12,6 +12,7 @@ const (
|
|||||||
Event_Config = "config"
|
Event_Config = "config"
|
||||||
Event_UserInput = "userinput"
|
Event_UserInput = "userinput"
|
||||||
Event_RouteGone = "route:gone"
|
Event_RouteGone = "route:gone"
|
||||||
|
Event_WorkspaceUpdate = "workspace:update"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WaveEvent struct {
|
type WaveEvent struct {
|
||||||
|
Loading…
Reference in New Issue
Block a user