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}} 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/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/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. 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 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 " +} +``` + +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/docs/docs/wsh.mdx b/docs/docs/wsh.mdx index 6766e78f7..e27f79c92 100644 --- a/docs/docs/wsh.mdx +++ b/docs/docs/wsh.mdx @@ -298,7 +298,7 @@ This will delete the block with the specified id. wsh ssh [user@host] ``` -This will use Wave's internal ssh implementation to connect to the specified remote machine. +This will use Wave's internal ssh implementation to connect to the specified remote machine. The `-i` flag can be used to specify a path to an identity file. --- diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 543276d92..9f13278f4 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -1,13 +1,14 @@ // 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 { 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 { @@ -30,16 +31,19 @@ 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 - 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; + isInitialized: boolean = false; + isWaveReady: boolean = false; + isDestroyed: boolean = false; constructor(fullConfig: FullConfigType) { console.log("createBareTabView"); @@ -55,11 +59,15 @@ 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, _) => { this.waveReadyResolve = resolve; }); + this.waveReadyPromise.then(() => { + this.isWaveReady = true; + }); wcIdToWaveTabMap.set(this.webContents.id, this); if (isDevVite) { this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`); @@ -69,10 +77,19 @@ export class WaveTabView extends WebContentsView { this.webContents.on("destroyed", () => { wcIdToWaveTabMap.delete(this.webContents.id); removeWaveTabView(this.waveTabId); + this.isDestroyed = true; }); this.setBackgroundColor(computeBgColor(fullConfig)); } + get waveTabId(): string { + return this._waveTabId; + } + + set waveTabId(waveTabId: string) { + this._waveTabId = waveTabId; + } + positionTabOnScreen(winBounds: Rectangle) { const curBounds = this.getBounds(); if ( @@ -102,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.allTabViews.delete(this.waveTabId); + if (!this.isDestroyed) { + this.webContents?.close(); } + this.isDestroyed = true; } } @@ -129,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; @@ -141,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); } } @@ -155,23 +190,22 @@ 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 function getOrCreateWebViewForTab(fullConfig: FullConfigType, tabId: string): [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(); - tabView.waveTabId = tabId; setWaveTabView(tabId, tabView); + tabView.waveTabId = tabId; tabView.webContents.on("will-navigate", shNavHandler); tabView.webContents.on("will-frame-navigate", shFrameNavHandler); tabView.webContents.on("did-attach-webview", (event, wc) => { @@ -205,11 +239,17 @@ export function getOrCreateWebViewForTab(fullConfig: FullConfigType, tabId: stri } 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 3b3891d9e..8b843d459 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 = { @@ -18,15 +25,45 @@ 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; +} + +type WindowActionQueueEntry = + | { + op: "switchtab"; + tabId: string; + setInBackend: boolean; + } + | { + op: "createtab"; + pinned: boolean; + } + | { + op: "closetab"; + tabId: string; + } + | { + op: "switchworkspace"; + workspaceId: string; + }; + export class WaveBrowserWindow extends BaseWindow { waveWindowId: string; workspaceId: string; waveReadyPromise: Promise; - allTabViews: Map; + allLoadedTabViews: Map; activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; - private tabSwitchQueue: { tabView: WaveTabView; tabInitialized: boolean }[]; + private actionQueue: WindowActionQueueEntry[]; constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { console.log("create win", waveWindow.oid); @@ -105,16 +142,16 @@ export class WaveBrowserWindow extends BaseWindow { } super(winOpts); - this.tabSwitchQueue = []; + this.actionQueue = []; this.waveWindowId = waveWindow.oid; this.workspaceId = waveWindow.workspaceid; - this.allTabViews = new Map(); + this.allLoadedTabViews = new Map(); const winBoundsPoller = setInterval(() => { if (this.isDestroyed()) { clearInterval(winBoundsPoller); return; } - if (this.tabSwitchQueue.length > 0) { + if (this.actionQueue.length > 0) { return; } this.finalizePositioning(); @@ -165,7 +202,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); }); @@ -223,6 +260,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(); @@ -235,93 +277,88 @@ 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)); } - this.destroy(); }); waveWindowMap.set(waveWindow.oid, this); } + private 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); - 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 newWs = await WindowService.SwitchWorkspace(this.waveWindowId, workspaceId); - if (!newWs) { + if (workspaceId == this.workspaceId) { + console.log("switchWorkspace already on this workspace", this.waveWindowId); return; } - console.log("switchWorkspace newWs", newWs); - if (this.allTabViews.size) { - for (const tab of this.allTabViews.values()) { - this.contentView.removeChildView(tab); - tab?.destroy(); + + // 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 || curWorkspace.pinnedtabids?.length) && + (!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; + } } } - console.log("destroyed all tabs", this.waveWindowId); - this.workspaceId = workspaceId; - this.allTabViews = 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); - 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._queueActionInternal({ op: "switchtab", tabId, setInBackend }); } - async createTab(pinned = false) { - const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned); - await this.setActiveTab(tabId, false); + private 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 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); - } - } - - 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(); + private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) { if (this.activeTabView == tabView) { return; } @@ -331,29 +368,14 @@ 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; - 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]); @@ -362,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) { @@ -402,13 +424,13 @@ export class WaveBrowserWindow extends BaseWindow { this.finalizePositioning(); } - finalizePositioning() { + private finalizePositioning() { if (this.isDestroyed()) { return; } 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; } @@ -416,32 +438,104 @@ export class WaveBrowserWindow extends BaseWindow { } } - async queueTabSwitch(tabView: WaveTabView, tabInitialized: boolean) { - if (this.tabSwitchQueue.length == 2) { - this.tabSwitchQueue[1] = { tabView, tabInitialized }; + async queueCreateTab(pinned = false) { + await this._queueActionInternal({ op: "createtab", pinned }); + } + + async queueCloseTab(tabId: string) { + await this._queueActionInternal({ op: "closetab", tabId }); + } + + private async _queueActionInternal(entry: WindowActionQueueEntry) { + if (this.actionQueue.length >= 2) { + this.actionQueue[1] = entry; return; } - this.tabSwitchQueue.push({ tabView, tabInitialized }); - if (this.tabSwitchQueue.length == 1) { - await this.processTabSwitchQueue(); + const wasEmpty = this.actionQueue.length === 0; + this.actionQueue.push(entry); + if (wasEmpty) { + await this.processActionQueue(); } } - 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(); + private removeTabViewLater(tabId: string, delayMs: number) { + setTimeout(() => { + this.removeTabView(tabId, false); + }, 1000); + } + + // 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 run an action that is going to be overwritten + private async processActionQueue() { + while (this.actionQueue.length > 0) { + try { + const entry = this.actionQueue[0]; + let tabId: string = null; + // have to use "===" here to get the typechecker to work :/ + 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; + } + const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); + await this.setTabViewIntoWindow(tabView, tabInitialized); + } catch (e) { + console.log("error caught in processActionQueue", e); + } finally { + this.actionQueue.shift(); + } } } - async mainResizeHandler(_: any) { + private async mainResizeHandler(_: any) { if (this == null || this.isDestroyed() || this.fullScreen) { return; } @@ -457,22 +551,32 @@ 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); - for (const tabView of this.allTabViews.values()) { - tabView?.destroy(); - } - waveWindowMap.delete(this.waveWindowId); - if (focusedWaveWindow == this) { - focusedWaveWindow = null; - } + this.deleteAllowed = true; super.destroy(); } } export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { for (const ww of waveWindowMap.values()) { - if (ww.allTabViews.has(tabId)) { + if (ww.allLoadedTabViews.has(tabId)) { return ww; } } @@ -537,34 +641,121 @@ 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) { + if (ww != null) { + await ww.queueCreateTab(); + } + event.returnValue = true; + return null; +}); + +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; } - await ww.createTab(); + await ww.queueCloseTab(tabId); event.returnValue = true; return null; }); -ipcMain.on("close-tab", async (event, tabId) => { - const ww = getWaveWindowByTabId(tabId); - await ww.closeTab(tabId); - event.returnValue = true; - return null; +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("switch-workspace", async (event, workspaceId) => { - 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.forceClose(); +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(); + if (windows.length > 0) { + for (const window of windows) { + console.log("relaunch -- closing window", window.waveWindowId); + window.close(); + } + await delay(1200); + } + 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 9fedeb32d..b16d13f55 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 { 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( @@ -368,42 +345,13 @@ 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}`); }) ); }); -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(); -} - -// 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 +360,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(); @@ -479,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; - } - const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu(); - // const { x, y } = electron.screen.getCursorScreenPoint(); - // const windowPos = window.getPosition(); - menu.popup(); - event.returnValue = true; -}); - // we try to set the primary display as index [0] function getActivityDisplays(): ActivityDisplayType[] { const displays = electron.screen.getAllDisplays(); @@ -541,40 +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 instantiateAppMenu(): electron.Menu { - return getAppMenu({ - createNewWaveWindow, - relaunchBrowserWindows, - }); -} - -function makeAppMenu() { - const menu = instantiateAppMenu(); - electron.Menu.setApplicationMenu(menu); -} - function hideWindowWithCatch(window: WaveBrowserWindow) { if (window == null) { return; @@ -644,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); @@ -651,37 +561,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(); @@ -696,7 +575,6 @@ async function appMain() { electronApp.quit(); return; } - makeAppMenu(); try { await runWaveSrv(handleWSEvent); } catch (e) { @@ -717,6 +595,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..0d73d1d41 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -1,10 +1,20 @@ // 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, + getWaveWindowByWorkspaceId, + relaunchBrowserWindows, + WaveBrowserWindow, +} from "./emain-window"; +import { ElectronWshClient } from "./emain-wsh"; import { unamePlatform } from "./platform"; import { updater } from "./updater"; @@ -27,7 +37,45 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents return null; } -function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { +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) => { + fireAndForget(() => createWorkspace((window as WaveBrowserWindow) ?? ww)); + }, + }, + ]; + function getWorkspaceSwitchAccelerator(i: number): string { + if (i < 10) { + if (i == 9) { + i = 0; + } else { + i++; + } + return unamePlatform == "darwin" ? `Command+Control+${i}` : `Alt+Control+${i}`; + } + } + workspaceList?.length && + workspaceMenu.push( + { type: "separator" }, + ...workspaceList.map((workspace, i) => { + return { + label: `Switch to ${workspace.workspacedata.name}`, + click: (_, window) => { + ((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid); + }, + accelerator: getWorkspaceSwitchAccelerator(i), + }; + }) + ); + return workspaceMenu; +} + +async function getAppMenu(callbacks: AppMenuCallbacks, workspaceId?: string): Promise { + const ww = workspaceId && getWaveWindowByWorkspaceId(workspaceId); const fileMenu: Electron.MenuItemConstructorOptions[] = [ { label: "New Window", @@ -46,7 +94,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { { label: "About Wave Terminal", click: (_, window) => { - getWindowWebContents(window)?.send("menu-item-about"); + getWindowWebContents(window ?? ww)?.send("menu-item-about"); }, }, { @@ -124,7 +172,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { label: "Reload Tab", accelerator: "Shift+CommandOrControl+R", click: (_, window) => { - getWindowWebContents(window)?.reloadIgnoringCache(); + getWindowWebContents(window ?? ww)?.reloadIgnoringCache(); }, }, { @@ -143,7 +191,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { label: "Toggle DevTools", accelerator: devToolsAccel, click: (_, window) => { - let wc = getWindowWebContents(window); + let wc = getWindowWebContents(window ?? ww); wc?.toggleDevTools(); }, }, @@ -154,14 +202,14 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { 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; } @@ -175,7 +223,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { label: "Zoom In (hidden)", accelerator: "CommandOrControl+Shift+=", click: (_, window) => { - const wc = getWindowWebContents(window); + const wc = getWindowWebContents(window ?? ww); if (wc == null) { return; } @@ -191,7 +239,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { label: "Zoom Out", accelerator: "CommandOrControl+-", click: (_, window) => { - const wc = getWindowWebContents(window); + const wc = getWindowWebContents(window ?? ww); if (wc == null) { return; } @@ -205,7 +253,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { label: "Zoom Out (hidden)", accelerator: "CommandOrControl+Shift+-", click: (_, window) => { - const wc = getWindowWebContents(window); + const wc = getWindowWebContents(window ?? ww); if (wc == null) { return; } @@ -224,6 +272,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { role: "togglefullscreen", }, ]; + + const workspaceMenu = await getWorkspaceMenu(); + const windowMenu: Electron.MenuItemConstructorOptions[] = [ { role: "minimize", accelerator: "" }, { role: "zoom" }, @@ -249,6 +300,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { role: "viewMenu", submenu: viewMenu, }, + { + label: "Workspace", + id: "workspace-menu", + submenu: workspaceMenu, + }, { role: "windowMenu", submenu: windowMenu, @@ -257,4 +313,65 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { return electron.Menu.buildFromTemplate(menuTemplate); } +export function instantiateAppMenu(workspaceId?: string): Promise { + return getAppMenu( + { + createNewWaveWindow, + relaunchBrowserWindows, + }, + workspaceId + ); +} + +export function makeAppMenu() { + fireAndForget(async () => { + const menu = await instantiateAppMenu(); + electron.Menu.setApplicationMenu(menu); + }); +} + +waveEventSubscribe({ + eventType: "workspace:update", + 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 86ecdadeb..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) => { @@ -40,11 +40,12 @@ 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), 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 240020b77..03a526e27 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(); }); @@ -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); }); @@ -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/block/block.scss b/frontend/app/block/block.scss index 990b4c16d..9ad0482d6 100644 --- a/frontend/app/block/block.scss +++ b/frontend/app/block/block.scss @@ -166,6 +166,7 @@ flex: 1 2 auto; overflow: hidden; padding-right: 4px; + @include mixins.ellipsis() } .connecting-svg { diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 843ae7fe9..90d2f0f54 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -185,8 +185,8 @@ const BlockFrame_Header = ({ const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const dragHandleRef = preview ? null : nodeModel.dragHandleRef; const connName = blockData?.meta?.connection; - const allSettings = jotai.useAtomValue(atoms.fullConfigAtom); - const wshEnabled = allSettings?.connections?.[connName]?.["conn:wshenabled"] ?? true; + const connStatus = util.useAtomValueSafe(getConnStatusAtom(connName)); + const wshProblem = connName && !connStatus?.wshenabled && connStatus?.status == "connected"; React.useEffect(() => { if (!magnified || preview || prevMagifiedState.current) { @@ -266,7 +266,7 @@ const BlockFrame_Header = ({ changeConnModalAtom={changeConnModalAtom} /> )} - {manageConnection && !wshEnabled && ( + {manageConnection && wshProblem && ( )}
{headerTextElems}
@@ -342,6 +342,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 +358,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 +403,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 +443,11 @@ const ConnStatusOverlay = React.memo(
) : null} + {showWshError ? ( +
+
+ ) : null}
); @@ -657,8 +711,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); @@ -671,8 +725,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); @@ -683,7 +737,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) => { @@ -706,30 +760,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); @@ -785,33 +833,33 @@ 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; } ); 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)") { @@ -842,9 +890,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/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/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); })} ); 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/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/store/global.ts b/frontend/app/store/global.ts index eb5c10cf7..d2e44cc38 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; @@ -103,8 +100,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 { @@ -662,10 +659,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); } @@ -692,7 +685,6 @@ export { isDev, loadConnStatus, openLink, - overrideStaticTabAtom, PLATFORM, pushFlashError, pushNotification, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index f49a44979..56448b3fb 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); @@ -70,20 +71,25 @@ 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; } const layoutModel = getLayoutModelForTab(tabAtom); - layoutModel.closeFocusedNode(); + fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel)); } function switchBlockByBlockNum(index: number) { @@ -245,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/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/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/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/app/tab/tab.scss b/frontend/app/tab/tab.scss index b1dd0a23b..ce6343c8c 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -57,7 +57,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.1); } .name { @@ -114,7 +114,7 @@ body:not(.nohover) .tab:hover, body:not(.nohover) .is-dragging { .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.1); } .close { visibility: visible; diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 69ef09cfa..2c7a20800 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -3,6 +3,7 @@ import { Button } from "@/element/button"; import { ContextMenuModel } from "@/store/contextmenu"; +import { fireAndForget } from "@/util/util"; import { clsx } from "clsx"; import { atom, useAtom, useAtomValue } from "jotai"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; @@ -85,14 +86,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); }; @@ -101,20 +109,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 @@ -163,7 +165,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); @@ -188,10 +193,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" }); @@ -348,11 +354,17 @@ const Tab = memo( e.stopPropagation(); onPinChange(); }} + title="Unpin Tab" > ) : ( - )} diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index e15fd069d..d4f3cbc06 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, useSetAtom } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; @@ -446,9 +446,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) { @@ -458,13 +460,12 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Reset dragging state setDraggingTabId(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) + ) ); }), [] @@ -566,7 +567,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); }; @@ -595,7 +597,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => { }; function onEllipsisClick() { - getApi().showContextMenu(); + getApi().showContextMenu(workspace.oid); } const tabsWrapperWidth = tabIds.length * tabWidthRef.current; diff --git a/frontend/app/tab/workspaceswitcher.scss b/frontend/app/tab/workspaceswitcher.scss index eb7d96a30..e95ac00b2 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; @@ -71,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; @@ -81,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; @@ -145,6 +146,7 @@ .left-icon { font-size: 14px; + width: 16px; } } @@ -164,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; @@ -219,7 +223,7 @@ display: flex; align-items: center; justify-content: center; - margin-top: 10px; + margin-top: 5px; } } diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 8f7b8e7ed..0872b4574 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 = [ + "circle", + "triangle", + "star", + "heart", + "bolt", + "solid@cloud", + "moon", + "layer-group", + "rocket", + "flask", + "paperclip", + "chart-line", + "graduation-cap", + "mug-hot", +]; + const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => { const handleColorClick = (color: string) => { onSelect(color); @@ -117,31 +144,8 @@ const ColorAndIconSelector = memo( value={title} autoFocus /> - - + +