mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
merge main
This commit is contained in:
commit
78f4a5fd1c
10
.github/workflows/build-helper.yml
vendored
10
.github/workflows/build-helper.yml
vendored
@ -113,9 +113,15 @@ jobs:
|
||||
env:
|
||||
USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.
|
||||
SNAPCRAFT_BUILD_ENVIRONMENT: host
|
||||
- name: Build (Darwin)
|
||||
# Retry Darwin build in case of notarization failures
|
||||
- uses: nick-fields/retry@v3
|
||||
name: Build (Darwin)
|
||||
if: matrix.platform == 'darwin'
|
||||
run: task package
|
||||
with:
|
||||
command: task package
|
||||
timeout_minutes: 120
|
||||
retry_on: error
|
||||
max_attempts: 3
|
||||
env:
|
||||
USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.
|
||||
CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}}
|
||||
|
55
.github/workflows/publish-release.yml
vendored
55
.github/workflows/publish-release.yml
vendored
@ -6,9 +6,29 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
publish:
|
||||
publish-s3:
|
||||
name: Publish to Releases
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v2
|
||||
with:
|
||||
version: 3.x
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish from staging
|
||||
run: "task artifacts:publish:${{ github.ref_name }}"
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}"
|
||||
AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}"
|
||||
AWS_DEFAULT_REGION: us-west-2
|
||||
shell: bash
|
||||
publish-snap-amd64:
|
||||
name: Publish AMD64 Snap
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [publish-s3]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Task
|
||||
@ -19,26 +39,45 @@ jobs:
|
||||
- name: Install Snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
shell: bash
|
||||
- name: Publish from staging
|
||||
run: "task artifacts:publish:${{ github.ref_name }}"
|
||||
- name: Download Snap from Release
|
||||
uses: robinraju/release-downloader@v1
|
||||
with:
|
||||
tag: ${{github.ref_name}}
|
||||
fileName: "*amd64.snap"
|
||||
- name: Publish to Snapcraft
|
||||
run: "task artifacts:snap:publish:${{ github.ref_name }}"
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}"
|
||||
AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}"
|
||||
AWS_DEFAULT_REGION: us-west-2
|
||||
SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}"
|
||||
shell: bash
|
||||
publish-snap-arm64:
|
||||
name: Publish ARM64 Snap
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: [publish-s3]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v2
|
||||
with:
|
||||
version: 3.x
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install Snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
shell: bash
|
||||
- name: Download Snap from Release
|
||||
uses: robinraju/release-downloader@v1
|
||||
with:
|
||||
tag: ${{github.ref_name}}
|
||||
fileName: "*.snap"
|
||||
fileName: "*arm64.snap"
|
||||
- name: Publish to Snapcraft
|
||||
run: "task artifacts:snap:publish:${{ github.ref_name }}"
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}"
|
||||
shell: bash
|
||||
bump-winget:
|
||||
name: Submit WinGet PR
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }}
|
||||
needs: [publish]
|
||||
needs: [publish-s3]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
@ -1,9 +1,11 @@
|
||||
<p align="center">
|
||||
<a href="https://www.waveterm.dev">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./assets/wave-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./assets/wave-light.png">
|
||||
<img alt="Wave Terminal Logo" src="./assets/wave-light.png" width="240">
|
||||
</picture>
|
||||
</a>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
|
@ -276,8 +276,11 @@ tasks:
|
||||
CHANNEL: '{{if contains "beta" .UP_VERSION}}beta{{else}}beta,stable{{end}}'
|
||||
cmd: |
|
||||
echo "Releasing to channels: [{{.CHANNEL}}]"
|
||||
snapcraft upload --release={{.CHANNEL}} waveterm_{{.UP_VERSION}}_arm64.snap
|
||||
snapcraft upload --release={{.CHANNEL}} waveterm_{{.UP_VERSION}}_amd64.snap
|
||||
for file in waveterm_{{.UP_VERSION}}_*.snap; do
|
||||
echo "Publishing $file"
|
||||
snapcraft upload --release={{.CHANNEL}} $file
|
||||
echo "Finished publishing $file"
|
||||
done
|
||||
|
||||
artifacts:winget:publish:*:
|
||||
desc: Submits a version bump request to WinGet for the latest release.
|
||||
|
@ -1,12 +1,3 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="../assets/wave-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="../assets/wave-light.png">
|
||||
<img alt="Wave Terminal Logo" src="../assets/wave-light.png" width="240">
|
||||
</picture>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
# Wave Terminal Documentation
|
||||
|
||||
This is the home for Wave Terminal's documentation site. This README is specifically about _building_ and contributing to the docs site. If you are looking for the actual hosted docs, go here -- https://docs.waveterm.dev
|
||||
|
@ -16,11 +16,18 @@ The easiest way to access connections is to click the <i className="fa-sharp fa-
|
||||
|
||||
## What are wsh Shell Extensions?
|
||||
|
||||
`wsh` is a small program that helps manage waveterm regardless of which machine you are currently connected to. In order to not interrupt the normal flow of the remote session, we install it on your remote machine at `~/.waveterm/bin/wsh`. Then, when wave connects to your connection (and only when wave connects to your connection), `~/.waveterm/bin` is added to your `PATH` for that individual session. For more info on what `wsh` is capable of, see [wsh command](/wsh). And if you wish to view the source code of `wsh`, you can find it [here](https://github.com/wavetermdev/waveterm/tree/main/cmd/wsh).
|
||||
`wsh` is a small program that helps manage waveterm regardless of which machine you are currently connected to. It is always included on your host machine, but you also have the option to install it when connecting to a remote machine. If it is installed on the remote machine, it is installed at `~/.waveterm/bin/wsh`. Then, when wave connects to your connection (and only when wave connects to your connection), `~/.waveterm/bin` is added to your `PATH` for that individual session. For more info on what `wsh` is capable of, see [wsh command](/wsh). And if you wish to view the source code of `wsh`, you can find it [here](https://github.com/wavetermdev/waveterm/tree/main/cmd/wsh).
|
||||
|
||||
With `wsh` installed, you have the ability to view certain widgets from the remote machine as if it were your host. In addition, `wsh` can be used to influence the widgets across various machines. As a very simple example, you can close a widget on the host machine by using the `wsh` command in a terminal window on a remote machine. For more information on what you can accomplish with `wsh`, take a look [here](/wsh).
|
||||
|
||||
## Add a New Connection to the Dropdown
|
||||
|
||||
The SSH values that are loaded into the dropdown by default are obtained by parsing your `~/.ssh/config` and `/etc/ssh/ssh_config` files. Adding a new connection is as simple as adding a new `Host` to one of these files, typically the `~/.ssh/config` file.
|
||||
The SSH values that are loaded into the dropdown by default are obtained by parsing the internal `config/connections.json` file in addition to your `~/.ssh/config` and `/etc/ssh/ssh_config` files. Adding a new connection can be added in a couple ways:
|
||||
|
||||
- adding a new `Host` to one of your ssh config files, typically the `~/.ssh/config` file
|
||||
- adding a new entry in the internal `config/connections.json` file
|
||||
- manually typing your connection into the connection box (if this successfully connects, the connection will be added to the internal `config/connections.json` file)
|
||||
- use `wsh ssh [user]@[host]` in your terminal (if this successfully connects, the connection will be added to the internal `config/connections.json` file)
|
||||
|
||||
WSL values are added by searching the installed WSL distributions as they appear in the Windows Registry.
|
||||
|
||||
@ -55,6 +62,20 @@ Host myhost
|
||||
|
||||
You would then be able to access this connection with `myhost` or `username@myhost`. And if you wanted to manually specify a port such as port 2222, you could do that by either adding `Port 2222` to the config file or connecting to `username@myhost:2222`.
|
||||
|
||||
## Internal SSH Configuration
|
||||
|
||||
In addition to the regular ssh config file, wave also has its own config file to manage separate variables. These include
|
||||
| Keyword | Description |
|
||||
|---------|-------------|
|
||||
| conn:wshenabled | This boolean allows wsh to be used for your connection, if it is set to `false`, `wsh` will never be used for that connection. It defaults to `true`.|
|
||||
| conn:askbeforewshinstall | This boolean is used to prompt the user before installing wsh. If it is set to false, `wsh` will automatically be installed instead without prompting. It defaults to `true`.|
|
||||
| display:hidden | This boolean hides the connection from the dropdown list. It defaults to `false` |
|
||||
| display:order | This float determines the order of connections in the connection dropdown. It defaults to `0`.|
|
||||
| term:fontsize | This int can be used to override the terminal font size for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. |
|
||||
| term:fontfamily | This string can be used to specify a terminal font family for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. |
|
||||
| term:theme | This string can be used to specify a terminal theme for blocks using this connection. The block metadata takes priority over this setting. It defaults to null which means the global setting will be used instead. |
|
||||
| ssh:identityfile | A list of strings containing the paths to identity files that will be used. If a `wsh ssh` command using the `-i` flag is successful, the identity file will automatically be added here. |
|
||||
|
||||
## Managing Connections with the CLI
|
||||
|
||||
The `wsh` command gives some commands specifically for interacting with the connections. You can view these [here](/wsh#conn).
|
||||
|
@ -66,6 +66,23 @@ Set these keys:
|
||||
|
||||
Note: we set the `ai:*` key to true to clear all the existing "ai" keys so this config is a clean slate.
|
||||
|
||||
### How can I connect to Perplexity?
|
||||
|
||||
Open your [config file](./config) in Wave using `wsh editconfig`.
|
||||
|
||||
Set these keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"ai:*": true,
|
||||
"ai:apitype": "perplexity",
|
||||
"ai:model": "llama-3.1-sonar-small-128k-online",
|
||||
"ai:apitoken": "<your perplexity API key>"
|
||||
}
|
||||
```
|
||||
|
||||
Note: we set the `ai:*` key to true to clear all the existing "ai" keys so this config is a clean slate.
|
||||
|
||||
To switch between models, consider [adding AI Presets](./presets) instead.
|
||||
|
||||
### How can I see the block numbers?
|
||||
|
@ -298,7 +298,7 @@ This will delete the block with the specified id.
|
||||
wsh ssh [user@host]
|
||||
```
|
||||
|
||||
This will use Wave's internal ssh implementation to connect to the specified remote machine.
|
||||
This will use Wave's internal ssh implementation to connect to the specified remote machine. The `-i` flag can be used to specify a path to an identity file.
|
||||
|
||||
---
|
||||
|
||||
|
@ -1,13 +1,14 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { FileService } from "@/app/store/services";
|
||||
import { adaptFromElectronKeyEvent } from "@/util/keyutil";
|
||||
import { Rectangle, shell, WebContentsView } from "electron";
|
||||
import { getWaveWindowById } from "emain/emain-window";
|
||||
import path from "path";
|
||||
import { configureAuthKeyRequestInjection } from "./authkey";
|
||||
import { setWasActive } from "./emain-activity";
|
||||
import { handleCtrlShiftFocus, handleCtrlShiftState, shFrameNavHandler, shNavHandler } from "./emain-util";
|
||||
import { waveWindowMap } from "./emain-window";
|
||||
import { getElectronAppBasePath, isDevVite } from "./platform";
|
||||
|
||||
function computeBgColor(fullConfig: FullConfigType): string {
|
||||
@ -30,16 +31,19 @@ export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabVie
|
||||
}
|
||||
|
||||
export class WaveTabView extends WebContentsView {
|
||||
waveWindowId: string; // this will be set for any tabviews that are initialized. (unset for the hot spare)
|
||||
isActiveTab: boolean;
|
||||
waveWindowId: string; // set when showing in an active window
|
||||
waveTabId: string; // always set, WaveTabViews are unique per tab
|
||||
private _waveTabId: string; // always set, WaveTabViews are unique per tab
|
||||
lastUsedTs: number; // ts milliseconds
|
||||
createdTs: number; // ts milliseconds
|
||||
initPromise: Promise<void>;
|
||||
initResolve: () => void;
|
||||
savedInitOpts: WaveInitOpts;
|
||||
waveReadyPromise: Promise<void>;
|
||||
initResolve: () => void;
|
||||
waveReadyResolve: () => void;
|
||||
isInitialized: boolean = false;
|
||||
isWaveReady: boolean = false;
|
||||
isDestroyed: boolean = false;
|
||||
|
||||
constructor(fullConfig: FullConfigType) {
|
||||
console.log("createBareTabView");
|
||||
@ -55,11 +59,15 @@ export class WaveTabView extends WebContentsView {
|
||||
this.initResolve = resolve;
|
||||
});
|
||||
this.initPromise.then(() => {
|
||||
this.isInitialized = true;
|
||||
console.log("tabview init", Date.now() - this.createdTs + "ms");
|
||||
});
|
||||
this.waveReadyPromise = new Promise((resolve, _) => {
|
||||
this.waveReadyResolve = resolve;
|
||||
});
|
||||
this.waveReadyPromise.then(() => {
|
||||
this.isWaveReady = true;
|
||||
});
|
||||
wcIdToWaveTabMap.set(this.webContents.id, this);
|
||||
if (isDevVite) {
|
||||
this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html}`);
|
||||
@ -69,10 +77,19 @@ export class WaveTabView extends WebContentsView {
|
||||
this.webContents.on("destroyed", () => {
|
||||
wcIdToWaveTabMap.delete(this.webContents.id);
|
||||
removeWaveTabView(this.waveTabId);
|
||||
this.isDestroyed = true;
|
||||
});
|
||||
this.setBackgroundColor(computeBgColor(fullConfig));
|
||||
}
|
||||
|
||||
get waveTabId(): string {
|
||||
return this._waveTabId;
|
||||
}
|
||||
|
||||
set waveTabId(waveTabId: string) {
|
||||
this._waveTabId = waveTabId;
|
||||
}
|
||||
|
||||
positionTabOnScreen(winBounds: Rectangle) {
|
||||
const curBounds = this.getBounds();
|
||||
if (
|
||||
@ -102,14 +119,11 @@ export class WaveTabView extends WebContentsView {
|
||||
|
||||
destroy() {
|
||||
console.log("destroy tab", this.waveTabId);
|
||||
this.webContents.close();
|
||||
removeWaveTabView(this.waveTabId);
|
||||
|
||||
// TODO: circuitous
|
||||
const waveWindow = waveWindowMap.get(this.waveWindowId);
|
||||
if (waveWindow) {
|
||||
waveWindow.allTabViews.delete(this.waveTabId);
|
||||
if (!this.isDestroyed) {
|
||||
this.webContents?.close();
|
||||
}
|
||||
this.isDestroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,6 +143,31 @@ export function getWaveTabView(waveTabId: string): WaveTabView | undefined {
|
||||
return rtn;
|
||||
}
|
||||
|
||||
function tryEvictEntry(waveTabId: string): boolean {
|
||||
const tabView = wcvCache.get(waveTabId);
|
||||
if (!tabView) {
|
||||
return false;
|
||||
}
|
||||
if (tabView.isActiveTab) {
|
||||
return false;
|
||||
}
|
||||
const lastUsedDiff = Date.now() - tabView.lastUsedTs;
|
||||
if (lastUsedDiff < 1000) {
|
||||
return false;
|
||||
}
|
||||
const ww = getWaveWindowById(tabView.waveWindowId);
|
||||
if (!ww) {
|
||||
// this shouldn't happen, but if it does, just destroy the tabview
|
||||
console.log("[error] WaveWindow not found for WaveTabView", tabView.waveTabId);
|
||||
tabView.destroy();
|
||||
return true;
|
||||
} else {
|
||||
// will trigger a destroy on the tabview
|
||||
ww.removeTabView(tabView.waveTabId, false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function checkAndEvictCache(): void {
|
||||
if (wcvCache.size <= MaxCacheSize) {
|
||||
return;
|
||||
@ -141,13 +180,9 @@ function checkAndEvictCache(): void {
|
||||
// Otherwise, sort by lastUsedTs
|
||||
return a.lastUsedTs - b.lastUsedTs;
|
||||
});
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < sorted.length - MaxCacheSize; i++) {
|
||||
if (sorted[i].isActiveTab) {
|
||||
// don't evict WaveTabViews that are currently showing in a window
|
||||
continue;
|
||||
}
|
||||
const tabView = sorted[i];
|
||||
tabView?.destroy();
|
||||
tryEvictEntry(sorted[i].waveTabId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,23 +190,22 @@ export function clearTabCache() {
|
||||
const wcVals = Array.from(wcvCache.values());
|
||||
for (let i = 0; i < wcVals.length; i++) {
|
||||
const tabView = wcVals[i];
|
||||
if (tabView.isActiveTab) {
|
||||
continue;
|
||||
}
|
||||
tabView?.destroy();
|
||||
tryEvictEntry(tabView.waveTabId);
|
||||
}
|
||||
}
|
||||
|
||||
// returns [tabview, initialized]
|
||||
export function getOrCreateWebViewForTab(fullConfig: FullConfigType, tabId: string): [WaveTabView, boolean] {
|
||||
export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> {
|
||||
let tabView = getWaveTabView(tabId);
|
||||
if (tabView) {
|
||||
return [tabView, true];
|
||||
}
|
||||
const fullConfig = await FileService.GetFullConfig();
|
||||
tabView = getSpareTab(fullConfig);
|
||||
tabView.waveWindowId = waveWindowId;
|
||||
tabView.lastUsedTs = Date.now();
|
||||
tabView.waveTabId = tabId;
|
||||
setWaveTabView(tabId, tabView);
|
||||
tabView.waveTabId = tabId;
|
||||
tabView.webContents.on("will-navigate", shNavHandler);
|
||||
tabView.webContents.on("will-frame-navigate", shFrameNavHandler);
|
||||
tabView.webContents.on("did-attach-webview", (event, wc) => {
|
||||
@ -205,11 +239,17 @@ export function getOrCreateWebViewForTab(fullConfig: FullConfigType, tabId: stri
|
||||
}
|
||||
|
||||
export function setWaveTabView(waveTabId: string, wcv: WaveTabView): void {
|
||||
if (waveTabId == null) {
|
||||
return;
|
||||
}
|
||||
wcvCache.set(waveTabId, wcv);
|
||||
checkAndEvictCache();
|
||||
}
|
||||
|
||||
function removeWaveTabView(waveTabId: string): void {
|
||||
if (waveTabId == null) {
|
||||
return;
|
||||
}
|
||||
wcvCache.delete(waveTabId);
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,21 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ClientService, FileService, WindowService, WorkspaceService } from "@/app/store/services";
|
||||
import { ClientService, FileService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { BaseWindow, BaseWindowConstructorOptions, dialog, ipcMain, screen } from "electron";
|
||||
import path from "path";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import { getGlobalIsQuitting, getGlobalIsRelaunching, setWasActive, setWasInFg } from "./emain-activity";
|
||||
import {
|
||||
getGlobalIsQuitting,
|
||||
getGlobalIsRelaunching,
|
||||
setGlobalIsRelaunching,
|
||||
setWasActive,
|
||||
setWasInFg,
|
||||
} from "./emain-activity";
|
||||
import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview";
|
||||
import { delay, ensureBoundsAreVisible } from "./emain-util";
|
||||
import { log } from "./log";
|
||||
import { getElectronAppBasePath, unamePlatform } from "./platform";
|
||||
import { updater } from "./updater";
|
||||
export type WindowOpts = {
|
||||
@ -18,15 +25,45 @@ export type WindowOpts = {
|
||||
export const waveWindowMap = new Map<string, WaveBrowserWindow>(); // waveWindowId -> WaveBrowserWindow
|
||||
export let focusedWaveWindow = null; // on blur we do not set this to null (but on destroy we do)
|
||||
|
||||
let cachedClientId: string = null;
|
||||
|
||||
async function getClientId() {
|
||||
if (cachedClientId != null) {
|
||||
return cachedClientId;
|
||||
}
|
||||
const clientData = await ClientService.GetClientData();
|
||||
cachedClientId = clientData?.oid;
|
||||
return cachedClientId;
|
||||
}
|
||||
|
||||
type WindowActionQueueEntry =
|
||||
| {
|
||||
op: "switchtab";
|
||||
tabId: string;
|
||||
setInBackend: boolean;
|
||||
}
|
||||
| {
|
||||
op: "createtab";
|
||||
pinned: boolean;
|
||||
}
|
||||
| {
|
||||
op: "closetab";
|
||||
tabId: string;
|
||||
}
|
||||
| {
|
||||
op: "switchworkspace";
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export class WaveBrowserWindow extends BaseWindow {
|
||||
waveWindowId: string;
|
||||
workspaceId: string;
|
||||
waveReadyPromise: Promise<void>;
|
||||
allTabViews: Map<string, WaveTabView>;
|
||||
allLoadedTabViews: Map<string, WaveTabView>;
|
||||
activeTabView: WaveTabView;
|
||||
private canClose: boolean;
|
||||
private deleteAllowed: boolean;
|
||||
private tabSwitchQueue: { tabView: WaveTabView; tabInitialized: boolean }[];
|
||||
private actionQueue: WindowActionQueueEntry[];
|
||||
|
||||
constructor(waveWindow: WaveWindow, fullConfig: FullConfigType, opts: WindowOpts) {
|
||||
console.log("create win", waveWindow.oid);
|
||||
@ -105,16 +142,16 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
}
|
||||
|
||||
super(winOpts);
|
||||
this.tabSwitchQueue = [];
|
||||
this.actionQueue = [];
|
||||
this.waveWindowId = waveWindow.oid;
|
||||
this.workspaceId = waveWindow.workspaceid;
|
||||
this.allTabViews = new Map<string, WaveTabView>();
|
||||
this.allLoadedTabViews = new Map<string, WaveTabView>();
|
||||
const winBoundsPoller = setInterval(() => {
|
||||
if (this.isDestroyed()) {
|
||||
clearInterval(winBoundsPoller);
|
||||
return;
|
||||
}
|
||||
if (this.tabSwitchQueue.length > 0) {
|
||||
if (this.actionQueue.length > 0) {
|
||||
return;
|
||||
}
|
||||
this.finalizePositioning();
|
||||
@ -165,7 +202,7 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
}
|
||||
focusedWaveWindow = this;
|
||||
console.log("focus win", this.waveWindowId);
|
||||
fireAndForget(async () => await ClientService.FocusWindow(this.waveWindowId));
|
||||
fireAndForget(() => ClientService.FocusWindow(this.waveWindowId));
|
||||
setWasInFg(true);
|
||||
setWasActive(true);
|
||||
});
|
||||
@ -223,6 +260,11 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
console.log("win quitting or updating", this.waveWindowId);
|
||||
return;
|
||||
}
|
||||
waveWindowMap.delete(this.waveWindowId);
|
||||
if (focusedWaveWindow == this) {
|
||||
focusedWaveWindow = null;
|
||||
}
|
||||
this.removeAllChildViews();
|
||||
if (getGlobalIsRelaunching()) {
|
||||
console.log("win relaunching", this.waveWindowId);
|
||||
this.destroy();
|
||||
@ -235,17 +277,36 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
}
|
||||
if (this.deleteAllowed) {
|
||||
console.log("win removing window from backend DB", this.waveWindowId);
|
||||
fireAndForget(async () => await WindowService.CloseWindow(this.waveWindowId, true));
|
||||
fireAndForget(() => WindowService.CloseWindow(this.waveWindowId, true));
|
||||
}
|
||||
this.destroy();
|
||||
});
|
||||
waveWindowMap.set(waveWindow.oid, this);
|
||||
}
|
||||
|
||||
private removeAllChildViews() {
|
||||
for (const tabView of this.allLoadedTabViews.values()) {
|
||||
if (!this.isDestroyed()) {
|
||||
this.contentView.removeChildView(tabView);
|
||||
}
|
||||
tabView?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async switchWorkspace(workspaceId: string) {
|
||||
console.log("switchWorkspace", workspaceId, this.waveWindowId);
|
||||
if (workspaceId == this.workspaceId) {
|
||||
console.log("switchWorkspace already on this workspace", this.waveWindowId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the workspace is already owned by a window, then we can just call SwitchWorkspace without first prompting the user, since it'll just focus to the other window.
|
||||
const workspaceList = await WorkspaceService.ListWorkspaces();
|
||||
if (!workspaceList.find((wse) => wse.workspaceid === workspaceId)?.windowid) {
|
||||
const curWorkspace = await WorkspaceService.GetWorkspace(this.workspaceId);
|
||||
if (curWorkspace.tabids.length > 1 && (!curWorkspace.name || !curWorkspace.icon)) {
|
||||
if (
|
||||
(curWorkspace.tabids?.length || curWorkspace.pinnedtabids?.length) &&
|
||||
(!curWorkspace.name || !curWorkspace.icon)
|
||||
) {
|
||||
const choice = dialog.showMessageBoxSync(this, {
|
||||
type: "question",
|
||||
buttons: ["Cancel", "Open in New Window", "Yes"],
|
||||
@ -262,66 +323,42 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
if (!newWin) {
|
||||
console.log("error creating new window", this.waveWindowId);
|
||||
}
|
||||
const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), { unamePlatform });
|
||||
const newBwin = await createBrowserWindow(newWin, await FileService.GetFullConfig(), {
|
||||
unamePlatform,
|
||||
});
|
||||
newBwin.show();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, workspaceId);
|
||||
if (!newWs) {
|
||||
return;
|
||||
}
|
||||
console.log("switchWorkspace newWs", newWs);
|
||||
if (this.allTabViews.size) {
|
||||
for (const tab of this.allTabViews.values()) {
|
||||
this.contentView.removeChildView(tab);
|
||||
tab?.destroy();
|
||||
}
|
||||
}
|
||||
console.log("destroyed all tabs", this.waveWindowId);
|
||||
this.workspaceId = workspaceId;
|
||||
this.allTabViews = new Map();
|
||||
await this.setActiveTab(newWs.activetabid, false);
|
||||
await this._queueActionInternal({ op: "switchworkspace", workspaceId });
|
||||
}
|
||||
|
||||
async setActiveTab(tabId: string, setInBackend: boolean) {
|
||||
console.log("setActiveTab", tabId, this.waveWindowId, this.workspaceId, setInBackend);
|
||||
if (setInBackend) {
|
||||
await WorkspaceService.SetActiveTab(this.workspaceId, tabId);
|
||||
}
|
||||
const fullConfig = await FileService.GetFullConfig();
|
||||
const [tabView, tabInitialized] = getOrCreateWebViewForTab(fullConfig, tabId);
|
||||
await this.queueTabSwitch(tabView, tabInitialized);
|
||||
await this._queueActionInternal({ op: "switchtab", tabId, setInBackend });
|
||||
}
|
||||
|
||||
async createTab(pinned = false) {
|
||||
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned);
|
||||
await this.setActiveTab(tabId, false);
|
||||
private async initializeTab(tabView: WaveTabView) {
|
||||
const clientId = await getClientId();
|
||||
await tabView.initPromise;
|
||||
this.contentView.addChildView(tabView);
|
||||
const initOpts = {
|
||||
tabId: tabView.waveTabId,
|
||||
clientId: clientId,
|
||||
windowId: this.waveWindowId,
|
||||
activate: true,
|
||||
};
|
||||
tabView.savedInitOpts = { ...initOpts };
|
||||
tabView.savedInitOpts.activate = false;
|
||||
let startTime = Date.now();
|
||||
console.log("before wave ready, init tab, sending wave-init", tabView.waveTabId);
|
||||
tabView.webContents.send("wave-init", initOpts);
|
||||
await tabView.waveReadyPromise;
|
||||
console.log("wave-ready init time", Date.now() - startTime + "ms");
|
||||
}
|
||||
|
||||
async closeTab(tabId: string) {
|
||||
console.log("closeTab", tabId, this.waveWindowId, this.workspaceId);
|
||||
const tabView = this.allTabViews.get(tabId);
|
||||
if (tabView) {
|
||||
const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true);
|
||||
if (rtn?.closewindow) {
|
||||
this.close();
|
||||
} else if (rtn?.newactivetabid) {
|
||||
await this.setActiveTab(rtn.newactivetabid, false);
|
||||
}
|
||||
this.allTabViews.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
forceClose() {
|
||||
console.log("forceClose window", this.waveWindowId);
|
||||
this.canClose = true;
|
||||
this.deleteAllowed = true;
|
||||
this.close();
|
||||
}
|
||||
|
||||
async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) {
|
||||
const clientData = await ClientService.GetClientData();
|
||||
private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean) {
|
||||
if (this.activeTabView == tabView) {
|
||||
return;
|
||||
}
|
||||
@ -331,29 +368,14 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
oldActiveView.isActiveTab = false;
|
||||
}
|
||||
this.activeTabView = tabView;
|
||||
this.allTabViews.set(tabView.waveTabId, tabView);
|
||||
this.allLoadedTabViews.set(tabView.waveTabId, tabView);
|
||||
if (!tabInitialized) {
|
||||
console.log("initializing a new tab");
|
||||
await tabView.initPromise;
|
||||
this.contentView.addChildView(tabView);
|
||||
const initOpts = {
|
||||
tabId: tabView.waveTabId,
|
||||
clientId: clientData.oid,
|
||||
windowId: this.waveWindowId,
|
||||
activate: true,
|
||||
};
|
||||
tabView.savedInitOpts = { ...initOpts };
|
||||
tabView.savedInitOpts.activate = false;
|
||||
let startTime = Date.now();
|
||||
tabView.webContents.send("wave-init", initOpts);
|
||||
console.log("before wave ready");
|
||||
await tabView.waveReadyPromise;
|
||||
// positionTabOnScreen(tabView, this.getContentBounds());
|
||||
console.log("wave-ready init time", Date.now() - startTime + "ms");
|
||||
// positionTabOffScreen(oldActiveView, this.getContentBounds());
|
||||
await this.repositionTabsSlowly(100);
|
||||
const p1 = this.initializeTab(tabView);
|
||||
const p2 = this.repositionTabsSlowly(100);
|
||||
await Promise.all([p1, p2]);
|
||||
} else {
|
||||
console.log("reusing an existing tab");
|
||||
console.log("reusing an existing tab, calling wave-init", tabView.waveTabId);
|
||||
const p1 = this.repositionTabsSlowly(35);
|
||||
const p2 = tabView.webContents.send("wave-init", tabView.savedInitOpts); // reinit
|
||||
await Promise.all([p1, p2]);
|
||||
@ -362,18 +384,18 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
// something is causing the new tab to lose focus so it requires manual refocusing
|
||||
tabView.webContents.focus();
|
||||
setTimeout(() => {
|
||||
if (this.activeTabView == tabView && !tabView.webContents.isFocused()) {
|
||||
if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) {
|
||||
tabView.webContents.focus();
|
||||
}
|
||||
}, 10);
|
||||
setTimeout(() => {
|
||||
if (this.activeTabView == tabView && !tabView.webContents.isFocused()) {
|
||||
if (tabView.webContents && this.activeTabView == tabView && !tabView.webContents.isFocused()) {
|
||||
tabView.webContents.focus();
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
|
||||
async repositionTabsSlowly(delayMs: number) {
|
||||
private async repositionTabsSlowly(delayMs: number) {
|
||||
const activeTabView = this.activeTabView;
|
||||
const winBounds = this.getContentBounds();
|
||||
if (activeTabView == null) {
|
||||
@ -402,13 +424,13 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
this.finalizePositioning();
|
||||
}
|
||||
|
||||
finalizePositioning() {
|
||||
private finalizePositioning() {
|
||||
if (this.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
const curBounds = this.getContentBounds();
|
||||
this.activeTabView?.positionTabOnScreen(curBounds);
|
||||
for (const tabView of this.allTabViews.values()) {
|
||||
for (const tabView of this.allLoadedTabViews.values()) {
|
||||
if (tabView == this.activeTabView) {
|
||||
continue;
|
||||
}
|
||||
@ -416,32 +438,104 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
}
|
||||
}
|
||||
|
||||
async queueTabSwitch(tabView: WaveTabView, tabInitialized: boolean) {
|
||||
if (this.tabSwitchQueue.length == 2) {
|
||||
this.tabSwitchQueue[1] = { tabView, tabInitialized };
|
||||
async queueCreateTab(pinned = false) {
|
||||
await this._queueActionInternal({ op: "createtab", pinned });
|
||||
}
|
||||
|
||||
async queueCloseTab(tabId: string) {
|
||||
await this._queueActionInternal({ op: "closetab", tabId });
|
||||
}
|
||||
|
||||
private async _queueActionInternal(entry: WindowActionQueueEntry) {
|
||||
if (this.actionQueue.length >= 2) {
|
||||
this.actionQueue[1] = entry;
|
||||
return;
|
||||
}
|
||||
this.tabSwitchQueue.push({ tabView, tabInitialized });
|
||||
if (this.tabSwitchQueue.length == 1) {
|
||||
await this.processTabSwitchQueue();
|
||||
const wasEmpty = this.actionQueue.length === 0;
|
||||
this.actionQueue.push(entry);
|
||||
if (wasEmpty) {
|
||||
await this.processActionQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async processTabSwitchQueue() {
|
||||
if (this.tabSwitchQueue.length == 0) {
|
||||
this.tabSwitchQueue = [];
|
||||
return;
|
||||
private removeTabViewLater(tabId: string, delayMs: number) {
|
||||
setTimeout(() => {
|
||||
this.removeTabView(tabId, false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// the queue and this function are used to serialize operations that update the window contents view
|
||||
// processActionQueue will replace [1] if it is already set
|
||||
// we don't mess with [0] because it is "in process"
|
||||
// we replace [1] because there is no point to run an action that is going to be overwritten
|
||||
private async processActionQueue() {
|
||||
while (this.actionQueue.length > 0) {
|
||||
try {
|
||||
const { tabView, tabInitialized } = this.tabSwitchQueue[0];
|
||||
const entry = this.actionQueue[0];
|
||||
let tabId: string = null;
|
||||
// have to use "===" here to get the typechecker to work :/
|
||||
switch (entry.op) {
|
||||
case "createtab":
|
||||
tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, entry.pinned);
|
||||
break;
|
||||
case "switchtab":
|
||||
tabId = entry.tabId;
|
||||
if (this.activeTabView?.waveTabId == tabId) {
|
||||
continue;
|
||||
}
|
||||
if (entry.setInBackend) {
|
||||
await WorkspaceService.SetActiveTab(this.workspaceId, tabId);
|
||||
}
|
||||
break;
|
||||
case "closetab":
|
||||
tabId = entry.tabId;
|
||||
const rtn = await WorkspaceService.CloseTab(this.workspaceId, tabId, true);
|
||||
if (rtn == null) {
|
||||
console.log(
|
||||
"[error] closeTab: no return value",
|
||||
tabId,
|
||||
this.workspaceId,
|
||||
this.waveWindowId
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.removeTabViewLater(tabId, 1000);
|
||||
if (rtn.closewindow) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (!rtn.newactivetabid) {
|
||||
return;
|
||||
}
|
||||
tabId = rtn.newactivetabid;
|
||||
break;
|
||||
case "switchworkspace":
|
||||
const newWs = await WindowService.SwitchWorkspace(this.waveWindowId, entry.workspaceId);
|
||||
if (!newWs) {
|
||||
return;
|
||||
}
|
||||
console.log("processActionQueue switchworkspace newWs", newWs);
|
||||
this.removeAllChildViews();
|
||||
console.log("destroyed all tabs", this.waveWindowId);
|
||||
this.workspaceId = entry.workspaceId;
|
||||
this.allLoadedTabViews = new Map();
|
||||
tabId = newWs.activetabid;
|
||||
break;
|
||||
}
|
||||
if (tabId == null) {
|
||||
return;
|
||||
}
|
||||
const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId);
|
||||
await this.setTabViewIntoWindow(tabView, tabInitialized);
|
||||
} catch (e) {
|
||||
console.log("error caught in processActionQueue", e);
|
||||
} finally {
|
||||
this.tabSwitchQueue.shift();
|
||||
await this.processTabSwitchQueue();
|
||||
this.actionQueue.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async mainResizeHandler(_: any) {
|
||||
private async mainResizeHandler(_: any) {
|
||||
if (this == null || this.isDestroyed() || this.fullScreen) {
|
||||
return;
|
||||
}
|
||||
@ -457,22 +551,32 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
}
|
||||
}
|
||||
|
||||
removeTabView(tabId: string, force: boolean) {
|
||||
if (!force && this.activeTabView?.waveTabId == tabId) {
|
||||
console.log("cannot remove active tab", tabId, this.waveWindowId);
|
||||
return;
|
||||
}
|
||||
const tabView = this.allLoadedTabViews.get(tabId);
|
||||
if (tabView == null) {
|
||||
console.log("removeTabView -- tabView not found", tabId, this.waveWindowId);
|
||||
// the tab was never loaded, so just return
|
||||
return;
|
||||
}
|
||||
this.contentView.removeChildView(tabView);
|
||||
this.allLoadedTabViews.delete(tabId);
|
||||
tabView.destroy();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
console.log("destroy win", this.waveWindowId);
|
||||
for (const tabView of this.allTabViews.values()) {
|
||||
tabView?.destroy();
|
||||
}
|
||||
waveWindowMap.delete(this.waveWindowId);
|
||||
if (focusedWaveWindow == this) {
|
||||
focusedWaveWindow = null;
|
||||
}
|
||||
this.deleteAllowed = true;
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow {
|
||||
for (const ww of waveWindowMap.values()) {
|
||||
if (ww.allTabViews.has(tabId)) {
|
||||
if (ww.allLoadedTabViews.has(tabId)) {
|
||||
return ww;
|
||||
}
|
||||
}
|
||||
@ -537,34 +641,121 @@ ipcMain.on("set-active-tab", async (event, tabId) => {
|
||||
ipcMain.on("create-tab", async (event, opts) => {
|
||||
const senderWc = event.sender;
|
||||
const ww = getWaveWindowByWebContentsId(senderWc.id);
|
||||
if (!ww) {
|
||||
if (ww != null) {
|
||||
await ww.queueCreateTab();
|
||||
}
|
||||
event.returnValue = true;
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.on("close-tab", async (event, workspaceId, tabId) => {
|
||||
const ww = getWaveWindowByWorkspaceId(workspaceId);
|
||||
if (ww == null) {
|
||||
console.log(`close-tab: no window found for workspace ws=${workspaceId} tab=${tabId}`);
|
||||
return;
|
||||
}
|
||||
await ww.createTab();
|
||||
await ww.queueCloseTab(tabId);
|
||||
event.returnValue = true;
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.on("close-tab", async (event, tabId) => {
|
||||
const ww = getWaveWindowByTabId(tabId);
|
||||
await ww.closeTab(tabId);
|
||||
event.returnValue = true;
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.on("switch-workspace", async (event, workspaceId) => {
|
||||
ipcMain.on("switch-workspace", (event, workspaceId) => {
|
||||
fireAndForget(async () => {
|
||||
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
||||
console.log("switch-workspace", workspaceId, ww?.waveWindowId);
|
||||
await ww?.switchWorkspace(workspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on("delete-workspace", async (event, workspaceId) => {
|
||||
export async function createWorkspace(window: WaveBrowserWindow) {
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
const newWsId = await WorkspaceService.CreateWorkspace();
|
||||
if (newWsId) {
|
||||
await window.switchWorkspace(newWsId);
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on("create-workspace", (event) => {
|
||||
fireAndForget(async () => {
|
||||
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
||||
console.log("create-workspace", ww?.waveWindowId);
|
||||
await createWorkspace(ww);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on("delete-workspace", (event, workspaceId) => {
|
||||
fireAndForget(async () => {
|
||||
const ww = getWaveWindowByWebContentsId(event.sender.id);
|
||||
console.log("delete-workspace", workspaceId, ww?.waveWindowId);
|
||||
await WorkspaceService.DeleteWorkspace(workspaceId);
|
||||
console.log("delete-workspace done", workspaceId, ww?.waveWindowId);
|
||||
if (ww?.workspaceId == workspaceId) {
|
||||
console.log("delete-workspace closing window", workspaceId, ww?.waveWindowId);
|
||||
ww.forceClose();
|
||||
ww.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export async function createNewWaveWindow() {
|
||||
log("createNewWaveWindow");
|
||||
const clientData = await ClientService.GetClientData();
|
||||
const fullConfig = await FileService.GetFullConfig();
|
||||
let recreatedWindow = false;
|
||||
const allWindows = getAllWaveWindows();
|
||||
if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {
|
||||
console.log("no windows, but clientData has windowids, recreating first window");
|
||||
// reopen the first window
|
||||
const existingWindowId = clientData.windowids[0];
|
||||
const existingWindowData = (await ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
|
||||
if (existingWindowData != null) {
|
||||
const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform });
|
||||
await win.waveReadyPromise;
|
||||
win.show();
|
||||
recreatedWindow = true;
|
||||
}
|
||||
}
|
||||
if (recreatedWindow) {
|
||||
console.log("recreated window, returning");
|
||||
return;
|
||||
}
|
||||
console.log("creating new window");
|
||||
const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform });
|
||||
await newBrowserWindow.waveReadyPromise;
|
||||
newBrowserWindow.show();
|
||||
}
|
||||
|
||||
export async function relaunchBrowserWindows() {
|
||||
console.log("relaunchBrowserWindows");
|
||||
setGlobalIsRelaunching(true);
|
||||
const windows = getAllWaveWindows();
|
||||
if (windows.length > 0) {
|
||||
for (const window of windows) {
|
||||
console.log("relaunch -- closing window", window.waveWindowId);
|
||||
window.close();
|
||||
}
|
||||
await delay(1200);
|
||||
}
|
||||
setGlobalIsRelaunching(false);
|
||||
|
||||
const clientData = await ClientService.GetClientData();
|
||||
const fullConfig = await FileService.GetFullConfig();
|
||||
const wins: WaveBrowserWindow[] = [];
|
||||
for (const windowId of clientData.windowids.slice().reverse()) {
|
||||
const windowData: WaveWindow = await WindowService.GetWindow(windowId);
|
||||
if (windowData == null) {
|
||||
console.log("relaunch -- window data not found, closing window", windowId);
|
||||
await WindowService.CloseWindow(windowId, true);
|
||||
continue;
|
||||
}
|
||||
console.log("relaunch -- creating window", windowId, windowData);
|
||||
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
|
||||
wins.push(win);
|
||||
}
|
||||
for (const win of wins) {
|
||||
await win.waveReadyPromise;
|
||||
console.log("show window", win.waveWindowId);
|
||||
win.show();
|
||||
}
|
||||
}
|
||||
|
@ -55,6 +55,16 @@ export class ElectronWshClientType extends WshClient {
|
||||
}
|
||||
ww.focus();
|
||||
}
|
||||
|
||||
// async handle_workspaceupdate(rh: RpcResponseHelper) {
|
||||
// console.log("workspaceupdate");
|
||||
// fireAndForget(async () => {
|
||||
// console.log("workspace menu clicked");
|
||||
// const updatedWorkspaceMenu = await getWorkspaceMenu();
|
||||
// const workspaceMenu = Menu.getApplicationMenu().getMenuItemById("workspace-menu");
|
||||
// workspaceMenu.submenu = Menu.buildFromTemplate(updatedWorkspaceMenu);
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
export let ElectronWshClient: ElectronWshClientType;
|
||||
|
153
emain/emain.ts
153
emain/emain.ts
@ -10,8 +10,6 @@ import * as path from "path";
|
||||
import { PNG } from "pngjs";
|
||||
import { sprintf } from "sprintf-js";
|
||||
import { Readable } from "stream";
|
||||
import * as util from "util";
|
||||
import winston from "winston";
|
||||
import * as services from "../frontend/app/store/services";
|
||||
import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil";
|
||||
import { getWebServerEndpoint } from "../frontend/util/endpoints";
|
||||
@ -25,7 +23,6 @@ import {
|
||||
getGlobalIsRelaunching,
|
||||
setForceQuit,
|
||||
setGlobalIsQuitting,
|
||||
setGlobalIsRelaunching,
|
||||
setGlobalIsStarting,
|
||||
setWasActive,
|
||||
setWasInFg,
|
||||
@ -35,16 +32,19 @@ import { handleCtrlShiftState } from "./emain-util";
|
||||
import { getIsWaveSrvDead, getWaveSrvProc, getWaveSrvReady, getWaveVersion, runWaveSrv } from "./emain-wavesrv";
|
||||
import {
|
||||
createBrowserWindow,
|
||||
createNewWaveWindow,
|
||||
focusedWaveWindow,
|
||||
getAllWaveWindows,
|
||||
getWaveWindowById,
|
||||
getWaveWindowByWebContentsId,
|
||||
getWaveWindowByWorkspaceId,
|
||||
relaunchBrowserWindows,
|
||||
WaveBrowserWindow,
|
||||
} from "./emain-window";
|
||||
import { ElectronWshClient, initElectronWshClient } from "./emain-wsh";
|
||||
import { getLaunchSettings } from "./launchsettings";
|
||||
import { getAppMenu } from "./menu";
|
||||
import { log } from "./log";
|
||||
import { makeAppMenu } from "./menu";
|
||||
import {
|
||||
getElectronAppBasePath,
|
||||
getElectronAppUnpackedBasePath,
|
||||
@ -65,30 +65,7 @@ electron.nativeTheme.themeSource = "dark";
|
||||
|
||||
let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused)
|
||||
let webviewKeys: string[] = []; // the keys to trap when webview has focus
|
||||
const oldConsoleLog = console.log;
|
||||
|
||||
const loggerTransports: winston.transport[] = [
|
||||
new winston.transports.File({ filename: path.join(waveDataDir, "waveapp.log"), level: "info" }),
|
||||
];
|
||||
if (isDev) {
|
||||
loggerTransports.push(new winston.transports.Console());
|
||||
}
|
||||
const loggerConfig = {
|
||||
level: "info",
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
|
||||
winston.format.printf((info) => `${info.timestamp} ${info.message}`)
|
||||
),
|
||||
transports: loggerTransports,
|
||||
};
|
||||
const logger = winston.createLogger(loggerConfig);
|
||||
function log(...msg: any[]) {
|
||||
try {
|
||||
logger.info(util.format(...msg));
|
||||
} catch (e) {
|
||||
oldConsoleLog(...msg);
|
||||
}
|
||||
}
|
||||
console.log = log;
|
||||
console.log(
|
||||
sprintf(
|
||||
@ -368,42 +345,13 @@ electron.ipcMain.on("quicklook", (event, filePath: string) => {
|
||||
|
||||
electron.ipcMain.on("open-native-path", (event, filePath: string) => {
|
||||
console.log("open-native-path", filePath);
|
||||
fireAndForget(async () =>
|
||||
fireAndForget(() =>
|
||||
electron.shell.openPath(filePath).then((excuse) => {
|
||||
if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
async function createNewWaveWindow(): Promise<void> {
|
||||
log("createNewWaveWindow");
|
||||
const clientData = await services.ClientService.GetClientData();
|
||||
const fullConfig = await services.FileService.GetFullConfig();
|
||||
let recreatedWindow = false;
|
||||
const allWindows = getAllWaveWindows();
|
||||
if (allWindows.length === 0 && clientData?.windowids?.length >= 1) {
|
||||
console.log("no windows, but clientData has windowids, recreating first window");
|
||||
// reopen the first window
|
||||
const existingWindowId = clientData.windowids[0];
|
||||
const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
|
||||
if (existingWindowData != null) {
|
||||
const win = await createBrowserWindow(existingWindowData, fullConfig, { unamePlatform });
|
||||
await win.waveReadyPromise;
|
||||
win.show();
|
||||
recreatedWindow = true;
|
||||
}
|
||||
}
|
||||
if (recreatedWindow) {
|
||||
console.log("recreated window, returning");
|
||||
return;
|
||||
}
|
||||
console.log("creating new window");
|
||||
const newBrowserWindow = await createBrowserWindow(null, fullConfig, { unamePlatform });
|
||||
await newBrowserWindow.waveReadyPromise;
|
||||
newBrowserWindow.show();
|
||||
}
|
||||
|
||||
// Here's where init is not getting fired
|
||||
electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-ready") => {
|
||||
const tabView = getWaveTabViewByWebContentsId(event.sender.id);
|
||||
if (tabView == null || tabView.initResolve == null) {
|
||||
@ -412,10 +360,9 @@ electron.ipcMain.on("set-window-init-status", (event, status: "ready" | "wave-re
|
||||
if (status === "ready") {
|
||||
tabView.initResolve();
|
||||
if (tabView.savedInitOpts) {
|
||||
console.log("savedInitOpts");
|
||||
// this handles the "reload" case. we'll re-send the init opts to the frontend
|
||||
console.log("savedInitOpts calling wave-init", tabView.waveTabId);
|
||||
tabView.webContents.send("wave-init", tabView.savedInitOpts);
|
||||
} else {
|
||||
console.log("no-savedInitOpts");
|
||||
}
|
||||
} else if (status === "wave-ready") {
|
||||
tabView.waveReadyResolve();
|
||||
@ -479,17 +426,6 @@ function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string
|
||||
|
||||
electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow));
|
||||
|
||||
electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => {
|
||||
if (menuDefArr?.length === 0) {
|
||||
return;
|
||||
}
|
||||
const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu();
|
||||
// const { x, y } = electron.screen.getCursorScreenPoint();
|
||||
// const windowPos = window.getPosition();
|
||||
menu.popup();
|
||||
event.returnValue = true;
|
||||
});
|
||||
|
||||
// we try to set the primary display as index [0]
|
||||
function getActivityDisplays(): ActivityDisplayType[] {
|
||||
const displays = electron.screen.getAllDisplays();
|
||||
@ -541,40 +477,6 @@ function runActiveTimer() {
|
||||
setTimeout(runActiveTimer, 60000);
|
||||
}
|
||||
|
||||
function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electron.Menu {
|
||||
const menuItems: electron.MenuItem[] = [];
|
||||
for (const menuDef of menuDefArr) {
|
||||
const menuItemTemplate: electron.MenuItemConstructorOptions = {
|
||||
role: menuDef.role as any,
|
||||
label: menuDef.label,
|
||||
type: menuDef.type,
|
||||
click: (_, window) => {
|
||||
const ww = window as WaveBrowserWindow;
|
||||
ww?.activeTabView?.webContents?.send("contextmenu-click", menuDef.id);
|
||||
},
|
||||
checked: menuDef.checked,
|
||||
};
|
||||
if (menuDef.submenu != null) {
|
||||
menuItemTemplate.submenu = convertMenuDefArrToMenu(menuDef.submenu);
|
||||
}
|
||||
const menuItem = new electron.MenuItem(menuItemTemplate);
|
||||
menuItems.push(menuItem);
|
||||
}
|
||||
return electron.Menu.buildFromTemplate(menuItems);
|
||||
}
|
||||
|
||||
function instantiateAppMenu(): electron.Menu {
|
||||
return getAppMenu({
|
||||
createNewWaveWindow,
|
||||
relaunchBrowserWindows,
|
||||
});
|
||||
}
|
||||
|
||||
function makeAppMenu() {
|
||||
const menu = instantiateAppMenu();
|
||||
electron.Menu.setApplicationMenu(menu);
|
||||
}
|
||||
|
||||
function hideWindowWithCatch(window: WaveBrowserWindow) {
|
||||
if (window == null) {
|
||||
return;
|
||||
@ -644,6 +546,14 @@ process.on("uncaughtException", (error) => {
|
||||
if (caughtException) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the error is related to QUIC protocol, if so, ignore (can happen with the updater)
|
||||
if (error?.message?.includes("net::ERR_QUIC_PROTOCOL_ERROR")) {
|
||||
console.log("Ignoring QUIC protocol error:", error.message);
|
||||
console.log("Stack Trace:", error.stack);
|
||||
return;
|
||||
}
|
||||
|
||||
caughtException = true;
|
||||
console.log("Uncaught Exception, shutting down: ", error);
|
||||
console.log("Stack Trace:", error.stack);
|
||||
@ -651,37 +561,6 @@ process.on("uncaughtException", (error) => {
|
||||
electronApp.quit();
|
||||
});
|
||||
|
||||
async function relaunchBrowserWindows(): Promise<void> {
|
||||
console.log("relaunchBrowserWindows");
|
||||
setGlobalIsRelaunching(true);
|
||||
const windows = getAllWaveWindows();
|
||||
for (const window of windows) {
|
||||
console.log("relaunch -- closing window", window.waveWindowId);
|
||||
window.close();
|
||||
}
|
||||
setGlobalIsRelaunching(false);
|
||||
|
||||
const clientData = await services.ClientService.GetClientData();
|
||||
const fullConfig = await services.FileService.GetFullConfig();
|
||||
const wins: WaveBrowserWindow[] = [];
|
||||
for (const windowId of clientData.windowids.slice().reverse()) {
|
||||
const windowData: WaveWindow = await services.WindowService.GetWindow(windowId);
|
||||
if (windowData == null) {
|
||||
console.log("relaunch -- window data not found, closing window", windowId);
|
||||
await services.WindowService.CloseWindow(windowId, true);
|
||||
continue;
|
||||
}
|
||||
console.log("relaunch -- creating window", windowId, windowData);
|
||||
const win = await createBrowserWindow(windowData, fullConfig, { unamePlatform });
|
||||
wins.push(win);
|
||||
}
|
||||
for (const win of wins) {
|
||||
await win.waveReadyPromise;
|
||||
console.log("show window", win.waveWindowId);
|
||||
win.show();
|
||||
}
|
||||
}
|
||||
|
||||
async function appMain() {
|
||||
// Set disableHardwareAcceleration as early as possible, if required.
|
||||
const launchSettings = getLaunchSettings();
|
||||
@ -696,7 +575,6 @@ async function appMain() {
|
||||
electronApp.quit();
|
||||
return;
|
||||
}
|
||||
makeAppMenu();
|
||||
try {
|
||||
await runWaveSrv(handleWSEvent);
|
||||
} catch (e) {
|
||||
@ -717,6 +595,7 @@ async function appMain() {
|
||||
} catch (e) {
|
||||
console.log("error initializing wshrpc", e);
|
||||
}
|
||||
makeAppMenu();
|
||||
await configureAutoUpdater();
|
||||
setGlobalIsStarting(false);
|
||||
if (fullConfig?.settings?.["window:maxtabcachesize"] != null) {
|
||||
|
31
emain/log.ts
Normal file
31
emain/log.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import path from "path";
|
||||
import { format } from "util";
|
||||
import winston from "winston";
|
||||
import { getWaveDataDir, isDev } from "./platform";
|
||||
|
||||
const oldConsoleLog = console.log;
|
||||
|
||||
const loggerTransports: winston.transport[] = [
|
||||
new winston.transports.File({ filename: path.join(getWaveDataDir(), "waveapp.log"), level: "info" }),
|
||||
];
|
||||
if (isDev) {
|
||||
loggerTransports.push(new winston.transports.Console());
|
||||
}
|
||||
const loggerConfig = {
|
||||
level: "info",
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
|
||||
winston.format.printf((info) => `${info.timestamp} ${info.message}`)
|
||||
),
|
||||
transports: loggerTransports,
|
||||
};
|
||||
const logger = winston.createLogger(loggerConfig);
|
||||
function log(...msg: any[]) {
|
||||
try {
|
||||
logger.info(format(...msg));
|
||||
} catch (e) {
|
||||
oldConsoleLog(...msg);
|
||||
}
|
||||
}
|
||||
|
||||
export { log };
|
137
emain/menu.ts
137
emain/menu.ts
@ -1,10 +1,20 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import * as electron from "electron";
|
||||
import { fireAndForget } from "../frontend/util/util";
|
||||
import { clearTabCache } from "./emain-tabview";
|
||||
import { focusedWaveWindow, WaveBrowserWindow } from "./emain-window";
|
||||
import {
|
||||
createNewWaveWindow,
|
||||
createWorkspace,
|
||||
focusedWaveWindow,
|
||||
getWaveWindowByWorkspaceId,
|
||||
relaunchBrowserWindows,
|
||||
WaveBrowserWindow,
|
||||
} from "./emain-window";
|
||||
import { ElectronWshClient } from "./emain-wsh";
|
||||
import { unamePlatform } from "./platform";
|
||||
import { updater } from "./updater";
|
||||
|
||||
@ -27,7 +37,45 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
async function getWorkspaceMenu(ww?: WaveBrowserWindow): Promise<Electron.MenuItemConstructorOptions[]> {
|
||||
const workspaceList = await RpcApi.WorkspaceListCommand(ElectronWshClient);
|
||||
console.log("workspaceList:", workspaceList);
|
||||
const workspaceMenu: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: "Create New Workspace",
|
||||
click: (_, window) => {
|
||||
fireAndForget(() => createWorkspace((window as WaveBrowserWindow) ?? ww));
|
||||
},
|
||||
},
|
||||
];
|
||||
function getWorkspaceSwitchAccelerator(i: number): string {
|
||||
if (i < 10) {
|
||||
if (i == 9) {
|
||||
i = 0;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
return unamePlatform == "darwin" ? `Command+Control+${i}` : `Alt+Control+${i}`;
|
||||
}
|
||||
}
|
||||
workspaceList?.length &&
|
||||
workspaceMenu.push(
|
||||
{ type: "separator" },
|
||||
...workspaceList.map<Electron.MenuItemConstructorOptions>((workspace, i) => {
|
||||
return {
|
||||
label: `Switch to ${workspace.workspacedata.name}`,
|
||||
click: (_, window) => {
|
||||
((window as WaveBrowserWindow) ?? ww)?.switchWorkspace(workspace.workspacedata.oid);
|
||||
},
|
||||
accelerator: getWorkspaceSwitchAccelerator(i),
|
||||
};
|
||||
})
|
||||
);
|
||||
return workspaceMenu;
|
||||
}
|
||||
|
||||
async function getAppMenu(callbacks: AppMenuCallbacks, workspaceId?: string): Promise<Electron.Menu> {
|
||||
const ww = workspaceId && getWaveWindowByWorkspaceId(workspaceId);
|
||||
const fileMenu: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: "New Window",
|
||||
@ -46,7 +94,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
{
|
||||
label: "About Wave Terminal",
|
||||
click: (_, window) => {
|
||||
getWindowWebContents(window)?.send("menu-item-about");
|
||||
getWindowWebContents(window ?? ww)?.send("menu-item-about");
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -124,7 +172,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
label: "Reload Tab",
|
||||
accelerator: "Shift+CommandOrControl+R",
|
||||
click: (_, window) => {
|
||||
getWindowWebContents(window)?.reloadIgnoringCache();
|
||||
getWindowWebContents(window ?? ww)?.reloadIgnoringCache();
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -143,7 +191,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
label: "Toggle DevTools",
|
||||
accelerator: devToolsAccel,
|
||||
click: (_, window) => {
|
||||
let wc = getWindowWebContents(window);
|
||||
let wc = getWindowWebContents(window ?? ww);
|
||||
wc?.toggleDevTools();
|
||||
},
|
||||
},
|
||||
@ -154,14 +202,14 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
label: "Reset Zoom",
|
||||
accelerator: "CommandOrControl+0",
|
||||
click: (_, window) => {
|
||||
getWindowWebContents(window)?.setZoomFactor(1);
|
||||
getWindowWebContents(window ?? ww)?.setZoomFactor(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Zoom In",
|
||||
accelerator: "CommandOrControl+=",
|
||||
click: (_, window) => {
|
||||
const wc = getWindowWebContents(window);
|
||||
const wc = getWindowWebContents(window ?? ww);
|
||||
if (wc == null) {
|
||||
return;
|
||||
}
|
||||
@ -175,7 +223,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
label: "Zoom In (hidden)",
|
||||
accelerator: "CommandOrControl+Shift+=",
|
||||
click: (_, window) => {
|
||||
const wc = getWindowWebContents(window);
|
||||
const wc = getWindowWebContents(window ?? ww);
|
||||
if (wc == null) {
|
||||
return;
|
||||
}
|
||||
@ -191,7 +239,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
label: "Zoom Out",
|
||||
accelerator: "CommandOrControl+-",
|
||||
click: (_, window) => {
|
||||
const wc = getWindowWebContents(window);
|
||||
const wc = getWindowWebContents(window ?? ww);
|
||||
if (wc == null) {
|
||||
return;
|
||||
}
|
||||
@ -205,7 +253,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
label: "Zoom Out (hidden)",
|
||||
accelerator: "CommandOrControl+Shift+-",
|
||||
click: (_, window) => {
|
||||
const wc = getWindowWebContents(window);
|
||||
const wc = getWindowWebContents(window ?? ww);
|
||||
if (wc == null) {
|
||||
return;
|
||||
}
|
||||
@ -224,6 +272,9 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
role: "togglefullscreen",
|
||||
},
|
||||
];
|
||||
|
||||
const workspaceMenu = await getWorkspaceMenu();
|
||||
|
||||
const windowMenu: Electron.MenuItemConstructorOptions[] = [
|
||||
{ role: "minimize", accelerator: "" },
|
||||
{ role: "zoom" },
|
||||
@ -249,6 +300,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
role: "viewMenu",
|
||||
submenu: viewMenu,
|
||||
},
|
||||
{
|
||||
label: "Workspace",
|
||||
id: "workspace-menu",
|
||||
submenu: workspaceMenu,
|
||||
},
|
||||
{
|
||||
role: "windowMenu",
|
||||
submenu: windowMenu,
|
||||
@ -257,4 +313,65 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
|
||||
return electron.Menu.buildFromTemplate(menuTemplate);
|
||||
}
|
||||
|
||||
export function instantiateAppMenu(workspaceId?: string): Promise<electron.Menu> {
|
||||
return getAppMenu(
|
||||
{
|
||||
createNewWaveWindow,
|
||||
relaunchBrowserWindows,
|
||||
},
|
||||
workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
export function makeAppMenu() {
|
||||
fireAndForget(async () => {
|
||||
const menu = await instantiateAppMenu();
|
||||
electron.Menu.setApplicationMenu(menu);
|
||||
});
|
||||
}
|
||||
|
||||
waveEventSubscribe({
|
||||
eventType: "workspace:update",
|
||||
handler: makeAppMenu,
|
||||
});
|
||||
|
||||
function convertMenuDefArrToMenu(workspaceId: string, menuDefArr: ElectronContextMenuItem[]): electron.Menu {
|
||||
const menuItems: electron.MenuItem[] = [];
|
||||
for (const menuDef of menuDefArr) {
|
||||
const menuItemTemplate: electron.MenuItemConstructorOptions = {
|
||||
role: menuDef.role as any,
|
||||
label: menuDef.label,
|
||||
type: menuDef.type,
|
||||
click: (_, window) => {
|
||||
const ww = (window as WaveBrowserWindow) ?? getWaveWindowByWorkspaceId(workspaceId);
|
||||
if (!ww) {
|
||||
console.error("invalid window for context menu click handler:", ww, window, workspaceId);
|
||||
return;
|
||||
}
|
||||
ww?.activeTabView?.webContents?.send("contextmenu-click", menuDef.id);
|
||||
},
|
||||
checked: menuDef.checked,
|
||||
};
|
||||
if (menuDef.submenu != null) {
|
||||
menuItemTemplate.submenu = convertMenuDefArrToMenu(workspaceId, menuDef.submenu);
|
||||
}
|
||||
const menuItem = new electron.MenuItem(menuItemTemplate);
|
||||
menuItems.push(menuItem);
|
||||
}
|
||||
return electron.Menu.buildFromTemplate(menuItems);
|
||||
}
|
||||
|
||||
electron.ipcMain.on("contextmenu-show", (event, workspaceId: string, menuDefArr?: ElectronContextMenuItem[]) => {
|
||||
if (menuDefArr?.length === 0) {
|
||||
return;
|
||||
}
|
||||
fireAndForget(async () => {
|
||||
const menu = menuDefArr
|
||||
? convertMenuDefArrToMenu(workspaceId, menuDefArr)
|
||||
: await instantiateAppMenu(workspaceId);
|
||||
menu.popup();
|
||||
});
|
||||
event.returnValue = true;
|
||||
});
|
||||
|
||||
export { getAppMenu };
|
||||
|
@ -16,7 +16,7 @@ contextBridge.exposeInMainWorld("api", {
|
||||
getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"),
|
||||
getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"),
|
||||
openNewWindow: () => ipcRenderer.send("open-new-window"),
|
||||
showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position),
|
||||
showContextMenu: (workspaceId, menu) => ipcRenderer.send("contextmenu-show", workspaceId, menu),
|
||||
onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)),
|
||||
downloadFile: (filePath) => ipcRenderer.send("download", { filePath }),
|
||||
openExternal: (url) => {
|
||||
@ -40,11 +40,12 @@ contextBridge.exposeInMainWorld("api", {
|
||||
registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys),
|
||||
onControlShiftStateUpdate: (callback) =>
|
||||
ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)),
|
||||
createWorkspace: () => ipcRenderer.send("create-workspace"),
|
||||
switchWorkspace: (workspaceId) => ipcRenderer.send("switch-workspace", workspaceId),
|
||||
deleteWorkspace: (workspaceId) => ipcRenderer.send("delete-workspace", workspaceId),
|
||||
setActiveTab: (tabId) => ipcRenderer.send("set-active-tab", tabId),
|
||||
createTab: () => ipcRenderer.send("create-tab"),
|
||||
closeTab: (tabId) => ipcRenderer.send("close-tab", tabId),
|
||||
closeTab: (workspaceId, tabId) => ipcRenderer.send("close-tab", workspaceId, tabId),
|
||||
setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status),
|
||||
onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)),
|
||||
sendLog: (log) => ipcRenderer.send("fe-log", log),
|
||||
|
@ -96,7 +96,7 @@ export class Updater {
|
||||
body: "A new version of Wave Terminal is ready to install.",
|
||||
});
|
||||
updateNotification.on("click", () => {
|
||||
fireAndForget(() => this.promptToInstallUpdate());
|
||||
fireAndForget(this.promptToInstallUpdate.bind(this));
|
||||
});
|
||||
updateNotification.show();
|
||||
});
|
||||
@ -112,7 +112,7 @@ export class Updater {
|
||||
private set status(value: UpdaterStatus) {
|
||||
this._status = value;
|
||||
getAllWaveWindows().forEach((window) => {
|
||||
const allTabs = Array.from(window.allTabViews.values());
|
||||
const allTabs = Array.from(window.allLoadedTabViews.values());
|
||||
allTabs.forEach((tab) => {
|
||||
tab.webContents.send("app-update-status", value);
|
||||
});
|
||||
@ -188,7 +188,7 @@ export class Updater {
|
||||
if (allWindows.length > 0) {
|
||||
await dialog.showMessageBox(focusedWaveWindow ?? allWindows[0], dialogOpts).then(({ response }) => {
|
||||
if (response === 0) {
|
||||
fireAndForget(async () => this.installUpdate());
|
||||
fireAndForget(this.installUpdate.bind(this));
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -210,7 +210,7 @@ export function getResolvedUpdateChannel(): string {
|
||||
return isDev() ? "dev" : (autoUpdater.channel ?? "latest");
|
||||
}
|
||||
|
||||
ipcMain.on("install-app-update", () => fireAndForget(() => updater?.promptToInstallUpdate()));
|
||||
ipcMain.on("install-app-update", () => fireAndForget(updater?.promptToInstallUpdate.bind(updater)));
|
||||
ipcMain.on("get-app-update-status", (event) => {
|
||||
event.returnValue = updater?.status;
|
||||
});
|
||||
|
@ -166,6 +166,7 @@
|
||||
flex: 1 2 auto;
|
||||
overflow: hidden;
|
||||
padding-right: 4px;
|
||||
@include mixins.ellipsis()
|
||||
}
|
||||
|
||||
.connecting-svg {
|
||||
|
@ -185,8 +185,8 @@ const BlockFrame_Header = ({
|
||||
const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);
|
||||
const dragHandleRef = preview ? null : nodeModel.dragHandleRef;
|
||||
const connName = blockData?.meta?.connection;
|
||||
const allSettings = jotai.useAtomValue(atoms.fullConfigAtom);
|
||||
const wshEnabled = allSettings?.connections?.[connName]?.["conn:wshenabled"] ?? true;
|
||||
const connStatus = util.useAtomValueSafe(getConnStatusAtom(connName));
|
||||
const wshProblem = connName && !connStatus?.wshenabled && connStatus?.status == "connected";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!magnified || preview || prevMagifiedState.current) {
|
||||
@ -266,7 +266,7 @@ const BlockFrame_Header = ({
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
/>
|
||||
)}
|
||||
{manageConnection && !wshEnabled && (
|
||||
{manageConnection && wshProblem && (
|
||||
<IconButton decl={wshInstallButton} className="block-frame-header-iconbutton" />
|
||||
)}
|
||||
<div className="block-frame-textelems-wrapper">{headerTextElems}</div>
|
||||
@ -342,6 +342,8 @@ const ConnStatusOverlay = React.memo(
|
||||
const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30);
|
||||
const width = domRect?.width;
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom);
|
||||
const [showWshError, setShowWshError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (width) {
|
||||
@ -356,12 +358,40 @@ const ConnStatusOverlay = React.memo(
|
||||
prtn.catch((e) => console.log("error reconnecting", connName, e));
|
||||
}, [connName]);
|
||||
|
||||
const handleDisableWsh = React.useCallback(async () => {
|
||||
// using unknown is a hack. we need proper types for the
|
||||
// connection config on the frontend
|
||||
const metamaptype: unknown = {
|
||||
"conn:wshenabled": false,
|
||||
};
|
||||
const data: ConnConfigRequest = {
|
||||
host: connName,
|
||||
metamaptype: metamaptype,
|
||||
};
|
||||
try {
|
||||
await RpcApi.SetConnectionsConfigCommand(TabRpcClient, data);
|
||||
} catch (e) {
|
||||
console.log("problem setting connection config: ", e);
|
||||
}
|
||||
}, [connName]);
|
||||
|
||||
const handleRemoveWshError = React.useCallback(async () => {
|
||||
try {
|
||||
await RpcApi.DismissWshFailCommand(TabRpcClient, connName);
|
||||
} catch (e) {
|
||||
console.log("unable to dismiss wsh error: ", e);
|
||||
}
|
||||
}, [connName]);
|
||||
|
||||
let statusText = `Disconnected from "${connName}"`;
|
||||
let showReconnect = true;
|
||||
if (connStatus.status == "connecting") {
|
||||
statusText = `Connecting to "${connName}"...`;
|
||||
showReconnect = false;
|
||||
}
|
||||
if (connStatus.status == "connected") {
|
||||
showReconnect = false;
|
||||
}
|
||||
let reconDisplay = null;
|
||||
let reconClassName = "outlined grey";
|
||||
if (width && width < 350) {
|
||||
@ -373,18 +403,37 @@ const ConnStatusOverlay = React.memo(
|
||||
}
|
||||
const showIcon = connStatus.status != "connecting";
|
||||
|
||||
if (isLayoutMode || connStatus.status == "connected" || connModalOpen) {
|
||||
const wshConfigEnabled = fullConfig?.connections?.[connName]?.["conn:wshenabled"] ?? true;
|
||||
React.useEffect(() => {
|
||||
const showWshErrorTemp =
|
||||
connStatus.status == "connected" &&
|
||||
connStatus.wsherror &&
|
||||
connStatus.wsherror != "" &&
|
||||
wshConfigEnabled;
|
||||
|
||||
setShowWshError(showWshErrorTemp);
|
||||
}, [connStatus, wshConfigEnabled]);
|
||||
|
||||
if (!showWshError && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="connstatus-overlay" ref={overlayRefCallback}>
|
||||
<div className="connstatus-content">
|
||||
<div className={clsx("connstatus-status-icon-wrapper", { "has-error": showError })}>
|
||||
<div className={clsx("connstatus-status-icon-wrapper", { "has-error": showError || showWshError })}>
|
||||
{showIcon && <i className="fa-solid fa-triangle-exclamation"></i>}
|
||||
<div className="connstatus-status">
|
||||
<div className="connstatus-status-text">{statusText}</div>
|
||||
{showError ? <div className="connstatus-error">error: {connStatus.error}</div> : null}
|
||||
{showWshError ? (
|
||||
<div className="connstatus-error">unable to use wsh: {connStatus.wsherror}</div>
|
||||
) : null}
|
||||
{showWshError && (
|
||||
<Button className={reconClassName} onClick={handleDisableWsh}>
|
||||
always disable wsh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showReconnect ? (
|
||||
@ -394,6 +443,11 @@ const ConnStatusOverlay = React.memo(
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{showWshError ? (
|
||||
<div className="connstatus-actions">
|
||||
<Button className={`fa-xmark fa-solid ${reconClassName}`} onClick={handleRemoveWshError} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -657,8 +711,8 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
}
|
||||
if (
|
||||
conn.includes(connSelected) &&
|
||||
connectionsConfig[conn]?.["display:hidden"] != true &&
|
||||
(connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
|
||||
connectionsConfig?.[conn]?.["display:hidden"] != true &&
|
||||
(connectionsConfig?.[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
|
||||
// != false is necessary because of defaults
|
||||
) {
|
||||
filteredList.push(conn);
|
||||
@ -671,8 +725,8 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
}
|
||||
if (
|
||||
conn.includes(connSelected) &&
|
||||
connectionsConfig[conn]?.["display:hidden"] != true &&
|
||||
(connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
|
||||
connectionsConfig?.[conn]?.["display:hidden"] != true &&
|
||||
(connectionsConfig?.[conn]?.["conn:wshenabled"] != false || !filterOutNowsh)
|
||||
// != false is necessary because of defaults
|
||||
) {
|
||||
filteredWslList.push(conn);
|
||||
@ -683,7 +737,7 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
const newConnectionSuggestion: SuggestionConnectionItem = {
|
||||
status: "connected",
|
||||
icon: "plus",
|
||||
iconColor: "var(--conn-icon-color)",
|
||||
iconColor: "var(--grey-text-color)",
|
||||
label: `${connSelected} (New Connection)`,
|
||||
value: "",
|
||||
onSelect: (_: string) => {
|
||||
@ -706,22 +760,12 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e));
|
||||
},
|
||||
};
|
||||
const priorityItems: Array<SuggestionConnectionItem> = [];
|
||||
if (createNew) {
|
||||
priorityItems.push(newConnectionSuggestion);
|
||||
}
|
||||
if (showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error")) {
|
||||
priorityItems.push(reconnectSuggestion);
|
||||
}
|
||||
const prioritySuggestions: SuggestionConnectionScope = {
|
||||
headerText: "",
|
||||
items: priorityItems,
|
||||
};
|
||||
const localName = getUserName() + "@" + getHostName();
|
||||
const localSuggestion: SuggestionConnectionScope = {
|
||||
headerText: "Local",
|
||||
items: [],
|
||||
};
|
||||
if (localName.includes(connSelected)) {
|
||||
localSuggestion.items.push({
|
||||
status: "connected",
|
||||
icon: "laptop",
|
||||
@ -730,6 +774,10 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
label: localName,
|
||||
current: connection == null,
|
||||
});
|
||||
}
|
||||
if (localName == connSelected) {
|
||||
createNew = false;
|
||||
}
|
||||
for (const wslConn of filteredWslList) {
|
||||
const connStatus = connStatusMap.get(wslConn);
|
||||
const connColorNum = computeConnColorNum(connStatus);
|
||||
@ -785,33 +833,33 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
(itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => {
|
||||
const connNameA = itemA.value;
|
||||
const connNameB = itemB.value;
|
||||
const valueA = connectionsConfig[connNameA]?.["display:order"] ?? 0;
|
||||
const valueB = connectionsConfig[connNameB]?.["display:order"] ?? 0;
|
||||
const valueA = connectionsConfig?.[connNameA]?.["display:order"] ?? 0;
|
||||
const valueB = connectionsConfig?.[connNameB]?.["display:order"] ?? 0;
|
||||
return valueA - valueB;
|
||||
}
|
||||
);
|
||||
const remoteSuggestions: SuggestionConnectionScope = {
|
||||
headerText: "Remote",
|
||||
items: [...sortedRemoteItems, connectionsEditItem],
|
||||
items: [...sortedRemoteItems],
|
||||
};
|
||||
|
||||
let suggestions: Array<SuggestionsType> = [];
|
||||
if (prioritySuggestions.items.length > 0) {
|
||||
suggestions.push(prioritySuggestions);
|
||||
}
|
||||
if (localSuggestion.items.length > 0) {
|
||||
suggestions.push(localSuggestion);
|
||||
}
|
||||
if (remoteSuggestions.items.length > 0) {
|
||||
suggestions.push(remoteSuggestions);
|
||||
}
|
||||
|
||||
let selectionList: Array<SuggestionConnectionItem> = [
|
||||
...prioritySuggestions.items,
|
||||
...localSuggestion.items,
|
||||
...remoteSuggestions.items,
|
||||
const suggestions: Array<SuggestionsType> = [
|
||||
...(showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error")
|
||||
? [reconnectSuggestion]
|
||||
: []),
|
||||
...(localSuggestion.items.length > 0 ? [localSuggestion] : []),
|
||||
...(remoteSuggestions.items.length > 0 ? [remoteSuggestions] : []),
|
||||
...(connSelected == "" ? [connectionsEditItem] : []),
|
||||
...(createNew ? [newConnectionSuggestion] : []),
|
||||
];
|
||||
|
||||
let selectionList: Array<SuggestionConnectionItem> = suggestions.flatMap((item) => {
|
||||
if ("items" in item) {
|
||||
return item.items;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
// quick way to change icon color when highlighted
|
||||
selectionList = selectionList.map((item, index) => {
|
||||
if (index == rowIndex && item.iconColor == "var(--grey-text-color)") {
|
||||
@ -842,9 +890,10 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
return true;
|
||||
}
|
||||
if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) {
|
||||
setRowIndex((idx) => Math.min(idx + 1, selectionList.flat().length - 1));
|
||||
setRowIndex((idx) => Math.min(idx + 1, selectionList.length - 1));
|
||||
return true;
|
||||
}
|
||||
setRowIndex(0);
|
||||
},
|
||||
[changeConnModalAtom, viewModel, blockId, connSelected, selectionList]
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ import { FlexiModal } from "./modal";
|
||||
import { QuickTips } from "@/app/element/quicktips";
|
||||
import { atoms, getApi } from "@/app/store/global";
|
||||
import { modalsModel } from "@/app/store/modalmodel";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import "./tos.scss";
|
||||
|
||||
@ -20,25 +21,22 @@ const pageNumAtom: PrimitiveAtom<number> = atom<number>(1);
|
||||
const ModalPage1 = () => {
|
||||
const settings = useAtomValue(atoms.settingsAtom);
|
||||
const clientData = useAtomValue(atoms.client);
|
||||
const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen);
|
||||
const [telemetryEnabled, setTelemetryEnabled] = useState<boolean>(!!settings["telemetry:enabled"]);
|
||||
const setPageNum = useSetAtom(pageNumAtom);
|
||||
|
||||
const acceptTos = () => {
|
||||
if (!clientData.tosagreed) {
|
||||
services.ClientService.AgreeTos();
|
||||
fireAndForget(services.ClientService.AgreeTos);
|
||||
}
|
||||
setPageNum(2);
|
||||
};
|
||||
|
||||
const setTelemetry = (value: boolean) => {
|
||||
services.ClientService.TelemetryUpdate(value)
|
||||
.then(() => {
|
||||
fireAndForget(() =>
|
||||
services.ClientService.TelemetryUpdate(value).then(() => {
|
||||
setTelemetryEnabled(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("failed to set telemetry:", error);
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const label = telemetryEnabled ? "Telemetry Enabled" : "Telemetry Disabled";
|
||||
|
@ -63,7 +63,8 @@ const Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>(
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderItem(item as SuggestionBaseItem, index);
|
||||
fullIndex += 1;
|
||||
return renderItem(item as SuggestionBaseItem, fullIndex);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
@ -5,9 +5,9 @@ import { Modal } from "@/app/modals/modal";
|
||||
import { Markdown } from "@/element/markdown";
|
||||
import { modalsModel } from "@/store/modalmodel";
|
||||
import * as keyutil from "@/util/keyutil";
|
||||
import { UserInputService } from "../store/services";
|
||||
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { UserInputService } from "../store/services";
|
||||
import "./userinputmodal.scss";
|
||||
|
||||
const UserInputModal = (userInputRequest: UserInputRequest) => {
|
||||
@ -16,33 +16,39 @@ const UserInputModal = (userInputRequest: UserInputRequest) => {
|
||||
const checkboxRef = useRef<HTMLInputElement>();
|
||||
|
||||
const handleSendErrResponse = useCallback(() => {
|
||||
fireAndForget(() =>
|
||||
UserInputService.SendUserInputResponse({
|
||||
type: "userinputresp",
|
||||
requestid: userInputRequest.requestid,
|
||||
errormsg: "Canceled by the user",
|
||||
});
|
||||
})
|
||||
);
|
||||
modalsModel.popModal();
|
||||
}, [responseText, userInputRequest]);
|
||||
|
||||
const handleSendText = useCallback(() => {
|
||||
fireAndForget(() =>
|
||||
UserInputService.SendUserInputResponse({
|
||||
type: "userinputresp",
|
||||
requestid: userInputRequest.requestid,
|
||||
text: responseText,
|
||||
checkboxstat: checkboxRef?.current?.checked ?? false,
|
||||
});
|
||||
})
|
||||
);
|
||||
modalsModel.popModal();
|
||||
}, [responseText, userInputRequest]);
|
||||
console.log("bar");
|
||||
|
||||
const handleSendConfirm = useCallback(
|
||||
(response: boolean) => {
|
||||
fireAndForget(() =>
|
||||
UserInputService.SendUserInputResponse({
|
||||
type: "userinputresp",
|
||||
requestid: userInputRequest.requestid,
|
||||
confirm: response,
|
||||
checkboxstat: checkboxRef?.current?.checked ?? false,
|
||||
});
|
||||
})
|
||||
);
|
||||
modalsModel.popModal();
|
||||
},
|
||||
[userInputRequest]
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { getApi } from "./global";
|
||||
import { atoms, getApi, globalStore } from "./global";
|
||||
|
||||
class ContextMenuModelType {
|
||||
handlers: Map<string, () => void> = new Map(); // id -> handler
|
||||
@ -48,7 +48,7 @@ class ContextMenuModelType {
|
||||
showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent<any>): void {
|
||||
this.handlers.clear();
|
||||
const electronMenuItems = this._convertAndRegisterMenu(menu);
|
||||
getApi().showContextMenu(electronMenuItems);
|
||||
getApi().showContextMenu(globalStore.get(atoms.workspace).oid, electronMenuItems);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,9 +43,6 @@ function setPlatform(platform: NodeJS.Platform) {
|
||||
PLATFORM = platform;
|
||||
}
|
||||
|
||||
// Used to override the tab id when switching tabs to prevent flicker in the tab bar.
|
||||
const overrideStaticTabAtom = atom(null) as PrimitiveAtom<string>;
|
||||
|
||||
function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom<string>;
|
||||
const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom<string>;
|
||||
@ -103,8 +100,8 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
||||
const tabAtom: Atom<Tab> = atom((get) => {
|
||||
return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), get);
|
||||
});
|
||||
// This atom is used to determine the tab id to use for the static tab. It is set to the overrideStaticTabAtom value if it is not null, otherwise it is set to the initOpts.tabId value.
|
||||
const staticTabIdAtom: Atom<string> = atom((get) => get(overrideStaticTabAtom) ?? initOpts.tabId);
|
||||
// this is *the* tab that this tabview represents. it should never change.
|
||||
const staticTabIdAtom: Atom<string> = atom(initOpts.tabId);
|
||||
const controlShiftDelayAtom = atom(false);
|
||||
const updaterStatusAtom = atom<UpdaterStatus>("up-to-date") as PrimitiveAtom<UpdaterStatus>;
|
||||
try {
|
||||
@ -662,10 +659,6 @@ function createTab() {
|
||||
}
|
||||
|
||||
function setActiveTab(tabId: string) {
|
||||
// We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used.
|
||||
// Also overrides the staticTabAtom to the new tab id so that the active tab is set correctly.
|
||||
globalStore.set(overrideStaticTabAtom, tabId);
|
||||
document.body.classList.add("nohover");
|
||||
getApi().setActiveTab(tabId);
|
||||
}
|
||||
|
||||
@ -692,7 +685,6 @@ export {
|
||||
isDev,
|
||||
loadConnStatus,
|
||||
openLink,
|
||||
overrideStaticTabAtom,
|
||||
PLATFORM,
|
||||
pushFlashError,
|
||||
pushNotification,
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
} from "@/layout/index";
|
||||
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
|
||||
import * as keyutil from "@/util/keyutil";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import * as jotai from "jotai";
|
||||
|
||||
const simpleControlShiftAtom = jotai.atom(false);
|
||||
@ -70,20 +71,25 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
|
||||
}
|
||||
|
||||
function genericClose(tabId: string) {
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const tabORef = WOS.makeORef("tab", tabId);
|
||||
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);
|
||||
const tabData = globalStore.get(tabAtom);
|
||||
if (tabData == null) {
|
||||
return;
|
||||
}
|
||||
if (ws.pinnedtabids?.includes(tabId) && tabData.blockids?.length == 1) {
|
||||
// don't allow closing the last block in a pinned tab
|
||||
return;
|
||||
}
|
||||
if (tabData.blockids == null || tabData.blockids.length == 0) {
|
||||
// close tab
|
||||
getApi().closeTab(tabId);
|
||||
getApi().closeTab(ws.oid, tabId);
|
||||
deleteLayoutModelForTab(tabId);
|
||||
return;
|
||||
}
|
||||
const layoutModel = getLayoutModelForTab(tabAtom);
|
||||
layoutModel.closeFocusedNode();
|
||||
fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel));
|
||||
}
|
||||
|
||||
function switchBlockByBlockNum(index: number) {
|
||||
@ -246,11 +252,21 @@ function registerGlobalKeys() {
|
||||
});
|
||||
globalKeyMap.set("Cmd:w", () => {
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
if (!ws.pinnedtabids?.includes(tabId)) {
|
||||
genericClose(tabId);
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:Shift:w", () => {
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
if (ws.pinnedtabids?.includes(tabId)) {
|
||||
// switch to first unpinned tab if it exists (for close spamming)
|
||||
if (ws.tabids != null && ws.tabids.length > 0) {
|
||||
getApi().setActiveTab(ws.tabids[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
getApi().closeTab(ws.oid, tabId);
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:m", () => {
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
|
@ -183,6 +183,11 @@ class WorkspaceServiceType {
|
||||
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns workspaceId
|
||||
CreateWorkspace(): Promise<string> {
|
||||
return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
DeleteWorkspace(workspaceId: string): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments))
|
||||
|
@ -6,6 +6,7 @@
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { fetch } from "@/util/fetchutil";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { globalStore } from "./jotaiStore";
|
||||
@ -301,7 +302,7 @@ function setObjectValue<T extends WaveObj>(value: T, setFn?: Setter, pushToServe
|
||||
}
|
||||
setFn(wov.dataAtom, { value: value, loading: false });
|
||||
if (pushToServer) {
|
||||
ObjectService.UpdateObject(value, false);
|
||||
fireAndForget(() => ObjectService.UpdateObject(value, false));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,6 +92,11 @@ class RpcApiType {
|
||||
return client.wshRpcCall("deletesubblock", data, opts);
|
||||
}
|
||||
|
||||
// command "dismisswshfail" [call]
|
||||
DismissWshFailCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("dismisswshfail", data, opts);
|
||||
}
|
||||
|
||||
// command "dispose" [call]
|
||||
DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("dispose", data, opts);
|
||||
@ -262,6 +267,11 @@ class RpcApiType {
|
||||
return client.wshRpcCall("setconfig", data, opts);
|
||||
}
|
||||
|
||||
// command "setconnectionsconfig" [call]
|
||||
SetConnectionsConfigCommand(client: WshClient, data: ConnConfigRequest, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("setconnectionsconfig", data, opts);
|
||||
}
|
||||
|
||||
// command "setmeta" [call]
|
||||
SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("setmeta", data, opts);
|
||||
|
@ -57,7 +57,7 @@
|
||||
.tab-inner {
|
||||
border-color: transparent;
|
||||
border-radius: 6px;
|
||||
background: rgb(from var(--main-text-color) r g b / 0.07);
|
||||
background: rgb(from var(--main-text-color) r g b / 0.1);
|
||||
}
|
||||
|
||||
.name {
|
||||
@ -114,7 +114,7 @@ body:not(.nohover) .tab:hover,
|
||||
body:not(.nohover) .is-dragging {
|
||||
.tab-inner {
|
||||
border-color: transparent;
|
||||
background: rgb(from var(--main-text-color) r g b / 0.07);
|
||||
background: rgb(from var(--main-text-color) r g b / 0.1);
|
||||
}
|
||||
.close {
|
||||
visibility: visible;
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
import { Button } from "@/element/button";
|
||||
import { ContextMenuModel } from "@/store/contextmenu";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { clsx } from "clsx";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
@ -85,14 +86,21 @@ const Tab = memo(
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRenameTab = (event) => {
|
||||
const selectEditableText = useCallback(() => {
|
||||
if (editableRef.current) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.selectNodeContents(editableRef.current);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRenameTab: React.MouseEventHandler<HTMLDivElement> = (event) => {
|
||||
event?.stopPropagation();
|
||||
setIsEditable(true);
|
||||
editableTimeoutRef.current = setTimeout(() => {
|
||||
if (editableRef.current) {
|
||||
editableRef.current.focus();
|
||||
document.execCommand("selectAll", false);
|
||||
}
|
||||
selectEditableText();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
@ -101,20 +109,14 @@ const Tab = memo(
|
||||
newText = newText || originalName;
|
||||
editableRef.current.innerText = newText;
|
||||
setIsEditable(false);
|
||||
ObjectService.UpdateTabName(id, newText);
|
||||
fireAndForget(() => ObjectService.UpdateTabName(id, newText));
|
||||
setTimeout(() => refocusNode(null), 10);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
|
||||
event.preventDefault();
|
||||
if (editableRef.current) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.selectNodeContents(editableRef.current);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
selectEditableText();
|
||||
return;
|
||||
}
|
||||
// this counts glyphs, not characters
|
||||
@ -163,7 +165,10 @@ const Tab = memo(
|
||||
let menu: ContextMenuItem[] = [
|
||||
{ label: isPinned ? "Unpin Tab" : "Pin Tab", click: () => onPinChange() },
|
||||
{ label: "Rename Tab", click: () => handleRenameTab(null) },
|
||||
{ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) },
|
||||
{
|
||||
label: "Copy TabId",
|
||||
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
|
||||
},
|
||||
{ type: "separator" },
|
||||
];
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
@ -188,10 +193,11 @@ const Tab = memo(
|
||||
}
|
||||
submenu.push({
|
||||
label: preset["display:name"] ?? presetName,
|
||||
click: () => {
|
||||
ObjectService.UpdateObjectMeta(oref, preset);
|
||||
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
|
||||
},
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
await ObjectService.UpdateObjectMeta(oref, preset);
|
||||
await RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
|
||||
}),
|
||||
});
|
||||
}
|
||||
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
|
||||
@ -348,11 +354,17 @@ const Tab = memo(
|
||||
e.stopPropagation();
|
||||
onPinChange();
|
||||
}}
|
||||
title="Unpin Tab"
|
||||
>
|
||||
<i className="fa fa-solid fa-thumbtack" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="ghost grey close" onClick={onClose} onMouseDown={handleMouseDownOnClose}>
|
||||
<Button
|
||||
className="ghost grey close"
|
||||
onClick={onClose}
|
||||
onMouseDown={handleMouseDownOnClose}
|
||||
title="Close Tab"
|
||||
>
|
||||
<i className="fa fa-solid fa-xmark" />
|
||||
</Button>
|
||||
)}
|
||||
|
@ -5,7 +5,7 @@ import { Button } from "@/app/element/button";
|
||||
import { modalsModel } from "@/app/store/modalmodel";
|
||||
import { WindowDrag } from "@/element/windowdrag";
|
||||
import { deleteLayoutModelForTab } from "@/layout/index";
|
||||
import { atoms, createTab, getApi, isDev, PLATFORM, setActiveTab } from "@/store/global";
|
||||
import { atoms, createTab, getApi, globalStore, isDev, PLATFORM, setActiveTab } from "@/store/global";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { OverlayScrollbars } from "overlayscrollbars";
|
||||
@ -446,9 +446,11 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
let pinnedTabCount = pinnedTabIds.size;
|
||||
const draggedTabId = draggingTabDataRef.current.tabId;
|
||||
const isPinned = pinnedTabIds.has(draggedTabId);
|
||||
if (pinnedTabIds.has(tabIds[tabIndex + 1]) && !isPinned) {
|
||||
const nextTabId = tabIds[tabIndex + 1];
|
||||
const prevTabId = tabIds[tabIndex - 1];
|
||||
if (!isPinned && nextTabId && pinnedTabIds.has(nextTabId)) {
|
||||
pinnedTabIds.add(draggedTabId);
|
||||
} else if (!pinnedTabIds.has(tabIds[tabIndex - 1]) && isPinned) {
|
||||
} else if (isPinned && prevTabId && !pinnedTabIds.has(prevTabId)) {
|
||||
pinnedTabIds.delete(draggedTabId);
|
||||
}
|
||||
if (pinnedTabCount != pinnedTabIds.size) {
|
||||
@ -458,9 +460,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
// Reset dragging state
|
||||
setDraggingTabId(null);
|
||||
// Update workspace tab ids
|
||||
fireAndForget(
|
||||
async () =>
|
||||
await WorkspaceService.UpdateTabIds(
|
||||
fireAndForget(() =>
|
||||
WorkspaceService.UpdateTabIds(
|
||||
workspace.oid,
|
||||
tabIds.slice(pinnedTabCount),
|
||||
tabIds.slice(0, pinnedTabCount)
|
||||
@ -566,7 +567,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
|
||||
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
|
||||
event?.stopPropagation();
|
||||
getApi().closeTab(tabId);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
getApi().closeTab(ws.oid, tabId);
|
||||
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
|
||||
deleteLayoutModelForTab(tabId);
|
||||
};
|
||||
@ -595,7 +597,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
};
|
||||
|
||||
function onEllipsisClick() {
|
||||
getApi().showContextMenu();
|
||||
getApi().showContextMenu(workspace.oid);
|
||||
}
|
||||
|
||||
const tabsWrapperWidth = tabIds.length * tabWidthRef.current;
|
||||
|
@ -9,10 +9,14 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--modal-bg-color);
|
||||
margin-top: 6px;
|
||||
margin-right: 13px;
|
||||
box-sizing: border-box;
|
||||
background-color: rgb(from var(--main-text-color) r g b / 0.1) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(from var(--main-text-color) r g b / 0.14) !important;
|
||||
}
|
||||
|
||||
.workspace-icon {
|
||||
width: 15px;
|
||||
@ -71,6 +75,10 @@
|
||||
|
||||
.expandable-menu-item-group {
|
||||
margin: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
--workspace-color: var(--main-bg-color);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 4px;
|
||||
@ -81,13 +89,6 @@
|
||||
.expandable-menu-item {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-menu-item-group {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
--workspace-color: var(--main-bg-color);
|
||||
|
||||
.menu-group-title-wrapper {
|
||||
display: flex;
|
||||
@ -145,6 +146,7 @@
|
||||
|
||||
.left-icon {
|
||||
font-size: 14px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,6 +166,8 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 5px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--modal-border-color);
|
||||
|
||||
.color-circle {
|
||||
width: 15px;
|
||||
@ -219,7 +223,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,6 +32,33 @@ interface ColorSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const colors = [
|
||||
"#58C142", // Green (accent)
|
||||
"#00FFDB", // Teal
|
||||
"#429DFF", // Blue
|
||||
"#BF55EC", // Purple
|
||||
"#FF453A", // Red
|
||||
"#FF9500", // Orange
|
||||
"#FFE900", // Yellow
|
||||
];
|
||||
|
||||
const icons = [
|
||||
"circle",
|
||||
"triangle",
|
||||
"star",
|
||||
"heart",
|
||||
"bolt",
|
||||
"solid@cloud",
|
||||
"moon",
|
||||
"layer-group",
|
||||
"rocket",
|
||||
"flask",
|
||||
"paperclip",
|
||||
"chart-line",
|
||||
"graduation-cap",
|
||||
"mug-hot",
|
||||
];
|
||||
|
||||
const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => {
|
||||
const handleColorClick = (color: string) => {
|
||||
onSelect(color);
|
||||
@ -117,31 +144,8 @@ const ColorAndIconSelector = memo(
|
||||
value={title}
|
||||
autoFocus
|
||||
/>
|
||||
<ColorSelector
|
||||
selectedColor={color}
|
||||
colors={["#e91e63", "#8bc34a", "#ff9800", "#ffc107", "#03a9f4", "#3f51b5", "#f44336"]}
|
||||
onSelect={onColorChange}
|
||||
/>
|
||||
<IconSelector
|
||||
selectedIcon={icon}
|
||||
icons={[
|
||||
"triangle",
|
||||
"star",
|
||||
"cube",
|
||||
"gem",
|
||||
"chess-knight",
|
||||
"heart",
|
||||
"plane",
|
||||
"rocket",
|
||||
"shield-cat",
|
||||
"paw-simple",
|
||||
"umbrella",
|
||||
"graduation-cap",
|
||||
"mug-hot",
|
||||
"circle",
|
||||
]}
|
||||
onSelect={onIconChange}
|
||||
/>
|
||||
<ColorSelector selectedColor={color} colors={colors} onSelect={onColorChange} />
|
||||
<IconSelector selectedIcon={icon} icons={icons} onSelect={onIconChange} />
|
||||
<div className="delete-ws-btn-wrapper">
|
||||
<Button className="ghost red font-size-12" onClick={onDeleteWorkspace}>
|
||||
Delete workspace
|
||||
@ -189,12 +193,10 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
|
||||
}, []);
|
||||
|
||||
const onDeleteWorkspace = useCallback((workspaceId: string) => {
|
||||
fireAndForget(async () => {
|
||||
getApi().deleteWorkspace(workspaceId);
|
||||
setTimeout(() => {
|
||||
fireAndForget(updateWorkspaceList);
|
||||
}, 10);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);
|
||||
@ -206,7 +208,16 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
|
||||
);
|
||||
|
||||
const saveWorkspace = () => {
|
||||
setObjectValue({ ...activeWorkspace, name: "New Workspace", icon: "circle", color: "green" }, undefined, true);
|
||||
setObjectValue(
|
||||
{
|
||||
...activeWorkspace,
|
||||
name: `New Workspace (${activeWorkspace.oid.slice(0, 5)})`,
|
||||
icon: icons[0],
|
||||
color: colors[0],
|
||||
},
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
setTimeout(() => {
|
||||
fireAndForget(updateWorkspaceList);
|
||||
}, 10);
|
||||
@ -233,16 +244,23 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
|
||||
</ExpandableMenu>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
{!isActiveWorkspaceSaved && (
|
||||
<div className="actions">
|
||||
{isActiveWorkspaceSaved ? (
|
||||
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
|
||||
<ExpandableMenuItemLeftElement>
|
||||
<i className="fa-sharp fa-solid fa-plus"></i>
|
||||
</ExpandableMenuItemLeftElement>
|
||||
<div className="content">Create new workspace</div>
|
||||
</ExpandableMenuItem>
|
||||
) : (
|
||||
<ExpandableMenuItem onClick={() => saveWorkspace()}>
|
||||
<ExpandableMenuItemLeftElement>
|
||||
<i className="fa-sharp fa-solid fa-floppy-disk"></i>
|
||||
</ExpandableMenuItemLeftElement>
|
||||
<div className="content">Save workspace</div>
|
||||
</ExpandableMenuItem>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@ -263,12 +281,10 @@ const WorkspaceSwitcherItem = ({
|
||||
const isCurrentWorkspace = activeWorkspace.oid === workspace.oid;
|
||||
|
||||
const setWorkspace = useCallback((newWorkspace: Workspace) => {
|
||||
fireAndForget(async () => {
|
||||
if (newWorkspace.name != "") {
|
||||
setObjectValue({ ...newWorkspace, otype: "workspace" }, undefined, true);
|
||||
}
|
||||
setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isActive = !!workspaceEntry.windowId;
|
||||
|
@ -543,26 +543,26 @@ function TableBody({
|
||||
},
|
||||
{
|
||||
label: "Copy File Name",
|
||||
click: () => navigator.clipboard.writeText(fileName),
|
||||
click: () => fireAndForget(() => navigator.clipboard.writeText(fileName)),
|
||||
},
|
||||
{
|
||||
label: "Copy Full File Name",
|
||||
click: () => navigator.clipboard.writeText(finfo.path),
|
||||
click: () => fireAndForget(() => navigator.clipboard.writeText(finfo.path)),
|
||||
},
|
||||
{
|
||||
label: "Copy File Name (Shell Quoted)",
|
||||
click: () => navigator.clipboard.writeText(shellQuote([fileName])),
|
||||
click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([fileName]))),
|
||||
},
|
||||
{
|
||||
label: "Copy Full File Name (Shell Quoted)",
|
||||
click: () => navigator.clipboard.writeText(shellQuote([finfo.path])),
|
||||
click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Download File",
|
||||
click: async () => {
|
||||
click: () => {
|
||||
getApi().downloadFile(normPath);
|
||||
},
|
||||
},
|
||||
@ -572,7 +572,7 @@ function TableBody({
|
||||
// TODO: Only show this option for local files, resolve correct host path if connection is WSL
|
||||
{
|
||||
label: openNativeLabel,
|
||||
click: async () => {
|
||||
click: () => {
|
||||
getApi().openNativePath(normPath);
|
||||
},
|
||||
},
|
||||
@ -581,7 +581,8 @@ function TableBody({
|
||||
},
|
||||
{
|
||||
label: "Open Preview in New Block",
|
||||
click: async () => {
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
const blockDef: BlockDef = {
|
||||
meta: {
|
||||
view: "preview",
|
||||
@ -589,13 +590,14 @@ function TableBody({
|
||||
},
|
||||
};
|
||||
await createBlock(blockDef);
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
if (finfo.mimetype == "directory") {
|
||||
menu.push({
|
||||
label: "Open Terminal in New Block",
|
||||
click: async () => {
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
const termBlockDef: BlockDef = {
|
||||
meta: {
|
||||
controller: "shell",
|
||||
@ -604,7 +606,7 @@ function TableBody({
|
||||
},
|
||||
};
|
||||
await createBlock(termBlockDef);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
menu.push(
|
||||
@ -613,9 +615,11 @@ function TableBody({
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
click: async () => {
|
||||
click: () => {
|
||||
fireAndForget(async () => {
|
||||
await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e));
|
||||
setRefreshVersion((current) => current + 1);
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -24,7 +24,7 @@ import * as WOS from "@/store/wos";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil";
|
||||
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed, keydownWrapper } from "@/util/keyutil";
|
||||
import { base64ToString, isBlank, jotaiLoadableValue, makeConnRoute, stringToBase64 } from "@/util/util";
|
||||
import { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, makeConnRoute, stringToBase64 } from "@/util/util";
|
||||
import { Monaco } from "@monaco-editor/react";
|
||||
import clsx from "clsx";
|
||||
import { Atom, atom, Getter, PrimitiveAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai";
|
||||
@ -257,7 +257,7 @@ export class PreviewModel implements ViewModel {
|
||||
className: clsx(
|
||||
`${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500`
|
||||
),
|
||||
onClick: this.handleFileSave.bind(this),
|
||||
onClick: () => fireAndForget(this.handleFileSave.bind(this)),
|
||||
});
|
||||
if (get(this.canPreview)) {
|
||||
viewTextChildren.push({
|
||||
@ -265,7 +265,7 @@ export class PreviewModel implements ViewModel {
|
||||
text: "Preview",
|
||||
className:
|
||||
"grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500",
|
||||
onClick: () => this.setEditMode(false),
|
||||
onClick: () => fireAndForget(() => this.setEditMode(false)),
|
||||
});
|
||||
}
|
||||
} else if (get(this.canPreview)) {
|
||||
@ -274,7 +274,7 @@ export class PreviewModel implements ViewModel {
|
||||
text: "Edit",
|
||||
className:
|
||||
"grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500",
|
||||
onClick: () => this.setEditMode(true),
|
||||
onClick: () => fireAndForget(() => this.setEditMode(true)),
|
||||
});
|
||||
}
|
||||
return [
|
||||
@ -497,7 +497,7 @@ export class PreviewModel implements ViewModel {
|
||||
return;
|
||||
}
|
||||
const blockOref = WOS.makeORef("block", this.blockId);
|
||||
services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
|
||||
await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
|
||||
|
||||
// Clear the saved file buffers
|
||||
globalStore.set(this.fileContentSaved, null);
|
||||
@ -538,7 +538,7 @@ export class PreviewModel implements ViewModel {
|
||||
}
|
||||
console.log(newFileInfo.path);
|
||||
this.updateOpenFileModalAndError(false);
|
||||
this.goHistory(newFileInfo.path);
|
||||
await this.goHistory(newFileInfo.path);
|
||||
refocusNode(this.blockId);
|
||||
} catch (e) {
|
||||
globalStore.set(this.openFileError, e.message);
|
||||
@ -546,7 +546,7 @@ export class PreviewModel implements ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
goHistoryBack() {
|
||||
async goHistoryBack() {
|
||||
const blockMeta = globalStore.get(this.blockAtom)?.meta;
|
||||
const curPath = globalStore.get(this.metaFilePath);
|
||||
const updateMeta = goHistoryBack("file", curPath, blockMeta, true);
|
||||
@ -555,10 +555,10 @@ export class PreviewModel implements ViewModel {
|
||||
}
|
||||
updateMeta.edit = false;
|
||||
const blockOref = WOS.makeORef("block", this.blockId);
|
||||
services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
|
||||
await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
|
||||
}
|
||||
|
||||
goHistoryForward() {
|
||||
async goHistoryForward() {
|
||||
const blockMeta = globalStore.get(this.blockAtom)?.meta;
|
||||
const curPath = globalStore.get(this.metaFilePath);
|
||||
const updateMeta = goHistoryForward("file", curPath, blockMeta);
|
||||
@ -567,13 +567,13 @@ export class PreviewModel implements ViewModel {
|
||||
}
|
||||
updateMeta.edit = false;
|
||||
const blockOref = WOS.makeORef("block", this.blockId);
|
||||
services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
|
||||
await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
|
||||
}
|
||||
|
||||
setEditMode(edit: boolean) {
|
||||
async setEditMode(edit: boolean) {
|
||||
const blockMeta = globalStore.get(this.blockAtom)?.meta;
|
||||
const blockOref = WOS.makeORef("block", this.blockId);
|
||||
services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit });
|
||||
await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit });
|
||||
}
|
||||
|
||||
async handleFileSave() {
|
||||
@ -588,7 +588,7 @@ export class PreviewModel implements ViewModel {
|
||||
}
|
||||
const conn = globalStore.get(this.connection) ?? "";
|
||||
try {
|
||||
services.FileService.SaveFile(conn, filePath, stringToBase64(newFileContent));
|
||||
await services.FileService.SaveFile(conn, filePath, stringToBase64(newFileContent));
|
||||
globalStore.set(this.fileContent, newFileContent);
|
||||
globalStore.set(this.newFileContent, null);
|
||||
console.log("saved file", filePath);
|
||||
@ -630,32 +630,34 @@ export class PreviewModel implements ViewModel {
|
||||
|
||||
getSettingsMenuItems(): ContextMenuItem[] {
|
||||
const menuItems: ContextMenuItem[] = [];
|
||||
const blockData = globalStore.get(this.blockAtom);
|
||||
menuItems.push({
|
||||
label: "Copy Full Path",
|
||||
click: async () => {
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
const filePath = await globalStore.get(this.normFilePath);
|
||||
if (filePath == null) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(filePath);
|
||||
},
|
||||
await navigator.clipboard.writeText(filePath);
|
||||
}),
|
||||
});
|
||||
menuItems.push({
|
||||
label: "Copy File Name",
|
||||
click: async () => {
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
const fileInfo = await globalStore.get(this.statFile);
|
||||
if (fileInfo == null || fileInfo.name == null) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(fileInfo.name);
|
||||
},
|
||||
await navigator.clipboard.writeText(fileInfo.name);
|
||||
}),
|
||||
});
|
||||
const mimeType = jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), "");
|
||||
if (mimeType == "directory") {
|
||||
menuItems.push({
|
||||
label: "Open Terminal in New Block",
|
||||
click: async () => {
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
const fileInfo = await globalStore.get(this.statFile);
|
||||
const termBlockDef: BlockDef = {
|
||||
meta: {
|
||||
@ -665,7 +667,7 @@ export class PreviewModel implements ViewModel {
|
||||
},
|
||||
};
|
||||
await createBlock(termBlockDef);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
const loadableSV = globalStore.get(this.loadableSpecializedView);
|
||||
@ -677,11 +679,11 @@ export class PreviewModel implements ViewModel {
|
||||
menuItems.push({ type: "separator" });
|
||||
menuItems.push({
|
||||
label: "Save File",
|
||||
click: this.handleFileSave.bind(this),
|
||||
click: () => fireAndForget(this.handleFileSave.bind(this)),
|
||||
});
|
||||
menuItems.push({
|
||||
label: "Revert File",
|
||||
click: this.handleFileRevert.bind(this),
|
||||
click: () => fireAndForget(this.handleFileRevert.bind(this)),
|
||||
});
|
||||
}
|
||||
menuItems.push({ type: "separator" });
|
||||
@ -689,12 +691,13 @@ export class PreviewModel implements ViewModel {
|
||||
label: "Word Wrap",
|
||||
type: "checkbox",
|
||||
checked: wordWrap,
|
||||
click: () => {
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
const blockOref = WOS.makeORef("block", this.blockId);
|
||||
services.ObjectService.UpdateObjectMeta(blockOref, {
|
||||
await services.ObjectService.UpdateObjectMeta(blockOref, {
|
||||
"editor:wordwrap": !wordWrap,
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -716,16 +719,16 @@ export class PreviewModel implements ViewModel {
|
||||
|
||||
keyDownHandler(e: WaveKeyboardEvent): boolean {
|
||||
if (checkKeyPressed(e, "Cmd:ArrowLeft")) {
|
||||
this.goHistoryBack();
|
||||
fireAndForget(this.goHistoryBack.bind(this));
|
||||
return true;
|
||||
}
|
||||
if (checkKeyPressed(e, "Cmd:ArrowRight")) {
|
||||
this.goHistoryForward();
|
||||
fireAndForget(this.goHistoryForward.bind(this));
|
||||
return true;
|
||||
}
|
||||
if (checkKeyPressed(e, "Cmd:ArrowUp")) {
|
||||
// handle up directory
|
||||
this.goParentDirectory({});
|
||||
fireAndForget(() => this.goParentDirectory({}));
|
||||
return true;
|
||||
}
|
||||
const openModalOpen = globalStore.get(this.openFileModal);
|
||||
@ -739,7 +742,7 @@ export class PreviewModel implements ViewModel {
|
||||
if (canPreview) {
|
||||
if (checkKeyPressed(e, "Cmd:e")) {
|
||||
const editMode = globalStore.get(this.editMode);
|
||||
this.setEditMode(!editMode);
|
||||
fireAndForget(() => this.setEditMode(!editMode));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -833,15 +836,15 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
|
||||
|
||||
function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean {
|
||||
if (checkKeyPressed(e, "Cmd:e")) {
|
||||
model.setEditMode(false);
|
||||
fireAndForget(() => model.setEditMode(false));
|
||||
return true;
|
||||
}
|
||||
if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) {
|
||||
model.handleFileSave();
|
||||
fireAndForget(model.handleFileSave.bind(model));
|
||||
return true;
|
||||
}
|
||||
if (checkKeyPressed(e, "Cmd:r")) {
|
||||
model.handleFileRevert();
|
||||
fireAndForget(model.handleFileRevert.bind(model));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -990,7 +993,7 @@ const OpenFileModal = memo(
|
||||
|
||||
const handleCommandOperations = async () => {
|
||||
if (checkKeyPressed(waveEvent, "Enter")) {
|
||||
model.handleOpenFile(filePath);
|
||||
await model.handleOpenFile(filePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -311,6 +311,11 @@ class TermViewModel implements ViewModel {
|
||||
}
|
||||
|
||||
updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
|
||||
if (fullStatus == null) {
|
||||
return;
|
||||
}
|
||||
const curStatus = globalStore.get(this.shellProcFullStatus);
|
||||
if (curStatus == null || curStatus.version < fullStatus.version) {
|
||||
globalStore.set(this.shellProcFullStatus, fullStatus);
|
||||
const status = fullStatus?.shellprocstatus ?? "init";
|
||||
if (status == "running") {
|
||||
@ -319,6 +324,7 @@ class TermViewModel implements ViewModel {
|
||||
this.termRef.current?.setIsRunning?.(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getVDomModel(): VDomModel {
|
||||
const vdomBlockId = globalStore.get(this.vdomBlockId);
|
||||
|
@ -119,7 +119,11 @@ export class TermWrap {
|
||||
data = data.substring(nextSlashIdx);
|
||||
}
|
||||
setTimeout(() => {
|
||||
services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { "cmd:cwd": data });
|
||||
fireAndForget(() =>
|
||||
services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), {
|
||||
"cmd:cwd": data,
|
||||
})
|
||||
);
|
||||
}, 0);
|
||||
return true;
|
||||
});
|
||||
@ -284,7 +288,9 @@ export class TermWrap {
|
||||
const serializedOutput = this.serializeAddon.serialize();
|
||||
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
|
||||
console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize);
|
||||
services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize);
|
||||
fireAndForget(() =>
|
||||
services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize)
|
||||
);
|
||||
this.dataBytesProcessed = 0;
|
||||
}
|
||||
|
||||
|
@ -180,15 +180,27 @@ export class WaveAiModel implements ViewModel {
|
||||
const presetKey = get(this.presetKey);
|
||||
const presetName = presets[presetKey]?.["display:name"] ?? "";
|
||||
const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl);
|
||||
if (aiOpts?.apitype == "anthropic") {
|
||||
const modelName = aiOpts.model;
|
||||
|
||||
// Handle known API providers
|
||||
switch (aiOpts?.apitype) {
|
||||
case "anthropic":
|
||||
viewTextChildren.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "globe",
|
||||
title: "Using Remote Antropic API (" + modelName + ")",
|
||||
title: `Using Remote Anthropic API (${aiOpts.model})`,
|
||||
noAction: true,
|
||||
});
|
||||
} else if (isCloud) {
|
||||
break;
|
||||
case "perplexity":
|
||||
viewTextChildren.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "globe",
|
||||
title: `Using Remote Perplexity API (${aiOpts.model})`,
|
||||
noAction: true,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
if (isCloud) {
|
||||
viewTextChildren.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "cloud",
|
||||
@ -202,18 +214,20 @@ export class WaveAiModel implements ViewModel {
|
||||
viewTextChildren.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "location-dot",
|
||||
title: "Using Local Model @ " + baseUrl + " (" + modelName + ")",
|
||||
title: `Using Local Model @ ${baseUrl} (${modelName})`,
|
||||
noAction: true,
|
||||
});
|
||||
} else {
|
||||
viewTextChildren.push({
|
||||
elemtype: "iconbutton",
|
||||
icon: "globe",
|
||||
title: "Using Remote Model @ " + baseUrl + " (" + modelName + ")",
|
||||
title: `Using Remote Model @ ${baseUrl} (${modelName})`,
|
||||
noAction: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownItems = Object.entries(presets)
|
||||
.sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1))
|
||||
.map(
|
||||
@ -221,12 +235,12 @@ export class WaveAiModel implements ViewModel {
|
||||
({
|
||||
label: preset[1]["display:name"],
|
||||
onClick: () =>
|
||||
fireAndForget(async () => {
|
||||
await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), {
|
||||
fireAndForget(() =>
|
||||
ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), {
|
||||
...preset[1],
|
||||
"ai:preset": preset[0],
|
||||
});
|
||||
}),
|
||||
})
|
||||
),
|
||||
}) as MenuItem
|
||||
);
|
||||
dropdownItems.push({
|
||||
@ -386,7 +400,7 @@ export class WaveAiModel implements ViewModel {
|
||||
this.setLocked(false);
|
||||
this.cancel = false;
|
||||
};
|
||||
handleAiStreamingResponse();
|
||||
fireAndForget(handleAiStreamingResponse);
|
||||
}
|
||||
|
||||
useWaveAi() {
|
||||
@ -404,14 +418,14 @@ export class WaveAiModel implements ViewModel {
|
||||
|
||||
keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {
|
||||
if (checkKeyPressed(waveEvent, "Cmd:l")) {
|
||||
this.clearMessages();
|
||||
fireAndForget(this.clearMessages.bind(this));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeWaveAiViewModel(blockId): WaveAiModel {
|
||||
function makeWaveAiViewModel(blockId: string): WaveAiModel {
|
||||
const waveAiModel = new WaveAiModel(blockId);
|
||||
return waveAiModel;
|
||||
}
|
||||
@ -572,24 +586,33 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
||||
model.textAreaRef = textAreaRef;
|
||||
}, []);
|
||||
|
||||
const adjustTextAreaHeight = () => {
|
||||
const adjustTextAreaHeight = useCallback(
|
||||
(value: string) => {
|
||||
if (textAreaRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust the height of the textarea to fit the text
|
||||
const textAreaMaxLines = 100;
|
||||
const textAreaMaxLines = 5;
|
||||
const textAreaLineHeight = termFontSize * 1.5;
|
||||
const textAreaMinHeight = textAreaLineHeight;
|
||||
const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines;
|
||||
|
||||
textAreaRef.current.style.height = "1px";
|
||||
if (value === "") {
|
||||
textAreaRef.current.style.height = `${textAreaLineHeight}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
textAreaRef.current.style.height = `${textAreaLineHeight}px`;
|
||||
const scrollHeight = textAreaRef.current.scrollHeight;
|
||||
const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight);
|
||||
textAreaRef.current.style.height = newHeight + "px";
|
||||
};
|
||||
},
|
||||
[termFontSize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextAreaHeight();
|
||||
adjustTextAreaHeight(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
@ -625,7 +648,7 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => {
|
||||
|
||||
// a weird workaround to initialize ansynchronously
|
||||
useEffect(() => {
|
||||
model.populateMessages();
|
||||
fireAndForget(model.populateMessages.bind(model));
|
||||
}, []);
|
||||
|
||||
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
|
@ -293,7 +293,7 @@ export class WebViewModel implements ViewModel {
|
||||
* @param url The URL that has been navigated to.
|
||||
*/
|
||||
handleNavigate(url: string) {
|
||||
ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url });
|
||||
fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url }));
|
||||
globalStore.set(this.url, url);
|
||||
}
|
||||
|
||||
@ -432,22 +432,18 @@ export class WebViewModel implements ViewModel {
|
||||
return [
|
||||
{
|
||||
label: "Set Block Homepage",
|
||||
click: async () => {
|
||||
await this.setHomepageUrl(this.getUrl(), "block");
|
||||
},
|
||||
click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "block")),
|
||||
},
|
||||
{
|
||||
label: "Set Default Homepage",
|
||||
click: async () => {
|
||||
await this.setHomepageUrl(this.getUrl(), "global");
|
||||
},
|
||||
click: () => fireAndForget(() => this.setHomepageUrl(this.getUrl(), "global")),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools",
|
||||
click: async () => {
|
||||
click: () => {
|
||||
if (this.webviewRef.current) {
|
||||
if (this.webviewRef.current.isDevToolsOpened()) {
|
||||
this.webviewRef.current.closeDevTools();
|
||||
|
@ -58,7 +58,6 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
|
||||
const setActiveDrag = useSetAtom(layoutModel.activeDrag);
|
||||
const setReady = useSetAtom(layoutModel.ready);
|
||||
const isResizing = useAtomValue(layoutModel.isResizing);
|
||||
const ephemeralNode = useAtomValue(layoutModel.ephemeralNode);
|
||||
|
||||
const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({
|
||||
activeDrag: monitor.isDragging(),
|
||||
|
@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { getSettingsKeyAtom } from "@/app/store/global";
|
||||
import { atomWithThrottle, boundNumber } from "@/util/util";
|
||||
import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util";
|
||||
import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai";
|
||||
import { splitAtom } from "jotai/utils";
|
||||
import { createRef, CSSProperties } from "react";
|
||||
@ -852,7 +852,7 @@ export class LayoutModel {
|
||||
animationTimeS: this.animationTimeS,
|
||||
ready: this.ready,
|
||||
disablePointerEvents: this.activeDrag,
|
||||
onClose: async () => await this.closeNode(nodeid),
|
||||
onClose: () => fireAndForget(() => this.closeNode(nodeid)),
|
||||
toggleMagnify: () => this.magnifyNodeToggle(nodeid),
|
||||
focusNode: () => this.focusNode(nodeid),
|
||||
dragHandleRef: createRef(),
|
||||
|
@ -24,7 +24,7 @@ export function getLayoutModelForTab(tabAtom: Atom<Tab>): LayoutModel {
|
||||
}
|
||||
const layoutTreeStateAtom = withLayoutTreeStateAtomFromTab(tabAtom);
|
||||
const layoutModel = new LayoutModel(layoutTreeStateAtom, globalStore.get, globalStore.set);
|
||||
globalStore.sub(layoutTreeStateAtom, () => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated()));
|
||||
globalStore.sub(layoutTreeStateAtom, () => fireAndForget(layoutModel.onTreeStateAtomUpdated.bind(layoutModel)));
|
||||
layoutModelMap.set(tabId, layoutModel);
|
||||
return layoutModel;
|
||||
}
|
||||
@ -56,7 +56,7 @@ export function useTileLayout(tabAtom: Atom<Tab>, tileContent: TileLayoutContent
|
||||
useOnResize(layoutModel?.displayContainerRef, layoutModel?.onContainerResize);
|
||||
|
||||
// Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout.
|
||||
useEffect(() => fireAndForget(async () => layoutModel.onTreeStateAtomUpdated(true)), []);
|
||||
useEffect(() => fireAndForget(() => layoutModel.onTreeStateAtomUpdated(true)), []);
|
||||
|
||||
useEffect(() => layoutModel.registerTileLayout(tileContent), [tileContent]);
|
||||
return layoutModel;
|
||||
|
5
frontend/types/custom.d.ts
vendored
5
frontend/types/custom.d.ts
vendored
@ -74,7 +74,7 @@ declare global {
|
||||
getWebviewPreload: () => string;
|
||||
getAboutModalDetails: () => AboutModalDetails;
|
||||
getDocsiteUrl: () => string;
|
||||
showContextMenu: (menu?: ElectronContextMenuItem[]) => void;
|
||||
showContextMenu: (workspaceId: string, menu?: ElectronContextMenuItem[]) => void;
|
||||
onContextMenuClick: (callback: (id: string) => void) => void;
|
||||
onNavigate: (callback: (url: string) => void) => void;
|
||||
onIframeNavigate: (callback: (url: string) => void) => void;
|
||||
@ -91,11 +91,12 @@ declare global {
|
||||
setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview
|
||||
registerGlobalWebviewKeys: (keys: string[]) => void;
|
||||
onControlShiftStateUpdate: (callback: (state: boolean) => void) => void;
|
||||
createWorkspace: () => void;
|
||||
switchWorkspace: (workspaceId: string) => void;
|
||||
deleteWorkspace: (workspaceId: string) => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
createTab: () => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
closeTab: (workspaceId: string, tabId: string) => void;
|
||||
setWindowInitStatus: (status: "ready" | "wave-ready") => void;
|
||||
onWaveInit: (callback: (initOpts: WaveInitOpts) => void) => void;
|
||||
sendLog: (log: string) => void;
|
||||
|
9
frontend/types/gotypes.d.ts
vendored
9
frontend/types/gotypes.d.ts
vendored
@ -56,6 +56,7 @@ declare global {
|
||||
// blockcontroller.BlockControllerRuntimeStatus
|
||||
type BlockControllerRuntimeStatus = {
|
||||
blockid: string;
|
||||
version: number;
|
||||
shellprocstatus?: string;
|
||||
shellprocconnname?: string;
|
||||
shellprocexitcode: number;
|
||||
@ -278,6 +279,12 @@ declare global {
|
||||
err: string;
|
||||
};
|
||||
|
||||
// wshrpc.ConnConfigRequest
|
||||
type ConnConfigRequest = {
|
||||
host: string;
|
||||
metamaptype: MetaType;
|
||||
};
|
||||
|
||||
// wshrpc.ConnKeywords
|
||||
type ConnKeywords = {
|
||||
"conn:wshenabled"?: boolean;
|
||||
@ -319,6 +326,7 @@ declare global {
|
||||
hasconnected: boolean;
|
||||
activeconnnum: number;
|
||||
error?: string;
|
||||
wsherror?: string;
|
||||
};
|
||||
|
||||
// wshrpc.CpuDataRequest
|
||||
@ -630,6 +638,7 @@ declare global {
|
||||
"autoupdate:installonquit"?: boolean;
|
||||
"autoupdate:channel"?: string;
|
||||
"preview:showhiddenfiles"?: boolean;
|
||||
"tab:preset"?: string;
|
||||
"widget:*"?: boolean;
|
||||
"widget:showhelp"?: boolean;
|
||||
"window:*"?: boolean;
|
||||
|
@ -91,7 +91,7 @@ function makeIconClass(icon: string, fw: boolean, opts?: { spin?: boolean; defau
|
||||
if (icon.match(/^(solid@)?[a-z0-9-]+$/)) {
|
||||
// strip off "solid@" prefix if it exists
|
||||
icon = icon.replace(/^solid@/, "");
|
||||
return clsx(`fa fa-sharp fa-solid fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
|
||||
return clsx(`fa fa-solid fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
|
||||
}
|
||||
if (icon.match(/^regular@[a-z0-9-]+$/)) {
|
||||
// strip off the "regular@" prefix if it exists
|
||||
|
@ -22,7 +22,6 @@ import {
|
||||
initGlobal,
|
||||
initGlobalWaveEventSubs,
|
||||
loadConnStatus,
|
||||
overrideStaticTabAtom,
|
||||
pushFlashError,
|
||||
pushNotification,
|
||||
removeNotificationById,
|
||||
@ -89,14 +88,15 @@ async function reinitWave() {
|
||||
console.log("Reinit Wave");
|
||||
getApi().sendLog("Reinit Wave");
|
||||
|
||||
// We use this hack to prevent a flicker of the previously-hovered tab when this view was last active. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used.
|
||||
// Also overrides the staticTabAtom to the new tab id so that the active tab is set correctly.
|
||||
globalStore.set(overrideStaticTabAtom, savedInitOpts.tabId);
|
||||
// We use this hack to prevent a flicker of the previously-hovered tab when this view was last active.
|
||||
document.body.classList.add("nohover");
|
||||
requestAnimationFrame(() =>
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove("nohover");
|
||||
}, 100);
|
||||
}, 100)
|
||||
);
|
||||
|
||||
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
|
||||
await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
|
||||
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
|
||||
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
|
||||
const initialTab = await WOS.reloadWaveObject<Tab>(WOS.makeORef("tab", savedInitOpts.tabId));
|
||||
|
2
go.mod
2
go.mod
@ -16,7 +16,7 @@ require (
|
||||
github.com/kevinburke/ssh_config v1.2.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/sashabaranov/go-openai v1.35.7
|
||||
github.com/sashabaranov/go-openai v1.36.0
|
||||
github.com/sawka/txwrap v0.2.0
|
||||
github.com/shirou/gopsutil/v4 v4.24.10
|
||||
github.com/skeema/knownhosts v1.3.0
|
||||
|
4
go.sum
4
go.sum
@ -58,8 +58,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sashabaranov/go-openai v1.35.7 h1:icyrRbkYoKPa4rbO1WSInpJu3qDQrPEnsoJVZ6QymdI=
|
||||
github.com/sashabaranov/go-openai v1.35.7/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sashabaranov/go-openai v1.36.0 h1:fcSrn8uGuorzPWCBp8L0aCR95Zjb/Dd+ZSML0YZy9EI=
|
||||
github.com/sashabaranov/go-openai v1.36.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94=
|
||||
github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA=
|
||||
github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM=
|
||||
|
@ -7,7 +7,7 @@
|
||||
"productName": "Wave",
|
||||
"description": "Open-Source AI-Native Terminal Built for Seamless Workflows",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.9.3",
|
||||
"version": "0.10.0-beta.2",
|
||||
"homepage": "https://waveterm.dev",
|
||||
"build": {
|
||||
"appId": "dev.commandline.waveterm"
|
||||
@ -144,7 +144,8 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"send@npm:0.18.0": "0.19.0",
|
||||
"cookie@0.6.0": "^0.7.0"
|
||||
"cookie@0.6.0": "^0.7.0",
|
||||
"path-to-regexp@npm:0.1.10": "^0.1.12"
|
||||
},
|
||||
"packageManager": "yarn@4.5.1",
|
||||
"workspaces": [
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/filestore"
|
||||
@ -77,10 +78,13 @@ type BlockController struct {
|
||||
ShellInputCh chan *BlockInputUnion
|
||||
ShellProcStatus string
|
||||
ShellProcExitCode int
|
||||
RunLock *atomic.Bool
|
||||
StatusVersion int
|
||||
}
|
||||
|
||||
type BlockControllerRuntimeStatus struct {
|
||||
BlockId string `json:"blockid"`
|
||||
Version int `json:"version"`
|
||||
ShellProcStatus string `json:"shellprocstatus,omitempty"`
|
||||
ShellProcConnName string `json:"shellprocconnname,omitempty"`
|
||||
ShellProcExitCode int `json:"shellprocexitcode"`
|
||||
@ -95,6 +99,8 @@ func (bc *BlockController) WithLock(f func()) {
|
||||
func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
|
||||
var rtn BlockControllerRuntimeStatus
|
||||
bc.WithLock(func() {
|
||||
bc.StatusVersion++
|
||||
rtn.Version = bc.StatusVersion
|
||||
rtn.BlockId = bc.BlockId
|
||||
rtn.ShellProcStatus = bc.ShellProcStatus
|
||||
if bc.ShellProc != nil {
|
||||
@ -354,7 +360,26 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
}
|
||||
cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr
|
||||
}
|
||||
if !conn.WshEnabled.Load() {
|
||||
shellProc, err = shellexec.StartRemoteShellProcNoWsh(rc.TermSize, cmdStr, cmdOpts, conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
shellProc, err = shellexec.StartRemoteShellProc(rc.TermSize, cmdStr, cmdOpts, conn)
|
||||
if err != nil {
|
||||
conn.WithLock(func() {
|
||||
conn.WshError = err.Error()
|
||||
})
|
||||
conn.WshEnabled.Store(false)
|
||||
log.Printf("error starting remote shell proc with wsh: %v", err)
|
||||
log.Print("attempting install without wsh")
|
||||
shellProc, err = shellexec.StartRemoteShellProcNoWsh(rc.TermSize, cmdStr, cmdOpts, conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -473,7 +498,9 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj
|
||||
defer func() {
|
||||
wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId))
|
||||
bc.UpdateControllerAndSendUpdate(func() bool {
|
||||
if bc.ShellProcStatus == Status_Running {
|
||||
bc.ShellProcStatus = Status_Done
|
||||
}
|
||||
bc.ShellProcExitCode = exitCode
|
||||
return true
|
||||
})
|
||||
@ -549,7 +576,31 @@ func setTermSize(ctx context.Context, blockId string, termSize waveobj.TermSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bc *BlockController) LockRunLock() bool {
|
||||
rtn := bc.RunLock.CompareAndSwap(false, true)
|
||||
if rtn {
|
||||
log.Printf("block %q run() lock\n", bc.BlockId)
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
func (bc *BlockController) UnlockRunLock() {
|
||||
bc.RunLock.Store(false)
|
||||
log.Printf("block %q run() unlock\n", bc.BlockId)
|
||||
}
|
||||
|
||||
func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts, force bool) {
|
||||
runningShellCommand := false
|
||||
ok := bc.LockRunLock()
|
||||
if !ok {
|
||||
log.Printf("block %q is already executing run()\n", bc.BlockId)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if !runningShellCommand {
|
||||
bc.UnlockRunLock()
|
||||
}
|
||||
}()
|
||||
curStatus := bc.GetRuntimeStatus()
|
||||
controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "")
|
||||
if controllerName != BlockController_Shell && controllerName != BlockController_Cmd {
|
||||
@ -572,14 +623,16 @@ func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, r
|
||||
waveobj.MetaKey_CmdRunOnce: false,
|
||||
waveobj.MetaKey_CmdRunOnStart: false,
|
||||
}
|
||||
err := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, bc.BlockId), metaUpdate)
|
||||
err := wstore.UpdateObjectMeta(ctx, waveobj.MakeORef(waveobj.OType_Block, bc.BlockId), metaUpdate, false)
|
||||
if err != nil {
|
||||
log.Printf("error updating block meta (in blockcontroller.run): %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
runningShellCommand = true
|
||||
go func() {
|
||||
defer panichandler.PanicHandler("blockcontroller:run-shell-command")
|
||||
defer bc.UnlockRunLock()
|
||||
var termSize waveobj.TermSize
|
||||
if rtOpts != nil {
|
||||
termSize = rtOpts.TermSize
|
||||
@ -639,7 +692,7 @@ func CheckConnStatus(blockId string) error {
|
||||
func (bc *BlockController) StopShellProc(shouldWait bool) {
|
||||
bc.Lock.Lock()
|
||||
defer bc.Lock.Unlock()
|
||||
if bc.ShellProc == nil || bc.ShellProcStatus == Status_Done {
|
||||
if bc.ShellProc == nil || bc.ShellProcStatus == Status_Done || bc.ShellProcStatus == Status_Init {
|
||||
return
|
||||
}
|
||||
bc.ShellProc.Close()
|
||||
@ -670,6 +723,7 @@ func getOrCreateBlockController(tabId string, blockId string, controllerName str
|
||||
TabId: tabId,
|
||||
BlockId: blockId,
|
||||
ShellProcStatus: Status_Init,
|
||||
RunLock: &atomic.Bool{},
|
||||
}
|
||||
blockControllerMap[blockId] = bc
|
||||
createdController = true
|
||||
@ -697,11 +751,13 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// check if conn is different, if so, stop the current controller
|
||||
log.Printf("resync controller %s %q (%q) (force %v)\n", blockId, controllerName, connName, force)
|
||||
// check if conn is different, if so, stop the current controller, and set status back to init
|
||||
if curBc != nil {
|
||||
bcStatus := curBc.GetRuntimeStatus()
|
||||
if bcStatus.ShellProcStatus == Status_Running && bcStatus.ShellProcConnName != connName {
|
||||
StopBlockController(blockId)
|
||||
log.Printf("stopping blockcontroller %s due to conn change\n", blockId)
|
||||
StopBlockControllerAndSetStatus(blockId, Status_Init)
|
||||
}
|
||||
}
|
||||
// now if there is a conn, ensure it is connected
|
||||
@ -735,20 +791,20 @@ func startBlockController(ctx context.Context, tabId string, blockId string, rtO
|
||||
return fmt.Errorf("unknown controller %q", controllerName)
|
||||
}
|
||||
connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "")
|
||||
log.Printf("start blockcontroller %s %q (%q)\n", blockId, controllerName, connName)
|
||||
err = CheckConnStatus(blockId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot start shellproc: %w", err)
|
||||
}
|
||||
bc := getOrCreateBlockController(tabId, blockId, controllerName)
|
||||
bcStatus := bc.GetRuntimeStatus()
|
||||
log.Printf("start blockcontroller %s %q (%q) (curstatus %s) (force %v)\n", blockId, controllerName, connName, bcStatus.ShellProcStatus, force)
|
||||
if bcStatus.ShellProcStatus == Status_Init || bcStatus.ShellProcStatus == Status_Done {
|
||||
go bc.run(blockData, blockData.Meta, rtOpts, force)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func StopBlockController(blockId string) {
|
||||
func StopBlockControllerAndSetStatus(blockId string, newStatus string) {
|
||||
bc := GetBlockController(blockId)
|
||||
if bc == nil {
|
||||
return
|
||||
@ -757,13 +813,17 @@ func StopBlockController(blockId string) {
|
||||
bc.ShellProc.Close()
|
||||
<-bc.ShellProc.DoneCh
|
||||
bc.UpdateControllerAndSendUpdate(func() bool {
|
||||
bc.ShellProcStatus = Status_Done
|
||||
bc.ShellProcStatus = newStatus
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func StopBlockController(blockId string) {
|
||||
StopBlockControllerAndSetStatus(blockId, Status_Done)
|
||||
}
|
||||
|
||||
func getControllerList() []*BlockController {
|
||||
globalLock.Lock()
|
||||
defer globalLock.Unlock()
|
||||
|
@ -59,6 +59,7 @@ type SSHConn struct {
|
||||
DomainSockListener net.Listener
|
||||
ConnController *ssh.Session
|
||||
Error string
|
||||
WshError string
|
||||
HasWaiter *atomic.Bool
|
||||
LastConnectTime int64
|
||||
ActiveConnNum int
|
||||
@ -94,10 +95,12 @@ func (conn *SSHConn) DeriveConnStatus() wshrpc.ConnStatus {
|
||||
return wshrpc.ConnStatus{
|
||||
Status: conn.Status,
|
||||
Connected: conn.Status == Status_Connected,
|
||||
WshEnabled: conn.WshEnabled.Load(),
|
||||
Connection: conn.Opts.String(),
|
||||
HasConnected: (conn.LastConnectTime > 0),
|
||||
ActiveConnNum: conn.ActiveConnNum,
|
||||
Error: conn.Error,
|
||||
WshError: conn.WshError,
|
||||
}
|
||||
}
|
||||
|
||||
@ -532,7 +535,11 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn
|
||||
})
|
||||
} else if installErr != nil {
|
||||
log.Printf("error: unable to install wsh shell extensions for %s: %v\n", conn.GetName(), err)
|
||||
return fmt.Errorf("conncontroller %s wsh install error: %v", conn.GetName(), installErr)
|
||||
log.Print("attempting to run with nowsh instead")
|
||||
conn.WithLock(func() {
|
||||
conn.WshError = installErr.Error()
|
||||
})
|
||||
conn.WshEnabled.Store(false)
|
||||
} else {
|
||||
conn.WshEnabled.Store(true)
|
||||
}
|
||||
@ -541,7 +548,12 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn
|
||||
csErr := conn.StartConnServer()
|
||||
if csErr != nil {
|
||||
log.Printf("error: unable to start conn server for %s: %v\n", conn.GetName(), csErr)
|
||||
return fmt.Errorf("conncontroller %s start wsh connserver error: %v", conn.GetName(), csErr)
|
||||
log.Print("attempting to run with nowsh instead")
|
||||
conn.WithLock(func() {
|
||||
conn.WshError = csErr.Error()
|
||||
})
|
||||
conn.WshEnabled.Store(false)
|
||||
//return fmt.Errorf("conncontroller %s start wsh connserver error: %v", conn.GetName(), csErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -14,7 +14,6 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/wcloud"
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wlayout"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||
"github.com/wavetermdev/waveterm/pkg/wsl"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
@ -49,7 +48,6 @@ func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnSta
|
||||
|
||||
// moves the window to the front of the windowId stack
|
||||
func (cs *ClientService) FocusWindow(ctx context.Context, windowId string) error {
|
||||
log.Printf("FocusWindow %s\n", windowId)
|
||||
return wcore.FocusWindow(ctx, windowId)
|
||||
}
|
||||
|
||||
@ -65,7 +63,7 @@ func (cs *ClientService) AgreeTos(ctx context.Context) (waveobj.UpdatesRtnType,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client data: %w", err)
|
||||
}
|
||||
wlayout.BootstrapStarterLayout(ctx)
|
||||
wcore.BootstrapStarterLayout(ctx)
|
||||
return waveobj.ContextGetUpdatesRtn(ctx), nil
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
|
||||
@ -142,7 +143,7 @@ func (svc *ObjectService) UpdateObjectMeta(uiContext waveobj.UIContext, orefStr
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing object reference: %w", err)
|
||||
}
|
||||
err = wstore.UpdateObjectMeta(ctx, *oref, meta)
|
||||
err = wstore.UpdateObjectMeta(ctx, *oref, meta, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updateing %q meta: %w", orefStr, err)
|
||||
}
|
||||
@ -174,6 +175,10 @@ func (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj wave
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating object: %w", err)
|
||||
}
|
||||
if (waveObj.GetOType() == waveobj.OType_Workspace) && (waveObj.(*waveobj.Workspace).Name != "") {
|
||||
wps.Broker.Publish(wps.WaveEvent{
|
||||
Event: wps.Event_WorkspaceUpdate})
|
||||
}
|
||||
if returnUpdates {
|
||||
return waveobj.ContextGetUpdatesRtn(ctx), nil
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wlayout"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
@ -50,24 +49,6 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating window: %w", err)
|
||||
}
|
||||
ws, err := wcore.GetWorkspace(ctx, window.WorkspaceId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting workspace: %w", err)
|
||||
}
|
||||
if len(ws.TabIds) == 0 {
|
||||
_, err = wcore.CreateTab(ctx, ws.OID, "", true, false)
|
||||
if err != nil {
|
||||
return window, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
ws, err = wcore.GetWorkspace(ctx, window.WorkspaceId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting updated workspace: %w", err)
|
||||
}
|
||||
err = wlayout.BootstrapNewWorkspaceLayout(ctx, ws)
|
||||
if err != nil {
|
||||
return window, fmt.Errorf("error bootstrapping new workspace layout: %w", err)
|
||||
}
|
||||
}
|
||||
return window, nil
|
||||
}
|
||||
|
||||
@ -146,12 +127,12 @@ func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId
|
||||
if !windowCreated {
|
||||
return nil, fmt.Errorf("new window not created")
|
||||
}
|
||||
wlayout.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{
|
||||
ActionType: wlayout.LayoutActionDataType_Remove,
|
||||
wcore.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{
|
||||
ActionType: wcore.LayoutActionDataType_Remove,
|
||||
BlockId: blockId,
|
||||
})
|
||||
wlayout.QueueLayoutActionForTab(ctx, ws.ActiveTabId, waveobj.LayoutActionData{
|
||||
ActionType: wlayout.LayoutActionDataType_Insert,
|
||||
wcore.QueueLayoutActionForTab(ctx, ws.ActiveTabId, waveobj.LayoutActionData{
|
||||
ActionType: wcore.LayoutActionDataType_Insert,
|
||||
BlockId: blockId,
|
||||
Focused: true,
|
||||
})
|
||||
|
@ -11,7 +11,6 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wlayout"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
@ -20,6 +19,20 @@ const DefaultTimeout = 2 * time.Second
|
||||
|
||||
type WorkspaceService struct{}
|
||||
|
||||
func (svc *WorkspaceService) CreateWorkspace_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ReturnDesc: "workspaceId",
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) CreateWorkspace(ctx context.Context) (string, error) {
|
||||
newWS, err := wcore.CreateWorkspace(ctx, "", "", "", false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating workspace: %w", err)
|
||||
}
|
||||
return newWS.OID, nil
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"workspaceId"},
|
||||
@ -78,14 +91,10 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned)
|
||||
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned, false)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout())
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error applying new tab layout: %w", err)
|
||||
}
|
||||
updates := waveobj.ContextGetUpdatesRtn(ctx)
|
||||
go func() {
|
||||
defer panichandler.PanicHandler("WorkspaceService:CreateTab:SendUpdateEvents")
|
||||
|
@ -236,10 +236,8 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st
|
||||
return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
|
||||
}
|
||||
|
||||
func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
|
||||
func StartRemoteShellProcNoWsh(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
|
||||
client := conn.GetClient()
|
||||
if !conn.WshEnabled.Load() {
|
||||
// no wsh code
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -278,7 +276,10 @@ func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts Comm
|
||||
return nil, err
|
||||
}
|
||||
return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
|
||||
client := conn.GetClient()
|
||||
shellPath := cmdOpts.ShellPath
|
||||
if shellPath == "" {
|
||||
remoteShellPath, err := remote.DetectShell(client)
|
||||
|
@ -572,6 +572,8 @@ var StaticMimeTypeMap = map[string]string{
|
||||
".oeb": "application/vnd.openeye.oeb",
|
||||
".oxt": "application/vnd.openofficeorg.extension",
|
||||
".osm": "application/vnd.openstreetmap.data+xml",
|
||||
".exe": "application/vnd.microsoft.portable-executable",
|
||||
".dll": "application/vnd.microsoft.portable-executable",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide",
|
||||
".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
|
||||
@ -1108,14 +1110,18 @@ var StaticMimeTypeMap = map[string]string{
|
||||
".jsx": "text/jsx",
|
||||
".less": "text/less",
|
||||
".md": "text/markdown",
|
||||
".mdx": "text/mdx",
|
||||
".m": "text/mips",
|
||||
".miz": "text/mizar",
|
||||
".n3": "text/n3",
|
||||
".txt": "text/plain",
|
||||
".conf": "text/plain",
|
||||
".awk": "text/x-awk",
|
||||
".provn": "text/provenance-notation",
|
||||
".rst": "text/prs.fallenstein.rst",
|
||||
".tag": "text/prs.lines.tag",
|
||||
".rs": "text/x-rust",
|
||||
".ini": "text/x-ini",
|
||||
".sass": "text/scss",
|
||||
".scss": "text/scss",
|
||||
".sgml": "text/SGML",
|
||||
|
@ -620,6 +620,7 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {
|
||||
// on error just returns ""
|
||||
// does not return "application/octet-stream" as this is considered a detection failure
|
||||
// can pass an existing fileInfo to avoid re-statting the file
|
||||
// falls back to text/plain for 0 byte files
|
||||
func DetectMimeType(path string, fileInfo fs.FileInfo, extended bool) string {
|
||||
if fileInfo == nil {
|
||||
statRtn, err := os.Stat(path)
|
||||
@ -648,6 +649,9 @@ func DetectMimeType(path string, fileInfo fs.FileInfo, extended bool) string {
|
||||
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
if fileInfo.Size() == 0 {
|
||||
return "text/plain"
|
||||
}
|
||||
if !extended {
|
||||
return ""
|
||||
}
|
||||
|
179
pkg/waveai/perplexitybackend.go
Normal file
179
pkg/waveai/perplexitybackend.go
Normal 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
|
||||
}
|
@ -17,6 +17,7 @@ const OpenAICloudReqStr = "openai-cloudreq"
|
||||
const PacketEOFStr = "EOF"
|
||||
const DefaultAzureAPIVersion = "2023-05-15"
|
||||
const ApiType_Anthropic = "anthropic"
|
||||
const ApiType_Perplexity = "perplexity"
|
||||
|
||||
type OpenAICmdInfoPacketOutputType struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
@ -74,6 +75,15 @@ func RunAICommand(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan
|
||||
anthropicBackend := AnthropicBackend{}
|
||||
return anthropicBackend.StreamCompletion(ctx, request)
|
||||
}
|
||||
if request.Opts.APIType == ApiType_Perplexity {
|
||||
endpoint := request.Opts.BaseURL
|
||||
if endpoint == "" {
|
||||
endpoint = "default"
|
||||
}
|
||||
log.Printf("sending ai chat message to perplexity endpoint %q using model %s\n", endpoint, request.Opts.Model)
|
||||
perplexityBackend := PerplexityBackend{}
|
||||
return perplexityBackend.StreamCompletion(ctx, request)
|
||||
}
|
||||
if IsCloudAIRequest(request.Opts) {
|
||||
log.Print("sending ai chat message to default waveterm cloud endpoint\n")
|
||||
cloudBackend := WaveAICloudBackend{}
|
||||
|
@ -48,6 +48,8 @@ const (
|
||||
|
||||
ConfigKey_PreviewShowHiddenFiles = "preview:showhiddenfiles"
|
||||
|
||||
ConfigKey_TabPreset = "tab:preset"
|
||||
|
||||
ConfigKey_WidgetClear = "widget:*"
|
||||
ConfigKey_WidgetShowHelp = "widget:showhelp"
|
||||
|
||||
|
@ -75,6 +75,8 @@ type SettingsType struct {
|
||||
|
||||
PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"`
|
||||
|
||||
TabPreset string `json:"tab:preset,omitempty"`
|
||||
|
||||
WidgetClear bool `json:"widget:*,omitempty"`
|
||||
WidgetShowHelp *bool `json:"widget:showhelp,omitempty"`
|
||||
|
||||
|
@ -152,8 +152,7 @@ func DeleteBlock(ctx context.Context, blockId string, recursive bool) error {
|
||||
log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount)
|
||||
parentORef := waveobj.ParseORefNoErr(block.ParentORef)
|
||||
|
||||
if parentORef.OType == waveobj.OType_Tab {
|
||||
if parentBlockCount == 0 && recursive {
|
||||
if recursive && parentORef.OType == waveobj.OType_Tab && parentBlockCount == 0 {
|
||||
// if parent tab has no blocks, delete the tab
|
||||
log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID)
|
||||
parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID)
|
||||
@ -166,8 +165,6 @@ func DeleteBlock(ctx context.Context, blockId string, recursive bool) error {
|
||||
}
|
||||
SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)
|
||||
}
|
||||
|
||||
}
|
||||
go blockcontroller.StopBlockController(blockId)
|
||||
sendBlockCloseEvent(blockId)
|
||||
return nil
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package wlayout
|
||||
package wcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
|
||||
@ -131,7 +130,7 @@ func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayou
|
||||
for i := 0; i < len(layout); i++ {
|
||||
layoutAction := layout[i]
|
||||
|
||||
blockData, err := wcore.CreateBlock(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{})
|
||||
blockData, err := CreateBlock(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create block to apply portable layout to tab %s: %w", tabId, err)
|
||||
}
|
||||
@ -153,18 +152,6 @@ func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayou
|
||||
return nil
|
||||
}
|
||||
|
||||
func BootstrapNewWorkspaceLayout(ctx context.Context, workspace *waveobj.Workspace) error {
|
||||
log.Printf("BootstrapNewWorkspaceLayout, workspace: %v\n", workspace)
|
||||
tabId := workspace.ActiveTabId
|
||||
newTabLayout := GetNewTabLayout()
|
||||
|
||||
err := ApplyPortableLayout(ctx, tabId, newTabLayout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error applying new window layout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BootstrapStarterLayout(ctx context.Context) error {
|
||||
ctx, cancelFn := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancelFn()
|
@ -19,11 +19,6 @@ import (
|
||||
// the wcore package coordinates actions across the storage layer
|
||||
// orchestrating the wave object store, the wave pubsub system, and the wave rpc system
|
||||
|
||||
// TODO bring Tx infra into wcore
|
||||
|
||||
const DefaultTimeout = 2 * time.Second
|
||||
const DefaultActivateBlockTimeout = 60 * time.Second
|
||||
|
||||
// Ensures that the initial data is present in the store, creates an initial window if needed
|
||||
func EnsureInitialData() error {
|
||||
// does not need to run in a transaction since it is called on startup
|
||||
@ -58,16 +53,12 @@ func EnsureInitialData() error {
|
||||
log.Println("client has windows")
|
||||
return nil
|
||||
}
|
||||
log.Println("client has no windows, creating default workspace")
|
||||
defaultWs, err := CreateWorkspace(ctx, "Default workspace", "circle", "green")
|
||||
log.Println("client has no windows, creating starter workspace")
|
||||
starterWs, err := CreateWorkspace(ctx, "Starter workspace", "circle", "#58C142", true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating default workspace: %w", err)
|
||||
return fmt.Errorf("error creating starter workspace: %w", err)
|
||||
}
|
||||
_, err = CreateTab(ctx, defaultWs.OID, "", true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
_, err = CreateWindow(ctx, nil, defaultWs.OID)
|
||||
_, err = CreateWindow(ctx, nil, starterWs.OID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating window: %w", err)
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ func CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId str
|
||||
log.Printf("CreateWindow %v %v\n", winSize, workspaceId)
|
||||
var ws *waveobj.Workspace
|
||||
if workspaceId == "" {
|
||||
ws1, err := CreateWorkspace(ctx, "", "", "")
|
||||
ws1, err := CreateWorkspace(ctx, "", "", "", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating workspace: %w", err)
|
||||
}
|
||||
@ -176,7 +176,7 @@ func CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window {
|
||||
}
|
||||
if len(ws.TabIds) == 0 {
|
||||
log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID)
|
||||
_, err = CreateTab(ctx, ws.OID, "", true, false)
|
||||
_, err = CreateTab(ctx, ws.OID, "", true, false, false)
|
||||
if err != nil {
|
||||
log.Printf("error creating tab (in checkAndFixWindow): %v\n", err)
|
||||
}
|
||||
|
@ -11,12 +11,13 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/telemetry"
|
||||
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||
"github.com/wavetermdev/waveterm/pkg/wstore"
|
||||
)
|
||||
|
||||
func CreateWorkspace(ctx context.Context, name string, icon string, color string) (*waveobj.Workspace, error) {
|
||||
log.Println("CreateWorkspace")
|
||||
func CreateWorkspace(ctx context.Context, name string, icon string, color string, isInitialLaunch bool) (*waveobj.Workspace, error) {
|
||||
ws := &waveobj.Workspace{
|
||||
OID: uuid.NewString(),
|
||||
TabIds: []string{},
|
||||
@ -25,7 +26,22 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string
|
||||
Icon: icon,
|
||||
Color: color,
|
||||
}
|
||||
wstore.DBInsert(ctx, ws)
|
||||
err := wstore.DBInsert(ctx, ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error inserting workspace: %w", err)
|
||||
}
|
||||
_, err = CreateTab(ctx, ws.OID, "", true, false, isInitialLaunch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
|
||||
wps.Broker.Publish(wps.WaveEvent{
|
||||
Event: wps.Event_WorkspaceUpdate})
|
||||
|
||||
ws, err = GetWorkspace(ctx, ws.OID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting updated workspace: %w", err)
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
@ -38,7 +54,7 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error getting workspace: %w", err)
|
||||
}
|
||||
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 && len(workspace.PinnedTabIds) > 0 {
|
||||
if workspace.Name != "" && workspace.Icon != "" && !force && (len(workspace.TabIds) > 0 || len(workspace.PinnedTabIds) > 0) {
|
||||
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
|
||||
return false, nil
|
||||
}
|
||||
@ -56,6 +72,8 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
|
||||
return false, fmt.Errorf("error deleting workspace: %w", err)
|
||||
}
|
||||
log.Printf("deleted workspace %s\n", workspaceId)
|
||||
wps.Broker.Publish(wps.WaveEvent{
|
||||
Event: wps.Event_WorkspaceUpdate})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@ -63,8 +81,18 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error)
|
||||
return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID)
|
||||
}
|
||||
|
||||
func getTabPresetMeta() (waveobj.MetaMapType, error) {
|
||||
settings := wconfig.GetWatcher().GetFullConfig()
|
||||
tabPreset := settings.Settings.TabPreset
|
||||
if tabPreset == "" {
|
||||
return nil, nil
|
||||
}
|
||||
presetMeta := settings.Presets[tabPreset]
|
||||
return presetMeta, nil
|
||||
}
|
||||
|
||||
// returns tabid
|
||||
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool) (string, error) {
|
||||
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool, isInitialLaunch bool) (string, error) {
|
||||
if tabName == "" {
|
||||
ws, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
@ -72,7 +100,9 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate
|
||||
}
|
||||
tabName = "T" + fmt.Sprint(len(ws.TabIds)+len(ws.PinnedTabIds)+1)
|
||||
}
|
||||
tab, err := createTabObj(ctx, workspaceId, tabName, pinned)
|
||||
|
||||
// The initial tab for the initial launch should be pinned
|
||||
tab, err := createTabObj(ctx, workspaceId, tabName, pinned || isInitialLaunch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
@ -82,6 +112,21 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate
|
||||
return "", fmt.Errorf("error setting active tab: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// No need to apply an initial layout for the initial launch, since the starter layout will get applied after TOS modal dismissal
|
||||
if !isInitialLaunch {
|
||||
err = ApplyPortableLayout(ctx, tab.OID, GetNewTabLayout())
|
||||
if err != nil {
|
||||
return tab.OID, fmt.Errorf("error applying new tab layout: %w", err)
|
||||
}
|
||||
presetMeta, presetErr := getTabPresetMeta()
|
||||
if presetErr != nil {
|
||||
log.Printf("error getting tab preset meta: %v\n", presetErr)
|
||||
} else if presetMeta != nil && len(presetMeta) > 0 {
|
||||
tabORef := waveobj.ORefFromWaveObj(tab)
|
||||
wstore.UpdateObjectMeta(ctx, *tabORef, presetMeta, true)
|
||||
}
|
||||
}
|
||||
telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
|
||||
return tab.OID, nil
|
||||
}
|
||||
@ -163,7 +208,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
|
||||
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
|
||||
|
||||
// if no tabs remaining, close window
|
||||
if newActiveTabId == "" && recursive {
|
||||
if recursive && newActiveTabId == "" {
|
||||
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
|
||||
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
|
||||
if err != nil {
|
||||
|
@ -12,6 +12,7 @@ const (
|
||||
Event_Config = "config"
|
||||
Event_UserInput = "userinput"
|
||||
Event_RouteGone = "route:gone"
|
||||
Event_WorkspaceUpdate = "workspace:update"
|
||||
)
|
||||
|
||||
type WaveEvent struct {
|
||||
|
@ -115,6 +115,12 @@ func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData
|
||||
return err
|
||||
}
|
||||
|
||||
// command "dismisswshfail", wshserver.DismissWshFailCommand
|
||||
func DismissWshFailCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "dismisswshfail", data, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// command "dispose", wshserver.DisposeCommand
|
||||
func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts)
|
||||
@ -317,6 +323,12 @@ func SetConfigCommand(w *wshutil.WshRpc, data wshrpc.MetaSettingsType, opts *wsh
|
||||
return err
|
||||
}
|
||||
|
||||
// command "setconnectionsconfig", wshserver.SetConnectionsConfigCommand
|
||||
func SetConnectionsConfigCommand(w *wshutil.WshRpc, data wshrpc.ConnConfigRequest, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "setconnectionsconfig", data, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// command "setmeta", wshserver.SetMetaCommand
|
||||
func SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "setmeta", data, opts)
|
||||
|
@ -59,6 +59,8 @@ const (
|
||||
Command_StreamWaveAi = "streamwaveai"
|
||||
Command_StreamCpuData = "streamcpudata"
|
||||
Command_Test = "test"
|
||||
Command_SetConfig = "setconfig"
|
||||
Command_SetConnectionsConfig = "connectionsconfig"
|
||||
Command_RemoteStreamFile = "remotestreamfile"
|
||||
Command_RemoteFileInfo = "remotefileinfo"
|
||||
Command_RemoteFileTouch = "remotefiletouch"
|
||||
@ -81,6 +83,7 @@ const (
|
||||
Command_ConnList = "connlist"
|
||||
Command_WslList = "wsllist"
|
||||
Command_WslDefaultDistro = "wsldefaultdistro"
|
||||
Command_DismissWshFail = "dismisswshfail"
|
||||
|
||||
Command_WorkspaceList = "workspacelist"
|
||||
|
||||
@ -139,6 +142,7 @@ type WshRpcInterface interface {
|
||||
StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData]
|
||||
TestCommand(ctx context.Context, data string) error
|
||||
SetConfigCommand(ctx context.Context, data MetaSettingsType) error
|
||||
SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error
|
||||
BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error)
|
||||
WaveInfoCommand(ctx context.Context) (*WaveInfoData, error)
|
||||
WshActivityCommand(ct context.Context, data map[string]int) error
|
||||
@ -156,6 +160,7 @@ type WshRpcInterface interface {
|
||||
ConnListCommand(ctx context.Context) ([]string, error)
|
||||
WslListCommand(ctx context.Context) ([]string, error)
|
||||
WslDefaultDistroCommand(ctx context.Context) (string, error)
|
||||
DismissWshFailCommand(ctx context.Context, connName string) error
|
||||
|
||||
// eventrecv is special, it's handled internally by WshRpc with EventListener
|
||||
EventRecvCommand(ctx context.Context, data wps.WaveEvent) error
|
||||
@ -512,6 +517,11 @@ func (m MetaSettingsType) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.MetaMapType)
|
||||
}
|
||||
|
||||
type ConnConfigRequest struct {
|
||||
Host string `json:"host"`
|
||||
MetaMapType waveobj.MetaMapType `json:"metamaptype"`
|
||||
}
|
||||
|
||||
type ConnStatus struct {
|
||||
Status string `json:"status"`
|
||||
WshEnabled bool `json:"wshenabled"`
|
||||
@ -520,6 +530,7 @@ type ConnStatus struct {
|
||||
HasConnected bool `json:"hasconnected"` // true if it has *ever* connected successfully
|
||||
ActiveConnNum int `json:"activeconnnum"`
|
||||
Error string `json:"error,omitempty"`
|
||||
WshError string `json:"wsherror,omitempty"`
|
||||
}
|
||||
|
||||
type WebSelectorOpts struct {
|
||||
|
@ -29,7 +29,6 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig"
|
||||
"github.com/wavetermdev/waveterm/pkg/wcore"
|
||||
"github.com/wavetermdev/waveterm/pkg/wlayout"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
@ -121,7 +120,7 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM
|
||||
func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error {
|
||||
log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta)
|
||||
oref := data.ORef
|
||||
err := wstore.UpdateObjectMeta(ctx, oref, data.Meta)
|
||||
err := wstore.UpdateObjectMeta(ctx, oref, data.Meta, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating object meta: %w", err)
|
||||
}
|
||||
@ -180,8 +179,8 @@ func (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.Command
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating block: %w", err)
|
||||
}
|
||||
err = wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{
|
||||
ActionType: wlayout.LayoutActionDataType_Insert,
|
||||
err = wcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{
|
||||
ActionType: wcore.LayoutActionDataType_Insert,
|
||||
BlockId: blockData.OID,
|
||||
Magnified: data.Magnified,
|
||||
Focused: true,
|
||||
@ -506,8 +505,8 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting block: %w", err)
|
||||
}
|
||||
wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{
|
||||
ActionType: wlayout.LayoutActionDataType_Remove,
|
||||
wcore.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{
|
||||
ActionType: wcore.LayoutActionDataType_Remove,
|
||||
BlockId: data.BlockId,
|
||||
})
|
||||
updates := waveobj.ContextGetUpdatesRtn(ctx)
|
||||
@ -575,6 +574,11 @@ func (ws *WshServer) SetConfigCommand(ctx context.Context, data wshrpc.MetaSetti
|
||||
return wconfig.SetBaseConfigValue(data.MetaMapType)
|
||||
}
|
||||
|
||||
func (ws *WshServer) SetConnectionsConfigCommand(ctx context.Context, data wshrpc.ConnConfigRequest) error {
|
||||
log.Printf("SET CONNECTIONS CONFIG: %v\n", data)
|
||||
return wconfig.SetConnectionsConfigValue(data.Host, data.MetaMapType)
|
||||
}
|
||||
|
||||
func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) {
|
||||
rtn := conncontroller.GetAllConnStatus()
|
||||
return rtn, nil
|
||||
@ -685,6 +689,25 @@ func (ws *WshServer) WslDefaultDistroCommand(ctx context.Context) (string, error
|
||||
return distro.Name(), nil
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the WshFail Command in runtime memory on the backend
|
||||
*/
|
||||
func (ws *WshServer) DismissWshFailCommand(ctx context.Context, connName string) error {
|
||||
opts, err := remote.ParseOpts(connName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn := conncontroller.GetConn(ctx, opts, false, nil)
|
||||
if conn == nil {
|
||||
return fmt.Errorf("connection %s not found", connName)
|
||||
}
|
||||
conn.WithLock(func() {
|
||||
conn.WshError = ""
|
||||
})
|
||||
conn.FireConnChangeEvent()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) {
|
||||
blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
|
||||
if err != nil {
|
||||
|
@ -89,6 +89,7 @@ func (conn *WslConn) DeriveConnStatus() wshrpc.ConnStatus {
|
||||
return wshrpc.ConnStatus{
|
||||
Status: conn.Status,
|
||||
Connected: conn.Status == Status_Connected,
|
||||
WshEnabled: true, // always use wsh for wsl connections (temporary)
|
||||
Connection: conn.GetName(),
|
||||
HasConnected: (conn.LastConnectTime > 0),
|
||||
ActiveConnNum: conn.ActiveConnNum,
|
||||
|
@ -53,7 +53,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) error {
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType) error {
|
||||
func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType, mergeSpecial bool) error {
|
||||
return WithTx(ctx, func(tx *TxWrap) error {
|
||||
if oref.IsEmpty() {
|
||||
return fmt.Errorf("empty object reference")
|
||||
@ -66,7 +66,7 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM
|
||||
if objMeta == nil {
|
||||
objMeta = make(map[string]any)
|
||||
}
|
||||
newMeta := waveobj.MergeMeta(objMeta, meta, false)
|
||||
newMeta := waveobj.MergeMeta(objMeta, meta, mergeSpecial)
|
||||
waveobj.SetMeta(obj, newMeta)
|
||||
DBUpdate(tx.Context(), obj)
|
||||
return nil
|
||||
|
14
yarn.lock
14
yarn.lock
@ -16401,13 +16401,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:0.1.10":
|
||||
version: 0.1.10
|
||||
resolution: "path-to-regexp@npm:0.1.10"
|
||||
checksum: 10c0/34196775b9113ca6df88e94c8d83ba82c0e1a2063dd33bfe2803a980da8d49b91db8104f49d5191b44ea780d46b8670ce2b7f4a5e349b0c48c6779b653f1afe4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "path-to-regexp@npm:3.3.0"
|
||||
@ -16415,6 +16408,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:^0.1.12":
|
||||
version: 0.1.12
|
||||
resolution: "path-to-regexp@npm:0.1.12"
|
||||
checksum: 10c0/1c6ff10ca169b773f3bba943bbc6a07182e332464704572962d277b900aeee81ac6aa5d060ff9e01149636c30b1f63af6e69dd7786ba6e0ddb39d4dee1f0645b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:^1.7.0":
|
||||
version: 1.9.0
|
||||
resolution: "path-to-regexp@npm:1.9.0"
|
||||
|
Loading…
Reference in New Issue
Block a user