From cb50023d7966efa5e61f8af01ff1ca646f231834 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 17:06:52 -0500 Subject: [PATCH 01/44] Fix issue where WaveAI text area would resize on launch (#1397) --- frontend/app/view/waveai/waveai.tsx | 39 ++++++++++++++++++----------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index 340162522..cae5b8856 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -572,24 +572,33 @@ const ChatInput = forwardRef( model.textAreaRef = textAreaRef; }, []); - const adjustTextAreaHeight = () => { - if (textAreaRef.current == null) { - return; - } - // Adjust the height of the textarea to fit the text - const textAreaMaxLines = 100; - const textAreaLineHeight = termFontSize * 1.5; - const textAreaMinHeight = textAreaLineHeight; - const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; + const adjustTextAreaHeight = useCallback( + (value: string) => { + if (textAreaRef.current == null) { + return; + } - textAreaRef.current.style.height = "1px"; - const scrollHeight = textAreaRef.current.scrollHeight; - const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); - textAreaRef.current.style.height = newHeight + "px"; - }; + // Adjust the height of the textarea to fit the text + const textAreaMaxLines = 5; + const textAreaLineHeight = termFontSize * 1.5; + const textAreaMinHeight = textAreaLineHeight; + const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; + + if (value === "") { + textAreaRef.current.style.height = `${textAreaLineHeight}px`; + return; + } + + textAreaRef.current.style.height = `${textAreaLineHeight}px`; + const scrollHeight = textAreaRef.current.scrollHeight; + const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); + textAreaRef.current.style.height = newHeight + "px"; + }, + [termFontSize] + ); useEffect(() => { - adjustTextAreaHeight(); + adjustTextAreaHeight(value); }, [value]); return ( From 898d21d1b221576b7506a56eaf75bc8f680df233 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 17:25:02 -0500 Subject: [PATCH 02/44] Small tweak to nohover to only start timeout on requestanimationframe (#1398) --- frontend/wave.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/wave.ts b/frontend/wave.ts index c296de862..497bbae12 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -92,9 +92,11 @@ async function reinitWave() { // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used. // Also overrides the staticTabAtom to the new tab id so that the active tab is set correctly. globalStore.set(overrideStaticTabAtom, savedInitOpts.tabId); - setTimeout(() => { - document.body.classList.remove("nohover"); - }, 100); + requestAnimationFrame(() => + setTimeout(() => { + document.body.classList.remove("nohover"); + }, 100) + ); const client = await WOS.reloadWaveObject(WOS.makeORef("client", savedInitOpts.clientId)); const waveWindow = await WOS.reloadWaveObject(WOS.makeORef("window", savedInitOpts.windowId)); From 5fbd72b5900f2a6a7f5072d0a288816fb5d71a56 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 17:58:04 -0500 Subject: [PATCH 03/44] Fix edge case when dragging pinnned tabs (#1399) --- frontend/app/tab/tabbar.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 0b3891ae9..be3994bc6 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -452,9 +452,11 @@ const TabBar = memo(({ workspace }: TabBarProps) => { let pinnedTabCount = pinnedTabIds.size; const draggedTabId = draggingTabDataRef.current.tabId; const isPinned = pinnedTabIds.has(draggedTabId); - if (pinnedTabIds.has(tabIds[tabIndex + 1]) && !isPinned) { + const nextTabId = tabIds[tabIndex + 1]; + const prevTabId = tabIds[tabIndex - 1]; + if (!isPinned && nextTabId && pinnedTabIds.has(nextTabId)) { pinnedTabIds.add(draggedTabId); - } else if (!pinnedTabIds.has(tabIds[tabIndex - 1]) && isPinned) { + } else if (isPinned && prevTabId && !pinnedTabIds.has(prevTabId)) { pinnedTabIds.delete(draggedTabId); } if (pinnedTabCount != pinnedTabIds.size) { From 1e804f4d153f49bfc695a84e2e30be8689197bd6 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 19:18:42 -0500 Subject: [PATCH 04/44] Use window destroy instead of forceClose (#1400) `destroy` bypasses the `close` event and forces the window to close. This means that we can use it instead of `forceClose` and we don't need to call it in the `closed` event. --- emain/emain-window.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 3b3891d9e..67de6c38f 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -237,7 +237,13 @@ export class WaveBrowserWindow extends BaseWindow { console.log("win removing window from backend DB", this.waveWindowId); fireAndForget(async () => await WindowService.CloseWindow(this.waveWindowId, true)); } - this.destroy(); + for (const tabView of this.allTabViews.values()) { + tabView?.destroy(); + } + waveWindowMap.delete(this.waveWindowId); + if (focusedWaveWindow == this) { + focusedWaveWindow = null; + } }); waveWindowMap.set(waveWindow.oid, this); } @@ -313,13 +319,6 @@ export class WaveBrowserWindow extends BaseWindow { } } - forceClose() { - console.log("forceClose window", this.waveWindowId); - this.canClose = true; - this.deleteAllowed = true; - this.close(); - } - async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) { const clientData = await ClientService.GetClientData(); if (this.activeTabView == tabView) { @@ -459,13 +458,7 @@ export class WaveBrowserWindow extends BaseWindow { destroy() { console.log("destroy win", this.waveWindowId); - for (const tabView of this.allTabViews.values()) { - tabView?.destroy(); - } - waveWindowMap.delete(this.waveWindowId); - if (focusedWaveWindow == this) { - focusedWaveWindow = null; - } + this.deleteAllowed = true; super.destroy(); } } @@ -565,6 +558,6 @@ ipcMain.on("delete-workspace", async (event, workspaceId) => { console.log("delete-workspace done", workspaceId, ww?.waveWindowId); if (ww?.workspaceId == workspaceId) { console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId); - ww.forceClose(); + ww.destroy(); } }); From 297e2b627fcfa9784d6309c4091ad557fcf157a8 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 19:23:53 -0500 Subject: [PATCH 05/44] Patch version of path-to-regexp (#1401) Fixes https://github.com/wavetermdev/waveterm/security/dependabot/50 --- package.json | 3 ++- yarn.lock | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index fa5b508b2..602ab5ff0 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,8 @@ }, "resolutions": { "send@npm:0.18.0": "0.19.0", - "cookie@0.6.0": "^0.7.0" + "cookie@0.6.0": "^0.7.0", + "path-to-regexp@npm:0.1.10": "^0.1.12" }, "packageManager": "yarn@4.5.1", "workspaces": [ diff --git a/yarn.lock b/yarn.lock index c6a2ad84c..66300f526 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16401,13 +16401,6 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:0.1.10": - version: 0.1.10 - resolution: "path-to-regexp@npm:0.1.10" - checksum: 10c0/34196775b9113ca6df88e94c8d83ba82c0e1a2063dd33bfe2803a980da8d49b91db8104f49d5191b44ea780d46b8670ce2b7f4a5e349b0c48c6779b653f1afe4 - languageName: node - linkType: hard - "path-to-regexp@npm:3.3.0": version: 3.3.0 resolution: "path-to-regexp@npm:3.3.0" @@ -16415,6 +16408,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^0.1.12": + version: 0.1.12 + resolution: "path-to-regexp@npm:0.1.12" + checksum: 10c0/1c6ff10ca169b773f3bba943bbc6a07182e332464704572962d277b900aeee81ac6aa5d060ff9e01149636c30b1f63af6e69dd7786ba6e0ddb39d4dee1f0645b + languageName: node + linkType: hard + "path-to-regexp@npm:^1.7.0": version: 1.9.0 resolution: "path-to-regexp@npm:1.9.0" From 7a61f25331269f8902dcd8dc19635c54301a8db0 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 21:09:54 -0500 Subject: [PATCH 06/44] Auditing async usage in frontend code (#1402) I found a lot of places where asyncs weren't being properly wrapped or awaited --- emain/emain-window.ts | 4 +- emain/emain.ts | 2 +- emain/updater.ts | 6 +- frontend/app/modals/tos.tsx | 12 +- frontend/app/modals/userinputmodal.tsx | 44 ++++--- frontend/app/store/keymodel.ts | 3 +- frontend/app/store/wos.ts | 3 +- frontend/app/tab/tab.tsx | 44 ++++--- frontend/app/tab/tabbar.tsx | 17 ++- frontend/app/tab/workspaceswitcher.tsx | 20 ++-- .../app/view/preview/directorypreview.tsx | 60 +++++----- frontend/app/view/preview/preview.tsx | 113 +++++++++--------- frontend/app/view/term/termwrap.ts | 10 +- frontend/app/view/waveai/waveai.tsx | 16 +-- frontend/app/view/webview/webview.tsx | 12 +- frontend/layout/lib/TileLayout.tsx | 1 - frontend/layout/lib/layoutModel.ts | 4 +- frontend/layout/lib/layoutModelHooks.ts | 4 +- 18 files changed, 194 insertions(+), 181 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 67de6c38f..393599c9e 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -165,7 +165,7 @@ export class WaveBrowserWindow extends BaseWindow { } focusedWaveWindow = this; console.log("focus win", this.waveWindowId); - fireAndForget(async () => await ClientService.FocusWindow(this.waveWindowId)); + fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); setWasInFg(true); setWasActive(true); }); @@ -235,7 +235,7 @@ export class WaveBrowserWindow extends BaseWindow { } if (this.deleteAllowed) { console.log("win removing window from backend DB", this.waveWindowId); - fireAndForget(async () => await WindowService.CloseWindow(this.waveWindowId, true)); + fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true)); } for (const tabView of this.allTabViews.values()) { tabView?.destroy(); diff --git a/emain/emain.ts b/emain/emain.ts index 9fedeb32d..2d2c15047 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -368,7 +368,7 @@ electron.ipcMain.on("quicklook", (event, filePath: string) => { electron.ipcMain.on("open-native-path", (event, filePath: string) => { console.log("open-native-path", filePath); - fireAndForget(async () => + fireAndForget(() => electron.shell.openPath(filePath).then((excuse) => { if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`); }) diff --git a/emain/updater.ts b/emain/updater.ts index 240020b77..93ec747a9 100644 --- a/emain/updater.ts +++ b/emain/updater.ts @@ -96,7 +96,7 @@ export class Updater { body: "A new version of Wave Terminal is ready to install.", }); updateNotification.on("click", () => { - fireAndForget(() => this.promptToInstallUpdate()); + fireAndForget(this.promptToInstallUpdate.bind(this)); }); updateNotification.show(); }); @@ -188,7 +188,7 @@ export class Updater { if (allWindows.length > 0) { await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => { if (response === 0) { - fireAndForget(async () => this.installUpdate()); + fireAndForget(this.installUpdate.bind(this)); } }); } @@ -210,7 +210,7 @@ export function getResolvedUpdateChannel(): string { return isDev() ? "dev" : (autoUpdater.channel ?? "latest"); } -ipcMain.on("install-app-update", () => fireAndForget(() => updater?.promptToInstallUpdate())); +ipcMain.on("install-app-update", () => fireAndForget(updater?.promptToInstallUpdate.bind(updater))); ipcMain.on("get-app-update-status", (event) => { event.returnValue = updater?.status; }); diff --git a/frontend/app/modals/tos.tsx b/frontend/app/modals/tos.tsx index f0519ebdb..02a281a48 100644 --- a/frontend/app/modals/tos.tsx +++ b/frontend/app/modals/tos.tsx @@ -12,6 +12,7 @@ import { FlexiModal } from "./modal"; import { QuickTips } from "@/app/element/quicktips"; import { atoms, getApi } from "@/app/store/global"; import { modalsModel } from "@/app/store/modalmodel"; +import { fireAndForget } from "@/util/util"; import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; import "./tos.scss"; @@ -20,25 +21,22 @@ const pageNumAtom: PrimitiveAtom = atom(1); const ModalPage1 = () => { const settings = useAtomValue(atoms.settingsAtom); const clientData = useAtomValue(atoms.client); - const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen); const [telemetryEnabled, setTelemetryEnabled] = useState(!!settings["telemetry:enabled"]); const setPageNum = useSetAtom(pageNumAtom); const acceptTos = () => { if (!clientData.tosagreed) { - services.ClientService.AgreeTos(); + fireAndForget(services.ClientService.AgreeTos); } setPageNum(2); }; const setTelemetry = (value: boolean) => { - services.ClientService.TelemetryUpdate(value) - .then(() => { + fireAndForget(() => + services.ClientService.TelemetryUpdate(value).then(() => { setTelemetryEnabled(value); }) - .catch((error) => { - console.error("failed to set telemetry:", error); - }); + ); }; const label = telemetryEnabled ? "Telemetry Enabled" : "Telemetry Disabled"; diff --git a/frontend/app/modals/userinputmodal.tsx b/frontend/app/modals/userinputmodal.tsx index daeec9755..7a3a839f0 100644 --- a/frontend/app/modals/userinputmodal.tsx +++ b/frontend/app/modals/userinputmodal.tsx @@ -5,9 +5,9 @@ import { Modal } from "@/app/modals/modal"; import { Markdown } from "@/element/markdown"; import { modalsModel } from "@/store/modalmodel"; import * as keyutil from "@/util/keyutil"; -import { UserInputService } from "../store/services"; - +import { fireAndForget } from "@/util/util"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { UserInputService } from "../store/services"; import "./userinputmodal.scss"; const UserInputModal = (userInputRequest: UserInputRequest) => { @@ -16,33 +16,39 @@ const UserInputModal = (userInputRequest: UserInputRequest) => { const checkboxRef = useRef(); const handleSendErrResponse = useCallback(() => { - UserInputService.SendUserInputResponse({ - type: "userinputresp", - requestid: userInputRequest.requestid, - errormsg: "Canceled by the user", - }); + fireAndForget(() => + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + errormsg: "Canceled by the user", + }) + ); modalsModel.popModal(); }, [responseText, userInputRequest]); const handleSendText = useCallback(() => { - UserInputService.SendUserInputResponse({ - type: "userinputresp", - requestid: userInputRequest.requestid, - text: responseText, - checkboxstat: checkboxRef?.current?.checked ?? false, - }); + fireAndForget(() => + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + text: responseText, + checkboxstat: checkboxRef?.current?.checked ?? false, + }) + ); modalsModel.popModal(); }, [responseText, userInputRequest]); console.log("bar"); const handleSendConfirm = useCallback( (response: boolean) => { - UserInputService.SendUserInputResponse({ - type: "userinputresp", - requestid: userInputRequest.requestid, - confirm: response, - checkboxstat: checkboxRef?.current?.checked ?? false, - }); + fireAndForget(() => + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + confirm: response, + checkboxstat: checkboxRef?.current?.checked ?? false, + }) + ); modalsModel.popModal(); }, [userInputRequest] diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index f49a44979..82daad8b9 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -19,6 +19,7 @@ import { } from "@/layout/index"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import * as keyutil from "@/util/keyutil"; +import { fireAndForget } from "@/util/util"; import * as jotai from "jotai"; const simpleControlShiftAtom = jotai.atom(false); @@ -83,7 +84,7 @@ function genericClose(tabId: string) { return; } const layoutModel = getLayoutModelForTab(tabAtom); - layoutModel.closeFocusedNode(); + fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel)); } function switchBlockByBlockNum(index: number) { diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index dadb43d49..6d78529b6 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -6,6 +6,7 @@ import { waveEventSubscribe } from "@/app/store/wps"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; +import { fireAndForget } from "@/util/util"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; import { useEffect } from "react"; import { globalStore } from "./jotaiStore"; @@ -301,7 +302,7 @@ function setObjectValue(value: T, setFn?: Setter, pushToServe } setFn(wov.dataAtom, { value: value, loading: false }); if (pushToServer) { - ObjectService.UpdateObject(value, false); + fireAndForget(() => ObjectService.UpdateObject(value, false)); } } diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 302d66154..76d892610 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -6,6 +6,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { Button } from "@/element/button"; import { ContextMenuModel } from "@/store/contextmenu"; +import { fireAndForget } from "@/util/util"; import { clsx } from "clsx"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { ObjectService } from "../store/services"; @@ -72,14 +73,21 @@ const Tab = memo( }; }, []); - const handleRenameTab = (event) => { + const selectEditableText = useCallback(() => { + if (editableRef.current) { + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(editableRef.current); + selection.removeAllRanges(); + selection.addRange(range); + } + }, []); + + const handleRenameTab: React.MouseEventHandler = (event) => { event?.stopPropagation(); setIsEditable(true); editableTimeoutRef.current = setTimeout(() => { - if (editableRef.current) { - editableRef.current.focus(); - document.execCommand("selectAll", false); - } + selectEditableText(); }, 0); }; @@ -88,20 +96,14 @@ const Tab = memo( newText = newText || originalName; editableRef.current.innerText = newText; setIsEditable(false); - ObjectService.UpdateTabName(id, newText); + fireAndForget(() => ObjectService.UpdateTabName(id, newText)); setTimeout(() => refocusNode(null), 10); }; - const handleKeyDown = (event) => { + const handleKeyDown: React.KeyboardEventHandler = (event) => { if ((event.metaKey || event.ctrlKey) && event.key === "a") { event.preventDefault(); - if (editableRef.current) { - const range = document.createRange(); - const selection = window.getSelection(); - range.selectNodeContents(editableRef.current); - selection.removeAllRanges(); - selection.addRange(range); - } + selectEditableText(); return; } // this counts glyphs, not characters @@ -150,7 +152,10 @@ const Tab = memo( let menu: ContextMenuItem[] = [ { label: isPinned ? "Unpin Tab" : "Pin Tab", click: () => onPinChange() }, { label: "Rename Tab", click: () => handleRenameTab(null) }, - { label: "Copy TabId", click: () => navigator.clipboard.writeText(id) }, + { + label: "Copy TabId", + click: () => fireAndForget(() => navigator.clipboard.writeText(id)), + }, { type: "separator" }, ]; const fullConfig = globalStore.get(atoms.fullConfigAtom); @@ -175,10 +180,11 @@ const Tab = memo( } submenu.push({ label: preset["display:name"] ?? presetName, - click: () => { - ObjectService.UpdateObjectMeta(oref, preset); - RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }); - }, + click: () => + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(oref, preset); + await RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }); + }), }); } menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index be3994bc6..817f48262 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -467,13 +467,12 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Reset dragging state setDraggingTab(null); // Update workspace tab ids - fireAndForget( - async () => - await WorkspaceService.UpdateTabIds( - workspace.oid, - tabIds.slice(pinnedTabCount), - tabIds.slice(0, pinnedTabCount) - ) + fireAndForget(() => + WorkspaceService.UpdateTabIds( + workspace.oid, + tabIds.slice(pinnedTabCount), + tabIds.slice(0, pinnedTabCount) + ) ); }), [] @@ -579,9 +578,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handlePinChange = useCallback( (tabId: string, pinned: boolean) => { console.log("handlePinChange", tabId, pinned); - fireAndForget(async () => { - await WorkspaceService.ChangeTabPinning(workspace.oid, tabId, pinned); - }); + fireAndForget(() => WorkspaceService.ChangeTabPinning(workspace.oid, tabId, pinned)); }, [workspace] ); diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 80944ef8e..fc33615e2 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -189,12 +189,10 @@ const WorkspaceSwitcher = () => { }, []); const onDeleteWorkspace = useCallback((workspaceId: string) => { - fireAndForget(async () => { - getApi().deleteWorkspace(workspaceId); - setTimeout(() => { - fireAndForget(updateWorkspaceList); - }, 10); - }); + getApi().deleteWorkspace(workspaceId); + setTimeout(() => { + fireAndForget(updateWorkspaceList); + }, 10); }, []); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); @@ -267,12 +265,10 @@ const WorkspaceSwitcherItem = ({ const isCurrentWorkspace = activeWorkspace.oid === workspace.oid; const setWorkspace = useCallback((newWorkspace: Workspace) => { - fireAndForget(async () => { - if (newWorkspace.name != "") { - setObjectValue({ ...newWorkspace, otype: "workspace" }, undefined, true); - } - setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); - }); + if (newWorkspace.name != "") { + setObjectValue({ ...newWorkspace, otype: "workspace" }, undefined, true); + } + setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); }, []); const isActive = !!workspaceEntry.windowId; diff --git a/frontend/app/view/preview/directorypreview.tsx b/frontend/app/view/preview/directorypreview.tsx index 00ab63da5..c7c4bd6ba 100644 --- a/frontend/app/view/preview/directorypreview.tsx +++ b/frontend/app/view/preview/directorypreview.tsx @@ -543,26 +543,26 @@ function TableBody({ }, { label: "Copy File Name", - click: () => navigator.clipboard.writeText(fileName), + click: () => fireAndForget(() => navigator.clipboard.writeText(fileName)), }, { label: "Copy Full File Name", - click: () => navigator.clipboard.writeText(finfo.path), + click: () => fireAndForget(() => navigator.clipboard.writeText(finfo.path)), }, { label: "Copy File Name (Shell Quoted)", - click: () => navigator.clipboard.writeText(shellQuote([fileName])), + click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([fileName]))), }, { label: "Copy Full File Name (Shell Quoted)", - click: () => navigator.clipboard.writeText(shellQuote([finfo.path])), + click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))), }, { type: "separator", }, { label: "Download File", - click: async () => { + click: () => { getApi().downloadFile(normPath); }, }, @@ -572,7 +572,7 @@ function TableBody({ // TODO: Only show this option for local files, resolve correct host path if connection is WSL { label: openNativeLabel, - click: async () => { + click: () => { getApi().openNativePath(normPath); }, }, @@ -581,30 +581,32 @@ function TableBody({ }, { label: "Open Preview in New Block", - click: async () => { - const blockDef: BlockDef = { - meta: { - view: "preview", - file: finfo.path, - }, - }; - await createBlock(blockDef); - }, + click: () => + fireAndForget(async () => { + const blockDef: BlockDef = { + meta: { + view: "preview", + file: finfo.path, + }, + }; + await createBlock(blockDef); + }), }, ]; if (finfo.mimetype == "directory") { menu.push({ label: "Open Terminal in New Block", - click: async () => { - const termBlockDef: BlockDef = { - meta: { - controller: "shell", - view: "term", - "cmd:cwd": finfo.path, - }, - }; - await createBlock(termBlockDef); - }, + click: () => + fireAndForget(async () => { + const termBlockDef: BlockDef = { + meta: { + controller: "shell", + view: "term", + "cmd:cwd": finfo.path, + }, + }; + await createBlock(termBlockDef); + }), }); } menu.push( @@ -613,9 +615,11 @@ function TableBody({ }, { label: "Delete", - click: async () => { - await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e)); - setRefreshVersion((current) => current + 1); + click: () => { + fireAndForget(async () => { + await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e)); + setRefreshVersion((current) => current + 1); + }); }, } ); diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 77ebd5898..618ad100f 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -24,7 +24,7 @@ import * as WOS from "@/store/wos"; import { getWebServerEndpoint } from "@/util/endpoints"; import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed, keydownWrapper } from "@/util/keyutil"; -import { base64ToString, isBlank, jotaiLoadableValue, makeConnRoute, stringToBase64 } from "@/util/util"; +import { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, makeConnRoute, stringToBase64 } from "@/util/util"; import { Monaco } from "@monaco-editor/react"; import clsx from "clsx"; import { Atom, atom, Getter, PrimitiveAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai"; @@ -257,7 +257,7 @@ export class PreviewModel implements ViewModel { className: clsx( `${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` ), - onClick: this.handleFileSave.bind(this), + onClick: () => fireAndForget(this.handleFileSave.bind(this)), }); if (get(this.canPreview)) { viewTextChildren.push({ @@ -265,7 +265,7 @@ export class PreviewModel implements ViewModel { text: "Preview", className: "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", - onClick: () => this.setEditMode(false), + onClick: () => fireAndForget(() => this.setEditMode(false)), }); } } else if (get(this.canPreview)) { @@ -274,7 +274,7 @@ export class PreviewModel implements ViewModel { text: "Edit", className: "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", - onClick: () => this.setEditMode(true), + onClick: () => fireAndForget(() => this.setEditMode(true)), }); } return [ @@ -497,7 +497,7 @@ export class PreviewModel implements ViewModel { return; } const blockOref = WOS.makeORef("block", this.blockId); - services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); // Clear the saved file buffers globalStore.set(this.fileContentSaved, null); @@ -538,7 +538,7 @@ export class PreviewModel implements ViewModel { } console.log(newFileInfo.path); this.updateOpenFileModalAndError(false); - this.goHistory(newFileInfo.path); + await this.goHistory(newFileInfo.path); refocusNode(this.blockId); } catch (e) { globalStore.set(this.openFileError, e.message); @@ -546,7 +546,7 @@ export class PreviewModel implements ViewModel { } } - goHistoryBack() { + async goHistoryBack() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.metaFilePath); const updateMeta = goHistoryBack("file", curPath, blockMeta, true); @@ -555,10 +555,10 @@ export class PreviewModel implements ViewModel { } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); - services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } - goHistoryForward() { + async goHistoryForward() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.metaFilePath); const updateMeta = goHistoryForward("file", curPath, blockMeta); @@ -567,13 +567,13 @@ export class PreviewModel implements ViewModel { } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); - services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } - setEditMode(edit: boolean) { + async setEditMode(edit: boolean) { const blockMeta = globalStore.get(this.blockAtom)?.meta; const blockOref = WOS.makeORef("block", this.blockId); - services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); + await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); } async handleFileSave() { @@ -588,7 +588,7 @@ export class PreviewModel implements ViewModel { } const conn = globalStore.get(this.connection) ?? ""; try { - services.FileService.SaveFile(conn, filePath, stringToBase64(newFileContent)); + await services.FileService.SaveFile(conn, filePath, stringToBase64(newFileContent)); globalStore.set(this.fileContent, newFileContent); globalStore.set(this.newFileContent, null); console.log("saved file", filePath); @@ -630,42 +630,44 @@ export class PreviewModel implements ViewModel { getSettingsMenuItems(): ContextMenuItem[] { const menuItems: ContextMenuItem[] = []; - const blockData = globalStore.get(this.blockAtom); menuItems.push({ label: "Copy Full Path", - click: async () => { - const filePath = await globalStore.get(this.normFilePath); - if (filePath == null) { - return; - } - navigator.clipboard.writeText(filePath); - }, + click: () => + fireAndForget(async () => { + const filePath = await globalStore.get(this.normFilePath); + if (filePath == null) { + return; + } + await navigator.clipboard.writeText(filePath); + }), }); menuItems.push({ label: "Copy File Name", - click: async () => { - const fileInfo = await globalStore.get(this.statFile); - if (fileInfo == null || fileInfo.name == null) { - return; - } - navigator.clipboard.writeText(fileInfo.name); - }, + click: () => + fireAndForget(async () => { + const fileInfo = await globalStore.get(this.statFile); + if (fileInfo == null || fileInfo.name == null) { + return; + } + await navigator.clipboard.writeText(fileInfo.name); + }), }); const mimeType = jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { menuItems.push({ label: "Open Terminal in New Block", - click: async () => { - const fileInfo = await globalStore.get(this.statFile); - const termBlockDef: BlockDef = { - meta: { - view: "term", - controller: "shell", - "cmd:cwd": fileInfo.dir, - }, - }; - await createBlock(termBlockDef); - }, + click: () => + fireAndForget(async () => { + const fileInfo = await globalStore.get(this.statFile); + const termBlockDef: BlockDef = { + meta: { + view: "term", + controller: "shell", + "cmd:cwd": fileInfo.dir, + }, + }; + await createBlock(termBlockDef); + }), }); } const loadableSV = globalStore.get(this.loadableSpecializedView); @@ -677,11 +679,11 @@ export class PreviewModel implements ViewModel { menuItems.push({ type: "separator" }); menuItems.push({ label: "Save File", - click: this.handleFileSave.bind(this), + click: () => fireAndForget(this.handleFileSave.bind(this)), }); menuItems.push({ label: "Revert File", - click: this.handleFileRevert.bind(this), + click: () => fireAndForget(this.handleFileRevert.bind(this)), }); } menuItems.push({ type: "separator" }); @@ -689,12 +691,13 @@ export class PreviewModel implements ViewModel { label: "Word Wrap", type: "checkbox", checked: wordWrap, - click: () => { - const blockOref = WOS.makeORef("block", this.blockId); - services.ObjectService.UpdateObjectMeta(blockOref, { - "editor:wordwrap": !wordWrap, - }); - }, + click: () => + fireAndForget(async () => { + const blockOref = WOS.makeORef("block", this.blockId); + await services.ObjectService.UpdateObjectMeta(blockOref, { + "editor:wordwrap": !wordWrap, + }); + }), }); } } @@ -716,16 +719,16 @@ export class PreviewModel implements ViewModel { keyDownHandler(e: WaveKeyboardEvent): boolean { if (checkKeyPressed(e, "Cmd:ArrowLeft")) { - this.goHistoryBack(); + fireAndForget(this.goHistoryBack.bind(this)); return true; } if (checkKeyPressed(e, "Cmd:ArrowRight")) { - this.goHistoryForward(); + fireAndForget(this.goHistoryForward.bind(this)); return true; } if (checkKeyPressed(e, "Cmd:ArrowUp")) { // handle up directory - this.goParentDirectory({}); + fireAndForget(() => this.goParentDirectory({})); return true; } const openModalOpen = globalStore.get(this.openFileModal); @@ -739,7 +742,7 @@ export class PreviewModel implements ViewModel { if (canPreview) { if (checkKeyPressed(e, "Cmd:e")) { const editMode = globalStore.get(this.editMode); - this.setEditMode(!editMode); + fireAndForget(() => this.setEditMode(!editMode)); return true; } } @@ -833,15 +836,15 @@ function CodeEditPreview({ model }: SpecializedViewProps) { function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean { if (checkKeyPressed(e, "Cmd:e")) { - model.setEditMode(false); + fireAndForget(() => model.setEditMode(false)); return true; } if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) { - model.handleFileSave(); + fireAndForget(model.handleFileSave.bind(model)); return true; } if (checkKeyPressed(e, "Cmd:r")) { - model.handleFileRevert(); + fireAndForget(model.handleFileRevert.bind(model)); return true; } return false; @@ -990,7 +993,7 @@ const OpenFileModal = memo( const handleCommandOperations = async () => { if (checkKeyPressed(waveEvent, "Enter")) { - model.handleOpenFile(filePath); + await model.handleOpenFile(filePath); return true; } return false; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 270850c6c..c26ab2a32 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -119,7 +119,11 @@ export class TermWrap { data = data.substring(nextSlashIdx); } setTimeout(() => { - services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { "cmd:cwd": data }); + fireAndForget(() => + services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { + "cmd:cwd": data, + }) + ); }, 0); return true; }); @@ -284,7 +288,9 @@ export class TermWrap { const serializedOutput = this.serializeAddon.serialize(); const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize); - services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize); + fireAndForget(() => + services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize) + ); this.dataBytesProcessed = 0; } diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index cae5b8856..f29e5fa04 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -221,12 +221,12 @@ export class WaveAiModel implements ViewModel { ({ label: preset[1]["display:name"], onClick: () => - fireAndForget(async () => { - await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { + fireAndForget(() => + ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { ...preset[1], "ai:preset": preset[0], - }); - }), + }) + ), }) as MenuItem ); dropdownItems.push({ @@ -386,7 +386,7 @@ export class WaveAiModel implements ViewModel { this.setLocked(false); this.cancel = false; }; - handleAiStreamingResponse(); + fireAndForget(handleAiStreamingResponse); } useWaveAi() { @@ -404,14 +404,14 @@ export class WaveAiModel implements ViewModel { keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { if (checkKeyPressed(waveEvent, "Cmd:l")) { - this.clearMessages(); + fireAndForget(this.clearMessages.bind(this)); return true; } return false; } } -function makeWaveAiViewModel(blockId): WaveAiModel { +function makeWaveAiViewModel(blockId: string): WaveAiModel { const waveAiModel = new WaveAiModel(blockId); return waveAiModel; } @@ -634,7 +634,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { // a weird workaround to initialize ansynchronously useEffect(() => { - model.populateMessages(); + fireAndForget(model.populateMessages.bind(model)); }, []); const handleTextAreaChange = (e: React.ChangeEvent) => { diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index dee0eef88..bfc8e11f2 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -293,7 +293,7 @@ export class WebViewModel implements ViewModel { * @param url The URL that has been navigated to. */ handleNavigate(url: string) { - ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url }); + fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url })); globalStore.set(this.url, url); } @@ -432,22 +432,18 @@ export class WebViewModel implements ViewModel { return [ { label: "Set Block Homepage", - click: async () => { - await this.setHomepageUrl(this.getUrl(), "block"); - }, + click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "block")), }, { label: "Set Default Homepage", - click: async () => { - await this.setHomepageUrl(this.getUrl(), "global"); - }, + click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "global")), }, { type: "separator", }, { label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools", - click: async () => { + click: () => { if (this.webviewRef.current) { if (this.webviewRef.current.isDevToolsOpened()) { this.webviewRef.current.closeDevTools(); diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index d78aa2e2e..5b45a9e20 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -58,7 +58,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr const setActiveDrag = useSetAtom(layoutModel.activeDrag); const setReady = useSetAtom(layoutModel.ready); const isResizing = useAtomValue(layoutModel.isResizing); - const ephemeralNode = useAtomValue(layoutModel.ephemeralNode); const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({ activeDrag: monitor.isDragging(), diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 3d19d26ef..1a70fc22d 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getSettingsKeyAtom } from "@/app/store/global"; -import { atomWithThrottle, boundNumber } from "@/util/util"; +import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; import { splitAtom } from "jotai/utils"; import { createRef, CSSProperties } from "react"; @@ -852,7 +852,7 @@ export class LayoutModel { animationTimeS: this.animationTimeS, ready: this.ready, disablePointerEvents: this.activeDrag, - onClose: async () => await this.closeNode(nodeid), + onClose: () => fireAndForget(() => this.closeNode(nodeid)), toggleMagnify: () => this.magnifyNodeToggle(nodeid), focusNode: () => this.focusNode(nodeid), dragHandleRef: createRef(), diff --git a/frontend/layout/lib/layoutModelHooks.ts b/frontend/layout/lib/layoutModelHooks.ts index 12b840c33..9d9a39394 100644 --- a/frontend/layout/lib/layoutModelHooks.ts +++ b/frontend/layout/lib/layoutModelHooks.ts @@ -24,7 +24,7 @@ export function getLayoutModelForTab(tabAtom: Atom): LayoutModel { } const layoutTreeStateAtom = withLayoutTreeStateAtomFromTab(tabAtom); const layoutModel = new LayoutModel(layoutTreeStateAtom, globalStore.get, globalStore.set); - globalStore.sub(layoutTreeStateAtom, () => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated())); + globalStore.sub(layoutTreeStateAtom, () => fireAndForget(layoutModel.onTreeStateAtomUpdated.bind(layoutModel))); layoutModelMap.set(tabId, layoutModel); return layoutModel; } @@ -56,7 +56,7 @@ export function useTileLayout(tabAtom: Atom, tileContent: TileLayoutContent useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); // Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout. - useEffect(() => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated(true)), []); + useEffect(() => fireAndForget(() => layoutModel.onTreeStateAtomUpdated(true)), []); useEffect(() => layoutModel.registerTileLayout(tileContent), [tileContent]); return layoutModel; From 5744f4b06f07d7e957d47063fe9e161b2fe4a6f4 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 5 Dec 2024 18:26:20 -0800 Subject: [PATCH 07/44] closeTab fix (#1403) fixes bug with closeTab when the tab didn't exist in the waveWindow cache. also adds Cmd-Shift-W to close a tab (doesn't work for pinned tabs). and restores Cmd-W for killing blocks on pinned tabs --- emain/emain-tabview.ts | 2 +- emain/emain-window.ts | 60 ++++++++++++++++++++-------------- emain/preload.ts | 2 +- emain/updater.ts | 2 +- frontend/app/store/keymodel.ts | 21 ++++++++++-- frontend/app/tab/tabbar.tsx | 5 +-- frontend/types/custom.d.ts | 2 +- 7 files changed, 60 insertions(+), 34 deletions(-) diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 543276d92..4085521d8 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -108,7 +108,7 @@ export class WaveTabView extends WebContentsView { // TODO: circuitous const waveWindow = waveWindowMap.get(this.waveWindowId); if (waveWindow) { - waveWindow.allTabViews.delete(this.waveTabId); + waveWindow.allLoadedTabViews.delete(this.waveTabId); } } } diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 393599c9e..3e0b2e6c9 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -22,7 +22,7 @@ export class WaveBrowserWindow extends BaseWindow { waveWindowId: string; workspaceId: string; waveReadyPromise: Promise; - allTabViews: Map; + allLoadedTabViews: Map; activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; @@ -108,7 +108,7 @@ export class WaveBrowserWindow extends BaseWindow { this.tabSwitchQueue = []; this.waveWindowId = waveWindow.oid; this.workspaceId = waveWindow.workspaceid; - this.allTabViews = new Map(); + this.allLoadedTabViews = new Map(); const winBoundsPoller = setInterval(() => { if (this.isDestroyed()) { clearInterval(winBoundsPoller); @@ -237,7 +237,7 @@ export class WaveBrowserWindow extends BaseWindow { console.log("win removing window from backend DB", this.waveWindowId); fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true)); } - for (const tabView of this.allTabViews.values()) { + for (const tabView of this.allLoadedTabViews.values()) { tabView?.destroy(); } waveWindowMap.delete(this.waveWindowId); @@ -278,15 +278,15 @@ export class WaveBrowserWindow extends BaseWindow { return; } console.log("switchWorkspace newWs", newWs); - if (this.allTabViews.size) { - for (const tab of this.allTabViews.values()) { + if (this.allLoadedTabViews.size) { + for (const tab of this.allLoadedTabViews.values()) { this.contentView.removeChildView(tab); tab?.destroy(); } } console.log("destroyed all tabs", this.waveWindowId); this.workspaceId = workspaceId; - this.allTabViews = new Map(); + this.allLoadedTabViews = new Map(); await this.setActiveTab(newWs.activetabid, false); } @@ -306,17 +306,22 @@ export class WaveBrowserWindow extends BaseWindow { } async closeTab(tabId: string) { - console.log("closeTab", tabId, this.waveWindowId, this.workspaceId); - const tabView = this.allTabViews.get(tabId); - if (tabView) { - const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); - if (rtn?.closewindow) { - this.close(); - } else if (rtn?.newactivetabid) { - await this.setActiveTab(rtn.newactivetabid, false); - } - this.allTabViews.delete(tabId); + console.log(`closeTab tabid=${tabId} ws=${this.workspaceId} window=${this.waveWindowId}`); + const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); + if (rtn == null) { + console.log("[error] closeTab: no return value", tabId, this.workspaceId, this.waveWindowId); + return; } + if (rtn.closewindow) { + this.close(); + return; + } + if (!rtn.newactivetabid) { + console.log("[error] closeTab, no new active tab", tabId, this.workspaceId, this.waveWindowId); + return; + } + await this.setActiveTab(rtn.newactivetabid, false); + this.allLoadedTabViews.delete(tabId); } async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) { @@ -330,7 +335,7 @@ export class WaveBrowserWindow extends BaseWindow { oldActiveView.isActiveTab = false; } this.activeTabView = tabView; - this.allTabViews.set(tabView.waveTabId, tabView); + this.allLoadedTabViews.set(tabView.waveTabId, tabView); if (!tabInitialized) { console.log("initializing a new tab"); await tabView.initPromise; @@ -407,7 +412,7 @@ export class WaveBrowserWindow extends BaseWindow { } const curBounds = this.getContentBounds(); this.activeTabView?.positionTabOnScreen(curBounds); - for (const tabView of this.allTabViews.values()) { + for (const tabView of this.allLoadedTabViews.values()) { if (tabView == this.activeTabView) { continue; } @@ -465,7 +470,7 @@ export class WaveBrowserWindow extends BaseWindow { export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { for (const ww of waveWindowMap.values()) { - if (ww.allTabViews.has(tabId)) { + if (ww.allLoadedTabViews.has(tabId)) { return ww; } } @@ -530,17 +535,22 @@ ipcMain.on("set-active-tab", async (event, tabId) => { ipcMain.on("create-tab", async (event, opts) => { const senderWc = event.sender; const ww = getWaveWindowByWebContentsId(senderWc.id); - if (!ww) { - return; + if (ww != null) { + await ww.createTab(); } - await ww.createTab(); event.returnValue = true; return null; }); -ipcMain.on("close-tab", async (event, tabId) => { - const ww = getWaveWindowByTabId(tabId); - await ww.closeTab(tabId); +ipcMain.on("close-tab", async (event, workspaceId, tabId) => { + const ww = getWaveWindowByWorkspaceId(workspaceId); + if (ww == null) { + console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`); + return; + } + if (ww != null) { + await ww.closeTab(tabId); + } event.returnValue = true; return null; }); diff --git a/emain/preload.ts b/emain/preload.ts index 86ecdadeb..1636eda94 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -44,7 +44,7 @@ contextBridge.exposeInMainWorld("api", { deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId), setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), createTab: () => ipcRenderer.send("create-tab"), - closeTab: (tabId) => ipcRenderer.send("close-tab", tabId), + closeTab: (workspaceId, tabId) => ipcRenderer.send("close-tab", workspaceId, tabId), setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), sendLog: (log) => ipcRenderer.send("fe-log", log), diff --git a/emain/updater.ts b/emain/updater.ts index 93ec747a9..03a526e27 100644 --- a/emain/updater.ts +++ b/emain/updater.ts @@ -112,7 +112,7 @@ export class Updater { private set status(value: UpdaterStatus) { this._status = value; getAllWaveWindows().forEach((window) => { - const allTabs = Array.from(window.allTabViews.values()); + const allTabs = Array.from(window.allLoadedTabViews.values()); allTabs.forEach((tab) => { tab.webContents.send("app-update-status", value); }); diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 82daad8b9..56448b3fb 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -71,15 +71,20 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean { } function genericClose(tabId: string) { + const ws = globalStore.get(atoms.workspace); const tabORef = WOS.makeORef("tab", tabId); const tabAtom = WOS.getWaveObjectAtom(tabORef); const tabData = globalStore.get(tabAtom); if (tabData == null) { return; } + if (ws.pinnedtabids?.includes(tabId) && tabData.blockids?.length == 1) { + // don't allow closing the last block in a pinned tab + return; + } if (tabData.blockids == null || tabData.blockids.length == 0) { // close tab - getApi().closeTab(tabId); + getApi().closeTab(ws.oid, tabId); deleteLayoutModelForTab(tabId); return; } @@ -246,11 +251,21 @@ function registerGlobalKeys() { return true; }); globalKeyMap.set("Cmd:w", () => { + const tabId = globalStore.get(atoms.staticTabId); + genericClose(tabId); + return true; + }); + globalKeyMap.set("Cmd:Shift:w", () => { const tabId = globalStore.get(atoms.staticTabId); const ws = globalStore.get(atoms.workspace); - if (!ws.pinnedtabids?.includes(tabId)) { - genericClose(tabId); + if (ws.pinnedtabids?.includes(tabId)) { + // switch to first unpinned tab if it exists (for close spamming) + if (ws.tabids != null && ws.tabids.length > 0) { + getApi().setActiveTab(ws.tabids[0]); + } + return true; } + getApi().closeTab(ws.oid, tabId); return true; }); globalKeyMap.set("Cmd:m", () => { diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 817f48262..2bc6fa853 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -5,7 +5,7 @@ import { Button } from "@/app/element/button"; import { modalsModel } from "@/app/store/modalmodel"; import { WindowDrag } from "@/element/windowdrag"; import { deleteLayoutModelForTab } from "@/layout/index"; -import { atoms, createTab, getApi, isDev, PLATFORM, setActiveTab } from "@/store/global"; +import { atoms, createTab, getApi, globalStore, isDev, PLATFORM, setActiveTab } from "@/store/global"; import { fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; @@ -570,7 +570,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => { const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { event?.stopPropagation(); - getApi().closeTab(tabId); + const ws = globalStore.get(atoms.workspace); + getApi().closeTab(ws.oid, tabId); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); deleteLayoutModelForTab(tabId); }; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 49dd49803..6c8053bf1 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -93,7 +93,7 @@ declare global { deleteWorkspace: (workspaceId: string) => void; setActiveTab: (tabId: string) => void; createTab: () => void; - closeTab: (tabId: string) => void; + closeTab: (workspaceId: string, tabId: string) => void; setWindowInitStatus: (status: "ready" | "wave-ready") => void; onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; sendLog: (log: string) => void; From 3c69237d9b2efe8e84e9047290df31ee92803816 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 23:03:30 -0500 Subject: [PATCH 08/44] Prevent crashes when user spams Cmd:T (#1404) When a user spams Cmd:T, a WaveTabView might be created but never end up getting mounted to the window, since another will come along before it can. In these cases, the WaveTabView is essentially in a bad state and attempting to switch to it will result in the window becoming unresponsive. While we could recover it by running waveInit again, it's easier to just dispose of it and treat it as an unloaded tab next time it gets switched to. This adds a timeout to each WaveTabView where once it gets assigned a tab ID, it has 1 second to respond "ready" or it will be destroyed. This should help prevent resource leakages for these dead views. --- emain/emain-tabview.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 4085521d8..aaac9b409 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -32,15 +32,18 @@ export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabVie export class WaveTabView extends WebContentsView { isActiveTab: boolean; waveWindowId: string; // set when showing in an active window - waveTabId: string; // always set, WaveTabViews are unique per tab + private _waveTabId: string; // always set, WaveTabViews are unique per tab lastUsedTs: number; // ts milliseconds createdTs: number; // ts milliseconds initPromise: Promise; + initResolve: () => void; savedInitOpts: WaveInitOpts; waveReadyPromise: Promise; - initResolve: () => void; waveReadyResolve: () => void; + // used to destroy the tab if it is not initialized within a certain time after being assigned a tabId + private destroyTabTimeout: NodeJS.Timeout; + constructor(fullConfig: FullConfigType) { console.log("createBareTabView"); super({ @@ -60,6 +63,13 @@ export class WaveTabView extends WebContentsView { this.waveReadyPromise = new Promise((resolve, _) => { this.waveReadyResolve = resolve; }); + + // Once the frontend is ready, we can cancel the destroyTabTimeout, assuming the tab hasn't been destroyed yet + // Only after a tab is ready will we add it to the wcvCache + this.waveReadyPromise.then(() => { + clearTimeout(this.destroyTabTimeout); + setWaveTabView(this.waveTabId, this); + }); wcIdToWaveTabMap.set(this.webContents.id, this); if (isDevVite) { this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`); @@ -73,6 +83,17 @@ export class WaveTabView extends WebContentsView { this.setBackgroundColor(computeBgColor(fullConfig)); } + get waveTabId(): string { + return this._waveTabId; + } + + set waveTabId(waveTabId: string) { + this._waveTabId = waveTabId; + this.destroyTabTimeout = setTimeout(() => { + this.destroy(); + }, 1000); + } + positionTabOnScreen(winBounds: Rectangle) { const curBounds = this.getBounds(); if ( @@ -102,7 +123,7 @@ export class WaveTabView extends WebContentsView { destroy() { console.log("destroy tab", this.waveTabId); - this.webContents.close(); + this.webContents?.close(); removeWaveTabView(this.waveTabId); // TODO: circuitous @@ -171,7 +192,6 @@ export function getOrCreateWebViewForTab(fullConfig: FullConfigType, tabId: stri tabView = getSpareTab(fullConfig); tabView.lastUsedTs = Date.now(); tabView.waveTabId = tabId; - setWaveTabView(tabId, tabView); tabView.webContents.on("will-navigate", shNavHandler); tabView.webContents.on("will-frame-navigate", shFrameNavHandler); tabView.webContents.on("did-attach-webview", (event, wc) => { From 0fe05a725a65ece390b74ca58747c9f303efb04f Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Thu, 5 Dec 2024 23:50:55 -0500 Subject: [PATCH 09/44] Refuse setActiveTab if tab is already set (#1405) --- emain/emain-window.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 3e0b2e6c9..5a9d5f81c 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -292,6 +292,9 @@ export class WaveBrowserWindow extends BaseWindow { async setActiveTab(tabId: string, setInBackend: boolean) { console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend); + if (this.activeTabView?.waveTabId == tabId) { + return; + } if (setInBackend) { await WorkspaceService.SetActiveTab(this.workspaceId, tabId); } From 3b03a7ab3d5e264453e0921661c48175894eec70 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Dec 2024 00:10:17 -0800 Subject: [PATCH 10/44] tab race condition fixes (#1407) --- emain/emain-tabview.ts | 8 ++- emain/emain-window.ts | 109 ++++++++++++++++++++--------------- emain/emain.ts | 6 +- frontend/app/store/global.ts | 4 +- 4 files changed, 75 insertions(+), 52 deletions(-) diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index aaac9b409..9797db836 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -1,6 +1,7 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { FileService } from "@/app/store/services"; import { adaptFromElectronKeyEvent } from "@/util/keyutil"; import { Rectangle, shell, WebContentsView } from "electron"; import path from "path"; @@ -40,6 +41,8 @@ export class WaveTabView extends WebContentsView { savedInitOpts: WaveInitOpts; waveReadyPromise: Promise; waveReadyResolve: () => void; + isInitialized: boolean = false; + isWaveReady: boolean = false; // used to destroy the tab if it is not initialized within a certain time after being assigned a tabId private destroyTabTimeout: NodeJS.Timeout; @@ -58,6 +61,7 @@ export class WaveTabView extends WebContentsView { this.initResolve = resolve; }); this.initPromise.then(() => { + this.isInitialized = true; console.log("tabview init", Date.now() - this.createdTs + "ms"); }); this.waveReadyPromise = new Promise((resolve, _) => { @@ -67,6 +71,7 @@ export class WaveTabView extends WebContentsView { // Once the frontend is ready, we can cancel the destroyTabTimeout, assuming the tab hasn't been destroyed yet // Only after a tab is ready will we add it to the wcvCache this.waveReadyPromise.then(() => { + this.isWaveReady = true; clearTimeout(this.destroyTabTimeout); setWaveTabView(this.waveTabId, this); }); @@ -184,11 +189,12 @@ export function clearTabCache() { } // returns [tabview, initialized] -export function getOrCreateWebViewForTab(fullConfig: FullConfigType, tabId: string): [WaveTabView, boolean] { +export async function getOrCreateWebViewForTab(tabId: string): Promise<[WaveTabView, boolean]> { let tabView = getWaveTabView(tabId); if (tabView) { return [tabView, true]; } + const fullConfig = await FileService.GetFullConfig(); tabView = getSpareTab(fullConfig); tabView.lastUsedTs = Date.now(); tabView.waveTabId = tabId; diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 5a9d5f81c..f35d474fe 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -18,6 +18,17 @@ export type WindowOpts = { export const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow export let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do) +let cachedClientId: string = null; + +async function getClientId() { + if (cachedClientId != null) { + return cachedClientId; + } + const clientData = await ClientService.GetClientData(); + cachedClientId = clientData?.oid; + return cachedClientId; +} + export class WaveBrowserWindow extends BaseWindow { waveWindowId: string; workspaceId: string; @@ -26,7 +37,7 @@ export class WaveBrowserWindow extends BaseWindow { activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; - private tabSwitchQueue: { tabView: WaveTabView; tabInitialized: boolean }[]; + private tabSwitchQueue: { tabId: string; setInBackend: boolean }[]; constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { console.log("create win", waveWindow.oid); @@ -292,15 +303,7 @@ export class WaveBrowserWindow extends BaseWindow { async setActiveTab(tabId: string, setInBackend: boolean) { console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend); - if (this.activeTabView?.waveTabId == tabId) { - return; - } - if (setInBackend) { - await WorkspaceService.SetActiveTab(this.workspaceId, tabId); - } - const fullConfig = await FileService.GetFullConfig(); - const [tabView, tabInitialized] = getOrCreateWebViewForTab(fullConfig, tabId); - await this.queueTabSwitch(tabView, tabInitialized); + await this.queueTabSwitch(tabId, setInBackend); } async createTab(pinned = false) { @@ -327,8 +330,26 @@ export class WaveBrowserWindow extends BaseWindow { this.allLoadedTabViews.delete(tabId); } + async initializeTab(tabView: WaveTabView) { + const clientId = await getClientId(); + await tabView.initPromise; + this.contentView.addChildView(tabView); + const initOpts = { + tabId: tabView.waveTabId, + clientId: clientId, + windowId: this.waveWindowId, + activate: true, + }; + tabView.savedInitOpts = { ...initOpts }; + tabView.savedInitOpts.activate = false; + let startTime = Date.now(); + console.log("before wave ready, init tab, sending wave-init", tabView.waveTabId); + tabView.webContents.send("wave-init", initOpts); + await tabView.waveReadyPromise; + console.log("wave-ready init time", Date.now() - startTime + "ms"); + } + async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) { - const clientData = await ClientService.GetClientData(); if (this.activeTabView == tabView) { return; } @@ -341,26 +362,11 @@ export class WaveBrowserWindow extends BaseWindow { this.allLoadedTabViews.set(tabView.waveTabId, tabView); if (!tabInitialized) { console.log("initializing a new tab"); - await tabView.initPromise; - this.contentView.addChildView(tabView); - const initOpts = { - tabId: tabView.waveTabId, - clientId: clientData.oid, - windowId: this.waveWindowId, - activate: true, - }; - tabView.savedInitOpts = { ...initOpts }; - tabView.savedInitOpts.activate = false; - let startTime = Date.now(); - tabView.webContents.send("wave-init", initOpts); - console.log("before wave ready"); - await tabView.waveReadyPromise; - // positionTabOnScreen(tabView, this.getContentBounds()); - console.log("wave-ready init time", Date.now() - startTime + "ms"); - // positionTabOffScreen(oldActiveView, this.getContentBounds()); - await this.repositionTabsSlowly(100); + const p1 = this.initializeTab(tabView); + const p2 = this.repositionTabsSlowly(100); + await Promise.all([p1, p2]); } else { - console.log("reusing an existing tab"); + console.log("reusing an existing tab, calling wave-init", tabView.waveTabId); const p1 = this.repositionTabsSlowly(35); const p2 = tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit await Promise.all([p1, p2]); @@ -423,28 +429,41 @@ export class WaveBrowserWindow extends BaseWindow { } } - async queueTabSwitch(tabView: WaveTabView, tabInitialized: boolean) { - if (this.tabSwitchQueue.length == 2) { - this.tabSwitchQueue[1] = { tabView, tabInitialized }; + async queueTabSwitch(tabId: string, setInBackend: boolean) { + if (this.tabSwitchQueue.length >= 2) { + this.tabSwitchQueue[1] = { tabId, setInBackend }; return; } - this.tabSwitchQueue.push({ tabView, tabInitialized }); - if (this.tabSwitchQueue.length == 1) { + const wasEmpty = this.tabSwitchQueue.length === 0; + this.tabSwitchQueue.push({ tabId, setInBackend }); + if (wasEmpty) { await this.processTabSwitchQueue(); } } + // the queue and this function are used to serialize tab switches + // [0] => the tab that is currently being switched to + // [1] => the tab that will be switched to next + // queueTabSwitch will replace [1] if it is already set + // we don't mess with [0] because it is "in process" + // we replace [1] because there is no point to switching to a tab that will be switched out of immediately async processTabSwitchQueue() { - if (this.tabSwitchQueue.length == 0) { - this.tabSwitchQueue = []; - return; - } - try { - const { tabView, tabInitialized } = this.tabSwitchQueue[0]; - await this.setTabViewIntoWindow(tabView, tabInitialized); - } finally { - this.tabSwitchQueue.shift(); - await this.processTabSwitchQueue(); + while (this.tabSwitchQueue.length > 0) { + try { + const { tabId, setInBackend } = this.tabSwitchQueue[0]; + if (this.activeTabView?.waveTabId == tabId) { + continue; + } + if (setInBackend) { + await WorkspaceService.SetActiveTab(this.workspaceId, tabId); + } + const [tabView, tabInitialized] = await getOrCreateWebViewForTab(tabId); + await this.setTabViewIntoWindow(tabView, tabInitialized); + } catch (e) { + console.log("error caught in processTabSwitchQueue", e); + } finally { + this.tabSwitchQueue.shift(); + } } } diff --git a/emain/emain.ts b/emain/emain.ts index 2d2c15047..733702637 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -403,7 +403,6 @@ async function createNewWaveWindow(): Promise { newBrowserWindow.show(); } -// Here's where init is not getting fired electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => { const tabView = getWaveTabViewByWebContentsId(event.sender.id); if (tabView == null || tabView.initResolve == null) { @@ -412,10 +411,9 @@ electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-re if (status === "ready") { tabView.initResolve(); if (tabView.savedInitOpts) { - console.log("savedInitOpts"); + // this handles the "reload" case. we'll re-send the init opts to the frontend + console.log("savedInitOpts calling wave-init", tabView.waveTabId); tabView.webContents.send("wave-init", tabView.savedInitOpts); - } else { - console.log("no-savedInitOpts"); } } else if (status === "wave-ready") { tabView.waveReadyResolve(); diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 4f20994a6..45eca2f2c 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -103,8 +103,8 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { const tabAtom: Atom = atom((get) => { return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), get); }); - // This atom is used to determine the tab id to use for the static tab. It is set to the overrideStaticTabAtom value if it is not null, otherwise it is set to the initOpts.tabId value. - const staticTabIdAtom: Atom = atom((get) => get(overrideStaticTabAtom) ?? initOpts.tabId); + // this is *the* tab that this tabview represents. it should never change. + const staticTabIdAtom: Atom = atom(initOpts.tabId); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; try { From 74bb5c731780fa6870fa00e8a09a56a906ba749b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:21:17 -0800 Subject: [PATCH 11/44] Bump github.com/sashabaranov/go-openai from 1.35.7 to 1.36.0 (#1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [github.com/sashabaranov/go-openai](https://github.com/sashabaranov/go-openai) from 1.35.7 to 1.36.0.
Release notes

Sourced from github.com/sashabaranov/go-openai's releases.

v1.36.0

What's Changed

New Contributors

Full Changelog: https://github.com/sashabaranov/go-openai/compare/v1.35.7...v1.36.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/sashabaranov/go-openai&package-manager=go_modules&previous-version=1.35.7&new-version=1.36.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4d008bed6..2330bc0ad 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/kevinburke/ssh_config v1.2.0 github.com/mattn/go-sqlite3 v1.14.24 github.com/mitchellh/mapstructure v1.5.0 - github.com/sashabaranov/go-openai v1.35.7 + github.com/sashabaranov/go-openai v1.36.0 github.com/sawka/txwrap v0.2.0 github.com/shirou/gopsutil/v4 v4.24.10 github.com/skeema/knownhosts v1.3.0 diff --git a/go.sum b/go.sum index e36da9cf9..181333b89 100644 --- a/go.sum +++ b/go.sum @@ -58,8 +58,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sashabaranov/go-openai v1.35.7 h1:icyrRbkYoKPa4rbO1WSInpJu3qDQrPEnsoJVZ6QymdI= -github.com/sashabaranov/go-openai v1.35.7/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/sashabaranov/go-openai v1.36.0 h1:fcSrn8uGuorzPWCBp8L0aCR95Zjb/Dd+ZSML0YZy9EI= +github.com/sashabaranov/go-openai v1.36.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94= github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM= From 9f10be5629557cfa40a5dd1d4050709d82967cdd Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Dec 2024 09:36:26 -0800 Subject: [PATCH 12/44] add more extensions to mimetype database. (#1417) fallback to text/plain for 0 byte files --- pkg/util/utilfn/mimetypes.go | 6 ++++++ pkg/util/utilfn/utilfn.go | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/pkg/util/utilfn/mimetypes.go b/pkg/util/utilfn/mimetypes.go index 1e464ac29..fdd601d43 100644 --- a/pkg/util/utilfn/mimetypes.go +++ b/pkg/util/utilfn/mimetypes.go @@ -572,6 +572,8 @@ var StaticMimeTypeMap = map[string]string{ ".oeb": "application/vnd.openeye.oeb", ".oxt": "application/vnd.openofficeorg.extension", ".osm": "application/vnd.openstreetmap.data+xml", + ".exe": "application/vnd.microsoft.portable-executable", + ".dll": "application/vnd.microsoft.portable-executable", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide", ".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow", @@ -1108,14 +1110,18 @@ var StaticMimeTypeMap = map[string]string{ ".jsx": "text/jsx", ".less": "text/less", ".md": "text/markdown", + ".mdx": "text/mdx", ".m": "text/mips", ".miz": "text/mizar", ".n3": "text/n3", ".txt": "text/plain", + ".conf": "text/plain", + ".awk": "text/x-awk", ".provn": "text/provenance-notation", ".rst": "text/prs.fallenstein.rst", ".tag": "text/prs.lines.tag", ".rs": "text/x-rust", + ".ini": "text/x-ini", ".sass": "text/scss", ".scss": "text/scss", ".sgml": "text/SGML", diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go index 84b7e7921..626d9730b 100644 --- a/pkg/util/utilfn/utilfn.go +++ b/pkg/util/utilfn/utilfn.go @@ -620,6 +620,7 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error { // on error just returns "" // does not return "application/octet-stream" as this is considered a detection failure // can pass an existing fileInfo to avoid re-statting the file +// falls back to text/plain for 0 byte files func DetectMimeType(path string, fileInfo fs.FileInfo, extended bool) string { if fileInfo == nil { statRtn, err := os.Stat(path) @@ -648,6 +649,9 @@ func DetectMimeType(path string, fileInfo fs.FileInfo, extended bool) string { if mimeType := mime.TypeByExtension(ext); mimeType != "" { return mimeType } + if fileInfo.Size() == 0 { + return "text/plain" + } if !extended { return "" } From 6dfc85b324eb3e74dce2c164ef262d61753d19c6 Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:11:38 -0800 Subject: [PATCH 13/44] Retry Without Wsh on Fail (#1406) Adds the ability for connections to continue without wsh if they fail. This involves creating a menu that warns the user that wsh could not be used. --- docs/docs/connections.mdx | 25 +++++- docs/docs/wsh.mdx | 2 +- frontend/app/block/block.scss | 1 + frontend/app/block/blockframe.tsx | 65 +++++++++++++- frontend/app/store/wshclientapi.ts | 10 +++ frontend/types/gotypes.d.ts | 7 ++ pkg/blockcontroller/blockcontroller.go | 21 ++++- pkg/remote/conncontroller/conncontroller.go | 16 +++- pkg/shellexec/shellexec.go | 83 +++++++++--------- pkg/wshrpc/wshclient/wshclient.go | 12 +++ pkg/wshrpc/wshrpctypes.go | 95 ++++++++++++--------- pkg/wshrpc/wshserver/wshserver.go | 24 ++++++ 12 files changed, 268 insertions(+), 93 deletions(-) diff --git a/docs/docs/connections.mdx b/docs/docs/connections.mdx index a497d2f49..565a799ee 100644 --- a/docs/docs/connections.mdx +++ b/docs/docs/connections.mdx @@ -16,11 +16,18 @@ The easiest way to access connections is to click the { if (!magnified || preview || prevMagifiedState.current) { @@ -342,6 +345,8 @@ const ConnStatusOverlay = React.memo( const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); const width = domRect?.width; const [showError, setShowError] = React.useState(false); + const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); + const [showWshError, setShowWshError] = React.useState(false); React.useEffect(() => { if (width) { @@ -356,12 +361,40 @@ const ConnStatusOverlay = React.memo( prtn.catch((e) => console.log("error reconnecting", connName, e)); }, [connName]); + const handleDisableWsh = React.useCallback(async () => { + // using unknown is a hack. we need proper types for the + // connection config on the frontend + const metamaptype: unknown = { + "conn:wshenabled": false, + }; + const data: ConnConfigRequest = { + host: connName, + metamaptype: metamaptype, + }; + try { + await RpcApi.SetConnectionsConfigCommand(TabRpcClient, data); + } catch (e) { + console.log("problem setting connection config: ", e); + } + }, [connName]); + + const handleRemoveWshError = React.useCallback(async () => { + try { + await RpcApi.DismissWshFailCommand(TabRpcClient, connName); + } catch (e) { + console.log("unable to dismiss wsh error: ", e); + } + }, [connName]); + let statusText = `Disconnected from "${connName}"`; let showReconnect = true; if (connStatus.status == "connecting") { statusText = `Connecting to "${connName}"...`; showReconnect = false; } + if (connStatus.status == "connected") { + showReconnect = false; + } let reconDisplay = null; let reconClassName = "outlined grey"; if (width && width < 350) { @@ -373,18 +406,37 @@ const ConnStatusOverlay = React.memo( } const showIcon = connStatus.status != "connecting"; - if (isLayoutMode || connStatus.status == "connected" || connModalOpen) { + const wshConfigEnabled = fullConfig?.connections?.[connName]?.["conn:wshenabled"] ?? true; + React.useEffect(() => { + const showWshErrorTemp = + connStatus.status == "connected" && + connStatus.wsherror && + connStatus.wsherror != "" && + wshConfigEnabled; + + setShowWshError(showWshErrorTemp); + }, [connStatus, wshConfigEnabled]); + + if (!showWshError && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) { return null; } return (
-
+
{showIcon && }
{statusText}
{showError ?
error: {connStatus.error}
: null} + {showWshError ? ( +
unable to use wsh: {connStatus.wsherror}
+ ) : null} + {showWshError && ( + + )}
{showReconnect ? ( @@ -394,6 +446,11 @@ const ConnStatusOverlay = React.memo(
) : null} + {showWshError ? ( +
+
+ ) : null}
); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index e6142aeee..a5e75774d 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -92,6 +92,11 @@ class RpcApiType { return client.wshRpcCall("deletesubblock", data, opts); } + // command "dismisswshfail" [call] + DismissWshFailCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("dismisswshfail", data, opts); + } + // command "dispose" [call] DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise { return client.wshRpcCall("dispose", data, opts); @@ -262,6 +267,11 @@ class RpcApiType { return client.wshRpcCall("setconfig", data, opts); } + // command "setconnectionsconfig" [call] + SetConnectionsConfigCommand(client: WshClient, data: ConnConfigRequest, opts?: RpcOpts): Promise { + return client.wshRpcCall("setconnectionsconfig", data, opts); + } + // command "setmeta" [call] SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise { return client.wshRpcCall("setmeta", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c3d4d7db4..224da154c 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -278,6 +278,12 @@ declare global { err: string; }; + // wshrpc.ConnConfigRequest + type ConnConfigRequest = { + host: string; + metamaptype: MetaType; + }; + // wshrpc.ConnKeywords type ConnKeywords = { "conn:wshenabled"?: boolean; @@ -319,6 +325,7 @@ declare global { hasconnected: boolean; activeconnnum: number; error?: string; + wsherror?: string; }; // wshrpc.CpuDataRequest diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 413aa3a20..53921b341 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -354,7 +354,26 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj } cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr } - shellProc, err = shellexec.StartRemoteShellProc(rc.TermSize, cmdStr, cmdOpts, conn) + if !conn.WshEnabled.Load() { + shellProc, err = shellexec.StartRemoteShellProcNoWsh(rc.TermSize, cmdStr, cmdOpts, conn) + if err != nil { + return err + } + } else { + shellProc, err = shellexec.StartRemoteShellProc(rc.TermSize, cmdStr, cmdOpts, conn) + if err != nil { + conn.WithLock(func() { + conn.WshError = err.Error() + }) + conn.WshEnabled.Store(false) + log.Printf("error starting remote shell proc with wsh: %v", err) + log.Print("attempting install without wsh") + shellProc, err = shellexec.StartRemoteShellProcNoWsh(rc.TermSize, cmdStr, cmdOpts, conn) + if err != nil { + return err + } + } + } if err != nil { return err } diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index 182180f0c..016898caa 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -59,6 +59,7 @@ type SSHConn struct { DomainSockListener net.Listener ConnController *ssh.Session Error string + WshError string HasWaiter *atomic.Bool LastConnectTime int64 ActiveConnNum int @@ -94,10 +95,12 @@ func (conn *SSHConn) DeriveConnStatus() wshrpc.ConnStatus { return wshrpc.ConnStatus{ Status: conn.Status, Connected: conn.Status == Status_Connected, + WshEnabled: conn.WshEnabled.Load(), Connection: conn.Opts.String(), HasConnected: (conn.LastConnectTime > 0), ActiveConnNum: conn.ActiveConnNum, Error: conn.Error, + WshError: conn.WshError, } } @@ -532,7 +535,11 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn }) } else if installErr != nil { log.Printf("error: unable to install wsh shell extensions for %s: %v\n", conn.GetName(), err) - return fmt.Errorf("conncontroller %s wsh install error: %v", conn.GetName(), installErr) + log.Print("attempting to run with nowsh instead") + conn.WithLock(func() { + conn.WshError = installErr.Error() + }) + conn.WshEnabled.Store(false) } else { conn.WshEnabled.Store(true) } @@ -541,7 +548,12 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn csErr := conn.StartConnServer() if csErr != nil { log.Printf("error: unable to start conn server for %s: %v\n", conn.GetName(), csErr) - return fmt.Errorf("conncontroller %s start wsh connserver error: %v", conn.GetName(), csErr) + log.Print("attempting to run with nowsh instead") + conn.WithLock(func() { + conn.WshError = csErr.Error() + }) + conn.WshEnabled.Store(false) + //return fmt.Errorf("conncontroller %s start wsh connserver error: %v", conn.GetName(), csErr) } } } else { diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 0c4585975..d22cdbb1f 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -236,49 +236,50 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } +func StartRemoteShellProcNoWsh(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { + client := conn.GetClient() + session, err := client.NewSession() + if err != nil { + return nil, err + } + + remoteStdinRead, remoteStdinWriteOurs, err := os.Pipe() + if err != nil { + return nil, err + } + + remoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe() + if err != nil { + return nil, err + } + + pipePty := &PipePty{ + remoteStdinWrite: remoteStdinWriteOurs, + remoteStdoutRead: remoteStdoutReadOurs, + } + if termSize.Rows == 0 || termSize.Cols == 0 { + termSize.Rows = shellutil.DefaultTermRows + termSize.Cols = shellutil.DefaultTermCols + } + if termSize.Rows <= 0 || termSize.Cols <= 0 { + return nil, fmt.Errorf("invalid term size: %v", termSize) + } + session.Stdin = remoteStdinRead + session.Stdout = remoteStdoutWrite + session.Stderr = remoteStdoutWrite + + session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) + sessionWrap := MakeSessionWrap(session, "", pipePty) + err = session.Shell() + if err != nil { + pipePty.Close() + return nil, err + } + return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil +} + func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { client := conn.GetClient() - if !conn.WshEnabled.Load() { - // no wsh code - session, err := client.NewSession() - if err != nil { - return nil, err - } - - remoteStdinRead, remoteStdinWriteOurs, err := os.Pipe() - if err != nil { - return nil, err - } - - remoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe() - if err != nil { - return nil, err - } - - pipePty := &PipePty{ - remoteStdinWrite: remoteStdinWriteOurs, - remoteStdoutRead: remoteStdoutReadOurs, - } - if termSize.Rows == 0 || termSize.Cols == 0 { - termSize.Rows = shellutil.DefaultTermRows - termSize.Cols = shellutil.DefaultTermCols - } - if termSize.Rows <= 0 || termSize.Cols <= 0 { - return nil, fmt.Errorf("invalid term size: %v", termSize) - } - session.Stdin = remoteStdinRead - session.Stdout = remoteStdoutWrite - session.Stderr = remoteStdoutWrite - - session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) - sessionWrap := MakeSessionWrap(session, "", pipePty) - err = session.Shell() - if err != nil { - pipePty.Close() - return nil, err - } - return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil - } shellPath := cmdOpts.ShellPath if shellPath == "" { remoteShellPath, err := remote.DetectShell(client) diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 30f81fe8c..d8aa04748 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -115,6 +115,12 @@ func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData return err } +// command "dismisswshfail", wshserver.DismissWshFailCommand +func DismissWshFailCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "dismisswshfail", data, opts) + return err +} + // command "dispose", wshserver.DisposeCommand func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts) @@ -317,6 +323,12 @@ func SetConfigCommand(w *wshutil.WshRpc, data wshrpc.MetaSettingsType, opts *wsh return err } +// command "setconnectionsconfig", wshserver.SetConnectionsConfigCommand +func SetConnectionsConfigCommand(w *wshutil.WshRpc, data wshrpc.ConnConfigRequest, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "setconnectionsconfig", data, opts) + return err +} + // command "setmeta", wshserver.SetMetaCommand func SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setmeta", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 334ddd18e..ed5aaa7bb 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -29,48 +29,50 @@ const ( ) const ( - Command_Authenticate = "authenticate" // special - Command_Dispose = "dispose" // special (disposes of the route, for multiproxy only) - Command_RouteAnnounce = "routeannounce" // special (for routing) - Command_RouteUnannounce = "routeunannounce" // special (for routing) - Command_Message = "message" - Command_GetMeta = "getmeta" - Command_SetMeta = "setmeta" - Command_SetView = "setview" - Command_ControllerInput = "controllerinput" - Command_ControllerRestart = "controllerrestart" - Command_ControllerStop = "controllerstop" - Command_ControllerResync = "controllerresync" - Command_FileAppend = "fileappend" - Command_FileAppendIJson = "fileappendijson" - Command_ResolveIds = "resolveids" - Command_BlockInfo = "blockinfo" - Command_CreateBlock = "createblock" - Command_DeleteBlock = "deleteblock" - Command_FileWrite = "filewrite" - Command_FileRead = "fileread" - Command_EventPublish = "eventpublish" - Command_EventRecv = "eventrecv" - Command_EventSub = "eventsub" - Command_EventUnsub = "eventunsub" - Command_EventUnsubAll = "eventunsuball" - Command_EventReadHistory = "eventreadhistory" - Command_StreamTest = "streamtest" - Command_StreamWaveAi = "streamwaveai" - Command_StreamCpuData = "streamcpudata" - Command_Test = "test" - Command_RemoteStreamFile = "remotestreamfile" - Command_RemoteFileInfo = "remotefileinfo" - Command_RemoteFileTouch = "remotefiletouch" - Command_RemoteWriteFile = "remotewritefile" - Command_RemoteFileDelete = "remotefiledelete" - Command_RemoteFileJoin = "remotefilejoin" - Command_WaveInfo = "waveinfo" - Command_WshActivity = "wshactivity" - Command_Activity = "activity" - Command_GetVar = "getvar" - Command_SetVar = "setvar" - Command_RemoteMkdir = "remotemkdir" + Command_Authenticate = "authenticate" // special + Command_Dispose = "dispose" // special (disposes of the route, for multiproxy only) + Command_RouteAnnounce = "routeannounce" // special (for routing) + Command_RouteUnannounce = "routeunannounce" // special (for routing) + Command_Message = "message" + Command_GetMeta = "getmeta" + Command_SetMeta = "setmeta" + Command_SetView = "setview" + Command_ControllerInput = "controllerinput" + Command_ControllerRestart = "controllerrestart" + Command_ControllerStop = "controllerstop" + Command_ControllerResync = "controllerresync" + Command_FileAppend = "fileappend" + Command_FileAppendIJson = "fileappendijson" + Command_ResolveIds = "resolveids" + Command_BlockInfo = "blockinfo" + Command_CreateBlock = "createblock" + Command_DeleteBlock = "deleteblock" + Command_FileWrite = "filewrite" + Command_FileRead = "fileread" + Command_EventPublish = "eventpublish" + Command_EventRecv = "eventrecv" + Command_EventSub = "eventsub" + Command_EventUnsub = "eventunsub" + Command_EventUnsubAll = "eventunsuball" + Command_EventReadHistory = "eventreadhistory" + Command_StreamTest = "streamtest" + Command_StreamWaveAi = "streamwaveai" + Command_StreamCpuData = "streamcpudata" + Command_Test = "test" + Command_SetConfig = "setconfig" + Command_SetConnectionsConfig = "connectionsconfig" + Command_RemoteStreamFile = "remotestreamfile" + Command_RemoteFileInfo = "remotefileinfo" + Command_RemoteFileTouch = "remotefiletouch" + Command_RemoteWriteFile = "remotewritefile" + Command_RemoteFileDelete = "remotefiledelete" + Command_RemoteFileJoin = "remotefilejoin" + Command_WaveInfo = "waveinfo" + Command_WshActivity = "wshactivity" + Command_Activity = "activity" + Command_GetVar = "getvar" + Command_SetVar = "setvar" + Command_RemoteMkdir = "remotemkdir" Command_ConnStatus = "connstatus" Command_WslStatus = "wslstatus" @@ -81,6 +83,7 @@ const ( Command_ConnList = "connlist" Command_WslList = "wsllist" Command_WslDefaultDistro = "wsldefaultdistro" + Command_DismissWshFail = "dismisswshfail" Command_WorkspaceList = "workspacelist" @@ -139,6 +142,7 @@ type WshRpcInterface interface { StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData] TestCommand(ctx context.Context, data string) error SetConfigCommand(ctx context.Context, data MetaSettingsType) error + SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error) WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) WshActivityCommand(ct context.Context, data map[string]int) error @@ -156,6 +160,7 @@ type WshRpcInterface interface { ConnListCommand(ctx context.Context) ([]string, error) WslListCommand(ctx context.Context) ([]string, error) WslDefaultDistroCommand(ctx context.Context) (string, error) + DismissWshFailCommand(ctx context.Context, connName string) error // eventrecv is special, it's handled internally by WshRpc with EventListener EventRecvCommand(ctx context.Context, data wps.WaveEvent) error @@ -512,6 +517,11 @@ func (m MetaSettingsType) MarshalJSON() ([]byte, error) { return json.Marshal(m.MetaMapType) } +type ConnConfigRequest struct { + Host string `json:"host"` + MetaMapType waveobj.MetaMapType `json:"metamaptype"` +} + type ConnStatus struct { Status string `json:"status"` WshEnabled bool `json:"wshenabled"` @@ -520,6 +530,7 @@ type ConnStatus struct { HasConnected bool `json:"hasconnected"` // true if it has *ever* connected successfully ActiveConnNum int `json:"activeconnnum"` Error string `json:"error,omitempty"` + WshError string `json:"wsherror,omitempty"` } type WebSelectorOpts struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 1b5f1793c..a5f65301b 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -575,6 +575,11 @@ func (ws *WshServer) SetConfigCommand(ctx context.Context, data wshrpc.MetaSetti return wconfig.SetBaseConfigValue(data.MetaMapType) } +func (ws *WshServer) SetConnectionsConfigCommand(ctx context.Context, data wshrpc.ConnConfigRequest) error { + log.Printf("SET CONNECTIONS CONFIG: %v\n", data) + return wconfig.SetConnectionsConfigValue(data.Host, data.MetaMapType) +} + func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) { rtn := conncontroller.GetAllConnStatus() return rtn, nil @@ -685,6 +690,25 @@ func (ws *WshServer) WslDefaultDistroCommand(ctx context.Context) (string, error return distro.Name(), nil } +/** + * Dismisses the WshFail Command in runtime memory on the backend + */ +func (ws *WshServer) DismissWshFailCommand(ctx context.Context, connName string) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return err + } + conn := conncontroller.GetConn(ctx, opts, false, nil) + if conn == nil { + return fmt.Errorf("connection %s not found", connName) + } + conn.WithLock(func() { + conn.WshError = "" + }) + conn.FireConnChangeEvent() + return nil +} + func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) { blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { From 00e3c4ec756cdd897ca88a1015930e74a77eac5d Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Dec 2024 11:08:51 -0800 Subject: [PATCH 14/44] fix bcstart -- don't allow two controllers to start simultaneously (#1418) --- pkg/blockcontroller/blockcontroller.go | 51 +++++++++++++++++++--- pkg/service/clientservice/clientservice.go | 1 - 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 53921b341..bc7ef20be 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -13,6 +13,7 @@ import ( "log" "strings" "sync" + "sync/atomic" "time" "github.com/wavetermdev/waveterm/pkg/filestore" @@ -77,6 +78,7 @@ type BlockController struct { ShellInputCh chan *BlockInputUnion ShellProcStatus string ShellProcExitCode int + RunLock *atomic.Bool } type BlockControllerRuntimeStatus struct { @@ -492,7 +494,9 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj defer func() { wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId)) bc.UpdateControllerAndSendUpdate(func() bool { - bc.ShellProcStatus = Status_Done + if bc.ShellProcStatus == Status_Running { + bc.ShellProcStatus = Status_Done + } bc.ShellProcExitCode = exitCode return true }) @@ -568,7 +572,31 @@ func setTermSize(ctx context.Context, blockId string, termSize waveobj.TermSize) return nil } +func (bc *BlockController) LockRunLock() bool { + rtn := bc.RunLock.CompareAndSwap(false, true) + if rtn { + log.Printf("block %q run() lock\n", bc.BlockId) + } + return rtn +} + +func (bc *BlockController) UnlockRunLock() { + bc.RunLock.Store(false) + log.Printf("block %q run() unlock\n", bc.BlockId) +} + func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts, force bool) { + runningShellCommand := false + ok := bc.LockRunLock() + if !ok { + log.Printf("block %q is already executing run()\n", bc.BlockId) + return + } + defer func() { + if !runningShellCommand { + bc.UnlockRunLock() + } + }() curStatus := bc.GetRuntimeStatus() controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "") if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { @@ -597,8 +625,10 @@ func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, r return } } + runningShellCommand = true go func() { defer panichandler.PanicHandler("blockcontroller:run-shell-command") + defer bc.UnlockRunLock() var termSize waveobj.TermSize if rtOpts != nil { termSize = rtOpts.TermSize @@ -658,7 +688,7 @@ func CheckConnStatus(blockId string) error { func (bc *BlockController) StopShellProc(shouldWait bool) { bc.Lock.Lock() defer bc.Lock.Unlock() - if bc.ShellProc == nil || bc.ShellProcStatus == Status_Done { + if bc.ShellProc == nil || bc.ShellProcStatus == Status_Done || bc.ShellProcStatus == Status_Init { return } bc.ShellProc.Close() @@ -689,6 +719,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str TabId: tabId, BlockId: blockId, ShellProcStatus: Status_Init, + RunLock: &atomic.Bool{}, } blockControllerMap[blockId] = bc createdController = true @@ -716,11 +747,13 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts } return nil } - // check if conn is different, if so, stop the current controller + log.Printf("resync controller %s %q (%q) (force %v)\n", blockId, controllerName, connName, force) + // check if conn is different, if so, stop the current controller, and set status back to init if curBc != nil { bcStatus := curBc.GetRuntimeStatus() if bcStatus.ShellProcStatus == Status_Running && bcStatus.ShellProcConnName != connName { - StopBlockController(blockId) + log.Printf("stopping blockcontroller %s due to conn change\n", blockId) + StopBlockControllerAndSetStatus(blockId, Status_Init) } } // now if there is a conn, ensure it is connected @@ -754,20 +787,20 @@ func startBlockController(ctx context.Context, tabId string, blockId string, rtO return fmt.Errorf("unknown controller %q", controllerName) } connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "") - log.Printf("start blockcontroller %s %q (%q)\n", blockId, controllerName, connName) err = CheckConnStatus(blockId) if err != nil { return fmt.Errorf("cannot start shellproc: %w", err) } bc := getOrCreateBlockController(tabId, blockId, controllerName) bcStatus := bc.GetRuntimeStatus() + log.Printf("start blockcontroller %s %q (%q) (curstatus %s) (force %v)\n", blockId, controllerName, connName, bcStatus.ShellProcStatus, force) if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done { go bc.run(blockData, blockData.Meta, rtOpts, force) } return nil } -func StopBlockController(blockId string) { +func StopBlockControllerAndSetStatus(blockId string, newStatus string) { bc := GetBlockController(blockId) if bc == nil { return @@ -776,13 +809,17 @@ func StopBlockController(blockId string) { bc.ShellProc.Close() <-bc.ShellProc.DoneCh bc.UpdateControllerAndSendUpdate(func() bool { - bc.ShellProcStatus = Status_Done + bc.ShellProcStatus = newStatus return true }) } } +func StopBlockController(blockId string) { + StopBlockControllerAndSetStatus(blockId, Status_Done) +} + func getControllerList() []*BlockController { globalLock.Lock() defer globalLock.Unlock() diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go index ba5a52867..682d19fd6 100644 --- a/pkg/service/clientservice/clientservice.go +++ b/pkg/service/clientservice/clientservice.go @@ -49,7 +49,6 @@ func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnSta // moves the window to the front of the windowId stack func (cs *ClientService) FocusWindow(ctx context.Context, windowId string) error { - log.Printf("FocusWindow %s\n", windowId) return wcore.FocusWindow(ctx, windowId) } From 925389fc7094a3692d312c38197e75e689270781 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Dec 2024 12:00:24 -0800 Subject: [PATCH 15/44] force createTab to go through the queue as well (#1420) --- emain/emain-window.ts | 54 ++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index f35d474fe..3261a2d6c 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -29,6 +29,17 @@ async function getClientId() { return cachedClientId; } +type TabSwitchQueueEntry = + | { + createTab: false; + tabId: string; + setInBackend: boolean; + } + | { + createTab: true; + pinned: boolean; + }; + export class WaveBrowserWindow extends BaseWindow { waveWindowId: string; workspaceId: string; @@ -37,7 +48,7 @@ export class WaveBrowserWindow extends BaseWindow { activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; - private tabSwitchQueue: { tabId: string; setInBackend: boolean }[]; + private tabSwitchQueue: TabSwitchQueueEntry[]; constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { console.log("create win", waveWindow.oid); @@ -306,11 +317,6 @@ export class WaveBrowserWindow extends BaseWindow { await this.queueTabSwitch(tabId, setInBackend); } - async createTab(pinned = false) { - const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned); - await this.setActiveTab(tabId, false); - } - async closeTab(tabId: string) { console.log(`closeTab tabid=${tabId} ws=${this.workspaceId} window=${this.waveWindowId}`); const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); @@ -430,12 +436,20 @@ export class WaveBrowserWindow extends BaseWindow { } async queueTabSwitch(tabId: string, setInBackend: boolean) { + await this._queueTabSwitchInternal({ createTab: false, tabId, setInBackend }); + } + + async queueCreateTab(pinned = false) { + await this._queueTabSwitchInternal({ createTab: true, pinned }); + } + + async _queueTabSwitchInternal(entry: TabSwitchQueueEntry) { if (this.tabSwitchQueue.length >= 2) { - this.tabSwitchQueue[1] = { tabId, setInBackend }; + this.tabSwitchQueue[1] = entry; return; } const wasEmpty = this.tabSwitchQueue.length === 0; - this.tabSwitchQueue.push({ tabId, setInBackend }); + this.tabSwitchQueue.push(entry); if (wasEmpty) { await this.processTabSwitchQueue(); } @@ -450,12 +464,24 @@ export class WaveBrowserWindow extends BaseWindow { async processTabSwitchQueue() { while (this.tabSwitchQueue.length > 0) { try { - const { tabId, setInBackend } = this.tabSwitchQueue[0]; - if (this.activeTabView?.waveTabId == tabId) { - continue; + const entry = this.tabSwitchQueue[0]; + let tabId: string = null; + // have to use "===" here to get the typechecker to work :/ + if (entry.createTab === true) { + const { pinned } = entry; + tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned); + } else if (entry.createTab === false) { + let setInBackend: boolean = false; + ({ tabId, setInBackend } = entry); + if (this.activeTabView?.waveTabId == tabId) { + continue; + } + if (setInBackend) { + await WorkspaceService.SetActiveTab(this.workspaceId, tabId); + } } - if (setInBackend) { - await WorkspaceService.SetActiveTab(this.workspaceId, tabId); + if (tabId == null) { + return; } const [tabView, tabInitialized] = await getOrCreateWebViewForTab(tabId); await this.setTabViewIntoWindow(tabView, tabInitialized); @@ -558,7 +584,7 @@ ipcMain.on("create-tab", async (event, opts) => { const senderWc = event.sender; const ww = getWaveWindowByWebContentsId(senderWc.id); if (ww != null) { - await ww.createTab(); + await ww.queueCreateTab(); } event.returnValue = true; return null; From ab565409cbc761be08ef0c4595a1b632d0de4cf7 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Fri, 6 Dec 2024 12:13:49 -0800 Subject: [PATCH 16/44] Even simpler tab flicker fix (#1421) --- frontend/app/store/global.ts | 8 -------- frontend/wave.ts | 8 +++----- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 45eca2f2c..cbd56ec52 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -43,9 +43,6 @@ function setPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } -// Used to override the tab id when switching tabs to prevent flicker in the tab bar. -const overrideStaticTabAtom = atom(null) as PrimitiveAtom; - function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom; @@ -658,10 +655,6 @@ function createTab() { } function setActiveTab(tabId: string) { - // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used. - // Also overrides the staticTabAtom to the new tab id so that the active tab is set correctly. - globalStore.set(overrideStaticTabAtom, tabId); - document.body.classList.add("nohover"); getApi().setActiveTab(tabId); } @@ -688,7 +681,6 @@ export { isDev, loadConnStatus, openLink, - overrideStaticTabAtom, PLATFORM, pushFlashError, pushNotification, diff --git a/frontend/wave.ts b/frontend/wave.ts index 497bbae12..deca51ae7 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -22,7 +22,6 @@ import { initGlobal, initGlobalWaveEventSubs, loadConnStatus, - overrideStaticTabAtom, pushFlashError, pushNotification, removeNotificationById, @@ -89,16 +88,15 @@ async function reinitWave() { console.log("Reinit Wave"); getApi().sendLog("Reinit Wave"); - // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used. - // Also overrides the staticTabAtom to the new tab id so that the active tab is set correctly. - globalStore.set(overrideStaticTabAtom, savedInitOpts.tabId); + // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. + document.body.classList.add("nohover"); requestAnimationFrame(() => setTimeout(() => { document.body.classList.remove("nohover"); }, 100) ); - const client = await WOS.reloadWaveObject(WOS.makeORef("client", savedInitOpts.clientId)); + await WOS.reloadWaveObject(WOS.makeORef("client", savedInitOpts.clientId)); const waveWindow = await WOS.reloadWaveObject(WOS.makeORef("window", savedInitOpts.windowId)); const ws = await WOS.reloadWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); const initialTab = await WOS.reloadWaveObject(WOS.makeORef("tab", savedInitOpts.tabId)); From 66d1686e848c8bb8b0046c3d79cfd7caaca2079c Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:19:19 -0800 Subject: [PATCH 17/44] fix: changes for nowsh compatibility with wsl (#1422) The wsl largely ignores most nowsh stuff, but there are some options that can be specified for wsl. This ensures that it will still work whether or not they are set. Additionally if fixes the wsh not installed icon. --- frontend/app/block/blockframe.tsx | 12 ++++++------ pkg/wsl/wsl.go | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index c0daf8b76..5592c6d56 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -714,8 +714,8 @@ const ChangeConnectionBlockModal = React.memo( } if ( conn.includes(connSelected) && - connectionsConfig[conn]?.["display:hidden"] != true && - (connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) + connectionsConfig?.[conn]?.["display:hidden"] != true && + (connectionsConfig?.[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) // != false is necessary because of defaults ) { filteredList.push(conn); @@ -728,8 +728,8 @@ const ChangeConnectionBlockModal = React.memo( } if ( conn.includes(connSelected) && - connectionsConfig[conn]?.["display:hidden"] != true && - (connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) + connectionsConfig?.[conn]?.["display:hidden"] != true && + (connectionsConfig?.[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) // != false is necessary because of defaults ) { filteredWslList.push(conn); @@ -842,8 +842,8 @@ const ChangeConnectionBlockModal = React.memo( (itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => { const connNameA = itemA.value; const connNameB = itemB.value; - const valueA = connectionsConfig[connNameA]?.["display:order"] ?? 0; - const valueB = connectionsConfig[connNameB]?.["display:order"] ?? 0; + const valueA = connectionsConfig?.[connNameA]?.["display:order"] ?? 0; + const valueB = connectionsConfig?.[connNameB]?.["display:order"] ?? 0; return valueA - valueB; } ); diff --git a/pkg/wsl/wsl.go b/pkg/wsl/wsl.go index 331e58a46..c5fa1bbae 100644 --- a/pkg/wsl/wsl.go +++ b/pkg/wsl/wsl.go @@ -89,6 +89,7 @@ func (conn *WslConn) DeriveConnStatus() wshrpc.ConnStatus { return wshrpc.ConnStatus{ Status: conn.Status, Connected: conn.Status == Status_Connected, + WshEnabled: true, // always use wsh for wsl connections (temporary) Connection: conn.GetName(), HasConnected: (conn.LastConnectTime > 0), ActiveConnNum: conn.ActiveConnNum, From 72ea58267d562ab6e0f035dff9c8db1114991ac9 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Fri, 6 Dec 2024 15:33:00 -0800 Subject: [PATCH 18/44] 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. --- emain/emain-window.ts | 120 ++++++++++++++++-- emain/emain-wsh.ts | 10 ++ emain/emain.ts | 112 ++-------------- emain/log.ts | 31 +++++ emain/menu.ts | 68 +++++++++- emain/preload.ts | 1 + frontend/app/store/services.ts | 5 + frontend/app/tab/workspaceswitcher.tsx | 15 ++- frontend/types/custom.d.ts | 1 + pkg/service/objectservice/objectservice.go | 5 + pkg/service/windowservice/windowservice.go | 19 +-- .../workspaceservice/workspaceservice.go | 19 +++ pkg/wcore/block.go | 25 ++-- pkg/wcore/workspace.go | 23 +++- pkg/wps/wpstypes.go | 1 + 15 files changed, 301 insertions(+), 154 deletions(-) create mode 100644 emain/log.ts diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 3261a2d6c..550b27b21 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -1,14 +1,21 @@ // Copyright 2024, Command Line Inc. // 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 { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron"; import path from "path"; 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 { delay, ensureBoundsAreVisible } from "./emain-util"; +import { log } from "./log"; import { getElectronAppBasePath, unamePlatform } from "./platform"; import { updater } from "./updater"; export type WindowOpts = { @@ -272,6 +279,10 @@ export class WaveBrowserWindow extends BaseWindow { async switchWorkspace(workspaceId: string) { 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); if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) { const choice = dialog.showMessageBoxSync(this, { @@ -603,19 +614,100 @@ ipcMain.on("close-tab", async (event, workspaceId, tabId) => { return null; }); -ipcMain.on("switch-workspace", async (event, workspaceId) => { - const ww = getWaveWindowByWebContentsId(event.sender.id); - console.log("switch-workspace", workspaceId, ww?.waveWindowId); - await ww?.switchWorkspace(workspaceId); +ipcMain.on("switch-workspace", (event, workspaceId) => { + fireAndForget(async () => { + const ww = getWaveWindowByWebContentsId(event.sender.id); + console.log("switch-workspace", workspaceId, ww?.waveWindowId); + await ww?.switchWorkspace(workspaceId); + }); }); -ipcMain.on("delete-workspace", async (event, workspaceId) => { - 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 createWorkspace(window: WaveBrowserWindow) { + if (!window) { + return; } + 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(); + } +} diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index b27d63f56..70e5cbf2a 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -55,6 +55,16 @@ export class ElectronWshClientType extends WshClient { } 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; diff --git a/emain/emain.ts b/emain/emain.ts index 733702637..660b03461 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -10,8 +10,6 @@ import * as path from "path"; import { PNG } from "pngjs"; import { sprintf } from "sprintf-js"; import { Readable } from "stream"; -import * as util from "util"; -import winston from "winston"; import * as services from "../frontend/app/store/services"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; @@ -25,7 +23,6 @@ import { getGlobalIsRelaunching, setForceQuit, setGlobalIsQuitting, - setGlobalIsRelaunching, setGlobalIsStarting, setWasActive, setWasInFg, @@ -35,16 +32,19 @@ import { handleCtrlShiftState } from "./emain-util"; import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv"; import { createBrowserWindow, + createNewWaveWindow, focusedWaveWindow, getAllWaveWindows, getWaveWindowById, getWaveWindowByWebContentsId, getWaveWindowByWorkspaceId, + relaunchBrowserWindows, WaveBrowserWindow, } from "./emain-window"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; -import { getAppMenu } from "./menu"; +import { log } from "./log"; +import { instantiateAppMenu, makeAppMenu } from "./menu"; import { getElectronAppBasePath, 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 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( sprintf( @@ -375,34 +352,6 @@ electron.ipcMain.on("open-native-path", (event, filePath: string) => { ); }); -async function createNewWaveWindow(): Promise { - 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") => { const tabView = getWaveTabViewByWebContentsId(event.sender.id); if (tabView == null || tabView.initResolve == null) { @@ -481,10 +430,10 @@ electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenu if (menuDefArr?.length === 0) { return; } - const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu(); - // const { x, y } = electron.screen.getCursorScreenPoint(); - // const windowPos = window.getPosition(); - menu.popup(); + fireAndForget(async () => { + const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : await instantiateAppMenu(); + menu.popup(); + }); event.returnValue = true; }); @@ -561,18 +510,6 @@ function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electro 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) { if (window == null) { return; @@ -649,37 +586,6 @@ process.on("uncaughtException", (error) => { electronApp.quit(); }); -async function relaunchBrowserWindows(): Promise { - 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() { // Set disableHardwareAcceleration as early as possible, if required. const launchSettings = getLaunchSettings(); @@ -694,7 +600,6 @@ async function appMain() { electronApp.quit(); return; } - makeAppMenu(); try { await runWaveSrv(handleWSEvent); } catch (e) { @@ -715,6 +620,7 @@ async function appMain() { } catch (e) { console.log("error initializing wshrpc", e); } + makeAppMenu(); await configureAutoUpdater(); setGlobalIsStarting(false); if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { diff --git a/emain/log.ts b/emain/log.ts new file mode 100644 index 000000000..bba5e9b88 --- /dev/null +++ b/emain/log.ts @@ -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 }; diff --git a/emain/menu.ts b/emain/menu.ts index bc55424d1..a97e98fa7 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -1,10 +1,19 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; import { fireAndForget } from "../frontend/util/util"; 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 { updater } from "./updater"; @@ -27,7 +36,35 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents return null; } -function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { +async function getWorkspaceMenu(): Promise { + 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((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 { const fileMenu: Electron.MenuItemConstructorOptions[] = [ { label: "New Window", @@ -224,6 +261,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { role: "togglefullscreen", }, ]; + + const workspaceMenu = await getWorkspaceMenu(); + const windowMenu: Electron.MenuItemConstructorOptions[] = [ { role: "minimize", accelerator: "" }, { role: "zoom" }, @@ -249,6 +289,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { role: "viewMenu", submenu: viewMenu, }, + { + label: "Workspace", + id: "workspace-menu", + submenu: workspaceMenu, + }, { role: "windowMenu", submenu: windowMenu, @@ -257,4 +302,23 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { return electron.Menu.buildFromTemplate(menuTemplate); } +export function instantiateAppMenu(): Promise { + 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 }; diff --git a/emain/preload.ts b/emain/preload.ts index 1636eda94..b9048ba08 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld("api", { registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), onControlShiftStateUpdate: (callback) => ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), + createWorkspace: () => ipcRenderer.send("create-workspace"), switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId), deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId), setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index fab70a66d..8884917a4 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -183,6 +183,11 @@ class WorkspaceServiceType { return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments)) } + // @returns workspaceId + CreateWorkspace(): Promise { + return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments)) + } + // @returns object updates DeleteWorkspace(workspaceId: string): Promise { return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments)) diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index fc33615e2..7f322c590 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -235,16 +235,23 @@ const WorkspaceSwitcher = () => { - {!isActiveWorkspaceSaved && ( -
+
+ {isActiveWorkspaceSaved ? ( + getApi().createWorkspace()}> + + + +
Create new workspace
+
+ ) : ( saveWorkspace()}>
Save workspace
-
- )} + )} +
); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 6c8053bf1..f2c9fc9cf 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -89,6 +89,7 @@ declare global { setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview registerGlobalWebviewKeys: (keys: string[]) => void; onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; + createWorkspace: () => void; switchWorkspace: (workspaceId: string) => void; deleteWorkspace: (workspaceId: string) => void; setActiveTab: (tabId: string) => void; diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 40baca645..b489ad664 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -12,6 +12,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" + "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -174,6 +175,10 @@ func (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj wave if err != nil { 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 { return waveobj.ContextGetUpdatesRtn(ctx), nil } diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go index 2c4e62bcf..e4f0bb31d 100644 --- a/pkg/service/windowservice/windowservice.go +++ b/pkg/service/windowservice/windowservice.go @@ -50,23 +50,14 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win if err != nil { return nil, fmt.Errorf("error creating window: %w", err) } + ws, err := wcore.GetWorkspace(ctx, window.WorkspaceId) 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 = wcore.CreateTab(ctx, ws.OID, "", true, false) - if err != nil { - 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) - } + err = wlayout.BootstrapNewWorkspaceLayout(ctx, ws) + if err != nil { + return window, fmt.Errorf("error bootstrapping new workspace layout: %w", err) } return window, nil } diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index 2c6dfb92d..61272f3b0 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -20,6 +20,25 @@ const DefaultTimeout = 2 * time.Second 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 { return tsgenmeta.MethodMeta{ ArgNames: []string{"workspaceId"}, diff --git a/pkg/wcore/block.go b/pkg/wcore/block.go index 118a582bf..75c4b6d18 100644 --- a/pkg/wcore/block.go +++ b/pkg/wcore/block.go @@ -152,21 +152,18 @@ func DeleteBlock(ctx context.Context, blockId string, recursive bool) error { log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount) parentORef := waveobj.ParseORefNoErr(block.ParentORef) - if parentORef.OType == waveobj.OType_Tab { - if parentBlockCount == 0 && recursive { - // if parent tab has no blocks, delete the tab - log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID) - parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID) - if err != nil { - 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) + if recursive && parentORef.OType == waveobj.OType_Tab && parentBlockCount == 0 { + // if parent tab has no blocks, delete the tab + log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID) + parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID) + if err != nil { + 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) } go blockcontroller.StopBlockController(blockId) sendBlockCloseEvent(blockId) diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 4d0b6e8d7..b07f58bb9 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -11,6 +11,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -25,7 +26,21 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string Icon: icon, 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 } @@ -38,7 +53,7 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool, if err != nil { 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) 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) } log.Printf("deleted workspace %s\n", workspaceId) + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WorkspaceUpdate}) 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) // if no tabs remaining, close window - if newActiveTabId == "" && recursive { + if recursive && newActiveTabId == "" { log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId) windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) if err != nil { diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index e3bcdd45f..1a21046aa 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -12,6 +12,7 @@ const ( Event_Config = "config" Event_UserInput = "userinput" Event_RouteGone = "route:gone" + Event_WorkspaceUpdate = "workspace:update" ) type WaveEvent struct { From 9f6cdfdbf685da36d702835cf1fb03b8a04d777c Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Dec 2024 15:42:29 -0800 Subject: [PATCH 19/44] closetab / tab destroy fixes (#1424) --- emain/emain-tabview.ts | 74 ++++++++++++++----------- emain/emain-window.ts | 119 ++++++++++++++++++++++++++--------------- 2 files changed, 121 insertions(+), 72 deletions(-) diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 9797db836..9f13278f4 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -4,11 +4,11 @@ import { FileService } from "@/app/store/services"; import { adaptFromElectronKeyEvent } from "@/util/keyutil"; import { Rectangle, shell, WebContentsView } from "electron"; +import { getWaveWindowById } from "emain/emain-window"; import path from "path"; import { configureAuthKeyRequestInjection } from "./authkey"; import { setWasActive } from "./emain-activity"; import { handleCtrlShiftFocus, handleCtrlShiftState, shFrameNavHandler, shNavHandler } from "./emain-util"; -import { waveWindowMap } from "./emain-window"; import { getElectronAppBasePath, isDevVite } from "./platform"; function computeBgColor(fullConfig: FullConfigType): string { @@ -31,8 +31,8 @@ export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabVie } export class WaveTabView extends WebContentsView { + waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare) isActiveTab: boolean; - waveWindowId: string; // set when showing in an active window private _waveTabId: string; // always set, WaveTabViews are unique per tab lastUsedTs: number; // ts milliseconds createdTs: number; // ts milliseconds @@ -43,9 +43,7 @@ export class WaveTabView extends WebContentsView { waveReadyResolve: () => void; isInitialized: boolean = false; isWaveReady: boolean = false; - - // used to destroy the tab if it is not initialized within a certain time after being assigned a tabId - private destroyTabTimeout: NodeJS.Timeout; + isDestroyed: boolean = false; constructor(fullConfig: FullConfigType) { console.log("createBareTabView"); @@ -67,13 +65,8 @@ export class WaveTabView extends WebContentsView { this.waveReadyPromise = new Promise((resolve, _) => { this.waveReadyResolve = resolve; }); - - // Once the frontend is ready, we can cancel the destroyTabTimeout, assuming the tab hasn't been destroyed yet - // Only after a tab is ready will we add it to the wcvCache this.waveReadyPromise.then(() => { this.isWaveReady = true; - clearTimeout(this.destroyTabTimeout); - setWaveTabView(this.waveTabId, this); }); wcIdToWaveTabMap.set(this.webContents.id, this); if (isDevVite) { @@ -84,6 +77,7 @@ export class WaveTabView extends WebContentsView { this.webContents.on("destroyed", () => { wcIdToWaveTabMap.delete(this.webContents.id); removeWaveTabView(this.waveTabId); + this.isDestroyed = true; }); this.setBackgroundColor(computeBgColor(fullConfig)); } @@ -94,9 +88,6 @@ export class WaveTabView extends WebContentsView { set waveTabId(waveTabId: string) { this._waveTabId = waveTabId; - this.destroyTabTimeout = setTimeout(() => { - this.destroy(); - }, 1000); } positionTabOnScreen(winBounds: Rectangle) { @@ -128,14 +119,11 @@ export class WaveTabView extends WebContentsView { destroy() { console.log("destroy tab", this.waveTabId); - this.webContents?.close(); removeWaveTabView(this.waveTabId); - - // TODO: circuitous - const waveWindow = waveWindowMap.get(this.waveWindowId); - if (waveWindow) { - waveWindow.allLoadedTabViews.delete(this.waveTabId); + if (!this.isDestroyed) { + this.webContents?.close(); } + this.isDestroyed = true; } } @@ -155,6 +143,31 @@ export function getWaveTabView(waveTabId: string): WaveTabView | undefined { return rtn; } +function tryEvictEntry(waveTabId: string): boolean { + const tabView = wcvCache.get(waveTabId); + if (!tabView) { + return false; + } + if (tabView.isActiveTab) { + return false; + } + const lastUsedDiff = Date.now() - tabView.lastUsedTs; + if (lastUsedDiff < 1000) { + return false; + } + const ww = getWaveWindowById(tabView.waveWindowId); + if (!ww) { + // this shouldn't happen, but if it does, just destroy the tabview + console.log("[error] WaveWindow not found for WaveTabView", tabView.waveTabId); + tabView.destroy(); + return true; + } else { + // will trigger a destroy on the tabview + ww.removeTabView(tabView.waveTabId, false); + return true; + } +} + function checkAndEvictCache(): void { if (wcvCache.size <= MaxCacheSize) { return; @@ -167,13 +180,9 @@ function checkAndEvictCache(): void { // Otherwise, sort by lastUsedTs return a.lastUsedTs - b.lastUsedTs; }); + const now = Date.now(); for (let i = 0; i < sorted.length - MaxCacheSize; i++) { - if (sorted[i].isActiveTab) { - // don't evict WaveTabViews that are currently showing in a window - continue; - } - const tabView = sorted[i]; - tabView?.destroy(); + tryEvictEntry(sorted[i].waveTabId); } } @@ -181,22 +190,21 @@ export function clearTabCache() { const wcVals = Array.from(wcvCache.values()); for (let i = 0; i < wcVals.length; i++) { const tabView = wcVals[i]; - if (tabView.isActiveTab) { - continue; - } - tabView?.destroy(); + tryEvictEntry(tabView.waveTabId); } } // returns [tabview, initialized] -export async function getOrCreateWebViewForTab(tabId: string): Promise<[WaveTabView, boolean]> { +export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> { let tabView = getWaveTabView(tabId); if (tabView) { return [tabView, true]; } const fullConfig = await FileService.GetFullConfig(); tabView = getSpareTab(fullConfig); + tabView.waveWindowId = waveWindowId; tabView.lastUsedTs = Date.now(); + setWaveTabView(tabId, tabView); tabView.waveTabId = tabId; tabView.webContents.on("will-navigate", shNavHandler); tabView.webContents.on("will-frame-navigate", shFrameNavHandler); @@ -231,11 +239,17 @@ export async function getOrCreateWebViewForTab(tabId: string): Promise<[WaveTabV } export function setWaveTabView(waveTabId: string, wcv: WaveTabView): void { + if (waveTabId == null) { + return; + } wcvCache.set(waveTabId, wcv); checkAndEvictCache(); } function removeWaveTabView(waveTabId: string): void { + if (waveTabId == null) { + return; + } wcvCache.delete(waveTabId); } diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 550b27b21..3e511eae1 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -38,13 +38,17 @@ async function getClientId() { type TabSwitchQueueEntry = | { - createTab: false; + op: "switch"; tabId: string; setInBackend: boolean; } | { - createTab: true; + op: "create"; pinned: boolean; + } + | { + op: "close"; + tabId: string; }; export class WaveBrowserWindow extends BaseWindow { @@ -252,6 +256,11 @@ export class WaveBrowserWindow extends BaseWindow { console.log("win quitting or updating", this.waveWindowId); return; } + waveWindowMap.delete(this.waveWindowId); + if (focusedWaveWindow == this) { + focusedWaveWindow = null; + } + this.removeAllChildViews(); if (getGlobalIsRelaunching()) { console.log("win relaunching", this.waveWindowId); this.destroy(); @@ -266,17 +275,19 @@ export class WaveBrowserWindow extends BaseWindow { console.log("win removing window from backend DB", this.waveWindowId); fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true)); } - for (const tabView of this.allLoadedTabViews.values()) { - tabView?.destroy(); - } - waveWindowMap.delete(this.waveWindowId); - if (focusedWaveWindow == this) { - focusedWaveWindow = null; - } }); waveWindowMap.set(waveWindow.oid, this); } + removeAllChildViews() { + for (const tabView of this.allLoadedTabViews.values()) { + if (!this.isDestroyed()) { + this.contentView.removeChildView(tabView); + } + tabView?.destroy(); + } + } + async switchWorkspace(workspaceId: string) { console.log("switchWorkspace", workspaceId, this.waveWindowId); if (workspaceId == this.workspaceId) { @@ -311,12 +322,7 @@ export class WaveBrowserWindow extends BaseWindow { return; } console.log("switchWorkspace newWs", newWs); - if (this.allLoadedTabViews.size) { - for (const tab of this.allLoadedTabViews.values()) { - this.contentView.removeChildView(tab); - tab?.destroy(); - } - } + this.removeAllChildViews(); console.log("destroyed all tabs", this.waveWindowId); this.workspaceId = workspaceId; this.allLoadedTabViews = new Map(); @@ -329,22 +335,7 @@ export class WaveBrowserWindow extends BaseWindow { } async closeTab(tabId: string) { - console.log(`closeTab tabid=${tabId} ws=${this.workspaceId} window=${this.waveWindowId}`); - const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); - if (rtn == null) { - console.log("[error] closeTab: no return value", tabId, this.workspaceId, this.waveWindowId); - return; - } - if (rtn.closewindow) { - this.close(); - return; - } - if (!rtn.newactivetabid) { - console.log("[error] closeTab, no new active tab", tabId, this.workspaceId, this.waveWindowId); - return; - } - await this.setActiveTab(rtn.newactivetabid, false); - this.allLoadedTabViews.delete(tabId); + await this.queueCloseTab(tabId); } async initializeTab(tabView: WaveTabView) { @@ -447,11 +438,15 @@ export class WaveBrowserWindow extends BaseWindow { } async queueTabSwitch(tabId: string, setInBackend: boolean) { - await this._queueTabSwitchInternal({ createTab: false, tabId, setInBackend }); + await this._queueTabSwitchInternal({ op: "switch", tabId, setInBackend }); } async queueCreateTab(pinned = false) { - await this._queueTabSwitchInternal({ createTab: true, pinned }); + await this._queueTabSwitchInternal({ op: "create", pinned }); + } + + async queueCloseTab(tabId: string) { + await this._queueTabSwitchInternal({ op: "close", tabId }); } async _queueTabSwitchInternal(entry: TabSwitchQueueEntry) { @@ -466,6 +461,12 @@ export class WaveBrowserWindow extends BaseWindow { } } + removeTabViewLater(tabId: string, delayMs: number) { + setTimeout(() => { + this.removeTabView(tabId, false); + }, 1000); + } + // the queue and this function are used to serialize tab switches // [0] => the tab that is currently being switched to // [1] => the tab that will be switched to next @@ -478,10 +479,10 @@ export class WaveBrowserWindow extends BaseWindow { const entry = this.tabSwitchQueue[0]; let tabId: string = null; // have to use "===" here to get the typechecker to work :/ - if (entry.createTab === true) { + if (entry.op === "create") { const { pinned } = entry; tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned); - } else if (entry.createTab === false) { + } else if (entry.op === "switch") { let setInBackend: boolean = false; ({ tabId, setInBackend } = entry); if (this.activeTabView?.waveTabId == tabId) { @@ -490,11 +491,28 @@ export class WaveBrowserWindow extends BaseWindow { if (setInBackend) { await WorkspaceService.SetActiveTab(this.workspaceId, tabId); } + } else if (entry.op === "close") { + console.log("processTabSwitchQueue closeTab", entry.tabId); + tabId = entry.tabId; + const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); + if (rtn == null) { + console.log("[error] closeTab: no return value", tabId, this.workspaceId, this.waveWindowId); + return; + } + this.removeTabViewLater(tabId, 1000); + if (rtn.closewindow) { + this.close(); + return; + } + if (!rtn.newactivetabid) { + return; + } + tabId = rtn.newactivetabid; } if (tabId == null) { return; } - const [tabView, tabInitialized] = await getOrCreateWebViewForTab(tabId); + const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); await this.setTabViewIntoWindow(tabView, tabInitialized); } catch (e) { console.log("error caught in processTabSwitchQueue", e); @@ -520,6 +538,22 @@ export class WaveBrowserWindow extends BaseWindow { } } + removeTabView(tabId: string, force: boolean) { + if (!force && this.activeTabView?.waveTabId == tabId) { + console.log("cannot remove active tab", tabId, this.waveWindowId); + return; + } + const tabView = this.allLoadedTabViews.get(tabId); + if (tabView == null) { + console.log("removeTabView -- tabView not found", tabId, this.waveWindowId); + // the tab was never loaded, so just return + return; + } + this.contentView.removeChildView(tabView); + this.allLoadedTabViews.delete(tabId); + tabView.destroy(); + } + destroy() { console.log("destroy win", this.waveWindowId); this.deleteAllowed = true; @@ -607,9 +641,7 @@ ipcMain.on("close-tab", async (event, workspaceId, tabId) => { console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`); return; } - if (ww != null) { - await ww.closeTab(tabId); - } + await ww.queueCloseTab(tabId); event.returnValue = true; return null; }); @@ -685,9 +717,12 @@ 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(); + if (windows.length > 0) { + for (const window of windows) { + console.log("relaunch -- closing window", window.waveWindowId); + window.close(); + } + await delay(1200); } setGlobalIsRelaunching(false); From e2b999c4b0d9ae81bb9020597c5d854c3714f447 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Fri, 6 Dec 2024 15:50:52 -0800 Subject: [PATCH 20/44] Rename "Default workspace" to "Starter workspace" (#1425) --- pkg/wcore/wcore.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index b5ab4165a..11fea7f15 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -21,8 +21,8 @@ import ( // TODO bring Tx infra into wcore -const DefaultTimeout = 2 * time.Second -const DefaultActivateBlockTimeout = 60 * time.Second +const StarterTimeout = 2 * time.Second +const StarterActivateBlockTimeout = 60 * time.Second // Ensures that the initial data is present in the store, creates an initial window if needed func EnsureInitialData() error { @@ -58,16 +58,16 @@ func EnsureInitialData() error { log.Println("client has windows") return nil } - log.Println("client has no windows, creating default workspace") - defaultWs, err := CreateWorkspace(ctx, "Default workspace", "circle", "green") + log.Println("client has no windows, creating starter workspace") + starterWs, err := CreateWorkspace(ctx, "Starter workspace", "circle", "green") if err != nil { - return fmt.Errorf("error creating default workspace: %w", err) + return fmt.Errorf("error creating starter workspace: %w", err) } - _, err = CreateTab(ctx, defaultWs.OID, "", true, true) + _, err = CreateTab(ctx, starterWs.OID, "", true, true) if err != nil { return fmt.Errorf("error creating tab: %w", err) } - _, err = CreateWindow(ctx, nil, defaultWs.OID) + _, err = CreateWindow(ctx, nil, starterWs.OID) if err != nil { return fmt.Errorf("error creating window: %w", err) } From 7bf1ca5a4959325cd3c830807d97ddcd6ad1e172 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Fri, 6 Dec 2024 15:55:36 -0800 Subject: [PATCH 21/44] Remove unused vars in wcore (#1426) --- pkg/wcore/wcore.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index 11fea7f15..97123c602 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -19,11 +19,6 @@ import ( // the wcore package coordinates actions across the storage layer // orchestrating the wave object store, the wave pubsub system, and the wave rpc system -// TODO bring Tx infra into wcore - -const StarterTimeout = 2 * time.Second -const StarterActivateBlockTimeout = 60 * time.Second - // Ensures that the initial data is present in the store, creates an initial window if needed func EnsureInitialData() error { // does not need to run in a transaction since it is called on startup From e0ede0ff606bf5325da89944e06aa6c29bbe5f95 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Fri, 6 Dec 2024 16:35:01 -0800 Subject: [PATCH 22/44] Add workspace switch accelerators, skip prompt if workspace is already open (#1427) --- emain/emain-window.ts | 47 +++++++++++++++++++++++++------------------ emain/menu.ts | 13 +++++++++++- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 3e511eae1..2cf191504 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -294,27 +294,34 @@ export class WaveBrowserWindow extends BaseWindow { console.log("switchWorkspace already on this workspace", this.waveWindowId); return; } - const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); - if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) { - const choice = dialog.showMessageBoxSync(this, { - type: "question", - buttons: ["Cancel", "Open in New Window", "Yes"], - title: "Confirm", - message: - "This window has unsaved tabs, switching workspaces will delete the existing tabs. Would you like to continue?", - }); - if (choice === 0) { - console.log("user cancelled switch workspace", this.waveWindowId); - return; - } else if (choice === 1) { - console.log("user chose open in new window", this.waveWindowId); - const newWin = await WindowService.CreateWindow(null, workspaceId); - if (!newWin) { - console.log("error creating new window", this.waveWindowId); + + // If the workspace is already owned by a window, then we can just call SwitchWorkspace without first prompting the user, since it'll just focus to the other window. + const workspaceList = await WorkspaceService.ListWorkspaces(); + if (!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid) { + const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); + if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) { + const choice = dialog.showMessageBoxSync(this, { + type: "question", + buttons: ["Cancel", "Open in New Window", "Yes"], + title: "Confirm", + message: + "This window has unsaved tabs, switching workspaces will delete the existing tabs. Would you like to continue?", + }); + if (choice === 0) { + console.log("user cancelled switch workspace", this.waveWindowId); + return; + } else if (choice === 1) { + console.log("user chose open in new window", this.waveWindowId); + const newWin = await WindowService.CreateWindow(null, workspaceId); + if (!newWin) { + console.log("error creating new window", this.waveWindowId); + } + const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), { + unamePlatform, + }); + newBwin.show(); + return; } - const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), { unamePlatform }); - newBwin.show(); - return; } } const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, workspaceId); diff --git a/emain/menu.ts b/emain/menu.ts index a97e98fa7..a4a22eedd 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -48,16 +48,27 @@ async function getWorkspaceMenu(): Promise((workspace) => { + ...workspaceList.map((workspace, i) => { 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); }, + accelerator: getWorkspaceSwitchAccelerator(i), }; }) ); From 297f006cce9b3898353bcb2d1641f100a5a3bd34 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:36:27 +0000 Subject: [PATCH 23/44] chore: bump package version to 0.10.0-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 602ab5ff0..9f264fd79 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.9.3", + "version": "0.10.0-beta.0", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From 7d21f55b84e5a074327ecbe70fa87488f8779d39 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Fri, 6 Dec 2024 17:26:40 -0800 Subject: [PATCH 24/44] Retry the macOS builds in case of notarize failures (#1428) The notarize operation is flaky so I'm wrapping it in a retry --- .github/workflows/build-helper.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml index 96ce7d55b..c0ac82acc 100644 --- a/.github/workflows/build-helper.yml +++ b/.github/workflows/build-helper.yml @@ -113,9 +113,15 @@ jobs: env: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. SNAPCRAFT_BUILD_ENVIRONMENT: host - - name: Build (Darwin) + # Retry Darwin build in case of notarization failures + - uses: nick-fields/retry@v3 + name: Build (Darwin) if: matrix.platform == 'darwin' - run: task package + with: + command: task package + timeout_minutes: 120 + retry_on: error + max_attempts: 3 env: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}} From f858d3ba0f794cb0ed4bff4be19009f29375170d Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Fri, 6 Dec 2024 19:10:34 -0800 Subject: [PATCH 25/44] Pass workspace id to contextmenu-show (#1429) Sometimes, the context menu click handlers don't seem to get passed any window object. Here, I'm sending over the workspace id with the `contextmenu-show` event so that we can resolve our cached copy of the object in case the value from the click handler is empty. --- emain/emain-window.ts | 5 +- emain/emain.ts | 35 +------------- emain/menu.ts | 80 +++++++++++++++++++++++-------- emain/preload.ts | 2 +- frontend/app/store/contextmenu.ts | 4 +- frontend/app/tab/tabbar.tsx | 2 +- frontend/types/custom.d.ts | 2 +- 7 files changed, 71 insertions(+), 59 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 2cf191504..6709db4aa 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -299,7 +299,10 @@ export class WaveBrowserWindow extends BaseWindow { const workspaceList = await WorkspaceService.ListWorkspaces(); if (!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid) { const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); - if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) { + if ( + (curWorkspace.tabids?.length || curWorkspace.pinnedtabids?.length) && + (!curWorkspace.name || !curWorkspace.icon) + ) { const choice = dialog.showMessageBoxSync(this, { type: "question", buttons: ["Cancel", "Open in New Window", "Yes"], diff --git a/emain/emain.ts b/emain/emain.ts index 660b03461..5af067a9b 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -44,7 +44,7 @@ import { import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; import { log } from "./log"; -import { instantiateAppMenu, makeAppMenu } from "./menu"; +import { makeAppMenu } from "./menu"; import { getElectronAppBasePath, getElectronAppUnpackedBasePath, @@ -426,17 +426,6 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); -electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { - if (menuDefArr?.length === 0) { - return; - } - fireAndForget(async () => { - const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : await instantiateAppMenu(); - menu.popup(); - }); - event.returnValue = true; -}); - // we try to set the primary display as index [0] function getActivityDisplays(): ActivityDisplayType[] { const displays = electron.screen.getAllDisplays(); @@ -488,28 +477,6 @@ function runActiveTimer() { setTimeout(runActiveTimer, 60000); } -function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electron.Menu { - const menuItems: electron.MenuItem[] = []; - for (const menuDef of menuDefArr) { - const menuItemTemplate: electron.MenuItemConstructorOptions = { - role: menuDef.role as any, - label: menuDef.label, - type: menuDef.type, - click: (_, window) => { - const ww = window as WaveBrowserWindow; - ww?.activeTabView?.webContents?.send("contextmenu-click", menuDef.id); - }, - checked: menuDef.checked, - }; - if (menuDef.submenu != null) { - menuItemTemplate.submenu = convertMenuDefArrToMenu(menuDef.submenu); - } - const menuItem = new electron.MenuItem(menuItemTemplate); - menuItems.push(menuItem); - } - return electron.Menu.buildFromTemplate(menuItems); -} - function hideWindowWithCatch(window: WaveBrowserWindow) { if (window == null) { return; diff --git a/emain/menu.ts b/emain/menu.ts index a4a22eedd..528f2b440 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -10,6 +10,7 @@ import { createNewWaveWindow, createWorkspace, focusedWaveWindow, + getWaveWindowByWorkspaceId, relaunchBrowserWindows, WaveBrowserWindow, } from "./emain-window"; @@ -36,15 +37,14 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents return null; } -async function getWorkspaceMenu(): Promise { +async function getWorkspaceMenu(ww?: WaveBrowserWindow): Promise { 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)); + fireAndForget(() => createWorkspace((window as WaveBrowserWindow) ?? ww)); }, }, ]; @@ -65,8 +65,7 @@ async function getWorkspaceMenu(): Promise { - const ww = window as WaveBrowserWindow; - ww.switchWorkspace(workspace.workspacedata.oid); + ((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid); }, accelerator: getWorkspaceSwitchAccelerator(i), }; @@ -75,7 +74,8 @@ async function getWorkspaceMenu(): Promise { +async function getAppMenu(callbacks: AppMenuCallbacks, workspaceId?: string): Promise { + const ww = workspaceId && getWaveWindowByWorkspaceId(workspaceId); const fileMenu: Electron.MenuItemConstructorOptions[] = [ { label: "New Window", @@ -94,7 +94,7 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { { label: "About Wave Terminal", click: (_, window) => { - getWindowWebContents(window)?.send("menu-item-about"); + getWindowWebContents(window ?? ww)?.send("menu-item-about"); }, }, { @@ -172,7 +172,7 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { label: "Reload Tab", accelerator: "Shift+CommandOrControl+R", click: (_, window) => { - getWindowWebContents(window)?.reloadIgnoringCache(); + getWindowWebContents(window ?? ww)?.reloadIgnoringCache(); }, }, { @@ -191,7 +191,7 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { label: "Toggle DevTools", accelerator: devToolsAccel, click: (_, window) => { - let wc = getWindowWebContents(window); + let wc = getWindowWebContents(window ?? ww); wc?.toggleDevTools(); }, }, @@ -202,14 +202,14 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { label: "Reset Zoom", accelerator: "CommandOrControl+0", click: (_, window) => { - getWindowWebContents(window)?.setZoomFactor(1); + getWindowWebContents(window ?? ww)?.setZoomFactor(1); }, }, { label: "Zoom In", accelerator: "CommandOrControl+=", click: (_, window) => { - const wc = getWindowWebContents(window); + const wc = getWindowWebContents(window ?? ww); if (wc == null) { return; } @@ -223,7 +223,7 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { label: "Zoom In (hidden)", accelerator: "CommandOrControl+Shift+=", click: (_, window) => { - const wc = getWindowWebContents(window); + const wc = getWindowWebContents(window ?? ww); if (wc == null) { return; } @@ -239,7 +239,7 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { label: "Zoom Out", accelerator: "CommandOrControl+-", click: (_, window) => { - const wc = getWindowWebContents(window); + const wc = getWindowWebContents(window ?? ww); if (wc == null) { return; } @@ -253,7 +253,7 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { label: "Zoom Out (hidden)", accelerator: "CommandOrControl+Shift+-", click: (_, window) => { - const wc = getWindowWebContents(window); + const wc = getWindowWebContents(window ?? ww); if (wc == null) { return; } @@ -313,11 +313,14 @@ async function getAppMenu(callbacks: AppMenuCallbacks): Promise { return electron.Menu.buildFromTemplate(menuTemplate); } -export function instantiateAppMenu(): Promise { - return getAppMenu({ - createNewWaveWindow, - relaunchBrowserWindows, - }); +export function instantiateAppMenu(workspaceId?: string): Promise { + return getAppMenu( + { + createNewWaveWindow, + relaunchBrowserWindows, + }, + workspaceId + ); } export function makeAppMenu() { @@ -332,4 +335,43 @@ waveEventSubscribe({ handler: makeAppMenu, }); +function convertMenuDefArrToMenu(workspaceId: string, menuDefArr: ElectronContextMenuItem[]): electron.Menu { + const menuItems: electron.MenuItem[] = []; + for (const menuDef of menuDefArr) { + const menuItemTemplate: electron.MenuItemConstructorOptions = { + role: menuDef.role as any, + label: menuDef.label, + type: menuDef.type, + click: (_, window) => { + const ww = (window as WaveBrowserWindow) ?? getWaveWindowByWorkspaceId(workspaceId); + if (!ww) { + console.error("invalid window for context menu click handler:", ww, window, workspaceId); + return; + } + ww?.activeTabView?.webContents?.send("contextmenu-click", menuDef.id); + }, + checked: menuDef.checked, + }; + if (menuDef.submenu != null) { + menuItemTemplate.submenu = convertMenuDefArrToMenu(workspaceId, menuDef.submenu); + } + const menuItem = new electron.MenuItem(menuItemTemplate); + menuItems.push(menuItem); + } + return electron.Menu.buildFromTemplate(menuItems); +} + +electron.ipcMain.on("contextmenu-show", (event, workspaceId: string, menuDefArr?: ElectronContextMenuItem[]) => { + if (menuDefArr?.length === 0) { + return; + } + fireAndForget(async () => { + const menu = menuDefArr + ? convertMenuDefArrToMenu(workspaceId, menuDefArr) + : await instantiateAppMenu(workspaceId); + menu.popup(); + }); + event.returnValue = true; +}); + export { getAppMenu }; diff --git a/emain/preload.ts b/emain/preload.ts index b9048ba08..484c13e08 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -16,7 +16,7 @@ contextBridge.exposeInMainWorld("api", { getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), openNewWindow: () => ipcRenderer.send("open-new-window"), - showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), + showContextMenu: (workspaceId, menu) => ipcRenderer.send("contextmenu-show", workspaceId, menu), onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)), downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), openExternal: (url) => { diff --git a/frontend/app/store/contextmenu.ts b/frontend/app/store/contextmenu.ts index 5cbcbc836..17a83ee51 100644 --- a/frontend/app/store/contextmenu.ts +++ b/frontend/app/store/contextmenu.ts @@ -1,7 +1,7 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getApi } from "./global"; +import { atoms, getApi, globalStore } from "./global"; class ContextMenuModelType { handlers: Map void> = new Map(); // id -> handler @@ -48,7 +48,7 @@ class ContextMenuModelType { showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent): void { this.handlers.clear(); const electronMenuItems = this._convertAndRegisterMenu(menu); - getApi().showContextMenu(electronMenuItems); + getApi().showContextMenu(globalStore.get(atoms.workspace).oid, electronMenuItems); } } diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 2bc6fa853..dcb774f20 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -599,7 +599,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { }; function onEllipsisClick() { - getApi().showContextMenu(); + getApi().showContextMenu(workspace.oid); } const tabsWrapperWidth = tabIds.length * tabWidthRef.current; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index f2c9fc9cf..3bcecf1ef 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -72,7 +72,7 @@ declare global { getWebviewPreload: () => string; getAboutModalDetails: () => AboutModalDetails; getDocsiteUrl: () => string; - showContextMenu: (menu?: ElectronContextMenuItem[]) => void; + showContextMenu: (workspaceId: string, menu?: ElectronContextMenuItem[]) => void; onContextMenuClick: (callback: (id: string) => void) => void; onNavigate: (callback: (url: string) => void) => void; onIframeNavigate: (callback: (url: string) => void) => void; From ac8dc25ead86c7cecca43b5211054edd191b466d Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Dec 2024 19:39:58 -0800 Subject: [PATCH 26/44] fix block controller status (add version) (#1430) --- frontend/app/view/term/term.tsx | 18 ++++++++++++------ frontend/types/gotypes.d.ts | 1 + pkg/blockcontroller/blockcontroller.go | 4 ++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index fb429f7b7..60824412c 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -311,12 +311,18 @@ class TermViewModel implements ViewModel { } updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { - globalStore.set(this.shellProcFullStatus, fullStatus); - const status = fullStatus?.shellprocstatus ?? "init"; - if (status == "running") { - this.termRef.current?.setIsRunning?.(true); - } else { - this.termRef.current?.setIsRunning?.(false); + if (fullStatus == null) { + return; + } + const curStatus = globalStore.get(this.shellProcFullStatus); + if (curStatus == null || curStatus.version < fullStatus.version) { + globalStore.set(this.shellProcFullStatus, fullStatus); + const status = fullStatus?.shellprocstatus ?? "init"; + if (status == "running") { + this.termRef.current?.setIsRunning?.(true); + } else { + this.termRef.current?.setIsRunning?.(false); + } } } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 224da154c..1e4d53ca1 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -56,6 +56,7 @@ declare global { // blockcontroller.BlockControllerRuntimeStatus type BlockControllerRuntimeStatus = { blockid: string; + version: number; shellprocstatus?: string; shellprocconnname?: string; shellprocexitcode: number; diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index bc7ef20be..0f863529d 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -79,10 +79,12 @@ type BlockController struct { ShellProcStatus string ShellProcExitCode int RunLock *atomic.Bool + StatusVersion int } type BlockControllerRuntimeStatus struct { BlockId string `json:"blockid"` + Version int `json:"version"` ShellProcStatus string `json:"shellprocstatus,omitempty"` ShellProcConnName string `json:"shellprocconnname,omitempty"` ShellProcExitCode int `json:"shellprocexitcode"` @@ -97,6 +99,8 @@ func (bc *BlockController) WithLock(f func()) { func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus { var rtn BlockControllerRuntimeStatus bc.WithLock(func() { + bc.StatusVersion++ + rtn.Version = bc.StatusVersion rtn.BlockId = bc.BlockId rtn.ShellProcStatus = bc.ShellProcStatus if bc.ShellProc != nil { From 4d3105e9b01c9290ded69c63dc54aa23bfda0ad9 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Sat, 7 Dec 2024 03:41:25 +0000 Subject: [PATCH 27/44] chore: bump package version to 0.10.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9f264fd79..0ea51b136 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.10.0-beta.0", + "version": "0.10.0-beta.1", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From f78067b62de4cbcab0990f44262ca3b4e8674d76 Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Fri, 6 Dec 2024 21:32:50 -0800 Subject: [PATCH 28/44] fix: smarter connection typeahead arrows (#1431) This changes the order of typeahead options to be more user friendly. Additionally, it moves the selected element to the top after a non-arrow keypress. --- frontend/app/block/blockframe.tsx | 69 ++++++++++++-------------- frontend/app/modals/typeaheadmodal.tsx | 3 +- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 5592c6d56..79e5df755 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -740,7 +740,7 @@ const ChangeConnectionBlockModal = React.memo( const newConnectionSuggestion: SuggestionConnectionItem = { status: "connected", icon: "plus", - iconColor: "var(--conn-icon-color)", + iconColor: "var(--grey-text-color)", label: `${connSelected} (New Connection)`, value: "", onSelect: (_: string) => { @@ -763,30 +763,24 @@ const ChangeConnectionBlockModal = React.memo( prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e)); }, }; - const priorityItems: Array = []; - if (createNew) { - priorityItems.push(newConnectionSuggestion); - } - if (showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error")) { - priorityItems.push(reconnectSuggestion); - } - const prioritySuggestions: SuggestionConnectionScope = { - headerText: "", - items: priorityItems, - }; const localName = getUserName() + "@" + getHostName(); const localSuggestion: SuggestionConnectionScope = { headerText: "Local", items: [], }; - localSuggestion.items.push({ - status: "connected", - icon: "laptop", - iconColor: "var(--grey-text-color)", - value: "", - label: localName, - current: connection == null, - }); + if (localName.includes(connSelected)) { + localSuggestion.items.push({ + status: "connected", + icon: "laptop", + iconColor: "var(--grey-text-color)", + value: "", + label: localName, + current: connection == null, + }); + } + if (localName == connSelected) { + createNew = false; + } for (const wslConn of filteredWslList) { const connStatus = connStatusMap.get(wslConn); const connColorNum = computeConnColorNum(connStatus); @@ -849,26 +843,26 @@ const ChangeConnectionBlockModal = React.memo( ); const remoteSuggestions: SuggestionConnectionScope = { headerText: "Remote", - items: [...sortedRemoteItems, connectionsEditItem], + items: [...sortedRemoteItems], }; - let suggestions: Array = []; - if (prioritySuggestions.items.length > 0) { - suggestions.push(prioritySuggestions); - } - if (localSuggestion.items.length > 0) { - suggestions.push(localSuggestion); - } - if (remoteSuggestions.items.length > 0) { - suggestions.push(remoteSuggestions); - } - - let selectionList: Array = [ - ...prioritySuggestions.items, - ...localSuggestion.items, - ...remoteSuggestions.items, + const suggestions: Array = [ + ...(showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error") + ? [reconnectSuggestion] + : []), + ...(localSuggestion.items.length > 0 ? [localSuggestion] : []), + ...(remoteSuggestions.items.length > 0 ? [remoteSuggestions] : []), + ...(connSelected == "" ? [connectionsEditItem] : []), + ...(createNew ? [newConnectionSuggestion] : []), ]; + let selectionList: Array = suggestions.flatMap((item) => { + if ("items" in item) { + return item.items; + } + return item; + }); + // quick way to change icon color when highlighted selectionList = selectionList.map((item, index) => { if (index == rowIndex && item.iconColor == "var(--grey-text-color)") { @@ -899,9 +893,10 @@ const ChangeConnectionBlockModal = React.memo( return true; } if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) { - setRowIndex((idx) => Math.min(idx + 1, selectionList.flat().length - 1)); + setRowIndex((idx) => Math.min(idx + 1, selectionList.length - 1)); return true; } + setRowIndex(0); }, [changeConnModalAtom, viewModel, blockId, connSelected, selectionList] ); diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx index 5ab1a555c..772894f33 100644 --- a/frontend/app/modals/typeaheadmodal.tsx +++ b/frontend/app/modals/typeaheadmodal.tsx @@ -63,7 +63,8 @@ const Suggestions = forwardRef( ); } - return renderItem(item as SuggestionBaseItem, index); + fullIndex += 1; + return renderItem(item as SuggestionBaseItem, fullIndex); })} ); From cf9d24a834d7751e59dc962e3b6036a5f5e3643b Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 10:13:41 -0800 Subject: [PATCH 29/44] Separate Snap publish jobs (#1433) Separating Snap publish jobs so they can be retried individually --- .github/workflows/publish-release.yml | 55 +++++++++++++++++++++++---- Taskfile.yml | 7 +++- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index ce8ae26e1..21e9ddc40 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -6,9 +6,29 @@ on: release: types: [published] jobs: - publish: + publish-s3: + name: Publish to Releases if: ${{ startsWith(github.ref, 'refs/tags/') }} runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Publish from staging + run: "task artifacts:publish:${{ github.ref_name }}" + env: + AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" + AWS_DEFAULT_REGION: us-west-2 + shell: bash + publish-snap-amd64: + name: Publish AMD64 Snap + if: ${{ startsWith(github.ref, 'refs/tags/') }} + needs: [publish-s3] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Task @@ -19,26 +39,45 @@ jobs: - name: Install Snapcraft run: sudo snap install snapcraft --classic shell: bash - - name: Publish from staging - run: "task artifacts:publish:${{ github.ref_name }}" + - name: Download Snap from Release + uses: robinraju/release-downloader@v1 + with: + tag: ${{github.ref_name}} + fileName: "*amd64.snap" + - name: Publish to Snapcraft + run: "task artifacts:snap:publish:${{ github.ref_name }}" env: - AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" - AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" - AWS_DEFAULT_REGION: us-west-2 + SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" + shell: bash + publish-snap-arm64: + name: Publish ARM64 Snap + if: ${{ startsWith(github.ref, 'refs/tags/') }} + needs: [publish-s3] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Snapcraft + run: sudo snap install snapcraft --classic shell: bash - name: Download Snap from Release uses: robinraju/release-downloader@v1 with: tag: ${{github.ref_name}} - fileName: "*.snap" + fileName: "*arm64.snap" - name: Publish to Snapcraft run: "task artifacts:snap:publish:${{ github.ref_name }}" env: SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" shell: bash bump-winget: + name: Submit WinGet PR if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }} - needs: [publish] + needs: [publish-s3] runs-on: windows-latest steps: - uses: actions/checkout@v4 diff --git a/Taskfile.yml b/Taskfile.yml index 9a8d5de9f..ed396e014 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -276,8 +276,11 @@ tasks: CHANNEL: '{{if contains "beta" .UP_VERSION}}beta{{else}}beta,stable{{end}}' cmd: | echo "Releasing to channels: [{{.CHANNEL}}]" - snapcraft upload --release={{.CHANNEL}} waveterm_{{.UP_VERSION}}_arm64.snap - snapcraft upload --release={{.CHANNEL}} waveterm_{{.UP_VERSION}}_amd64.snap + for file in waveterm_{{.UP_VERSION}}_*.snap; do + echo "Publishing $file" + snapcraft upload --release={{.CHANNEL}} $file + echo "Finished publishing $file" + done artifacts:winget:publish:*: desc: Submits a version bump request to WinGet for the latest release. From c5501a5335737242a7bcbd82c144247f578a6bff Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 14:24:02 -0800 Subject: [PATCH 30/44] Move wlayout to wcore, create new tab layout for all new tabs (#1437) Moves the wlayout package contents to wcore to prevent import cycles. Moves the layout calls to other wcore functions instead of being handled by the services. Removes redundant CreateTab in EnsureInitialData and adds a isInitialLaunch flag to the CreateTab and CreateWorkspace functions to ensure that the initial tab is pinned and does not have the initial tab layout (since the starter layout gets applied later) --- pkg/service/clientservice/clientservice.go | 3 +-- pkg/service/windowservice/windowservice.go | 18 ++++-------------- .../workspaceservice/workspaceservice.go | 11 +++-------- pkg/{wlayout/wlayout.go => wcore/layout.go} | 5 ++--- pkg/wcore/wcore.go | 6 +----- pkg/wcore/window.go | 13 +++++++++++-- pkg/wcore/workspace.go | 18 ++++++++++++++---- pkg/wshrpc/wshserver/wshserver.go | 9 ++++----- 8 files changed, 40 insertions(+), 43 deletions(-) rename pkg/{wlayout/wlayout.go => wcore/layout.go} (97%) diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go index 682d19fd6..93a279240 100644 --- a/pkg/service/clientservice/clientservice.go +++ b/pkg/service/clientservice/clientservice.go @@ -14,7 +14,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" - "github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wstore" @@ -64,7 +63,7 @@ func (cs *ClientService) AgreeTos(ctx context.Context) (waveobj.UpdatesRtnType, if err != nil { return nil, fmt.Errorf("error updating client data: %w", err) } - wlayout.BootstrapStarterLayout(ctx) + wcore.BootstrapStarterLayout(ctx) return waveobj.ContextGetUpdatesRtn(ctx), nil } diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go index e4f0bb31d..9ed232b8e 100644 --- a/pkg/service/windowservice/windowservice.go +++ b/pkg/service/windowservice/windowservice.go @@ -14,7 +14,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" - "github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -50,15 +49,6 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win if err != nil { return nil, fmt.Errorf("error creating window: %w", err) } - - ws, err := wcore.GetWorkspace(ctx, window.WorkspaceId) - if err != nil { - return window, fmt.Errorf("error getting 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 } @@ -137,12 +127,12 @@ func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId if !windowCreated { return nil, fmt.Errorf("new window not created") } - wlayout.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{ - ActionType: wlayout.LayoutActionDataType_Remove, + wcore.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{ + ActionType: wcore.LayoutActionDataType_Remove, BlockId: blockId, }) - wlayout.QueueLayoutActionForTab(ctx, ws.ActiveTabId, waveobj.LayoutActionData{ - ActionType: wlayout.LayoutActionDataType_Insert, + wcore.QueueLayoutActionForTab(ctx, ws.ActiveTabId, waveobj.LayoutActionData{ + ActionType: wcore.LayoutActionDataType_Insert, BlockId: blockId, Focused: true, }) diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index 61272f3b0..4f2b4bfb4 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -11,7 +11,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" - "github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -27,12 +26,12 @@ func (svc *WorkspaceService) CreateWorkspace_Meta() tsgenmeta.MethodMeta { } func (svc *WorkspaceService) CreateWorkspace(ctx context.Context) (string, error) { - newWS, err := wcore.CreateWorkspace(ctx, "", "", "") + newWS, err := wcore.CreateWorkspace(ctx, "", "", "", false) if err != nil { return "", fmt.Errorf("error creating workspace: %w", err) } - err = wlayout.BootstrapNewWorkspaceLayout(ctx, newWS) + err = wcore.BootstrapNewWorkspaceLayout(ctx, newWS) if err != nil { return newWS.OID, fmt.Errorf("error bootstrapping new workspace layout: %w", err) } @@ -97,14 +96,10 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned) + tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned, false) if err != nil { return "", nil, fmt.Errorf("error creating tab: %w", err) } - err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout()) - if err != nil { - return "", nil, fmt.Errorf("error applying new tab layout: %w", err) - } updates := waveobj.ContextGetUpdatesRtn(ctx) go func() { defer panichandler.PanicHandler("WorkspaceService:CreateTab:SendUpdateEvents") diff --git a/pkg/wlayout/wlayout.go b/pkg/wcore/layout.go similarity index 97% rename from pkg/wlayout/wlayout.go rename to pkg/wcore/layout.go index aba38c7fb..87ae335f5 100644 --- a/pkg/wlayout/wlayout.go +++ b/pkg/wcore/layout.go @@ -1,7 +1,7 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -package wlayout +package wcore import ( "context" @@ -10,7 +10,6 @@ import ( "time" "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -131,7 +130,7 @@ func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayou for i := 0; i < len(layout); i++ { layoutAction := layout[i] - blockData, err := wcore.CreateBlock(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{}) + blockData, err := CreateBlock(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{}) if err != nil { return fmt.Errorf("unable to create block to apply portable layout to tab %s: %w", tabId, err) } diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index 97123c602..d140ba649 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -54,14 +54,10 @@ func EnsureInitialData() error { return nil } log.Println("client has no windows, creating starter workspace") - starterWs, err := CreateWorkspace(ctx, "Starter workspace", "circle", "green") + starterWs, err := CreateWorkspace(ctx, "Starter workspace", "circle", "green", true) if err != nil { return fmt.Errorf("error creating starter workspace: %w", err) } - _, err = CreateTab(ctx, starterWs.OID, "", true, true) - if err != nil { - return fmt.Errorf("error creating tab: %w", err) - } _, err = CreateWindow(ctx, nil, starterWs.OID) if err != nil { return fmt.Errorf("error creating window: %w", err) diff --git a/pkg/wcore/window.go b/pkg/wcore/window.go index 10c1ae407..31e757a3a 100644 --- a/pkg/wcore/window.go +++ b/pkg/wcore/window.go @@ -75,11 +75,20 @@ func CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId str log.Printf("CreateWindow %v %v\n", winSize, workspaceId) var ws *waveobj.Workspace if workspaceId == "" { - ws1, err := CreateWorkspace(ctx, "", "", "") + ws1, err := CreateWorkspace(ctx, "", "", "", false) if err != nil { return nil, fmt.Errorf("error creating workspace: %w", err) } ws = ws1 + err = BootstrapNewWorkspaceLayout(ctx, ws) + if err != nil { + errStr := fmt.Errorf("error bootstrapping new workspace layout: %w", err) + _, err = DeleteWorkspace(ctx, ws.OID, true) + if err != nil { + errStr = fmt.Errorf("%s\nerror deleting workspace: %w", errStr, err) + } + return nil, errStr + } } else { ws1, err := GetWorkspace(ctx, workspaceId) if err != nil { @@ -176,7 +185,7 @@ func CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window { } if len(ws.TabIds) == 0 { log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID) - _, err = CreateTab(ctx, ws.OID, "", true, false) + _, err = CreateTab(ctx, ws.OID, "", true, false, false) if err != nil { log.Printf("error creating tab (in checkAndFixWindow): %v\n", err) } diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index b07f58bb9..6c8ba439c 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -16,7 +16,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wstore" ) -func CreateWorkspace(ctx context.Context, name string, icon string, color string) (*waveobj.Workspace, error) { +func CreateWorkspace(ctx context.Context, name string, icon string, color string, isInitialLaunch bool) (*waveobj.Workspace, error) { log.Println("CreateWorkspace") ws := &waveobj.Workspace{ OID: uuid.NewString(), @@ -31,7 +31,7 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string return nil, fmt.Errorf("error inserting workspace: %w", err) } - _, err = CreateTab(ctx, ws.OID, "", true, false) + _, err = CreateTab(ctx, ws.OID, "", true, false, isInitialLaunch) if err != nil { return nil, fmt.Errorf("error creating tab: %w", err) } @@ -81,7 +81,7 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error) } // returns tabid -func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool) (string, error) { +func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool, isInitialLaunch bool) (string, error) { if tabName == "" { ws, err := GetWorkspace(ctx, workspaceId) if err != nil { @@ -89,7 +89,9 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate } tabName = "T" + fmt.Sprint(len(ws.TabIds)+len(ws.PinnedTabIds)+1) } - tab, err := createTabObj(ctx, workspaceId, tabName, pinned) + + // The initial tab for the initial launch should be pinned + tab, err := createTabObj(ctx, workspaceId, tabName, pinned || isInitialLaunch) if err != nil { return "", fmt.Errorf("error creating tab: %w", err) } @@ -99,6 +101,14 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate return "", fmt.Errorf("error setting active tab: %w", err) } } + + // No need to apply an initial layout for the initial launch, since the starter layout will get applied after TOS modal dismissal + if !isInitialLaunch { + err = ApplyPortableLayout(ctx, tab.OID, GetNewTabLayout()) + if err != nil { + return tab.OID, fmt.Errorf("error applying new tab layout: %w", err) + } + } telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab") return tab.OID, nil } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index a5f65301b..86d4b24d7 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -29,7 +29,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" - "github.com/wavetermdev/waveterm/pkg/wlayout" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshutil" @@ -180,8 +179,8 @@ func (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.Command if err != nil { return nil, fmt.Errorf("error creating block: %w", err) } - err = wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ - ActionType: wlayout.LayoutActionDataType_Insert, + err = wcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ + ActionType: wcore.LayoutActionDataType_Insert, BlockId: blockData.OID, Magnified: data.Magnified, Focused: true, @@ -506,8 +505,8 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command if err != nil { return fmt.Errorf("error deleting block: %w", err) } - wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ - ActionType: wlayout.LayoutActionDataType_Remove, + wcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ + ActionType: wcore.LayoutActionDataType_Remove, BlockId: data.BlockId, }) updates := waveobj.ContextGetUpdatesRtn(ctx) From 878a7285ab04494de9c05007b98ea0d1271badc5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 9 Dec 2024 14:48:16 -0800 Subject: [PATCH 31/44] implement tab:preset, and increase active tab opacity (#1439) --- frontend/app/tab/tab.scss | 4 ++-- frontend/types/gotypes.d.ts | 1 + pkg/blockcontroller/blockcontroller.go | 2 +- pkg/service/objectservice/objectservice.go | 2 +- pkg/wconfig/metaconsts.go | 2 ++ pkg/wconfig/settingsconfig.go | 2 ++ pkg/wcore/workspace.go | 18 ++++++++++++++++++ pkg/wshrpc/wshserver/wshserver.go | 2 +- pkg/wstore/wstore.go | 4 ++-- 9 files changed, 30 insertions(+), 7 deletions(-) diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 771df50a4..031276698 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -41,7 +41,7 @@ .tab-inner { border-color: transparent; border-radius: 6px; - background: rgb(from var(--main-text-color) r g b / 0.07); + background: rgb(from var(--main-text-color) r g b / 0.14); } .name { @@ -111,7 +111,7 @@ body:not(.nohover) .tab:hover { .tab-inner { border-color: transparent; - background: rgb(from var(--main-text-color) r g b / 0.07); + background: rgb(from var(--main-text-color) r g b / 0.14); } .close { visibility: visible; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1e4d53ca1..8584c1260 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -638,6 +638,7 @@ declare global { "autoupdate:installonquit"?: boolean; "autoupdate:channel"?: string; "preview:showhiddenfiles"?: boolean; + "tab:preset"?: string; "widget:*"?: boolean; "widget:showhelp"?: boolean; "window:*"?: boolean; diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 0f863529d..35d32eb68 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -623,7 +623,7 @@ func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, r waveobj.MetaKey_CmdRunOnce: false, waveobj.MetaKey_CmdRunOnStart: false, } - err := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, bc.BlockId), metaUpdate) + err := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, bc.BlockId), metaUpdate, false) if err != nil { log.Printf("error updating block meta (in blockcontroller.run): %v\n", err) return diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index b489ad664..487372c89 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -143,7 +143,7 @@ func (svc *ObjectService) UpdateObjectMeta(uiContext waveobj.UIContext, orefStr if err != nil { return nil, fmt.Errorf("error parsing object reference: %w", err) } - err = wstore.UpdateObjectMeta(ctx, *oref, meta) + err = wstore.UpdateObjectMeta(ctx, *oref, meta, false) if err != nil { return nil, fmt.Errorf("error updateing %q meta: %w", orefStr, err) } diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 87d8940dd..40c40f592 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -48,6 +48,8 @@ const ( ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles" + ConfigKey_TabPreset = "tab:preset" + ConfigKey_WidgetClear = "widget:*" ConfigKey_WidgetShowHelp = "widget:showhelp" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index ae49174cf..5b64adae7 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -75,6 +75,8 @@ type SettingsType struct { PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"` + TabPreset string `json:"tab:preset,omitempty"` + WidgetClear bool `json:"widget:*,omitempty"` WidgetShowHelp *bool `json:"widget:showhelp,omitempty"` diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 6c8ba439c..4363d9a22 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -11,6 +11,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wstore" @@ -80,6 +81,16 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error) return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID) } +func getTabPresetMeta() (waveobj.MetaMapType, error) { + settings := wconfig.GetWatcher().GetFullConfig() + tabPreset := settings.Settings.TabPreset + if tabPreset == "" { + return nil, nil + } + presetMeta := settings.Presets[tabPreset] + return presetMeta, nil +} + // returns tabid func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool, isInitialLaunch bool) (string, error) { if tabName == "" { @@ -108,6 +119,13 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate if err != nil { return tab.OID, fmt.Errorf("error applying new tab layout: %w", err) } + presetMeta, presetErr := getTabPresetMeta() + if presetErr != nil { + log.Printf("error getting tab preset meta: %v\n", presetErr) + } else if presetMeta != nil && len(presetMeta) > 0 { + tabORef := waveobj.ORefFromWaveObj(tab) + wstore.UpdateObjectMeta(ctx, *tabORef, presetMeta, true) + } } telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab") return tab.OID, nil diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 86d4b24d7..9e0cd1702 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -120,7 +120,7 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef - err := wstore.UpdateObjectMeta(ctx, oref, data.Meta) + err := wstore.UpdateObjectMeta(ctx, oref, data.Meta, false) if err != nil { return fmt.Errorf("error updating object meta: %w", err) } diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 508701a1c..3ba3fab31 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -53,7 +53,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { }) } -func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType) error { +func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType, mergeSpecial bool) error { return WithTx(ctx, func(tx *TxWrap) error { if oref.IsEmpty() { return fmt.Errorf("empty object reference") @@ -66,7 +66,7 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM if objMeta == nil { objMeta = make(map[string]any) } - newMeta := waveobj.MergeMeta(objMeta, meta, false) + newMeta := waveobj.MergeMeta(objMeta, meta, mergeSpecial) waveobj.SetMeta(obj, newMeta) DBUpdate(tx.Context(), obj) return nil From 6a3f72830b2ef54eb207e0bcb9e3143d3a98f4fd Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 15:11:02 -0800 Subject: [PATCH 32/44] Was accidentally bootstrapping new layout twice (#1441) --- pkg/service/workspaceservice/workspaceservice.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index 4f2b4bfb4..a46743b55 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -30,11 +30,6 @@ func (svc *WorkspaceService) CreateWorkspace(ctx context.Context) (string, error if err != nil { return "", fmt.Errorf("error creating workspace: %w", err) } - - err = wcore.BootstrapNewWorkspaceLayout(ctx, newWS) - if err != nil { - return newWS.OID, fmt.Errorf("error bootstrapping new workspace layout: %w", err) - } return newWS.OID, nil } From 43c134ea9a397b1fea3f93c6ffd49c88b0d5783d Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Mon, 9 Dec 2024 15:18:58 -0800 Subject: [PATCH 33/44] fix: don't show wsh error for a connection error (#1438) --- frontend/app/block/blockframe.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 79e5df755..90d2f0f54 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -186,10 +186,7 @@ const BlockFrame_Header = ({ const dragHandleRef = preview ? null : nodeModel.dragHandleRef; const connName = blockData?.meta?.connection; const connStatus = util.useAtomValueSafe(getConnStatusAtom(connName)); - const wshEnabled = - (connName && - (connStatus?.status == "connecting" || (connStatus?.wshenabled && connStatus?.status == "connected"))) ?? - true; + const wshProblem = connName && !connStatus?.wshenabled && connStatus?.status == "connected"; React.useEffect(() => { if (!magnified || preview || prevMagifiedState.current) { @@ -269,7 +266,7 @@ const BlockFrame_Header = ({ changeConnModalAtom={changeConnModalAtom} /> )} - {manageConnection && !wshEnabled && ( + {manageConnection && wshProblem && ( )}
{headerTextElems}
From 7c799d74eb538b47e1ca8f9309ed948c3f283bb0 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 15:22:41 -0800 Subject: [PATCH 34/44] Found another erroneous layout bootstrap (#1442) CreateTab already bootstraps its own layout, don't need BootstrapNewWorkspaceLayout --- pkg/wcore/layout.go | 12 ------------ pkg/wcore/window.go | 9 --------- pkg/wcore/workspace.go | 7 ++++--- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/pkg/wcore/layout.go b/pkg/wcore/layout.go index 87ae335f5..9bca8d24f 100644 --- a/pkg/wcore/layout.go +++ b/pkg/wcore/layout.go @@ -152,18 +152,6 @@ func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayou return nil } -func BootstrapNewWorkspaceLayout(ctx context.Context, workspace *waveobj.Workspace) error { - log.Printf("BootstrapNewWorkspaceLayout, workspace: %v\n", workspace) - tabId := workspace.ActiveTabId - newTabLayout := GetNewTabLayout() - - err := ApplyPortableLayout(ctx, tabId, newTabLayout) - if err != nil { - return fmt.Errorf("error applying new window layout: %w", err) - } - return nil -} - func BootstrapStarterLayout(ctx context.Context) error { ctx, cancelFn := context.WithTimeout(ctx, 2*time.Second) defer cancelFn() diff --git a/pkg/wcore/window.go b/pkg/wcore/window.go index 31e757a3a..81035b144 100644 --- a/pkg/wcore/window.go +++ b/pkg/wcore/window.go @@ -80,15 +80,6 @@ func CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId str return nil, fmt.Errorf("error creating workspace: %w", err) } ws = ws1 - err = BootstrapNewWorkspaceLayout(ctx, ws) - if err != nil { - errStr := fmt.Errorf("error bootstrapping new workspace layout: %w", err) - _, err = DeleteWorkspace(ctx, ws.OID, true) - if err != nil { - errStr = fmt.Errorf("%s\nerror deleting workspace: %w", errStr, err) - } - return nil, errStr - } } else { ws1, err := GetWorkspace(ctx, workspaceId) if err != nil { diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 4363d9a22..d366723c6 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -31,17 +31,18 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string if err != nil { return nil, fmt.Errorf("error inserting workspace: %w", err) } - _, err = CreateTab(ctx, ws.OID, "", true, false, isInitialLaunch) if err != nil { return nil, fmt.Errorf("error creating tab: %w", err) } + + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WorkspaceUpdate}) + 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 } From b706d4524b5c841334e36a1ecf866bdae75dac6e Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 15:47:56 -0800 Subject: [PATCH 35/44] Queue workspace switching on emain (#1440) --- emain/emain-window.ts | 167 +++++++++++++++++++++--------------------- 1 file changed, 85 insertions(+), 82 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 6709db4aa..8b843d459 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -36,19 +36,23 @@ async function getClientId() { return cachedClientId; } -type TabSwitchQueueEntry = +type WindowActionQueueEntry = | { - op: "switch"; + op: "switchtab"; tabId: string; setInBackend: boolean; } | { - op: "create"; + op: "createtab"; pinned: boolean; } | { - op: "close"; + op: "closetab"; tabId: string; + } + | { + op: "switchworkspace"; + workspaceId: string; }; export class WaveBrowserWindow extends BaseWindow { @@ -59,7 +63,7 @@ export class WaveBrowserWindow extends BaseWindow { activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; - private tabSwitchQueue: TabSwitchQueueEntry[]; + private actionQueue: WindowActionQueueEntry[]; constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { console.log("create win", waveWindow.oid); @@ -138,7 +142,7 @@ export class WaveBrowserWindow extends BaseWindow { } super(winOpts); - this.tabSwitchQueue = []; + this.actionQueue = []; this.waveWindowId = waveWindow.oid; this.workspaceId = waveWindow.workspaceid; this.allLoadedTabViews = new Map(); @@ -147,7 +151,7 @@ export class WaveBrowserWindow extends BaseWindow { clearInterval(winBoundsPoller); return; } - if (this.tabSwitchQueue.length > 0) { + if (this.actionQueue.length > 0) { return; } this.finalizePositioning(); @@ -279,7 +283,7 @@ export class WaveBrowserWindow extends BaseWindow { waveWindowMap.set(waveWindow.oid, this); } - removeAllChildViews() { + private removeAllChildViews() { for (const tabView of this.allLoadedTabViews.values()) { if (!this.isDestroyed()) { this.contentView.removeChildView(tabView); @@ -327,28 +331,15 @@ export class WaveBrowserWindow extends BaseWindow { } } } - const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, workspaceId); - if (!newWs) { - return; - } - console.log("switchWorkspace newWs", newWs); - this.removeAllChildViews(); - console.log("destroyed all tabs", this.waveWindowId); - this.workspaceId = workspaceId; - this.allLoadedTabViews = new Map(); - await this.setActiveTab(newWs.activetabid, false); + await this._queueActionInternal({ op: "switchworkspace", workspaceId }); } async setActiveTab(tabId: string, setInBackend: boolean) { console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend); - await this.queueTabSwitch(tabId, setInBackend); + await this._queueActionInternal({ op: "switchtab", tabId, setInBackend }); } - async closeTab(tabId: string) { - await this.queueCloseTab(tabId); - } - - async initializeTab(tabView: WaveTabView) { + private async initializeTab(tabView: WaveTabView) { const clientId = await getClientId(); await tabView.initPromise; this.contentView.addChildView(tabView); @@ -367,7 +358,7 @@ export class WaveBrowserWindow extends BaseWindow { console.log("wave-ready init time", Date.now() - startTime + "ms"); } - async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) { + private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) { if (this.activeTabView == tabView) { return; } @@ -393,18 +384,18 @@ export class WaveBrowserWindow extends BaseWindow { // something is causing the new tab to lose focus so it requires manual refocusing tabView.webContents.focus(); setTimeout(() => { - if (this.activeTabView == tabView && !tabView.webContents.isFocused()) { + if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 10); setTimeout(() => { - if (this.activeTabView == tabView && !tabView.webContents.isFocused()) { + if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) { tabView.webContents.focus(); } }, 30); } - async repositionTabsSlowly(delayMs: number) { + private async repositionTabsSlowly(delayMs: number) { const activeTabView = this.activeTabView; const winBounds = this.getContentBounds(); if (activeTabView == null) { @@ -433,7 +424,7 @@ export class WaveBrowserWindow extends BaseWindow { this.finalizePositioning(); } - finalizePositioning() { + private finalizePositioning() { if (this.isDestroyed()) { return; } @@ -447,77 +438,89 @@ export class WaveBrowserWindow extends BaseWindow { } } - async queueTabSwitch(tabId: string, setInBackend: boolean) { - await this._queueTabSwitchInternal({ op: "switch", tabId, setInBackend }); - } - async queueCreateTab(pinned = false) { - await this._queueTabSwitchInternal({ op: "create", pinned }); + await this._queueActionInternal({ op: "createtab", pinned }); } async queueCloseTab(tabId: string) { - await this._queueTabSwitchInternal({ op: "close", tabId }); + await this._queueActionInternal({ op: "closetab", tabId }); } - async _queueTabSwitchInternal(entry: TabSwitchQueueEntry) { - if (this.tabSwitchQueue.length >= 2) { - this.tabSwitchQueue[1] = entry; + private async _queueActionInternal(entry: WindowActionQueueEntry) { + if (this.actionQueue.length >= 2) { + this.actionQueue[1] = entry; return; } - const wasEmpty = this.tabSwitchQueue.length === 0; - this.tabSwitchQueue.push(entry); + const wasEmpty = this.actionQueue.length === 0; + this.actionQueue.push(entry); if (wasEmpty) { - await this.processTabSwitchQueue(); + await this.processActionQueue(); } } - removeTabViewLater(tabId: string, delayMs: number) { + private removeTabViewLater(tabId: string, delayMs: number) { setTimeout(() => { this.removeTabView(tabId, false); }, 1000); } - // the queue and this function are used to serialize tab switches - // [0] => the tab that is currently being switched to - // [1] => the tab that will be switched to next - // queueTabSwitch will replace [1] if it is already set + // the queue and this function are used to serialize operations that update the window contents view + // processActionQueue will replace [1] if it is already set // we don't mess with [0] because it is "in process" - // we replace [1] because there is no point to switching to a tab that will be switched out of immediately - async processTabSwitchQueue() { - while (this.tabSwitchQueue.length > 0) { + // we replace [1] because there is no point to run an action that is going to be overwritten + private async processActionQueue() { + while (this.actionQueue.length > 0) { try { - const entry = this.tabSwitchQueue[0]; + const entry = this.actionQueue[0]; let tabId: string = null; // have to use "===" here to get the typechecker to work :/ - if (entry.op === "create") { - const { pinned } = entry; - tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned); - } else if (entry.op === "switch") { - let setInBackend: boolean = false; - ({ tabId, setInBackend } = entry); - if (this.activeTabView?.waveTabId == tabId) { - continue; - } - if (setInBackend) { - await WorkspaceService.SetActiveTab(this.workspaceId, tabId); - } - } else if (entry.op === "close") { - console.log("processTabSwitchQueue closeTab", entry.tabId); - tabId = entry.tabId; - const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); - if (rtn == null) { - console.log("[error] closeTab: no return value", tabId, this.workspaceId, this.waveWindowId); - return; - } - this.removeTabViewLater(tabId, 1000); - if (rtn.closewindow) { - this.close(); - return; - } - if (!rtn.newactivetabid) { - return; - } - tabId = rtn.newactivetabid; + switch (entry.op) { + case "createtab": + tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, entry.pinned); + break; + case "switchtab": + tabId = entry.tabId; + if (this.activeTabView?.waveTabId == tabId) { + continue; + } + if (entry.setInBackend) { + await WorkspaceService.SetActiveTab(this.workspaceId, tabId); + } + break; + case "closetab": + tabId = entry.tabId; + const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true); + if (rtn == null) { + console.log( + "[error] closeTab: no return value", + tabId, + this.workspaceId, + this.waveWindowId + ); + return; + } + this.removeTabViewLater(tabId, 1000); + if (rtn.closewindow) { + this.close(); + return; + } + if (!rtn.newactivetabid) { + return; + } + tabId = rtn.newactivetabid; + break; + case "switchworkspace": + const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId); + if (!newWs) { + return; + } + console.log("processActionQueue switchworkspace newWs", newWs); + this.removeAllChildViews(); + console.log("destroyed all tabs", this.waveWindowId); + this.workspaceId = entry.workspaceId; + this.allLoadedTabViews = new Map(); + tabId = newWs.activetabid; + break; } if (tabId == null) { return; @@ -525,14 +528,14 @@ export class WaveBrowserWindow extends BaseWindow { const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); await this.setTabViewIntoWindow(tabView, tabInitialized); } catch (e) { - console.log("error caught in processTabSwitchQueue", e); + console.log("error caught in processActionQueue", e); } finally { - this.tabSwitchQueue.shift(); + this.actionQueue.shift(); } } } - async mainResizeHandler(_: any) { + private async mainResizeHandler(_: any) { if (this == null || this.isDestroyed() || this.fullScreen) { return; } From c071cc04c3d0ea6ca6668b943d907fe6ffc3e5d1 Mon Sep 17 00:00:00 2001 From: systemshift <42102034+systemshift@users.noreply.github.com> Date: Mon, 9 Dec 2024 15:48:33 -0800 Subject: [PATCH 36/44] Perplexity api (#1432) I have added Perplexity to the default AI models. I see Anthropic models are becoming part of the default as well, so I thought I should add a model that is specific for web search. This pull request is a work in progress; reviews and edit recommendations are welcome. --------- Co-authored-by: sawka --- docs/docs/faq.mdx | 17 +++ frontend/app/view/waveai/waveai.tsx | 70 ++++++----- pkg/waveai/perplexitybackend.go | 179 ++++++++++++++++++++++++++++ pkg/waveai/waveai.go | 10 ++ 4 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 pkg/waveai/perplexitybackend.go diff --git a/docs/docs/faq.mdx b/docs/docs/faq.mdx index 1c95d5535..b34825415 100644 --- a/docs/docs/faq.mdx +++ b/docs/docs/faq.mdx @@ -66,6 +66,23 @@ Set these keys: Note: we set the `ai:*` key to true to clear all the existing "ai" keys so this config is a clean slate. +### How can I connect to Perplexity? + +Open your [config file](./config) in Wave using `wsh editconfig`. + +Set these keys: + +```json +{ + "ai:*": true, + "ai:apitype": "perplexity", + "ai:model": "llama-3.1-sonar-small-128k-online", + "ai:apitoken": "" +} +``` + +Note: we set the `ai:*` key to true to clear all the existing "ai" keys so this config is a clean slate. + To switch between models, consider [adding AI Presets](./presets) instead. ### How can I see the block numbers? diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index f29e5fa04..6c0c02a38 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -180,40 +180,54 @@ export class WaveAiModel implements ViewModel { const presetKey = get(this.presetKey); const presetName = presets[presetKey]?.["display:name"] ?? ""; const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); - if (aiOpts?.apitype == "anthropic") { - const modelName = aiOpts.model; - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: "Using Remote Antropic API (" + modelName + ")", - noAction: true, - }); - } else if (isCloud) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "cloud", - title: "Using Wave's AI Proxy (gpt-4o-mini)", - noAction: true, - }); - } else { - const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint"; - const modelName = aiOpts.model; - if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "location-dot", - title: "Using Local Model @ " + baseUrl + " (" + modelName + ")", - noAction: true, - }); - } else { + + // Handle known API providers + switch (aiOpts?.apitype) { + case "anthropic": viewTextChildren.push({ elemtype: "iconbutton", icon: "globe", - title: "Using Remote Model @ " + baseUrl + " (" + modelName + ")", + title: `Using Remote Anthropic API (${aiOpts.model})`, noAction: true, }); - } + break; + case "perplexity": + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "globe", + title: `Using Remote Perplexity API (${aiOpts.model})`, + noAction: true, + }); + break; + default: + if (isCloud) { + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "cloud", + title: "Using Wave's AI Proxy (gpt-4o-mini)", + noAction: true, + }); + } else { + const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint"; + const modelName = aiOpts.model; + if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) { + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "location-dot", + title: `Using Local Model @ ${baseUrl} (${modelName})`, + noAction: true, + }); + } else { + viewTextChildren.push({ + elemtype: "iconbutton", + icon: "globe", + title: `Using Remote Model @ ${baseUrl} (${modelName})`, + noAction: true, + }); + } + } } + const dropdownItems = Object.entries(presets) .sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1)) .map( diff --git a/pkg/waveai/perplexitybackend.go b/pkg/waveai/perplexitybackend.go new file mode 100644 index 000000000..991c87098 --- /dev/null +++ b/pkg/waveai/perplexitybackend.go @@ -0,0 +1,179 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveai + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +type PerplexityBackend struct{} + +var _ AIBackend = PerplexityBackend{} + +// Perplexity API request types +type perplexityMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type perplexityRequest struct { + Model string `json:"model"` + Messages []perplexityMessage `json:"messages"` + Stream bool `json:"stream"` +} + +// Perplexity API response types +type perplexityResponseDelta struct { + Content string `json:"content"` +} + +type perplexityResponseChoice struct { + Delta perplexityResponseDelta `json:"delta"` + FinishReason string `json:"finish_reason"` +} + +type perplexityResponse struct { + ID string `json:"id"` + Choices []perplexityResponseChoice `json:"choices"` + Model string `json:"model"` +} + +func (PerplexityBackend) StreamCompletion(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType] { + rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]) + + go func() { + defer func() { + panicErr := panichandler.PanicHandler("PerplexityBackend.StreamCompletion") + if panicErr != nil { + rtn <- makeAIError(panicErr) + } + close(rtn) + }() + + if request.Opts == nil { + rtn <- makeAIError(errors.New("no perplexity opts found")) + return + } + + model := request.Opts.Model + if model == "" { + model = "llama-3.1-sonar-small-128k-online" + } + + // Convert messages format + var messages []perplexityMessage + for _, msg := range request.Prompt { + role := "user" + if msg.Role == "assistant" { + role = "assistant" + } else if msg.Role == "system" { + role = "system" + } + + messages = append(messages, perplexityMessage{ + Role: role, + Content: msg.Content, + }) + } + + perplexityReq := perplexityRequest{ + Model: model, + Messages: messages, + Stream: true, + } + + reqBody, err := json.Marshal(perplexityReq) + if err != nil { + rtn <- makeAIError(fmt.Errorf("failed to marshal perplexity request: %v", err)) + return + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.perplexity.ai/chat/completions", strings.NewReader(string(reqBody))) + if err != nil { + rtn <- makeAIError(fmt.Errorf("failed to create perplexity request: %v", err)) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+request.Opts.APIToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + rtn <- makeAIError(fmt.Errorf("failed to send perplexity request: %v", err)) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + rtn <- makeAIError(fmt.Errorf("Perplexity API error: %s - %s", resp.Status, string(bodyBytes))) + return + } + + reader := bufio.NewReader(resp.Body) + sentHeader := false + + for { + // Check for context cancellation + select { + case <-ctx.Done(): + rtn <- makeAIError(fmt.Errorf("request cancelled: %v", ctx.Err())) + return + default: + } + + line, err := reader.ReadString('\n') + if err == io.EOF { + break + } + if err != nil { + rtn <- makeAIError(fmt.Errorf("error reading stream: %v", err)) + break + } + + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var response perplexityResponse + if err := json.Unmarshal([]byte(data), &response); err != nil { + rtn <- makeAIError(fmt.Errorf("error unmarshaling response: %v", err)) + break + } + + if !sentHeader { + pk := MakeOpenAIPacket() + pk.Model = response.Model + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Response: *pk} + sentHeader = true + } + + for _, choice := range response.Choices { + pk := MakeOpenAIPacket() + pk.Text = choice.Delta.Content + pk.FinishReason = choice.FinishReason + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Response: *pk} + } + } + }() + + return rtn +} diff --git a/pkg/waveai/waveai.go b/pkg/waveai/waveai.go index 5f5d61edd..44afda0c1 100644 --- a/pkg/waveai/waveai.go +++ b/pkg/waveai/waveai.go @@ -17,6 +17,7 @@ const OpenAICloudReqStr = "openai-cloudreq" const PacketEOFStr = "EOF" const DefaultAzureAPIVersion = "2023-05-15" const ApiType_Anthropic = "anthropic" +const ApiType_Perplexity = "perplexity" type OpenAICmdInfoPacketOutputType struct { Model string `json:"model,omitempty"` @@ -74,6 +75,15 @@ func RunAICommand(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan anthropicBackend := AnthropicBackend{} return anthropicBackend.StreamCompletion(ctx, request) } + if request.Opts.APIType == ApiType_Perplexity { + endpoint := request.Opts.BaseURL + if endpoint == "" { + endpoint = "default" + } + log.Printf("sending ai chat message to perplexity endpoint %q using model %s\n", endpoint, request.Opts.Model) + perplexityBackend := PerplexityBackend{} + return perplexityBackend.StreamCompletion(ctx, request) + } if IsCloudAIRequest(request.Opts) { log.Print("sending ai chat message to default waveterm cloud endpoint\n") cloudBackend := WaveAICloudBackend{} From edab90aa5574d76d0e53b47b1bd4160903be7886 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 16:24:32 -0800 Subject: [PATCH 37/44] New colors for workspace switcher (#1443) Also updates workspace switcher button and tab bg colors --- frontend/app/tab/tab.scss | 4 +- frontend/app/tab/workspaceswitcher.scss | 6 ++- frontend/app/tab/workspaceswitcher.tsx | 60 ++++++++++++++----------- pkg/wcore/wcore.go | 2 +- pkg/wcore/workspace.go | 1 - 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 031276698..1e63b5763 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -41,7 +41,7 @@ .tab-inner { border-color: transparent; border-radius: 6px; - background: rgb(from var(--main-text-color) r g b / 0.14); + background: rgb(from var(--main-text-color) r g b / 0.1); } .name { @@ -111,7 +111,7 @@ body:not(.nohover) .tab:hover { .tab-inner { border-color: transparent; - background: rgb(from var(--main-text-color) r g b / 0.14); + background: rgb(from var(--main-text-color) r g b / 0.1); } .close { visibility: visible; diff --git a/frontend/app/tab/workspaceswitcher.scss b/frontend/app/tab/workspaceswitcher.scss index eb7d96a30..880db794f 100644 --- a/frontend/app/tab/workspaceswitcher.scss +++ b/frontend/app/tab/workspaceswitcher.scss @@ -9,10 +9,14 @@ align-items: center; gap: 12px; border-radius: 6px; - background: var(--modal-bg-color); margin-top: 6px; margin-right: 13px; box-sizing: border-box; + background-color: rgb(from var(--main-text-color) r g b / 0.1) !important; + + &:hover { + background-color: rgb(from var(--main-text-color) r g b / 0.14) !important; + } .workspace-icon { width: 15px; diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 7f322c590..4c81ee4b4 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -32,6 +32,33 @@ interface ColorSelectorProps { className?: string; } +const colors = [ + "#58C142", // Green (accent) + "#00FFDB", // Teal + "#429DFF", // Blue + "#BF55EC", // Purple + "#FF453A", // Red + "#FF9500", // Orange + "#FFE900", // Yellow +]; + +const icons = [ + "triangle", + "star", + "cube", + "gem", + "chess-knight", + "heart", + "plane", + "rocket", + "shield-cat", + "paw-simple", + "umbrella", + "graduation-cap", + "mug-hot", + "circle", +]; + const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => { const handleColorClick = (color: string) => { onSelect(color); @@ -117,31 +144,8 @@ const ColorAndIconSelector = memo( value={title} autoFocus /> - - + +
) : ( - )} From 598296079826ef22f768201b030bdcb782fb14fd Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 00:57:23 +0000 Subject: [PATCH 40/44] chore: bump package version to 0.10.0-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ea51b136..f4fd63064 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.10.0-beta.1", + "version": "0.10.0-beta.2", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From 1f756193c0d554a4c9777e431990017109d88f3e Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 18:07:34 -0800 Subject: [PATCH 41/44] Add href to wave logo in readme (#1447) Also remove unnecessary logo in docs readme --- README.md | 12 +++++++----- docs/README.md | 9 --------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5a4ad188c..5f5b9f22d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@

- - - - Wave Terminal Logo - + + + + + Wave Terminal Logo + +

diff --git a/docs/README.md b/docs/README.md index 549609cf3..67d19195d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,12 +1,3 @@ -

- - - - Wave Terminal Logo - -
-

- # Wave Terminal Documentation This is the home for Wave Terminal's documentation site. This README is specifically about _building_ and contributing to the docs site. If you are looking for the actual hosted docs, go here -- https://docs.waveterm.dev From 24d808cddc59facf40c79557c8a30c792996b195 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 18:29:33 -0800 Subject: [PATCH 42/44] Add divider between color and icon selectors (#1448) Also make left icon fixed width --- frontend/app/tab/workspaceswitcher.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/app/tab/workspaceswitcher.scss b/frontend/app/tab/workspaceswitcher.scss index 880db794f..e95ac00b2 100644 --- a/frontend/app/tab/workspaceswitcher.scss +++ b/frontend/app/tab/workspaceswitcher.scss @@ -75,6 +75,10 @@ .expandable-menu-item-group { margin: 0 8px; + border: 1px solid transparent; + border-radius: 4px; + + --workspace-color: var(--main-bg-color); &:last-child { margin-bottom: 4px; @@ -85,13 +89,6 @@ .expandable-menu-item { margin: 0; } - } - - .expandable-menu-item-group { - border: 1px solid transparent; - border-radius: 4px; - - --workspace-color: var(--main-bg-color); .menu-group-title-wrapper { display: flex; @@ -149,6 +146,7 @@ .left-icon { font-size: 14px; + width: 16px; } } @@ -168,6 +166,8 @@ justify-content: center; align-items: center; margin-top: 5px; + padding-bottom: 15px; + border-bottom: 1px solid var(--modal-border-color); .color-circle { width: 15px; @@ -223,7 +223,7 @@ display: flex; align-items: center; justify-content: center; - margin-top: 10px; + margin-top: 5px; } } From 9bae0303719636014fae9820d380e63ad5c0b953 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Dec 2024 18:32:18 -0800 Subject: [PATCH 43/44] Remove workspace oid from menu (#1449) Now that new workspace has this already in the name, it looks weird to have it added after ![image](https://github.com/user-attachments/assets/405d2111-25e5-4ef4-a467-fae6acb55e11) --- emain/menu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emain/menu.ts b/emain/menu.ts index 528f2b440..0d73d1d41 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -63,7 +63,7 @@ async function getWorkspaceMenu(ww?: WaveBrowserWindow): Promise((workspace, i) => { return { - label: `Switch to ${workspace.workspacedata.name} (${workspace.workspacedata.oid.slice(0, 5)})`, + label: `Switch to ${workspace.workspacedata.name}`, click: (_, window) => { ((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid); }, From 648fa4c49a2ff26fc4e7fe72e8885a53e17bb4e8 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 9 Dec 2024 18:36:53 -0800 Subject: [PATCH 44/44] ignore auto-updater QUIC protocol error (#1450) --- emain/emain.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/emain/emain.ts b/emain/emain.ts index 5af067a9b..b16d13f55 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -546,6 +546,14 @@ process.on("uncaughtException", (error) => { if (caughtException) { return; } + + // Check if the error is related to QUIC protocol, if so, ignore (can happen with the updater) + if (error?.message?.includes("net::ERR_QUIC_PROTOCOL_ERROR")) { + console.log("Ignoring QUIC protocol error:", error.message); + console.log("Stack Trace:", error.stack); + return; + } + caughtException = true; console.log("Uncaught Exception, shutting down: ", error); console.log("Stack Trace:", error.stack);