merge main

This commit is contained in:
Red Adaya 2024-12-10 15:33:50 +08:00
commit 78f4a5fd1c
73 changed files with 1739 additions and 893 deletions

View File

@ -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}}

View File

@ -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

View File

@ -1,9 +1,11 @@
<p align="center">
<a href="https://www.waveterm.dev">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./assets/wave-dark.png">
<source media="(prefers-color-scheme: light)" srcset="./assets/wave-light.png">
<img alt="Wave Terminal Logo" src="./assets/wave-light.png" width="240">
</picture>
</a>
<br/>
</p>

View File

@ -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.

View File

@ -1,12 +1,3 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/wave-dark.png">
<source media="(prefers-color-scheme: light)" srcset="../assets/wave-light.png">
<img alt="Wave Terminal Logo" src="../assets/wave-light.png" width="240">
</picture>
<br/>
</p>
# 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

View File

@ -16,11 +16,18 @@ The easiest way to access connections is to click the <i className="fa-sharp fa-
## What are wsh Shell Extensions?
`wsh` is a small program that helps manage waveterm regardless of which machine you are currently connected to. In order to not interrupt the normal flow of the remote session, we install it on your remote machine at `~/.waveterm/bin/wsh`. Then, when wave connects to your connection (and only when wave connects to your connection), `~/.waveterm/bin` is added to your `PATH` for that individual session. For more info on what `wsh` is capable of, see [wsh command](/wsh). And if you wish to view the source code of `wsh`, you can find it [here](https://github.com/wavetermdev/waveterm/tree/main/cmd/wsh).
`wsh` is a small program that helps manage waveterm regardless of which machine you are currently connected to. It is always included on your host machine, but you also have the option to install it when connecting to a remote machine. If it is installed on the remote machine, it is installed at `~/.waveterm/bin/wsh`. Then, when wave connects to your connection (and only when wave connects to your connection), `~/.waveterm/bin` is added to your `PATH` for that individual session. For more info on what `wsh` is capable of, see [wsh command](/wsh). And if you wish to view the source code of `wsh`, you can find it [here](https://github.com/wavetermdev/waveterm/tree/main/cmd/wsh).
With `wsh` installed, you have the ability to view certain widgets from the remote machine as if it were your host. In addition, `wsh` can be used to influence the widgets across various machines. As a very simple example, you can close a widget on the host machine by using the `wsh` command in a terminal window on a remote machine. For more information on what you can accomplish with `wsh`, take a look [here](/wsh).
## Add a New Connection to the Dropdown
The SSH values that are loaded into the dropdown by default are obtained by parsing your `~/.ssh/config` and `/etc/ssh/ssh_config` files. Adding a new connection is as simple as adding a new `Host` to one of these files, typically the `~/.ssh/config` file.
The SSH values that are loaded into the dropdown by default are obtained by parsing the internal `config/connections.json` file in addition to your `~/.ssh/config` and `/etc/ssh/ssh_config` files. Adding a new connection can be added in a couple ways:
- adding a new `Host` to one of your ssh config files, typically the `~/.ssh/config` file
- adding a new entry in the internal `config/connections.json` file
- manually typing your connection into the connection box (if this successfully connects, the connection will be added to the internal `config/connections.json` file)
- use `wsh ssh [user]@[host]` in your terminal (if this successfully connects, the connection will be added to the internal `config/connections.json` file)
WSL values are added by searching the installed WSL distributions as they appear in the Windows Registry.
@ -55,6 +62,20 @@ Host myhost
You would then be able to access this connection with `myhost` or `username@myhost`. And if you wanted to manually specify a port such as port 2222, you could do that by either adding `Port 2222` to the config file or connecting to `username@myhost:2222`.
## Internal SSH Configuration
In addition to the regular ssh config file, wave also has its own config file to manage separate variables. These include
| Keyword | Description |
|---------|-------------|
| conn:wshenabled | This boolean allows wsh to be used for your connection, if it is set to `false`, `wsh` will never be used for that connection. It defaults to `true`.|
| conn:askbeforewshinstall | This boolean is used to prompt the user before installing wsh. If it is set to false, `wsh` will automatically be installed instead without prompting. It defaults to `true`.|
| display:hidden | This boolean hides the connection from the dropdown list. It defaults to `false` |
| display:order | This float determines the order of connections in the connection dropdown. It defaults to `0`.|
| term:fontsize | This int can be used to override the terminal font size for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. |
| term:fontfamily | This string can be used to specify a terminal font family for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. |
| term:theme | This string can be used to specify a terminal theme for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. |
| ssh:identityfile | A list of strings containing the paths to identity files that will be used. If a `wsh ssh` command using the `-i` flag is successful, the identity file will automatically be added here. |
## Managing Connections with the CLI
The `wsh` command gives some commands specifically for interacting with the connections. You can view these [here](/wsh#conn).

View File

@ -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": "<your perplexity API key>"
}
```
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?

View File

@ -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.
---

View File

@ -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<void>;
initResolve: () => void;
savedInitOpts: WaveInitOpts;
waveReadyPromise: Promise<void>;
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);
}

View File

@ -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<string, WaveBrowserWindow>(); // 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<void>;
allTabViews: Map<string, WaveTabView>;
allLoadedTabViews: Map<string, WaveTabView>;
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<string, WaveTabView>();
this.allLoadedTabViews = new Map<string, WaveTabView>();
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,17 +277,36 @@ 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);
if (workspaceId == this.workspaceId) {
console.log("switchWorkspace already on this workspace", this.waveWindowId);
return;
}
// 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)) {
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"],
@ -262,66 +323,42 @@ export class WaveBrowserWindow extends BaseWindow {
if (!newWin) {
console.log("error creating new window", this.waveWindowId);
}
const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), { unamePlatform });
const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), {
unamePlatform,
});
newBwin.show();
return;
}
}
const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, workspaceId);
if (!newWs) {
return;
}
console.log("switchWorkspace newWs", newWs);
if (this.allTabViews.size) {
for (const tab of this.allTabViews.values()) {
this.contentView.removeChildView(tab);
tab?.destroy();
}
}
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;
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 { tabView, tabInitialized } = this.tabSwitchQueue[0];
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.tabSwitchQueue.shift();
await this.processTabSwitchQueue();
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", async (event, 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) => {
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.forceClose();
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();
}
}

View File

@ -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;

View File

@ -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<void> {
log("createNewWaveWindow");
const clientData = await services.ClientService.GetClientData();
const fullConfig = await services.FileService.GetFullConfig();
let recreatedWindow = false;
const allWindows = getAllWaveWindows();
if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {
console.log("no windows, but clientData has windowids, recreating first window");
// reopen the first window
const existingWindowId = clientData.windowids[0];
const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
if (existingWindowData != null) {
const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform });
await win.waveReadyPromise;
win.show();
recreatedWindow = true;
}
}
if (recreatedWindow) {
console.log("recreated window, returning");
return;
}
console.log("creating new window");
const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform });
await newBrowserWindow.waveReadyPromise;
newBrowserWindow.show();
}
// 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<void> {
console.log("relaunchBrowserWindows");
setGlobalIsRelaunching(true);
const windows = getAllWaveWindows();
for (const window of windows) {
console.log("relaunch -- closing window", window.waveWindowId);
window.close();
}
setGlobalIsRelaunching(false);
const clientData = await services.ClientService.GetClientData();
const fullConfig = await services.FileService.GetFullConfig();
const wins: WaveBrowserWindow[] = [];
for (const windowId of clientData.windowids.slice().reverse()) {
const windowData: WaveWindow = await services.WindowService.GetWindow(windowId);
if (windowData == null) {
console.log("relaunch -- window data not found, closing window", windowId);
await services.WindowService.CloseWindow(windowId, true);
continue;
}
console.log("relaunch -- creating window", windowId, windowData);
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
wins.push(win);
}
for (const win of wins) {
await win.waveReadyPromise;
console.log("show window", win.waveWindowId);
win.show();
}
}
async function appMain() {
// 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) {

31
emain/log.ts Normal file
View File

@ -0,0 +1,31 @@
import path from "path";
import { format } from "util";
import winston from "winston";
import { getWaveDataDir, isDev } from "./platform";
const oldConsoleLog = console.log;
const loggerTransports: winston.transport[] = [
new winston.transports.File({ filename: path.join(getWaveDataDir(), "waveapp.log"), level: "info" }),
];
if (isDev) {
loggerTransports.push(new winston.transports.Console());
}
const loggerConfig = {
level: "info",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
winston.format.printf((info) => `${info.timestamp} ${info.message}`)
),
transports: loggerTransports,
};
const logger = winston.createLogger(loggerConfig);
function log(...msg: any[]) {
try {
logger.info(format(...msg));
} catch (e) {
oldConsoleLog(...msg);
}
}
export { log };

View File

@ -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<Electron.MenuItemConstructorOptions[]> {
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<Electron.MenuItemConstructorOptions>((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<Electron.Menu> {
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<electron.Menu> {
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 };

View File

@ -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),

View File

@ -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;
});

View File

@ -166,6 +166,7 @@
flex: 1 2 auto;
overflow: hidden;
padding-right: 4px;
@include mixins.ellipsis()
}
.connecting-svg {

View File

@ -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 && (
<IconButton decl={wshInstallButton} className="block-frame-header-iconbutton" />
)}
<div className="block-frame-textelems-wrapper">{headerTextElems}</div>
@ -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 (
<div className="connstatus-overlay" ref={overlayRefCallback}>
<div className="connstatus-content">
<div className={clsx("connstatus-status-icon-wrapper", { "has-error": showError })}>
<div className={clsx("connstatus-status-icon-wrapper", { "has-error": showError || showWshError })}>
{showIcon && <i className="fa-solid fa-triangle-exclamation"></i>}
<div className="connstatus-status">
<div className="connstatus-status-text">{statusText}</div>
{showError ? <div className="connstatus-error">error: {connStatus.error}</div> : null}
{showWshError ? (
<div className="connstatus-error">unable to use wsh: {connStatus.wsherror}</div>
) : null}
{showWshError && (
<Button className={reconClassName} onClick={handleDisableWsh}>
always disable wsh
</Button>
)}
</div>
</div>
{showReconnect ? (
@ -394,6 +443,11 @@ const ConnStatusOverlay = React.memo(
</Button>
</div>
) : null}
{showWshError ? (
<div className="connstatus-actions">
<Button className={`fa-xmark fa-solid ${reconClassName}`} onClick={handleRemoveWshError} />
</div>
) : null}
</div>
</div>
);
@ -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,22 +760,12 @@ const ChangeConnectionBlockModal = React.memo(
prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e));
},
};
const priorityItems: Array<SuggestionConnectionItem> = [];
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: [],
};
if (localName.includes(connSelected)) {
localSuggestion.items.push({
status: "connected",
icon: "laptop",
@ -730,6 +774,10 @@ const ChangeConnectionBlockModal = React.memo(
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<SuggestionsType> = [];
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<SuggestionConnectionItem> = [
...prioritySuggestions.items,
...localSuggestion.items,
...remoteSuggestions.items,
const suggestions: Array<SuggestionsType> = [
...(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<SuggestionConnectionItem> = 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]
);

View File

@ -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<number> = atom<number>(1);
const ModalPage1 = () => {
const settings = useAtomValue(atoms.settingsAtom);
const clientData = useAtomValue(atoms.client);
const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen);
const [telemetryEnabled, setTelemetryEnabled] = useState<boolean>(!!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";

View File

@ -63,7 +63,8 @@ const Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>(
</div>
);
}
return renderItem(item as SuggestionBaseItem, index);
fullIndex += 1;
return renderItem(item as SuggestionBaseItem, fullIndex);
})}
</div>
);

View File

@ -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<HTMLInputElement>();
const handleSendErrResponse = useCallback(() => {
fireAndForget(() =>
UserInputService.SendUserInputResponse({
type: "userinputresp",
requestid: userInputRequest.requestid,
errormsg: "Canceled by the user",
});
})
);
modalsModel.popModal();
}, [responseText, userInputRequest]);
const handleSendText = useCallback(() => {
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) => {
fireAndForget(() =>
UserInputService.SendUserInputResponse({
type: "userinputresp",
requestid: userInputRequest.requestid,
confirm: response,
checkboxstat: checkboxRef?.current?.checked ?? false,
});
})
);
modalsModel.popModal();
},
[userInputRequest]

View File

@ -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<string, () => void> = new Map(); // id -> handler
@ -48,7 +48,7 @@ class ContextMenuModelType {
showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent<any>): void {
this.handlers.clear();
const electronMenuItems = this._convertAndRegisterMenu(menu);
getApi().showContextMenu(electronMenuItems);
getApi().showContextMenu(globalStore.get(atoms.workspace).oid, electronMenuItems);
}
}

View File

@ -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<string>;
function initGlobalAtoms(initOpts: GlobalInitOptions) {
const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>;
const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom<string>;
@ -103,8 +100,8 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
const tabAtom: Atom<Tab> = 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<string> = atom((get) => get(overrideStaticTabAtom) ?? initOpts.tabId);
// this is *the* tab that this tabview represents. it should never change.
const staticTabIdAtom: Atom<string> = atom(initOpts.tabId);
const controlShiftDelayAtom = atom(false);
const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>;
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,

View File

@ -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<Tab>(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) {
@ -246,11 +252,21 @@ function registerGlobalKeys() {
});
globalKeyMap.set("Cmd:w", () => {
const tabId = globalStore.get(atoms.staticTabId);
const ws = globalStore.get(atoms.workspace);
if (!ws.pinnedtabids?.includes(tabId)) {
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)) {
// 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", () => {
const layoutModel = getLayoutModelForStaticTab();

View File

@ -183,6 +183,11 @@ class WorkspaceServiceType {
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
}
// @returns workspaceId
CreateWorkspace(): Promise<string> {
return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments))
}
// @returns object updates
DeleteWorkspace(workspaceId: string): Promise<void> {
return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments))

View File

@ -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<T extends WaveObj>(value: T, setFn?: Setter, pushToServe
}
setFn(wov.dataAtom, { value: value, loading: false });
if (pushToServer) {
ObjectService.UpdateObject(value, false);
fireAndForget(() => ObjectService.UpdateObject(value, false));
}
}

View File

@ -92,6 +92,11 @@ class RpcApiType {
return client.wshRpcCall("deletesubblock", data, opts);
}
// command "dismisswshfail" [call]
DismissWshFailCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("dismisswshfail", data, opts);
}
// command "dispose" [call]
DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise<void> {
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<void> {
return client.wshRpcCall("setconnectionsconfig", data, opts);
}
// command "setmeta" [call]
SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("setmeta", data, opts);

View File

@ -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;

View File

@ -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<HTMLDivElement> = (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<HTMLDivElement> = (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"
>
<i className="fa fa-solid fa-thumbtack" />
</Button>
) : (
<Button className="ghost grey close" onClick={onClose} onMouseDown={handleMouseDownOnClose}>
<Button
className="ghost grey close"
onClick={onClose}
onMouseDown={handleMouseDownOnClose}
title="Close Tab"
>
<i className="fa fa-solid fa-xmark" />
</Button>
)}

View File

@ -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,9 +460,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
// Reset dragging state
setDraggingTabId(null);
// Update workspace tab ids
fireAndForget(
async () =>
await WorkspaceService.UpdateTabIds(
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<HTMLButtonElement, 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;

View File

@ -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;
}
}

View File

@ -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
/>
<ColorSelector
selectedColor={color}
colors={["#e91e63", "#8bc34a", "#ff9800", "#ffc107", "#03a9f4", "#3f51b5", "#f44336"]}
onSelect={onColorChange}
/>
<IconSelector
selectedIcon={icon}
icons={[
"triangle",
"star",
"cube",
"gem",
"chess-knight",
"heart",
"plane",
"rocket",
"shield-cat",
"paw-simple",
"umbrella",
"graduation-cap",
"mug-hot",
"circle",
]}
onSelect={onIconChange}
/>
<ColorSelector selectedColor={color} colors={colors} onSelect={onColorChange} />
<IconSelector selectedIcon={icon} icons={icons} onSelect={onIconChange} />
<div className="delete-ws-btn-wrapper">
<Button className="ghost red font-size-12" onClick={onDeleteWorkspace}>
Delete workspace
@ -189,12 +193,10 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
}, []);
const onDeleteWorkspace = useCallback((workspaceId: string) => {
fireAndForget(async () => {
getApi().deleteWorkspace(workspaceId);
setTimeout(() => {
fireAndForget(updateWorkspaceList);
}, 10);
});
}, []);
const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);
@ -206,7 +208,16 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
);
const saveWorkspace = () => {
setObjectValue({ ...activeWorkspace, name: "New Workspace", icon: "circle", color: "green" }, undefined, true);
setObjectValue(
{
...activeWorkspace,
name: `New Workspace (${activeWorkspace.oid.slice(0, 5)})`,
icon: icons[0],
color: colors[0],
},
undefined,
true
);
setTimeout(() => {
fireAndForget(updateWorkspaceList);
}, 10);
@ -233,16 +244,23 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
</ExpandableMenu>
</OverlayScrollbarsComponent>
{!isActiveWorkspaceSaved && (
<div className="actions">
{isActiveWorkspaceSaved ? (
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
<ExpandableMenuItemLeftElement>
<i className="fa-sharp fa-solid fa-plus"></i>
</ExpandableMenuItemLeftElement>
<div className="content">Create new workspace</div>
</ExpandableMenuItem>
) : (
<ExpandableMenuItem onClick={() => saveWorkspace()}>
<ExpandableMenuItemLeftElement>
<i className="fa-sharp fa-solid fa-floppy-disk"></i>
</ExpandableMenuItemLeftElement>
<div className="content">Save workspace</div>
</ExpandableMenuItem>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
@ -263,12 +281,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 });
});
}, []);
const isActive = !!workspaceEntry.windowId;

View File

@ -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,7 +581,8 @@ function TableBody({
},
{
label: "Open Preview in New Block",
click: async () => {
click: () =>
fireAndForget(async () => {
const blockDef: BlockDef = {
meta: {
view: "preview",
@ -589,13 +590,14 @@ function TableBody({
},
};
await createBlock(blockDef);
},
}),
},
];
if (finfo.mimetype == "directory") {
menu.push({
label: "Open Terminal in New Block",
click: async () => {
click: () =>
fireAndForget(async () => {
const termBlockDef: BlockDef = {
meta: {
controller: "shell",
@ -604,7 +606,7 @@ function TableBody({
},
};
await createBlock(termBlockDef);
},
}),
});
}
menu.push(
@ -613,9 +615,11 @@ function TableBody({
},
{
label: "Delete",
click: async () => {
click: () => {
fireAndForget(async () => {
await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e));
setRefreshVersion((current) => current + 1);
});
},
}
);

View File

@ -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,32 +630,34 @@ export class PreviewModel implements ViewModel {
getSettingsMenuItems(): ContextMenuItem[] {
const menuItems: ContextMenuItem[] = [];
const blockData = globalStore.get(this.blockAtom);
menuItems.push({
label: "Copy Full Path",
click: async () => {
click: () =>
fireAndForget(async () => {
const filePath = await globalStore.get(this.normFilePath);
if (filePath == null) {
return;
}
navigator.clipboard.writeText(filePath);
},
await navigator.clipboard.writeText(filePath);
}),
});
menuItems.push({
label: "Copy File Name",
click: async () => {
click: () =>
fireAndForget(async () => {
const fileInfo = await globalStore.get(this.statFile);
if (fileInfo == null || fileInfo.name == null) {
return;
}
navigator.clipboard.writeText(fileInfo.name);
},
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 () => {
click: () =>
fireAndForget(async () => {
const fileInfo = await globalStore.get(this.statFile);
const termBlockDef: BlockDef = {
meta: {
@ -665,7 +667,7 @@ export class PreviewModel implements ViewModel {
},
};
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: () => {
click: () =>
fireAndForget(async () => {
const blockOref = WOS.makeORef("block", this.blockId);
services.ObjectService.UpdateObjectMeta(blockOref, {
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;

View File

@ -311,6 +311,11 @@ class TermViewModel implements ViewModel {
}
updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
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") {
@ -319,6 +324,7 @@ class TermViewModel implements ViewModel {
this.termRef.current?.setIsRunning?.(false);
}
}
}
getVDomModel(): VDomModel {
const vdomBlockId = globalStore.get(this.vdomBlockId);

View File

@ -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;
}

View File

@ -180,15 +180,27 @@ 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;
// Handle known API providers
switch (aiOpts?.apitype) {
case "anthropic":
viewTextChildren.push({
elemtype: "iconbutton",
icon: "globe",
title: "Using Remote Antropic API (" + modelName + ")",
title: `Using Remote Anthropic API (${aiOpts.model})`,
noAction: true,
});
} else if (isCloud) {
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",
@ -202,18 +214,20 @@ export class WaveAiModel implements ViewModel {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "location-dot",
title: "Using Local Model @ " + baseUrl + " (" + modelName + ")",
title: `Using Local Model @ ${baseUrl} (${modelName})`,
noAction: true,
});
} else {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "globe",
title: "Using Remote Model @ " + baseUrl + " (" + modelName + ")",
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(
@ -221,12 +235,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 +400,7 @@ export class WaveAiModel implements ViewModel {
this.setLocked(false);
this.cancel = false;
};
handleAiStreamingResponse();
fireAndForget(handleAiStreamingResponse);
}
useWaveAi() {
@ -404,14 +418,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;
}
@ -572,24 +586,33 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
model.textAreaRef = textAreaRef;
}, []);
const adjustTextAreaHeight = () => {
const adjustTextAreaHeight = useCallback(
(value: string) => {
if (textAreaRef.current == null) {
return;
}
// Adjust the height of the textarea to fit the text
const textAreaMaxLines = 100;
const textAreaMaxLines = 5;
const textAreaLineHeight = termFontSize * 1.5;
const textAreaMinHeight = textAreaLineHeight;
const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines;
textAreaRef.current.style.height = "1px";
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 (
@ -625,7 +648,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<HTMLTextAreaElement>) => {

View File

@ -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();

View File

@ -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(),

View File

@ -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(),

View File

@ -24,7 +24,7 @@ export function getLayoutModelForTab(tabAtom: Atom<Tab>): 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<Tab>, 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;

View File

@ -74,7 +74,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;
@ -91,11 +91,12 @@ 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;
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;

View File

@ -56,6 +56,7 @@ declare global {
// blockcontroller.BlockControllerRuntimeStatus
type BlockControllerRuntimeStatus = {
blockid: string;
version: number;
shellprocstatus?: string;
shellprocconnname?: string;
shellprocexitcode: number;
@ -278,6 +279,12 @@ declare global {
err: string;
};
// wshrpc.ConnConfigRequest
type ConnConfigRequest = {
host: string;
metamaptype: MetaType;
};
// wshrpc.ConnKeywords
type ConnKeywords = {
"conn:wshenabled"?: boolean;
@ -319,6 +326,7 @@ declare global {
hasconnected: boolean;
activeconnnum: number;
error?: string;
wsherror?: string;
};
// wshrpc.CpuDataRequest
@ -630,6 +638,7 @@ declare global {
"autoupdate:installonquit"?: boolean;
"autoupdate:channel"?: string;
"preview:showhiddenfiles"?: boolean;
"tab:preset"?: string;
"widget:*"?: boolean;
"widget:showhelp"?: boolean;
"window:*"?: boolean;

View File

@ -91,7 +91,7 @@ function makeIconClass(icon: string, fw: boolean, opts?: { spin?: boolean; defau
if (icon.match(/^(solid@)?[a-z0-9-]+$/)) {
// strip off "solid@" prefix if it exists
icon = icon.replace(/^solid@/, "");
return clsx(`fa fa-sharp fa-solid fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
return clsx(`fa fa-solid fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
}
if (icon.match(/^regular@[a-z0-9-]+$/)) {
// strip off the "regular@" prefix if it exists

View File

@ -22,7 +22,6 @@ import {
initGlobal,
initGlobalWaveEventSubs,
loadConnStatus,
overrideStaticTabAtom,
pushFlashError,
pushNotification,
removeNotificationById,
@ -89,14 +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);
}, 100)
);
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
const initialTab = await WOS.reloadWaveObject<Tab>(WOS.makeORef("tab", savedInitOpts.tabId));

2
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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.2",
"homepage": "https://waveterm.dev",
"build": {
"appId": "dev.commandline.waveterm"
@ -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": [

View File

@ -13,6 +13,7 @@ import (
"log"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wavetermdev/waveterm/pkg/filestore"
@ -77,10 +78,13 @@ type BlockController struct {
ShellInputCh chan *BlockInputUnion
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"`
@ -95,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 {
@ -354,7 +360,26 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
}
cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr
}
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
}
@ -473,7 +498,9 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
defer func() {
wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId))
bc.UpdateControllerAndSendUpdate(func() bool {
if bc.ShellProcStatus == Status_Running {
bc.ShellProcStatus = Status_Done
}
bc.ShellProcExitCode = exitCode
return true
})
@ -549,7 +576,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 {
@ -572,14 +623,16 @@ 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
}
}
runningShellCommand = true
go func() {
defer panichandler.PanicHandler("blockcontroller:run-shell-command")
defer bc.UnlockRunLock()
var termSize waveobj.TermSize
if rtOpts != nil {
termSize = rtOpts.TermSize
@ -639,7 +692,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()
@ -670,6 +723,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
TabId: tabId,
BlockId: blockId,
ShellProcStatus: Status_Init,
RunLock: &atomic.Bool{},
}
blockControllerMap[blockId] = bc
createdController = true
@ -697,11 +751,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
@ -735,20 +791,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
@ -757,13 +813,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()

View File

@ -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 {

View File

@ -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"
@ -49,7 +48,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)
}
@ -65,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
}

View File

@ -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"
)
@ -142,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)
}
@ -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
}

View File

@ -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,24 +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 nil, 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)
}
}
return window, nil
}
@ -146,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,
})

View File

@ -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"
)
@ -20,6 +19,20 @@ 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, "", "", "", false)
if err != nil {
return "", fmt.Errorf("error creating workspace: %w", err)
}
return newWS.OID, nil
}
func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ArgNames: []string{"workspaceId"},
@ -78,14 +91,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")

View File

@ -236,10 +236,8 @@ 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 StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
func StartRemoteShellProcNoWsh(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
@ -279,6 +277,9 @@ func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts Comm
}
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()
shellPath := cmdOpts.ShellPath
if shellPath == "" {
remoteShellPath, err := remote.DetectShell(client)

View File

@ -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",

View File

@ -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 ""
}

View File

@ -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
}

View File

@ -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{}

View File

@ -48,6 +48,8 @@ const (
ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles"
ConfigKey_TabPreset = "tab:preset"
ConfigKey_WidgetClear = "widget:*"
ConfigKey_WidgetShowHelp = "widget:showhelp"

View File

@ -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"`

View File

@ -152,8 +152,7 @@ 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 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)
@ -166,8 +165,6 @@ func DeleteBlock(ctx context.Context, blockId string, recursive bool) error {
}
SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)
}
}
go blockcontroller.StopBlockController(blockId)
sendBlockCloseEvent(blockId)
return nil

View File

@ -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)
}
@ -153,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()

View File

@ -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 DefaultTimeout = 2 * time.Second
const DefaultActivateBlockTimeout = 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
@ -58,16 +53,12 @@ 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", "#58C142", true)
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)
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)
}

View File

@ -75,7 +75,7 @@ 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)
}
@ -176,7 +176,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)
}

View File

@ -11,12 +11,13 @@ 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"
)
func CreateWorkspace(ctx context.Context, name string, icon string, color string) (*waveobj.Workspace, error) {
log.Println("CreateWorkspace")
func CreateWorkspace(ctx context.Context, name string, icon string, color string, isInitialLaunch bool) (*waveobj.Workspace, error) {
ws := &waveobj.Workspace{
OID: uuid.NewString(),
TabIds: []string{},
@ -25,7 +26,22 @@ 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, 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)
}
return ws, nil
}
@ -38,7 +54,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 +72,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
}
@ -63,8 +81,18 @@ 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) (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 {
@ -72,7 +100,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)
}
@ -82,6 +112,21 @@ 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)
}
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
}
@ -163,7 +208,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 {

View File

@ -12,6 +12,7 @@ const (
Event_Config = "config"
Event_UserInput = "userinput"
Event_RouteGone = "route:gone"
Event_WorkspaceUpdate = "workspace:update"
)
type WaveEvent struct {

View File

@ -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)

View File

@ -59,6 +59,8 @@ const (
Command_StreamWaveAi = "streamwaveai"
Command_StreamCpuData = "streamcpudata"
Command_Test = "test"
Command_SetConfig = "setconfig"
Command_SetConnectionsConfig = "connectionsconfig"
Command_RemoteStreamFile = "remotestreamfile"
Command_RemoteFileInfo = "remotefileinfo"
Command_RemoteFileTouch = "remotefiletouch"
@ -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 {

View File

@ -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"
@ -121,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)
}
@ -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)
@ -575,6 +574,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 +689,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 {

View File

@ -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,

View File

@ -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

View File

@ -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"