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: env:
USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.
SNAPCRAFT_BUILD_ENVIRONMENT: host 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' if: matrix.platform == 'darwin'
run: task package with:
command: task package
timeout_minutes: 120
retry_on: error
max_attempts: 3
env: env:
USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. 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}} CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}}

View File

@ -6,9 +6,29 @@ on:
release: release:
types: [published] types: [published]
jobs: jobs:
publish: publish-s3:
name: Publish to Releases
if: ${{ startsWith(github.ref, 'refs/tags/') }} if: ${{ startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Task - name: Install Task
@ -19,26 +39,45 @@ jobs:
- name: Install Snapcraft - name: Install Snapcraft
run: sudo snap install snapcraft --classic run: sudo snap install snapcraft --classic
shell: bash shell: bash
- name: Publish from staging - name: Download Snap from Release
run: "task artifacts:publish:${{ github.ref_name }}" 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: env:
AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" shell: bash
AWS_DEFAULT_REGION: us-west-2 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 shell: bash
- name: Download Snap from Release - name: Download Snap from Release
uses: robinraju/release-downloader@v1 uses: robinraju/release-downloader@v1
with: with:
tag: ${{github.ref_name}} tag: ${{github.ref_name}}
fileName: "*.snap" fileName: "*arm64.snap"
- name: Publish to Snapcraft - name: Publish to Snapcraft
run: "task artifacts:snap:publish:${{ github.ref_name }}" run: "task artifacts:snap:publish:${{ github.ref_name }}"
env: env:
SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}"
shell: bash shell: bash
bump-winget: bump-winget:
name: Submit WinGet PR
if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }} if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }}
needs: [publish] needs: [publish-s3]
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

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

View File

@ -276,8 +276,11 @@ tasks:
CHANNEL: '{{if contains "beta" .UP_VERSION}}beta{{else}}beta,stable{{end}}' CHANNEL: '{{if contains "beta" .UP_VERSION}}beta{{else}}beta,stable{{end}}'
cmd: | cmd: |
echo "Releasing to channels: [{{.CHANNEL}}]" echo "Releasing to channels: [{{.CHANNEL}}]"
snapcraft upload --release={{.CHANNEL}} waveterm_{{.UP_VERSION}}_arm64.snap for file in waveterm_{{.UP_VERSION}}_*.snap; do
snapcraft upload --release={{.CHANNEL}} waveterm_{{.UP_VERSION}}_amd64.snap echo "Publishing $file"
snapcraft upload --release={{.CHANNEL}} $file
echo "Finished publishing $file"
done
artifacts:winget:publish:*: artifacts:winget:publish:*:
desc: Submits a version bump request to WinGet for the latest release. 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 # 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 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? ## 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 ## 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. 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`. 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 ## Managing Connections with the CLI
The `wsh` command gives some commands specifically for interacting with the connections. You can view these [here](/wsh#conn). 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. 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. To switch between models, consider [adding AI Presets](./presets) instead.
### How can I see the block numbers? ### 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] 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. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { FileService } from "@/app/store/services";
import { adaptFromElectronKeyEvent } from "@/util/keyutil"; import { adaptFromElectronKeyEvent } from "@/util/keyutil";
import { Rectangle, shell, WebContentsView } from "electron"; import { Rectangle, shell, WebContentsView } from "electron";
import { getWaveWindowById } from "emain/emain-window";
import path from "path"; import path from "path";
import { configureAuthKeyRequestInjection } from "./authkey"; import { configureAuthKeyRequestInjection } from "./authkey";
import { setWasActive } from "./emain-activity"; import { setWasActive } from "./emain-activity";
import { handleCtrlShiftFocus, handleCtrlShiftState, shFrameNavHandler, shNavHandler } from "./emain-util"; import { handleCtrlShiftFocus, handleCtrlShiftState, shFrameNavHandler, shNavHandler } from "./emain-util";
import { waveWindowMap } from "./emain-window";
import { getElectronAppBasePath, isDevVite } from "./platform"; import { getElectronAppBasePath, isDevVite } from "./platform";
function computeBgColor(fullConfig: FullConfigType): string { function computeBgColor(fullConfig: FullConfigType): string {
@ -30,16 +31,19 @@ export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabVie
} }
export class WaveTabView extends WebContentsView { export class WaveTabView extends WebContentsView {
waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare)
isActiveTab: boolean; isActiveTab: boolean;
waveWindowId: string; // set when showing in an active window private _waveTabId: string; // always set, WaveTabViews are unique per tab
waveTabId: string; // always set, WaveTabViews are unique per tab
lastUsedTs: number; // ts milliseconds lastUsedTs: number; // ts milliseconds
createdTs: number; // ts milliseconds createdTs: number; // ts milliseconds
initPromise: Promise<void>; initPromise: Promise<void>;
initResolve: () => void;
savedInitOpts: WaveInitOpts; savedInitOpts: WaveInitOpts;
waveReadyPromise: Promise<void>; waveReadyPromise: Promise<void>;
initResolve: () => void;
waveReadyResolve: () => void; waveReadyResolve: () => void;
isInitialized: boolean = false;
isWaveReady: boolean = false;
isDestroyed: boolean = false;
constructor(fullConfig: FullConfigType) { constructor(fullConfig: FullConfigType) {
console.log("createBareTabView"); console.log("createBareTabView");
@ -55,11 +59,15 @@ export class WaveTabView extends WebContentsView {
this.initResolve = resolve; this.initResolve = resolve;
}); });
this.initPromise.then(() => { this.initPromise.then(() => {
this.isInitialized = true;
console.log("tabview init", Date.now() - this.createdTs + "ms"); console.log("tabview init", Date.now() - this.createdTs + "ms");
}); });
this.waveReadyPromise = new Promise((resolve, _) => { this.waveReadyPromise = new Promise((resolve, _) => {
this.waveReadyResolve = resolve; this.waveReadyResolve = resolve;
}); });
this.waveReadyPromise.then(() => {
this.isWaveReady = true;
});
wcIdToWaveTabMap.set(this.webContents.id, this); wcIdToWaveTabMap.set(this.webContents.id, this);
if (isDevVite) { if (isDevVite) {
this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`); this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`);
@ -69,10 +77,19 @@ export class WaveTabView extends WebContentsView {
this.webContents.on("destroyed", () => { this.webContents.on("destroyed", () => {
wcIdToWaveTabMap.delete(this.webContents.id); wcIdToWaveTabMap.delete(this.webContents.id);
removeWaveTabView(this.waveTabId); removeWaveTabView(this.waveTabId);
this.isDestroyed = true;
}); });
this.setBackgroundColor(computeBgColor(fullConfig)); this.setBackgroundColor(computeBgColor(fullConfig));
} }
get waveTabId(): string {
return this._waveTabId;
}
set waveTabId(waveTabId: string) {
this._waveTabId = waveTabId;
}
positionTabOnScreen(winBounds: Rectangle) { positionTabOnScreen(winBounds: Rectangle) {
const curBounds = this.getBounds(); const curBounds = this.getBounds();
if ( if (
@ -102,14 +119,11 @@ export class WaveTabView extends WebContentsView {
destroy() { destroy() {
console.log("destroy tab", this.waveTabId); console.log("destroy tab", this.waveTabId);
this.webContents.close();
removeWaveTabView(this.waveTabId); removeWaveTabView(this.waveTabId);
if (!this.isDestroyed) {
// TODO: circuitous this.webContents?.close();
const waveWindow = waveWindowMap.get(this.waveWindowId);
if (waveWindow) {
waveWindow.allTabViews.delete(this.waveTabId);
} }
this.isDestroyed = true;
} }
} }
@ -129,6 +143,31 @@ export function getWaveTabView(waveTabId: string): WaveTabView | undefined {
return rtn; 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 { function checkAndEvictCache(): void {
if (wcvCache.size <= MaxCacheSize) { if (wcvCache.size <= MaxCacheSize) {
return; return;
@ -141,13 +180,9 @@ function checkAndEvictCache(): void {
// Otherwise, sort by lastUsedTs // Otherwise, sort by lastUsedTs
return a.lastUsedTs - b.lastUsedTs; return a.lastUsedTs - b.lastUsedTs;
}); });
const now = Date.now();
for (let i = 0; i < sorted.length - MaxCacheSize; i++) { for (let i = 0; i < sorted.length - MaxCacheSize; i++) {
if (sorted[i].isActiveTab) { tryEvictEntry(sorted[i].waveTabId);
// don't evict WaveTabViews that are currently showing in a window
continue;
}
const tabView = sorted[i];
tabView?.destroy();
} }
} }
@ -155,23 +190,22 @@ export function clearTabCache() {
const wcVals = Array.from(wcvCache.values()); const wcVals = Array.from(wcvCache.values());
for (let i = 0; i < wcVals.length; i++) { for (let i = 0; i < wcVals.length; i++) {
const tabView = wcVals[i]; const tabView = wcVals[i];
if (tabView.isActiveTab) { tryEvictEntry(tabView.waveTabId);
continue;
}
tabView?.destroy();
} }
} }
// returns [tabview, initialized] // 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); let tabView = getWaveTabView(tabId);
if (tabView) { if (tabView) {
return [tabView, true]; return [tabView, true];
} }
const fullConfig = await FileService.GetFullConfig();
tabView = getSpareTab(fullConfig); tabView = getSpareTab(fullConfig);
tabView.waveWindowId = waveWindowId;
tabView.lastUsedTs = Date.now(); tabView.lastUsedTs = Date.now();
tabView.waveTabId = tabId;
setWaveTabView(tabId, tabView); setWaveTabView(tabId, tabView);
tabView.waveTabId = tabId;
tabView.webContents.on("will-navigate", shNavHandler); tabView.webContents.on("will-navigate", shNavHandler);
tabView.webContents.on("will-frame-navigate", shFrameNavHandler); tabView.webContents.on("will-frame-navigate", shFrameNavHandler);
tabView.webContents.on("did-attach-webview", (event, wc) => { 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 { export function setWaveTabView(waveTabId: string, wcv: WaveTabView): void {
if (waveTabId == null) {
return;
}
wcvCache.set(waveTabId, wcv); wcvCache.set(waveTabId, wcv);
checkAndEvictCache(); checkAndEvictCache();
} }
function removeWaveTabView(waveTabId: string): void { function removeWaveTabView(waveTabId: string): void {
if (waveTabId == null) {
return;
}
wcvCache.delete(waveTabId); wcvCache.delete(waveTabId);
} }

View File

@ -1,14 +1,21 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { ClientService, FileService, WindowService, WorkspaceService } from "@/app/store/services"; import { ClientService, FileService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services";
import { fireAndForget } from "@/util/util"; import { fireAndForget } from "@/util/util";
import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron"; import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron";
import path from "path"; import path from "path";
import { debounce } from "throttle-debounce"; import { debounce } from "throttle-debounce";
import { getGlobalIsQuitting, getGlobalIsRelaunching, setWasActive, setWasInFg } from "./emain-activity"; import {
getGlobalIsQuitting,
getGlobalIsRelaunching,
setGlobalIsRelaunching,
setWasActive,
setWasInFg,
} from "./emain-activity";
import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview";
import { delay, ensureBoundsAreVisible } from "./emain-util"; import { delay, ensureBoundsAreVisible } from "./emain-util";
import { log } from "./log";
import { getElectronAppBasePath, unamePlatform } from "./platform"; import { getElectronAppBasePath, unamePlatform } from "./platform";
import { updater } from "./updater"; import { updater } from "./updater";
export type WindowOpts = { export type WindowOpts = {
@ -18,15 +25,45 @@ export type WindowOpts = {
export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow 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) 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 { export class WaveBrowserWindow extends BaseWindow {
waveWindowId: string; waveWindowId: string;
workspaceId: string; workspaceId: string;
waveReadyPromise: Promise<void>; waveReadyPromise: Promise<void>;
allTabViews: Map<string, WaveTabView>; allLoadedTabViews: Map<string, WaveTabView>;
activeTabView: WaveTabView; activeTabView: WaveTabView;
private canClose: boolean; private canClose: boolean;
private deleteAllowed: boolean; private deleteAllowed: boolean;
private tabSwitchQueue: { tabView: WaveTabView; tabInitialized: boolean }[]; private actionQueue: WindowActionQueueEntry[];
constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) { constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) {
console.log("create win", waveWindow.oid); console.log("create win", waveWindow.oid);
@ -105,16 +142,16 @@ export class WaveBrowserWindow extends BaseWindow {
} }
super(winOpts); super(winOpts);
this.tabSwitchQueue = []; this.actionQueue = [];
this.waveWindowId = waveWindow.oid; this.waveWindowId = waveWindow.oid;
this.workspaceId = waveWindow.workspaceid; this.workspaceId = waveWindow.workspaceid;
this.allTabViews = new Map<string, WaveTabView>(); this.allLoadedTabViews = new Map<string, WaveTabView>();
const winBoundsPoller = setInterval(() => { const winBoundsPoller = setInterval(() => {
if (this.isDestroyed()) { if (this.isDestroyed()) {
clearInterval(winBoundsPoller); clearInterval(winBoundsPoller);
return; return;
} }
if (this.tabSwitchQueue.length > 0) { if (this.actionQueue.length > 0) {
return; return;
} }
this.finalizePositioning(); this.finalizePositioning();
@ -165,7 +202,7 @@ export class WaveBrowserWindow extends BaseWindow {
} }
focusedWaveWindow = this; focusedWaveWindow = this;
console.log("focus win", this.waveWindowId); console.log("focus win", this.waveWindowId);
fireAndForget(async () => await ClientService.FocusWindow(this.waveWindowId)); fireAndForget(() => ClientService.FocusWindow(this.waveWindowId));
setWasInFg(true); setWasInFg(true);
setWasActive(true); setWasActive(true);
}); });
@ -223,6 +260,11 @@ export class WaveBrowserWindow extends BaseWindow {
console.log("win quitting or updating", this.waveWindowId); console.log("win quitting or updating", this.waveWindowId);
return; return;
} }
waveWindowMap.delete(this.waveWindowId);
if (focusedWaveWindow == this) {
focusedWaveWindow = null;
}
this.removeAllChildViews();
if (getGlobalIsRelaunching()) { if (getGlobalIsRelaunching()) {
console.log("win relaunching", this.waveWindowId); console.log("win relaunching", this.waveWindowId);
this.destroy(); this.destroy();
@ -235,93 +277,88 @@ export class WaveBrowserWindow extends BaseWindow {
} }
if (this.deleteAllowed) { if (this.deleteAllowed) {
console.log("win removing window from backend DB", this.waveWindowId); 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); 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) { async switchWorkspace(workspaceId: string) {
console.log("switchWorkspace", workspaceId, this.waveWindowId); console.log("switchWorkspace", workspaceId, this.waveWindowId);
const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId); if (workspaceId == this.workspaceId) {
if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) { console.log("switchWorkspace already on this workspace", this.waveWindowId);
const choice = dialog.showMessageBoxSync(this, {
type: "question",
buttons: ["Cancel", "Open in New Window", "Yes"],
title: "Confirm",
message:
"This window has unsaved tabs, switching workspaces will delete the existing tabs. Would you like to continue?",
});
if (choice === 0) {
console.log("user cancelled switch workspace", this.waveWindowId);
return;
} else if (choice === 1) {
console.log("user chose open in new window", this.waveWindowId);
const newWin = await WindowService.CreateWindow(null, workspaceId);
if (!newWin) {
console.log("error creating new window", this.waveWindowId);
}
const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), { unamePlatform });
newBwin.show();
return;
}
}
const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, workspaceId);
if (!newWs) {
return; return;
} }
console.log("switchWorkspace newWs", newWs);
if (this.allTabViews.size) { // 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.
for (const tab of this.allTabViews.values()) { const workspaceList = await WorkspaceService.ListWorkspaces();
this.contentView.removeChildView(tab); if (!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid) {
tab?.destroy(); const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId);
if (
(curWorkspace.tabids?.length || curWorkspace.pinnedtabids?.length) &&
(!curWorkspace.name || !curWorkspace.icon)
) {
const choice = dialog.showMessageBoxSync(this, {
type: "question",
buttons: ["Cancel", "Open in New Window", "Yes"],
title: "Confirm",
message:
"This window has unsaved tabs, switching workspaces will delete the existing tabs. Would you like to continue?",
});
if (choice === 0) {
console.log("user cancelled switch workspace", this.waveWindowId);
return;
} else if (choice === 1) {
console.log("user chose open in new window", this.waveWindowId);
const newWin = await WindowService.CreateWindow(null, workspaceId);
if (!newWin) {
console.log("error creating new window", this.waveWindowId);
}
const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), {
unamePlatform,
});
newBwin.show();
return;
}
} }
} }
console.log("destroyed all tabs", this.waveWindowId); await this._queueActionInternal({ op: "switchworkspace", workspaceId });
this.workspaceId = workspaceId;
this.allTabViews = new Map();
await this.setActiveTab(newWs.activetabid, false);
} }
async setActiveTab(tabId: string, setInBackend: boolean) { async setActiveTab(tabId: string, setInBackend: boolean) {
console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend); console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend);
if (setInBackend) { await this._queueActionInternal({ op: "switchtab", tabId, setInBackend });
await WorkspaceService.SetActiveTab(this.workspaceId, tabId);
}
const fullConfig = await FileService.GetFullConfig();
const [tabView, tabInitialized] = getOrCreateWebViewForTab(fullConfig, tabId);
await this.queueTabSwitch(tabView, tabInitialized);
} }
async createTab(pinned = false) { private async initializeTab(tabView: WaveTabView) {
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned); const clientId = await getClientId();
await this.setActiveTab(tabId, false); 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) { private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) {
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();
if (this.activeTabView == tabView) { if (this.activeTabView == tabView) {
return; return;
} }
@ -331,29 +368,14 @@ export class WaveBrowserWindow extends BaseWindow {
oldActiveView.isActiveTab = false; oldActiveView.isActiveTab = false;
} }
this.activeTabView = tabView; this.activeTabView = tabView;
this.allTabViews.set(tabView.waveTabId, tabView); this.allLoadedTabViews.set(tabView.waveTabId, tabView);
if (!tabInitialized) { if (!tabInitialized) {
console.log("initializing a new tab"); console.log("initializing a new tab");
await tabView.initPromise; const p1 = this.initializeTab(tabView);
this.contentView.addChildView(tabView); const p2 = this.repositionTabsSlowly(100);
const initOpts = { await Promise.all([p1, p2]);
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);
} else { } else {
console.log("reusing an existing tab"); console.log("reusing an existing tab, calling wave-init", tabView.waveTabId);
const p1 = this.repositionTabsSlowly(35); const p1 = this.repositionTabsSlowly(35);
const p2 = tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit const p2 = tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit
await Promise.all([p1, p2]); 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 // something is causing the new tab to lose focus so it requires manual refocusing
tabView.webContents.focus(); tabView.webContents.focus();
setTimeout(() => { setTimeout(() => {
if (this.activeTabView == tabView && !tabView.webContents.isFocused()) { if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) {
tabView.webContents.focus(); tabView.webContents.focus();
} }
}, 10); }, 10);
setTimeout(() => { setTimeout(() => {
if (this.activeTabView == tabView && !tabView.webContents.isFocused()) { if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) {
tabView.webContents.focus(); tabView.webContents.focus();
} }
}, 30); }, 30);
} }
async repositionTabsSlowly(delayMs: number) { private async repositionTabsSlowly(delayMs: number) {
const activeTabView = this.activeTabView; const activeTabView = this.activeTabView;
const winBounds = this.getContentBounds(); const winBounds = this.getContentBounds();
if (activeTabView == null) { if (activeTabView == null) {
@ -402,13 +424,13 @@ export class WaveBrowserWindow extends BaseWindow {
this.finalizePositioning(); this.finalizePositioning();
} }
finalizePositioning() { private finalizePositioning() {
if (this.isDestroyed()) { if (this.isDestroyed()) {
return; return;
} }
const curBounds = this.getContentBounds(); const curBounds = this.getContentBounds();
this.activeTabView?.positionTabOnScreen(curBounds); this.activeTabView?.positionTabOnScreen(curBounds);
for (const tabView of this.allTabViews.values()) { for (const tabView of this.allLoadedTabViews.values()) {
if (tabView == this.activeTabView) { if (tabView == this.activeTabView) {
continue; continue;
} }
@ -416,32 +438,104 @@ export class WaveBrowserWindow extends BaseWindow {
} }
} }
async queueTabSwitch(tabView: WaveTabView, tabInitialized: boolean) { async queueCreateTab(pinned = false) {
if (this.tabSwitchQueue.length == 2) { await this._queueActionInternal({ op: "createtab", pinned });
this.tabSwitchQueue[1] = { tabView, tabInitialized }; }
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; return;
} }
this.tabSwitchQueue.push({ tabView, tabInitialized }); const wasEmpty = this.actionQueue.length === 0;
if (this.tabSwitchQueue.length == 1) { this.actionQueue.push(entry);
await this.processTabSwitchQueue(); if (wasEmpty) {
await this.processActionQueue();
} }
} }
async processTabSwitchQueue() { private removeTabViewLater(tabId: string, delayMs: number) {
if (this.tabSwitchQueue.length == 0) { setTimeout(() => {
this.tabSwitchQueue = []; this.removeTabView(tabId, false);
return; }, 1000);
} }
try {
const { tabView, tabInitialized } = this.tabSwitchQueue[0]; // the queue and this function are used to serialize operations that update the window contents view
await this.setTabViewIntoWindow(tabView, tabInitialized); // processActionQueue will replace [1] if it is already set
} finally { // we don't mess with [0] because it is "in process"
this.tabSwitchQueue.shift(); // we replace [1] because there is no point to run an action that is going to be overwritten
await this.processTabSwitchQueue(); private async processActionQueue() {
while (this.actionQueue.length > 0) {
try {
const entry = this.actionQueue[0];
let tabId: string = null;
// have to use "===" here to get the typechecker to work :/
switch (entry.op) {
case "createtab":
tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, entry.pinned);
break;
case "switchtab":
tabId = entry.tabId;
if (this.activeTabView?.waveTabId == tabId) {
continue;
}
if (entry.setInBackend) {
await WorkspaceService.SetActiveTab(this.workspaceId, tabId);
}
break;
case "closetab":
tabId = entry.tabId;
const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true);
if (rtn == null) {
console.log(
"[error] closeTab: no return value",
tabId,
this.workspaceId,
this.waveWindowId
);
return;
}
this.removeTabViewLater(tabId, 1000);
if (rtn.closewindow) {
this.close();
return;
}
if (!rtn.newactivetabid) {
return;
}
tabId = rtn.newactivetabid;
break;
case "switchworkspace":
const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId);
if (!newWs) {
return;
}
console.log("processActionQueue switchworkspace newWs", newWs);
this.removeAllChildViews();
console.log("destroyed all tabs", this.waveWindowId);
this.workspaceId = entry.workspaceId;
this.allLoadedTabViews = new Map();
tabId = newWs.activetabid;
break;
}
if (tabId == null) {
return;
}
const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId);
await this.setTabViewIntoWindow(tabView, tabInitialized);
} catch (e) {
console.log("error caught in processActionQueue", e);
} finally {
this.actionQueue.shift();
}
} }
} }
async mainResizeHandler(_: any) { private async mainResizeHandler(_: any) {
if (this == null || this.isDestroyed() || this.fullScreen) { if (this == null || this.isDestroyed() || this.fullScreen) {
return; 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() { destroy() {
console.log("destroy win", this.waveWindowId); console.log("destroy win", this.waveWindowId);
for (const tabView of this.allTabViews.values()) { this.deleteAllowed = true;
tabView?.destroy();
}
waveWindowMap.delete(this.waveWindowId);
if (focusedWaveWindow == this) {
focusedWaveWindow = null;
}
super.destroy(); super.destroy();
} }
} }
export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow {
for (const ww of waveWindowMap.values()) { for (const ww of waveWindowMap.values()) {
if (ww.allTabViews.has(tabId)) { if (ww.allLoadedTabViews.has(tabId)) {
return ww; return ww;
} }
} }
@ -537,34 +641,121 @@ ipcMain.on("set-active-tab", async (event, tabId) => {
ipcMain.on("create-tab", async (event, opts) => { ipcMain.on("create-tab", async (event, opts) => {
const senderWc = event.sender; const senderWc = event.sender;
const ww = getWaveWindowByWebContentsId(senderWc.id); 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; return;
} }
await ww.createTab(); await ww.queueCloseTab(tabId);
event.returnValue = true; event.returnValue = true;
return null; return null;
}); });
ipcMain.on("close-tab", async (event, tabId) => { ipcMain.on("switch-workspace", (event, workspaceId) => {
const ww = getWaveWindowByTabId(tabId); fireAndForget(async () => {
await ww.closeTab(tabId); const ww = getWaveWindowByWebContentsId(event.sender.id);
event.returnValue = true; console.log("switch-workspace", workspaceId, ww?.waveWindowId);
return null; await ww?.switchWorkspace(workspaceId);
});
}); });
ipcMain.on("switch-workspace", async (event, workspaceId) => { export async function createWorkspace(window: WaveBrowserWindow) {
const ww = getWaveWindowByWebContentsId(event.sender.id); if (!window) {
console.log("switch-workspace", workspaceId, ww?.waveWindowId); return;
await ww?.switchWorkspace(workspaceId);
});
ipcMain.on("delete-workspace", async (event, workspaceId) => {
const ww = getWaveWindowByWebContentsId(event.sender.id);
console.log("delete-workspace", workspaceId, ww?.waveWindowId);
await WorkspaceService.DeleteWorkspace(workspaceId);
console.log("delete-workspace done", workspaceId, ww?.waveWindowId);
if (ww?.workspaceId == workspaceId) {
console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId);
ww.forceClose();
} }
const newWsId = await WorkspaceService.CreateWorkspace();
if (newWsId) {
await window.switchWorkspace(newWsId);
}
}
ipcMain.on("create-workspace", (event) => {
fireAndForget(async () => {
const ww = getWaveWindowByWebContentsId(event.sender.id);
console.log("create-workspace", ww?.waveWindowId);
await createWorkspace(ww);
});
}); });
ipcMain.on("delete-workspace", (event, workspaceId) => {
fireAndForget(async () => {
const ww = getWaveWindowByWebContentsId(event.sender.id);
console.log("delete-workspace", workspaceId, ww?.waveWindowId);
await WorkspaceService.DeleteWorkspace(workspaceId);
console.log("delete-workspace done", workspaceId, ww?.waveWindowId);
if (ww?.workspaceId == workspaceId) {
console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId);
ww.destroy();
}
});
});
export async function createNewWaveWindow() {
log("createNewWaveWindow");
const clientData = await ClientService.GetClientData();
const fullConfig = await FileService.GetFullConfig();
let recreatedWindow = false;
const allWindows = getAllWaveWindows();
if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {
console.log("no windows, but clientData has windowids, recreating first window");
// reopen the first window
const existingWindowId = clientData.windowids[0];
const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
if (existingWindowData != null) {
const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform });
await win.waveReadyPromise;
win.show();
recreatedWindow = true;
}
}
if (recreatedWindow) {
console.log("recreated window, returning");
return;
}
console.log("creating new window");
const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform });
await newBrowserWindow.waveReadyPromise;
newBrowserWindow.show();
}
export async function relaunchBrowserWindows() {
console.log("relaunchBrowserWindows");
setGlobalIsRelaunching(true);
const windows = getAllWaveWindows();
if (windows.length > 0) {
for (const window of windows) {
console.log("relaunch -- closing window", window.waveWindowId);
window.close();
}
await delay(1200);
}
setGlobalIsRelaunching(false);
const clientData = await ClientService.GetClientData();
const fullConfig = await FileService.GetFullConfig();
const wins: WaveBrowserWindow[] = [];
for (const windowId of clientData.windowids.slice().reverse()) {
const windowData: WaveWindow = await WindowService.GetWindow(windowId);
if (windowData == null) {
console.log("relaunch -- window data not found, closing window", windowId);
await WindowService.CloseWindow(windowId, true);
continue;
}
console.log("relaunch -- creating window", windowId, windowData);
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
wins.push(win);
}
for (const win of wins) {
await win.waveReadyPromise;
console.log("show window", win.waveWindowId);
win.show();
}
}

View File

@ -55,6 +55,16 @@ export class ElectronWshClientType extends WshClient {
} }
ww.focus(); ww.focus();
} }
// async handle_workspaceupdate(rh: RpcResponseHelper) {
// console.log("workspaceupdate");
// fireAndForget(async () => {
// console.log("workspace menu clicked");
// const updatedWorkspaceMenu = await getWorkspaceMenu();
// const workspaceMenu = Menu.getApplicationMenu().getMenuItemById("workspace-menu");
// workspaceMenu.submenu = Menu.buildFromTemplate(updatedWorkspaceMenu);
// });
// }
} }
export let ElectronWshClient: ElectronWshClientType; export let ElectronWshClient: ElectronWshClientType;

View File

@ -10,8 +10,6 @@ import * as path from "path";
import { PNG } from "pngjs"; import { PNG } from "pngjs";
import { sprintf } from "sprintf-js"; import { sprintf } from "sprintf-js";
import { Readable } from "stream"; import { Readable } from "stream";
import * as util from "util";
import winston from "winston";
import * as services from "../frontend/app/store/services"; import * as services from "../frontend/app/store/services";
import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil";
import { getWebServerEndpoint } from "../frontend/util/endpoints"; import { getWebServerEndpoint } from "../frontend/util/endpoints";
@ -25,7 +23,6 @@ import {
getGlobalIsRelaunching, getGlobalIsRelaunching,
setForceQuit, setForceQuit,
setGlobalIsQuitting, setGlobalIsQuitting,
setGlobalIsRelaunching,
setGlobalIsStarting, setGlobalIsStarting,
setWasActive, setWasActive,
setWasInFg, setWasInFg,
@ -35,16 +32,19 @@ import { handleCtrlShiftState } from "./emain-util";
import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv"; import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv";
import { import {
createBrowserWindow, createBrowserWindow,
createNewWaveWindow,
focusedWaveWindow, focusedWaveWindow,
getAllWaveWindows, getAllWaveWindows,
getWaveWindowById, getWaveWindowById,
getWaveWindowByWebContentsId, getWaveWindowByWebContentsId,
getWaveWindowByWorkspaceId, getWaveWindowByWorkspaceId,
relaunchBrowserWindows,
WaveBrowserWindow, WaveBrowserWindow,
} from "./emain-window"; } from "./emain-window";
import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh";
import { getLaunchSettings } from "./launchsettings"; import { getLaunchSettings } from "./launchsettings";
import { getAppMenu } from "./menu"; import { log } from "./log";
import { makeAppMenu } from "./menu";
import { import {
getElectronAppBasePath, getElectronAppBasePath,
getElectronAppUnpackedBasePath, getElectronAppUnpackedBasePath,
@ -65,30 +65,7 @@ electron.nativeTheme.themeSource = "dark";
let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused) let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused)
let webviewKeys: string[] = []; // the keys to trap when webview has focus let webviewKeys: string[] = []; // the keys to trap when webview has focus
const oldConsoleLog = console.log;
const loggerTransports: winston.transport[] = [
new winston.transports.File({ filename: path.join(waveDataDir, "waveapp.log"), level: "info" }),
];
if (isDev) {
loggerTransports.push(new winston.transports.Console());
}
const loggerConfig = {
level: "info",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
winston.format.printf((info) => `${info.timestamp} ${info.message}`)
),
transports: loggerTransports,
};
const logger = winston.createLogger(loggerConfig);
function log(...msg: any[]) {
try {
logger.info(util.format(...msg));
} catch (e) {
oldConsoleLog(...msg);
}
}
console.log = log; console.log = log;
console.log( console.log(
sprintf( sprintf(
@ -368,42 +345,13 @@ electron.ipcMain.on("quicklook", (event, filePath: string) => {
electron.ipcMain.on("open-native-path", (event, filePath: string) => { electron.ipcMain.on("open-native-path", (event, filePath: string) => {
console.log("open-native-path", filePath); console.log("open-native-path", filePath);
fireAndForget(async () => fireAndForget(() =>
electron.shell.openPath(filePath).then((excuse) => { electron.shell.openPath(filePath).then((excuse) => {
if (excuse) console.error(`Failed to open ${filePath} in native application: ${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") => { electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => {
const tabView = getWaveTabViewByWebContentsId(event.sender.id); const tabView = getWaveTabViewByWebContentsId(event.sender.id);
if (tabView == null || tabView.initResolve == null) { if (tabView == null || tabView.initResolve == null) {
@ -412,10 +360,9 @@ electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-re
if (status === "ready") { if (status === "ready") {
tabView.initResolve(); tabView.initResolve();
if (tabView.savedInitOpts) { 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); tabView.webContents.send("wave-init", tabView.savedInitOpts);
} else {
console.log("no-savedInitOpts");
} }
} else if (status === "wave-ready") { } else if (status === "wave-ready") {
tabView.waveReadyResolve(); tabView.waveReadyResolve();
@ -479,17 +426,6 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string
electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); 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] // we try to set the primary display as index [0]
function getActivityDisplays(): ActivityDisplayType[] { function getActivityDisplays(): ActivityDisplayType[] {
const displays = electron.screen.getAllDisplays(); const displays = electron.screen.getAllDisplays();
@ -541,40 +477,6 @@ function runActiveTimer() {
setTimeout(runActiveTimer, 60000); 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) { function hideWindowWithCatch(window: WaveBrowserWindow) {
if (window == null) { if (window == null) {
return; return;
@ -644,6 +546,14 @@ process.on("uncaughtException", (error) => {
if (caughtException) { if (caughtException) {
return; 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; caughtException = true;
console.log("Uncaught Exception, shutting down: ", error); console.log("Uncaught Exception, shutting down: ", error);
console.log("Stack Trace:", error.stack); console.log("Stack Trace:", error.stack);
@ -651,37 +561,6 @@ process.on("uncaughtException", (error) => {
electronApp.quit(); electronApp.quit();
}); });
async function relaunchBrowserWindows(): Promise<void> {
console.log("relaunchBrowserWindows");
setGlobalIsRelaunching(true);
const windows = getAllWaveWindows();
for (const window of windows) {
console.log("relaunch -- closing window", window.waveWindowId);
window.close();
}
setGlobalIsRelaunching(false);
const clientData = await services.ClientService.GetClientData();
const fullConfig = await services.FileService.GetFullConfig();
const wins: WaveBrowserWindow[] = [];
for (const windowId of clientData.windowids.slice().reverse()) {
const windowData: WaveWindow = await services.WindowService.GetWindow(windowId);
if (windowData == null) {
console.log("relaunch -- window data not found, closing window", windowId);
await services.WindowService.CloseWindow(windowId, true);
continue;
}
console.log("relaunch -- creating window", windowId, windowData);
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
wins.push(win);
}
for (const win of wins) {
await win.waveReadyPromise;
console.log("show window", win.waveWindowId);
win.show();
}
}
async function appMain() { async function appMain() {
// Set disableHardwareAcceleration as early as possible, if required. // Set disableHardwareAcceleration as early as possible, if required.
const launchSettings = getLaunchSettings(); const launchSettings = getLaunchSettings();
@ -696,7 +575,6 @@ async function appMain() {
electronApp.quit(); electronApp.quit();
return; return;
} }
makeAppMenu();
try { try {
await runWaveSrv(handleWSEvent); await runWaveSrv(handleWSEvent);
} catch (e) { } catch (e) {
@ -717,6 +595,7 @@ async function appMain() {
} catch (e) { } catch (e) {
console.log("error initializing wshrpc", e); console.log("error initializing wshrpc", e);
} }
makeAppMenu();
await configureAutoUpdater(); await configureAutoUpdater();
setGlobalIsStarting(false); setGlobalIsStarting(false);
if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { if (fullConfig?.settings?.["window:maxtabcachesize"] != null) {

31
emain/log.ts Normal file
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. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { waveEventSubscribe } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import * as electron from "electron"; import * as electron from "electron";
import { fireAndForget } from "../frontend/util/util"; import { fireAndForget } from "../frontend/util/util";
import { clearTabCache } from "./emain-tabview"; import { clearTabCache } from "./emain-tabview";
import { focusedWaveWindow, WaveBrowserWindow } from "./emain-window"; import {
createNewWaveWindow,
createWorkspace,
focusedWaveWindow,
getWaveWindowByWorkspaceId,
relaunchBrowserWindows,
WaveBrowserWindow,
} from "./emain-window";
import { ElectronWshClient } from "./emain-wsh";
import { unamePlatform } from "./platform"; import { unamePlatform } from "./platform";
import { updater } from "./updater"; import { updater } from "./updater";
@ -27,7 +37,45 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents
return null; 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[] = [ const fileMenu: Electron.MenuItemConstructorOptions[] = [
{ {
label: "New Window", label: "New Window",
@ -46,7 +94,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
{ {
label: "About Wave Terminal", label: "About Wave Terminal",
click: (_, window) => { 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", label: "Reload Tab",
accelerator: "Shift+CommandOrControl+R", accelerator: "Shift+CommandOrControl+R",
click: (_, window) => { click: (_, window) => {
getWindowWebContents(window)?.reloadIgnoringCache(); getWindowWebContents(window ?? ww)?.reloadIgnoringCache();
}, },
}, },
{ {
@ -143,7 +191,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
label: "Toggle DevTools", label: "Toggle DevTools",
accelerator: devToolsAccel, accelerator: devToolsAccel,
click: (_, window) => { click: (_, window) => {
let wc = getWindowWebContents(window); let wc = getWindowWebContents(window ?? ww);
wc?.toggleDevTools(); wc?.toggleDevTools();
}, },
}, },
@ -154,14 +202,14 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
label: "Reset Zoom", label: "Reset Zoom",
accelerator: "CommandOrControl+0", accelerator: "CommandOrControl+0",
click: (_, window) => { click: (_, window) => {
getWindowWebContents(window)?.setZoomFactor(1); getWindowWebContents(window ?? ww)?.setZoomFactor(1);
}, },
}, },
{ {
label: "Zoom In", label: "Zoom In",
accelerator: "CommandOrControl+=", accelerator: "CommandOrControl+=",
click: (_, window) => { click: (_, window) => {
const wc = getWindowWebContents(window); const wc = getWindowWebContents(window ?? ww);
if (wc == null) { if (wc == null) {
return; return;
} }
@ -175,7 +223,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
label: "Zoom In (hidden)", label: "Zoom In (hidden)",
accelerator: "CommandOrControl+Shift+=", accelerator: "CommandOrControl+Shift+=",
click: (_, window) => { click: (_, window) => {
const wc = getWindowWebContents(window); const wc = getWindowWebContents(window ?? ww);
if (wc == null) { if (wc == null) {
return; return;
} }
@ -191,7 +239,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
label: "Zoom Out", label: "Zoom Out",
accelerator: "CommandOrControl+-", accelerator: "CommandOrControl+-",
click: (_, window) => { click: (_, window) => {
const wc = getWindowWebContents(window); const wc = getWindowWebContents(window ?? ww);
if (wc == null) { if (wc == null) {
return; return;
} }
@ -205,7 +253,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
label: "Zoom Out (hidden)", label: "Zoom Out (hidden)",
accelerator: "CommandOrControl+Shift+-", accelerator: "CommandOrControl+Shift+-",
click: (_, window) => { click: (_, window) => {
const wc = getWindowWebContents(window); const wc = getWindowWebContents(window ?? ww);
if (wc == null) { if (wc == null) {
return; return;
} }
@ -224,6 +272,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
role: "togglefullscreen", role: "togglefullscreen",
}, },
]; ];
const workspaceMenu = await getWorkspaceMenu();
const windowMenu: Electron.MenuItemConstructorOptions[] = [ const windowMenu: Electron.MenuItemConstructorOptions[] = [
{ role: "minimize", accelerator: "" }, { role: "minimize", accelerator: "" },
{ role: "zoom" }, { role: "zoom" },
@ -249,6 +300,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
role: "viewMenu", role: "viewMenu",
submenu: viewMenu, submenu: viewMenu,
}, },
{
label: "Workspace",
id: "workspace-menu",
submenu: workspaceMenu,
},
{ {
role: "windowMenu", role: "windowMenu",
submenu: windowMenu, submenu: windowMenu,
@ -257,4 +313,65 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
return electron.Menu.buildFromTemplate(menuTemplate); 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 }; export { getAppMenu };

View File

@ -16,7 +16,7 @@ contextBridge.exposeInMainWorld("api", {
getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"),
getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"),
openNewWindow: () => ipcRenderer.send("open-new-window"), 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)), onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)),
downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), downloadFile: (filePath) => ipcRenderer.send("download", { filePath }),
openExternal: (url) => { openExternal: (url) => {
@ -40,11 +40,12 @@ contextBridge.exposeInMainWorld("api", {
registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys),
onControlShiftStateUpdate: (callback) => onControlShiftStateUpdate: (callback) =>
ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)),
createWorkspace: () => ipcRenderer.send("create-workspace"),
switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId), switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId),
deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId), deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId),
setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId), setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId),
createTab: () => ipcRenderer.send("create-tab"), 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), setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status),
onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)),
sendLog: (log) => ipcRenderer.send("fe-log", log), 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.", body: "A new version of Wave Terminal is ready to install.",
}); });
updateNotification.on("click", () => { updateNotification.on("click", () => {
fireAndForget(() => this.promptToInstallUpdate()); fireAndForget(this.promptToInstallUpdate.bind(this));
}); });
updateNotification.show(); updateNotification.show();
}); });
@ -112,7 +112,7 @@ export class Updater {
private set status(value: UpdaterStatus) { private set status(value: UpdaterStatus) {
this._status = value; this._status = value;
getAllWaveWindows().forEach((window) => { getAllWaveWindows().forEach((window) => {
const allTabs = Array.from(window.allTabViews.values()); const allTabs = Array.from(window.allLoadedTabViews.values());
allTabs.forEach((tab) => { allTabs.forEach((tab) => {
tab.webContents.send("app-update-status", value); tab.webContents.send("app-update-status", value);
}); });
@ -188,7 +188,7 @@ export class Updater {
if (allWindows.length > 0) { if (allWindows.length > 0) {
await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => { await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => {
if (response === 0) { 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"); 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) => { ipcMain.on("get-app-update-status", (event) => {
event.returnValue = updater?.status; event.returnValue = updater?.status;
}); });

View File

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

View File

@ -185,8 +185,8 @@ const BlockFrame_Header = ({
const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);
const dragHandleRef = preview ? null : nodeModel.dragHandleRef; const dragHandleRef = preview ? null : nodeModel.dragHandleRef;
const connName = blockData?.meta?.connection; const connName = blockData?.meta?.connection;
const allSettings = jotai.useAtomValue(atoms.fullConfigAtom); const connStatus = util.useAtomValueSafe(getConnStatusAtom(connName));
const wshEnabled = allSettings?.connections?.[connName]?.["conn:wshenabled"] ?? true; const wshProblem = connName && !connStatus?.wshenabled && connStatus?.status == "connected";
React.useEffect(() => { React.useEffect(() => {
if (!magnified || preview || prevMagifiedState.current) { if (!magnified || preview || prevMagifiedState.current) {
@ -266,7 +266,7 @@ const BlockFrame_Header = ({
changeConnModalAtom={changeConnModalAtom} changeConnModalAtom={changeConnModalAtom}
/> />
)} )}
{manageConnection && !wshEnabled && ( {manageConnection && wshProblem && (
<IconButton decl={wshInstallButton} className="block-frame-header-iconbutton" /> <IconButton decl={wshInstallButton} className="block-frame-header-iconbutton" />
)} )}
<div className="block-frame-textelems-wrapper">{headerTextElems}</div> <div className="block-frame-textelems-wrapper">{headerTextElems}</div>
@ -342,6 +342,8 @@ const ConnStatusOverlay = React.memo(
const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30);
const width = domRect?.width; const width = domRect?.width;
const [showError, setShowError] = React.useState(false); const [showError, setShowError] = React.useState(false);
const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom);
const [showWshError, setShowWshError] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (width) { if (width) {
@ -356,12 +358,40 @@ const ConnStatusOverlay = React.memo(
prtn.catch((e) => console.log("error reconnecting", connName, e)); prtn.catch((e) => console.log("error reconnecting", connName, e));
}, [connName]); }, [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 statusText = `Disconnected from "${connName}"`;
let showReconnect = true; let showReconnect = true;
if (connStatus.status == "connecting") { if (connStatus.status == "connecting") {
statusText = `Connecting to "${connName}"...`; statusText = `Connecting to "${connName}"...`;
showReconnect = false; showReconnect = false;
} }
if (connStatus.status == "connected") {
showReconnect = false;
}
let reconDisplay = null; let reconDisplay = null;
let reconClassName = "outlined grey"; let reconClassName = "outlined grey";
if (width && width < 350) { if (width && width < 350) {
@ -373,18 +403,37 @@ const ConnStatusOverlay = React.memo(
} }
const showIcon = connStatus.status != "connecting"; 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 null;
} }
return ( return (
<div className="connstatus-overlay" ref={overlayRefCallback}> <div className="connstatus-overlay" ref={overlayRefCallback}>
<div className="connstatus-content"> <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>} {showIcon && <i className="fa-solid fa-triangle-exclamation"></i>}
<div className="connstatus-status"> <div className="connstatus-status">
<div className="connstatus-status-text">{statusText}</div> <div className="connstatus-status-text">{statusText}</div>
{showError ? <div className="connstatus-error">error: {connStatus.error}</div> : null} {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>
</div> </div>
{showReconnect ? ( {showReconnect ? (
@ -394,6 +443,11 @@ const ConnStatusOverlay = React.memo(
</Button> </Button>
</div> </div>
) : null} ) : null}
{showWshError ? (
<div className="connstatus-actions">
<Button className={`fa-xmark fa-solid ${reconClassName}`} onClick={handleRemoveWshError} />
</div>
) : null}
</div> </div>
</div> </div>
); );
@ -657,8 +711,8 @@ const ChangeConnectionBlockModal = React.memo(
} }
if ( if (
conn.includes(connSelected) && conn.includes(connSelected) &&
connectionsConfig[conn]?.["display:hidden"] != true && connectionsConfig?.[conn]?.["display:hidden"] != true &&
(connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) (connectionsConfig?.[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
// != false is necessary because of defaults // != false is necessary because of defaults
) { ) {
filteredList.push(conn); filteredList.push(conn);
@ -671,8 +725,8 @@ const ChangeConnectionBlockModal = React.memo(
} }
if ( if (
conn.includes(connSelected) && conn.includes(connSelected) &&
connectionsConfig[conn]?.["display:hidden"] != true && connectionsConfig?.[conn]?.["display:hidden"] != true &&
(connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) (connectionsConfig?.[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
// != false is necessary because of defaults // != false is necessary because of defaults
) { ) {
filteredWslList.push(conn); filteredWslList.push(conn);
@ -683,7 +737,7 @@ const ChangeConnectionBlockModal = React.memo(
const newConnectionSuggestion: SuggestionConnectionItem = { const newConnectionSuggestion: SuggestionConnectionItem = {
status: "connected", status: "connected",
icon: "plus", icon: "plus",
iconColor: "var(--conn-icon-color)", iconColor: "var(--grey-text-color)",
label: `${connSelected} (New Connection)`, label: `${connSelected} (New Connection)`,
value: "", value: "",
onSelect: (_: string) => { onSelect: (_: string) => {
@ -706,30 +760,24 @@ const ChangeConnectionBlockModal = React.memo(
prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e)); 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 localName = getUserName() + "@" + getHostName();
const localSuggestion: SuggestionConnectionScope = { const localSuggestion: SuggestionConnectionScope = {
headerText: "Local", headerText: "Local",
items: [], items: [],
}; };
localSuggestion.items.push({ if (localName.includes(connSelected)) {
status: "connected", localSuggestion.items.push({
icon: "laptop", status: "connected",
iconColor: "var(--grey-text-color)", icon: "laptop",
value: "", iconColor: "var(--grey-text-color)",
label: localName, value: "",
current: connection == null, label: localName,
}); current: connection == null,
});
}
if (localName == connSelected) {
createNew = false;
}
for (const wslConn of filteredWslList) { for (const wslConn of filteredWslList) {
const connStatus = connStatusMap.get(wslConn); const connStatus = connStatusMap.get(wslConn);
const connColorNum = computeConnColorNum(connStatus); const connColorNum = computeConnColorNum(connStatus);
@ -785,33 +833,33 @@ const ChangeConnectionBlockModal = React.memo(
(itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => { (itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => {
const connNameA = itemA.value; const connNameA = itemA.value;
const connNameB = itemB.value; const connNameB = itemB.value;
const valueA = connectionsConfig[connNameA]?.["display:order"] ?? 0; const valueA = connectionsConfig?.[connNameA]?.["display:order"] ?? 0;
const valueB = connectionsConfig[connNameB]?.["display:order"] ?? 0; const valueB = connectionsConfig?.[connNameB]?.["display:order"] ?? 0;
return valueA - valueB; return valueA - valueB;
} }
); );
const remoteSuggestions: SuggestionConnectionScope = { const remoteSuggestions: SuggestionConnectionScope = {
headerText: "Remote", headerText: "Remote",
items: [...sortedRemoteItems, connectionsEditItem], items: [...sortedRemoteItems],
}; };
let suggestions: Array<SuggestionsType> = []; const suggestions: Array<SuggestionsType> = [
if (prioritySuggestions.items.length > 0) { ...(showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error")
suggestions.push(prioritySuggestions); ? [reconnectSuggestion]
} : []),
if (localSuggestion.items.length > 0) { ...(localSuggestion.items.length > 0 ? [localSuggestion] : []),
suggestions.push(localSuggestion); ...(remoteSuggestions.items.length > 0 ? [remoteSuggestions] : []),
} ...(connSelected == "" ? [connectionsEditItem] : []),
if (remoteSuggestions.items.length > 0) { ...(createNew ? [newConnectionSuggestion] : []),
suggestions.push(remoteSuggestions);
}
let selectionList: Array<SuggestionConnectionItem> = [
...prioritySuggestions.items,
...localSuggestion.items,
...remoteSuggestions.items,
]; ];
let selectionList: Array<SuggestionConnectionItem> = suggestions.flatMap((item) => {
if ("items" in item) {
return item.items;
}
return item;
});
// quick way to change icon color when highlighted // quick way to change icon color when highlighted
selectionList = selectionList.map((item, index) => { selectionList = selectionList.map((item, index) => {
if (index == rowIndex && item.iconColor == "var(--grey-text-color)") { if (index == rowIndex && item.iconColor == "var(--grey-text-color)") {
@ -842,9 +890,10 @@ const ChangeConnectionBlockModal = React.memo(
return true; return true;
} }
if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) { 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; return true;
} }
setRowIndex(0);
}, },
[changeConnModalAtom, viewModel, blockId, connSelected, selectionList] [changeConnModalAtom, viewModel, blockId, connSelected, selectionList]
); );

View File

@ -12,6 +12,7 @@ import { FlexiModal } from "./modal";
import { QuickTips } from "@/app/element/quicktips"; import { QuickTips } from "@/app/element/quicktips";
import { atoms, getApi } from "@/app/store/global"; import { atoms, getApi } from "@/app/store/global";
import { modalsModel } from "@/app/store/modalmodel"; import { modalsModel } from "@/app/store/modalmodel";
import { fireAndForget } from "@/util/util";
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai";
import "./tos.scss"; import "./tos.scss";
@ -20,25 +21,22 @@ const pageNumAtom: PrimitiveAtom<number> = atom<number>(1);
const ModalPage1 = () => { const ModalPage1 = () => {
const settings = useAtomValue(atoms.settingsAtom); const settings = useAtomValue(atoms.settingsAtom);
const clientData = useAtomValue(atoms.client); const clientData = useAtomValue(atoms.client);
const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen);
const [telemetryEnabled, setTelemetryEnabled] = useState<boolean>(!!settings["telemetry:enabled"]); const [telemetryEnabled, setTelemetryEnabled] = useState<boolean>(!!settings["telemetry:enabled"]);
const setPageNum = useSetAtom(pageNumAtom); const setPageNum = useSetAtom(pageNumAtom);
const acceptTos = () => { const acceptTos = () => {
if (!clientData.tosagreed) { if (!clientData.tosagreed) {
services.ClientService.AgreeTos(); fireAndForget(services.ClientService.AgreeTos);
} }
setPageNum(2); setPageNum(2);
}; };
const setTelemetry = (value: boolean) => { const setTelemetry = (value: boolean) => {
services.ClientService.TelemetryUpdate(value) fireAndForget(() =>
.then(() => { services.ClientService.TelemetryUpdate(value).then(() => {
setTelemetryEnabled(value); setTelemetryEnabled(value);
}) })
.catch((error) => { );
console.error("failed to set telemetry:", error);
});
}; };
const label = telemetryEnabled ? "Telemetry Enabled" : "Telemetry Disabled"; const label = telemetryEnabled ? "Telemetry Enabled" : "Telemetry Disabled";

View File

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

View File

@ -5,9 +5,9 @@ import { Modal } from "@/app/modals/modal";
import { Markdown } from "@/element/markdown"; import { Markdown } from "@/element/markdown";
import { modalsModel } from "@/store/modalmodel"; import { modalsModel } from "@/store/modalmodel";
import * as keyutil from "@/util/keyutil"; 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { UserInputService } from "../store/services";
import "./userinputmodal.scss"; import "./userinputmodal.scss";
const UserInputModal = (userInputRequest: UserInputRequest) => { const UserInputModal = (userInputRequest: UserInputRequest) => {
@ -16,33 +16,39 @@ const UserInputModal = (userInputRequest: UserInputRequest) => {
const checkboxRef = useRef<HTMLInputElement>(); const checkboxRef = useRef<HTMLInputElement>();
const handleSendErrResponse = useCallback(() => { const handleSendErrResponse = useCallback(() => {
UserInputService.SendUserInputResponse({ fireAndForget(() =>
type: "userinputresp", UserInputService.SendUserInputResponse({
requestid: userInputRequest.requestid, type: "userinputresp",
errormsg: "Canceled by the user", requestid: userInputRequest.requestid,
}); errormsg: "Canceled by the user",
})
);
modalsModel.popModal(); modalsModel.popModal();
}, [responseText, userInputRequest]); }, [responseText, userInputRequest]);
const handleSendText = useCallback(() => { const handleSendText = useCallback(() => {
UserInputService.SendUserInputResponse({ fireAndForget(() =>
type: "userinputresp", UserInputService.SendUserInputResponse({
requestid: userInputRequest.requestid, type: "userinputresp",
text: responseText, requestid: userInputRequest.requestid,
checkboxstat: checkboxRef?.current?.checked ?? false, text: responseText,
}); checkboxstat: checkboxRef?.current?.checked ?? false,
})
);
modalsModel.popModal(); modalsModel.popModal();
}, [responseText, userInputRequest]); }, [responseText, userInputRequest]);
console.log("bar"); console.log("bar");
const handleSendConfirm = useCallback( const handleSendConfirm = useCallback(
(response: boolean) => { (response: boolean) => {
UserInputService.SendUserInputResponse({ fireAndForget(() =>
type: "userinputresp", UserInputService.SendUserInputResponse({
requestid: userInputRequest.requestid, type: "userinputresp",
confirm: response, requestid: userInputRequest.requestid,
checkboxstat: checkboxRef?.current?.checked ?? false, confirm: response,
}); checkboxstat: checkboxRef?.current?.checked ?? false,
})
);
modalsModel.popModal(); modalsModel.popModal();
}, },
[userInputRequest] [userInputRequest]

View File

@ -1,7 +1,7 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { getApi } from "./global"; import { atoms, getApi, globalStore } from "./global";
class ContextMenuModelType { class ContextMenuModelType {
handlers: Map<string, () => void> = new Map(); // id -> handler handlers: Map<string, () => void> = new Map(); // id -> handler
@ -48,7 +48,7 @@ class ContextMenuModelType {
showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent<any>): void { showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent<any>): void {
this.handlers.clear(); this.handlers.clear();
const electronMenuItems = this._convertAndRegisterMenu(menu); 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; 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) { function initGlobalAtoms(initOpts: GlobalInitOptions) {
const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>; const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>;
const clientIdAtom = atom(initOpts.clientId) 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) => { const tabAtom: Atom<Tab> = atom((get) => {
return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), 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. // this is *the* tab that this tabview represents. it should never change.
const staticTabIdAtom: Atom<string> = atom((get) => get(overrideStaticTabAtom) ?? initOpts.tabId); const staticTabIdAtom: Atom<string> = atom(initOpts.tabId);
const controlShiftDelayAtom = atom(false); const controlShiftDelayAtom = atom(false);
const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>; const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>;
try { try {
@ -662,10 +659,6 @@ function createTab() {
} }
function setActiveTab(tabId: string) { 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); getApi().setActiveTab(tabId);
} }
@ -692,7 +685,6 @@ export {
isDev, isDev,
loadConnStatus, loadConnStatus,
openLink, openLink,
overrideStaticTabAtom,
PLATFORM, PLATFORM,
pushFlashError, pushFlashError,
pushNotification, pushNotification,

View File

@ -19,6 +19,7 @@ import {
} from "@/layout/index"; } from "@/layout/index";
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
import * as keyutil from "@/util/keyutil"; import * as keyutil from "@/util/keyutil";
import { fireAndForget } from "@/util/util";
import * as jotai from "jotai"; import * as jotai from "jotai";
const simpleControlShiftAtom = jotai.atom(false); const simpleControlShiftAtom = jotai.atom(false);
@ -70,20 +71,25 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
} }
function genericClose(tabId: string) { function genericClose(tabId: string) {
const ws = globalStore.get(atoms.workspace);
const tabORef = WOS.makeORef("tab", tabId); const tabORef = WOS.makeORef("tab", tabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef); const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);
const tabData = globalStore.get(tabAtom); const tabData = globalStore.get(tabAtom);
if (tabData == null) { if (tabData == null) {
return; 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) { if (tabData.blockids == null || tabData.blockids.length == 0) {
// close tab // close tab
getApi().closeTab(tabId); getApi().closeTab(ws.oid, tabId);
deleteLayoutModelForTab(tabId); deleteLayoutModelForTab(tabId);
return; return;
} }
const layoutModel = getLayoutModelForTab(tabAtom); const layoutModel = getLayoutModelForTab(tabAtom);
layoutModel.closeFocusedNode(); fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel));
} }
function switchBlockByBlockNum(index: number) { function switchBlockByBlockNum(index: number) {
@ -245,11 +251,21 @@ function registerGlobalKeys() {
return true; return true;
}); });
globalKeyMap.set("Cmd:w", () => { globalKeyMap.set("Cmd:w", () => {
const tabId = globalStore.get(atoms.staticTabId);
genericClose(tabId);
return true;
});
globalKeyMap.set("Cmd:Shift:w", () => {
const tabId = globalStore.get(atoms.staticTabId); const tabId = globalStore.get(atoms.staticTabId);
const ws = globalStore.get(atoms.workspace); const ws = globalStore.get(atoms.workspace);
if (!ws.pinnedtabids?.includes(tabId)) { if (ws.pinnedtabids?.includes(tabId)) {
genericClose(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; return true;
}); });
globalKeyMap.set("Cmd:m", () => { globalKeyMap.set("Cmd:m", () => {

View File

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

View File

@ -6,6 +6,7 @@
import { waveEventSubscribe } from "@/app/store/wps"; import { waveEventSubscribe } from "@/app/store/wps";
import { getWebServerEndpoint } from "@/util/endpoints"; import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil"; import { fetch } from "@/util/fetchutil";
import { fireAndForget } from "@/util/util";
import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { globalStore } from "./jotaiStore"; 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 }); setFn(wov.dataAtom, { value: value, loading: false });
if (pushToServer) { 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); 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] // command "dispose" [call]
DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise<void> { DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("dispose", data, opts); return client.wshRpcCall("dispose", data, opts);
@ -262,6 +267,11 @@ class RpcApiType {
return client.wshRpcCall("setconfig", data, opts); 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] // command "setmeta" [call]
SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise<void> { SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("setmeta", data, opts); return client.wshRpcCall("setmeta", data, opts);

View File

@ -57,7 +57,7 @@
.tab-inner { .tab-inner {
border-color: transparent; border-color: transparent;
border-radius: 6px; 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 { .name {
@ -114,7 +114,7 @@ body:not(.nohover) .tab:hover,
body:not(.nohover) .is-dragging { body:not(.nohover) .is-dragging {
.tab-inner { .tab-inner {
border-color: transparent; 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 { .close {
visibility: visible; visibility: visible;

View File

@ -3,6 +3,7 @@
import { Button } from "@/element/button"; import { Button } from "@/element/button";
import { ContextMenuModel } from "@/store/contextmenu"; import { ContextMenuModel } from "@/store/contextmenu";
import { fireAndForget } from "@/util/util";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { atom, useAtom, useAtomValue } from "jotai"; import { atom, useAtom, useAtomValue } from "jotai";
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; 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(); event?.stopPropagation();
setIsEditable(true); setIsEditable(true);
editableTimeoutRef.current = setTimeout(() => { editableTimeoutRef.current = setTimeout(() => {
if (editableRef.current) { selectEditableText();
editableRef.current.focus();
document.execCommand("selectAll", false);
}
}, 0); }, 0);
}; };
@ -101,20 +109,14 @@ const Tab = memo(
newText = newText || originalName; newText = newText || originalName;
editableRef.current.innerText = newText; editableRef.current.innerText = newText;
setIsEditable(false); setIsEditable(false);
ObjectService.UpdateTabName(id, newText); fireAndForget(() => ObjectService.UpdateTabName(id, newText));
setTimeout(() => refocusNode(null), 10); setTimeout(() => refocusNode(null), 10);
}; };
const handleKeyDown = (event) => { const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "a") { if ((event.metaKey || event.ctrlKey) && event.key === "a") {
event.preventDefault(); event.preventDefault();
if (editableRef.current) { selectEditableText();
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(editableRef.current);
selection.removeAllRanges();
selection.addRange(range);
}
return; return;
} }
// this counts glyphs, not characters // this counts glyphs, not characters
@ -163,7 +165,10 @@ const Tab = memo(
let menu: ContextMenuItem[] = [ let menu: ContextMenuItem[] = [
{ label: isPinned ? "Unpin Tab" : "Pin Tab", click: () => onPinChange() }, { label: isPinned ? "Unpin Tab" : "Pin Tab", click: () => onPinChange() },
{ label: "Rename Tab", click: () => handleRenameTab(null) }, { 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" }, { type: "separator" },
]; ];
const fullConfig = globalStore.get(atoms.fullConfigAtom); const fullConfig = globalStore.get(atoms.fullConfigAtom);
@ -188,10 +193,11 @@ const Tab = memo(
} }
submenu.push({ submenu.push({
label: preset["display:name"] ?? presetName, label: preset["display:name"] ?? presetName,
click: () => { click: () =>
ObjectService.UpdateObjectMeta(oref, preset); fireAndForget(async () => {
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }); await ObjectService.UpdateObjectMeta(oref, preset);
}, await RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
}),
}); });
} }
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
@ -348,11 +354,17 @@ const Tab = memo(
e.stopPropagation(); e.stopPropagation();
onPinChange(); onPinChange();
}} }}
title="Unpin Tab"
> >
<i className="fa fa-solid fa-thumbtack" /> <i className="fa fa-solid fa-thumbtack" />
</Button> </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" /> <i className="fa fa-solid fa-xmark" />
</Button> </Button>
)} )}

View File

@ -5,7 +5,7 @@ import { Button } from "@/app/element/button";
import { modalsModel } from "@/app/store/modalmodel"; import { modalsModel } from "@/app/store/modalmodel";
import { WindowDrag } from "@/element/windowdrag"; import { WindowDrag } from "@/element/windowdrag";
import { deleteLayoutModelForTab } from "@/layout/index"; 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 { fireAndForget } from "@/util/util";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
@ -446,9 +446,11 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
let pinnedTabCount = pinnedTabIds.size; let pinnedTabCount = pinnedTabIds.size;
const draggedTabId = draggingTabDataRef.current.tabId; const draggedTabId = draggingTabDataRef.current.tabId;
const isPinned = pinnedTabIds.has(draggedTabId); 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); pinnedTabIds.add(draggedTabId);
} else if (!pinnedTabIds.has(tabIds[tabIndex - 1]) && isPinned) { } else if (isPinned && prevTabId && !pinnedTabIds.has(prevTabId)) {
pinnedTabIds.delete(draggedTabId); pinnedTabIds.delete(draggedTabId);
} }
if (pinnedTabCount != pinnedTabIds.size) { if (pinnedTabCount != pinnedTabIds.size) {
@ -458,13 +460,12 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
// Reset dragging state // Reset dragging state
setDraggingTabId(null); setDraggingTabId(null);
// Update workspace tab ids // Update workspace tab ids
fireAndForget( fireAndForget(() =>
async () => WorkspaceService.UpdateTabIds(
await WorkspaceService.UpdateTabIds( workspace.oid,
workspace.oid, tabIds.slice(pinnedTabCount),
tabIds.slice(pinnedTabCount), tabIds.slice(0, pinnedTabCount)
tabIds.slice(0, pinnedTabCount) )
)
); );
}), }),
[] []
@ -566,7 +567,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => { const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
event?.stopPropagation(); 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"); tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutModelForTab(tabId); deleteLayoutModelForTab(tabId);
}; };
@ -595,7 +597,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
}; };
function onEllipsisClick() { function onEllipsisClick() {
getApi().showContextMenu(); getApi().showContextMenu(workspace.oid);
} }
const tabsWrapperWidth = tabIds.length * tabWidthRef.current; const tabsWrapperWidth = tabIds.length * tabWidthRef.current;

View File

@ -9,10 +9,14 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
border-radius: 6px; border-radius: 6px;
background: var(--modal-bg-color);
margin-top: 6px; margin-top: 6px;
margin-right: 13px; margin-right: 13px;
box-sizing: border-box; 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 { .workspace-icon {
width: 15px; width: 15px;
@ -71,6 +75,10 @@
.expandable-menu-item-group { .expandable-menu-item-group {
margin: 0 8px; margin: 0 8px;
border: 1px solid transparent;
border-radius: 4px;
--workspace-color: var(--main-bg-color);
&:last-child { &:last-child {
margin-bottom: 4px; margin-bottom: 4px;
@ -81,13 +89,6 @@
.expandable-menu-item { .expandable-menu-item {
margin: 0; margin: 0;
} }
}
.expandable-menu-item-group {
border: 1px solid transparent;
border-radius: 4px;
--workspace-color: var(--main-bg-color);
.menu-group-title-wrapper { .menu-group-title-wrapper {
display: flex; display: flex;
@ -145,6 +146,7 @@
.left-icon { .left-icon {
font-size: 14px; font-size: 14px;
width: 16px;
} }
} }
@ -164,6 +166,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-top: 5px; margin-top: 5px;
padding-bottom: 15px;
border-bottom: 1px solid var(--modal-border-color);
.color-circle { .color-circle {
width: 15px; width: 15px;
@ -219,7 +223,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 10px; margin-top: 5px;
} }
} }

View File

@ -32,6 +32,33 @@ interface ColorSelectorProps {
className?: string; 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 ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => {
const handleColorClick = (color: string) => { const handleColorClick = (color: string) => {
onSelect(color); onSelect(color);
@ -117,31 +144,8 @@ const ColorAndIconSelector = memo(
value={title} value={title}
autoFocus autoFocus
/> />
<ColorSelector <ColorSelector selectedColor={color} colors={colors} onSelect={onColorChange} />
selectedColor={color} <IconSelector selectedIcon={icon} icons={icons} onSelect={onIconChange} />
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}
/>
<div className="delete-ws-btn-wrapper"> <div className="delete-ws-btn-wrapper">
<Button className="ghost red font-size-12" onClick={onDeleteWorkspace}> <Button className="ghost red font-size-12" onClick={onDeleteWorkspace}>
Delete workspace Delete workspace
@ -189,12 +193,10 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
}, []); }, []);
const onDeleteWorkspace = useCallback((workspaceId: string) => { const onDeleteWorkspace = useCallback((workspaceId: string) => {
fireAndForget(async () => { getApi().deleteWorkspace(workspaceId);
getApi().deleteWorkspace(workspaceId); setTimeout(() => {
setTimeout(() => { fireAndForget(updateWorkspaceList);
fireAndForget(updateWorkspaceList); }, 10);
}, 10);
});
}, []); }, []);
const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon); const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);
@ -206,7 +208,16 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
); );
const saveWorkspace = () => { 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(() => { setTimeout(() => {
fireAndForget(updateWorkspaceList); fireAndForget(updateWorkspaceList);
}, 10); }, 10);
@ -233,16 +244,23 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
</ExpandableMenu> </ExpandableMenu>
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
{!isActiveWorkspaceSaved && ( <div className="actions">
<div className="actions"> {isActiveWorkspaceSaved ? (
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
<ExpandableMenuItemLeftElement>
<i className="fa-sharp fa-solid fa-plus"></i>
</ExpandableMenuItemLeftElement>
<div className="content">Create new workspace</div>
</ExpandableMenuItem>
) : (
<ExpandableMenuItem onClick={() => saveWorkspace()}> <ExpandableMenuItem onClick={() => saveWorkspace()}>
<ExpandableMenuItemLeftElement> <ExpandableMenuItemLeftElement>
<i className="fa-sharp fa-solid fa-floppy-disk"></i> <i className="fa-sharp fa-solid fa-floppy-disk"></i>
</ExpandableMenuItemLeftElement> </ExpandableMenuItemLeftElement>
<div className="content">Save workspace</div> <div className="content">Save workspace</div>
</ExpandableMenuItem> </ExpandableMenuItem>
</div> )}
)} </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
@ -263,12 +281,10 @@ const WorkspaceSwitcherItem = ({
const isCurrentWorkspace = activeWorkspace.oid === workspace.oid; const isCurrentWorkspace = activeWorkspace.oid === workspace.oid;
const setWorkspace = useCallback((newWorkspace: Workspace) => { const setWorkspace = useCallback((newWorkspace: Workspace) => {
fireAndForget(async () => { if (newWorkspace.name != "") {
if (newWorkspace.name != "") { setObjectValue({ ...newWorkspace, otype: "workspace" }, undefined, true);
setObjectValue({ ...newWorkspace, otype: "workspace" }, undefined, true); }
} setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
});
}, []); }, []);
const isActive = !!workspaceEntry.windowId; const isActive = !!workspaceEntry.windowId;

View File

@ -543,26 +543,26 @@ function TableBody({
}, },
{ {
label: "Copy File Name", label: "Copy File Name",
click: () => navigator.clipboard.writeText(fileName), click: () => fireAndForget(() => navigator.clipboard.writeText(fileName)),
}, },
{ {
label: "Copy Full File Name", label: "Copy Full File Name",
click: () => navigator.clipboard.writeText(finfo.path), click: () => fireAndForget(() => navigator.clipboard.writeText(finfo.path)),
}, },
{ {
label: "Copy File Name (Shell Quoted)", 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)", label: "Copy Full File Name (Shell Quoted)",
click: () => navigator.clipboard.writeText(shellQuote([finfo.path])), click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))),
}, },
{ {
type: "separator", type: "separator",
}, },
{ {
label: "Download File", label: "Download File",
click: async () => { click: () => {
getApi().downloadFile(normPath); 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 // TODO: Only show this option for local files, resolve correct host path if connection is WSL
{ {
label: openNativeLabel, label: openNativeLabel,
click: async () => { click: () => {
getApi().openNativePath(normPath); getApi().openNativePath(normPath);
}, },
}, },
@ -581,30 +581,32 @@ function TableBody({
}, },
{ {
label: "Open Preview in New Block", label: "Open Preview in New Block",
click: async () => { click: () =>
const blockDef: BlockDef = { fireAndForget(async () => {
meta: { const blockDef: BlockDef = {
view: "preview", meta: {
file: finfo.path, view: "preview",
}, file: finfo.path,
}; },
await createBlock(blockDef); };
}, await createBlock(blockDef);
}),
}, },
]; ];
if (finfo.mimetype == "directory") { if (finfo.mimetype == "directory") {
menu.push({ menu.push({
label: "Open Terminal in New Block", label: "Open Terminal in New Block",
click: async () => { click: () =>
const termBlockDef: BlockDef = { fireAndForget(async () => {
meta: { const termBlockDef: BlockDef = {
controller: "shell", meta: {
view: "term", controller: "shell",
"cmd:cwd": finfo.path, view: "term",
}, "cmd:cwd": finfo.path,
}; },
await createBlock(termBlockDef); };
}, await createBlock(termBlockDef);
}),
}); });
} }
menu.push( menu.push(
@ -613,9 +615,11 @@ function TableBody({
}, },
{ {
label: "Delete", label: "Delete",
click: async () => { click: () => {
await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e)); fireAndForget(async () => {
setRefreshVersion((current) => current + 1); 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 { getWebServerEndpoint } from "@/util/endpoints";
import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil"; import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed, keydownWrapper } from "@/util/keyutil"; 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 { Monaco } from "@monaco-editor/react";
import clsx from "clsx"; import clsx from "clsx";
import { Atom, atom, Getter, PrimitiveAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai"; import { Atom, atom, Getter, PrimitiveAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai";
@ -257,7 +257,7 @@ export class PreviewModel implements ViewModel {
className: clsx( className: clsx(
`${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` `${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)) { if (get(this.canPreview)) {
viewTextChildren.push({ viewTextChildren.push({
@ -265,7 +265,7 @@ export class PreviewModel implements ViewModel {
text: "Preview", text: "Preview",
className: className:
"grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", "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)) { } else if (get(this.canPreview)) {
@ -274,7 +274,7 @@ export class PreviewModel implements ViewModel {
text: "Edit", text: "Edit",
className: className:
"grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", "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 [ return [
@ -497,7 +497,7 @@ export class PreviewModel implements ViewModel {
return; return;
} }
const blockOref = WOS.makeORef("block", this.blockId); const blockOref = WOS.makeORef("block", this.blockId);
services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
// Clear the saved file buffers // Clear the saved file buffers
globalStore.set(this.fileContentSaved, null); globalStore.set(this.fileContentSaved, null);
@ -538,7 +538,7 @@ export class PreviewModel implements ViewModel {
} }
console.log(newFileInfo.path); console.log(newFileInfo.path);
this.updateOpenFileModalAndError(false); this.updateOpenFileModalAndError(false);
this.goHistory(newFileInfo.path); await this.goHistory(newFileInfo.path);
refocusNode(this.blockId); refocusNode(this.blockId);
} catch (e) { } catch (e) {
globalStore.set(this.openFileError, e.message); 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 blockMeta = globalStore.get(this.blockAtom)?.meta;
const curPath = globalStore.get(this.metaFilePath); const curPath = globalStore.get(this.metaFilePath);
const updateMeta = goHistoryBack("file", curPath, blockMeta, true); const updateMeta = goHistoryBack("file", curPath, blockMeta, true);
@ -555,10 +555,10 @@ export class PreviewModel implements ViewModel {
} }
updateMeta.edit = false; updateMeta.edit = false;
const blockOref = WOS.makeORef("block", this.blockId); 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 blockMeta = globalStore.get(this.blockAtom)?.meta;
const curPath = globalStore.get(this.metaFilePath); const curPath = globalStore.get(this.metaFilePath);
const updateMeta = goHistoryForward("file", curPath, blockMeta); const updateMeta = goHistoryForward("file", curPath, blockMeta);
@ -567,13 +567,13 @@ export class PreviewModel implements ViewModel {
} }
updateMeta.edit = false; updateMeta.edit = false;
const blockOref = WOS.makeORef("block", this.blockId); 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 blockMeta = globalStore.get(this.blockAtom)?.meta;
const blockOref = WOS.makeORef("block", this.blockId); const blockOref = WOS.makeORef("block", this.blockId);
services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit });
} }
async handleFileSave() { async handleFileSave() {
@ -588,7 +588,7 @@ export class PreviewModel implements ViewModel {
} }
const conn = globalStore.get(this.connection) ?? ""; const conn = globalStore.get(this.connection) ?? "";
try { try {
services.FileService.SaveFile(conn, filePath, stringToBase64(newFileContent)); await services.FileService.SaveFile(conn, filePath, stringToBase64(newFileContent));
globalStore.set(this.fileContent, newFileContent); globalStore.set(this.fileContent, newFileContent);
globalStore.set(this.newFileContent, null); globalStore.set(this.newFileContent, null);
console.log("saved file", filePath); console.log("saved file", filePath);
@ -630,42 +630,44 @@ export class PreviewModel implements ViewModel {
getSettingsMenuItems(): ContextMenuItem[] { getSettingsMenuItems(): ContextMenuItem[] {
const menuItems: ContextMenuItem[] = []; const menuItems: ContextMenuItem[] = [];
const blockData = globalStore.get(this.blockAtom);
menuItems.push({ menuItems.push({
label: "Copy Full Path", label: "Copy Full Path",
click: async () => { click: () =>
const filePath = await globalStore.get(this.normFilePath); fireAndForget(async () => {
if (filePath == null) { const filePath = await globalStore.get(this.normFilePath);
return; if (filePath == null) {
} return;
navigator.clipboard.writeText(filePath); }
}, await navigator.clipboard.writeText(filePath);
}),
}); });
menuItems.push({ menuItems.push({
label: "Copy File Name", label: "Copy File Name",
click: async () => { click: () =>
const fileInfo = await globalStore.get(this.statFile); fireAndForget(async () => {
if (fileInfo == null || fileInfo.name == null) { const fileInfo = await globalStore.get(this.statFile);
return; 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), ""); const mimeType = jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), "");
if (mimeType == "directory") { if (mimeType == "directory") {
menuItems.push({ menuItems.push({
label: "Open Terminal in New Block", label: "Open Terminal in New Block",
click: async () => { click: () =>
const fileInfo = await globalStore.get(this.statFile); fireAndForget(async () => {
const termBlockDef: BlockDef = { const fileInfo = await globalStore.get(this.statFile);
meta: { const termBlockDef: BlockDef = {
view: "term", meta: {
controller: "shell", view: "term",
"cmd:cwd": fileInfo.dir, controller: "shell",
}, "cmd:cwd": fileInfo.dir,
}; },
await createBlock(termBlockDef); };
}, await createBlock(termBlockDef);
}),
}); });
} }
const loadableSV = globalStore.get(this.loadableSpecializedView); const loadableSV = globalStore.get(this.loadableSpecializedView);
@ -677,11 +679,11 @@ export class PreviewModel implements ViewModel {
menuItems.push({ type: "separator" }); menuItems.push({ type: "separator" });
menuItems.push({ menuItems.push({
label: "Save File", label: "Save File",
click: this.handleFileSave.bind(this), click: () => fireAndForget(this.handleFileSave.bind(this)),
}); });
menuItems.push({ menuItems.push({
label: "Revert File", label: "Revert File",
click: this.handleFileRevert.bind(this), click: () => fireAndForget(this.handleFileRevert.bind(this)),
}); });
} }
menuItems.push({ type: "separator" }); menuItems.push({ type: "separator" });
@ -689,12 +691,13 @@ export class PreviewModel implements ViewModel {
label: "Word Wrap", label: "Word Wrap",
type: "checkbox", type: "checkbox",
checked: wordWrap, checked: wordWrap,
click: () => { click: () =>
const blockOref = WOS.makeORef("block", this.blockId); fireAndForget(async () => {
services.ObjectService.UpdateObjectMeta(blockOref, { const blockOref = WOS.makeORef("block", this.blockId);
"editor:wordwrap": !wordWrap, await services.ObjectService.UpdateObjectMeta(blockOref, {
}); "editor:wordwrap": !wordWrap,
}, });
}),
}); });
} }
} }
@ -716,16 +719,16 @@ export class PreviewModel implements ViewModel {
keyDownHandler(e: WaveKeyboardEvent): boolean { keyDownHandler(e: WaveKeyboardEvent): boolean {
if (checkKeyPressed(e, "Cmd:ArrowLeft")) { if (checkKeyPressed(e, "Cmd:ArrowLeft")) {
this.goHistoryBack(); fireAndForget(this.goHistoryBack.bind(this));
return true; return true;
} }
if (checkKeyPressed(e, "Cmd:ArrowRight")) { if (checkKeyPressed(e, "Cmd:ArrowRight")) {
this.goHistoryForward(); fireAndForget(this.goHistoryForward.bind(this));
return true; return true;
} }
if (checkKeyPressed(e, "Cmd:ArrowUp")) { if (checkKeyPressed(e, "Cmd:ArrowUp")) {
// handle up directory // handle up directory
this.goParentDirectory({}); fireAndForget(() => this.goParentDirectory({}));
return true; return true;
} }
const openModalOpen = globalStore.get(this.openFileModal); const openModalOpen = globalStore.get(this.openFileModal);
@ -739,7 +742,7 @@ export class PreviewModel implements ViewModel {
if (canPreview) { if (canPreview) {
if (checkKeyPressed(e, "Cmd:e")) { if (checkKeyPressed(e, "Cmd:e")) {
const editMode = globalStore.get(this.editMode); const editMode = globalStore.get(this.editMode);
this.setEditMode(!editMode); fireAndForget(() => this.setEditMode(!editMode));
return true; return true;
} }
} }
@ -833,15 +836,15 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean { function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean {
if (checkKeyPressed(e, "Cmd:e")) { if (checkKeyPressed(e, "Cmd:e")) {
model.setEditMode(false); fireAndForget(() => model.setEditMode(false));
return true; return true;
} }
if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) { if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) {
model.handleFileSave(); fireAndForget(model.handleFileSave.bind(model));
return true; return true;
} }
if (checkKeyPressed(e, "Cmd:r")) { if (checkKeyPressed(e, "Cmd:r")) {
model.handleFileRevert(); fireAndForget(model.handleFileRevert.bind(model));
return true; return true;
} }
return false; return false;
@ -990,7 +993,7 @@ const OpenFileModal = memo(
const handleCommandOperations = async () => { const handleCommandOperations = async () => {
if (checkKeyPressed(waveEvent, "Enter")) { if (checkKeyPressed(waveEvent, "Enter")) {
model.handleOpenFile(filePath); await model.handleOpenFile(filePath);
return true; return true;
} }
return false; return false;

View File

@ -311,12 +311,18 @@ class TermViewModel implements ViewModel {
} }
updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
globalStore.set(this.shellProcFullStatus, fullStatus); if (fullStatus == null) {
const status = fullStatus?.shellprocstatus ?? "init"; return;
if (status == "running") { }
this.termRef.current?.setIsRunning?.(true); const curStatus = globalStore.get(this.shellProcFullStatus);
} else { if (curStatus == null || curStatus.version < fullStatus.version) {
this.termRef.current?.setIsRunning?.(false); globalStore.set(this.shellProcFullStatus, fullStatus);
const status = fullStatus?.shellprocstatus ?? "init";
if (status == "running") {
this.termRef.current?.setIsRunning?.(true);
} else {
this.termRef.current?.setIsRunning?.(false);
}
} }
} }

View File

@ -119,7 +119,11 @@ export class TermWrap {
data = data.substring(nextSlashIdx); data = data.substring(nextSlashIdx);
} }
setTimeout(() => { 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); }, 0);
return true; return true;
}); });
@ -284,7 +288,9 @@ export class TermWrap {
const serializedOutput = this.serializeAddon.serialize(); const serializedOutput = this.serializeAddon.serialize();
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize); 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; this.dataBytesProcessed = 0;
} }

View File

@ -180,40 +180,54 @@ export class WaveAiModel implements ViewModel {
const presetKey = get(this.presetKey); const presetKey = get(this.presetKey);
const presetName = presets[presetKey]?.["display:name"] ?? ""; const presetName = presets[presetKey]?.["display:name"] ?? "";
const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl);
if (aiOpts?.apitype == "anthropic") {
const modelName = aiOpts.model; // Handle known API providers
viewTextChildren.push({ switch (aiOpts?.apitype) {
elemtype: "iconbutton", case "anthropic":
icon: "globe",
title: "Using Remote Antropic API (" + modelName + ")",
noAction: true,
});
} else if (isCloud) {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "cloud",
title: "Using Wave's AI Proxy (gpt-4o-mini)",
noAction: true,
});
} else {
const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint";
const modelName = aiOpts.model;
if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "location-dot",
title: "Using Local Model @ " + baseUrl + " (" + modelName + ")",
noAction: true,
});
} else {
viewTextChildren.push({ viewTextChildren.push({
elemtype: "iconbutton", elemtype: "iconbutton",
icon: "globe", icon: "globe",
title: "Using Remote Model @ " + baseUrl + " (" + modelName + ")", title: `Using Remote Anthropic API (${aiOpts.model})`,
noAction: true, noAction: true,
}); });
} break;
case "perplexity":
viewTextChildren.push({
elemtype: "iconbutton",
icon: "globe",
title: `Using Remote Perplexity API (${aiOpts.model})`,
noAction: true,
});
break;
default:
if (isCloud) {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "cloud",
title: "Using Wave's AI Proxy (gpt-4o-mini)",
noAction: true,
});
} else {
const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint";
const modelName = aiOpts.model;
if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "location-dot",
title: `Using Local Model @ ${baseUrl} (${modelName})`,
noAction: true,
});
} else {
viewTextChildren.push({
elemtype: "iconbutton",
icon: "globe",
title: `Using Remote Model @ ${baseUrl} (${modelName})`,
noAction: true,
});
}
}
} }
const dropdownItems = Object.entries(presets) const dropdownItems = Object.entries(presets)
.sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1)) .sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1))
.map( .map(
@ -221,12 +235,12 @@ export class WaveAiModel implements ViewModel {
({ ({
label: preset[1]["display:name"], label: preset[1]["display:name"],
onClick: () => onClick: () =>
fireAndForget(async () => { fireAndForget(() =>
await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), {
...preset[1], ...preset[1],
"ai:preset": preset[0], "ai:preset": preset[0],
}); })
}), ),
}) as MenuItem }) as MenuItem
); );
dropdownItems.push({ dropdownItems.push({
@ -386,7 +400,7 @@ export class WaveAiModel implements ViewModel {
this.setLocked(false); this.setLocked(false);
this.cancel = false; this.cancel = false;
}; };
handleAiStreamingResponse(); fireAndForget(handleAiStreamingResponse);
} }
useWaveAi() { useWaveAi() {
@ -404,14 +418,14 @@ export class WaveAiModel implements ViewModel {
keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {
if (checkKeyPressed(waveEvent, "Cmd:l")) { if (checkKeyPressed(waveEvent, "Cmd:l")) {
this.clearMessages(); fireAndForget(this.clearMessages.bind(this));
return true; return true;
} }
return false; return false;
} }
} }
function makeWaveAiViewModel(blockId): WaveAiModel { function makeWaveAiViewModel(blockId: string): WaveAiModel {
const waveAiModel = new WaveAiModel(blockId); const waveAiModel = new WaveAiModel(blockId);
return waveAiModel; return waveAiModel;
} }
@ -572,24 +586,33 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
model.textAreaRef = textAreaRef; model.textAreaRef = textAreaRef;
}, []); }, []);
const adjustTextAreaHeight = () => { const adjustTextAreaHeight = useCallback(
if (textAreaRef.current == null) { (value: string) => {
return; if (textAreaRef.current == null) {
} return;
// Adjust the height of the textarea to fit the text }
const textAreaMaxLines = 100;
const textAreaLineHeight = termFontSize * 1.5;
const textAreaMinHeight = textAreaLineHeight;
const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines;
textAreaRef.current.style.height = "1px"; // Adjust the height of the textarea to fit the text
const scrollHeight = textAreaRef.current.scrollHeight; const textAreaMaxLines = 5;
const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); const textAreaLineHeight = termFontSize * 1.5;
textAreaRef.current.style.height = newHeight + "px"; const textAreaMinHeight = textAreaLineHeight;
}; const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines;
if (value === "") {
textAreaRef.current.style.height = `${textAreaLineHeight}px`;
return;
}
textAreaRef.current.style.height = `${textAreaLineHeight}px`;
const scrollHeight = textAreaRef.current.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight);
textAreaRef.current.style.height = newHeight + "px";
},
[termFontSize]
);
useEffect(() => { useEffect(() => {
adjustTextAreaHeight(); adjustTextAreaHeight(value);
}, [value]); }, [value]);
return ( return (
@ -625,7 +648,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => {
// a weird workaround to initialize ansynchronously // a weird workaround to initialize ansynchronously
useEffect(() => { useEffect(() => {
model.populateMessages(); fireAndForget(model.populateMessages.bind(model));
}, []); }, []);
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { 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. * @param url The URL that has been navigated to.
*/ */
handleNavigate(url: string) { 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); globalStore.set(this.url, url);
} }
@ -432,22 +432,18 @@ export class WebViewModel implements ViewModel {
return [ return [
{ {
label: "Set Block Homepage", label: "Set Block Homepage",
click: async () => { click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "block")),
await this.setHomepageUrl(this.getUrl(), "block");
},
}, },
{ {
label: "Set Default Homepage", label: "Set Default Homepage",
click: async () => { click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "global")),
await this.setHomepageUrl(this.getUrl(), "global");
},
}, },
{ {
type: "separator", type: "separator",
}, },
{ {
label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools", label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools",
click: async () => { click: () => {
if (this.webviewRef.current) { if (this.webviewRef.current) {
if (this.webviewRef.current.isDevToolsOpened()) { if (this.webviewRef.current.isDevToolsOpened()) {
this.webviewRef.current.closeDevTools(); this.webviewRef.current.closeDevTools();

View File

@ -58,7 +58,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
const setActiveDrag = useSetAtom(layoutModel.activeDrag); const setActiveDrag = useSetAtom(layoutModel.activeDrag);
const setReady = useSetAtom(layoutModel.ready); const setReady = useSetAtom(layoutModel.ready);
const isResizing = useAtomValue(layoutModel.isResizing); const isResizing = useAtomValue(layoutModel.isResizing);
const ephemeralNode = useAtomValue(layoutModel.ephemeralNode);
const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({ const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({
activeDrag: monitor.isDragging(), activeDrag: monitor.isDragging(),

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import { getSettingsKeyAtom } from "@/app/store/global"; 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 { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai";
import { splitAtom } from "jotai/utils"; import { splitAtom } from "jotai/utils";
import { createRef, CSSProperties } from "react"; import { createRef, CSSProperties } from "react";
@ -852,7 +852,7 @@ export class LayoutModel {
animationTimeS: this.animationTimeS, animationTimeS: this.animationTimeS,
ready: this.ready, ready: this.ready,
disablePointerEvents: this.activeDrag, disablePointerEvents: this.activeDrag,
onClose: async () => await this.closeNode(nodeid), onClose: () => fireAndForget(() => this.closeNode(nodeid)),
toggleMagnify: () => this.magnifyNodeToggle(nodeid), toggleMagnify: () => this.magnifyNodeToggle(nodeid),
focusNode: () => this.focusNode(nodeid), focusNode: () => this.focusNode(nodeid),
dragHandleRef: createRef(), dragHandleRef: createRef(),

View File

@ -24,7 +24,7 @@ export function getLayoutModelForTab(tabAtom: Atom<Tab>): LayoutModel {
} }
const layoutTreeStateAtom = withLayoutTreeStateAtomFromTab(tabAtom); const layoutTreeStateAtom = withLayoutTreeStateAtomFromTab(tabAtom);
const layoutModel = new LayoutModel(layoutTreeStateAtom, globalStore.get, globalStore.set); 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); layoutModelMap.set(tabId, layoutModel);
return layoutModel; return layoutModel;
} }
@ -56,7 +56,7 @@ export function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContent
useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); 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. // 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]); useEffect(() => layoutModel.registerTileLayout(tileContent), [tileContent]);
return layoutModel; return layoutModel;

View File

@ -74,7 +74,7 @@ declare global {
getWebviewPreload: () => string; getWebviewPreload: () => string;
getAboutModalDetails: () => AboutModalDetails; getAboutModalDetails: () => AboutModalDetails;
getDocsiteUrl: () => string; getDocsiteUrl: () => string;
showContextMenu: (menu?: ElectronContextMenuItem[]) => void; showContextMenu: (workspaceId: string, menu?: ElectronContextMenuItem[]) => void;
onContextMenuClick: (callback: (id: string) => void) => void; onContextMenuClick: (callback: (id: string) => void) => void;
onNavigate: (callback: (url: string) => void) => void; onNavigate: (callback: (url: string) => void) => void;
onIframeNavigate: (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 setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview
registerGlobalWebviewKeys: (keys: string[]) => void; registerGlobalWebviewKeys: (keys: string[]) => void;
onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; onControlShiftStateUpdate: (callback: (state: boolean) => void) => void;
createWorkspace: () => void;
switchWorkspace: (workspaceId: string) => void; switchWorkspace: (workspaceId: string) => void;
deleteWorkspace: (workspaceId: string) => void; deleteWorkspace: (workspaceId: string) => void;
setActiveTab: (tabId: string) => void; setActiveTab: (tabId: string) => void;
createTab: () => void; createTab: () => void;
closeTab: (tabId: string) => void; closeTab: (workspaceId: string, tabId: string) => void;
setWindowInitStatus: (status: "ready" | "wave-ready") => void; setWindowInitStatus: (status: "ready" | "wave-ready") => void;
onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void; onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void;
sendLog: (log: string) => void; sendLog: (log: string) => void;

View File

@ -56,6 +56,7 @@ declare global {
// blockcontroller.BlockControllerRuntimeStatus // blockcontroller.BlockControllerRuntimeStatus
type BlockControllerRuntimeStatus = { type BlockControllerRuntimeStatus = {
blockid: string; blockid: string;
version: number;
shellprocstatus?: string; shellprocstatus?: string;
shellprocconnname?: string; shellprocconnname?: string;
shellprocexitcode: number; shellprocexitcode: number;
@ -278,6 +279,12 @@ declare global {
err: string; err: string;
}; };
// wshrpc.ConnConfigRequest
type ConnConfigRequest = {
host: string;
metamaptype: MetaType;
};
// wshrpc.ConnKeywords // wshrpc.ConnKeywords
type ConnKeywords = { type ConnKeywords = {
"conn:wshenabled"?: boolean; "conn:wshenabled"?: boolean;
@ -319,6 +326,7 @@ declare global {
hasconnected: boolean; hasconnected: boolean;
activeconnnum: number; activeconnnum: number;
error?: string; error?: string;
wsherror?: string;
}; };
// wshrpc.CpuDataRequest // wshrpc.CpuDataRequest
@ -630,6 +638,7 @@ declare global {
"autoupdate:installonquit"?: boolean; "autoupdate:installonquit"?: boolean;
"autoupdate:channel"?: string; "autoupdate:channel"?: string;
"preview:showhiddenfiles"?: boolean; "preview:showhiddenfiles"?: boolean;
"tab:preset"?: string;
"widget:*"?: boolean; "widget:*"?: boolean;
"widget:showhelp"?: boolean; "widget:showhelp"?: boolean;
"window:*"?: 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-]+$/)) { if (icon.match(/^(solid@)?[a-z0-9-]+$/)) {
// strip off "solid@" prefix if it exists // strip off "solid@" prefix if it exists
icon = icon.replace(/^solid@/, ""); 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-]+$/)) { if (icon.match(/^regular@[a-z0-9-]+$/)) {
// strip off the "regular@" prefix if it exists // strip off the "regular@" prefix if it exists

View File

@ -22,7 +22,6 @@ import {
initGlobal, initGlobal,
initGlobalWaveEventSubs, initGlobalWaveEventSubs,
loadConnStatus, loadConnStatus,
overrideStaticTabAtom,
pushFlashError, pushFlashError,
pushNotification, pushNotification,
removeNotificationById, removeNotificationById,
@ -89,14 +88,15 @@ async function reinitWave() {
console.log("Reinit Wave"); console.log("Reinit Wave");
getApi().sendLog("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. // We use this hack to prevent a flicker of the previously-hovered tab when this view was last active.
// Also overrides the staticTabAtom to the new tab id so that the active tab is set correctly. document.body.classList.add("nohover");
globalStore.set(overrideStaticTabAtom, savedInitOpts.tabId); requestAnimationFrame(() =>
setTimeout(() => { setTimeout(() => {
document.body.classList.remove("nohover"); 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 waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid)); const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
const initialTab = await WOS.reloadWaveObject<Tab>(WOS.makeORef("tab", savedInitOpts.tabId)); 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/kevinburke/ssh_config v1.2.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/mitchellh/mapstructure v1.5.0 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/sawka/txwrap v0.2.0
github.com/shirou/gopsutil/v4 v4.24.10 github.com/shirou/gopsutil/v4 v4.24.10
github.com/skeema/knownhosts v1.3.0 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 h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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/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.36.0 h1:fcSrn8uGuorzPWCBp8L0aCR95Zjb/Dd+ZSML0YZy9EI=
github.com/sashabaranov/go-openai v1.35.7/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 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 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94=
github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= 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= github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM=

View File

@ -7,7 +7,7 @@
"productName": "Wave", "productName": "Wave",
"description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.9.3", "version": "0.10.0-beta.2",
"homepage": "https://waveterm.dev", "homepage": "https://waveterm.dev",
"build": { "build": {
"appId": "dev.commandline.waveterm" "appId": "dev.commandline.waveterm"
@ -144,7 +144,8 @@
}, },
"resolutions": { "resolutions": {
"send@npm:0.18.0": "0.19.0", "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", "packageManager": "yarn@4.5.1",
"workspaces": [ "workspaces": [

View File

@ -13,6 +13,7 @@ import (
"log" "log"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/filestore"
@ -77,10 +78,13 @@ type BlockController struct {
ShellInputCh chan *BlockInputUnion ShellInputCh chan *BlockInputUnion
ShellProcStatus string ShellProcStatus string
ShellProcExitCode int ShellProcExitCode int
RunLock *atomic.Bool
StatusVersion int
} }
type BlockControllerRuntimeStatus struct { type BlockControllerRuntimeStatus struct {
BlockId string `json:"blockid"` BlockId string `json:"blockid"`
Version int `json:"version"`
ShellProcStatus string `json:"shellprocstatus,omitempty"` ShellProcStatus string `json:"shellprocstatus,omitempty"`
ShellProcConnName string `json:"shellprocconnname,omitempty"` ShellProcConnName string `json:"shellprocconnname,omitempty"`
ShellProcExitCode int `json:"shellprocexitcode"` ShellProcExitCode int `json:"shellprocexitcode"`
@ -95,6 +99,8 @@ func (bc *BlockController) WithLock(f func()) {
func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus { func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
var rtn BlockControllerRuntimeStatus var rtn BlockControllerRuntimeStatus
bc.WithLock(func() { bc.WithLock(func() {
bc.StatusVersion++
rtn.Version = bc.StatusVersion
rtn.BlockId = bc.BlockId rtn.BlockId = bc.BlockId
rtn.ShellProcStatus = bc.ShellProcStatus rtn.ShellProcStatus = bc.ShellProcStatus
if bc.ShellProc != nil { if bc.ShellProc != nil {
@ -354,7 +360,26 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
} }
cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr
} }
shellProc, err = shellexec.StartRemoteShellProc(rc.TermSize, cmdStr, cmdOpts, conn) if !conn.WshEnabled.Load() {
shellProc, err = shellexec.StartRemoteShellProcNoWsh(rc.TermSize, cmdStr, cmdOpts, conn)
if err != nil {
return err
}
} else {
shellProc, err = shellexec.StartRemoteShellProc(rc.TermSize, cmdStr, cmdOpts, conn)
if err != nil {
conn.WithLock(func() {
conn.WshError = err.Error()
})
conn.WshEnabled.Store(false)
log.Printf("error starting remote shell proc with wsh: %v", err)
log.Print("attempting install without wsh")
shellProc, err = shellexec.StartRemoteShellProcNoWsh(rc.TermSize, cmdStr, cmdOpts, conn)
if err != nil {
return err
}
}
}
if err != nil { if err != nil {
return err return err
} }
@ -473,7 +498,9 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
defer func() { defer func() {
wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId)) wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId))
bc.UpdateControllerAndSendUpdate(func() bool { bc.UpdateControllerAndSendUpdate(func() bool {
bc.ShellProcStatus = Status_Done if bc.ShellProcStatus == Status_Running {
bc.ShellProcStatus = Status_Done
}
bc.ShellProcExitCode = exitCode bc.ShellProcExitCode = exitCode
return true return true
}) })
@ -549,7 +576,31 @@ func setTermSize(ctx context.Context, blockId string, termSize waveobj.TermSize)
return nil 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) { 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() curStatus := bc.GetRuntimeStatus()
controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "") controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "")
if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { 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_CmdRunOnce: false,
waveobj.MetaKey_CmdRunOnStart: 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 { if err != nil {
log.Printf("error updating block meta (in blockcontroller.run): %v\n", err) log.Printf("error updating block meta (in blockcontroller.run): %v\n", err)
return return
} }
} }
runningShellCommand = true
go func() { go func() {
defer panichandler.PanicHandler("blockcontroller:run-shell-command") defer panichandler.PanicHandler("blockcontroller:run-shell-command")
defer bc.UnlockRunLock()
var termSize waveobj.TermSize var termSize waveobj.TermSize
if rtOpts != nil { if rtOpts != nil {
termSize = rtOpts.TermSize termSize = rtOpts.TermSize
@ -639,7 +692,7 @@ func CheckConnStatus(blockId string) error {
func (bc *BlockController) StopShellProc(shouldWait bool) { func (bc *BlockController) StopShellProc(shouldWait bool) {
bc.Lock.Lock() bc.Lock.Lock()
defer bc.Lock.Unlock() 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 return
} }
bc.ShellProc.Close() bc.ShellProc.Close()
@ -670,6 +723,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
TabId: tabId, TabId: tabId,
BlockId: blockId, BlockId: blockId,
ShellProcStatus: Status_Init, ShellProcStatus: Status_Init,
RunLock: &atomic.Bool{},
} }
blockControllerMap[blockId] = bc blockControllerMap[blockId] = bc
createdController = true createdController = true
@ -697,11 +751,13 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
} }
return nil 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 { if curBc != nil {
bcStatus := curBc.GetRuntimeStatus() bcStatus := curBc.GetRuntimeStatus()
if bcStatus.ShellProcStatus == Status_Running && bcStatus.ShellProcConnName != connName { 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 // 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) return fmt.Errorf("unknown controller %q", controllerName)
} }
connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "") connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "")
log.Printf("start blockcontroller %s %q (%q)\n", blockId, controllerName, connName)
err = CheckConnStatus(blockId) err = CheckConnStatus(blockId)
if err != nil { if err != nil {
return fmt.Errorf("cannot start shellproc: %w", err) return fmt.Errorf("cannot start shellproc: %w", err)
} }
bc := getOrCreateBlockController(tabId, blockId, controllerName) bc := getOrCreateBlockController(tabId, blockId, controllerName)
bcStatus := bc.GetRuntimeStatus() 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 { if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
go bc.run(blockData, blockData.Meta, rtOpts, force) go bc.run(blockData, blockData.Meta, rtOpts, force)
} }
return nil return nil
} }
func StopBlockController(blockId string) { func StopBlockControllerAndSetStatus(blockId string, newStatus string) {
bc := GetBlockController(blockId) bc := GetBlockController(blockId)
if bc == nil { if bc == nil {
return return
@ -757,13 +813,17 @@ func StopBlockController(blockId string) {
bc.ShellProc.Close() bc.ShellProc.Close()
<-bc.ShellProc.DoneCh <-bc.ShellProc.DoneCh
bc.UpdateControllerAndSendUpdate(func() bool { bc.UpdateControllerAndSendUpdate(func() bool {
bc.ShellProcStatus = Status_Done bc.ShellProcStatus = newStatus
return true return true
}) })
} }
} }
func StopBlockController(blockId string) {
StopBlockControllerAndSetStatus(blockId, Status_Done)
}
func getControllerList() []*BlockController { func getControllerList() []*BlockController {
globalLock.Lock() globalLock.Lock()
defer globalLock.Unlock() defer globalLock.Unlock()

View File

@ -59,6 +59,7 @@ type SSHConn struct {
DomainSockListener net.Listener DomainSockListener net.Listener
ConnController *ssh.Session ConnController *ssh.Session
Error string Error string
WshError string
HasWaiter *atomic.Bool HasWaiter *atomic.Bool
LastConnectTime int64 LastConnectTime int64
ActiveConnNum int ActiveConnNum int
@ -94,10 +95,12 @@ func (conn *SSHConn) DeriveConnStatus() wshrpc.ConnStatus {
return wshrpc.ConnStatus{ return wshrpc.ConnStatus{
Status: conn.Status, Status: conn.Status,
Connected: conn.Status == Status_Connected, Connected: conn.Status == Status_Connected,
WshEnabled: conn.WshEnabled.Load(),
Connection: conn.Opts.String(), Connection: conn.Opts.String(),
HasConnected: (conn.LastConnectTime > 0), HasConnected: (conn.LastConnectTime > 0),
ActiveConnNum: conn.ActiveConnNum, ActiveConnNum: conn.ActiveConnNum,
Error: conn.Error, Error: conn.Error,
WshError: conn.WshError,
} }
} }
@ -532,7 +535,11 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn
}) })
} else if installErr != nil { } else if installErr != nil {
log.Printf("error: unable to install wsh shell extensions for %s: %v\n", conn.GetName(), err) 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 { } else {
conn.WshEnabled.Store(true) conn.WshEnabled.Store(true)
} }
@ -541,7 +548,12 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn
csErr := conn.StartConnServer() csErr := conn.StartConnServer()
if csErr != nil { if csErr != nil {
log.Printf("error: unable to start conn server for %s: %v\n", conn.GetName(), csErr) 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 { } else {

View File

@ -14,7 +14,6 @@ import (
"github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wcloud"
"github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wlayout"
"github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wsl" "github.com/wavetermdev/waveterm/pkg/wsl"
"github.com/wavetermdev/waveterm/pkg/wstore" "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 // moves the window to the front of the windowId stack
func (cs *ClientService) FocusWindow(ctx context.Context, windowId string) error { func (cs *ClientService) FocusWindow(ctx context.Context, windowId string) error {
log.Printf("FocusWindow %s\n", windowId)
return wcore.FocusWindow(ctx, windowId) return wcore.FocusWindow(ctx, windowId)
} }
@ -65,7 +63,7 @@ func (cs *ClientService) AgreeTos(ctx context.Context) (waveobj.UpdatesRtnType,
if err != nil { if err != nil {
return nil, fmt.Errorf("error updating client data: %w", err) return nil, fmt.Errorf("error updating client data: %w", err)
} }
wlayout.BootstrapStarterLayout(ctx) wcore.BootstrapStarterLayout(ctx)
return waveobj.ContextGetUpdatesRtn(ctx), nil return waveobj.ContextGetUpdatesRtn(ctx), nil
} }

View File

@ -12,6 +12,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
@ -142,7 +143,7 @@ func (svc *ObjectService) UpdateObjectMeta(uiContext waveobj.UIContext, orefStr
if err != nil { if err != nil {
return nil, fmt.Errorf("error parsing object reference: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("error updateing %q meta: %w", orefStr, err) 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 { if err != nil {
return nil, fmt.Errorf("error updating object: %w", err) return nil, fmt.Errorf("error updating object: %w", err)
} }
if (waveObj.GetOType() == waveobj.OType_Workspace) && (waveObj.(*waveobj.Workspace).Name != "") {
wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_WorkspaceUpdate})
}
if returnUpdates { if returnUpdates {
return waveobj.ContextGetUpdatesRtn(ctx), nil return waveobj.ContextGetUpdatesRtn(ctx), nil
} }

View File

@ -14,7 +14,6 @@ import (
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wlayout"
"github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
@ -50,24 +49,6 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating window: %w", err) return nil, fmt.Errorf("error creating window: %w", err)
} }
ws, err := wcore.GetWorkspace(ctx, window.WorkspaceId)
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 return window, nil
} }
@ -146,12 +127,12 @@ func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId
if !windowCreated { if !windowCreated {
return nil, fmt.Errorf("new window not created") return nil, fmt.Errorf("new window not created")
} }
wlayout.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{ wcore.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{
ActionType: wlayout.LayoutActionDataType_Remove, ActionType: wcore.LayoutActionDataType_Remove,
BlockId: blockId, BlockId: blockId,
}) })
wlayout.QueueLayoutActionForTab(ctx, ws.ActiveTabId, waveobj.LayoutActionData{ wcore.QueueLayoutActionForTab(ctx, ws.ActiveTabId, waveobj.LayoutActionData{
ActionType: wlayout.LayoutActionDataType_Insert, ActionType: wcore.LayoutActionDataType_Insert,
BlockId: blockId, BlockId: blockId,
Focused: true, Focused: true,
}) })

View File

@ -11,7 +11,6 @@ import (
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wlayout"
"github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
@ -20,6 +19,20 @@ const DefaultTimeout = 2 * time.Second
type WorkspaceService struct{} type WorkspaceService struct{}
func (svc *WorkspaceService) CreateWorkspace_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{
ReturnDesc: "workspaceId",
}
}
func (svc *WorkspaceService) CreateWorkspace(ctx context.Context) (string, error) {
newWS, err := wcore.CreateWorkspace(ctx, "", "", "", false)
if err != nil {
return "", fmt.Errorf("error creating workspace: %w", err)
}
return newWS.OID, nil
}
func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta { func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta {
return tsgenmeta.MethodMeta{ return tsgenmeta.MethodMeta{
ArgNames: []string{"workspaceId"}, ArgNames: []string{"workspaceId"},
@ -78,14 +91,10 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn() defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx) 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 { if err != nil {
return "", nil, fmt.Errorf("error creating tab: %w", err) 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) updates := waveobj.ContextGetUpdatesRtn(ctx)
go func() { go func() {
defer panichandler.PanicHandler("WorkspaceService:CreateTab:SendUpdateEvents") defer panichandler.PanicHandler("WorkspaceService:CreateTab:SendUpdateEvents")

View File

@ -236,49 +236,50 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st
return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
} }
func StartRemoteShellProcNoWsh(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
client := conn.GetClient()
session, err := client.NewSession()
if err != nil {
return nil, err
}
remoteStdinRead, remoteStdinWriteOurs, err := os.Pipe()
if err != nil {
return nil, err
}
remoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe()
if err != nil {
return nil, err
}
pipePty := &PipePty{
remoteStdinWrite: remoteStdinWriteOurs,
remoteStdoutRead: remoteStdoutReadOurs,
}
if termSize.Rows == 0 || termSize.Cols == 0 {
termSize.Rows = shellutil.DefaultTermRows
termSize.Cols = shellutil.DefaultTermCols
}
if termSize.Rows <= 0 || termSize.Cols <= 0 {
return nil, fmt.Errorf("invalid term size: %v", termSize)
}
session.Stdin = remoteStdinRead
session.Stdout = remoteStdoutWrite
session.Stderr = remoteStdoutWrite
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
sessionWrap := MakeSessionWrap(session, "", pipePty)
err = session.Shell()
if err != nil {
pipePty.Close()
return nil, err
}
return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}
func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
client := conn.GetClient() client := conn.GetClient()
if !conn.WshEnabled.Load() {
// no wsh code
session, err := client.NewSession()
if err != nil {
return nil, err
}
remoteStdinRead, remoteStdinWriteOurs, err := os.Pipe()
if err != nil {
return nil, err
}
remoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe()
if err != nil {
return nil, err
}
pipePty := &PipePty{
remoteStdinWrite: remoteStdinWriteOurs,
remoteStdoutRead: remoteStdoutReadOurs,
}
if termSize.Rows == 0 || termSize.Cols == 0 {
termSize.Rows = shellutil.DefaultTermRows
termSize.Cols = shellutil.DefaultTermCols
}
if termSize.Rows <= 0 || termSize.Cols <= 0 {
return nil, fmt.Errorf("invalid term size: %v", termSize)
}
session.Stdin = remoteStdinRead
session.Stdout = remoteStdoutWrite
session.Stderr = remoteStdoutWrite
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
sessionWrap := MakeSessionWrap(session, "", pipePty)
err = session.Shell()
if err != nil {
pipePty.Close()
return nil, err
}
return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}
shellPath := cmdOpts.ShellPath shellPath := cmdOpts.ShellPath
if shellPath == "" { if shellPath == "" {
remoteShellPath, err := remote.DetectShell(client) remoteShellPath, err := remote.DetectShell(client)

View File

@ -572,6 +572,8 @@ var StaticMimeTypeMap = map[string]string{
".oeb": "application/vnd.openeye.oeb", ".oeb": "application/vnd.openeye.oeb",
".oxt": "application/vnd.openofficeorg.extension", ".oxt": "application/vnd.openofficeorg.extension",
".osm": "application/vnd.openstreetmap.data+xml", ".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", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide", ".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide",
".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow", ".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
@ -1108,14 +1110,18 @@ var StaticMimeTypeMap = map[string]string{
".jsx": "text/jsx", ".jsx": "text/jsx",
".less": "text/less", ".less": "text/less",
".md": "text/markdown", ".md": "text/markdown",
".mdx": "text/mdx",
".m": "text/mips", ".m": "text/mips",
".miz": "text/mizar", ".miz": "text/mizar",
".n3": "text/n3", ".n3": "text/n3",
".txt": "text/plain", ".txt": "text/plain",
".conf": "text/plain",
".awk": "text/x-awk",
".provn": "text/provenance-notation", ".provn": "text/provenance-notation",
".rst": "text/prs.fallenstein.rst", ".rst": "text/prs.fallenstein.rst",
".tag": "text/prs.lines.tag", ".tag": "text/prs.lines.tag",
".rs": "text/x-rust", ".rs": "text/x-rust",
".ini": "text/x-ini",
".sass": "text/scss", ".sass": "text/scss",
".scss": "text/scss", ".scss": "text/scss",
".sgml": "text/SGML", ".sgml": "text/SGML",

View File

@ -620,6 +620,7 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {
// on error just returns "" // on error just returns ""
// does not return "application/octet-stream" as this is considered a detection failure // does not return "application/octet-stream" as this is considered a detection failure
// can pass an existing fileInfo to avoid re-statting the file // 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 { func DetectMimeType(path string, fileInfo fs.FileInfo, extended bool) string {
if fileInfo == nil { if fileInfo == nil {
statRtn, err := os.Stat(path) 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 != "" { if mimeType := mime.TypeByExtension(ext); mimeType != "" {
return mimeType return mimeType
} }
if fileInfo.Size() == 0 {
return "text/plain"
}
if !extended { if !extended {
return "" 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 PacketEOFStr = "EOF"
const DefaultAzureAPIVersion = "2023-05-15" const DefaultAzureAPIVersion = "2023-05-15"
const ApiType_Anthropic = "anthropic" const ApiType_Anthropic = "anthropic"
const ApiType_Perplexity = "perplexity"
type OpenAICmdInfoPacketOutputType struct { type OpenAICmdInfoPacketOutputType struct {
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
@ -74,6 +75,15 @@ func RunAICommand(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan
anthropicBackend := AnthropicBackend{} anthropicBackend := AnthropicBackend{}
return anthropicBackend.StreamCompletion(ctx, request) 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) { if IsCloudAIRequest(request.Opts) {
log.Print("sending ai chat message to default waveterm cloud endpoint\n") log.Print("sending ai chat message to default waveterm cloud endpoint\n")
cloudBackend := WaveAICloudBackend{} cloudBackend := WaveAICloudBackend{}

View File

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

View File

@ -75,6 +75,8 @@ type SettingsType struct {
PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"` PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"`
TabPreset string `json:"tab:preset,omitempty"`
WidgetClear bool `json:"widget:*,omitempty"` WidgetClear bool `json:"widget:*,omitempty"`
WidgetShowHelp *bool `json:"widget:showhelp,omitempty"` WidgetShowHelp *bool `json:"widget:showhelp,omitempty"`

View File

@ -152,21 +152,18 @@ func DeleteBlock(ctx context.Context, blockId string, recursive bool) error {
log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount) log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount)
parentORef := waveobj.ParseORefNoErr(block.ParentORef) parentORef := waveobj.ParseORefNoErr(block.ParentORef)
if parentORef.OType == waveobj.OType_Tab { if recursive && parentORef.OType == waveobj.OType_Tab && parentBlockCount == 0 {
if parentBlockCount == 0 && recursive { // if parent tab has no blocks, delete the tab
// if parent tab has no blocks, delete the tab log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID)
log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID) parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID)
parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID) if err != nil {
if err != nil { return fmt.Errorf("error finding workspace for tab to delete %s: %w", parentORef.OID, err)
return fmt.Errorf("error finding workspace for tab to delete %s: %w", parentORef.OID, err)
}
newActiveTabId, err := DeleteTab(ctx, parentWorkspaceId, parentORef.OID, true)
if err != nil {
return fmt.Errorf("error deleting tab %s: %w", parentORef.OID, err)
}
SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)
} }
newActiveTabId, err := DeleteTab(ctx, parentWorkspaceId, parentORef.OID, true)
if err != nil {
return fmt.Errorf("error deleting tab %s: %w", parentORef.OID, err)
}
SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)
} }
go blockcontroller.StopBlockController(blockId) go blockcontroller.StopBlockController(blockId)
sendBlockCloseEvent(blockId) sendBlockCloseEvent(blockId)

View File

@ -1,7 +1,7 @@
// Copyright 2024, Command Line Inc. // Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package wlayout package wcore
import ( import (
"context" "context"
@ -10,7 +10,6 @@ import (
"time" "time"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wstore" "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++ { for i := 0; i < len(layout); i++ {
layoutAction := 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 { if err != nil {
return fmt.Errorf("unable to create block to apply portable layout to tab %s: %w", tabId, err) 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 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 { func BootstrapStarterLayout(ctx context.Context) error {
ctx, cancelFn := context.WithTimeout(ctx, 2*time.Second) ctx, cancelFn := context.WithTimeout(ctx, 2*time.Second)
defer cancelFn() defer cancelFn()

View File

@ -19,11 +19,6 @@ import (
// the wcore package coordinates actions across the storage layer // the wcore package coordinates actions across the storage layer
// orchestrating the wave object store, the wave pubsub system, and the wave rpc system // 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 // Ensures that the initial data is present in the store, creates an initial window if needed
func EnsureInitialData() error { func EnsureInitialData() error {
// does not need to run in a transaction since it is called on startup // 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") log.Println("client has windows")
return nil return nil
} }
log.Println("client has no windows, creating default workspace") log.Println("client has no windows, creating starter workspace")
defaultWs, err := CreateWorkspace(ctx, "Default workspace", "circle", "green") starterWs, err := CreateWorkspace(ctx, "Starter workspace", "circle", "#58C142", true)
if err != nil { if err != nil {
return fmt.Errorf("error creating default workspace: %w", err) return fmt.Errorf("error creating starter workspace: %w", err)
} }
_, err = CreateTab(ctx, defaultWs.OID, "", true, true) _, err = CreateWindow(ctx, nil, starterWs.OID)
if err != nil {
return fmt.Errorf("error creating tab: %w", err)
}
_, err = CreateWindow(ctx, nil, defaultWs.OID)
if err != nil { if err != nil {
return fmt.Errorf("error creating window: %w", err) 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) log.Printf("CreateWindow %v %v\n", winSize, workspaceId)
var ws *waveobj.Workspace var ws *waveobj.Workspace
if workspaceId == "" { if workspaceId == "" {
ws1, err := CreateWorkspace(ctx, "", "", "") ws1, err := CreateWorkspace(ctx, "", "", "", false)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating workspace: %w", err) 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 { if len(ws.TabIds) == 0 {
log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID) 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 { if err != nil {
log.Printf("error creating tab (in checkAndFixWindow): %v\n", err) 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/telemetry"
"github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wstore" "github.com/wavetermdev/waveterm/pkg/wstore"
) )
func CreateWorkspace(ctx context.Context, name string, icon string, color string) (*waveobj.Workspace, error) { func CreateWorkspace(ctx context.Context, name string, icon string, color string, isInitialLaunch bool) (*waveobj.Workspace, error) {
log.Println("CreateWorkspace")
ws := &waveobj.Workspace{ ws := &waveobj.Workspace{
OID: uuid.NewString(), OID: uuid.NewString(),
TabIds: []string{}, TabIds: []string{},
@ -25,7 +26,22 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string
Icon: icon, Icon: icon,
Color: color, Color: color,
} }
wstore.DBInsert(ctx, ws) err := wstore.DBInsert(ctx, ws)
if err != nil {
return nil, fmt.Errorf("error inserting workspace: %w", err)
}
_, err = CreateTab(ctx, ws.OID, "", true, false, 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 return ws, nil
} }
@ -38,7 +54,7 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
if err != nil { if err != nil {
return false, fmt.Errorf("error getting workspace: %w", err) return false, fmt.Errorf("error getting workspace: %w", err)
} }
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 && len(workspace.PinnedTabIds) > 0 { if workspace.Name != "" && workspace.Icon != "" && !force && (len(workspace.TabIds) > 0 || len(workspace.PinnedTabIds) > 0) {
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId) log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
return false, nil return false, nil
} }
@ -56,6 +72,8 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
return false, fmt.Errorf("error deleting workspace: %w", err) return false, fmt.Errorf("error deleting workspace: %w", err)
} }
log.Printf("deleted workspace %s\n", workspaceId) log.Printf("deleted workspace %s\n", workspaceId)
wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_WorkspaceUpdate})
return true, nil return true, nil
} }
@ -63,8 +81,18 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error)
return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID) 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 // 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 == "" { if tabName == "" {
ws, err := GetWorkspace(ctx, workspaceId) ws, err := GetWorkspace(ctx, workspaceId)
if err != nil { 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) 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 { if err != nil {
return "", fmt.Errorf("error creating tab: %w", err) 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) 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") telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
return tab.OID, nil 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) wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
// if no tabs remaining, close window // if no tabs remaining, close window
if newActiveTabId == "" && recursive { if recursive && newActiveTabId == "" {
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId) log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId) windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
if err != nil { if err != nil {

View File

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

View File

@ -115,6 +115,12 @@ func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData
return err 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 // command "dispose", wshserver.DisposeCommand
func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error { func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts) _, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts)
@ -317,6 +323,12 @@ func SetConfigCommand(w *wshutil.WshRpc, data wshrpc.MetaSettingsType, opts *wsh
return err 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 // command "setmeta", wshserver.SetMetaCommand
func SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wshrpc.RpcOpts) error { func SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "setmeta", data, opts) _, err := sendRpcRequestCallHelper[any](w, "setmeta", data, opts)

View File

@ -29,48 +29,50 @@ const (
) )
const ( const (
Command_Authenticate = "authenticate" // special Command_Authenticate = "authenticate" // special
Command_Dispose = "dispose" // special (disposes of the route, for multiproxy only) Command_Dispose = "dispose" // special (disposes of the route, for multiproxy only)
Command_RouteAnnounce = "routeannounce" // special (for routing) Command_RouteAnnounce = "routeannounce" // special (for routing)
Command_RouteUnannounce = "routeunannounce" // special (for routing) Command_RouteUnannounce = "routeunannounce" // special (for routing)
Command_Message = "message" Command_Message = "message"
Command_GetMeta = "getmeta" Command_GetMeta = "getmeta"
Command_SetMeta = "setmeta" Command_SetMeta = "setmeta"
Command_SetView = "setview" Command_SetView = "setview"
Command_ControllerInput = "controllerinput" Command_ControllerInput = "controllerinput"
Command_ControllerRestart = "controllerrestart" Command_ControllerRestart = "controllerrestart"
Command_ControllerStop = "controllerstop" Command_ControllerStop = "controllerstop"
Command_ControllerResync = "controllerresync" Command_ControllerResync = "controllerresync"
Command_FileAppend = "fileappend" Command_FileAppend = "fileappend"
Command_FileAppendIJson = "fileappendijson" Command_FileAppendIJson = "fileappendijson"
Command_ResolveIds = "resolveids" Command_ResolveIds = "resolveids"
Command_BlockInfo = "blockinfo" Command_BlockInfo = "blockinfo"
Command_CreateBlock = "createblock" Command_CreateBlock = "createblock"
Command_DeleteBlock = "deleteblock" Command_DeleteBlock = "deleteblock"
Command_FileWrite = "filewrite" Command_FileWrite = "filewrite"
Command_FileRead = "fileread" Command_FileRead = "fileread"
Command_EventPublish = "eventpublish" Command_EventPublish = "eventpublish"
Command_EventRecv = "eventrecv" Command_EventRecv = "eventrecv"
Command_EventSub = "eventsub" Command_EventSub = "eventsub"
Command_EventUnsub = "eventunsub" Command_EventUnsub = "eventunsub"
Command_EventUnsubAll = "eventunsuball" Command_EventUnsubAll = "eventunsuball"
Command_EventReadHistory = "eventreadhistory" Command_EventReadHistory = "eventreadhistory"
Command_StreamTest = "streamtest" Command_StreamTest = "streamtest"
Command_StreamWaveAi = "streamwaveai" Command_StreamWaveAi = "streamwaveai"
Command_StreamCpuData = "streamcpudata" Command_StreamCpuData = "streamcpudata"
Command_Test = "test" Command_Test = "test"
Command_RemoteStreamFile = "remotestreamfile" Command_SetConfig = "setconfig"
Command_RemoteFileInfo = "remotefileinfo" Command_SetConnectionsConfig = "connectionsconfig"
Command_RemoteFileTouch = "remotefiletouch" Command_RemoteStreamFile = "remotestreamfile"
Command_RemoteWriteFile = "remotewritefile" Command_RemoteFileInfo = "remotefileinfo"
Command_RemoteFileDelete = "remotefiledelete" Command_RemoteFileTouch = "remotefiletouch"
Command_RemoteFileJoin = "remotefilejoin" Command_RemoteWriteFile = "remotewritefile"
Command_WaveInfo = "waveinfo" Command_RemoteFileDelete = "remotefiledelete"
Command_WshActivity = "wshactivity" Command_RemoteFileJoin = "remotefilejoin"
Command_Activity = "activity" Command_WaveInfo = "waveinfo"
Command_GetVar = "getvar" Command_WshActivity = "wshactivity"
Command_SetVar = "setvar" Command_Activity = "activity"
Command_RemoteMkdir = "remotemkdir" Command_GetVar = "getvar"
Command_SetVar = "setvar"
Command_RemoteMkdir = "remotemkdir"
Command_ConnStatus = "connstatus" Command_ConnStatus = "connstatus"
Command_WslStatus = "wslstatus" Command_WslStatus = "wslstatus"
@ -81,6 +83,7 @@ const (
Command_ConnList = "connlist" Command_ConnList = "connlist"
Command_WslList = "wsllist" Command_WslList = "wsllist"
Command_WslDefaultDistro = "wsldefaultdistro" Command_WslDefaultDistro = "wsldefaultdistro"
Command_DismissWshFail = "dismisswshfail"
Command_WorkspaceList = "workspacelist" Command_WorkspaceList = "workspacelist"
@ -139,6 +142,7 @@ type WshRpcInterface interface {
StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData] StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData]
TestCommand(ctx context.Context, data string) error TestCommand(ctx context.Context, data string) error
SetConfigCommand(ctx context.Context, data MetaSettingsType) error SetConfigCommand(ctx context.Context, data MetaSettingsType) error
SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error
BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error) BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error)
WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) WaveInfoCommand(ctx context.Context) (*WaveInfoData, error)
WshActivityCommand(ct context.Context, data map[string]int) error WshActivityCommand(ct context.Context, data map[string]int) error
@ -156,6 +160,7 @@ type WshRpcInterface interface {
ConnListCommand(ctx context.Context) ([]string, error) ConnListCommand(ctx context.Context) ([]string, error)
WslListCommand(ctx context.Context) ([]string, error) WslListCommand(ctx context.Context) ([]string, error)
WslDefaultDistroCommand(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 // eventrecv is special, it's handled internally by WshRpc with EventListener
EventRecvCommand(ctx context.Context, data wps.WaveEvent) error EventRecvCommand(ctx context.Context, data wps.WaveEvent) error
@ -512,6 +517,11 @@ func (m MetaSettingsType) MarshalJSON() ([]byte, error) {
return json.Marshal(m.MetaMapType) return json.Marshal(m.MetaMapType)
} }
type ConnConfigRequest struct {
Host string `json:"host"`
MetaMapType waveobj.MetaMapType `json:"metamaptype"`
}
type ConnStatus struct { type ConnStatus struct {
Status string `json:"status"` Status string `json:"status"`
WshEnabled bool `json:"wshenabled"` WshEnabled bool `json:"wshenabled"`
@ -520,6 +530,7 @@ type ConnStatus struct {
HasConnected bool `json:"hasconnected"` // true if it has *ever* connected successfully HasConnected bool `json:"hasconnected"` // true if it has *ever* connected successfully
ActiveConnNum int `json:"activeconnnum"` ActiveConnNum int `json:"activeconnnum"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
WshError string `json:"wsherror,omitempty"`
} }
type WebSelectorOpts struct { type WebSelectorOpts struct {

View File

@ -29,7 +29,6 @@ import (
"github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wlayout"
"github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshutil" "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 { func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error {
log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta)
oref := data.ORef oref := data.ORef
err := wstore.UpdateObjectMeta(ctx, oref, data.Meta) err := wstore.UpdateObjectMeta(ctx, oref, data.Meta, false)
if err != nil { if err != nil {
return fmt.Errorf("error updating object meta: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("error creating block: %w", err) return nil, fmt.Errorf("error creating block: %w", err)
} }
err = wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ err = wcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{
ActionType: wlayout.LayoutActionDataType_Insert, ActionType: wcore.LayoutActionDataType_Insert,
BlockId: blockData.OID, BlockId: blockData.OID,
Magnified: data.Magnified, Magnified: data.Magnified,
Focused: true, Focused: true,
@ -506,8 +505,8 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command
if err != nil { if err != nil {
return fmt.Errorf("error deleting block: %w", err) return fmt.Errorf("error deleting block: %w", err)
} }
wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ wcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{
ActionType: wlayout.LayoutActionDataType_Remove, ActionType: wcore.LayoutActionDataType_Remove,
BlockId: data.BlockId, BlockId: data.BlockId,
}) })
updates := waveobj.ContextGetUpdatesRtn(ctx) updates := waveobj.ContextGetUpdatesRtn(ctx)
@ -575,6 +574,11 @@ func (ws *WshServer) SetConfigCommand(ctx context.Context, data wshrpc.MetaSetti
return wconfig.SetBaseConfigValue(data.MetaMapType) 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) { func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) {
rtn := conncontroller.GetAllConnStatus() rtn := conncontroller.GetAllConnStatus()
return rtn, nil return rtn, nil
@ -685,6 +689,25 @@ func (ws *WshServer) WslDefaultDistroCommand(ctx context.Context) (string, error
return distro.Name(), nil 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) { func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) {
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil { if err != nil {

View File

@ -89,6 +89,7 @@ func (conn *WslConn) DeriveConnStatus() wshrpc.ConnStatus {
return wshrpc.ConnStatus{ return wshrpc.ConnStatus{
Status: conn.Status, Status: conn.Status,
Connected: conn.Status == Status_Connected, Connected: conn.Status == Status_Connected,
WshEnabled: true, // always use wsh for wsl connections (temporary)
Connection: conn.GetName(), Connection: conn.GetName(),
HasConnected: (conn.LastConnectTime > 0), HasConnected: (conn.LastConnectTime > 0),
ActiveConnNum: conn.ActiveConnNum, 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 { return WithTx(ctx, func(tx *TxWrap) error {
if oref.IsEmpty() { if oref.IsEmpty() {
return fmt.Errorf("empty object reference") return fmt.Errorf("empty object reference")
@ -66,7 +66,7 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM
if objMeta == nil { if objMeta == nil {
objMeta = make(map[string]any) objMeta = make(map[string]any)
} }
newMeta := waveobj.MergeMeta(objMeta, meta, false) newMeta := waveobj.MergeMeta(objMeta, meta, mergeSpecial)
waveobj.SetMeta(obj, newMeta) waveobj.SetMeta(obj, newMeta)
DBUpdate(tx.Context(), obj) DBUpdate(tx.Context(), obj)
return nil return nil

View File

@ -16401,13 +16401,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "path-to-regexp@npm:3.3.0":
version: 3.3.0 version: 3.3.0
resolution: "path-to-regexp@npm:3.3.0" resolution: "path-to-regexp@npm:3.3.0"
@ -16415,6 +16408,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "path-to-regexp@npm:^1.7.0":
version: 1.9.0 version: 1.9.0
resolution: "path-to-regexp@npm:1.9.0" resolution: "path-to-regexp@npm:1.9.0"