diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..984c2c6fc
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,10 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+
+[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less}]
+charset = utf-8
+indent_style = space
+indent_size = 4
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..fbd75d33c
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=lf
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
new file mode 100644
index 000000000..3db62b1a9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -0,0 +1,31 @@
+---
+name: Bug Report
+about: Create a bug report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. MacOS/Linux, x64 or arm64]
+ - Version [e.g. v0.5.0]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md
new file mode 100644
index 000000000..9eb042ae7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.md
@@ -0,0 +1,14 @@
+---
+name: Feature Request
+about: Suggest a new idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..80a999287
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,39 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "friday"
+ time: "09:00"
+ timezone: "America/Los_Angeles"
+ groups:
+ dev-dependencies:
+ dependency-type: "development"
+ exclude-patterns:
+ - "*storybook*"
+ - "*electron*"
+ storybook:
+ patterns:
+ - "*storybook*"
+ prod-dependencies:
+ dependency-type: "production"
+ exclude-patterns:
+ - "*electron*"
+ electron:
+ patterns:
+ - "*electron*"
+ - package-ecosystem: "gomod"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "friday"
+ time: "09:00"
+ timezone: "America/Los_Angeles"
+ - package-ecosystem: "github-actions"
+ directory: "/.github/workflows"
+ schedule:
+ interval: "weekly"
+ day: "friday"
+ time: "09:00"
+ timezone: "America/Los_Angeles"
diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml
new file mode 100644
index 000000000..108e0f482
--- /dev/null
+++ b/.github/workflows/build-helper.yml
@@ -0,0 +1,151 @@
+# Build Helper workflow - Builds, signs, and packages binaries for each supported platform, then uploads to a staging bucket in S3 for wider distribution.
+# For more information on the macOS signing and notarization, see https://www.electron.build/code-signing and https://www.electron.build/configuration/mac
+# For more information on the Windows Code Signing, see https://docs.digicert.com/en/digicert-keylocker/ci-cd-integrations/plugins/github-custom-action-for-keypair-signing.html and https://docs.digicert.com/en/digicert-keylocker/signing-tools/sign-authenticode-with-electron-builder-using-ksp-integration.html
+
+name: Build Helper
+run-name: Build ${{ github.ref_name }}
+on:
+ push:
+ tags:
+ - "v[0-9]+.[0-9]+.[0-9]+*"
+env:
+ GO_VERSION: "1.22.5"
+ NODE_VERSION: "22.5.1"
+jobs:
+ runbuild:
+ permissions:
+ contents: write
+ outputs:
+ version: ${{ steps.set-version.outputs.WAVETERM_VERSION }}
+ strategy:
+ matrix:
+ include:
+ - platform: "darwin"
+ runner: "macos-latest-xlarge"
+ - platform: "linux"
+ runner: "ubuntu-latest"
+ - platform: "linux"
+ runner: ubuntu-24.04-arm64-16core
+ - platform: "windows"
+ runner: "windows-latest"
+ # - platform: "windows"
+ # runner: "windows-11-arm64-16core"
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install Linux Build Dependencies (Linux only)
+ if: matrix.platform == 'linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools
+
+ # The pre-installed version of the AWS CLI has a segfault problem so we'll install it via Homebrew instead.
+ - name: Upgrade AWS CLI (Mac only)
+ if: matrix.platform == 'darwin'
+ run: brew update && brew install awscli
+
+ # The version of FPM that comes bundled with electron-builder doesn't include a Linux ARM target. Installing Gems onto the runner is super quick so we'll just do this for all targets.
+ - name: Install FPM (not Windows)
+ if: matrix.platform != 'windows'
+ run: sudo gem install fpm
+ - name: Install FPM (Windows only)
+ if: matrix.platform == 'windows'
+ run: gem install fpm
+
+ # General build dependencies
+ - uses: actions/setup-go@v5
+ with:
+ go-version: ${{env.GO_VERSION}}
+ cache-dependency-path: |
+ go.sum
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{env.NODE_VERSION}}
+ - name: Install Yarn
+ run: |
+ corepack enable
+ yarn install
+ - name: Install Task
+ uses: arduino/setup-task@v2
+ with:
+ version: 3.x
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Set Version"
+ id: set-version
+ run: echo "WAVETERM_VERSION=$(task version)" >> "$GITHUB_OUTPUT"
+ shell: bash
+
+ # Windows Code Signing Setup
+ - name: Set up certificate (Windows only)
+ if: matrix.platform == 'windows'
+ run: |
+ echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12
+ shell: bash
+ - name: Set signing variables (Windows only)
+ if: matrix.platform == 'windows'
+ id: variables
+ run: |
+ echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV"
+ echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV"
+ echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV"
+ echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV"
+ echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_OUTPUT"
+ echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV"
+ echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH
+ echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH
+ echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH
+ shell: bash
+ - name: Setup Keylocker KSP (Windows only)
+ if: matrix.platform == 'windows'
+ run: |
+ curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o Keylockertools-windows-x64.msi
+ msiexec /i Keylockertools-windows-x64.msi /quiet /qn
+ C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user
+ smctl windows certsync
+ shell: cmd
+
+ # Build and upload packages
+ - name: Build (not Windows)
+ if: matrix.platform != 'windows'
+ run: task package
+ 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}}
+ CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD_2 }}
+ APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID_2 }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD_2 }}
+ APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID_2 }}
+ - name: Build (Windows only)
+ if: matrix.platform == 'windows'
+ run: task package
+ env:
+ USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one.
+ CSC_LINK: ${{ steps.variables.outputs.SM_CLIENT_CERT_FILE }}
+ CSC_KEY_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
+ shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell
+ - name: Upload to S3 staging
+ run: task artifacts:upload
+ env:
+ AWS_ACCESS_KEY_ID: "${{ secrets.ARTIFACTS_KEY_ID }}"
+ AWS_SECRET_ACCESS_KEY: "${{ secrets.ARTIFACTS_KEY_SECRET }}"
+ AWS_DEFAULT_REGION: us-west-2
+
+ - name: Create draft release
+ uses: softprops/action-gh-release@v2
+ with:
+ prerelease: ${{ contains(github.ref_name, '-beta') }}
+ name: Wave Terminal ${{ github.ref_name }} Release
+ generate_release_notes: true
+ draft: true
+ files: |
+ make/*.zip
+ make/*.dmg
+ make/*.exe
+ make/*.msi
+ make/*.rpm
+ make/*.deb
+ make/*.pacman
+ make/*.snap
+ make/*.flatpak
+ make/*.AppImage
diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml
new file mode 100644
index 000000000..fd6cd78d0
--- /dev/null
+++ b/.github/workflows/bump-version.yml
@@ -0,0 +1,83 @@
+# Workflow to manage bumping the package version and pushing it to the target branch with a new tag.
+# This workflow uses a GitHub App to bypass branch protection and uses the GitHub API directly to ensure commits and tags are signed.
+# For more information, see this doc: https://github.com/Nautilus-Cyberneering/pygithub/blob/main/docs/how_to_sign_automatic_commits_in_github_actions.md
+
+name: Bump Version
+run-name: "branch: ${{ github.ref_name }}; semver-bump: ${{ inputs.bump }}; prerelease: ${{ inputs.is-prerelease }}"
+on:
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: SemVer Bump
+ required: true
+ type: choice
+ default: none
+ options:
+ - none
+ - patch
+ - minor
+ - major
+ is-prerelease:
+ description: Is Prerelease
+ required: true
+ type: boolean
+ default: true
+env:
+ NODE_VERSION: "22.5.1"
+jobs:
+ bump-version:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Get App Token
+ uses: actions/create-github-app-token@v1
+ id: app-token
+ with:
+ app-id: ${{ vars.WAVE_BUILDER_APPID }}
+ private-key: ${{ secrets.WAVE_BUILDER_KEY }}
+ - uses: actions/checkout@v4
+ with:
+ token: ${{ steps.app-token.outputs.token }}
+
+ # General build dependencies
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{env.NODE_VERSION}}
+ - name: Install Yarn
+ run: |
+ corepack enable
+ yarn install
+ - name: Install Task
+ uses: arduino/setup-task@v2
+ with:
+ version: 3.x
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Bump Version: ${{ inputs.bump }}"
+ id: bump-version
+ run: echo "WAVETERM_VERSION=$( task version -- ${{ inputs.bump }} ${{inputs.is-prerelease}} )" >> "$GITHUB_OUTPUT"
+ shell: bash
+
+ - name: "Push version bump: ${{ steps.bump-version.outputs.WAVETERM_VERSION }}"
+ run: |
+ # Create a new commit for the package version bump in package.json
+ export VERSION=${{ steps.bump-version.outputs.WAVETERM_VERSION }}
+ export MESSAGE="chore: bump package version to $VERSION"
+ export FILE=package.json
+ export BRANCH=${{github.ref_name}}
+ export SHA=$( git rev-parse $BRANCH:$FILE )
+ export CONTENT=$( base64 -i $FILE )
+ gh api --method PUT /repos/:owner/:repo/contents/$FILE \
+ --field branch="$BRANCH" \
+ --field message="$MESSAGE" \
+ --field content="$CONTENT" \
+ --field sha="$SHA"
+
+ # Fetch the new commit and create a tag referencing it
+ git fetch
+ export TAG_SHA=$( git rev-parse origin/$BRANCH )
+ gh api --method POST /repos/:owner/:repo/git/refs \
+ --field ref="refs/tags/v$VERSION" \
+ --field sha="$TAG_SHA"
+ shell: bash
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..f7786e4b9
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,114 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ schedule:
+ - cron: "36 5 * * 5"
+
+env:
+ NODE_VERSION: "21.5.0"
+
+jobs:
+ analyze:
+ name: Analyze
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
+ # - https://gh.io/supported-runners-and-hardware-resources
+ # - https://gh.io/using-larger-runners
+ # Consider using larger runners for possible analysis time improvements.
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: ["go", "javascript-typescript"]
+ # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
+ # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
+ # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+ # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install Task
+ uses: arduino/setup-task@v2
+ with:
+ version: 3.x
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: stable
+ cache-dependency-path: |
+ go.sum
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{env.NODE_VERSION}}
+ - name: Install yarn
+ run: |
+ corepack enable
+ yarn install
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+ - name: Generate bindings
+ run: task generate
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild (not Go)
+ if: matrix.language != 'go'
+ uses: github/codeql-action/autobuild@v3
+
+ - name: Build (Go only)
+ if: matrix.language == 'go'
+ run: |
+ task build:server
+ task build:wsh
+
+ # ℹ️ Command-line programs to run using the OS shell.
+ # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+
+ # If the Autobuild fails above, remove it and uncomment the following three lines.
+ # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
+
+ # - run: |
+ # echo "Run, Build Application using script"
+ # ./location_of_script_within_repo/buildscript.sh
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
new file mode 100644
index 000000000..3d7fdc978
--- /dev/null
+++ b/.github/workflows/publish-release.yml
@@ -0,0 +1,25 @@
+# Workflow to copy artifacts from the staging bucket to the release bucket when a new GitHub Release is published.
+
+name: Publish Release
+run-name: Publish ${{ github.ref_name }}
+on:
+ release:
+ types: [published]
+jobs:
+ publish:
+ 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
+ if: startsWith(github.ref, 'refs/tags/')
+ 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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..ec8952a17
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,31 @@
+.task
+frontend/dist
+dist/
+dist-dev/
+frontend/node_modules
+node_modules/
+frontend/bindings
+bindings/
+*.log
+bin/
+*.dmg
+*.exe
+.DS_Store
+*~
+out/
+make/
+artifacts/
+
+# Yarn Modern
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+
+
+*storybook.log
+
+test-results.xml
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000..885ce6337
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,8 @@
+build
+bin
+.git
+frontend/dist
+frontend/node_modules
+*.min.*
+frontend/app/store/services.ts
+frontend/types/gotypes.d.ts
diff --git a/.storybook/global.css b/.storybook/global.css
new file mode 100644
index 000000000..7a132c094
--- /dev/null
+++ b/.storybook/global.css
@@ -0,0 +1,17 @@
+body {
+ height: 100vh;
+ padding: 0;
+}
+
+#storybook-root {
+ height: 100%;
+}
+
+.grid-item {
+ background-color: aquamarine;
+ border: 1px black solid;
+
+ &.react-grid-placeholder {
+ background-color: orange;
+ }
+}
diff --git a/.storybook/main.ts b/.storybook/main.ts
new file mode 100644
index 000000000..b1af7817e
--- /dev/null
+++ b/.storybook/main.ts
@@ -0,0 +1,41 @@
+import type { StorybookConfig } from "@storybook/react-vite";
+import type { ElectronViteConfig } from "electron-vite";
+import type { UserConfig } from "vite";
+
+const config: StorybookConfig = {
+ stories: ["../frontend/**/*.mdx", "../frontend/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
+
+ addons: [
+ "@storybook/addon-links",
+ "@storybook/addon-essentials",
+ "@chromatic-com/storybook",
+ "@storybook/addon-interactions",
+ ],
+
+ core: {},
+
+ framework: {
+ name: "@storybook/react-vite",
+ options: {},
+ },
+
+ docs: {},
+
+ managerHead: (head) => `
+ ${head}
+
+ `,
+
+ typescript: {
+ reactDocgen: "react-docgen-typescript",
+ },
+
+ async viteFinal(config) {
+ const { mergeConfig } = await import("vite");
+ const { tsImport } = await import("tsx/esm/api");
+ const electronViteConfig = (await tsImport("../electron.vite.config.ts", import.meta.url))
+ .default as ElectronViteConfig;
+ return mergeConfig(config, electronViteConfig.renderer as UserConfig);
+ },
+};
+export default config;
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
new file mode 100644
index 000000000..a6c2c09bb
--- /dev/null
+++ b/.storybook/preview.tsx
@@ -0,0 +1,29 @@
+// organize-imports-ignore
+import type { Preview } from "@storybook/react";
+import React from "react";
+import { DndProvider } from "react-dnd";
+import { HTML5Backend } from "react-dnd-html5-backend";
+import "./global.css";
+
+const preview: Preview = {
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ },
+
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+
+ tags: ["autodocs"],
+};
+
+export default preview;
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 000000000..23561f94b
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,9 @@
+{
+ "recommendations": [
+ "esbenp.prettier-vscode",
+ "golang.go",
+ "dbaeumer.vscode-eslint",
+ "vitest.explorer",
+ "task.vscode-task"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..17dd10412
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,40 @@
+{
+ "editor.formatOnSave": true,
+ "editor.detectIndentation": false,
+ "editor.formatOnPaste": true,
+ "editor.tabSize": 4,
+ "editor.insertSpaces": false,
+ "prettier.useEditorConfig": true,
+ "[javascript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[javascriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[less]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[css]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[html]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[json]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[yaml]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.insertSpaces": true,
+ "editor.autoIndent": "keep"
+ },
+ "[go]": {
+ "editor.defaultFormatter": "golang.go"
+ }
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 000000000..03571dc88
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,18 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Start Storybook",
+ "type": "shell",
+ "command": "yarn storybook",
+ "presentation": {
+ "reveal": "silent",
+ "panel": "shared"
+ },
+ "runOptions": {
+ "instanceLimit": 1,
+ "runOn": "folderOpen"
+ }
+ }
+ ]
+}
diff --git a/.yarnrc.yml b/.yarnrc.yml
new file mode 100644
index 000000000..3186f3f07
--- /dev/null
+++ b/.yarnrc.yml
@@ -0,0 +1 @@
+nodeLinker: node-modules
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..a7cae03be
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+coc@commandline.dev.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..55a1d00e7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,47 @@
+# Contributing to Wave Terminal
+
+We welcome and value contributions to Wave Terminal! Wave is an open source project, always open for contributors. There are several ways you can contribute:
+ * Submit issues related to bugs or new feature requests
+ * Fix outstanding [issues](https://github.com/wavetermdev/waveterm/issues) with the existing code
+ * Contribute to [documentation](https://github.com/wavetermdev/waveterm-docs)
+ * Spread the word on social media (tag us on [LinkedIn](https://www.linkedin.com/company/commandlinedev), [Twitter/X](https://twitter.com/commandlinedev))
+ * Or simply ⭐️ the repository to show your appreciation
+
+However you choose to contribute, please be mindful and respect our [code of conduct](./CODE_OF_CONDUCT.md).
+
+> All contributions are highly appreciated! 🥰
+
+## Before You Start
+We accept patches in the form of github pull requests. If you are new to github, please review this [github pull request guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests).
+
+### Contributor License Agreement
+Contributions to this project must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution, this simply gives us permission to use and redistribute your contributions as part of the project.
+
+> On submission of your first pull request you will be prompted to sign the CLA confirming your original code contribution and that you own the intellectual property.
+
+### Style guide
+The project uses American English.
+
+Coding style and formatting is automated for each pull request. We use [Prettier](https://prettier.io/).
+
+## How to contribute
+
+ * For minor changes, you are welcome to [open a pull request](https://github.com/wavetermdev/waveterm/pulls).
+ * For major changes, please [create an issue](https://github.com/wavetermdev/waveterm/issues/new) first.
+ * If you are looking for a place to start take a look at [open issues](https://github.com/wavetermdev/waveterm/issues).
+ * Join the [Discord channel](https://discord.gg/XfvZ334gwU) to collaborate with the community on your contribution.
+
+
+### Development Environment
+
+To build and run wave term locally see instructions below:
+ * [MacOS build instructions](./BUILD.md)
+ * [Linux build instructions](./build-linux.md)
+
+### Create a Pull Request
+
+Guidelines:
+ * Before writing any code, please look through existing PRs or issues to make sure nobody is already working on the same thing.
+ * Develop features on a branch - do not work on the main branch
+ * For anything but minor fixes, please submit tests and documentation
+ * Please reference the issue in the pull request
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..b207beb17
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright 2024 Command Line Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 000000000..50edd27dc
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1 @@
+Copyright 2024, Command Line Inc.
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..a32d26c6a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+# Wave Terminal
+
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield)
+
+Wave is an open-source terminal that can launch graphical widgets, controlled and integrated directly with the CLI. It includes a base terminal, directory browser, file previews (images, media, markdown), a graphical editor (for code/text files), a web browser, and integrated AI chat.
+
+Wave isn't just another terminal emulator; it's a rethink on how terminals are built. For too long there has been a disconnect between the CLI and the web. If you want fast, keyboard-accessible, easy-to-write applications, you use the CLI, but if you want graphical interfaces, native widgets, copy/paste, scrolling, variable font sizes, then you'd have to turn to the web. Wave's goal is to bridge that gap.
+
+![WaveTerm Screenshot](./assets/wave-screenshot.png)
+
+## Installation
+
+Wave Terminal works on MacOS, Linux, and Windows.
+
+Install Wave Terminal from: [www.waveterm.dev/download](https://www.waveterm.dev/download)
+
+Also available as a homebrew cask for MacOS:
+
+```
+brew install --cask wave
+```
+
+## Links
+
+- Homepage — https://www.waveterm.dev
+- Download Page — https://www.waveterm.dev/download
+- Documentation — https://docs.waveterm.dev/
+- Blog — https://blog.waveterm.dev/
+- Discord Community — https://discord.gg/XfvZ334gwU
+
+## Building from Source
+
+We use `task` to build Wave.
+
+```bash
+brew install go-task
+```
+
+Once task is installed you can run this command to build and launch the development version of Wave. Note that the development database and settings are kept in a separate folder from the production version (~/.waveterm-dev) to prevent cross-corruption.
+
+```bash
+task electron:dev
+```
+
+## Contributing
+
+Wave uses Github Issues for issue tracking.
+
+Find more information in our [Contributions Guide](CONTRIBUTING.md), which includes:
+
+- [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal)
+- [Contribution guidelines](CONTRIBUTING.md#before-you-start)
+
+## License
+
+Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./acknowledgements/README.md).
diff --git a/RELEASES.md b/RELEASES.md
new file mode 100644
index 000000000..2bc7dc917
--- /dev/null
+++ b/RELEASES.md
@@ -0,0 +1,64 @@
+# Building for release
+
+## Step-by-step guide
+
+1. Go to the [Actions tab](https://github.com/wavetermdev/thenextwave/actions) and select "Bump Version" from the left sidebar.
+2. Click on "Run workflow". You will see two options:
+ - "SemVer Bump": This defaults to `none`. Adjust this if you want to increment the version number according to semantic versioning rules (`patch`, `minor`, `major`).
+ - "Is Prerelease": This defaults to `true`. If set to `true`, a `-beta.X` version will be appended to the end of the version. If one is already present and the base SemVer is not being incremented, the `-beta` version will be incremented (i.e. `0.1.13-beta.0` to `0.1.13-beta.1`).
+3. After "Bump Version" a "Build Helper" run will kick off automatically for the new version. When this completes, it will generate a draft GitHub Release with all the built artifacts.
+4. Review the artifacts in the release and test them locally.
+5. When you are confident that the build is good, edit the GitHub Release to add a changelog and release summary and publish the release.
+6. The new version will be published to our release feed automatically when the GitHub Release is published. If the build is a prerelease, it will only release to users subscribed to the `beta` channel. If it is a general release, it will be released to all users.
+
+## Details
+
+### Bump Version workflow
+
+All releases start by first bumping the package version and creating a new Git tag. We have a workflow set up to automate this.
+
+To run it, trigger a new run of the [Bump Version workflow](https://github.com/wavetermdev/thenextwave/actions/workflows/bump-version.yml). When triggering the run, you will be prompted to select a version bump type, either `none`, `patch`, `minor`, or `major`, and whether the version is prerelease or not. This determines how much the version number is incremented.
+
+See [`version.cjs`](../../version.cjs) for more details on how this works.
+
+Once the tag has been created, a new [Build Helper](#build-helper-workflow) run will be automatically queued to generate the artifacts.
+
+### Build Helper workflow
+
+Our release builds are managed by the [Build Helper workflow](https://github.com/wavetermdev/thenextwave/actions/workflows/build-helper.yml).
+
+Under the hood, this will call the `package` task in [`Taskfile.yml`](../../Taskfile.yml), which will build the `wavesrv` and `wsh` binaries, then the frontend and Electron codebases using Vite, then it will call `electron-builder` to generate the distributable app packages. The configuration for `electron-builder` is defined in [`electron-builder.config.cjs`](../../electron-builder.config.cjs).
+
+This will also sign and notarize the macOS app package.
+
+Once a build is complete, it will be placed in `s3://waveterm-github-artifacts/staging-w2/`. It can be downloaded for testing using the `artifacts:download:*` task. When you are ready to publish the artifacts to the public release feed, use the `artifacts:publish:*` task to directly copy the artifacts from the staging bucket to the releases bucket.
+
+You will need to configure an AWS CLI profile with write permissions for the S3 buckets in order for the script to work. You should invoke the tasks as follows:
+
+```bash
+task artifacts:: -- --profile
+```
+
+### Automatic updates
+
+Thanks to [`electron-updater`](https://www.electron.build/auto-update.html), we are able to provide automatic app updates for macOS, Linux, and Windows, as long as the app was distributed as a DMG, AppImage, RPM, or DEB file (all Windows targets support auto updates).
+
+With each release, YAML files will be produced that point to the newest release for the current channel. These also include file sizes and checksums to aid in validating the packages. The app will check these files in our S3 bucket every hour to see if a new version is available.
+
+#### Update channels
+
+We utilize update channels to roll out beta and stable releases. These are determined based on the package versioning [described above](#bump-version-workflow). Users can select their update channel using the `autoupdate:channel` setting in Wave. See [here](https://www.electron.build/tutorials/release-using-channels.html) for more information.
+
+#### Homebrew
+
+Homebrew is automatically bumped when new artifacts are published.
+
+#### Linux
+
+We do not currently submit the Linux packages to any of the package repositories. We are working on addressing this in the near future.
+
+### `electron-build` configuration
+
+Most of our configuration is fairly standard. The main exception to this is that we exclude our Go binaries from the ASAR archive that Electron generates. ASAR files cannot be executed by NodeJS because they are not seen as files and therefore cannot be executed via a Shell command. More information can be found [here](https://www.electronjs.org/docs/latest/tutorial/asar-archives#executing-binaries-inside-asar-archive).
+
+We also exclude most of our `node_modules` from packaging, as Vite handles packaging of any dependencies for us. The one exception is `monaco-editor`.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..10f45830a
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,5 @@
+## Reporting Security Issues
+
+To report vulnerabilities or security concerns, please email us at: [security@commandline.dev](mailto:security@commandline.dev)
+
+** Please do not report security vulnerabilities through public github issues. **
\ No newline at end of file
diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 000000000..8e4884821
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,274 @@
+# Copyright 2024, Command Line Inc.
+# SPDX-License-Identifier: Apache-2.0
+
+version: "3"
+
+vars:
+ APP_NAME: "TheNextWave"
+ BIN_DIR: "bin"
+ VERSION:
+ sh: node version.cjs
+ RM: '{{if eq OS "windows"}}cmd --% /c del /S{{else}}rm {{end}}'
+ RMRF: '{{if eq OS "windows"}}powershell Remove-Item -Force -Recurse{{else}}rm -rf{{end}}'
+ DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}'
+ ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2
+ RELEASES_BUCKET: dl.waveterm.dev/releases-w2
+
+tasks:
+ electron:dev:
+ desc: Run the Electron application via the Vite dev server (enables hot reloading).
+ cmds:
+ - yarn dev
+ deps:
+ - yarn
+ - build:backend
+ env:
+ WCLOUD_ENDPOINT: "https://ot2e112zx5.execute-api.us-west-2.amazonaws.com/dev"
+ WCLOUD_WS_ENDPOINT: "wss://5lfzlg5crl.execute-api.us-west-2.amazonaws.com/dev/"
+
+ electron:start:
+ desc: Run the Electron application directly.
+ cmds:
+ - yarn start
+ deps:
+ - yarn
+ - build:backend
+
+ package:
+ desc: Package the application for the current platform.
+ cmds:
+ - cmd: '{{.RMRF}} "make"'
+ ignore_error: true
+ - yarn build:prod && yarn electron-builder -c electron-builder.config.cjs -p never
+ deps:
+ - yarn
+ - build:backend
+
+ build:backend:
+ desc: Build the wavesrv and wsh components.
+ cmds:
+ - task: build:server
+ - task: build:wsh
+
+ build:server:
+ desc: Build the wavesrv component.
+ deps:
+ - generate
+ - build:server:linux
+ - build:server:macos
+ - build:server:windows
+
+ build:server:macos:
+ desc: Build the wavesrv component for macOS (Darwin) platforms (generates artifacts for both arm64 and amd64).
+ status:
+ - exit {{if eq OS "darwin"}}1{{else}}0{{end}}
+ cmds:
+ - cmd: "{{.RM}} dist/bin/wavesrv*"
+ ignore_error: true
+ - task: build:server:internal
+ vars:
+ ARCHS: arm64,amd64
+
+ build:server:windows:
+ desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture).
+ status:
+ - exit {{if eq OS "windows"}}1{{else}}0{{end}}
+ cmds:
+ - cmd: "{{.RM}} dist/bin/wavesrv*"
+ ignore_error: true
+ - task: build:server:internal
+ vars:
+ ARCHS:
+ sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}}
+
+ build:server:linux:
+ desc: Build the wavesrv component for Linux platforms (only generates artifacts for the current architecture).
+ status:
+ - exit {{if eq OS "linux"}}1{{else}}0{{end}}
+ cmds:
+ - cmd: "{{.RM}} dist/bin/wavesrv*"
+ ignore_error: true
+ - task: build:server:internal
+ vars:
+ ARCHS:
+ sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}}
+ GO_LDFLAGS: -linkmode 'external' -extldflags=-static
+
+ build:server:internal:
+ requires:
+ vars:
+ - ARCHS
+ cmds:
+ - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} go build -tags "osusergo,netcgo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go
+ for:
+ var: ARCHS
+ split: ","
+ as: GOARCH
+ sources:
+ - "cmd/server/*.go"
+ - "pkg/**/*.go"
+ generates:
+ - dist/bin/wavesrv.*{{exeExt}}
+ deps:
+ - go:mod:tidy
+ internal: true
+
+ build:wsh:
+ desc: Build the wsh component for all possible targets.
+ cmds:
+ - cmd: "{{.RM}} dist/bin/wsh*"
+ ignore_error: true
+ - task: build:wsh:internal
+ vars:
+ GOOS: darwin
+ GOARCH: arm64
+ - task: build:wsh:internal
+ vars:
+ GOOS: darwin
+ GOARCH: amd64
+ - task: build:wsh:internal
+ vars:
+ GOOS: linux
+ GOARCH: arm64
+ - task: build:wsh:internal
+ vars:
+ GOOS: linux
+ GOARCH: amd64
+ - task: build:wsh:internal
+ vars:
+ GOOS: windows
+ GOARCH: amd64
+ - task: build:wsh:internal
+ vars:
+ GOOS: windows
+ GOARCH: arm64
+ deps:
+ - generate
+
+ dev:installwsh:
+ desc: quick shortcut to rebuild wsh and install for macos arm64
+ requires:
+ vars:
+ - VERSION
+ cmds:
+ - task: build:wsh:internal
+ vars:
+ GOOS: darwin
+ GOARCH: arm64
+ - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/.waveterm-dev/bin/wsh
+
+ build:wsh:internal:
+ vars:
+ EXT:
+ sh: echo {{if eq .GOOS "windows"}}.exe{{end}}
+ NORMALIZEDARCH:
+ sh: echo {{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}
+ requires:
+ vars:
+ - GOOS
+ - GOARCH
+ - VERSION
+ sources:
+ - "cmd/wsh/**/*.go"
+ - "pkg/**/*.go"
+ generates:
+ - dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}}
+ cmds:
+ - (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go)
+ deps:
+ - go:mod:tidy
+ internal: true
+
+ generate:
+ desc: Generate Typescript bindings for the Go backend.
+ cmds:
+ - go run cmd/generatets/main-generatets.go
+ - go run cmd/generatego/main-generatego.go
+ sources:
+ - "cmd/generatego/*.go"
+ - "cmd/generatets/*.go"
+ - "pkg/service/**/*.go"
+ - "pkg/waveobj/*.go"
+ - "pkg/wconfig/**/*.go"
+ - "pkg/wstore/*.go"
+ - "pkg/wshrpc/**/*.go"
+ - "pkg/tsgen/**/*.go"
+ - "pkg/gogen/**/*.go"
+ - "pkg/wconfig/**/*.go"
+ - "pkg/eventbus/eventbus.go"
+ generates:
+ - frontend/types/gotypes.d.ts
+ - pkg/wshrpc/wshclient/wshclient.go
+ - frontend/app/store/services.ts
+ - frontend/app/store/wshserver.ts
+
+ version:
+ desc: Get the current package version, or bump version if args are present. To pass args to `version.cjs`, add them after `--`.
+ summary: |
+ If no arguments are present, the current version will be returned.
+ If only a single argument is given, the following are valid inputs:
+ - `none`: No-op.
+ - `patch`: Bumps the patch version.
+ - `minor`: Bumps the minor version.
+ - `major`: Bumps the major version.
+ - '1', 'true': Bumps the prerelease version.
+ If two arguments are given, the first argument must be either `none`, `patch`, `minor`, or `major`. The second argument must be `1` or `true` to bump the prerelease version.
+ cmd: node version.cjs {{.CLI_ARGS}}
+
+ artifacts:upload:
+ desc: Uploads build artifacts to the staging bucket in S3. To add additional AWS CLI arguments, add them after `--`.
+ vars:
+ ORIGIN: "make/"
+ DESTINATION: "{{.ARTIFACTS_BUCKET}}/{{.VERSION}}"
+ cmd: aws s3 cp {{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive --exclude "*/*" --exclude "builder-*.yml" {{.CLI_ARGS}}
+
+ artifacts:download:*:
+ desc: Downloads the specified artifacts version from the staging bucket. To add additional AWS CLI arguments, add them after `--`.
+ vars:
+ DL_VERSION: '{{ replace "v" "" (index .MATCH 0)}}'
+ ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.DL_VERSION}}"
+ DESTINATION: "artifacts/{{.DL_VERSION}}"
+ cmds:
+ - '{{.RMRF}} "{{.DESTINATION}}"'
+ - aws s3 cp s3://{{.ORIGIN}}/ {{.DESTINATION}}/ --recursive {{.CLI_ARGS}}
+
+ artifacts:publish:*:
+ desc: Publishes the specified artifacts version from the staging bucket to the releases bucket. To add additional AWS CLI arguments, add them after `--`.
+ vars:
+ UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}'
+ ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.UP_VERSION}}"
+ DESTINATION: "{{.RELEASES_BUCKET}}"
+ cmd: |
+ OUTPUT=$(aws s3 cp s3://{{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive {{.CLI_ARGS}})
+
+ for line in $OUTPUT; do
+ PREFIX=${line%%{{.DESTINATION}}*}
+ SUFFIX=${line:${#PREFIX}}
+ if [[ -n "$SUFFIX" ]]; then
+ echo "https://$SUFFIX"
+ fi
+ done
+
+ yarn:
+ desc: Runs `yarn`
+ internal: true
+ generates:
+ - node_modules/**/*
+ - yarn.lock
+ - .yarn/*
+ sources:
+ - yarn.lock
+ - package.json
+ - .yarnrc.yml
+ cmds:
+ - yarn
+
+ go:mod:tidy:
+ desc: Runs `go mod tidy`
+ internal: true
+ generates:
+ - go.sum
+ sources:
+ - go.mod
+ cmds:
+ - go mod tidy
diff --git a/assets/wave-screenshot.png b/assets/wave-screenshot.png
new file mode 100644
index 000000000..920e95380
Binary files /dev/null and b/assets/wave-screenshot.png differ
diff --git a/build/appicon.png b/build/appicon.png
new file mode 100644
index 000000000..a10809fb0
Binary files /dev/null and b/build/appicon.png differ
diff --git a/build/icon.ico b/build/icon.ico
new file mode 100644
index 000000000..523704eaa
Binary files /dev/null and b/build/icon.ico differ
diff --git a/build/icons.icns b/build/icons.icns
new file mode 100644
index 000000000..79cd56e67
Binary files /dev/null and b/build/icons.icns differ
diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go
new file mode 100644
index 000000000..9c4a172f8
--- /dev/null
+++ b/cmd/generatego/main-generatego.go
@@ -0,0 +1,79 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "reflect"
+ "strings"
+
+ "github.com/wavetermdev/waveterm/pkg/gogen"
+ "github.com/wavetermdev/waveterm/pkg/util/utilfn"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wconfig"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+)
+
+const WshClientFileName = "pkg/wshrpc/wshclient/wshclient.go"
+const WaveObjMetaConstsFileName = "pkg/waveobj/metaconsts.go"
+const SettingsMetaConstsFileName = "pkg/wconfig/metaconsts.go"
+
+func GenerateWshClient() {
+ fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName)
+ var buf strings.Builder
+ gogen.GenerateBoilerplate(&buf, "wshclient", []string{
+ "github.com/wavetermdev/waveterm/pkg/wshutil",
+ "github.com/wavetermdev/waveterm/pkg/wshrpc",
+ "github.com/wavetermdev/waveterm/pkg/waveobj",
+ "github.com/wavetermdev/waveterm/pkg/wconfig",
+ "github.com/wavetermdev/waveterm/pkg/wps",
+ })
+ wshDeclMap := wshrpc.GenerateWshCommandDeclMap()
+ for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) {
+ methodDecl := wshDeclMap[key]
+ if methodDecl.CommandType == wshrpc.RpcType_ResponseStream {
+ gogen.GenMethod_ResponseStream(&buf, methodDecl)
+ } else if methodDecl.CommandType == wshrpc.RpcType_Call {
+ gogen.GenMethod_Call(&buf, methodDecl)
+ } else {
+ panic("unsupported command type " + methodDecl.CommandType)
+ }
+ }
+ buf.WriteString("\n")
+ err := os.WriteFile(WshClientFileName, []byte(buf.String()), 0644)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func GenerateWaveObjMetaConsts() {
+ fmt.Fprintf(os.Stderr, "generating waveobj meta consts file to %s\n", WaveObjMetaConstsFileName)
+ var buf strings.Builder
+ gogen.GenerateBoilerplate(&buf, "waveobj", []string{})
+ gogen.GenerateMetaMapConsts(&buf, "MetaKey_", reflect.TypeOf(waveobj.MetaTSType{}))
+ buf.WriteString("\n")
+ err := os.WriteFile(WaveObjMetaConstsFileName, []byte(buf.String()), 0644)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func GenerateSettingsMetaConsts() {
+ fmt.Fprintf(os.Stderr, "generating settings meta consts file to %s\n", SettingsMetaConstsFileName)
+ var buf strings.Builder
+ gogen.GenerateBoilerplate(&buf, "wconfig", []string{})
+ gogen.GenerateMetaMapConsts(&buf, "ConfigKey_", reflect.TypeOf(wconfig.SettingsType{}))
+ buf.WriteString("\n")
+ err := os.WriteFile(SettingsMetaConstsFileName, []byte(buf.String()), 0644)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func main() {
+ GenerateWshClient()
+ GenerateWaveObjMetaConsts()
+ GenerateSettingsMetaConsts()
+}
diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go
new file mode 100644
index 000000000..446312adc
--- /dev/null
+++ b/cmd/generatets/main-generatets.go
@@ -0,0 +1,132 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "reflect"
+ "sort"
+ "strings"
+
+ "github.com/wavetermdev/waveterm/pkg/service"
+ "github.com/wavetermdev/waveterm/pkg/tsgen"
+ "github.com/wavetermdev/waveterm/pkg/util/utilfn"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+)
+
+func generateTypesFile(tsTypesMap map[reflect.Type]string) error {
+ fd, err := os.Create("frontend/types/gotypes.d.ts")
+ if err != nil {
+ return err
+ }
+ defer fd.Close()
+ fmt.Fprintf(os.Stderr, "generating types file to %s\n", fd.Name())
+ tsgen.GenerateWaveObjTypes(tsTypesMap)
+ err = tsgen.GenerateServiceTypes(tsTypesMap)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating service types: %v\n", err)
+ os.Exit(1)
+ }
+ err = tsgen.GenerateWshServerTypes(tsTypesMap)
+ if err != nil {
+ return fmt.Errorf("error generating wsh server types: %w", err)
+ }
+ fmt.Fprintf(fd, "// Copyright 2024, Command Line Inc.\n")
+ fmt.Fprintf(fd, "// SPDX-License-Identifier: Apache-2.0\n\n")
+ fmt.Fprintf(fd, "// generated by cmd/generate/main-generatets.go\n\n")
+ fmt.Fprintf(fd, "declare global {\n\n")
+ var keys []reflect.Type
+ for key := range tsTypesMap {
+ keys = append(keys, key)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ iname, _ := tsgen.TypeToTSType(keys[i], tsTypesMap)
+ jname, _ := tsgen.TypeToTSType(keys[j], tsTypesMap)
+ return iname < jname
+ })
+ for _, key := range keys {
+ // don't output generic types
+ if strings.Index(key.Name(), "[") != -1 {
+ continue
+ }
+ tsCode := tsTypesMap[key]
+ istr := utilfn.IndentString(" ", tsCode)
+ fmt.Fprint(fd, istr)
+ }
+ fmt.Fprintf(fd, "}\n\n")
+ fmt.Fprintf(fd, "export {}\n")
+ return nil
+}
+
+func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
+ fd, err := os.Create("frontend/app/store/services.ts")
+ if err != nil {
+ return err
+ }
+ defer fd.Close()
+ fmt.Fprintf(os.Stderr, "generating services file to %s\n", fd.Name())
+ fmt.Fprintf(fd, "// Copyright 2024, Command Line Inc.\n")
+ fmt.Fprintf(fd, "// SPDX-License-Identifier: Apache-2.0\n\n")
+ fmt.Fprintf(fd, "// generated by cmd/generate/main-generatets.go\n\n")
+ fmt.Fprintf(fd, "import * as WOS from \"./wos\";\n\n")
+ orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap)
+ for _, serviceName := range orderedKeys {
+ serviceObj := service.ServiceMap[serviceName]
+ svcStr := tsgen.GenerateServiceClass(serviceName, serviceObj, tsTypesMap)
+ fmt.Fprint(fd, svcStr)
+ fmt.Fprint(fd, "\n")
+ }
+ return nil
+}
+
+func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error {
+ fd, err := os.Create("frontend/app/store/wshclientapi.ts")
+ if err != nil {
+ return err
+ }
+ defer fd.Close()
+ declMap := wshrpc.GenerateWshCommandDeclMap()
+ fmt.Fprintf(os.Stderr, "generating wshclientapi file to %s\n", fd.Name())
+ fmt.Fprintf(fd, "// Copyright 2024, Command Line Inc.\n")
+ fmt.Fprintf(fd, "// SPDX-License-Identifier: Apache-2.0\n\n")
+ fmt.Fprintf(fd, "// generated by cmd/generate/main-generatets.go\n\n")
+ fmt.Fprintf(fd, "import { WshClient } from \"./wshclient\";\n\n")
+ orderedKeys := utilfn.GetOrderedMapKeys(declMap)
+ fmt.Fprintf(fd, "// WshServerCommandToDeclMap\n")
+ fmt.Fprintf(fd, "class RpcApiType {\n")
+ for _, methodDecl := range orderedKeys {
+ methodDecl := declMap[methodDecl]
+ methodStr := tsgen.GenerateWshClientApiMethod(methodDecl, tsTypeMap)
+ fmt.Fprint(fd, methodStr)
+ fmt.Fprintf(fd, "\n")
+ }
+ fmt.Fprintf(fd, "}\n\n")
+ fmt.Fprintf(fd, "export const RpcApi = new RpcApiType();\n")
+ return nil
+}
+
+func main() {
+ err := service.ValidateServiceMap()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error validating service map: %v\n", err)
+ os.Exit(1)
+ }
+ tsTypesMap := make(map[reflect.Type]string)
+ err = generateTypesFile(tsTypesMap)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating types file: %v\n", err)
+ os.Exit(1)
+ }
+ err = generateServicesFile(tsTypesMap)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating services file: %v\n", err)
+ os.Exit(1)
+ }
+ err = generateWshClientApiFile(tsTypesMap)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating wshserver file: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go
new file mode 100644
index 000000000..cbe4b974a
--- /dev/null
+++ b/cmd/server/main-server.go
@@ -0,0 +1,286 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "runtime/debug"
+ "strconv"
+
+ "runtime"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/wavetermdev/waveterm/pkg/authkey"
+ "github.com/wavetermdev/waveterm/pkg/blockcontroller"
+ "github.com/wavetermdev/waveterm/pkg/filestore"
+ "github.com/wavetermdev/waveterm/pkg/service"
+ "github.com/wavetermdev/waveterm/pkg/telemetry"
+ "github.com/wavetermdev/waveterm/pkg/util/shellutil"
+ "github.com/wavetermdev/waveterm/pkg/wavebase"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wcloud"
+ "github.com/wavetermdev/waveterm/pkg/wconfig"
+ "github.com/wavetermdev/waveterm/pkg/wcore"
+ "github.com/wavetermdev/waveterm/pkg/web"
+ "github.com/wavetermdev/waveterm/pkg/wlayout"
+ "github.com/wavetermdev/waveterm/pkg/wps"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver"
+ "github.com/wavetermdev/waveterm/pkg/wshutil"
+ "github.com/wavetermdev/waveterm/pkg/wstore"
+)
+
+// these are set at build time
+var WaveVersion = "0.0.0"
+var BuildTime = "0"
+
+const InitialTelemetryWait = 30 * time.Second
+const TelemetryTick = 10 * time.Minute
+const TelemetryInterval = 4 * time.Hour
+
+const ReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"
+
+var shutdownOnce sync.Once
+
+func doShutdown(reason string) {
+ shutdownOnce.Do(func() {
+ log.Printf("shutting down: %s\n", reason)
+ ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelFn()
+ go blockcontroller.StopAllBlockControllers()
+ shutdownActivityUpdate()
+ sendTelemetryWrapper()
+ // TODO deal with flush in progress
+ filestore.WFS.FlushCache(ctx)
+ watcher := wconfig.GetWatcher()
+ if watcher != nil {
+ watcher.Close()
+ }
+ time.Sleep(500 * time.Millisecond)
+ os.Exit(0)
+ })
+}
+
+func installShutdownSignalHandlers() {
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
+ go func() {
+ for sig := range sigCh {
+ doShutdown(fmt.Sprintf("got signal %v", sig))
+ break
+ }
+ }()
+}
+
+// watch stdin, kill server if stdin is closed
+func stdinReadWatch() {
+ buf := make([]byte, 1024)
+ for {
+ _, err := os.Stdin.Read(buf)
+ if err != nil {
+ doShutdown(fmt.Sprintf("stdin closed/error (%v)", err))
+ break
+ }
+ }
+}
+
+func configWatcher() {
+ watcher := wconfig.GetWatcher()
+ if watcher != nil {
+ watcher.Start()
+ }
+}
+
+func telemetryLoop() {
+ var nextSend int64
+ time.Sleep(InitialTelemetryWait)
+ for {
+ if time.Now().Unix() > nextSend {
+ nextSend = time.Now().Add(TelemetryInterval).Unix()
+ sendTelemetryWrapper()
+ }
+ time.Sleep(TelemetryTick)
+ }
+}
+
+func sendTelemetryWrapper() {
+ defer func() {
+ r := recover()
+ if r == nil {
+ return
+ }
+ log.Printf("[error] in sendTelemetryWrapper: %v\n", r)
+ debug.PrintStack()
+ }()
+ ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelFn()
+ client, err := wstore.DBGetSingleton[*waveobj.Client](ctx)
+ if err != nil {
+ log.Printf("[error] getting client data for telemetry: %v\n", err)
+ return
+ }
+ err = wcloud.SendTelemetry(ctx, client.OID)
+ if err != nil {
+ log.Printf("[error] sending telemetry: %v\n", err)
+ }
+}
+
+func startupActivityUpdate() {
+ ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelFn()
+ activity := telemetry.ActivityUpdate{
+ Startup: 1,
+ }
+ activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx)
+ err := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here)
+ if err != nil {
+ log.Printf("error updating startup activity: %v\n", err)
+ }
+}
+
+func shutdownActivityUpdate() {
+ activity := telemetry.ActivityUpdate{Shutdown: 1}
+ ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancelFn()
+ err := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous)
+ if err != nil {
+ log.Printf("error updating shutdown activity: %v\n", err)
+ }
+}
+
+func createMainWshClient() {
+ rpc := wshserver.GetMainRpcClient()
+ wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc)
+ wps.Broker.SetClient(wshutil.DefaultRouter)
+ localConnWsh := wshutil.MakeWshRpc(nil, nil, wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, &wshremote.ServerImpl{})
+ go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName)
+ wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh)
+}
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lmicroseconds)
+ log.SetPrefix("[wavesrv] ")
+ wavebase.WaveVersion = WaveVersion
+ wavebase.BuildTime = BuildTime
+
+ err := authkey.SetAuthKeyFromEnv()
+ if err != nil {
+ log.Printf("error setting auth key: %v\n", err)
+ return
+ }
+ err = service.ValidateServiceMap()
+ if err != nil {
+ log.Printf("error validating service map: %v\n", err)
+ return
+ }
+ err = wavebase.EnsureWaveHomeDir()
+ if err != nil {
+ log.Printf("error ensuring wave home dir: %v\n", err)
+ return
+ }
+ err = wavebase.EnsureWaveDBDir()
+ if err != nil {
+ log.Printf("error ensuring wave db dir: %v\n", err)
+ return
+ }
+ err = wavebase.EnsureWaveConfigDir()
+ if err != nil {
+ log.Printf("error ensuring wave config dir: %v\n", err)
+ return
+ }
+ waveLock, err := wavebase.AcquireWaveLock()
+ if err != nil {
+ log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err)
+ return
+ }
+ defer func() {
+ err = waveLock.Unlock()
+ if err != nil {
+ log.Printf("error releasing wave lock: %v\n", err)
+ }
+ }()
+ log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime)
+ log.Printf("wave home dir: %s\n", wavebase.GetWaveHomeDir())
+ err = filestore.InitFilestore()
+ if err != nil {
+ log.Printf("error initializing filestore: %v\n", err)
+ return
+ }
+ err = wstore.InitWStore()
+ if err != nil {
+ log.Printf("error initializing wstore: %v\n", err)
+ return
+ }
+ migrateErr := wstore.TryMigrateOldHistory()
+ if migrateErr != nil {
+ log.Printf("error migrating old history: %v\n", migrateErr)
+ }
+ go func() {
+ err := shellutil.InitCustomShellStartupFiles()
+ if err != nil {
+ log.Printf("error initializing wsh and shell-integration files: %v\n", err)
+ }
+ }()
+ window, firstRun, err := wcore.EnsureInitialData()
+ if err != nil {
+ log.Printf("error ensuring initial data: %v\n", err)
+ return
+ }
+ if window != nil {
+ ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancelFn()
+ if !firstRun {
+ err = wlayout.BootstrapNewWindowLayout(ctx, window)
+ if err != nil {
+ log.Panicf("error applying new window layout: %v\n", err)
+ return
+ }
+ }
+ }
+ createMainWshClient()
+ installShutdownSignalHandlers()
+ startupActivityUpdate()
+ go stdinReadWatch()
+ go telemetryLoop()
+ configWatcher()
+ webListener, err := web.MakeTCPListener("web")
+ if err != nil {
+ log.Printf("error creating web listener: %v\n", err)
+ return
+ }
+ wsListener, err := web.MakeTCPListener("websocket")
+ if err != nil {
+ log.Printf("error creating websocket listener: %v\n", err)
+ return
+ }
+ go web.RunWebSocketServer(wsListener)
+ unixListener, err := web.MakeUnixListener()
+ if err != nil {
+ log.Printf("error creating unix listener: %v\n", err)
+ return
+ }
+ go func() {
+ pidStr := os.Getenv(ReadySignalPidVarName)
+ if pidStr != "" {
+ _, err := strconv.Atoi(pidStr)
+ if err == nil {
+ if BuildTime == "" {
+ BuildTime = "0"
+ }
+ // use fmt instead of log here to make sure it goes directly to stderr
+ fmt.Fprintf(os.Stderr, "WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\n", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime)
+ }
+ }
+ }()
+ go wshutil.RunWshRpcOverListener(unixListener)
+ web.RunWebServer(webListener) // blocking
+ runtime.KeepAlive(waveLock)
+}
diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go
new file mode 100644
index 000000000..10d1933fe
--- /dev/null
+++ b/cmd/test/test-main.go
@@ -0,0 +1,55 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/wavetermdev/waveterm/pkg/vdom"
+ "github.com/wavetermdev/waveterm/pkg/wshutil"
+)
+
+func Page(ctx context.Context, props map[string]any) any {
+ clicked, setClicked := vdom.UseState(ctx, false)
+ var clickedDiv *vdom.Elem
+ if clicked {
+ clickedDiv = vdom.Bind(`clicked
`, nil)
+ }
+ clickFn := func() {
+ log.Printf("run clickFn\n")
+ setClicked(true)
+ }
+ return vdom.Bind(
+ `
+
+
hello world
+ hello
+
+
+`,
+ map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv},
+ )
+}
+
+func Button(ctx context.Context, props map[string]any) any {
+ ref := vdom.UseRef(ctx, nil)
+ clName, setClName := vdom.UseState(ctx, "button")
+ vdom.UseEffect(ctx, func() func() {
+ fmt.Printf("Button useEffect\n")
+ setClName("button mounted")
+ return nil
+ }, nil)
+ return vdom.Bind(`
+
+
+
+ `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]})
+}
+
+func main() {
+ wshutil.SetTermRawModeAndInstallShutdownHandlers(true)
+ defer wshutil.RestoreTermState()
+}
diff --git a/cmd/wsh/cmd/wshcmd-conn.go b/cmd/wsh/cmd/wshcmd-conn.go
new file mode 100644
index 000000000..c7f991056
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-conn.go
@@ -0,0 +1,132 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/remote"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+)
+
+var connCmd = &cobra.Command{
+ Use: "conn [status|reinstall|disconnect|connect|ensure] [connection-name]",
+ Short: "implements connection commands",
+ Args: cobra.RangeArgs(1, 2),
+ RunE: connRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(connCmd)
+}
+
+func connStatus() error {
+ resp, err := wshclient.ConnStatusCommand(RpcClient, nil)
+ if err != nil {
+ return fmt.Errorf("getting connection status: %w", err)
+ }
+ if len(resp) == 0 {
+ WriteStdout("no connections\n")
+ return nil
+ }
+ WriteStdout("%-30s %-12s\n", "connection", "status")
+ WriteStdout("----------------------------------------------\n")
+ for _, conn := range resp {
+ str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status)
+ if conn.Error != "" {
+ str += fmt.Sprintf(" (%s)", conn.Error)
+ }
+ str += "\n"
+ WriteStdout("%s\n", str)
+ }
+ return nil
+}
+
+func connDisconnectAll() error {
+ resp, err := wshclient.ConnStatusCommand(RpcClient, nil)
+ if err != nil {
+ return fmt.Errorf("getting connection status: %w", err)
+ }
+ if len(resp) == 0 {
+ return nil
+ }
+ for _, conn := range resp {
+ if conn.Status == "connected" {
+ err := connDisconnect(conn.Connection)
+ if err != nil {
+ WriteStdout("error disconnecting %q: %v\n", conn.Connection, err)
+ }
+ }
+ }
+ return nil
+}
+
+func connEnsure(connName string) error {
+ err := wshclient.ConnEnsureCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000})
+ if err != nil {
+ return fmt.Errorf("ensuring connection: %w", err)
+ }
+ WriteStdout("wsh ensured on connection %q\n", connName)
+ return nil
+}
+
+func connReinstall(connName string) error {
+ err := wshclient.ConnReinstallWshCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000})
+ if err != nil {
+ return fmt.Errorf("reinstalling connection: %w", err)
+ }
+ WriteStdout("wsh reinstalled on connection %q\n", connName)
+ return nil
+}
+
+func connDisconnect(connName string) error {
+ err := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000})
+ if err != nil {
+ return fmt.Errorf("disconnecting %q error: %w", connName, err)
+ }
+ WriteStdout("disconnected %q\n", connName)
+ return nil
+}
+
+func connConnect(connName string) error {
+ err := wshclient.ConnConnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000})
+ if err != nil {
+ return fmt.Errorf("connecting connection: %w", err)
+ }
+ WriteStdout("connected connection %q\n", connName)
+ return nil
+}
+
+func connRun(cmd *cobra.Command, args []string) error {
+ connCmd := args[0]
+ var connName string
+ if connCmd != "status" && connCmd != "disconnectall" {
+ if len(args) < 2 {
+ return fmt.Errorf("connection name is required %q", connCmd)
+ }
+ connName = args[1]
+ _, err := remote.ParseOpts(connName)
+ if err != nil {
+ return fmt.Errorf("cannot parse connection name: %w", err)
+ }
+ }
+ if connCmd == "status" {
+ return connStatus()
+ } else if connCmd == "ensure" {
+ return connEnsure(connName)
+ } else if connCmd == "reinstall" {
+ return connReinstall(connName)
+ } else if connCmd == "disconnect" {
+ return connDisconnect(connName)
+ } else if connCmd == "disconnectall" {
+ return connDisconnectAll()
+ } else if connCmd == "connect" {
+ return connConnect(connName)
+ } else {
+ return fmt.Errorf("unknown command %q", connCmd)
+ }
+}
diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go
new file mode 100644
index 000000000..cc00a694e
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-connserver.go
@@ -0,0 +1,32 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote"
+)
+
+var serverCmd = &cobra.Command{
+ Use: "connserver",
+ Hidden: true,
+ Short: "remote server to power wave blocks",
+ Args: cobra.NoArgs,
+ Run: serverRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(serverCmd)
+}
+
+func serverRun(cmd *cobra.Command, args []string) {
+ WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn)
+ go wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn)
+ RpcClient.SetServerImpl(&wshremote.ServerImpl{LogWriter: os.Stdout})
+
+ select {} // run forever
+}
diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go
new file mode 100644
index 000000000..6d30d272b
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-deleteblock.go
@@ -0,0 +1,52 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+)
+
+var deleteBlockCmd = &cobra.Command{
+ Use: "deleteblock",
+ Short: "delete a block",
+ Args: cobra.ExactArgs(1),
+ Run: deleteBlockRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(deleteBlockCmd)
+}
+
+func deleteBlockRun(cmd *cobra.Command, args []string) {
+ oref := args[0]
+ if oref == "" {
+ WriteStderr("[error] oref is required\n")
+ return
+ }
+ err := validateEasyORef(oref)
+ if err != nil {
+ WriteStderr("[error]%v\n", err)
+ return
+ }
+ fullORef, err := resolveSimpleId(oref)
+ if err != nil {
+ WriteStderr("[error] resolving oref: %v\n", err)
+ return
+ }
+ if fullORef.OType != "block" {
+ WriteStderr("[error] oref is not a block\n")
+ return
+ }
+ deleteBlockData := &wshrpc.CommandDeleteBlockData{
+ BlockId: fullORef.OID,
+ }
+ _, err = RpcClient.SendRpcRequest(wshrpc.Command_DeleteBlock, deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000})
+ if err != nil {
+ WriteStderr("[error] deleting block: %v\n", err)
+ return
+ }
+ WriteStdout("block deleted\n")
+}
diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go
new file mode 100644
index 000000000..0a32af9fd
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-editor.go
@@ -0,0 +1,75 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wps"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+)
+
+var editMagnified bool
+
+var editorCmd = &cobra.Command{
+ Use: "editor",
+ Short: "edit a file (blocks until editor is closed)",
+ Args: cobra.ExactArgs(1),
+ Run: editorRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ editCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode")
+ rootCmd.AddCommand(editorCmd)
+}
+
+func editorRun(cmd *cobra.Command, args []string) {
+ fileArg := args[0]
+ absFile, err := filepath.Abs(fileArg)
+ if err != nil {
+ WriteStderr("[error] getting absolute path: %v\n", err)
+ return
+ }
+ _, err = os.Stat(absFile)
+ if err == fs.ErrNotExist {
+ WriteStderr("[error] file does not exist: %q\n", absFile)
+ return
+ }
+ if err != nil {
+ WriteStderr("[error] getting file info: %v\n", err)
+ return
+ }
+ wshCmd := wshrpc.CommandCreateBlockData{
+ BlockDef: &waveobj.BlockDef{
+ Meta: map[string]any{
+ waveobj.MetaKey_View: "preview",
+ waveobj.MetaKey_File: absFile,
+ waveobj.MetaKey_Edit: true,
+ },
+ },
+ Magnified: editMagnified,
+ }
+ if RpcContext.Conn != "" {
+ wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn
+ }
+ blockRef, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, &wshrpc.RpcOpts{Timeout: 2000})
+ if err != nil {
+ WriteStderr("[error] running view command: %v\r\n", err)
+ return
+ }
+ doneCh := make(chan bool)
+ RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) {
+ if event.HasScope(blockRef.String()) {
+ close(doneCh)
+ }
+ })
+ wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{blockRef.String()}}, nil)
+ <-doneCh
+}
diff --git a/cmd/wsh/cmd/wshcmd-getmeta.go b/cmd/wsh/cmd/wshcmd-getmeta.go
new file mode 100644
index 000000000..f30fabba4
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-getmeta.go
@@ -0,0 +1,68 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "encoding/json"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+)
+
+var getMetaCmd = &cobra.Command{
+ Use: "getmeta {blockid|blocknum|this} [key]",
+ Short: "get metadata for an entity",
+ Args: cobra.RangeArgs(1, 2),
+ Run: getMetaRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(getMetaCmd)
+}
+
+func getMetaRun(cmd *cobra.Command, args []string) {
+ oref := args[0]
+ if oref == "" {
+ WriteStderr("[error] oref is required")
+ return
+ }
+ err := validateEasyORef(oref)
+ if err != nil {
+ WriteStderr("[error] %v\n", err)
+ return
+ }
+ fullORef, err := resolveSimpleId(oref)
+ if err != nil {
+ WriteStderr("[error] resolving oref: %v\n", err)
+ return
+ }
+ resp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: *fullORef}, &wshrpc.RpcOpts{Timeout: 2000})
+ if err != nil {
+ WriteStderr("[error] getting metadata: %v\n", err)
+ return
+ }
+ if len(args) > 1 {
+ val, ok := resp[args[1]]
+ if !ok {
+ return
+ }
+ outBArr, err := json.MarshalIndent(val, "", " ")
+ if err != nil {
+ WriteStderr("[error] formatting metadata: %v\n", err)
+ return
+ }
+ outStr := string(outBArr)
+ WriteStdout(outStr + "\n")
+ } else {
+ outBArr, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ WriteStderr("[error] formatting metadata: %v\n", err)
+ return
+ }
+ outStr := string(outBArr)
+ WriteStdout(outStr + "\n")
+ }
+}
diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go
new file mode 100644
index 000000000..6bffc992e
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-html.go
@@ -0,0 +1,43 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wshutil"
+)
+
+func init() {
+ rootCmd.AddCommand(htmlCmd)
+}
+
+var htmlCmd = &cobra.Command{
+ Use: "html",
+ Hidden: true,
+ Short: "Launch a demo html-mode terminal",
+ Run: htmlRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func htmlRun(cmd *cobra.Command, args []string) {
+ defer wshutil.DoShutdown("normal exit", 0, true)
+ setTermHtmlMode()
+ for {
+ var buf [1]byte
+ _, err := WrappedStdin.Read(buf[:])
+ if err != nil {
+ wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true)
+ }
+ if buf[0] == 0x03 {
+ wshutil.DoShutdown("read Ctrl-C from stdin", 1, true)
+ break
+ }
+ if buf[0] == 'x' {
+ wshutil.DoShutdown("read 'x' from stdin", 0, true)
+ break
+ }
+ }
+}
diff --git a/cmd/wsh/cmd/wshcmd-rcfiles.go b/cmd/wsh/cmd/wshcmd-rcfiles.go
new file mode 100644
index 000000000..84a432d1f
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-rcfiles.go
@@ -0,0 +1,34 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/util/shellutil"
+ "github.com/wavetermdev/waveterm/pkg/wavebase"
+)
+
+var WshBinDir = ".waveterm/bin"
+
+func init() {
+ rootCmd.AddCommand(rcfilesCmd)
+}
+
+var rcfilesCmd = &cobra.Command{
+ Use: "rcfiles",
+ Hidden: true,
+ Short: "Generate the rc files needed for various shells",
+ Run: func(cmd *cobra.Command, args []string) {
+ home := wavebase.GetHomeDir()
+ waveDir := filepath.Join(home, ".waveterm")
+ winBinDir := filepath.Join(waveDir, "bin")
+ err := shellutil.InitRcFiles(waveDir, winBinDir)
+ if err != nil {
+ WriteStderr(err.Error())
+ return
+ }
+ },
+}
diff --git a/cmd/wsh/cmd/wshcmd-readfile.go b/cmd/wsh/cmd/wshcmd-readfile.go
new file mode 100644
index 000000000..ccda2798c
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-readfile.go
@@ -0,0 +1,53 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "encoding/base64"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+)
+
+var readFileCmd = &cobra.Command{
+ Use: "readfile",
+ Short: "read a blockfile",
+ Args: cobra.ExactArgs(2),
+ Run: runReadFile,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(readFileCmd)
+}
+
+func runReadFile(cmd *cobra.Command, args []string) {
+ oref := args[0]
+ if oref == "" {
+ WriteStderr("[error] oref is required\n")
+ return
+ }
+ err := validateEasyORef(oref)
+ if err != nil {
+ WriteStderr("[error] %v\n", err)
+ return
+ }
+ fullORef, err := resolveSimpleId(oref)
+ if err != nil {
+ WriteStderr("error resolving oref: %v\n", err)
+ return
+ }
+ resp64, err := wshclient.FileReadCommand(RpcClient, wshrpc.CommandFileData{ZoneId: fullORef.OID, FileName: args[1]}, &wshrpc.RpcOpts{Timeout: 5000})
+ if err != nil {
+ WriteStderr("[error] reading file: %v\n", err)
+ return
+ }
+ resp, err := base64.StdEncoding.DecodeString(resp64)
+ if err != nil {
+ WriteStderr("[error] decoding file: %v\n", err)
+ return
+ }
+ WriteStdout(string(resp))
+}
diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go
new file mode 100644
index 000000000..411ff08b2
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-root.go
@@ -0,0 +1,182 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+ "github.com/wavetermdev/waveterm/pkg/wshutil"
+)
+
+var (
+ rootCmd = &cobra.Command{
+ Use: "wsh",
+ Short: "CLI tool to control Wave Terminal",
+ Long: `wsh is a small utility that lets you do cool things with Wave Terminal, right from the command line`,
+ SilenceUsage: true,
+ }
+)
+
+var usingHtmlMode bool
+var WrappedStdin io.Reader = os.Stdin
+var RpcClient *wshutil.WshRpc
+var RpcContext wshrpc.RpcContext
+var UsingTermWshMode bool
+
+func extraShutdownFn() {
+ if usingHtmlMode {
+ cmd := &wshrpc.CommandSetMetaData{
+ Meta: map[string]any{"term:mode": nil},
+ }
+ RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil)
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+func WriteStderr(fmtStr string, args ...interface{}) {
+ output := fmt.Sprintf(fmtStr, args...)
+ if UsingTermWshMode {
+ output = strings.ReplaceAll(output, "\n", "\r\n")
+ }
+ fmt.Fprint(os.Stderr, output)
+}
+
+func WriteStdout(fmtStr string, args ...interface{}) {
+ output := fmt.Sprintf(fmtStr, args...)
+ if UsingTermWshMode {
+ output = strings.ReplaceAll(output, "\n", "\r\n")
+ }
+ fmt.Print(output)
+}
+
+func preRunSetupRpcClient(cmd *cobra.Command, args []string) error {
+ err := setupRpcClient(nil)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output)
+func setupRpcClient(serverImpl wshutil.ServerImpl) error {
+ jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
+ if jwtToken == "" {
+ wshutil.SetTermRawModeAndInstallShutdownHandlers(true)
+ UsingTermWshMode = true
+ RpcClient, WrappedStdin = wshutil.SetupTerminalRpcClient(serverImpl)
+ return nil
+ }
+ rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken)
+ if err != nil {
+ return fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err)
+ }
+ RpcContext = *rpcCtx
+ sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)
+ if err != nil {
+ return fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err)
+ }
+ RpcClient, err = wshutil.SetupDomainSocketRpcClient(sockName, serverImpl)
+ if err != nil {
+ return fmt.Errorf("error setting up domain socket rpc client: %v", err)
+ }
+ wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true})
+ // note we don't modify WrappedStdin here (just use os.Stdin)
+ return nil
+}
+
+func setTermHtmlMode() {
+ wshutil.SetExtraShutdownFunc(extraShutdownFn)
+ cmd := &wshrpc.CommandSetMetaData{
+ Meta: map[string]any{"term:mode": "html"},
+ }
+ err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error setting html mode: %v\r\n", err)
+ }
+ usingHtmlMode = true
+}
+
+var oidRe = regexp.MustCompile(`^[0-9a-f]{8}$`)
+
+func validateEasyORef(oref string) error {
+ if oref == "this" {
+ return nil
+ }
+ if num, err := strconv.Atoi(oref); err == nil && num >= 1 {
+ return nil
+ }
+ if strings.Contains(oref, ":") {
+ _, err := waveobj.ParseORef(oref)
+ if err != nil {
+ return fmt.Errorf("invalid ORef: %v", err)
+ }
+ return nil
+ }
+ if len(oref) == 8 {
+ if !oidRe.MatchString(oref) {
+ return fmt.Errorf("invalid short OID format, must only use 0-9a-f: %q", oref)
+ }
+ return nil
+ }
+ _, err := uuid.Parse(oref)
+ if err != nil {
+ return fmt.Errorf("invalid object reference (must be UUID, or a positive integer): %v", err)
+ }
+ return nil
+}
+
+func isFullORef(orefStr string) bool {
+ _, err := waveobj.ParseORef(orefStr)
+ return err == nil
+}
+
+func resolveSimpleId(id string) (*waveobj.ORef, error) {
+ if isFullORef(id) {
+ orefObj, err := waveobj.ParseORef(id)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing full ORef: %v", err)
+ }
+ return &orefObj, nil
+ }
+ rtnData, err := wshclient.ResolveIdsCommand(RpcClient, wshrpc.CommandResolveIdsData{Ids: []string{id}}, &wshrpc.RpcOpts{Timeout: 2000})
+ if err != nil {
+ return nil, fmt.Errorf("error resolving ids: %v", err)
+ }
+ oref, ok := rtnData.ResolvedIds[id]
+ if !ok {
+ return nil, fmt.Errorf("id not found: %q", id)
+ }
+ return &oref, nil
+}
+
+// Execute executes the root command.
+func Execute() {
+ defer func() {
+ r := recover()
+ if r != nil {
+ WriteStderr("[panic] %v\n", r)
+ debug.PrintStack()
+ wshutil.DoShutdown("", 1, true)
+ } else {
+ wshutil.DoShutdown("", 0, false)
+ }
+ }()
+ err := rootCmd.Execute()
+ if err != nil {
+ wshutil.DoShutdown("", 1, true)
+ return
+ }
+}
diff --git a/cmd/wsh/cmd/wshcmd-setconfig.go b/cmd/wsh/cmd/wshcmd-setconfig.go
new file mode 100644
index 000000000..7c859e20a
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-setconfig.go
@@ -0,0 +1,39 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wconfig"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+)
+
+var setConfigCmd = &cobra.Command{
+ Use: "setconfig",
+ Short: "set config",
+ Args: cobra.MinimumNArgs(1),
+ Run: setConfigRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(setConfigCmd)
+}
+
+func setConfigRun(cmd *cobra.Command, args []string) {
+ metaSetsStrs := args[:]
+ meta, err := parseMetaSets(metaSetsStrs)
+ if err != nil {
+ WriteStderr("[error] %v\n", err)
+ return
+ }
+ commandData := wconfig.MetaSettingsType{MetaMapType: meta}
+ err = wshclient.SetConfigCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000})
+ if err != nil {
+ WriteStderr("[error] setting config: %v\n", err)
+ return
+ }
+ WriteStdout("config set\n")
+}
diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go
new file mode 100644
index 000000000..c400363dd
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-setmeta.go
@@ -0,0 +1,93 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+)
+
+var setMetaCmd = &cobra.Command{
+ Use: "setmeta {blockid|blocknum|this} key=value ...",
+ Short: "set metadata for an entity",
+ Args: cobra.MinimumNArgs(2),
+ Run: setMetaRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(setMetaCmd)
+}
+
+func parseMetaSets(metaSets []string) (map[string]interface{}, error) {
+ meta := make(map[string]interface{})
+ for _, metaSet := range metaSets {
+ fields := strings.Split(metaSet, "=")
+ if len(fields) != 2 {
+ return nil, fmt.Errorf("invalid meta set: %q", metaSet)
+ }
+ setVal := fields[1]
+ if setVal == "" || setVal == "null" {
+ meta[fields[0]] = nil
+ } else if setVal == "true" {
+ meta[fields[0]] = true
+ } else if setVal == "false" {
+ meta[fields[0]] = false
+ } else if setVal[0] == '[' || setVal[0] == '{' {
+ var val interface{}
+ err := json.Unmarshal([]byte(setVal), &val)
+ if err != nil {
+ return nil, fmt.Errorf("invalid json value: %v", err)
+ }
+ meta[fields[0]] = val
+ } else {
+ fval, err := strconv.ParseFloat(setVal, 64)
+ if err == nil {
+ meta[fields[0]] = fval
+ } else {
+ meta[fields[0]] = setVal
+ }
+ }
+ }
+ return meta, nil
+}
+
+func setMetaRun(cmd *cobra.Command, args []string) {
+ oref := args[0]
+ metaSetsStrs := args[1:]
+ if oref == "" {
+ WriteStderr("[error] oref is required\n")
+ return
+ }
+ err := validateEasyORef(oref)
+ if err != nil {
+ WriteStderr("[error] %v\n", err)
+ return
+ }
+ meta, err := parseMetaSets(metaSetsStrs)
+ if err != nil {
+ WriteStderr("[error] %v\n", err)
+ return
+ }
+ fullORef, err := resolveSimpleId(oref)
+ if err != nil {
+ WriteStderr("[error] resolving oref: %v\n", err)
+ return
+ }
+ setMetaWshCmd := &wshrpc.CommandSetMetaData{
+ ORef: *fullORef,
+ Meta: meta,
+ }
+ _, err = RpcClient.SendRpcRequest(wshrpc.Command_SetMeta, setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000})
+ if err != nil {
+ WriteStderr("[error] setting metadata: %v\n", err)
+ return
+ }
+ WriteStdout("metadata set\n")
+}
diff --git a/cmd/wsh/cmd/wshcmd-shell-unix.go b/cmd/wsh/cmd/wshcmd-shell-unix.go
new file mode 100644
index 000000000..d378c4b07
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-shell-unix.go
@@ -0,0 +1,62 @@
+//go:build !windows
+
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "bufio"
+ "os"
+ "os/user"
+ "runtime"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/util/shellutil"
+)
+
+func init() {
+ rootCmd.AddCommand(shellCmd)
+}
+
+var shellCmd = &cobra.Command{
+ Use: "shell",
+ Hidden: true,
+ Short: "Print the login shell of this user",
+ Run: func(cmd *cobra.Command, args []string) {
+ WriteStdout(shellCmdInner())
+ },
+}
+
+func shellCmdInner() string {
+ if runtime.GOOS == "darwin" {
+ return shellutil.GetMacUserShell() + "\n"
+ }
+ user, err := user.Current()
+ if err != nil {
+ return "/bin/bash\n"
+ }
+
+ passwd, err := os.Open("/etc/passwd")
+ if err != nil {
+ return "/bin/bash\n"
+ }
+
+ scanner := bufio.NewScanner(passwd)
+ for scanner.Scan() {
+ line := scanner.Text()
+ line = strings.TrimSpace(line)
+ parts := strings.Split(line, ":")
+
+ if len(parts) != 7 {
+ continue
+ }
+
+ if parts[0] == user.Username {
+ return parts[6] + "\n"
+ }
+ }
+ // none found
+ return "bin/bash\n"
+}
diff --git a/cmd/wsh/cmd/wshcmd-shell-win.go b/cmd/wsh/cmd/wshcmd-shell-win.go
new file mode 100644
index 000000000..367d50b71
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-shell-win.go
@@ -0,0 +1,27 @@
+//go:build windows
+
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func init() {
+ rootCmd.AddCommand(shellCmd)
+}
+
+var shellCmd = &cobra.Command{
+ Use: "shell",
+ Hidden: true,
+ Short: "Print the login shell of this user",
+ Run: func(cmd *cobra.Command, args []string) {
+ shellCmdInner()
+ },
+}
+
+func shellCmdInner() {
+ WriteStderr("not implemented/n")
+}
diff --git a/cmd/wsh/cmd/wshcmd-ssh.go b/cmd/wsh/cmd/wshcmd-ssh.go
new file mode 100644
index 000000000..980479918
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-ssh.go
@@ -0,0 +1,44 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+)
+
+var sshCmd = &cobra.Command{
+ Use: "ssh",
+ Short: "connect this terminal to a remote host",
+ Args: cobra.ExactArgs(1),
+ Run: sshRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ rootCmd.AddCommand(sshCmd)
+}
+
+func sshRun(cmd *cobra.Command, args []string) {
+ sshArg := args[0]
+ blockId := RpcContext.BlockId
+ if blockId == "" {
+ WriteStderr("[error] cannot determine blockid (not in JWT)\n")
+ return
+ }
+ data := wshrpc.CommandSetMetaData{
+ ORef: waveobj.MakeORef(waveobj.OType_Block, blockId),
+ Meta: map[string]any{
+ waveobj.MetaKey_Connection: sshArg,
+ },
+ }
+ err := wshclient.SetMetaCommand(RpcClient, data, nil)
+ if err != nil {
+ WriteStderr("[error] setting switching connection: %v\n", err)
+ return
+ }
+ WriteStderr("switched connection to %q\n", sshArg)
+}
diff --git a/cmd/wsh/cmd/wshcmd-term.go b/cmd/wsh/cmd/wshcmd-term.go
new file mode 100644
index 000000000..7cd3288bb
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-term.go
@@ -0,0 +1,70 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wavebase"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+)
+
+var termMagnified bool
+
+var termCmd = &cobra.Command{
+ Use: "term",
+ Short: "open a terminal in directory",
+ Args: cobra.RangeArgs(0, 1),
+ Run: termRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ termCmd.Flags().BoolVarP(&termMagnified, "magnified", "m", false, "open view in magnified mode")
+ rootCmd.AddCommand(termCmd)
+}
+
+func termRun(cmd *cobra.Command, args []string) {
+ var cwd string
+ if len(args) > 0 {
+ cwd = args[0]
+ cwd = wavebase.ExpandHomeDir(cwd)
+ } else {
+ var err error
+ cwd, err = os.Getwd()
+ if err != nil {
+ WriteStderr("[error] getting current directory: %v\n", err)
+ return
+ }
+ }
+ var err error
+ cwd, err = filepath.Abs(cwd)
+ if err != nil {
+ WriteStderr("[error] getting absolute path: %v\n", err)
+ return
+ }
+ createBlockData := wshrpc.CommandCreateBlockData{
+ BlockDef: &waveobj.BlockDef{
+ Meta: map[string]interface{}{
+ waveobj.MetaKey_View: "term",
+ waveobj.MetaKey_CmdCwd: cwd,
+ waveobj.MetaKey_Controller: "shell",
+ },
+ },
+ Magnified: termMagnified,
+ }
+ if RpcContext.Conn != "" {
+ createBlockData.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn
+ }
+ oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil)
+ if err != nil {
+ WriteStderr("[error] creating new terminal block: %v\n", err)
+ return
+ }
+ WriteStdout("terminal block created: %s\n", oref)
+}
diff --git a/cmd/wsh/cmd/wshcmd-version.go b/cmd/wsh/cmd/wshcmd-version.go
new file mode 100644
index 000000000..fadbcd8ec
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-version.go
@@ -0,0 +1,23 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/wavebase"
+)
+
+func init() {
+ rootCmd.AddCommand(versionCmd)
+}
+
+var versionCmd = &cobra.Command{
+ Use: "version",
+ Short: "Print the version number of wsh",
+ Run: func(cmd *cobra.Command, args []string) {
+ WriteStdout(fmt.Sprintf("wsh v%s\n", wavebase.WaveVersion))
+ },
+}
diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go
new file mode 100644
index 000000000..0aed5d482
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-view.go
@@ -0,0 +1,91 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+)
+
+var viewMagnified bool
+
+var viewCmd = &cobra.Command{
+ Use: "view {file|directory|URL}",
+ Short: "preview/edit a file or directory",
+ Args: cobra.ExactArgs(1),
+ Run: viewRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+var editCmd = &cobra.Command{
+ Use: "edit {file}",
+ Short: "edit a file",
+ Args: cobra.ExactArgs(1),
+ Run: viewRun,
+ PreRunE: preRunSetupRpcClient,
+}
+
+func init() {
+ viewCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode")
+ rootCmd.AddCommand(viewCmd)
+ rootCmd.AddCommand(editCmd)
+}
+
+func viewRun(cmd *cobra.Command, args []string) {
+ fileArg := args[0]
+ conn := RpcContext.Conn
+ var wshCmd *wshrpc.CommandCreateBlockData
+ if strings.HasPrefix(fileArg, "http://") || strings.HasPrefix(fileArg, "https://") {
+ wshCmd = &wshrpc.CommandCreateBlockData{
+ BlockDef: &waveobj.BlockDef{
+ Meta: map[string]any{
+ waveobj.MetaKey_View: "web",
+ waveobj.MetaKey_Url: fileArg,
+ },
+ },
+ Magnified: viewMagnified,
+ }
+ } else {
+ absFile, err := filepath.Abs(fileArg)
+ if err != nil {
+ WriteStderr("[error] getting absolute path: %v\n", err)
+ return
+ }
+ _, err = os.Stat(absFile)
+ if err == fs.ErrNotExist {
+ WriteStderr("[error] file does not exist: %q\n", absFile)
+ return
+ }
+ if err != nil {
+ WriteStderr("[error] getting file info: %v\n", err)
+ return
+ }
+ wshCmd = &wshrpc.CommandCreateBlockData{
+ BlockDef: &waveobj.BlockDef{
+ Meta: map[string]interface{}{
+ waveobj.MetaKey_View: "preview",
+ waveobj.MetaKey_File: absFile,
+ },
+ },
+ Magnified: viewMagnified,
+ }
+ if cmd.Use == "edit" {
+ wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true
+ }
+ if conn != "" {
+ wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn
+ }
+ }
+ _, err := RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000})
+ if err != nil {
+ WriteStderr("[error] running view command: %v\r\n", err)
+ return
+ }
+}
diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go
new file mode 100644
index 000000000..bd2985931
--- /dev/null
+++ b/cmd/wsh/cmd/wshcmd-web.go
@@ -0,0 +1,120 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/wavetermdev/waveterm/pkg/waveobj"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
+ "github.com/wavetermdev/waveterm/pkg/wshutil"
+)
+
+var webCmd = &cobra.Command{
+ Use: "web [open|get|set]",
+ Short: "web commands",
+ PersistentPreRunE: preRunSetupRpcClient,
+}
+
+var webOpenCmd = &cobra.Command{
+ Use: "open url",
+ Short: "open a url a web widget",
+ Args: cobra.ExactArgs(1),
+ RunE: webOpenRun,
+}
+
+var webGetCmd = &cobra.Command{
+ Use: "get [--inner] [--all] [--json] blockid css-selector",
+ Short: "get the html for a css selector",
+ Args: cobra.ExactArgs(2),
+ Hidden: true,
+ RunE: webGetRun,
+}
+
+var webGetInner bool
+var webGetAll bool
+var webGetJson bool
+var webOpenMagnified bool
+
+func init() {
+ webOpenCmd.Flags().BoolVarP(&webOpenMagnified, "magnified", "m", false, "open view in magnified mode")
+ webCmd.AddCommand(webOpenCmd)
+ webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)")
+ webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)")
+ webGetCmd.Flags().BoolVarP(&webGetJson, "json", "", false, "output as json")
+ webCmd.AddCommand(webGetCmd)
+ rootCmd.AddCommand(webCmd)
+}
+
+func webGetRun(cmd *cobra.Command, args []string) error {
+ oref := args[0]
+ if oref == "" {
+ return fmt.Errorf("blockid not specified")
+ }
+ err := validateEasyORef(oref)
+ if err != nil {
+ return err
+ }
+ fullORef, err := resolveSimpleId(oref)
+ if err != nil {
+ return fmt.Errorf("resolving blockid: %w", err)
+ }
+ blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil)
+ if err != nil {
+ return fmt.Errorf("getting block info: %w", err)
+ }
+ if blockInfo.Meta.GetString(waveobj.MetaKey_View, "") != "web" {
+ return fmt.Errorf("block %s is not a web block", fullORef.OID)
+ }
+ data := wshrpc.CommandWebSelectorData{
+ WindowId: blockInfo.WindowId,
+ BlockId: fullORef.OID,
+ TabId: blockInfo.TabId,
+ Selector: args[1],
+ Opts: &wshrpc.WebSelectorOpts{
+ Inner: webGetInner,
+ All: webGetAll,
+ },
+ }
+ output, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{
+ Route: wshutil.ElectronRoute,
+ Timeout: 5000,
+ })
+ if err != nil {
+ return err
+ }
+ if webGetJson {
+ barr, err := json.MarshalIndent(output, "", " ")
+ if err != nil {
+ return fmt.Errorf("json encoding: %w", err)
+ }
+ WriteStdout("%s\n", string(barr))
+ } else {
+ for _, item := range output {
+ WriteStdout("%s\n", item)
+ }
+ }
+ return nil
+}
+
+func webOpenRun(cmd *cobra.Command, args []string) error {
+ wshCmd := wshrpc.CommandCreateBlockData{
+ BlockDef: &waveobj.BlockDef{
+ Meta: map[string]any{
+ waveobj.MetaKey_View: "web",
+ waveobj.MetaKey_Url: args[0],
+ },
+ },
+ Magnified: webOpenMagnified,
+ }
+ oref, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, nil)
+ if err != nil {
+ return fmt.Errorf("creating block: %w", err)
+ }
+ WriteStdout("created block %s\n", oref)
+ return nil
+}
diff --git a/cmd/wsh/main-wsh.go b/cmd/wsh/main-wsh.go
new file mode 100644
index 000000000..cbf262f06
--- /dev/null
+++ b/cmd/wsh/main-wsh.go
@@ -0,0 +1,19 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+ "github.com/wavetermdev/waveterm/cmd/wsh/cmd"
+ "github.com/wavetermdev/waveterm/pkg/wavebase"
+)
+
+// set by main-server.go
+var WaveVersion = "0.0.0"
+var BuildTime = "0"
+
+func main() {
+ wavebase.WaveVersion = WaveVersion
+ wavebase.BuildTime = BuildTime
+ cmd.Execute()
+}
diff --git a/db/db.go b/db/db.go
new file mode 100644
index 000000000..d2a3604b8
--- /dev/null
+++ b/db/db.go
@@ -0,0 +1,12 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package db
+
+import "embed"
+
+//go:embed migrations-filestore/*.sql
+var FilestoreMigrationFS embed.FS
+
+//go:embed migrations-wstore/*.sql
+var WStoreMigrationFS embed.FS
diff --git a/db/migrations-filestore/000001_init.down.sql b/db/migrations-filestore/000001_init.down.sql
new file mode 100644
index 000000000..534c404e7
--- /dev/null
+++ b/db/migrations-filestore/000001_init.down.sql
@@ -0,0 +1,3 @@
+DROP TABLE db_wave_file;
+
+DROP TABLE db_file_data;
diff --git a/db/migrations-filestore/000001_init.up.sql b/db/migrations-filestore/000001_init.up.sql
new file mode 100644
index 000000000..af9fcf8c0
--- /dev/null
+++ b/db/migrations-filestore/000001_init.up.sql
@@ -0,0 +1,19 @@
+CREATE TABLE db_wave_file (
+ zoneid varchar(36) NOT NULL,
+ name varchar(200) NOT NULL,
+ size bigint NOT NULL,
+ createdts bigint NOT NULL,
+ modts bigint NOT NULL,
+ opts json NOT NULL,
+ meta json NOT NULL,
+ PRIMARY KEY (zoneid, name)
+);
+
+CREATE TABLE db_file_data (
+ zoneid varchar(36) NOT NULL,
+ name varchar(200) NOT NULL,
+ partidx int NOT NULL,
+ data blob NOT NULL,
+ PRIMARY KEY(zoneid, name, partidx)
+);
+
diff --git a/db/migrations-wstore/000001_init.down.sql b/db/migrations-wstore/000001_init.down.sql
new file mode 100644
index 000000000..177ce0860
--- /dev/null
+++ b/db/migrations-wstore/000001_init.down.sql
@@ -0,0 +1,7 @@
+DROP TABLE db_client;
+
+DROP TABLE db_workspace;
+
+DROP TABLE db_tab;
+
+DROP TABLE db_block;
diff --git a/db/migrations-wstore/000001_init.up.sql b/db/migrations-wstore/000001_init.up.sql
new file mode 100644
index 000000000..34c3b8832
--- /dev/null
+++ b/db/migrations-wstore/000001_init.up.sql
@@ -0,0 +1,30 @@
+CREATE TABLE db_client (
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
+ data json NOT NULL
+);
+
+CREATE TABLE db_window (
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
+ data json NOT NULL
+);
+
+CREATE TABLE db_workspace (
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
+ data json NOT NULL
+);
+
+CREATE TABLE db_tab (
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
+ data json NOT NULL
+);
+
+CREATE TABLE db_block (
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
+ data json NOT NULL
+);
+
diff --git a/db/migrations-wstore/000002_init.down.sql b/db/migrations-wstore/000002_init.down.sql
new file mode 100644
index 000000000..f7ef05cd1
--- /dev/null
+++ b/db/migrations-wstore/000002_init.down.sql
@@ -0,0 +1 @@
+DROP TABLE db_layout;
diff --git a/db/migrations-wstore/000002_init.up.sql b/db/migrations-wstore/000002_init.up.sql
new file mode 100644
index 000000000..b0a05456c
--- /dev/null
+++ b/db/migrations-wstore/000002_init.up.sql
@@ -0,0 +1,5 @@
+CREATE TABLE db_layout (
+ oid varchar(36) PRIMARY KEY,
+ version int NOT NULL,
+ data json NOT NULL
+);
diff --git a/db/migrations-wstore/000003_activity.down.sql b/db/migrations-wstore/000003_activity.down.sql
new file mode 100644
index 000000000..f355a3156
--- /dev/null
+++ b/db/migrations-wstore/000003_activity.down.sql
@@ -0,0 +1 @@
+DROP TABLE db_activity;
\ No newline at end of file
diff --git a/db/migrations-wstore/000003_activity.up.sql b/db/migrations-wstore/000003_activity.up.sql
new file mode 100644
index 000000000..142922bac
--- /dev/null
+++ b/db/migrations-wstore/000003_activity.up.sql
@@ -0,0 +1,11 @@
+CREATE TABLE db_activity (
+ day varchar(20) PRIMARY KEY,
+ uploaded boolean NOT NULL,
+ tdata json NOT NULL,
+ tzname varchar(50) NOT NULL,
+ tzoffset int NOT NULL,
+ clientversion varchar(20) NOT NULL,
+ clientarch varchar(20) NOT NULL,
+ buildtime varchar(20) NOT NULL DEFAULT '-',
+ osrelease varchar(20) NOT NULL DEFAULT '-'
+);
\ No newline at end of file
diff --git a/db/migrations-wstore/000004_history.down.sql b/db/migrations-wstore/000004_history.down.sql
new file mode 100644
index 000000000..556e9b40a
--- /dev/null
+++ b/db/migrations-wstore/000004_history.down.sql
@@ -0,0 +1 @@
+DROP TABLE history_migrated;
\ No newline at end of file
diff --git a/db/migrations-wstore/000004_history.up.sql b/db/migrations-wstore/000004_history.up.sql
new file mode 100644
index 000000000..6b0f2a684
--- /dev/null
+++ b/db/migrations-wstore/000004_history.up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE history_migrated (
+ historyid varchar(36) PRIMARY KEY,
+ ts bigint NOT NULL,
+ remotename varchar(200) NOT NULL,
+ haderror boolean NOT NULL,
+ cmdstr text NOT NULL,
+ exitcode int NULL DEFAULT NULL,
+ durationms int NULL DEFAULT NULL
+);
diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs
new file mode 100644
index 000000000..8ff672a26
--- /dev/null
+++ b/electron-builder.config.cjs
@@ -0,0 +1,103 @@
+const { Arch } = require("electron-builder");
+const pkg = require("./package.json");
+const fs = require("fs");
+const path = require("path");
+
+/**
+ * @type {import('electron-builder').Configuration}
+ * @see https://www.electron.build/configuration/configuration
+ */
+const config = {
+ appId: pkg.build.appId,
+ productName: pkg.productName,
+ executableName: pkg.name,
+ artifactName: "${productName}-${platform}-${arch}-${version}.${ext}",
+ generateUpdatesFilesForAllChannels: true,
+ npmRebuild: false,
+ nodeGypRebuild: false,
+ electronCompile: false,
+ files: [
+ {
+ from: "./dist",
+ to: "./dist",
+ filter: ["**/*", "!bin/*", "bin/wavesrv.${arch}*", "bin/wsh*"],
+ },
+ {
+ from: ".",
+ to: ".",
+ filter: ["package.json"],
+ },
+ "!node_modules", // We don't need electron-builder to package in Node modules as Vite has already bundled any code that our program is using.
+ ],
+ directories: {
+ output: "make",
+ },
+ asarUnpack: [
+ "dist/bin/**/*", // wavesrv and wsh binaries
+ ],
+ mac: {
+ target: [
+ {
+ target: "zip",
+ arch: ["universal", "arm64", "x64"],
+ },
+ {
+ target: "dmg",
+ arch: ["universal", "arm64", "x64"],
+ },
+ ],
+ icon: "build/icons.icns",
+ category: "public.app-category.developer-tools",
+ minimumSystemVersion: "10.15.0",
+ mergeASARs: true,
+ singleArchFiles: "dist/bin/wavesrv.*",
+ },
+ linux: {
+ artifactName: "${name}-${platform}-${arch}-${version}.${ext}",
+ category: "TerminalEmulator",
+ icon: "build/icons.icns",
+ target: ["zip", "deb", "rpm", "AppImage", "pacman"],
+ synopsis: pkg.description,
+ description: null,
+ desktop: {
+ Name: pkg.productName,
+ Comment: pkg.description,
+ Keywords: "developer;terminal;emulator;",
+ category: "Development;Utility;",
+ },
+ },
+ win: {
+ icon: "build/icons.icns",
+ publisherName: "Command Line Inc",
+ target: ["nsis", "msi", "zip"],
+ certificateSubjectName: "Command Line Inc",
+ certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH,
+ signingHashAlgorithms: ["sha256"],
+ },
+ appImage: {
+ license: "LICENSE",
+ },
+ publish: {
+ provider: "generic",
+ url: "https://dl.waveterm.dev/releases-w2",
+ },
+ afterPack: (context) => {
+ // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary.
+ if (context.electronPlatformName === "darwin" && context.arch === Arch.universal) {
+ const packageBinDir = path.resolve(
+ context.appOutDir,
+ `${pkg.name}.app/Contents/Resources/app.asar.unpacked/dist/bin`
+ );
+
+ // Reapply file permissions to the wavesrv binaries in the final app package
+ fs.readdirSync(packageBinDir, {
+ recursive: true,
+ withFileTypes: true,
+ })
+ .filter((f) => f.isFile() && f.name.startsWith("wavesrv"))
+ .forEach((f) => fs.chmodSync(path.resolve(f.parentPath ?? f.path, f.name), 0o755)); // 0o755 corresponds to -rwxr-xr-x
+ }
+ },
+};
+
+module.exports = config;
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
new file mode 100644
index 000000000..2a7ab6fe0
--- /dev/null
+++ b/electron.vite.config.ts
@@ -0,0 +1,75 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import react from "@vitejs/plugin-react-swc";
+import { defineConfig } from "electron-vite";
+import flow from "rollup-plugin-flow";
+import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
+import { viteStaticCopy } from "vite-plugin-static-copy";
+import svgr from "vite-plugin-svgr";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+export default defineConfig({
+ main: {
+ root: ".",
+ build: {
+ rollupOptions: {
+ input: {
+ index: "emain/emain.ts",
+ },
+ },
+ outDir: "dist/main",
+ },
+ plugins: [tsconfigPaths(), flow()],
+ resolve: {
+ alias: {
+ "@": "frontend",
+ },
+ },
+ },
+ preload: {
+ root: ".",
+ build: {
+ sourcemap: true,
+ rollupOptions: {
+ input: {
+ index: "emain/preload.ts",
+ },
+ output: {
+ format: "cjs",
+ },
+ },
+ outDir: "dist/preload",
+ },
+ plugins: [tsconfigPaths(), flow()],
+ },
+ renderer: {
+ root: ".",
+ build: {
+ target: "es6",
+ sourcemap: true,
+ outDir: "dist/frontend",
+ rollupOptions: {
+ input: {
+ index: "index.html",
+ },
+ },
+ },
+ server: {
+ open: false,
+ },
+ plugins: [
+ ViteImageOptimizer(),
+ tsconfigPaths(),
+ svgr({
+ svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true },
+ include: "**/*.svg",
+ }),
+ react({}),
+ flow(),
+ viteStaticCopy({
+ targets: [{ src: "node_modules/monaco-editor/min/vs/*", dest: "monaco" }],
+ }),
+ ],
+ },
+});
diff --git a/emain/authkey.ts b/emain/authkey.ts
new file mode 100644
index 000000000..257794253
--- /dev/null
+++ b/emain/authkey.ts
@@ -0,0 +1,23 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { ipcMain } from "electron";
+import { getWebServerEndpoint, getWSServerEndpoint } from "../frontend/util/endpoints";
+
+const AuthKeyHeader = "X-AuthKey";
+export const AuthKeyEnv = "AUTH_KEY";
+export const AuthKey = crypto.randomUUID();
+
+ipcMain.on("get-auth-key", (event) => {
+ event.returnValue = AuthKey;
+});
+
+export function configureAuthKeyRequestInjection(session: Electron.Session) {
+ const filter: Electron.WebRequestFilter = {
+ urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`],
+ };
+ session.webRequest.onBeforeSendHeaders(filter, (details, callback) => {
+ details.requestHeaders[AuthKeyHeader] = AuthKey;
+ callback({ requestHeaders: details.requestHeaders });
+ });
+}
diff --git a/emain/emain-web.ts b/emain/emain-web.ts
new file mode 100644
index 000000000..842e266bf
--- /dev/null
+++ b/emain/emain-web.ts
@@ -0,0 +1,64 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { BrowserWindow, ipcMain, webContents, WebContents } from "electron";
+
+export function getWebContentsByBlockId(win: BrowserWindow, tabId: string, blockId: string): Promise {
+ const prtn = new Promise((resolve, reject) => {
+ const randId = Math.floor(Math.random() * 1000000000).toString();
+ const respCh = `getWebContentsByBlockId-${randId}`;
+ win.webContents.send("webcontentsid-from-blockid", blockId, respCh);
+ ipcMain.once(respCh, (event, webContentsId) => {
+ if (webContentsId == null) {
+ resolve(null);
+ return;
+ }
+ const wc = webContents.fromId(parseInt(webContentsId));
+ resolve(wc);
+ });
+ setTimeout(() => {
+ reject(new Error("timeout waiting for response"));
+ }, 2000);
+ });
+ return prtn;
+}
+
+function escapeSelector(selector: string): string {
+ return selector
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"')
+ .replace(/'/g, "\\'")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/\t/g, "\\t");
+}
+
+export type WebGetOpts = {
+ all?: boolean;
+ inner?: boolean;
+};
+
+export async function webGetSelector(wc: WebContents, selector: string, opts?: WebGetOpts): Promise {
+ if (!wc || !selector) {
+ return null;
+ }
+ const escapedSelector = escapeSelector(selector);
+ const queryMethod = opts?.all ? "querySelectorAll" : "querySelector";
+ const prop = opts?.inner ? "innerHTML" : "outerHTML";
+ const execExpr = `
+ (() => {
+ const toArr = x => (x instanceof NodeList) ? Array.from(x) : (x ? [x] : []);
+ try {
+ const result = document.${queryMethod}("${escapedSelector}");
+ const value = toArr(result).map(el => el.${prop});
+ return { value };
+ } catch (error) {
+ return { error: error.message };
+ }
+ })()`;
+ const results = await wc.executeJavaScript(execExpr);
+ if (results.error) {
+ throw new Error(results.error);
+ }
+ return results.value;
+}
diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts
new file mode 100644
index 000000000..45812456e
--- /dev/null
+++ b/emain/emain-wsh.ts
@@ -0,0 +1,31 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import electron from "electron";
+import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient";
+import { getWebContentsByBlockId, webGetSelector } from "./emain-web";
+
+type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise };
+
+export class ElectronWshClientType extends WshClient {
+ constructor() {
+ super("electron");
+ }
+
+ async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise {
+ if (!data.tabid || !data.blockid || !data.windowid) {
+ throw new Error("tabid and blockid are required");
+ }
+ const windows = electron.BrowserWindow.getAllWindows();
+ const win = windows.find((w) => (w as WaveBrowserWindow).waveWindowId === data.windowid);
+ if (win == null) {
+ throw new Error(`no window found with id ${data.windowid}`);
+ }
+ const wc = await getWebContentsByBlockId(win, data.tabid, data.blockid);
+ if (wc == null) {
+ throw new Error(`no webcontents found with blockid ${data.blockid}`);
+ }
+ const rtn = await webGetSelector(wc, data.selector, data.opts);
+ return rtn;
+ }
+}
diff --git a/emain/emain.ts b/emain/emain.ts
new file mode 100644
index 000000000..5138cb19a
--- /dev/null
+++ b/emain/emain.ts
@@ -0,0 +1,881 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import * as electron from "electron";
+import { FastAverageColor } from "fast-average-color";
+import fs from "fs";
+import * as child_process from "node:child_process";
+import * as path from "path";
+import { PNG } from "pngjs";
+import * as readline from "readline";
+import { sprintf } from "sprintf-js";
+import { debounce } from "throttle-debounce";
+import * as util from "util";
+import winston from "winston";
+import { initGlobal } from "../frontend/app/store/global";
+import * as services from "../frontend/app/store/services";
+import { initElectronWshrpc } from "../frontend/app/store/wshrpcutil";
+import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints";
+import { fetch } from "../frontend/util/fetchutil";
+import * as keyutil from "../frontend/util/keyutil";
+import { fireAndForget } from "../frontend/util/util";
+import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey";
+import { ElectronWshClientType } from "./emain-wsh";
+import { getAppMenu } from "./menu";
+import {
+ getElectronAppBasePath,
+ getGoAppBasePath,
+ getWaveHomeDir,
+ getWaveSrvCwd,
+ getWaveSrvPath,
+ isDev,
+ isDevVite,
+ unameArch,
+ unamePlatform,
+} from "./platform";
+import { configureAutoUpdater, updater } from "./updater";
+
+let ElectronWshClient = new ElectronWshClientType();
+const electronApp = electron.app;
+let WaveVersion = "unknown"; // set by WAVESRV-ESTART
+let WaveBuildTime = 0; // set by WAVESRV-ESTART
+
+const WaveAppPathVarName = "WAVETERM_APP_PATH";
+const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID";
+electron.nativeTheme.themeSource = "dark";
+
+type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise };
+
+let waveSrvReadyResolve = (value: boolean) => {};
+const waveSrvReady: Promise = new Promise((resolve, _) => {
+ waveSrvReadyResolve = resolve;
+});
+let globalIsQuitting = false;
+let globalIsStarting = true;
+let globalIsRelaunching = false;
+
+// for activity updates
+let wasActive = true;
+let wasInFg = true;
+
+let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused)
+let webviewKeys: string[] = []; // the keys to trap when webview has focus
+
+let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null;
+
+const waveHome = getWaveHomeDir();
+
+const oldConsoleLog = console.log;
+
+const loggerTransports: winston.transport[] = [
+ new winston.transports.File({ filename: path.join(getWaveHomeDir(), "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" }),
+ 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(
+ "waveterm-app starting, WAVETERM_HOME=%s, electronpath=%s gopath=%s arch=%s/%s",
+ waveHome,
+ getElectronAppBasePath(),
+ getGoAppBasePath(),
+ unamePlatform,
+ unameArch
+ )
+);
+if (isDev) {
+ console.log("waveterm-app WAVETERM_DEV set");
+}
+
+initGlobal({ windowId: null, clientId: null, platform: unamePlatform, environment: "electron" });
+
+function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow {
+ const windowId = event.sender.id;
+ return electron.BrowserWindow.fromId(windowId);
+}
+
+function setCtrlShift(wc: Electron.WebContents, state: boolean) {
+ wc.send("control-shift-state-update", state);
+}
+
+function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) {
+ if (waveEvent.type == "keyup") {
+ if (waveEvent.key === "Control" || waveEvent.key === "Shift") {
+ setCtrlShift(sender, false);
+ }
+ if (waveEvent.key == "Meta") {
+ if (waveEvent.control && waveEvent.shift) {
+ setCtrlShift(sender, true);
+ }
+ }
+ return;
+ }
+ if (waveEvent.type == "keydown") {
+ if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") {
+ if (waveEvent.control && waveEvent.shift && !waveEvent.meta) {
+ // Set the control and shift without the Meta key
+ setCtrlShift(sender, true);
+ } else {
+ // Unset if Meta is pressed
+ setCtrlShift(sender, false);
+ }
+ }
+ return;
+ }
+}
+
+function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) {
+ if (!focused) {
+ setCtrlShift(sender, false);
+ }
+}
+
+function runWaveSrv(): Promise {
+ let pResolve: (value: boolean) => void;
+ let pReject: (reason?: any) => void;
+ const rtnPromise = new Promise((argResolve, argReject) => {
+ pResolve = argResolve;
+ pReject = argReject;
+ });
+ const envCopy = { ...process.env };
+ envCopy[WaveAppPathVarName] = getGoAppBasePath();
+ envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString();
+ envCopy[AuthKeyEnv] = AuthKey;
+ const waveSrvCmd = getWaveSrvPath();
+ console.log("trying to run local server", waveSrvCmd);
+ const proc = child_process.spawn(getWaveSrvPath(), {
+ cwd: getWaveSrvCwd(),
+ env: envCopy,
+ });
+ proc.on("exit", (e) => {
+ if (globalIsQuitting || updater?.status == "installing") {
+ return;
+ }
+ console.log("wavesrv exited, shutting down");
+ electronApp.quit();
+ });
+ proc.on("spawn", (e) => {
+ console.log("spawned wavesrv");
+ waveSrvProc = proc;
+ pResolve(true);
+ });
+ proc.on("error", (e) => {
+ console.log("error running wavesrv", e);
+ pReject(e);
+ });
+ const rlStdout = readline.createInterface({
+ input: proc.stdout,
+ terminal: false,
+ });
+ rlStdout.on("line", (line) => {
+ console.log(line);
+ });
+ const rlStderr = readline.createInterface({
+ input: proc.stderr,
+ terminal: false,
+ });
+ rlStderr.on("line", (line) => {
+ if (line.includes("WAVESRV-ESTART")) {
+ const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec(
+ line
+ );
+ if (startParams == null) {
+ console.log("error parsing WAVESRV-ESTART line", line);
+ electronApp.quit();
+ return;
+ }
+ process.env[WSServerEndpointVarName] = startParams[1];
+ process.env[WebServerEndpointVarName] = startParams[2];
+ WaveVersion = startParams[3];
+ WaveBuildTime = parseInt(startParams[4]);
+ waveSrvReadyResolve(true);
+ return;
+ }
+ if (line.startsWith("WAVESRV-EVENT:")) {
+ const evtJson = line.slice("WAVESRV-EVENT:".length);
+ try {
+ const evtMsg: WSEventType = JSON.parse(evtJson);
+ handleWSEvent(evtMsg);
+ } catch (e) {
+ console.log("error handling WAVESRV-EVENT", e);
+ }
+ return;
+ }
+ console.log(line);
+ });
+ return rtnPromise;
+}
+
+async function handleWSEvent(evtMsg: WSEventType) {
+ console.log("handleWSEvent", evtMsg?.eventtype);
+ if (evtMsg.eventtype == "electron:newwindow") {
+ const windowId: string = evtMsg.data;
+ const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow;
+ if (windowData == null) {
+ return;
+ }
+ const clientData = await services.ClientService.GetClientData();
+ const fullConfig = await services.FileService.GetFullConfig();
+ const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig);
+ await newWin.readyPromise;
+ newWin.show();
+ } else if (evtMsg.eventtype == "electron:closewindow") {
+ if (evtMsg.data === undefined) return;
+ const windows = electron.BrowserWindow.getAllWindows();
+ for (const window of windows) {
+ if ((window as any).waveWindowId === evtMsg.data) {
+ // Bypass the "Are you sure?" dialog, since this event is called when there's no more tabs for the window.
+ window.destroy();
+ }
+ }
+ } else {
+ console.log("unhandled electron ws eventtype", evtMsg.eventtype);
+ }
+}
+
+async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) {
+ if (win == null || win.isDestroyed() || win.fullScreen) {
+ return;
+ }
+ const bounds = win.getBounds();
+ try {
+ await services.WindowService.SetWindowPosAndSize(
+ windowId,
+ { x: bounds.x, y: bounds.y },
+ { width: bounds.width, height: bounds.height }
+ );
+ } catch (e) {
+ console.log("error resizing window", e);
+ }
+}
+
+function shNavHandler(event: Electron.Event, url: string) {
+ if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) {
+ // this is a dev-mode hot-reload, ignore it
+ console.log("allowing hot-reload of index.html");
+ return;
+ }
+ event.preventDefault();
+ if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) {
+ console.log("open external, shNav", url);
+ electron.shell.openExternal(url);
+ } else {
+ console.log("navigation canceled", url);
+ }
+}
+
+function shFrameNavHandler(event: Electron.Event) {
+ if (!event.frame?.parent) {
+ // only use this handler to process iframe events (non-iframe events go to shNavHandler)
+ return;
+ }
+ const url = event.url;
+ console.log(`frame-navigation url=${url} frame=${event.frame.name}`);
+ if (event.frame.name == "webview") {
+ // "webview" links always open in new window
+ // this will *not* effect the initial load because srcdoc does not count as an electron navigation
+ console.log("open external, frameNav", url);
+ event.preventDefault();
+ electron.shell.openExternal(url);
+ return;
+ }
+ if (
+ event.frame.name == "pdfview" &&
+ (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?"))
+ ) {
+ // allowed
+ return;
+ }
+ event.preventDefault();
+ console.log("frame navigation canceled");
+}
+
+// note, this does not *show* the window.
+// to show, await win.readyPromise and then win.show()
+function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfig: FullConfigType): WaveBrowserWindow {
+ let winWidth = waveWindow?.winsize?.width;
+ let winHeight = waveWindow?.winsize?.height;
+ let winPosX = waveWindow.pos.x;
+ let winPosY = waveWindow.pos.y;
+ if (winWidth == null || winWidth == 0) {
+ const primaryDisplay = electron.screen.getPrimaryDisplay();
+ const { width } = primaryDisplay.workAreaSize;
+ winWidth = width - winPosX - 100;
+ if (winWidth > 2000) {
+ winWidth = 2000;
+ }
+ }
+ if (winHeight == null || winHeight == 0) {
+ const primaryDisplay = electron.screen.getPrimaryDisplay();
+ const { height } = primaryDisplay.workAreaSize;
+ winHeight = height - winPosY - 100;
+ if (winHeight > 1200) {
+ winHeight = 1200;
+ }
+ }
+ let winBounds = {
+ x: winPosX,
+ y: winPosY,
+ width: winWidth,
+ height: winHeight,
+ };
+ winBounds = ensureBoundsAreVisible(winBounds);
+ const winOpts: Electron.BrowserWindowConstructorOptions = {
+ titleBarStyle: unamePlatform === "darwin" ? "hiddenInset" : "hidden",
+ titleBarOverlay:
+ unamePlatform !== "darwin"
+ ? {
+ symbolColor: "white",
+ color: "#00000000",
+ }
+ : false,
+ x: winBounds.x,
+ y: winBounds.y,
+ width: winBounds.width,
+ height: winBounds.height,
+ minWidth: 400,
+ minHeight: 300,
+ icon:
+ unamePlatform == "linux"
+ ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png")
+ : undefined,
+ webPreferences: {
+ preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"),
+ webviewTag: true,
+ },
+ show: false,
+ autoHideMenuBar: true,
+ };
+ const settings = fullConfig?.settings;
+ const isTransparent = settings?.["window:transparent"] ?? false;
+ const isBlur = !isTransparent && (settings?.["window:blur"] ?? false);
+ if (isTransparent) {
+ winOpts.transparent = true;
+ } else if (isBlur) {
+ switch (unamePlatform) {
+ case "win32": {
+ winOpts.backgroundMaterial = "acrylic";
+ break;
+ }
+ case "darwin": {
+ winOpts.vibrancy = "fullscreen-ui";
+ break;
+ }
+ }
+ } else {
+ winOpts.backgroundColor = "#222222";
+ }
+ const bwin = new electron.BrowserWindow(winOpts);
+ (bwin as any).waveWindowId = waveWindow.oid;
+ let readyResolve: (value: void) => void;
+ (bwin as any).readyPromise = new Promise((resolve, _) => {
+ readyResolve = resolve;
+ });
+ const win: WaveBrowserWindow = bwin as WaveBrowserWindow;
+ const usp = new URLSearchParams();
+ usp.set("clientid", clientId);
+ usp.set("windowid", waveWindow.oid);
+ const indexHtml = "index.html";
+ if (isDevVite) {
+ console.log("running as dev server");
+ win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html?${usp.toString()}`);
+ } else {
+ console.log("running as file");
+ win.loadFile(path.join(getElectronAppBasePath(), "frontend", indexHtml), { search: usp.toString() });
+ }
+ win.once("ready-to-show", () => {
+ readyResolve();
+ });
+ win.webContents.on("will-navigate", shNavHandler);
+ win.webContents.on("will-frame-navigate", shFrameNavHandler);
+ win.webContents.on("did-attach-webview", (event, wc) => {
+ wc.setWindowOpenHandler((details) => {
+ win.webContents.send("webview-new-window", wc.id, details);
+ return { action: "deny" };
+ });
+ });
+ win.webContents.on("before-input-event", (e, input) => {
+ const waveEvent = keyutil.adaptFromElectronKeyEvent(input);
+ // console.log("WIN bie", waveEvent.type, waveEvent.code);
+ handleCtrlShiftState(win.webContents, waveEvent);
+ if (win.isFocused()) {
+ wasActive = true;
+ }
+ });
+ win.on(
+ "resize",
+ debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
+ );
+ win.on(
+ "move",
+ debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win))
+ );
+ win.on("focus", () => {
+ wasInFg = true;
+ wasActive = true;
+ if (globalIsStarting) {
+ return;
+ }
+ console.log("focus", waveWindow.oid);
+ services.ClientService.FocusWindow(waveWindow.oid);
+ });
+ win.on("blur", () => {
+ handleCtrlShiftFocus(win.webContents, false);
+ });
+ win.on("enter-full-screen", async () => {
+ win.webContents.send("fullscreen-change", true);
+ });
+ win.on("leave-full-screen", async () => {
+ win.webContents.send("fullscreen-change", false);
+ });
+ win.on("close", (e) => {
+ if (globalIsQuitting || updater?.status == "installing") {
+ return;
+ }
+ const numWindows = electron.BrowserWindow.getAllWindows().length;
+ if (numWindows == 1) {
+ return;
+ }
+ const choice = electron.dialog.showMessageBoxSync(win, {
+ type: "question",
+ buttons: ["Cancel", "Yes"],
+ title: "Confirm",
+ message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?",
+ });
+ if (choice === 0) {
+ e.preventDefault();
+ }
+ });
+ win.on("closed", () => {
+ if (globalIsQuitting || updater?.status == "installing") {
+ return;
+ }
+ const numWindows = electron.BrowserWindow.getAllWindows().length;
+ if (numWindows == 0) {
+ return;
+ }
+ services.WindowService.CloseWindow(waveWindow.oid);
+ });
+ win.webContents.on("zoom-changed", (e) => {
+ win.webContents.send("zoom-changed");
+ });
+ win.webContents.setWindowOpenHandler(({ url, frameName }) => {
+ if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) {
+ console.log("openExternal fallback", url);
+ electron.shell.openExternal(url);
+ }
+ console.log("window-open denied", url);
+ return { action: "deny" };
+ });
+ configureAuthKeyRequestInjection(win.webContents.session);
+ return win;
+}
+
+function isWindowFullyVisible(bounds: electron.Rectangle): boolean {
+ const displays = electron.screen.getAllDisplays();
+
+ // Helper function to check if a point is inside any display
+ function isPointInDisplay(x: number, y: number) {
+ for (const display of displays) {
+ const { x: dx, y: dy, width, height } = display.bounds;
+ if (x >= dx && x < dx + width && y >= dy && y < dy + height) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Check all corners of the window
+ const topLeft = isPointInDisplay(bounds.x, bounds.y);
+ const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y);
+ const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height);
+ const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height);
+
+ return topLeft && topRight && bottomLeft && bottomRight;
+}
+
+function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display {
+ const displays = electron.screen.getAllDisplays();
+ let maxArea = 0;
+ let bestDisplay = null;
+
+ for (let display of displays) {
+ const { x, y, width, height } = display.bounds;
+ const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x));
+ const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y));
+ const overlapArea = overlapX * overlapY;
+
+ if (overlapArea > maxArea) {
+ maxArea = overlapArea;
+ bestDisplay = display;
+ }
+ }
+
+ return bestDisplay;
+}
+
+function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle {
+ const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea;
+ let { x, y, width, height } = bounds;
+
+ // Adjust width and height to fit within the display's work area
+ width = Math.min(width, dWidth);
+ height = Math.min(height, dHeight);
+
+ // Adjust x to ensure the window fits within the display
+ if (x < dx) {
+ x = dx;
+ } else if (x + width > dx + dWidth) {
+ x = dx + dWidth - width;
+ }
+
+ // Adjust y to ensure the window fits within the display
+ if (y < dy) {
+ y = dy;
+ } else if (y + height > dy + dHeight) {
+ y = dy + dHeight - height;
+ }
+ return { x, y, width, height };
+}
+
+function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle {
+ if (!isWindowFullyVisible(bounds)) {
+ let targetDisplay = findDisplayWithMostArea(bounds);
+
+ if (!targetDisplay) {
+ targetDisplay = electron.screen.getPrimaryDisplay();
+ }
+
+ return adjustBoundsToFitDisplay(bounds, targetDisplay);
+ }
+ return bounds;
+}
+
+// Listen for the open-external event from the renderer process
+electron.ipcMain.on("open-external", (event, url) => {
+ if (url && typeof url === "string") {
+ electron.shell.openExternal(url).catch((err) => {
+ console.error(`Failed to open URL ${url}:`, err);
+ });
+ } else {
+ console.error("Invalid URL received in open-external event:", url);
+ }
+});
+
+electron.ipcMain.on("download", (event, payload) => {
+ const window = electron.BrowserWindow.fromWebContents(event.sender);
+ const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath);
+ window.webContents.downloadURL(streamingUrl);
+});
+
+electron.ipcMain.on("get-cursor-point", (event) => {
+ const window = electron.BrowserWindow.fromWebContents(event.sender);
+ const screenPoint = electron.screen.getCursorScreenPoint();
+ const windowRect = window.getContentBounds();
+ const retVal: Electron.Point = {
+ x: screenPoint.x - windowRect.x,
+ y: screenPoint.y - windowRect.y,
+ };
+ event.returnValue = retVal;
+});
+
+electron.ipcMain.on("get-env", (event, varName) => {
+ event.returnValue = process.env[varName] ?? null;
+});
+
+electron.ipcMain.on("get-about-modal-details", (event) => {
+ event.returnValue = { version: WaveVersion, buildTime: WaveBuildTime } as AboutModalDetails;
+});
+
+const hasBeforeInputRegisteredMap = new Map();
+
+electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => {
+ webviewFocusId = focusedId;
+ console.log("webview-focus", focusedId);
+ if (focusedId == null) {
+ return;
+ }
+ const parentWc = event.sender;
+ const webviewWc = electron.webContents.fromId(focusedId);
+ if (webviewWc == null) {
+ webviewFocusId = null;
+ return;
+ }
+ if (!hasBeforeInputRegisteredMap.get(focusedId)) {
+ hasBeforeInputRegisteredMap.set(focusedId, true);
+ webviewWc.on("before-input-event", (e, input) => {
+ let waveEvent = keyutil.adaptFromElectronKeyEvent(input);
+ // console.log(`WEB ${focusedId}`, waveEvent.type, waveEvent.code);
+ handleCtrlShiftState(parentWc, waveEvent);
+ if (webviewFocusId != focusedId) {
+ return;
+ }
+ if (input.type != "keyDown") {
+ return;
+ }
+ for (let keyDesc of webviewKeys) {
+ if (keyutil.checkKeyPressed(waveEvent, keyDesc)) {
+ e.preventDefault();
+ parentWc.send("reinject-key", waveEvent);
+ console.log("webview reinject-key", keyDesc);
+ return;
+ }
+ }
+ });
+ webviewWc.on("destroyed", () => {
+ hasBeforeInputRegisteredMap.delete(focusedId);
+ });
+ }
+});
+
+electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => {
+ webviewKeys = keys ?? [];
+});
+
+if (unamePlatform !== "darwin") {
+ const fac = new FastAverageColor();
+
+ electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => {
+ const zoomFactor = event.sender.getZoomFactor();
+ const electronRect: Electron.Rectangle = {
+ x: rect.left * zoomFactor,
+ y: rect.top * zoomFactor,
+ height: rect.height * zoomFactor,
+ width: rect.width * zoomFactor,
+ };
+ const overlay = await event.sender.capturePage(electronRect);
+ const overlayBuffer = overlay.toPNG();
+ const png = PNG.sync.read(overlayBuffer);
+ const color = fac.prepareResult(fac.getColorFromArray4(png.data));
+ const window = electron.BrowserWindow.fromWebContents(event.sender);
+ window.setTitleBarOverlay({
+ color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color.
+ symbolColor: color.isDark ? "white" : "black",
+ });
+ });
+}
+
+async function createNewWaveWindow(): Promise {
+ const clientData = await services.ClientService.GetClientData();
+ const fullConfig = await services.FileService.GetFullConfig();
+ let recreatedWindow = false;
+ if (electron.BrowserWindow.getAllWindows().length === 0 && clientData?.windowids?.length >= 1) {
+ // reopen the first window
+ const existingWindowId = clientData.windowids[0];
+ const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow;
+ if (existingWindowData != null) {
+ const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig);
+ await win.readyPromise;
+ win.show();
+ recreatedWindow = true;
+ }
+ }
+ if (recreatedWindow) {
+ return;
+ }
+ const newWindow = await services.ClientService.MakeWindow();
+ const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig);
+ await newBrowserWindow.readyPromise;
+ newBrowserWindow.show();
+}
+
+electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow));
+
+electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => {
+ const window = electron.BrowserWindow.fromWebContents(event.sender);
+ if (menuDefArr?.length === 0) {
+ return;
+ }
+ const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu();
+ const { x, y } = electron.screen.getCursorScreenPoint();
+ const windowPos = window.getPosition();
+
+ menu.popup({ window, x: x - windowPos[0], y: y - windowPos[1] });
+ event.returnValue = true;
+});
+
+async function logActiveState() {
+ const activeState = { fg: wasInFg, active: wasActive, open: true };
+ const url = new URL(getWebServerEndpoint() + "/wave/log-active-state");
+ try {
+ const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) });
+ if (!resp.ok) {
+ console.log("error logging active state", resp.status, resp.statusText);
+ return;
+ }
+ } catch (e) {
+ console.log("error logging active state", e);
+ } finally {
+ // for next iteration
+ wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false;
+ wasActive = false;
+ }
+}
+
+// this isn't perfect, but gets the job done without being complicated
+function runActiveTimer() {
+ logActiveState();
+ 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) => {
+ (window as electron.BrowserWindow)?.webContents?.send("contextmenu-click", menuDef.id);
+ },
+ };
+ 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);
+}
+
+electronApp.on("window-all-closed", () => {
+ if (globalIsRelaunching) {
+ return;
+ }
+ if (unamePlatform !== "darwin") {
+ electronApp.quit();
+ }
+});
+electronApp.on("before-quit", () => {
+ globalIsQuitting = true;
+ updater?.stop();
+});
+process.on("SIGINT", () => {
+ console.log("Caught SIGINT, shutting down");
+ electronApp.quit();
+});
+process.on("SIGHUP", () => {
+ console.log("Caught SIGHUP, shutting down");
+ electronApp.quit();
+});
+process.on("SIGTERM", () => {
+ console.log("Caught SIGTERM, shutting down");
+ electronApp.quit();
+});
+let caughtException = false;
+process.on("uncaughtException", (error) => {
+ if (caughtException) {
+ return;
+ }
+ logger.error("Uncaught Exception, shutting down: ", error);
+ caughtException = true;
+ // Optionally, handle cleanup or exit the app
+ electronApp.quit();
+});
+
+async function relaunchBrowserWindows(): Promise {
+ globalIsRelaunching = true;
+ const windows = electron.BrowserWindow.getAllWindows();
+ for (const window of windows) {
+ window.removeAllListeners();
+ window.close();
+ }
+ globalIsRelaunching = 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.ObjectService.GetObject("window:" + windowId)) as WaveWindow;
+ if (windowData == null) {
+ services.WindowService.CloseWindow(windowId).catch((e) => {
+ /* ignore */
+ });
+ continue;
+ }
+ const win = createBrowserWindow(clientData.oid, windowData, fullConfig);
+ wins.push(win);
+ }
+ for (const win of wins) {
+ await win.readyPromise;
+ console.log("show", win.waveWindowId);
+ win.show();
+ }
+}
+
+process.on("uncaughtException", (error) => {
+ console.error("Uncaught Exception:", error);
+ console.error("Stack Trace:", error.stack);
+ electron.app.quit();
+});
+
+async function appMain() {
+ const startTs = Date.now();
+ const instanceLock = electronApp.requestSingleInstanceLock();
+ if (!instanceLock) {
+ console.log("waveterm-app could not get single-instance-lock, shutting down");
+ electronApp.quit();
+ return;
+ }
+ const waveHomeDir = getWaveHomeDir();
+ if (!fs.existsSync(waveHomeDir)) {
+ fs.mkdirSync(waveHomeDir);
+ }
+ makeAppMenu();
+ try {
+ await runWaveSrv();
+ } catch (e) {
+ console.log(e.toString());
+ }
+ const ready = await waveSrvReady;
+ console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms");
+ await electronApp.whenReady();
+ configureAuthKeyRequestInjection(electron.session.defaultSession);
+ await relaunchBrowserWindows();
+ await configureAutoUpdater();
+ setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe
+ try {
+ initElectronWshrpc(ElectronWshClient, AuthKey);
+ } catch (e) {
+ console.log("error initializing wshrpc", e);
+ }
+
+ globalIsStarting = false;
+
+ electronApp.on("activate", async () => {
+ if (electron.BrowserWindow.getAllWindows().length === 0) {
+ await createNewWaveWindow();
+ }
+ });
+}
+
+appMain().catch((e) => {
+ console.log("appMain error", e);
+ electronApp.quit();
+});
diff --git a/emain/menu.ts b/emain/menu.ts
new file mode 100644
index 000000000..646162b3d
--- /dev/null
+++ b/emain/menu.ts
@@ -0,0 +1,191 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import * as electron from "electron";
+import { fireAndForget } from "../frontend/util/util";
+import { unamePlatform } from "./platform";
+import { updater } from "./updater";
+
+type AppMenuCallbacks = {
+ createNewWaveWindow: () => Promise;
+ relaunchBrowserWindows: () => Promise;
+};
+
+function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu {
+ const fileMenu: Electron.MenuItemConstructorOptions[] = [
+ {
+ label: "New Window",
+ accelerator: "CommandOrControl+Shift+N",
+ click: () => fireAndForget(callbacks.createNewWaveWindow),
+ },
+ {
+ role: "close",
+ accelerator: "", // clear the accelerator
+ click: () => {
+ electron.BrowserWindow.getFocusedWindow()?.close();
+ },
+ },
+ ];
+ const appMenu: Electron.MenuItemConstructorOptions[] = [
+ {
+ label: "About Wave Terminal",
+ click: (_, window) => {
+ window?.webContents.send("menu-item-about");
+ },
+ },
+ {
+ label: "Check for Updates",
+ click: () => {
+ fireAndForget(() => updater?.checkForUpdates(true));
+ },
+ },
+ {
+ type: "separator",
+ },
+ ];
+ if (unamePlatform === "darwin") {
+ appMenu.push(
+ {
+ role: "services",
+ },
+ {
+ type: "separator",
+ },
+ {
+ role: "hide",
+ },
+ {
+ role: "hideOthers",
+ },
+ {
+ type: "separator",
+ }
+ );
+ }
+ appMenu.push({
+ role: "quit",
+ });
+ const editMenu: Electron.MenuItemConstructorOptions[] = [
+ {
+ role: "undo",
+ accelerator: unamePlatform === "darwin" ? "Command+Z" : "",
+ },
+ {
+ role: "redo",
+ accelerator: unamePlatform === "darwin" ? "Command+Shift+Z" : "",
+ },
+ {
+ type: "separator",
+ },
+ {
+ role: "cut",
+ accelerator: unamePlatform === "darwin" ? "Command+X" : "",
+ },
+ {
+ role: "copy",
+ accelerator: unamePlatform === "darwin" ? "Command+C" : "",
+ },
+ {
+ role: "paste",
+ accelerator: unamePlatform === "darwin" ? "Command+V" : "",
+ },
+ {
+ role: "pasteAndMatchStyle",
+ accelerator: unamePlatform === "darwin" ? "Command+Shift+V" : "",
+ },
+ {
+ role: "delete",
+ },
+ {
+ role: "selectAll",
+ accelerator: unamePlatform === "darwin" ? "Command+A" : "",
+ },
+ ];
+
+ const viewMenu: Electron.MenuItemConstructorOptions[] = [
+ {
+ role: "forceReload",
+ },
+ {
+ label: "Relaunch All Windows",
+ click: () => {
+ callbacks.relaunchBrowserWindows();
+ },
+ },
+ {
+ role: "toggleDevTools",
+ },
+ {
+ type: "separator",
+ },
+ {
+ label: "Actual Size",
+ accelerator: "CommandOrControl+0",
+ click: (_, window) => {
+ window.webContents.setZoomFactor(1);
+ },
+ },
+ {
+ label: "Zoom In",
+ accelerator: "CommandOrControl+=",
+ click: (_, window) => {
+ window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2);
+ },
+ },
+ {
+ label: "Zoom In (hidden)",
+ accelerator: "CommandOrControl+Shift+=",
+ click: (_, window) => {
+ window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2);
+ },
+ visible: false,
+ acceleratorWorksWhenHidden: true,
+ },
+ {
+ label: "Zoom Out",
+ accelerator: "CommandOrControl+-",
+ click: (_, window) => {
+ window.webContents.setZoomFactor(window.webContents.getZoomFactor() - 0.2);
+ },
+ },
+ {
+ type: "separator",
+ },
+ {
+ role: "togglefullscreen",
+ },
+ ];
+ const windowMenu: Electron.MenuItemConstructorOptions[] = [
+ { role: "minimize", accelerator: "" },
+ { role: "zoom" },
+ { type: "separator" },
+ { role: "front" },
+ { type: "separator" },
+ { role: "window" },
+ ];
+ const menuTemplate: Electron.MenuItemConstructorOptions[] = [
+ {
+ role: "appMenu",
+ submenu: appMenu,
+ },
+ {
+ role: "fileMenu",
+ submenu: fileMenu,
+ },
+ {
+ role: "editMenu",
+ submenu: editMenu,
+ },
+ {
+ role: "viewMenu",
+ submenu: viewMenu,
+ },
+ {
+ role: "windowMenu",
+ submenu: windowMenu,
+ },
+ ];
+ return electron.Menu.buildFromTemplate(menuTemplate);
+}
+
+export { getAppMenu };
diff --git a/emain/platform.ts b/emain/platform.ts
new file mode 100644
index 000000000..209ea1b59
--- /dev/null
+++ b/emain/platform.ts
@@ -0,0 +1,76 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { app, ipcMain } from "electron";
+import os from "os";
+import path from "path";
+import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev";
+import * as keyutil from "../frontend/util/keyutil";
+
+const isDev = !app.isPackaged;
+const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL;
+if (isDev) {
+ process.env[WaveDevVarName] = "1";
+}
+if (isDevVite) {
+ process.env[WaveDevViteVarName] = "1";
+}
+
+app.setName(isDev ? "TheNextWave (Dev)" : "TheNextWave");
+const unamePlatform = process.platform;
+const unameArch: string = process.arch;
+keyutil.setKeyUtilPlatform(unamePlatform);
+
+ipcMain.on("get-is-dev", (event) => {
+ event.returnValue = isDev;
+});
+ipcMain.on("get-platform", (event, url) => {
+ event.returnValue = unamePlatform;
+});
+ipcMain.on("get-user-name", (event) => {
+ const userInfo = os.userInfo();
+ event.returnValue = userInfo.username;
+});
+ipcMain.on("get-host-name", (event) => {
+ event.returnValue = os.hostname();
+});
+
+// must match golang
+function getWaveHomeDir() {
+ return path.join(os.homedir(), isDev ? ".waveterm-dev" : ".waveterm");
+}
+
+function getElectronAppBasePath(): string {
+ return path.dirname(import.meta.dirname);
+}
+
+function getGoAppBasePath(): string {
+ return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked");
+}
+
+const wavesrvBinName = `wavesrv.${unameArch}`;
+
+function getWaveSrvPath(): string {
+ if (process.platform === "win32") {
+ const winBinName = `${wavesrvBinName}.exe`;
+ const appPath = path.join(getGoAppBasePath(), "bin", winBinName);
+ return `${appPath}`;
+ }
+ return path.join(getGoAppBasePath(), "bin", wavesrvBinName);
+}
+
+function getWaveSrvCwd(): string {
+ return getWaveHomeDir();
+}
+
+export {
+ getElectronAppBasePath,
+ getGoAppBasePath,
+ getWaveHomeDir,
+ getWaveSrvCwd,
+ getWaveSrvPath,
+ isDev,
+ isDevVite,
+ unameArch,
+ unamePlatform,
+};
diff --git a/emain/preload.ts b/emain/preload.ts
new file mode 100644
index 000000000..e92972923
--- /dev/null
+++ b/emain/preload.ts
@@ -0,0 +1,50 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { contextBridge, ipcRenderer, WebviewTag } from "electron";
+
+contextBridge.exposeInMainWorld("api", {
+ getAuthKey: () => ipcRenderer.sendSync("get-auth-key"),
+ getIsDev: () => ipcRenderer.sendSync("get-is-dev"),
+ getPlatform: () => ipcRenderer.sendSync("get-platform"),
+ getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"),
+ getUserName: () => ipcRenderer.sendSync("get-user-name"),
+ getHostName: () => ipcRenderer.sendSync("get-host-name"),
+ getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"),
+ openNewWindow: () => ipcRenderer.send("open-new-window"),
+ showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position),
+ onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)),
+ downloadFile: (filePath) => ipcRenderer.send("download", { filePath }),
+ openExternal: (url) => {
+ if (url && typeof url === "string") {
+ ipcRenderer.send("open-external", url);
+ } else {
+ console.error("Invalid URL passed to openExternal:", url);
+ }
+ },
+ getEnv: (varName) => ipcRenderer.sendSync("get-env", varName),
+ onFullScreenChange: (callback) =>
+ ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)),
+ onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)),
+ getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"),
+ installAppUpdate: () => ipcRenderer.send("install-app-update"),
+ onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback),
+ updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect),
+ onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)),
+ setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused),
+ registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys),
+ onControlShiftStateUpdate: (callback) =>
+ ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)),
+});
+
+// Custom event for "new-window"
+ipcRenderer.on("webview-new-window", (e, webContentsId, details) => {
+ const event = new CustomEvent("new-window", { detail: details });
+ document.getElementById("webview").dispatchEvent(event);
+});
+
+ipcRenderer.on("webcontentsid-from-blockid", (e, blockId, responseCh) => {
+ const webviewElem: WebviewTag = document.querySelector("div[data-blockid='" + blockId + "'] webview");
+ const wcId = webviewElem?.dataset?.webcontentsid;
+ ipcRenderer.send(responseCh, wcId);
+});
diff --git a/emain/updater.ts b/emain/updater.ts
new file mode 100644
index 000000000..f9784d933
--- /dev/null
+++ b/emain/updater.ts
@@ -0,0 +1,212 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { BrowserWindow, dialog, ipcMain, Notification } from "electron";
+import { autoUpdater } from "electron-updater";
+import { FileService } from "../frontend/app/store/services";
+import { isDev } from "../frontend/util/isdev";
+import { fireAndForget } from "../frontend/util/util";
+
+export let updater: Updater;
+
+export class Updater {
+ autoCheckInterval: NodeJS.Timeout | null;
+ intervalms: number;
+ autoCheckEnabled: boolean;
+ availableUpdateReleaseName: string | null;
+ availableUpdateReleaseNotes: string | null;
+ private _status: UpdaterStatus;
+ lastUpdateCheck: Date;
+
+ constructor(settings: SettingsType) {
+ this.intervalms = settings["autoupdate:intervalms"];
+ this.autoCheckEnabled = settings["autoupdate:enabled"];
+
+ this._status = "up-to-date";
+ this.lastUpdateCheck = new Date(0);
+ this.autoCheckInterval = null;
+ this.availableUpdateReleaseName = null;
+
+ autoUpdater.autoInstallOnAppQuit = settings["autoupdate:installonquit"];
+
+ // Only update the release channel if it's specified, otherwise use the one configured in the artifact.
+ const channel = settings["autoupdate:channel"];
+ if (channel) {
+ autoUpdater.channel = channel;
+ }
+
+ autoUpdater.removeAllListeners();
+
+ autoUpdater.on("error", (err) => {
+ console.log("updater error");
+ console.log(err);
+ this.status = "error";
+ });
+
+ autoUpdater.on("checking-for-update", () => {
+ console.log("checking-for-update");
+ this.status = "checking";
+ });
+
+ autoUpdater.on("update-available", () => {
+ console.log("update-available; downloading...");
+ });
+
+ autoUpdater.on("update-not-available", () => {
+ console.log("update-not-available");
+ });
+
+ autoUpdater.on("update-downloaded", (event) => {
+ console.log("update-downloaded", [event]);
+ this.availableUpdateReleaseName = event.releaseName;
+ this.availableUpdateReleaseNotes = event.releaseNotes as string | null;
+
+ // Display the update banner and create a system notification
+ this.status = "ready";
+ const updateNotification = new Notification({
+ title: "Wave Terminal",
+ body: "A new version of Wave Terminal is ready to install.",
+ });
+ updateNotification.on("click", () => {
+ fireAndForget(() => this.promptToInstallUpdate());
+ });
+ updateNotification.show();
+ });
+ }
+
+ /**
+ * The status of the Updater.
+ */
+ get status(): UpdaterStatus {
+ return this._status;
+ }
+
+ private set status(value: UpdaterStatus) {
+ this._status = value;
+ BrowserWindow.getAllWindows().forEach((window) => {
+ window.webContents.send("app-update-status", value);
+ });
+ }
+
+ /**
+ * Check for updates and start the background update check, if configured.
+ */
+ async start() {
+ if (this.autoCheckEnabled) {
+ console.log("starting updater");
+ this.autoCheckInterval = setInterval(() => {
+ fireAndForget(() => this.checkForUpdates(false));
+ }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed.
+ await this.checkForUpdates(false);
+ }
+ }
+
+ /**
+ * Stop the background update check, if configured.
+ */
+ stop() {
+ console.log("stopping updater");
+ if (this.autoCheckInterval) {
+ clearInterval(this.autoCheckInterval);
+ this.autoCheckInterval = null;
+ }
+ }
+
+ /**
+ * Checks if the configured interval time has passed since the last update check, and if so, checks for updates using the `autoUpdater` object
+ * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check.
+ */
+ async checkForUpdates(userInput: boolean) {
+ const now = new Date();
+
+ // Run an update check always if the user requests it, otherwise only if there's an active update check interval and enough time has elapsed.
+ if (
+ userInput ||
+ (this.autoCheckInterval &&
+ (!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms))
+ ) {
+ const result = await autoUpdater.checkForUpdates();
+
+ // If the user requested this check and we do not have an available update, let them know with a popup dialog. No need to tell them if there is an update, because we show a banner once the update is ready to install.
+ if (userInput && !result.downloadPromise) {
+ const dialogOpts: Electron.MessageBoxOptions = {
+ type: "info",
+ message: "There are currently no updates available.",
+ };
+ dialog.showMessageBox(BrowserWindow.getFocusedWindow(), dialogOpts);
+ }
+
+ // Only update the last check time if this is an automatic check. This ensures the interval remains consistent.
+ if (!userInput) this.lastUpdateCheck = now;
+ }
+ }
+
+ /**
+ * Prompts the user to install the downloaded application update and restarts the application
+ */
+ async promptToInstallUpdate() {
+ const dialogOpts: Electron.MessageBoxOptions = {
+ type: "info",
+ buttons: ["Restart", "Later"],
+ title: "Application Update",
+ message: process.platform === "win32" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName,
+ detail: "A new version has been downloaded. Restart the application to apply the updates.",
+ };
+
+ const allWindows = BrowserWindow.getAllWindows();
+ if (allWindows.length > 0) {
+ await dialog
+ .showMessageBox(BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts)
+ .then(({ response }) => {
+ if (response === 0) {
+ this.installUpdate();
+ }
+ });
+ }
+ }
+
+ /**
+ * Restarts the app and installs an update if it is available.
+ */
+ installUpdate() {
+ if (this.status == "ready") {
+ this.status = "installing";
+ autoUpdater.quitAndInstall();
+ }
+ }
+}
+
+ipcMain.on("install-app-update", () => fireAndForget(() => updater?.promptToInstallUpdate()));
+ipcMain.on("get-app-update-status", (event) => {
+ event.returnValue = updater?.status;
+});
+
+let autoUpdateLock = false;
+
+/**
+ * Configures the auto-updater based on the user's preference
+ */
+export async function configureAutoUpdater() {
+ if (isDev()) {
+ console.log("skipping auto-updater in dev mode");
+ return;
+ }
+
+ // simple lock to prevent multiple auto-update configuration attempts, this should be very rare
+ if (autoUpdateLock) {
+ console.log("auto-update configuration already in progress, skipping");
+ return;
+ }
+ autoUpdateLock = true;
+
+ try {
+ console.log("Configuring updater");
+ const settings = (await FileService.GetFullConfig()).settings;
+ updater = new Updater(settings);
+ await updater.start();
+ } catch (e) {
+ console.warn("error configuring updater", e.toString());
+ }
+
+ autoUpdateLock = false;
+}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 000000000..bce3896fe
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,21 @@
+// @ts-check
+
+import eslint from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import tseslint from "typescript-eslint";
+
+const baseConfig = tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended);
+
+const customConfig = {
+ ...baseConfig,
+ overrides: [
+ {
+ files: ["emain/emain.ts", "electron.vite.config.ts"],
+ env: {
+ node: true,
+ },
+ },
+ ],
+};
+
+export default [customConfig, eslintConfigPrettier];
diff --git a/frontend/app/app.less b/frontend/app/app.less
new file mode 100644
index 000000000..d4ef24a77
--- /dev/null
+++ b/frontend/app/app.less
@@ -0,0 +1,147 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+@import "./reset.less";
+@import "./theme.less";
+
+body {
+ display: flex;
+ flex-direction: row;
+ width: 100vw;
+ height: 100vh;
+ color: var(--main-text-color);
+ font: var(--base-font);
+ overflow: hidden;
+ background: var(--main-bg-color);
+ -webkit-font-smoothing: auto;
+ backface-visibility: hidden;
+ transform: translateZ(0);
+}
+
+a.plain-link {
+ color: var(--secondary-text-color);
+}
+
+*::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+}
+
+*::-webkit-scrollbar-track {
+ background-color: var(--scrollbar-background-color);
+}
+
+*::-webkit-scrollbar-thumb {
+ background-color: var(--scrollbar-thumb-color);
+ border-radius: 4px;
+ margin: 0 1px 0 1px;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background-color: var(--scrollbar-thumb-hover-color);
+}
+
+.flex-spacer {
+ flex-grow: 1;
+}
+
+.text-fixed {
+ font: var(--fixed-font);
+}
+
+#main,
+.mainapp {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+
+ .app-background {
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.error-boundary {
+ color: var(--error-color);
+}
+
+/* OverlayScrollbars styling */
+.os-scrollbar {
+ --os-handle-bg: var(--scrollbar-thumb-color);
+ --os-handle-bg-hover: var(--scrollbar-thumb-hover-color);
+ --os-handle-bg-active: var(--scrollbar-thumb-active-color);
+}
+
+.scrollbar-hide-until-hover {
+ *::-webkit-scrollbar-thumb,
+ *::-webkit-scrollbar-track {
+ display: none;
+ }
+
+ *::-webkit-scrollbar-corner {
+ display: none;
+ }
+
+ *:hover::-webkit-scrollbar-thumb {
+ display: block;
+ }
+}
+
+a {
+ color: var(--accent-color);
+}
+
+.prefers-reduced-motion {
+ * {
+ transition-duration: none !important;
+ transition-timing-function: none !important;
+ transition-property: none !important;
+ transition-delay: none !important;
+ }
+}
+
+.flash-error-container {
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
+ z-index: var(--zindex-flash-error-container);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+
+ .flash-error {
+ background: var(--error-color);
+ color: var(--main-text-color);
+ border-radius: 4px;
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ width: 280px;
+ border: 1px solid transparent;
+ max-height: 100px;
+ cursor: pointer;
+
+ .flash-error-scroll {
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ }
+
+ &.hovered {
+ border: 1px solid var(--main-text-color);
+ }
+
+ .flash-error-title {
+ font-weight: bold;
+ margin-bottom: 5px;
+ }
+
+ .flash-error-message {
+ }
+ }
+}
diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx
new file mode 100644
index 000000000..9f5c5da12
--- /dev/null
+++ b/frontend/app/app.tsx
@@ -0,0 +1,361 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useWaveObjectValue } from "@/app/store/wos";
+import { Workspace } from "@/app/workspace/workspace";
+import { ContextMenuModel } from "@/store/contextmenu";
+import { PLATFORM, WOS, atoms, getApi, globalStore, removeFlashError, useSettingsPrefixAtom } from "@/store/global";
+import { appHandleKeyDown } from "@/store/keymodel";
+import { getWebServerEndpoint } from "@/util/endpoints";
+import { getElemAsStr } from "@/util/focusutil";
+import * as keyutil from "@/util/keyutil";
+import * as util from "@/util/util";
+import useResizeObserver from "@react-hook/resize-observer";
+import clsx from "clsx";
+import Color from "color";
+import * as csstree from "css-tree";
+import debug from "debug";
+import * as jotai from "jotai";
+import "overlayscrollbars/overlayscrollbars.css";
+import * as React from "react";
+import { DndProvider } from "react-dnd";
+import { HTML5Backend } from "react-dnd-html5-backend";
+import { debounce } from "throttle-debounce";
+import "./app.less";
+import { CenteredDiv } from "./element/quickelems";
+
+const dlog = debug("wave:app");
+const focusLog = debug("wave:focus");
+
+const App = () => {
+ let Provider = jotai.Provider;
+ return (
+
+
+
+ );
+};
+
+function isContentEditableBeingEdited() {
+ const activeElement = document.activeElement;
+ return (
+ activeElement &&
+ activeElement.getAttribute("contenteditable") !== null &&
+ activeElement.getAttribute("contenteditable") !== "false"
+ );
+}
+
+function canEnablePaste() {
+ const activeElement = document.activeElement;
+ return activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || isContentEditableBeingEdited();
+}
+
+function canEnableCopy() {
+ const sel = window.getSelection();
+ return !util.isBlank(sel?.toString());
+}
+
+function canEnableCut() {
+ const sel = window.getSelection();
+ if (document.activeElement?.classList.contains("xterm-helper-textarea")) {
+ return false;
+ }
+ return !util.isBlank(sel?.toString()) && canEnablePaste();
+}
+
+function handleContextMenu(e: React.MouseEvent) {
+ e.preventDefault();
+ const canPaste = canEnablePaste();
+ const canCopy = canEnableCopy();
+ const canCut = canEnableCut();
+ if (!canPaste && !canCopy && !canCut) {
+ return;
+ }
+ let menu: ContextMenuItem[] = [];
+ if (canCut) {
+ menu.push({ label: "Cut", role: "cut" });
+ }
+ if (canCopy) {
+ menu.push({ label: "Copy", role: "copy" });
+ }
+ if (canPaste) {
+ menu.push({ label: "Paste", role: "paste" });
+ }
+ ContextMenuModel.showContextMenu(menu, e);
+}
+
+function AppSettingsUpdater() {
+ const windowSettings = useSettingsPrefixAtom("window");
+ React.useEffect(() => {
+ const isTransparentOrBlur =
+ (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false;
+ const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1);
+ let baseBgColor = windowSettings?.["window:bgcolor"];
+ if (isTransparentOrBlur) {
+ document.body.classList.add("is-transparent");
+ const rootStyles = getComputedStyle(document.documentElement);
+ if (baseBgColor == null) {
+ baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim();
+ }
+ const color = new Color(baseBgColor);
+ const rgbaColor = color.alpha(opacity).string();
+ document.body.style.backgroundColor = rgbaColor;
+ } else {
+ document.body.classList.remove("is-transparent");
+ document.body.style.opacity = null;
+ }
+ }, [windowSettings]);
+ return null;
+}
+
+function appFocusIn(e: FocusEvent) {
+ focusLog("focusin", getElemAsStr(e.target), "<=", getElemAsStr(e.relatedTarget));
+}
+
+function appFocusOut(e: FocusEvent) {
+ focusLog("focusout", getElemAsStr(e.target), "=>", getElemAsStr(e.relatedTarget));
+}
+
+function appSelectionChange(e: Event) {
+ const selection = document.getSelection();
+ focusLog("selectionchange", getElemAsStr(selection.anchorNode));
+}
+
+function AppFocusHandler() {
+ return null;
+
+ // for debugging
+ React.useEffect(() => {
+ document.addEventListener("focusin", appFocusIn);
+ document.addEventListener("focusout", appFocusOut);
+ document.addEventListener("selectionchange", appSelectionChange);
+ const ivId = setInterval(() => {
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLElement) {
+ focusLog("activeElement", getElemAsStr(activeElement));
+ }
+ }, 2000);
+ return () => {
+ document.removeEventListener("focusin", appFocusIn);
+ document.removeEventListener("focusout", appFocusOut);
+ document.removeEventListener("selectionchange", appSelectionChange);
+ clearInterval(ivId);
+ };
+ });
+ return null;
+}
+
+function encodeFileURL(file: string) {
+ const webEndpoint = getWebServerEndpoint();
+ return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`;
+}
+
+function processBackgroundUrls(cssText: string): string {
+ if (util.isBlank(cssText)) {
+ return null;
+ }
+ cssText = cssText.trim();
+ if (cssText.endsWith(";")) {
+ cssText = cssText.slice(0, -1);
+ }
+ const attrRe = /^background(-image):\s*/;
+ cssText = cssText.replace(attrRe, "");
+ const ast = csstree.parse("background: " + cssText, {
+ context: "declaration",
+ });
+ let hasJSUrl = false;
+ csstree.walk(ast, {
+ visit: "Url",
+ enter(node) {
+ const originalUrl = node.value.trim();
+ if (originalUrl.startsWith("javascript:")) {
+ hasJSUrl = true;
+ return;
+ }
+ const newUrl = encodeFileURL(originalUrl);
+ node.value = newUrl;
+ },
+ });
+ if (hasJSUrl) {
+ console.log("invalid background, contains a 'javascript' protocol url which is not allowed");
+ return null;
+ }
+ const rtnStyle = csstree.generate(ast);
+ if (rtnStyle == null) {
+ return null;
+ }
+ return rtnStyle.replace(/^background:\s*/, "");
+}
+
+function AppBackground() {
+ const bgRef = React.useRef(null);
+ const tabId = jotai.useAtomValue(atoms.activeTabId);
+ const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId));
+ const bgAttr = tabData?.meta?.bg;
+ const style: React.CSSProperties = {};
+ if (!util.isBlank(bgAttr)) {
+ try {
+ const processedBg = processBackgroundUrls(bgAttr);
+ if (!util.isBlank(processedBg)) {
+ const opacity = util.boundNumber(tabData?.meta?.["bg:opacity"], 0, 1) ?? 0.5;
+ style.opacity = opacity;
+ style.background = processedBg;
+ const blendMode = tabData?.meta?.["bg:blendmode"];
+ if (!util.isBlank(blendMode)) {
+ style.backgroundBlendMode = blendMode;
+ }
+ }
+ } catch (e) {
+ console.error("error processing background", e);
+ }
+ }
+ const getAvgColor = React.useCallback(
+ debounce(30, () => {
+ if (
+ bgRef.current &&
+ PLATFORM !== "darwin" &&
+ bgRef.current &&
+ "windowControlsOverlay" in window.navigator
+ ) {
+ const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect();
+ const bgRect = bgRef.current.getBoundingClientRect();
+ if (titlebarRect && bgRect) {
+ const windowControlsLeft = titlebarRect.width - titlebarRect.height;
+ const windowControlsRect: Dimensions = {
+ top: titlebarRect.top,
+ left: windowControlsLeft,
+ height: titlebarRect.height,
+ width: bgRect.width - bgRect.left - windowControlsLeft,
+ };
+ getApi().updateWindowControlsOverlay(windowControlsRect);
+ }
+ }
+ }),
+ [bgRef, style]
+ );
+ React.useLayoutEffect(getAvgColor, [getAvgColor]);
+ useResizeObserver(bgRef, getAvgColor);
+
+ return
;
+}
+
+const AppKeyHandlers = () => {
+ React.useEffect(() => {
+ const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown);
+ document.addEventListener("keydown", staticKeyDownHandler);
+
+ return () => {
+ document.removeEventListener("keydown", staticKeyDownHandler);
+ };
+ }, []);
+ return null;
+};
+
+const FlashError = () => {
+ const flashErrors = jotai.useAtomValue(atoms.flashErrors);
+ const [hoveredId, setHoveredId] = React.useState(null);
+ const [ticker, setTicker] = React.useState(0);
+
+ React.useEffect(() => {
+ if (flashErrors.length == 0 || hoveredId != null) {
+ return;
+ }
+ const now = Date.now();
+ for (let ferr of flashErrors) {
+ if (ferr.expiration == null || ferr.expiration < now) {
+ removeFlashError(ferr.id);
+ }
+ }
+ setTimeout(() => setTicker(ticker + 1), 1000);
+ }, [flashErrors, ticker, hoveredId]);
+
+ if (flashErrors.length == 0) {
+ return null;
+ }
+
+ function copyError(id: string) {
+ const ferr = flashErrors.find((f) => f.id === id);
+ if (ferr == null) {
+ return;
+ }
+ let text = "";
+ if (ferr.title != null) {
+ text += ferr.title;
+ }
+ if (ferr.message != null) {
+ if (text.length > 0) {
+ text += "\n";
+ }
+ text += ferr.message;
+ }
+ navigator.clipboard.writeText(text);
+ }
+
+ function convertNewlinesToBreaks(text) {
+ return text.split("\n").map((part, index) => (
+
+ {part}
+
+
+ ));
+ }
+
+ return (
+
+ {flashErrors.map((err, idx) => (
+
copyError(err.id)}
+ onMouseEnter={() => setHoveredId(err.id)}
+ onMouseLeave={() => setHoveredId(null)}
+ title="Click to Copy Error Message"
+ >
+
+ {err.title != null ?
{err.title}
: null}
+ {err.message != null ? (
+
{convertNewlinesToBreaks(err.message)}
+ ) : null}
+
+
+ ))}
+
+ );
+};
+
+const AppInner = () => {
+ const prefersReducedMotion = jotai.useAtomValue(atoms.prefersReducedMotionAtom);
+ const client = jotai.useAtomValue(atoms.client);
+ const windowData = jotai.useAtomValue(atoms.waveWindow);
+ const isFullScreen = jotai.useAtomValue(atoms.isFullScreen);
+
+ if (client == null || windowData == null) {
+ return (
+
+
+
invalid configuration, client or window was not loaded
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export { App };
diff --git a/frontend/app/asset/dots-anim-4.svg b/frontend/app/asset/dots-anim-4.svg
new file mode 100644
index 000000000..028aaf349
--- /dev/null
+++ b/frontend/app/asset/dots-anim-4.svg
@@ -0,0 +1,18 @@
+
+dots anim 4
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/app/asset/logo.svg b/frontend/app/asset/logo.svg
new file mode 100644
index 000000000..51c1d820d
--- /dev/null
+++ b/frontend/app/asset/logo.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/app/asset/magnify.svg b/frontend/app/asset/magnify.svg
new file mode 100644
index 000000000..09a4919ca
--- /dev/null
+++ b/frontend/app/asset/magnify.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less
new file mode 100644
index 000000000..7151084aa
--- /dev/null
+++ b/frontend/app/block/block.less
@@ -0,0 +1,416 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+@import "../mixins.less";
+
+.block {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ min-height: 0;
+ border-radius: var(--block-border-radius);
+
+ .block-frame-icon {
+ margin-right: 0.5em;
+ }
+
+ .block-content {
+ position: relative;
+ display: flex;
+ flex-grow: 1;
+ width: 100%;
+ overflow: hidden;
+ min-height: 0;
+ padding: 5px;
+ }
+
+ .block-focuselem {
+ height: 0;
+ width: 0;
+ input {
+ width: 0;
+ height: 0;
+ opacity: 0;
+ pointer-events: none;
+ }
+ }
+
+ .block-header-animation-wrap {
+ max-height: 0;
+ transition:
+ max-height 0.3s ease-out,
+ opacity 0.3s ease-out;
+ overflow: hidden;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 30px;
+ z-index: var(--zindex-header-hover);
+
+ &.is-showing {
+ max-height: 30px;
+ }
+ }
+
+ &.block-preview.block-frame-default .block-frame-default-inner .block-frame-default-header {
+ background-color: rgba(0, 0, 0, 0.7);
+ }
+
+ &.block-frame-default {
+ position: relative;
+ padding: 1px;
+
+ .block-frame-default-inner {
+ background-color: var(--block-bg-color);
+ width: 100%;
+ height: 100%;
+ border-radius: var(--block-border-radius);
+ display: flex;
+ flex-direction: column;
+
+ .block-frame-default-header {
+ max-height: var(--header-height);
+ min-height: var(--header-height);
+ display: flex;
+ padding: 4px 5px 4px 10px;
+ align-items: center;
+ gap: 8px;
+ font: var(--header-font);
+ border-bottom: 1px solid var(--border-color);
+ border-radius: var(--block-border-radius) var(--block-border-radius) 0 0;
+
+ .block-frame-default-header-iconview {
+ display: flex;
+ flex-shrink: 3;
+ min-width: 17px;
+ align-items: center;
+ gap: 8px;
+ overflow-x: hidden;
+
+ .block-frame-view-icon {
+ font-size: var(--header-icon-size);
+ opacity: 0.5;
+ width: var(--header-icon-width);
+ i {
+ margin-right: 0;
+ }
+ }
+
+ .block-frame-view-type {
+ overflow-x: hidden;
+ text-wrap: nowrap;
+ text-overflow: ellipsis;
+ flex-shrink: 1;
+ min-width: 0;
+ }
+
+ .block-frame-blockid {
+ opacity: 0.5;
+ }
+ }
+
+ .block-frame-text {
+ .ellipsis();
+ font: var(--fixed-font);
+ font-size: 11px;
+ opacity: 0.7;
+ flex-grow: 1;
+
+ &.preview-filename {
+ direction: rtl;
+ text-align: left;
+ span {
+ cursor: pointer;
+
+ &:hover {
+ background: var(--highlight-bg-color);
+ }
+ }
+ }
+ }
+
+ .connection-button {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ font-weight: 400;
+ color: var(--main-text-color);
+ border-radius: 2px;
+ padding: auto;
+
+ &:hover {
+ background-color: var(--highlight-bg-color);
+ }
+
+ .connection-icon-box {
+ flex: 1 1 auto;
+ overflow: hidden;
+ }
+
+ .connection-name {
+ flex: 1 2 auto;
+ overflow: hidden;
+ padding-right: 4px;
+ }
+
+ .connecting-svg {
+ position: relative;
+ top: 5px;
+ left: 9px;
+ svg {
+ fill: var(--warning-color);
+ }
+ }
+ }
+
+ .block-frame-textelems-wrapper {
+ display: flex;
+ flex: 1 2 auto;
+ min-width: 0;
+ gap: 8px;
+ align-items: center;
+
+ .block-frame-div {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ justify-content: space-between;
+ align-items: center;
+
+ .input-wrapper {
+ flex-grow: 1;
+
+ input {
+ background-color: transparent;
+ outline: none;
+ border: none;
+ color: var(--app-text-color);
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ box-sizing: border-box;
+ opacity: 0.7;
+ font-weight: 500;
+ }
+ }
+
+ .button {
+ margin-left: 3px;
+ }
+
+ // webview specific. for refresh button
+ .iconbutton {
+ height: 100%;
+ width: 27px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ .block-frame-div-url,
+ .block-frame-div-search {
+ background: rgba(255, 255, 255, 0.1);
+
+ input {
+ opacity: 1;
+ }
+ }
+ }
+
+ .block-frame-end-icons {
+ display: flex;
+ flex-shrink: 0;
+
+ .iconbutton {
+ display: flex;
+ width: 24px;
+ padding: 4px 6px;
+ align-items: center;
+ }
+
+ .block-frame-magnify {
+ justify-content: center;
+ align-items: center;
+ padding: 0;
+
+ svg {
+ #arrow1,
+ #arrow2 {
+ fill: var(--main-text-color);
+ }
+ }
+ }
+ }
+ }
+
+ .block-frame-preview {
+ background-color: rgba(0, 0, 0, 0.7);
+ width: 100%;
+ flex-grow: 1;
+ border-bottom-left-radius: var(--block-border-radius);
+ border-bottom-right-radius: var(--block-border-radius);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .iconbutton {
+ opacity: 0.7;
+ font-size: 45px;
+ margin: -30px 0 0 0;
+ }
+ }
+ }
+
+ .connstatus-overlay {
+ position: absolute;
+ top: calc(var(--header-height) + 6px);
+ left: 6px;
+ right: 6px;
+ z-index: var(--zindex-block-mask-inner);
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ flex-direction: column;
+ overflow: hidden;
+ background: rgba(230, 186, 30, 0.2);
+ backdrop-filter: blur(50px);
+ border-radius: 6px;
+ box-shadow: 0px 13px 16px 0px rgba(0, 0, 0, 0.4);
+
+ .connstatus-content {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ padding: 10px 8px 10px 12px;
+ width: 100%;
+ font: var(--base-font);
+ color: var(--secondary-text-color);
+ gap: 12px;
+
+ .connstatus-status-icon-wrapper {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 12px;
+ flex-grow: 1;
+ min-width: 0;
+
+ &.has-error {
+ align-items: flex-start;
+ }
+
+ > i {
+ color: #e6ba1e;
+ font-size: 16px;
+ }
+
+ .connstatus-status {
+ .ellipsis();
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ flex-grow: 1;
+ width: 100%;
+
+ .connstatus-status-text {
+ .ellipsis();
+ max-width: 100%;
+ font-size: 11px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 16px;
+ letter-spacing: 0.11px;
+ color: white;
+ }
+
+ .connstatus-error {
+ .ellipsis();
+ width: 94%;
+ font-size: 11px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 15px;
+ letter-spacing: 0.11px;
+ }
+ }
+ }
+
+ .connstatus-actions {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 6px;
+
+ button {
+ i {
+ font-size: 11px;
+ opacity: 0.7;
+ }
+ }
+
+ .button:last-child {
+ margin-top: 1.5px;
+ }
+ }
+ }
+ }
+
+ .block-mask {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border: 2px solid transparent;
+ pointer-events: none;
+ padding: 2px;
+ border-radius: calc(var(--block-border-radius) + 2px);
+ z-index: var(--zindex-block-mask-inner);
+
+ &.show-block-mask {
+ user-select: none;
+ pointer-events: auto;
+ }
+
+ &.show-block-mask .block-mask-inner {
+ margin-top: var(--header-height); // TODO fix this magic
+ background-color: rgba(0, 0, 0, 0.5);
+ height: calc(100% - var(--header-height));
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .bignum {
+ margin-top: -15%;
+ font-size: 60px;
+ font-weight: bold;
+ opacity: 0.7;
+ }
+ }
+ }
+
+ &.block-focused {
+ .block-mask {
+ border: 2px solid var(--accent-color);
+ }
+
+ &.block-no-highlight,
+ &.block-preview {
+ .block-mask {
+ border: 2px solid rgba(255, 255, 255, 0.1);
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx
new file mode 100644
index 000000000..27c1c6ed3
--- /dev/null
+++ b/frontend/app/block/block.tsx
@@ -0,0 +1,279 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes";
+import { PlotView } from "@/app/view/plotview/plotview";
+import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview";
+import { ErrorBoundary } from "@/element/errorboundary";
+import { CenteredDiv } from "@/element/quickelems";
+import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index";
+import {
+ counterInc,
+ getBlockComponentModel,
+ registerBlockComponentModel,
+ unregisterBlockComponentModel,
+} from "@/store/global";
+import * as WOS from "@/store/wos";
+import { getElemAsStr } from "@/util/focusutil";
+import * as util from "@/util/util";
+import { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel } from "@/view/cpuplot/cpuplot";
+import { HelpView, HelpViewModel, makeHelpViewModel } from "@/view/helpview/helpview";
+import { TermViewModel, TerminalView, makeTerminalModel } from "@/view/term/term";
+import { WaveAi, WaveAiModel, makeWaveAiViewModel } from "@/view/waveai/waveai";
+import { WebView, WebViewModel, makeWebViewModel } from "@/view/webview/webview";
+import * as jotai from "jotai";
+import * as React from "react";
+import "./block.less";
+import { BlockFrame } from "./blockframe";
+import { blockViewToIcon, blockViewToName } from "./blockutil";
+
+type FullBlockProps = {
+ preview: boolean;
+ nodeModel: NodeModel;
+ viewModel: ViewModel;
+};
+
+function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel {
+ if (blockView === "term") {
+ return makeTerminalModel(blockId);
+ }
+ if (blockView === "preview") {
+ return makePreviewModel(blockId, nodeModel);
+ }
+ if (blockView === "web") {
+ return makeWebViewModel(blockId, nodeModel);
+ }
+ if (blockView === "waveai") {
+ return makeWaveAiViewModel(blockId);
+ }
+ if (blockView === "cpuplot") {
+ return makeCpuPlotViewModel(blockId);
+ }
+ if (blockView === "help") {
+ return makeHelpViewModel();
+ }
+ return makeDefaultViewModel(blockId, blockView);
+}
+
+function getViewElem(
+ blockId: string,
+ blockRef: React.RefObject,
+ contentRef: React.RefObject,
+ blockView: string,
+ viewModel: ViewModel
+): JSX.Element {
+ if (util.isBlank(blockView)) {
+ return No View ;
+ }
+ if (blockView === "term") {
+ return ;
+ }
+ if (blockView === "preview") {
+ return (
+
+ );
+ }
+ if (blockView === "plot") {
+ return ;
+ }
+ if (blockView === "web") {
+ return ;
+ }
+ if (blockView === "waveai") {
+ return ;
+ }
+ if (blockView === "cpuplot") {
+ return ;
+ }
+ if (blockView == "help") {
+ return ;
+ }
+ return Invalid View "{blockView}" ;
+}
+
+function makeDefaultViewModel(blockId: string, viewType: string): ViewModel {
+ const blockDataAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId));
+ let viewModel: ViewModel = {
+ viewType: viewType,
+ viewIcon: jotai.atom((get) => {
+ const blockData = get(blockDataAtom);
+ return blockViewToIcon(blockData?.meta?.view);
+ }),
+ viewName: jotai.atom((get) => {
+ const blockData = get(blockDataAtom);
+ return blockViewToName(blockData?.meta?.view);
+ }),
+ viewText: jotai.atom((get) => {
+ const blockData = get(blockDataAtom);
+ return blockData?.meta?.title;
+ }),
+ preIconButton: jotai.atom(null),
+ endIconButtons: jotai.atom(null),
+ };
+ return viewModel;
+}
+
+const BlockPreview = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId));
+ if (!blockData) {
+ return null;
+ }
+ return (
+
+ );
+});
+
+const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
+ counterInc("render-BlockFull");
+ const focusElemRef = React.useRef(null);
+ const blockRef = React.useRef(null);
+ const contentRef = React.useRef(null);
+ const [blockClicked, setBlockClicked] = React.useState(false);
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId));
+ const isFocused = jotai.useAtomValue(nodeModel.isFocused);
+ const disablePointerEvents = jotai.useAtomValue(nodeModel.disablePointerEvents);
+ const innerRect = useDebouncedNodeInnerRect(nodeModel);
+
+ React.useLayoutEffect(() => {
+ setBlockClicked(isFocused);
+ }, [isFocused]);
+
+ React.useLayoutEffect(() => {
+ if (!blockClicked) {
+ return;
+ }
+ setBlockClicked(false);
+ const focusWithin = blockRef.current?.contains(document.activeElement);
+ if (!focusWithin) {
+ setFocusTarget();
+ }
+ if (!isFocused) {
+ console.log("blockClicked focus", nodeModel.blockId);
+ nodeModel.focusNode();
+ }
+ }, [blockClicked, isFocused]);
+
+ const setBlockClickedTrue = React.useCallback(() => {
+ setBlockClicked(true);
+ }, []);
+
+ const [blockContentOffset, setBlockContentOffset] = React.useState();
+
+ React.useEffect(() => {
+ if (blockRef.current && contentRef.current) {
+ const blockRect = blockRef.current.getBoundingClientRect();
+ const contentRect = contentRef.current.getBoundingClientRect();
+ setBlockContentOffset({
+ top: 0,
+ left: 0,
+ width: blockRect.width - contentRect.width,
+ height: blockRect.height - contentRect.height,
+ });
+ }
+ }, [blockRef, contentRef]);
+
+ const blockContentStyle = React.useMemo(() => {
+ const retVal: React.CSSProperties = {
+ pointerEvents: disablePointerEvents ? "none" : undefined,
+ };
+ if (innerRect?.width && innerRect.height && blockContentOffset) {
+ retVal.width = `calc(${innerRect?.width} - ${blockContentOffset.width}px)`;
+ retVal.height = `calc(${innerRect?.height} - ${blockContentOffset.height}px)`;
+ }
+ return retVal;
+ }, [innerRect, disablePointerEvents, blockContentOffset]);
+
+ const viewElem = React.useMemo(
+ () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel),
+ [nodeModel.blockId, blockData?.meta?.view, viewModel]
+ );
+
+ const handleChildFocus = React.useCallback(
+ (event: React.FocusEvent) => {
+ console.log("setFocusedChild", nodeModel.blockId, getElemAsStr(event.target));
+ if (!isFocused) {
+ console.log("focusedChild focus", nodeModel.blockId);
+ nodeModel.focusNode();
+ }
+ },
+ [isFocused]
+ );
+
+ const setFocusTarget = React.useCallback(() => {
+ const ok = viewModel?.giveFocus?.();
+ if (ok) {
+ return;
+ }
+ focusElemRef.current?.focus({ preventScroll: true });
+ }, []);
+
+ const blockModel: BlockComponentModel2 = {
+ onClick: setBlockClickedTrue,
+ onFocusCapture: handleChildFocus,
+ blockRef: blockRef,
+ };
+
+ return (
+
+
+ {}}
+ />
+
+
+
+ Loading...}>{viewElem}
+
+
+
+ );
+});
+
+const Block = React.memo((props: BlockProps) => {
+ counterInc("render-Block");
+ counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8));
+ const [blockData, loading] = WOS.useWaveObjectValue(WOS.makeORef("block", props.nodeModel.blockId));
+ const bcm = getBlockComponentModel(props.nodeModel.blockId);
+ let viewModel = bcm?.viewModel;
+ if (viewModel == null || viewModel.viewType != blockData?.meta?.view) {
+ viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel);
+ registerBlockComponentModel(props.nodeModel.blockId, { viewModel });
+ }
+ React.useEffect(() => {
+ return () => {
+ unregisterBlockComponentModel(props.nodeModel.blockId);
+ };
+ }, []);
+ if (loading || util.isBlank(props.nodeModel.blockId) || blockData == null) {
+ return null;
+ }
+ if (props.preview) {
+ return ;
+ }
+ return ;
+});
+
+export { Block };
diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx
new file mode 100644
index 000000000..664ad2859
--- /dev/null
+++ b/frontend/app/block/blockframe.tsx
@@ -0,0 +1,743 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ blockViewToIcon,
+ blockViewToName,
+ computeConnColorNum,
+ ConnectionButton,
+ ControllerStatusIcon,
+ getBlockHeaderIcon,
+ Input,
+} from "@/app/block/blockutil";
+import { Button } from "@/app/element/button";
+import { useWidth } from "@/app/hook/useWidth";
+import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
+import { ContextMenuModel } from "@/app/store/contextmenu";
+import {
+ atoms,
+ getBlockComponentModel,
+ getConnStatusAtom,
+ getHostName,
+ getUserName,
+ globalStore,
+ refocusNode,
+ useBlockAtom,
+ useSettingsKeyAtom,
+ WOS,
+} from "@/app/store/global";
+import * as services from "@/app/store/services";
+import { RpcApi } from "@/app/store/wshclientapi";
+import { WindowRpcClient } from "@/app/store/wshrpcutil";
+import { ErrorBoundary } from "@/element/errorboundary";
+import { IconButton } from "@/element/iconbutton";
+import { MagnifyIcon } from "@/element/magnify";
+import { NodeModel } from "@/layout/index";
+import * as keyutil from "@/util/keyutil";
+import * as util from "@/util/util";
+import clsx from "clsx";
+import * as jotai from "jotai";
+import * as React from "react";
+import { BlockFrameProps } from "./blocktypes";
+
+const NumActiveConnColors = 8;
+
+function handleHeaderContextMenu(
+ e: React.MouseEvent,
+ blockData: Block,
+ viewModel: ViewModel,
+ magnified: boolean,
+ onMagnifyToggle: () => void,
+ onClose: () => void
+) {
+ e.preventDefault();
+ e.stopPropagation();
+ let menu: ContextMenuItem[] = [
+ {
+ label: magnified ? "Un-Magnify Block" : "Magnify Block",
+ click: () => {
+ onMagnifyToggle();
+ },
+ },
+ {
+ label: "Move to New Window",
+ click: () => {
+ const currentTabId = globalStore.get(atoms.activeTabId);
+ try {
+ services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid);
+ } catch (e) {
+ console.error("error moving block to new window", e);
+ }
+ },
+ },
+ { type: "separator" },
+ {
+ label: "Copy BlockId",
+ click: () => {
+ navigator.clipboard.writeText(blockData.oid);
+ },
+ },
+ ];
+ const extraItems = viewModel?.getSettingsMenuItems?.();
+ if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems);
+ menu.push(
+ { type: "separator" },
+ {
+ label: "Close Block",
+ click: onClose,
+ }
+ );
+ ContextMenuModel.showContextMenu(menu, e);
+}
+
+function getViewIconElem(viewIconUnion: string | IconButtonDecl, blockData: Block): JSX.Element {
+ if (viewIconUnion == null || typeof viewIconUnion === "string") {
+ const viewIcon = viewIconUnion as string;
+ return {getBlockHeaderIcon(viewIcon, blockData)}
;
+ } else {
+ return ;
+ }
+}
+
+const OptMagnifyButton = React.memo(
+ ({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => {
+ const magnifyDecl: IconButtonDecl = {
+ elemtype: "iconbutton",
+ icon: ,
+ title: magnified ? "Minimize" : "Magnify",
+ click: toggleMagnify,
+ disabled,
+ };
+ return ;
+ }
+);
+
+function computeEndIcons(
+ viewModel: ViewModel,
+ nodeModel: NodeModel,
+ onContextMenu: (e: React.MouseEvent) => void
+): JSX.Element[] {
+ const endIconsElem: JSX.Element[] = [];
+ const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons);
+ const magnified = jotai.useAtomValue(nodeModel.isMagnified);
+ const numLeafs = jotai.useAtomValue(nodeModel.numLeafs);
+ const magnifyDisabled = numLeafs <= 1;
+
+ if (endIconButtons && endIconButtons.length > 0) {
+ endIconsElem.push(...endIconButtons.map((button, idx) => ));
+ }
+ const settingsDecl: IconButtonDecl = {
+ elemtype: "iconbutton",
+ icon: "cog",
+ title: "Settings",
+ click: onContextMenu,
+ };
+ endIconsElem.push( );
+ endIconsElem.push(
+
+ );
+ const closeDecl: IconButtonDecl = {
+ elemtype: "iconbutton",
+ icon: "xmark-large",
+ title: "Close",
+ click: nodeModel.onClose,
+ };
+ endIconsElem.push( );
+ return endIconsElem;
+}
+
+const BlockFrame_Header = ({
+ nodeModel,
+ viewModel,
+ preview,
+ connBtnRef,
+ changeConnModalAtom,
+ error,
+}: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom; error?: Error }) => {
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId));
+ const viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(blockData?.meta?.view);
+ const showBlockIds = jotai.useAtomValue(useSettingsKeyAtom("blockheader:showblockids"));
+ const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
+ const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton);
+ const headerTextUnion = util.useAtomValueSafe(viewModel?.viewText);
+ const magnified = jotai.useAtomValue(nodeModel.isMagnified);
+ const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);
+ const dragHandleRef = preview ? null : nodeModel.dragHandleRef;
+
+ const onContextMenu = React.useCallback(
+ (e: React.MouseEvent) => {
+ handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify, nodeModel.onClose);
+ },
+ [magnified]
+ );
+
+ const endIconsElem = computeEndIcons(viewModel, nodeModel, onContextMenu);
+ const viewIconElem = getViewIconElem(viewIconUnion, blockData);
+ let preIconButtonElem: JSX.Element = null;
+ if (preIconButton) {
+ preIconButtonElem = ;
+ }
+
+ const headerTextElems: JSX.Element[] = [];
+ if (typeof headerTextUnion === "string") {
+ if (!util.isBlank(headerTextUnion)) {
+ headerTextElems.push(
+
+ {headerTextUnion}
+
+ );
+ }
+ } else if (Array.isArray(headerTextUnion)) {
+ headerTextElems.push(...renderHeaderElements(headerTextUnion, preview));
+ }
+ headerTextElems.unshift( );
+ if (error != null) {
+ const copyHeaderErr = () => {
+ navigator.clipboard.writeText(error.message + "\n" + error.stack);
+ };
+ headerTextElems.push(
+
+
+
+ );
+ }
+
+ return (
+
+ {preIconButtonElem}
+
+ {viewIconElem}
+
{viewName}
+ {showBlockIds &&
[{nodeModel.blockId.substring(0, 8)}]
}
+
+ {manageConnection && (
+
+ )}
+
{headerTextElems}
+
{endIconsElem}
+
+ );
+};
+
+const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => {
+ if (elem.elemtype == "iconbutton") {
+ return ;
+ } else if (elem.elemtype == "input") {
+ return ;
+ } else if (elem.elemtype == "text") {
+ return (
+
+ elem?.onClick()}>
+ {elem.text}
+
+
+ );
+ } else if (elem.elemtype == "textbutton") {
+ return (
+ elem.onClick(e)}>
+ {elem.text}
+
+ );
+ } else if (elem.elemtype == "div") {
+ return (
+
+ {elem.children.map((child, childIdx) => (
+
+ ))}
+
+ );
+ }
+ return null;
+});
+
+function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): JSX.Element[] {
+ const headerTextElems: JSX.Element[] = [];
+ for (let idx = 0; idx < headerTextUnion.length; idx++) {
+ const elem = headerTextUnion[idx];
+ const renderedElement = ;
+ if (renderedElement) {
+ headerTextElems.push(renderedElement);
+ }
+ }
+ return headerTextElems;
+}
+
+const ConnStatusOverlay = React.memo(
+ ({
+ nodeModel,
+ viewModel,
+ changeConnModalAtom,
+ }: {
+ nodeModel: NodeModel;
+ viewModel: ViewModel;
+ changeConnModalAtom: jotai.PrimitiveAtom;
+ }) => {
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId));
+ const [connModalOpen] = jotai.useAtom(changeConnModalAtom);
+ const connName = blockData.meta?.connection;
+ const connStatus = jotai.useAtomValue(getConnStatusAtom(connName));
+ const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom);
+ const overlayRef = React.useRef(null);
+ const width = useWidth(overlayRef);
+ const [showError, setShowError] = React.useState(false);
+ const blockNum = jotai.useAtomValue(nodeModel.blockNum);
+
+ React.useEffect(() => {
+ if (width) {
+ const hasError = !util.isBlank(connStatus.error);
+ const showError = hasError && width >= 250 && connStatus.status != "connecting";
+ setShowError(showError);
+ }
+ }, [width, connStatus, setShowError]);
+
+ const handleTryReconnect = React.useCallback(() => {
+ const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connName, { timeout: 60000 });
+ prtn.catch((e) => console.log("error reconnecting", connName, e));
+ }, [connName]);
+
+ let statusText = `Disconnected from "${connName}"`;
+ let showReconnect = true;
+ if (connStatus.status == "connecting") {
+ statusText = `Connecting to "${connName}"...`;
+ showReconnect = false;
+ }
+ let reconDisplay = null;
+ let reconClassName = "outlined grey";
+ if (width && width < 350) {
+ reconDisplay = ;
+ reconClassName = clsx(reconClassName, "font-size-12 vertical-padding-5 horizontal-padding-6");
+ } else {
+ reconDisplay = "Reconnect";
+ reconClassName = clsx(reconClassName, "font-size-11 vertical-padding-3 horizontal-padding-7");
+ }
+ const showIcon = connStatus.status != "connecting";
+
+ if (isLayoutMode || connStatus.status == "connected" || connModalOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+ {showIcon &&
}
+
+
{statusText}
+ {showError ?
error: {connStatus.error}
: null}
+
+
+ {showReconnect ? (
+
+
+ {reconDisplay}
+
+
+ ) : null}
+
+
+ );
+ }
+);
+
+const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => {
+ const isFocused = jotai.useAtomValue(nodeModel.isFocused);
+ const blockNum = jotai.useAtomValue(nodeModel.blockNum);
+ const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom);
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId));
+ const style: React.CSSProperties = {};
+ let showBlockMask = false;
+
+ if (!isFocused && blockData?.meta?.["frame:bordercolor"]) {
+ style.borderColor = blockData.meta["frame:bordercolor"];
+ }
+ if (isFocused && blockData?.meta?.["frame:bordercolor:focused"]) {
+ style.borderColor = blockData.meta["frame:bordercolor:focused"];
+ }
+ let innerElem = null;
+ if (isLayoutMode) {
+ showBlockMask = true;
+ innerElem = (
+
+ );
+ }
+ return (
+
+ {innerElem}
+
+ );
+});
+
+const BlockFrame_Default_Component = (props: BlockFrameProps) => {
+ const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId));
+ const isFocused = jotai.useAtomValue(nodeModel.isFocused);
+ const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
+ const customBg = util.useAtomValueSafe(viewModel?.blockBg);
+ const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection);
+ const changeConnModalAtom = useBlockAtom(nodeModel.blockId, "changeConn", () => {
+ return jotai.atom(false);
+ }) as jotai.PrimitiveAtom;
+ const connModalOpen = jotai.useAtomValue(changeConnModalAtom);
+
+ const connBtnRef = React.useRef();
+ React.useEffect(() => {
+ if (!manageConnection) {
+ return;
+ }
+ const bcm = getBlockComponentModel(nodeModel.blockId);
+ if (bcm != null) {
+ bcm.openSwitchConnection = () => {
+ globalStore.set(changeConnModalAtom, true);
+ };
+ }
+ return () => {
+ const bcm = getBlockComponentModel(nodeModel.blockId);
+ if (bcm != null) {
+ bcm.openSwitchConnection = null;
+ }
+ };
+ }, [manageConnection]);
+ React.useEffect(() => {
+ // on mount, if manageConnection, call ConnEnsure
+ if (!manageConnection || blockData == null || preview) {
+ return;
+ }
+ const connName = blockData?.meta?.connection;
+ if (!util.isBlank(connName)) {
+ console.log("ensure conn", nodeModel.blockId, connName);
+ RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }).catch((e) => {
+ console.log("error ensuring connection", nodeModel.blockId, connName, e);
+ });
+ }
+ }, [manageConnection, blockData]);
+
+ const viewIconElem = getViewIconElem(viewIconUnion, blockData);
+ const innerStyle: React.CSSProperties = {};
+ if (!preview && customBg?.bg != null) {
+ innerStyle.background = customBg.bg;
+ if (customBg["bg:opacity"] != null) {
+ innerStyle.opacity = customBg["bg:opacity"];
+ }
+ if (customBg["bg:blendmode"] != null) {
+ innerStyle.backgroundBlendMode = customBg["bg:blendmode"];
+ }
+ }
+ const previewElem = {viewIconElem}
;
+ const headerElem = (
+
+ );
+ const headerElemNoView = React.cloneElement(headerElem, { viewModel: null });
+ return (
+
+
+ {preview || viewModel == null ? null : (
+
+ )}
+
+ {headerElem}
+ {preview ? previewElem : children}
+
+ {preview || viewModel == null || !connModalOpen ? null : (
+
+ )}
+
+ );
+};
+
+const ChangeConnectionBlockModal = React.memo(
+ ({
+ blockId,
+ viewModel,
+ blockRef,
+ connBtnRef,
+ changeConnModalAtom,
+ nodeModel,
+ }: {
+ blockId: string;
+ viewModel: ViewModel;
+ blockRef: React.RefObject;
+ connBtnRef: React.RefObject;
+ changeConnModalAtom: jotai.PrimitiveAtom;
+ nodeModel: NodeModel;
+ }) => {
+ const [connSelected, setConnSelected] = React.useState("");
+ const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom);
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId));
+ const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused);
+ const connection = blockData?.meta?.connection;
+ const connStatusAtom = getConnStatusAtom(connection);
+ const connStatus = jotai.useAtomValue(connStatusAtom);
+ const [connList, setConnList] = React.useState>([]);
+ const allConnStatus = jotai.useAtomValue(atoms.allConnStatus);
+ const [rowIndex, setRowIndex] = React.useState(0);
+ const connStatusMap = new Map();
+ let maxActiveConnNum = 1;
+ for (const conn of allConnStatus) {
+ if (conn.activeconnnum > maxActiveConnNum) {
+ maxActiveConnNum = conn.activeconnnum;
+ }
+ connStatusMap.set(conn.connection, conn);
+ }
+ React.useEffect(() => {
+ if (!changeConnModalOpen) {
+ setConnList([]);
+ return;
+ }
+ const prtn = RpcApi.ConnListCommand(WindowRpcClient, { timeout: 2000 });
+ prtn.then((newConnList) => {
+ setConnList(newConnList ?? []);
+ }).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e));
+ }, [changeConnModalOpen, setConnList]);
+
+ const changeConnection = React.useCallback(
+ async (connName: string) => {
+ if (connName == "") {
+ connName = null;
+ }
+ if (connName == blockData?.meta?.connection) {
+ return;
+ }
+ const oldCwd = blockData?.meta?.file ?? "";
+ let newCwd: string;
+ if (oldCwd == "") {
+ newCwd = "";
+ } else {
+ newCwd = "~";
+ }
+ await RpcApi.SetMetaCommand(WindowRpcClient, {
+ oref: WOS.makeORef("block", blockId),
+ meta: { connection: connName, file: newCwd },
+ });
+ try {
+ await RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 });
+ } catch (e) {
+ console.log("error connecting", blockId, connName, e);
+ }
+ },
+ [blockId, blockData]
+ );
+
+ let createNew: boolean = true;
+ let showLocal: boolean = true;
+ let showReconnect: boolean = true;
+ if (connSelected == "") {
+ createNew = false;
+ } else {
+ showLocal = false;
+ showReconnect = false;
+ }
+ const filteredList: Array = [];
+ for (const conn of connList) {
+ if (conn === connSelected) {
+ createNew = false;
+ }
+ if (conn.includes(connSelected)) {
+ filteredList.push(conn);
+ }
+ }
+ // priority handles special suggestions when necessary
+ // for instance, when reconnecting
+ const newConnectionSuggestion: SuggestionConnectionItem = {
+ status: "connected",
+ icon: "plus",
+ iconColor: "var(--conn-icon-color)",
+ label: `${connSelected} (New Connection)`,
+ value: "",
+ onSelect: (_: string) => {
+ changeConnection(connSelected);
+ globalStore.set(changeConnModalAtom, false);
+ },
+ };
+ const reconnectSuggestion: SuggestionConnectionItem = {
+ status: "connected",
+ icon: "arrow-right-arrow-left",
+ iconColor: "var(--grey-text-color)",
+ label: `Reconnect to ${connStatus.connection}`,
+ value: "",
+ onSelect: async (_: string) => {
+ const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connStatus.connection, { timeout: 60000 });
+ prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e));
+ },
+ };
+ const priorityItems: Array = [];
+ if (createNew) {
+ priorityItems.push(newConnectionSuggestion);
+ }
+ if (showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error")) {
+ priorityItems.push(reconnectSuggestion);
+ }
+ const prioritySuggestions: SuggestionConnectionScope = {
+ headerText: "",
+ items: priorityItems,
+ };
+ const localName = getUserName() + "@" + getHostName();
+ const localSuggestion: SuggestionConnectionScope = {
+ headerText: "Local",
+ items: [],
+ };
+ if (showLocal) {
+ localSuggestion.items.push({
+ status: "connected",
+ icon: "laptop",
+ iconColor: "var(--grey-text-color)",
+ value: "",
+ label: localName,
+ });
+ }
+ const remoteItems = filteredList.map((connName) => {
+ const connStatus = connStatusMap.get(connName);
+ const connColorNum = computeConnColorNum(connStatus);
+ const item: SuggestionConnectionItem = {
+ status: "connected",
+ icon: "arrow-right-arrow-left",
+ iconColor:
+ connStatus?.status == "connected"
+ ? `var(--conn-icon-color-${connColorNum})`
+ : "var(--grey-text-color)",
+ value: connName,
+ label: connName,
+ };
+ return item;
+ });
+ const remoteSuggestions: SuggestionConnectionScope = {
+ headerText: "Remote",
+ items: remoteItems,
+ };
+
+ let suggestions: Array = [];
+ if (prioritySuggestions.items.length > 0) {
+ suggestions.push(prioritySuggestions);
+ }
+ if (localSuggestion.items.length > 0) {
+ suggestions.push(localSuggestion);
+ }
+ if (remoteSuggestions.items.length > 0) {
+ suggestions.push(remoteSuggestions);
+ }
+
+ let selectionList: Array = [
+ ...prioritySuggestions.items,
+ ...localSuggestion.items,
+ ...remoteSuggestions.items,
+ ];
+
+ // quick way to change icon color when highlighted
+ selectionList = selectionList.map((item, index) => {
+ if (index == rowIndex && item.iconColor == "var(--grey-text-color)") {
+ item.iconColor = "var(--main-text-color)";
+ }
+ return item;
+ });
+
+ const handleTypeAheadKeyDown = React.useCallback(
+ (waveEvent: WaveKeyboardEvent): boolean => {
+ if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
+ const rowItem = selectionList[rowIndex];
+ if ("onSelect" in rowItem && rowItem.onSelect) {
+ rowItem.onSelect(rowItem.value);
+ } else {
+ changeConnection(rowItem.value);
+ globalStore.set(changeConnModalAtom, false);
+ }
+ }
+ if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
+ globalStore.set(changeConnModalAtom, false);
+ setConnSelected("");
+ refocusNode(blockId);
+ return true;
+ }
+ if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) {
+ setRowIndex((idx) => Math.max(idx - 1, 0));
+ return true;
+ }
+ if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) {
+ setRowIndex((idx) => Math.min(idx + 1, filteredList.length));
+ return true;
+ }
+ },
+ [changeConnModalAtom, viewModel, blockId, connSelected, selectionList]
+ );
+ React.useEffect(() => {
+ setRowIndex((idx) => Math.min(idx, filteredList.length));
+ }, [selectionList, setRowIndex]);
+ // this check was also moved to BlockFrame to prevent all the above code from running unnecessarily
+ if (!changeConnModalOpen) {
+ return null;
+ }
+ return (
+ {
+ changeConnection(selected);
+ globalStore.set(changeConnModalAtom, false);
+ }}
+ selectIndex={rowIndex}
+ autoFocus={isNodeFocused}
+ onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)}
+ onChange={(current: string) => setConnSelected(current)}
+ value={connSelected}
+ label="Connect to (username@host)..."
+ onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)}
+ />
+ );
+ }
+);
+
+const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component;
+
+const BlockFrame = React.memo((props: BlockFrameProps) => {
+ const blockId = props.nodeModel.blockId;
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId));
+ const tabData = jotai.useAtomValue(atoms.tabAtom);
+
+ if (!blockId || !blockData) {
+ return null;
+ }
+ const FrameElem = BlockFrame_Default;
+ const numBlocks = tabData?.blockids?.length ?? 0;
+ return ;
+});
+
+export { BlockFrame, NumActiveConnColors };
diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts
new file mode 100644
index 000000000..2340f9d17
--- /dev/null
+++ b/frontend/app/block/blocktypes.ts
@@ -0,0 +1,24 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { NodeModel } from "@/layout/index";
+export interface BlockProps {
+ preview: boolean;
+ nodeModel: NodeModel;
+}
+
+export interface BlockComponentModel2 {
+ onClick?: () => void;
+ onFocusCapture?: React.FocusEventHandler;
+ blockRef?: React.RefObject;
+}
+
+export interface BlockFrameProps {
+ blockModel?: BlockComponentModel2;
+ nodeModel?: NodeModel;
+ viewModel?: ViewModel;
+ preview: boolean;
+ numBlocksInTab?: number;
+ children?: React.ReactNode;
+ connBtnRef?: React.RefObject;
+}
diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx
new file mode 100644
index 000000000..911514ed6
--- /dev/null
+++ b/frontend/app/block/blockutil.tsx
@@ -0,0 +1,308 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { NumActiveConnColors } from "@/app/block/blockframe";
+import { getConnStatusAtom, WOS } from "@/app/store/global";
+import * as services from "@/app/store/services";
+import { makeORef } from "@/app/store/wos";
+import { waveEventSubscribe } from "@/store/wps";
+import * as util from "@/util/util";
+import clsx from "clsx";
+import * as jotai from "jotai";
+import * as React from "react";
+import DotsSvg from "../asset/dots-anim-4.svg";
+
+export const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/;
+
+export function blockViewToIcon(view: string): string {
+ if (view == "term") {
+ return "terminal";
+ }
+ if (view == "preview") {
+ return "file";
+ }
+ if (view == "web") {
+ return "globe";
+ }
+ if (view == "waveai") {
+ return "sparkles";
+ }
+ if (view == "help") {
+ return "circle-question";
+ }
+ return "square";
+}
+
+export function blockViewToName(view: string): string {
+ if (util.isBlank(view)) {
+ return "(No View)";
+ }
+ if (view == "term") {
+ return "Terminal";
+ }
+ if (view == "preview") {
+ return "Preview";
+ }
+ if (view == "web") {
+ return "Web";
+ }
+ if (view == "waveai") {
+ return "WaveAI";
+ }
+ if (view == "help") {
+ return "Help";
+ }
+ return view;
+}
+
+export function processTitleString(titleString: string): React.ReactNode[] {
+ if (titleString == null) {
+ return null;
+ }
+ const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g;
+ let lastIdx = 0;
+ let match;
+ let partsStack = [[]];
+ while ((match = tagRegex.exec(titleString)) != null) {
+ const lastPart = partsStack[partsStack.length - 1];
+ const before = titleString.substring(lastIdx, match.index);
+ lastPart.push(before);
+ lastIdx = match.index + match[0].length;
+ const [_, isClosing, tagName, tagParam] = match;
+ if (tagName == "icon" && !isClosing) {
+ if (tagParam == null) {
+ continue;
+ }
+ const iconClass = util.makeIconClass(tagParam, false);
+ if (iconClass == null) {
+ continue;
+ }
+ lastPart.push( );
+ continue;
+ }
+ if (tagName == "c" || tagName == "color") {
+ if (isClosing) {
+ if (partsStack.length <= 1) {
+ continue;
+ }
+ partsStack.pop();
+ continue;
+ }
+ if (tagParam == null) {
+ continue;
+ }
+ if (!tagParam.match(colorRegex)) {
+ continue;
+ }
+ let children = [];
+ const rtag = React.createElement("span", { key: match.index, style: { color: tagParam } }, children);
+ lastPart.push(rtag);
+ partsStack.push(children);
+ continue;
+ }
+ if (tagName == "i" || tagName == "b") {
+ if (isClosing) {
+ if (partsStack.length <= 1) {
+ continue;
+ }
+ partsStack.pop();
+ continue;
+ }
+ let children = [];
+ const rtag = React.createElement(tagName, { key: match.index }, children);
+ lastPart.push(rtag);
+ partsStack.push(children);
+ continue;
+ }
+ }
+ partsStack[partsStack.length - 1].push(titleString.substring(lastIdx));
+ return partsStack[0];
+}
+
+export function getBlockHeaderIcon(blockIcon: string, blockData: Block): React.ReactNode {
+ let blockIconElem: React.ReactNode = null;
+ if (util.isBlank(blockIcon)) {
+ blockIcon = "square";
+ }
+ let iconColor = blockData?.meta?.["icon:color"];
+ if (iconColor && !iconColor.match(colorRegex)) {
+ iconColor = null;
+ }
+ let iconStyle = null;
+ if (!util.isBlank(iconColor)) {
+ iconStyle = { color: iconColor };
+ }
+ const iconClass = util.makeIconClass(blockIcon, true);
+ if (iconClass != null) {
+ blockIconElem = ;
+ }
+ return blockIconElem;
+}
+
+interface ConnectionButtonProps {
+ connection: string;
+ changeConnModalAtom: jotai.PrimitiveAtom;
+}
+
+export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string }) => {
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId));
+ const hasController = !util.isBlank(blockData?.meta?.controller);
+ const [controllerStatus, setControllerStatus] = React.useState(null);
+ const [gotInitialStatus, setGotInitialStatus] = React.useState(false);
+ const connection = blockData?.meta?.connection ?? "local";
+ const connStatusAtom = getConnStatusAtom(connection);
+ const connStatus = jotai.useAtomValue(connStatusAtom);
+ React.useEffect(() => {
+ if (!hasController) {
+ return;
+ }
+ const initialRTStatus = services.BlockService.GetControllerStatus(blockId);
+ initialRTStatus.then((rts) => {
+ setGotInitialStatus(true);
+ setControllerStatus(rts);
+ });
+ const unsubFn = waveEventSubscribe({
+ eventType: "controllerstatus",
+ scope: makeORef("block", blockId),
+ handler: (event) => {
+ const cstatus: BlockControllerRuntimeStatus = event.data;
+ setControllerStatus(cstatus);
+ },
+ });
+ return () => {
+ unsubFn();
+ };
+ }, [hasController]);
+ if (!hasController || !gotInitialStatus) {
+ return null;
+ }
+ if (controllerStatus?.shellprocstatus == "running") {
+ return null;
+ }
+ if (connStatus?.status != "connected") {
+ return null;
+ }
+ const controllerStatusElem = (
+
+
+
+ );
+ return controllerStatusElem;
+});
+
+export function computeConnColorNum(connStatus: ConnStatus): number {
+ // activeconnnum is 1-indexed, so we need to adjust for when mod is 0
+ const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors;
+ if (connColorNum == 0) {
+ return NumActiveConnColors;
+ }
+ return connColorNum;
+}
+
+export const ConnectionButton = React.memo(
+ React.forwardRef(
+ ({ connection, changeConnModalAtom }: ConnectionButtonProps, ref) => {
+ const [connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom);
+ const isLocal = util.isBlank(connection);
+ const connStatusAtom = getConnStatusAtom(connection);
+ const connStatus = jotai.useAtomValue(connStatusAtom);
+ let showDisconnectedSlash = false;
+ let connIconElem: React.ReactNode = null;
+ const connColorNum = computeConnColorNum(connStatus);
+ let color = `var(--conn-icon-color-${connColorNum})`;
+ const clickHandler = function () {
+ setConnModalOpen(true);
+ };
+ let titleText = null;
+ let shouldSpin = false;
+ if (isLocal) {
+ color = "var(--grey-text-color)";
+ titleText = "Connected to Local Machine";
+ connIconElem = (
+
+ );
+ } else {
+ titleText = "Connected to " + connection;
+ let iconName = "arrow-right-arrow-left";
+ let iconSvg = null;
+ if (connStatus?.status == "connecting") {
+ color = "var(--warning-color)";
+ titleText = "Connecting to " + connection;
+ shouldSpin = false;
+ iconSvg = (
+
+
+
+ );
+ } else if (connStatus?.status == "error") {
+ color = "var(--error-color)";
+ titleText = "Error connecting to " + connection;
+ if (connStatus?.error != null) {
+ titleText += " (" + connStatus.error + ")";
+ }
+ showDisconnectedSlash = true;
+ } else if (!connStatus?.connected) {
+ color = "var(--grey-text-color)";
+ titleText = "Disconnected from " + connection;
+ showDisconnectedSlash = true;
+ }
+ if (iconSvg != null) {
+ connIconElem = iconSvg;
+ } else {
+ connIconElem = (
+
+ );
+ }
+ }
+
+ return (
+
+
+ {connIconElem}
+
+
+ {isLocal ? null :
{connection}
}
+
+ );
+ }
+ )
+);
+
+export const Input = React.memo(
+ ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => {
+ const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl;
+ return (
+
+ onChange(e)}
+ onKeyDown={(e) => onKeyDown(e)}
+ onFocus={(e) => onFocus(e)}
+ onBlur={(e) => onBlur(e)}
+ onDragStart={(e) => e.preventDefault()}
+ />
+
+ );
+ }
+);
diff --git a/frontend/app/element/button.less b/frontend/app/element/button.less
new file mode 100644
index 000000000..8d74b84b9
--- /dev/null
+++ b/frontend/app/element/button.less
@@ -0,0 +1,304 @@
+/* Copyright 2024, Command Line Inc. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+.button {
+ // override default button appearance
+ border: 1px solid transparent;
+ outline: 1px solid transparent;
+ border: none;
+
+ cursor: pointer;
+ display: flex;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 20px;
+ padding-right: 20px;
+ align-items: center;
+ gap: 4px;
+ border-radius: 6px;
+ height: auto;
+ line-height: 16px;
+ white-space: nowrap;
+ user-select: none;
+ font-size: 14px;
+ font-weight: normal;
+ transition: all 0.3s ease;
+
+ &.solid {
+ &.green {
+ color: #000000;
+ background-color: var(--accent-color);
+ border: 1px solid #29f200;
+ &:hover {
+ color: #000000;
+ background-color: #29f200;
+ }
+ }
+
+ &.grey {
+ background-color: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: var(--main-text-color);
+ &:hover {
+ color: var(--main-text-color);
+ background-color: rgba(255, 255, 255, 0.09);
+ }
+ }
+
+ &.red {
+ background-color: #cc0000;
+ border: 1px solid #fc3131;
+ color: var(--main-text-color);
+ &:hover {
+ background-color: #f93939;
+ }
+ }
+
+ &.yellow {
+ color: #000000;
+ background-color: #c4a000;
+ border: 1px solid #fce94f;
+ &:hover {
+ color: #000000;
+ background-color: #fce94f;
+ }
+ }
+ }
+
+ &.outlined {
+ background-color: transparent;
+ &.green {
+ color: var(--accent-color);
+ border: 1px solid var(--accent-color);
+ &:hover {
+ color: #29f200;
+ border: 1px solid #29f200;
+ }
+ }
+
+ &.grey {
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ color: rgba(255, 255, 255, 0.6);
+ &:hover {
+ color: var(--main-text-color);
+ border: 1px solid var(--main-text-color);
+ }
+ }
+
+ &.red {
+ border: 1px solid #cc0000;
+ color: #cc0000;
+ &:hover {
+ color: #ff3c3c;
+ border: 1px solid #ff3c3c;
+ }
+ }
+
+ &.yellow {
+ color: #c4a000;
+ border: 1px solid #c4a000;
+ &:hover {
+ color: #fce94f;
+ border: 1px solid #fce94f;
+ }
+ }
+ }
+
+ &.ghost {
+ background-color: transparent;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 8px;
+ padding-right: 8px;
+
+ &.green {
+ border: none;
+ color: var(--accent-color);
+ &:hover {
+ color: #29f200;
+ }
+ }
+
+ &.grey {
+ border: none;
+ color: rgba(255, 255, 255, 0.6);
+ &:hover {
+ color: var(--main-text-color);
+ }
+ }
+
+ &.red {
+ border: none;
+ color: #cc0000;
+ &:hover {
+ color: #fc3131;
+ }
+ }
+
+ &.yellow {
+ border: none;
+ color: #c4a000;
+ &:hover {
+ color: #fce94f;
+ }
+ }
+ }
+
+ &.disabled {
+ cursor: default;
+ opacity: 0.5;
+ }
+
+ &:focus,
+ &.focus {
+ outline: 1px solid var(--success-color);
+ outline-offset: 2px;
+ }
+
+ // customs styles here
+ &.border-radius-2 {
+ border-radius: 4px;
+ }
+
+ &.border-radius-3 {
+ border-radius: 4px;
+ }
+
+ &.border-radius-4 {
+ border-radius: 4px;
+ }
+
+ &.border-radius-5 {
+ border-radius: 4px;
+ }
+
+ &.border-radius-6 {
+ border-radius: 4px;
+ }
+
+ &.border-radius-10 {
+ border-radius: 10px;
+ }
+
+ &.vertical-padding-0 {
+ padding-top: 0px;
+ padding-bottom: 0px;
+ }
+
+ &.vertical-padding-1 {
+ padding-top: 1px;
+ padding-bottom: 1px;
+ }
+
+ &.vertical-padding-2 {
+ padding-top: 2px;
+ padding-bottom: 2px;
+ }
+
+ &.vertical-padding-3 {
+ padding-top: 3px;
+ padding-bottom: 3px;
+ }
+
+ &.vertical-padding-4 {
+ padding-top: 4px;
+ padding-bottom: 4px;
+ }
+
+ &.vertical-padding-5 {
+ padding-top: 5px;
+ padding-bottom: 5px;
+ }
+
+ &.vertical-padding-6 {
+ padding-top: 6px;
+ padding-bottom: 6px;
+ }
+
+ &.vertical-padding-7 {
+ padding-top: 7px;
+ padding-bottom: 7px;
+ }
+
+ &.vertical-padding-8 {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+
+ &.vertical-padding-9 {
+ padding-top: 9px;
+ padding-bottom: 9px;
+ }
+
+ &.vertical-padding-10 {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ &.horizontal-padding-0 {
+ padding-left: 0px;
+ padding-right: 0px;
+ }
+
+ &.horizontal-padding-1 {
+ padding-left: 1px;
+ padding-right: 1px;
+ }
+
+ &.horizontal-padding-2 {
+ padding-left: 2px;
+ padding-right: 2px;
+ }
+
+ &.horizontal-padding-3 {
+ padding-left: 3px;
+ padding-right: 3px;
+ }
+
+ &.horizontal-padding-4 {
+ padding-left: 4px;
+ padding-right: 4px;
+ }
+
+ &.horizontal-padding-5 {
+ padding-left: 5px;
+ padding-right: 5px;
+ }
+
+ &.horizontal-padding-6 {
+ padding-left: 6px;
+ padding-right: 6px;
+ }
+
+ &.horizontal-padding-7 {
+ padding-left: 7px;
+ padding-right: 7px;
+ }
+
+ &.horizontal-padding-8 {
+ padding-left: 8px;
+ padding-right: 8px;
+ }
+
+ &.horizontal-padding-9 {
+ padding-left: 9px;
+ padding-right: 9px;
+ }
+
+ &.horizontal-padding-10 {
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+
+ &.font-size-11 {
+ font-size: 11px;
+ }
+
+ &.font-weight-500 {
+ font-weight: 500;
+ }
+
+ &.font-weight-600 {
+ font-weight: 600;
+ }
+}
diff --git a/frontend/app/element/button.tsx b/frontend/app/element/button.tsx
new file mode 100644
index 000000000..75403e34b
--- /dev/null
+++ b/frontend/app/element/button.tsx
@@ -0,0 +1,51 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import clsx from "clsx";
+import { Children, forwardRef, memo, ReactNode, useImperativeHandle, useRef } from "react";
+
+import "./button.less";
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ className?: string;
+ children?: ReactNode;
+ target?: string;
+ source?: string;
+}
+
+const Button = memo(
+ forwardRef(
+ ({ children, disabled, source, className = "", ...props }: ButtonProps, ref) => {
+ const btnRef = useRef(null);
+ useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement);
+
+ const childrenArray = Children.toArray(children);
+
+ // Check if the className contains any of the categories: solid, outlined, or ghost
+ const containsButtonCategory = /(solid|outline|ghost)/.test(className);
+ // If no category is present, default to 'solid'
+ const categoryClassName = containsButtonCategory ? className : `solid ${className}`;
+
+ // Check if the className contains any of the color options: green, grey, red, or yellow
+ const containsColor = /(green|grey|red|yellow)/.test(categoryClassName);
+ // If no color is present, default to 'green'
+ const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`;
+
+ return (
+
+ {childrenArray}
+
+ );
+ }
+ )
+);
+
+export { Button };
diff --git a/frontend/app/element/copybutton.less b/frontend/app/element/copybutton.less
new file mode 100644
index 000000000..1689a0f97
--- /dev/null
+++ b/frontend/app/element/copybutton.less
@@ -0,0 +1,11 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.copy-button {
+ &.copied {
+ opacity: 1;
+ i {
+ color: var(--success-color);
+ }
+ }
+}
diff --git a/frontend/app/element/copybutton.tsx b/frontend/app/element/copybutton.tsx
new file mode 100644
index 000000000..a5e26bc5e
--- /dev/null
+++ b/frontend/app/element/copybutton.tsx
@@ -0,0 +1,59 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { clsx } from "clsx";
+import { useEffect, useRef, useState } from "react";
+import "./copybutton.less";
+import { IconButton } from "./iconbutton";
+
+type CopyButtonProps = {
+ title: string;
+ className?: string;
+ onClick: (e: React.MouseEvent) => void;
+};
+
+const CopyButton = ({ title, className, onClick }: CopyButtonProps) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const timeoutRef = useRef(null);
+
+ const handleOnClick = (e: React.MouseEvent) => {
+ if (isCopied) {
+ return;
+ }
+ setIsCopied(true);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = setTimeout(() => {
+ setIsCopied(false);
+ timeoutRef.current = null;
+ }, 2000);
+
+ if (onClick) {
+ onClick(e);
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ return (
+
+ );
+};
+
+export { CopyButton };
diff --git a/frontend/app/element/errorboundary.tsx b/frontend/app/element/errorboundary.tsx
new file mode 100644
index 000000000..d69c00a7b
--- /dev/null
+++ b/frontend/app/element/errorboundary.tsx
@@ -0,0 +1,32 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { ReactNode } from "react";
+
+export class ErrorBoundary extends React.Component<
+ { children: ReactNode; fallback?: React.ReactElement & { error?: Error } },
+ { error: Error }
+> {
+ constructor(props) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ this.setState({ error: error });
+ }
+
+ render() {
+ const { fallback } = this.props;
+ const { error } = this.state;
+ if (error) {
+ if (fallback != null) {
+ return React.cloneElement(fallback as any, { error });
+ }
+ const errorMsg = `Error: ${error?.message}\n\n${error?.stack}`;
+ return {errorMsg} ;
+ } else {
+ return <>{this.props.children}>;
+ }
+ }
+}
diff --git a/frontend/app/element/iconbutton.less b/frontend/app/element/iconbutton.less
new file mode 100644
index 000000000..d781a23a9
--- /dev/null
+++ b/frontend/app/element/iconbutton.less
@@ -0,0 +1,25 @@
+.iconbutton {
+ cursor: pointer;
+ opacity: 0.7;
+ align-items: center;
+
+ &.bulb {
+ color: var(--bulb-color);
+ opacity: 1;
+
+ &:hover i::before {
+ content: "\f672";
+ position: relative;
+ left: -1px;
+ }
+ }
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &.disabled {
+ cursor: default;
+ opacity: 0.45 !important;
+ }
+}
diff --git a/frontend/app/element/iconbutton.tsx b/frontend/app/element/iconbutton.tsx
new file mode 100644
index 000000000..772eb55ef
--- /dev/null
+++ b/frontend/app/element/iconbutton.tsx
@@ -0,0 +1,20 @@
+import { useLongClick } from "@/app/hook/useLongClick";
+import { makeIconClass } from "@/util/util";
+import clsx from "clsx";
+import { memo, useRef } from "react";
+import "./iconbutton.less";
+
+export const IconButton = memo(({ decl, className }: { decl: IconButtonDecl; className?: string }) => {
+ const buttonRef = useRef(null);
+ useLongClick(buttonRef, decl.click, decl.longClick, decl.disabled);
+ return (
+
+ {typeof decl.icon === "string" ? : decl.icon}
+
+ );
+});
diff --git a/frontend/app/element/input.less b/frontend/app/element/input.less
new file mode 100644
index 000000000..7cd13ab29
--- /dev/null
+++ b/frontend/app/element/input.less
@@ -0,0 +1,86 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.input {
+ display: flex;
+ align-items: center;
+ border-radius: 6px;
+ position: relative;
+ min-height: 24px;
+ min-width: 50px;
+ width: 100%;
+ gap: 6px;
+ border: 2px solid var(--form-element-border-color);
+ background: var(--form-element-bg-color);
+
+ &:hover {
+ cursor: text;
+ }
+
+ &.focused {
+ border-color: var(--form-element-primary-color);
+ }
+
+ &.disabled {
+ opacity: 0.75;
+ }
+
+ &.error {
+ border-color: var(--form-element-error-color);
+ }
+
+ &-inner {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ flex-grow: 1;
+ --inner-padding: 5px 0 5px 16px;
+
+ &-label {
+ padding: var(--inner-padding);
+ margin-bottom: -10px;
+ font-size: 12.5px;
+ transition: all 0.3s;
+ color: var(--form-element-label-color);
+ line-height: 10px;
+ user-select: none;
+
+ &.float {
+ font-size: 10px;
+ top: 5px;
+ }
+
+ &.offset-left {
+ left: 0;
+ }
+ }
+
+ &-input {
+ width: 100%;
+ height: 100%;
+ border: none;
+ padding: var(--inner-padding);
+ font-size: 12px;
+ outline: none;
+ background-color: transparent;
+ color: var(--form-element-text-color);
+ line-height: 20px;
+
+ &.offset-left {
+ padding: 5px 16px 5px 0;
+ }
+
+ &:placeholder-shown {
+ user-select: none;
+ }
+ }
+ }
+
+ &.no-label {
+ height: 34px;
+
+ input {
+ height: 32px;
+ }
+ }
+}
diff --git a/frontend/app/element/input.tsx b/frontend/app/element/input.tsx
new file mode 100644
index 000000000..9b550a53d
--- /dev/null
+++ b/frontend/app/element/input.tsx
@@ -0,0 +1,176 @@
+import { clsx } from "clsx";
+import React, { forwardRef, useEffect, useRef, useState } from "react";
+
+import "./input.less";
+
+interface InputDecorationProps {
+ startDecoration?: React.ReactNode;
+ endDecoration?: React.ReactNode;
+}
+
+interface InputProps {
+ label?: string;
+ value?: string;
+ className?: string;
+ onChange?: (value: string) => void;
+ onKeyDown?: (event: React.KeyboardEvent) => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ placeholder?: string;
+ defaultValue?: string;
+ decoration?: InputDecorationProps;
+ required?: boolean;
+ maxLength?: number;
+ autoFocus?: boolean;
+ disabled?: boolean;
+ isNumber?: boolean;
+ inputRef?: React.MutableRefObject;
+}
+
+const Input = forwardRef(
+ (
+ {
+ label,
+ value,
+ className,
+ onChange,
+ onKeyDown,
+ onFocus,
+ onBlur,
+ placeholder,
+ defaultValue = "",
+ decoration,
+ required,
+ maxLength,
+ autoFocus,
+ disabled,
+ isNumber,
+ inputRef,
+ }: InputProps,
+ ref
+ ) => {
+ const [focused, setFocused] = useState(false);
+ const [internalValue, setInternalValue] = useState(defaultValue);
+ const [error, setError] = useState(false);
+ const [hasContent, setHasContent] = useState(Boolean(value || defaultValue));
+ const internalInputRef = useRef(null);
+
+ useEffect(() => {
+ if (value !== undefined) {
+ setFocused(Boolean(value));
+ }
+ }, [value]);
+
+ const handleComponentFocus = () => {
+ if (internalInputRef.current && !internalInputRef.current.contains(document.activeElement)) {
+ internalInputRef.current.focus();
+ }
+ };
+
+ const handleComponentBlur = () => {
+ if (internalInputRef.current?.contains(document.activeElement)) {
+ internalInputRef.current.blur();
+ }
+ };
+
+ const handleSetInputRef = (elem: HTMLInputElement) => {
+ if (inputRef) {
+ inputRef.current = elem;
+ }
+ internalInputRef.current = elem;
+ };
+
+ const handleFocus = () => {
+ setFocused(true);
+ onFocus && onFocus();
+ };
+
+ const handleBlur = () => {
+ if (internalInputRef.current) {
+ const inputValue = internalInputRef.current.value;
+ if (required && !inputValue) {
+ setError(true);
+ setFocused(false);
+ } else {
+ setError(false);
+ setFocused(false);
+ }
+ }
+ onBlur && onBlur();
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const inputValue = e.target.value;
+
+ if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
+ return;
+ }
+
+ if (required && !inputValue) {
+ setError(true);
+ setHasContent(false);
+ } else {
+ setError(false);
+ setHasContent(Boolean(inputValue));
+ }
+
+ if (value === undefined) {
+ setInternalValue(inputValue);
+ }
+
+ onChange && onChange(inputValue);
+ };
+
+ const inputValue = value ?? internalValue;
+
+ return (
+
+ {decoration?.startDecoration && <>{decoration.startDecoration}>}
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ {decoration?.endDecoration && <>{decoration.endDecoration}>}
+
+ );
+ }
+);
+
+export { Input };
+export type { InputDecorationProps, InputProps };
diff --git a/frontend/app/element/inputdecoration.less b/frontend/app/element/inputdecoration.less
new file mode 100644
index 000000000..bf4366f92
--- /dev/null
+++ b/frontend/app/element/inputdecoration.less
@@ -0,0 +1,21 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.input-decoration {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ i {
+ font-size: 13px;
+ color: var(--form-element-icon-color);
+ }
+}
+
+.input-decoration.start-position {
+ margin: 0 4px 0 16px;
+}
+
+.input-decoration.end-position {
+ margin: 0 16px 0 8px;
+}
diff --git a/frontend/app/element/inputdecoration.tsx b/frontend/app/element/inputdecoration.tsx
new file mode 100644
index 000000000..1a92773d0
--- /dev/null
+++ b/frontend/app/element/inputdecoration.tsx
@@ -0,0 +1,28 @@
+// Copyright 2023, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { clsx } from "clsx";
+import * as React from "react";
+
+import "./inputdecoration.less";
+
+interface InputDecorationProps {
+ position?: "start" | "end";
+ children: React.ReactNode;
+}
+
+const InputDecoration = (props: InputDecorationProps) => {
+ const { children, position = "end" } = props;
+ return (
+
+ {children}
+
+ );
+};
+
+export { InputDecoration };
diff --git a/frontend/app/element/linkbutton.less b/frontend/app/element/linkbutton.less
new file mode 100644
index 000000000..bb6827008
--- /dev/null
+++ b/frontend/app/element/linkbutton.less
@@ -0,0 +1,21 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+&.link-button {
+ text-decoration: none;
+
+ .button-inner {
+ display: flex;
+ border-radius: 6px;
+ padding: 8px 12px;
+ background-color: var(--button-grey-bg);
+ border: 1px solid var(--button-grey-border-color);
+ color: var(--button-grey-text-color);
+ i {
+ margin-right: 4px;
+ }
+ &:hover {
+ color: var(--button-grey-text-color);
+ }
+ }
+}
diff --git a/frontend/app/element/linkbutton.tsx b/frontend/app/element/linkbutton.tsx
new file mode 100644
index 000000000..4918024fc
--- /dev/null
+++ b/frontend/app/element/linkbutton.tsx
@@ -0,0 +1,37 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { clsx } from "clsx";
+import * as React from "react";
+
+import "./linkbutton.less";
+
+interface LinkButtonProps {
+ href: string;
+ rel?: string;
+ target?: string;
+ children: React.ReactNode;
+ disabled?: boolean;
+ leftIcon?: React.ReactNode;
+ rightIcon?: React.ReactNode;
+ style?: React.CSSProperties;
+ autoFocus?: boolean;
+ className?: string;
+ termInline?: boolean;
+ title?: string;
+ onClick?: (e: React.MouseEvent) => void;
+}
+
+const LinkButton = ({ leftIcon, rightIcon, children, className, ...rest }: LinkButtonProps) => {
+ return (
+
+
+ {leftIcon && {leftIcon} }
+ {children}
+ {rightIcon && {rightIcon} }
+
+
+ );
+};
+
+export { LinkButton };
diff --git a/frontend/app/element/magnify.less b/frontend/app/element/magnify.less
new file mode 100644
index 000000000..111674fdd
--- /dev/null
+++ b/frontend/app/element/magnify.less
@@ -0,0 +1,31 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.magnify-icon {
+ display: inline-block;
+ width: 15px;
+ height: 15px;
+ svg {
+ #arrow1 {
+ transform: rotate(180deg);
+ transform-origin: calc(29.167% + 4px) calc(70.833% + 4px); // account for path offset in the svg itself
+ }
+ #arrow2 {
+ transform: rotate(-180deg);
+ transform-origin: calc(70.833% + 4px) calc(29.167% + 4px);
+ }
+ #arrow1,
+ #arrow2 {
+ transition: transform 300ms ease-in;
+ transition-delay: 100ms;
+ }
+ }
+ &.enabled {
+ svg {
+ #arrow1,
+ #arrow2 {
+ transform: rotate(0deg);
+ }
+ }
+ }
+}
diff --git a/frontend/app/element/magnify.stories.tsx b/frontend/app/element/magnify.stories.tsx
new file mode 100644
index 000000000..8244feae4
--- /dev/null
+++ b/frontend/app/element/magnify.stories.tsx
@@ -0,0 +1,28 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { Meta, StoryObj } from "@storybook/react";
+import { MagnifyIcon } from "./magnify";
+
+const meta = {
+ title: "Icons/Magnify",
+ component: MagnifyIcon,
+ args: {
+ enabled: true,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Enabled: Story = {
+ args: {
+ enabled: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ enabled: false,
+ },
+};
diff --git a/frontend/app/element/magnify.tsx b/frontend/app/element/magnify.tsx
new file mode 100644
index 000000000..7ccbb3e0f
--- /dev/null
+++ b/frontend/app/element/magnify.tsx
@@ -0,0 +1,18 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import clsx from "clsx";
+import MagnifySVG from "../asset/magnify.svg";
+import "./magnify.less";
+
+interface MagnifyIconProps {
+ enabled: boolean;
+}
+
+export function MagnifyIcon({ enabled }: MagnifyIconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/element/markdown.less b/frontend/app/element/markdown.less
new file mode 100644
index 000000000..c41b9bedc
--- /dev/null
+++ b/frontend/app/element/markdown.less
@@ -0,0 +1,160 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.markdown {
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+ .content {
+ height: 100%;
+ width: 100%;
+ overflow: scroll;
+ line-height: 1.5;
+ color: var(--app-text-color);
+ font-family: var(--markdown-font);
+ font-size: 14px;
+ overflow-wrap: break-word;
+
+ .heading {
+ &:first-of-type {
+ margin-top: 0 !important;
+ }
+ color: var(--app-text-color);
+ margin-top: 16px;
+ margin-bottom: 8px;
+ }
+
+ strong {
+ color: var(--app-text-color);
+ }
+
+ a {
+ color: #32afff;
+ }
+
+ ul {
+ list-style-type: disc;
+ list-style-position: outside;
+ margin-left: 16px;
+ }
+
+ ol {
+ list-style-position: outside;
+ margin-left: 19px;
+ }
+
+ blockquote {
+ margin: 4px 10px 4px 10px;
+ border-radius: 3px;
+ background-color: var(--panel-bg-color);
+ padding: 2px 4px 2px 6px;
+ }
+
+ pre.codeblock {
+ background-color: var(--panel-bg-color);
+ margin: 4px 10px;
+ padding: 0.4em 0.7em;
+ border-radius: 4px;
+ position: relative;
+
+ code {
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow: auto;
+ overflow: hidden;
+ background-color: transparent;
+ }
+
+ .codeblock-actions {
+ visibility: hidden;
+ display: flex;
+ position: absolute;
+ top: 0;
+ right: 0;
+ border-radius: 4px;
+ backdrop-filter: blur(8px);
+ margin: 2px 2px;
+ padding: 4px 4px;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ }
+
+ &:hover .codeblock-actions {
+ visibility: visible;
+ }
+ }
+
+ code {
+ color: var(--main-text-color);
+ font: var(--fixed-font);
+ border-radius: 4px;
+ }
+
+ pre.selected {
+ outline: 2px solid var(--accent-color);
+ }
+
+ .heading {
+ font-weight: semibold;
+ padding-top: 6px;
+ }
+
+ .heading.is-1 {
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 6px;
+ font-size: 2em;
+ }
+ .heading.is-2 {
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 6px;
+ font-size: 1.5em;
+ }
+ .heading.is-3 {
+ font-size: 1.25em;
+ }
+ .heading.is-4 {
+ font-size: 1em;
+ }
+ .heading.is-5 {
+ font-size: 0.875em;
+ }
+ .heading.is-6 {
+ font-size: 0.85em;
+ }
+ }
+
+ // The TOC view should scroll independently of the contents view.
+ .toc {
+ max-width: 40%;
+ height: 100%;
+ overflow: scroll;
+ border-left: 1px solid var(--border-color);
+ .toc-inner {
+ height: fit-content;
+ position: sticky;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ text-wrap: wrap;
+
+ h4 {
+ padding-left: 5px;
+ }
+
+ .toc-item {
+ cursor: pointer;
+ --indent-factor: 1;
+
+ // The 5px offset in the padding will ensure that when the text in the item wraps, it indents slightly.
+ // The indent factor is set in the React code and denotes the depth of the item in the TOC tree.
+ padding-left: calc((var(--indent-factor) - 1) * 10px + 5px);
+ text-indent: -5px;
+ }
+ }
+ }
+}
diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx
new file mode 100644
index 000000000..1f1d64689
--- /dev/null
+++ b/frontend/app/element/markdown.tsx
@@ -0,0 +1,289 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CopyButton } from "@/app/element/copybutton";
+import { RpcApi } from "@/app/store/wshclientapi";
+import { WindowRpcClient } from "@/app/store/wshrpcutil";
+import { getWebServerEndpoint } from "@/util/endpoints";
+import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util";
+import { clsx } from "clsx";
+import { Atom } from "jotai";
+import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import ReactMarkdown, { Components } from "react-markdown";
+import rehypeHighlight from "rehype-highlight";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
+import rehypeSlug from "rehype-slug";
+import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc";
+import remarkGfm from "remark-gfm";
+import { openLink } from "../store/global";
+import { IconButton } from "./iconbutton";
+import "./markdown.less";
+
+const Link = ({
+ setFocusedHeading,
+ props,
+}: {
+ props: React.AnchorHTMLAttributes;
+ setFocusedHeading: (href: string) => void;
+}) => {
+ const onClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (props.href.startsWith("#")) {
+ setFocusedHeading(props.href);
+ } else {
+ openLink(props.href);
+ }
+ };
+ return (
+
+ {props.children}
+
+ );
+};
+
+const Heading = ({ props, hnum }: { props: React.HTMLAttributes; hnum: number }) => {
+ return (
+
+ {props.children}
+
+ );
+};
+
+const Code = ({ className, children }: { className: string; children: React.ReactNode }) => {
+ return {children}
;
+};
+
+type CodeBlockProps = {
+ children: React.ReactNode;
+ onClickExecute?: (cmd: string) => void;
+};
+
+const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {
+ const getTextContent = (children: any): string => {
+ if (typeof children === "string") {
+ return children;
+ } else if (Array.isArray(children)) {
+ return children.map(getTextContent).join("");
+ } else if (children.props && children.props.children) {
+ return getTextContent(children.props.children);
+ }
+ return "";
+ };
+
+ const handleCopy = async (e: React.MouseEvent) => {
+ let textToCopy = getTextContent(children);
+ textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline
+ await navigator.clipboard.writeText(textToCopy);
+ };
+
+ const handleExecute = (e: React.MouseEvent) => {
+ let textToCopy = getTextContent(children);
+ textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline
+ if (onClickExecute) {
+ onClickExecute(textToCopy);
+ return;
+ }
+ };
+
+ return (
+
+ {children}
+
+
+ {onClickExecute && (
+
+ )}
+
+
+ );
+};
+
+const MarkdownSource = (props: React.HTMLAttributes) => {
+ return null;
+};
+
+const MarkdownImg = ({
+ props,
+ resolveOpts,
+}: {
+ props: React.ImgHTMLAttributes;
+ resolveOpts: MarkdownResolveOpts;
+}) => {
+ const [resolvedSrc, setResolvedSrc] = useState(props.src);
+ const [resolvedStr, setResolvedStr] = useState(null);
+ const [resolving, setResolving] = useState(true);
+
+ useEffect(() => {
+ if (props.src.startsWith("http://") || props.src.startsWith("https://")) {
+ setResolving(false);
+ setResolvedSrc(props.src);
+ setResolvedStr(null);
+ return;
+ }
+ if (props.src.startsWith("data:image/")) {
+ setResolving(false);
+ setResolvedSrc(props.src);
+ setResolvedStr(null);
+ return;
+ }
+ if (resolveOpts == null) {
+ setResolving(false);
+ setResolvedSrc(null);
+ setResolvedStr(`[img:${props.src}]`);
+ return;
+ }
+ const resolveFn = async () => {
+ const route = makeConnRoute(resolveOpts.connName);
+ const fileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [resolveOpts.baseDir, props.src], {
+ route: route,
+ });
+ const usp = new URLSearchParams();
+ usp.set("path", fileInfo.path);
+ if (!isBlank(resolveOpts.connName)) {
+ usp.set("connection", resolveOpts.connName);
+ }
+ const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?" + usp.toString();
+ setResolvedSrc(streamingUrl);
+ setResolvedStr(null);
+ setResolving(false);
+ };
+ resolveFn();
+ }, [props.src]);
+
+ if (resolving) {
+ return null;
+ }
+ if (resolvedStr != null) {
+ return {resolvedStr} ;
+ }
+ if (resolvedSrc != null) {
+ return ;
+ }
+ return [img] ;
+};
+
+type MarkdownProps = {
+ text?: string;
+ textAtom?: Atom | Atom>;
+ showTocAtom?: Atom;
+ style?: React.CSSProperties;
+ className?: string;
+ onClickExecute?: (cmd: string) => void;
+ resolveOpts?: MarkdownResolveOpts;
+};
+
+const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts, onClickExecute }: MarkdownProps) => {
+ const textAtomValue = useAtomValueSafe(textAtom);
+ const tocRef = useRef([]);
+ const showToc = useAtomValueSafe(showTocAtom) ?? false;
+ const contentsOsRef = useRef(null);
+ const [focusedHeading, setFocusedHeading] = useState(null);
+
+ // Ensure uniqueness of ids between MD preview instances.
+ const [idPrefix] = useState(crypto.randomUUID());
+
+ useEffect(() => {
+ if (focusedHeading && contentsOsRef.current && contentsOsRef.current.osInstance()) {
+ const { viewport } = contentsOsRef.current.osInstance().elements();
+ const heading = document.getElementById(idPrefix + focusedHeading.slice(1));
+ if (heading) {
+ const headingBoundingRect = heading.getBoundingClientRect();
+ const viewportBoundingRect = viewport.getBoundingClientRect();
+ const headingTop = headingBoundingRect.top - viewportBoundingRect.top;
+ viewport.scrollBy({ top: headingTop });
+ }
+ }
+ }, [focusedHeading]);
+
+ const markdownComponents: Partial = {
+ a: (props: React.HTMLAttributes) => (
+
+ ),
+ h1: (props: React.HTMLAttributes) => ,
+ h2: (props: React.HTMLAttributes) => ,
+ h3: (props: React.HTMLAttributes) => ,
+ h4: (props: React.HTMLAttributes) => ,
+ h5: (props: React.HTMLAttributes) => ,
+ h6: (props: React.HTMLAttributes) => ,
+ img: (props: React.HTMLAttributes) => ,
+ source: (props: React.HTMLAttributes) => ,
+ code: Code,
+ pre: (props: React.HTMLAttributes) => (
+
+ ),
+ };
+
+ const toc = useMemo(() => {
+ if (showToc && tocRef.current.length > 0) {
+ return tocRef.current.map((item) => {
+ return (
+ setFocusedHeading(item.href)}
+ >
+ {item.value}
+
+ );
+ });
+ }
+ }, [showToc, tocRef]);
+
+ text = textAtomValue ?? text;
+
+ return (
+
+
+
+ rehypeSanitize({
+ ...defaultSchema,
+ attributes: {
+ ...defaultSchema.attributes,
+ span: [
+ ...(defaultSchema.attributes?.span || []),
+ // Allow all class names starting with `hljs-`.
+ ["className", /^hljs-./],
+ // Alternatively, to allow only certain class names:
+ // ['className', 'hljs-number', 'hljs-title', 'hljs-variable']
+ ],
+ },
+ tagNames: [...(defaultSchema.tagNames || []), "span"],
+ }),
+ () => rehypeSlug({ prefix: idPrefix }),
+ ]}
+ components={markdownComponents}
+ >
+ {text}
+
+
+ {toc && (
+
+
+
Table of Contents
+ {toc}
+
+
+ )}
+
+ );
+};
+
+export { Markdown };
diff --git a/frontend/app/element/modal.less b/frontend/app/element/modal.less
new file mode 100644
index 000000000..5339d03fb
--- /dev/null
+++ b/frontend/app/element/modal.less
@@ -0,0 +1,57 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.modal-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100%;
+ z-index: var(--zindex-elem-modal);
+ background-color: rgba(21, 23, 21, 0.7);
+
+ .modal {
+ display: flex;
+ flex-direction: column;
+ border-radius: 10px;
+ padding: 0;
+ width: 80%;
+ margin-top: 25vh;
+ margin-left: auto;
+ margin-right: auto;
+ background: var(--main-bg-color);
+ border: 1px solid var(--border-color);
+
+ .modal-header {
+ display: flex;
+ flex-direction: column;
+ padding: 20px 20px 10px;
+ border-bottom: 1px solid var(--border-color);
+
+ .modal-title {
+ margin: 0 0 5px;
+ color: var(--main-text-color);
+ font-size: var(--title-font-size);
+ }
+
+ p {
+ margin: 0;
+ font-size: 0.8rem;
+ color: var(--secondary-text-color);
+ }
+ }
+
+ .modal-content {
+ padding: 20px;
+ overflow: auto;
+ }
+
+ .modal-footer {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ padding: 15px 20px;
+ gap: 20px;
+ }
+ }
+}
diff --git a/frontend/app/element/modal.tsx b/frontend/app/element/modal.tsx
new file mode 100644
index 000000000..173ba5f5b
--- /dev/null
+++ b/frontend/app/element/modal.tsx
@@ -0,0 +1,83 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Button } from "@/element/button";
+import React from "react";
+
+import "./modal.less";
+
+interface ModalProps {
+ id?: string;
+ children: React.ReactNode;
+ onClickOut: () => void;
+}
+
+function Modal({ children, onClickOut, id = "modal", ...otherProps }: ModalProps) {
+ const handleOutsideClick = (e: React.SyntheticEvent) => {
+ if (typeof onClickOut === "function" && (e.target as Element).className === "modal-container") {
+ onClickOut();
+ }
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+interface ModalContentProps {
+ children: React.ReactNode;
+}
+
+function ModalContent({ children }: ModalContentProps) {
+ return {children}
;
+}
+
+interface ModalHeaderProps {
+ title: React.ReactNode;
+ description?: string;
+}
+
+function ModalHeader({ title, description }: ModalHeaderProps) {
+ return (
+
+ {typeof title === "string" ? {title} : title}
+ {description && {description}
}
+
+ );
+}
+
+interface ModalFooterProps {
+ children: React.ReactNode;
+}
+
+function ModalFooter({ children }: ModalFooterProps) {
+ return ;
+}
+
+interface WaveModalProps {
+ title: string;
+ description?: string;
+ id?: string;
+ onSubmit: () => void;
+ onCancel: () => void;
+ buttonLabel?: string;
+ children: React.ReactNode;
+}
+
+function WaveModal({ title, description, onSubmit, onCancel, buttonLabel = "Ok", children }: WaveModalProps) {
+ return (
+
+
+ {children}
+
+ {buttonLabel}
+
+
+ );
+}
+
+export { WaveModal };
diff --git a/frontend/app/element/quickelems.less b/frontend/app/element/quickelems.less
new file mode 100644
index 000000000..b4f845323
--- /dev/null
+++ b/frontend/app/element/quickelems.less
@@ -0,0 +1,12 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.centered-div {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
diff --git a/frontend/app/element/quickelems.tsx b/frontend/app/element/quickelems.tsx
new file mode 100644
index 000000000..c4bf8b926
--- /dev/null
+++ b/frontend/app/element/quickelems.tsx
@@ -0,0 +1,19 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import React from "react";
+import "./quickelems.less";
+
+function CenteredLoadingDiv() {
+ return loading... ;
+}
+
+function CenteredDiv({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+export { CenteredDiv, CenteredLoadingDiv };
diff --git a/frontend/app/element/quicktips.less b/frontend/app/element/quicktips.less
new file mode 100644
index 000000000..9a88ee51d
--- /dev/null
+++ b/frontend/app/element/quicktips.less
@@ -0,0 +1,83 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.tips-wrapper {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ width: 100%;
+
+ .tips-section {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: 5px;
+
+ .tip-section-header {
+ font-weight: bold;
+ margin-bottom: 5px;
+ margin-top: 10px;
+ font-size: 16px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ .tip {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ code {
+ padding: 0.1em 0.4em;
+ background-color: var(--highlight-bg-color);
+ }
+
+ .keybinding-group {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ margin-left: 5px;
+ margin-right: 5px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+ }
+
+ .keybinding {
+ display: inline-block;
+ padding: 0.1em 0.4em;
+ margin: 0 0.1em;
+ font: var(--fixed-font);
+ font-size: 0.85em;
+ color: var(--keybinding-color);
+ background-color: var(--keybinding-bg-color);
+ border-radius: 4px;
+ border: 1px solid var(--keybinding-border-color);
+ box-shadow: none;
+ }
+
+ .icon-wrap {
+ background-color: var(--highlight-bg-color);
+ padding: 2px;
+ color: var(--secondary-text-color);
+ font-size: 12px;
+ border-radius: 2px;
+ margin-right: 5px;
+
+ svg {
+ position: relative;
+ top: 3px;
+ left: 1px;
+ height: 13px;
+ #arrow1,
+ #arrow2 {
+ fill: var(--main-text-color);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/app/element/quicktips.tsx b/frontend/app/element/quicktips.tsx
new file mode 100644
index 000000000..6036d8ff9
--- /dev/null
+++ b/frontend/app/element/quicktips.tsx
@@ -0,0 +1,152 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { MagnifyIcon } from "@/app/element/magnify";
+import { PLATFORM } from "@/app/store/global";
+import "./quicktips.less";
+
+const KeyBinding = ({ keyDecl }: { keyDecl: string }) => {
+ const parts = keyDecl.split(":");
+ const elems: React.ReactNode[] = [];
+ for (let part of parts) {
+ if (part === "Cmd") {
+ if (PLATFORM === "darwin") {
+ elems.push(
+
+ ⌘ Cmd
+
+ );
+ } else {
+ elems.push(
+
+ Alt
+
+ );
+ }
+ continue;
+ }
+ if (part == "Ctrl") {
+ elems.push(
+
+ ^ Ctrl
+
+ );
+ continue;
+ }
+ if (part == "Shift") {
+ elems.push(
+
+ ⇧ Shift
+
+ );
+ continue;
+ }
+ if (part == "Arrows") {
+ elems.push(
+
+ ←
+
+ );
+ elems.push(
+
+ →
+
+ );
+ elems.push(
+
+ ↑
+
+ );
+ elems.push(
+
+ ↓
+
+ );
+ continue;
+ }
+ if (part == "Digit") {
+ elems.push(
+
+ Number (1-9)
+
+ );
+ continue;
+ }
+ elems.push(
+
+ {part.toUpperCase()}
+
+ );
+ }
+ return {elems}
;
+};
+
+const QuickTips = () => {
+ return (
+
+
+
Header Icons
+
+
+
+
+ Connect to a remote server
+
+
+
+
+
+
+ Magnify a Block
+
+
+
+
+
Important Keybindings
+
+
+
+ New Tab
+
+
+
+ New Terminal Block
+
+
+
+ Navigate Between Blocks
+
+
+
+ Focus Nth Block
+
+
+
+ Switch To Nth Tab
+
+
+
wsh commands
+
+
+
wsh view [filename|url]
+
+ Run this command in the terminal to preview a file, directory, or web URL.
+
+
+
+
+
+ );
+};
+
+export { KeyBinding, QuickTips };
diff --git a/frontend/app/element/toggle.less b/frontend/app/element/toggle.less
new file mode 100644
index 000000000..e66dbc43c
--- /dev/null
+++ b/frontend/app/element/toggle.less
@@ -0,0 +1,62 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.check-toggle-wrapper {
+ user-select: none;
+ display: flex;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+
+ .checkbox-toggle {
+ position: relative;
+ display: inline-block;
+ width: 32px;
+ height: 20px;
+
+ input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ .slider {
+ position: absolute;
+ cursor: pointer;
+ content: "";
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--toggle-bg-color);
+ transition: 0.5s;
+ border-radius: 33px;
+ }
+
+ .slider:before {
+ position: absolute;
+ content: "";
+ height: 16px;
+ width: 16px;
+ left: 3px;
+ bottom: 2px;
+ background-color: var(--toggle-thumb-color);
+ transition: 0.5s;
+ border-radius: 50%;
+ }
+
+ input:checked + .slider {
+ background-color: var(--toggle-checked-bg-color);
+ }
+
+ input:checked + .slider:before {
+ transform: translateX(11px);
+ }
+ }
+
+ label,
+ .toggle-label {
+ cursor: pointer;
+ padding: 0 5px;
+ }
+}
diff --git a/frontend/app/element/toggle.tsx b/frontend/app/element/toggle.tsx
new file mode 100644
index 000000000..939776a56
--- /dev/null
+++ b/frontend/app/element/toggle.tsx
@@ -0,0 +1,46 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useRef } from "react";
+import "./toggle.less";
+
+interface ToggleProps {
+ checked: boolean;
+ onChange: (value: boolean) => void;
+ label?: string;
+ id?: string;
+}
+
+const Toggle = ({ checked, onChange, label, id }: ToggleProps) => {
+ const inputRef = useRef(null);
+
+ const handleChange = (e: any) => {
+ if (onChange != null) {
+ onChange(e.target.checked);
+ }
+ };
+
+ const handleLabelClick = () => {
+ if (inputRef.current) {
+ inputRef.current.click();
+ }
+ };
+
+ const inputId = id || `toggle-${Math.random().toString(36).substr(2, 9)}`;
+
+ return (
+
+
+
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+ );
+};
+
+export { Toggle };
diff --git a/frontend/app/element/typingindicator.less b/frontend/app/element/typingindicator.less
new file mode 100644
index 000000000..bf7474013
--- /dev/null
+++ b/frontend/app/element/typingindicator.less
@@ -0,0 +1,46 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+@dot-width: 11px;
+@dot-color: var(--success-color);
+@speed: 1.5s;
+
+.typing {
+ position: relative;
+ height: @dot-width;
+
+ span {
+ content: "";
+ animation: blink @speed infinite;
+ animation-fill-mode: both;
+ height: @dot-width;
+ width: @dot-width;
+ background: @dot-color;
+ position: absolute;
+ left: 0;
+ top: 0;
+ border-radius: 50%;
+
+ &:nth-child(2) {
+ animation-delay: 0.2s;
+ margin-left: @dot-width * 1.5;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.4s;
+ margin-left: @dot-width * 3;
+ }
+ }
+}
+
+@keyframes blink {
+ 0% {
+ opacity: 0.1;
+ }
+ 20% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0.1;
+ }
+}
diff --git a/frontend/app/element/typingindicator.tsx b/frontend/app/element/typingindicator.tsx
new file mode 100644
index 000000000..1f63d7676
--- /dev/null
+++ b/frontend/app/element/typingindicator.tsx
@@ -0,0 +1,21 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { clsx } from "clsx";
+
+import "./typingindicator.less";
+
+type TypingIndicatorProps = {
+ className?: string;
+};
+const TypingIndicator = ({ className }: TypingIndicatorProps) => {
+ return (
+
+
+
+
+
+ );
+};
+
+export { TypingIndicator };
diff --git a/frontend/app/element/windowdrag.less b/frontend/app/element/windowdrag.less
new file mode 100644
index 000000000..795e7d645
--- /dev/null
+++ b/frontend/app/element/windowdrag.less
@@ -0,0 +1,7 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.window-drag {
+ -webkit-app-region: drag;
+ z-index: var(--zindex-window-drag);
+}
diff --git a/frontend/app/element/windowdrag.tsx b/frontend/app/element/windowdrag.tsx
new file mode 100644
index 000000000..3c356d06a
--- /dev/null
+++ b/frontend/app/element/windowdrag.tsx
@@ -0,0 +1,22 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { clsx } from "clsx";
+import React, { forwardRef } from "react";
+
+import "./windowdrag.less";
+
+interface WindowDragProps {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+const WindowDrag = forwardRef(({ children, className }, ref) => {
+ return (
+
+ {children}
+
+ );
+});
+
+export { WindowDrag };
diff --git a/frontend/app/hook/useDimensions.tsx b/frontend/app/hook/useDimensions.tsx
new file mode 100644
index 000000000..ac9f9086f
--- /dev/null
+++ b/frontend/app/hook/useDimensions.tsx
@@ -0,0 +1,67 @@
+import useResizeObserver from "@react-hook/resize-observer";
+import { useCallback, useRef, useState } from "react";
+import { debounce } from "throttle-debounce";
+
+/**
+ * Get the current dimensions for the specified element, and whether it is currently changing size. Update when the element resizes.
+ * @param ref The reference to the element to observe.
+ * @param delay The debounce delay to use for updating the dimensions.
+ * @returns The dimensions of the element, and direction in which the dimensions are changing.
+ */
+const useDimensions = (ref: React.RefObject, delay = 0) => {
+ const [dimensions, setDimensions] = useState<{
+ height: number | null;
+ width: number | null;
+ widthDirection?: string;
+ heightDirection?: string;
+ }>({
+ height: null,
+ width: null,
+ });
+
+ const previousDimensions = useRef<{ height: number | null; width: number | null }>({
+ height: null,
+ width: null,
+ });
+
+ const updateDimensions = useCallback((entry: ResizeObserverEntry) => {
+ const parentHeight = entry.contentRect.height;
+ const parentWidth = entry.contentRect.width;
+
+ let widthDirection = "";
+ let heightDirection = "";
+
+ if (previousDimensions.current.width !== null && previousDimensions.current.height !== null) {
+ if (parentWidth > previousDimensions.current.width) {
+ widthDirection = "expanding";
+ } else if (parentWidth < previousDimensions.current.width) {
+ widthDirection = "shrinking";
+ } else {
+ widthDirection = "unchanged";
+ }
+
+ if (parentHeight > previousDimensions.current.height) {
+ heightDirection = "expanding";
+ } else if (parentHeight < previousDimensions.current.height) {
+ heightDirection = "shrinking";
+ } else {
+ heightDirection = "unchanged";
+ }
+ }
+
+ previousDimensions.current = { height: parentHeight, width: parentWidth };
+
+ setDimensions({ height: parentHeight, width: parentWidth, widthDirection, heightDirection });
+ }, []);
+
+ const fUpdateDimensions = useCallback(delay > 0 ? debounce(delay, updateDimensions) : updateDimensions, [
+ updateDimensions,
+ delay,
+ ]);
+
+ useResizeObserver(ref, fUpdateDimensions);
+
+ return dimensions;
+};
+
+export { useDimensions };
diff --git a/frontend/app/hook/useHeight.tsx b/frontend/app/hook/useHeight.tsx
new file mode 100644
index 000000000..e6ce41c31
--- /dev/null
+++ b/frontend/app/hook/useHeight.tsx
@@ -0,0 +1,28 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import useResizeObserver from "@react-hook/resize-observer";
+import { useCallback, useState } from "react";
+import { debounce } from "throttle-debounce";
+
+/**
+ * Get the height of the specified element and update it when the element resizes.
+ * @param ref The reference to the element to observe.
+ * @param delay The debounce delay to use for updating the height.
+ * @returns The current height of the element, or null if the element is not yet mounted.
+ */
+const useHeight = (ref: React.RefObject, delay = 0) => {
+ const [height, setHeight] = useState(null);
+
+ const updateHeight = useCallback((entry: ResizeObserverEntry) => {
+ setHeight(entry.contentRect.height);
+ }, []);
+
+ const fUpdateHeight = useCallback(delay > 0 ? debounce(delay, updateHeight) : updateHeight, [updateHeight, delay]);
+
+ useResizeObserver(ref, fUpdateHeight);
+
+ return height;
+};
+
+export { useHeight };
diff --git a/frontend/app/hook/useLongClick.tsx b/frontend/app/hook/useLongClick.tsx
new file mode 100644
index 000000000..5b71563fd
--- /dev/null
+++ b/frontend/app/hook/useLongClick.tsx
@@ -0,0 +1,59 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export const useLongClick = (ref, onClick, onLongClick, disabled = false, ms = 300) => {
+ const timerRef = useRef(null);
+ const [longClickTriggered, setLongClickTriggered] = useState(false);
+
+ const startPress = useCallback(
+ (e: React.MouseEvent) => {
+ if (onLongClick == null) {
+ return;
+ }
+ setLongClickTriggered(false);
+ timerRef.current = setTimeout(() => {
+ setLongClickTriggered(true);
+ onLongClick?.(e);
+ }, ms);
+ },
+ [onLongClick, ms]
+ );
+
+ const stopPress = useCallback(() => {
+ clearTimeout(timerRef.current);
+ }, []);
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (longClickTriggered) {
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+ onClick?.(e);
+ },
+ [longClickTriggered, onClick]
+ );
+
+ useEffect(() => {
+ const element = ref.current;
+
+ if (!element || disabled) return;
+
+ element.addEventListener("mousedown", startPress);
+ element.addEventListener("mouseup", stopPress);
+ element.addEventListener("mouseleave", stopPress);
+ element.addEventListener("click", handleClick);
+
+ return () => {
+ element.removeEventListener("mousedown", startPress);
+ element.removeEventListener("mouseup", stopPress);
+ element.removeEventListener("mouseleave", stopPress);
+ element.removeEventListener("click", handleClick);
+ };
+ }, [ref.current, startPress, stopPress, handleClick]);
+
+ return ref;
+};
diff --git a/frontend/app/hook/useWidth.tsx b/frontend/app/hook/useWidth.tsx
new file mode 100644
index 000000000..c7007d7f4
--- /dev/null
+++ b/frontend/app/hook/useWidth.tsx
@@ -0,0 +1,28 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import useResizeObserver from "@react-hook/resize-observer";
+import { useCallback, useState } from "react";
+import { debounce } from "throttle-debounce";
+
+/**
+ * Get the width of the specified element and update it when the element resizes.
+ * @param ref The reference to the element to observe.
+ * @param delay The debounce delay to use for updating the width.
+ * @returns The current width of the element, or null if the element is not yet mounted.
+ */
+const useWidth = (ref: React.RefObject, delay = 0) => {
+ const [width, setWidth] = useState(null);
+
+ const updateWidth = useCallback((entry: ResizeObserverEntry) => {
+ setWidth(entry.contentRect.width);
+ }, []);
+
+ const fUpdateWidth = useCallback(delay > 0 ? debounce(delay, updateWidth) : updateWidth, [updateWidth, delay]);
+
+ useResizeObserver(ref, fUpdateWidth);
+
+ return width;
+};
+
+export { useWidth };
diff --git a/frontend/app/mixins.less b/frontend/app/mixins.less
new file mode 100644
index 000000000..9178b3971
--- /dev/null
+++ b/frontend/app/mixins.less
@@ -0,0 +1,9 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.ellipsis() {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/frontend/app/modals/about.less b/frontend/app/modals/about.less
new file mode 100644
index 000000000..9b4a2b302
--- /dev/null
+++ b/frontend/app/modals/about.less
@@ -0,0 +1,61 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.about-modal {
+ width: 445px;
+ padding-bottom: 34px;
+
+ .modal-content {
+ margin-bottom: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 24px;
+
+ .section-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 26px;
+ width: 100%;
+ }
+
+ .section {
+ align-items: center;
+ gap: 16px;
+ align-self: stretch;
+ width: 100%;
+ text-align: center;
+
+ &.logo-section {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ text-align: center;
+
+ .app-name {
+ font-size: 25px;
+ }
+
+ .text-standard {
+ line-height: 20px;
+ }
+ }
+ }
+
+ .section:nth-child(3) {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+
+ .wave-button-link {
+ display: flex;
+ align-items: center;
+
+ i {
+ font-size: 16px;
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/app/modals/about.tsx b/frontend/app/modals/about.tsx
new file mode 100644
index 000000000..589df36b0
--- /dev/null
+++ b/frontend/app/modals/about.tsx
@@ -0,0 +1,71 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import Logo from "@/app/asset/logo.svg";
+import { LinkButton } from "@/app/element/linkbutton";
+import { modalsModel } from "@/app/store/modalmodel";
+import { Modal } from "./modal";
+
+import { isDev } from "@/util/isdev";
+import { useState } from "react";
+import { getApi } from "../store/global";
+import "./about.less";
+
+interface AboutModalProps {}
+
+const AboutModal = ({}: AboutModalProps) => {
+ const currentDate = new Date();
+ const [details] = useState(() => getApi().getAboutModalDetails());
+
+ return (
+ modalsModel.popModal()}>
+
+
+
+
Wave Terminal
+
+ Open-Source AI-Native Terminal
+
+ Built for Seamless Workflows
+
+
+
+ Client Version {details.version} ({isDev() ? "dev-" : ""}
+ {details.buildTime})
+
+
+ }
+ >
+ Github
+
+ }
+ >
+ Website
+
+ }
+ >
+ Acknowledgements
+
+
+
© {currentDate.getFullYear()} Command Line Inc.
+
+
+ );
+};
+
+AboutModal.displayName = "AboutModal";
+
+export { AboutModal };
diff --git a/frontend/app/modals/modal.less b/frontend/app/modals/modal.less
new file mode 100644
index 000000000..46ebe01a6
--- /dev/null
+++ b/frontend/app/modals/modal.less
@@ -0,0 +1,72 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.modal-wrapper {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: var(--zindex-modal-wrapper);
+
+ .modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(21, 23, 21, 0.7);
+ z-index: var(--zindex-modal-backdrop);
+ }
+}
+
+.modal {
+ position: relative;
+ z-index: var(--zindex-modal);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 24px 16px 16px;
+ border-radius: 8px;
+ border: 0.5px solid var(--modal-border-color);
+ background: var(--modal-bg-color);
+ box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
+
+ .modal-close-btn {
+ position: absolute;
+ right: 8px;
+ top: 8px;
+ padding: 8px 12px;
+
+ i {
+ font-size: 18px;
+ }
+ }
+
+ .content-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+
+ .modal-content {
+ width: 100%;
+ padding: 0px 20px;
+ }
+ }
+
+ .modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+ padding-top: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+
+ .button:last-child {
+ margin-left: 8px;
+ }
+ }
+}
diff --git a/frontend/app/modals/modal.tsx b/frontend/app/modals/modal.tsx
new file mode 100644
index 000000000..bdf14602b
--- /dev/null
+++ b/frontend/app/modals/modal.tsx
@@ -0,0 +1,111 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Button } from "@/app/element/button";
+import clsx from "clsx";
+import { forwardRef } from "react";
+import ReactDOM from "react-dom";
+
+import "./modal.less";
+
+interface ModalProps {
+ children?: React.ReactNode;
+ okLabel?: string;
+ cancelLabel?: string;
+ className?: string;
+ onClickBackdrop?: () => void;
+ onOk?: () => void;
+ onCancel?: () => void;
+ onClose?: () => void;
+}
+
+const Modal = forwardRef(
+ ({ children, className, cancelLabel, okLabel, onCancel, onOk, onClose, onClickBackdrop }: ModalProps, ref) => {
+ const renderBackdrop = (onClick) =>
;
+
+ const renderFooter = () => {
+ return onOk || onCancel;
+ };
+
+ const renderModal = () => (
+
+ {renderBackdrop(onClickBackdrop)}
+
+
+
+
+
+ {children}
+
+ {renderFooter() && (
+
+ )}
+
+
+ );
+
+ return ReactDOM.createPortal(renderModal(), document.getElementById("main"));
+ }
+);
+
+interface ModalContentProps {
+ children: React.ReactNode;
+}
+
+function ModalContent({ children }: ModalContentProps) {
+ return {children}
;
+}
+
+interface ModalFooterProps {
+ okLabel?: string;
+ cancelLabel?: string;
+ onOk?: () => void;
+ onCancel?: () => void;
+}
+
+const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }: ModalFooterProps) => {
+ return (
+
+ {onCancel && (
+
+ {cancelLabel}
+
+ )}
+ {onOk && {okLabel} }
+
+ );
+};
+
+interface FlexiModalProps {
+ children?: React.ReactNode;
+ className?: string;
+ onClickBackdrop?: () => void;
+}
+
+interface FlexiModalComponent
+ extends React.ForwardRefExoticComponent> {
+ Content: typeof ModalContent;
+ Footer: typeof ModalFooter;
+}
+
+const FlexiModal = forwardRef(
+ ({ children, className, onClickBackdrop }: FlexiModalProps, ref) => {
+ const renderBackdrop = (onClick: () => void) =>
;
+
+ const renderModal = () => (
+
+ {renderBackdrop(onClickBackdrop)}
+
+ {children}
+
+
+ );
+
+ return ReactDOM.createPortal(renderModal(), document.getElementById("main")!);
+ }
+);
+
+(FlexiModal as FlexiModalComponent).Content = ModalContent;
+(FlexiModal as FlexiModalComponent).Footer = ModalFooter;
+
+export { FlexiModal, Modal };
diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx
new file mode 100644
index 000000000..f77d790f2
--- /dev/null
+++ b/frontend/app/modals/modalregistry.tsx
@@ -0,0 +1,16 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { AboutModal } from "./about";
+import { TosModal } from "./tos";
+import { UserInputModal } from "./userinputmodal";
+
+const modalRegistry: { [key: string]: React.ComponentType } = {
+ [TosModal.displayName || "TosModal"]: TosModal,
+ [UserInputModal.displayName || "UserInputModal"]: UserInputModal,
+ [AboutModal.displayName || "AboutModal"]: AboutModal,
+};
+
+export const getModalComponent = (key: string): React.ComponentType | undefined => {
+ return modalRegistry[key];
+};
diff --git a/frontend/app/modals/modalsrenderer.tsx b/frontend/app/modals/modalsrenderer.tsx
new file mode 100644
index 000000000..0cc3cbf83
--- /dev/null
+++ b/frontend/app/modals/modalsrenderer.tsx
@@ -0,0 +1,37 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { atoms, globalStore } from "@/store/global";
+import { modalsModel } from "@/store/modalmodel";
+import * as jotai from "jotai";
+import { useEffect } from "react";
+import { getModalComponent } from "./modalregistry";
+import { TosModal } from "./tos";
+
+const ModalsRenderer = () => {
+ const clientData = jotai.useAtomValue(atoms.client);
+ const [tosOpen, setTosOpen] = jotai.useAtom(modalsModel.tosOpen);
+ const [modals] = jotai.useAtom(modalsModel.modalsAtom);
+ const rtn: JSX.Element[] = [];
+ for (const modal of modals) {
+ const ModalComponent = getModalComponent(modal.displayName);
+ if (ModalComponent) {
+ rtn.push( );
+ }
+ }
+ if (tosOpen) {
+ rtn.push( );
+ }
+ useEffect(() => {
+ if (!clientData.tosagreed) {
+ setTosOpen(true);
+ }
+ }, [clientData]);
+ useEffect(() => {
+ globalStore.set(atoms.modalOpen, rtn.length > 0);
+ }, [rtn]);
+
+ return <>{rtn}>;
+};
+
+export { ModalsRenderer };
diff --git a/frontend/app/modals/tos.less b/frontend/app/modals/tos.less
new file mode 100644
index 000000000..1540a139c
--- /dev/null
+++ b/frontend/app/modals/tos.less
@@ -0,0 +1,242 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.tos-modal {
+ width: 560px;
+ border-radius: 10px;
+ padding: 0;
+
+ .modal-inner {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ padding: 30px;
+ width: 100%;
+
+ header.tos-header {
+ flex-direction: column;
+ gap: 8px;
+ border-bottom: none;
+ padding: 0;
+ margin-bottom: 36px;
+ width: 100%;
+
+ .logo {
+ margin-bottom: 10px;
+ display: flex;
+ justify-content: center;
+ }
+
+ .modal-title {
+ text-align: center;
+ font-size: 25px;
+ font-weight: 400;
+ color: var(--main-text-color);
+ }
+
+ .modal-subtitle {
+ color: var(--main-text-color);
+ text-align: center;
+
+ font-style: normal;
+ font-weight: 300;
+ line-height: 20px;
+ }
+ }
+
+ .tos-content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 32px;
+ width: 100%;
+ margin-bottom: 0;
+ margin-bottom: 20px;
+
+ .check-toggle-wrapper .toggle-label {
+ color: var(--secondary-text-color);
+ }
+
+ .tips-wrapper {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ width: 100%;
+
+ .tips-section {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: 5px;
+
+ .tip-section-header {
+ font-weight: bold;
+ margin-bottom: 5px;
+ margin-top: 10px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ .tip {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ .keybinding2 {
+ font: var(--fixed-font);
+ background-color: var(--highlight-bg-color);
+ color: var(--main-text-color);
+ padding: 2px 8px;
+ border-radius: 4px;
+ }
+
+ .keybinding-group {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ margin-left: 5px;
+ margin-right: 5px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+ }
+
+ .keybinding {
+ display: inline-block;
+ padding: 0.1em 0.4em;
+ margin: 0 0.1em;
+ font-family: "SF Pro Text", "Segoe UI", sans-serif;
+ font-size: 0.85em;
+ color: #e0e0e0;
+ background-color: #333;
+ border-radius: 4px;
+ border: 1px solid #444;
+ box-shadow: none;
+ }
+
+ .keybinding3 {
+ color: black;
+ display: inline-block;
+ padding: 0.2em 0.4em;
+ min-width: 24px;
+ height: 22px;
+ margin: 0 0.1em;
+ font-family: "SF Pro Text", "Segoe UI", sans-serif;
+ font-size: 0.9em;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background-color: #ddd;
+ color: var(--invert-text-color);
+ box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.1);
+ text-align: center;
+ }
+
+ .icon-wrap {
+ background-color: var(--highlight-bg-color);
+ padding: 2px;
+ color: var(--secondary-text-color);
+ font-size: 12px;
+ border-radius: 2px;
+ margin-right: 5px;
+
+ svg {
+ position: relative;
+ top: 3px;
+ left: 1px;
+ height: 13px;
+ #arrow1,
+ #arrow2 {
+ fill: var(--main-text-color);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .content-section {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 18px;
+
+ .icon-wrapper {
+ .icon {
+ font-size: 32px;
+ color: rgba(255, 255, 255, 0.5);
+ }
+
+ .fa-people-group {
+ font-size: 25px;
+ }
+ }
+
+ .content-section-inner {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ flex: 1 0 0;
+
+ .content-section-title {
+ color: var(--main-text-color);
+ font-style: normal;
+ line-height: 18px;
+ font-size: 16px;
+ margin-bottom: 5px;
+ }
+
+ .content-section-text {
+ color: var(--secondary-text-color);
+ font-style: normal;
+ line-height: 20px;
+
+ b {
+ color: var(--main-text-color);
+ }
+ }
+
+ .content-section-field {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .check-toggle-wrapper {
+ margin-top: 5px;
+
+ label {
+ font-size: 13px;
+ }
+ }
+ }
+ }
+ }
+
+ footer {
+ .content-section-text {
+ text-align: center;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 11px;
+ }
+
+ .button-wrapper {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ button {
+ font-size: 14px;
+ }
+
+ button.disabled-button {
+ cursor: default;
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/app/modals/tos.tsx b/frontend/app/modals/tos.tsx
new file mode 100644
index 000000000..c7233f7c0
--- /dev/null
+++ b/frontend/app/modals/tos.tsx
@@ -0,0 +1,206 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import Logo from "@/app/asset/logo.svg";
+import { Button } from "@/app/element/button";
+import { Toggle } from "@/app/element/toggle";
+import * as services from "@/store/services";
+import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
+import { useEffect, useRef, useState } from "react";
+import { FlexiModal } from "./modal";
+
+import { QuickTips } from "@/app/element/quicktips";
+import { atoms } from "@/app/store/global";
+import { modalsModel } from "@/app/store/modalmodel";
+import { RpcApi } from "@/app/store/wshclientapi";
+import { WindowRpcClient } from "@/app/store/wshrpcutil";
+import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai";
+import "./tos.less";
+
+const pageNumAtom: PrimitiveAtom = atom(1);
+
+const ModalPage1 = () => {
+ const settings = useAtomValue(atoms.settingsAtom);
+ const clientData = useAtomValue(atoms.client);
+ const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen);
+ const [telemetryEnabled, setTelemetryEnabled] = useState(!!settings["telemetry:enabled"]);
+ const setPageNum = useSetAtom(pageNumAtom);
+
+ const acceptTos = () => {
+ if (!clientData.tosagreed) {
+ services.ClientService.AgreeTos();
+ }
+ setPageNum(2);
+ };
+
+ const setTelemetry = (value: boolean) => {
+ RpcApi.SetConfigCommand(WindowRpcClient, { "telemetry:enabled": value })
+ .then(() => {
+ setTelemetryEnabled(value);
+ })
+ .catch((error) => {
+ console.error("failed to set telemetry:", error);
+ });
+ };
+
+ const label = telemetryEnabled ? "Telemetry Enabled" : "Telemetry Disabled";
+
+ return (
+ <>
+
+
+
+
+ Welcome to Wave Terminal
+
+
+
+
+
+
Support us on GitHub
+
+ We're
open source and committed to providing a free terminal for individual users.
+ Please show your support by giving us a star on{" "}
+
+ Github (wavetermdev/waveterm)
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+const ModalPage2 = () => {
+ const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen);
+
+ const handleGetStarted = () => {
+ setTosOpen(false);
+ };
+
+ return (
+ <>
+
+
+
+
+ Icons and Keybindings
+
+
+
+
+
+ >
+ );
+};
+
+const TosModal = () => {
+ const modalRef = useRef(null);
+ const [pageNum, setPageNum] = useAtom(pageNumAtom);
+ const clientData = useAtomValue(atoms.client);
+
+ const updateModalHeight = () => {
+ const windowHeight = window.innerHeight;
+ if (modalRef.current) {
+ const modalHeight = modalRef.current.offsetHeight;
+ const maxHeight = windowHeight * 0.9;
+ if (maxHeight < modalHeight) {
+ modalRef.current.style.height = `${maxHeight}px`;
+ } else {
+ modalRef.current.style.height = "auto";
+ }
+ }
+ };
+
+ useEffect(() => {
+ // on unmount, always reset pagenum
+ if (clientData.tosagreed) {
+ setPageNum(2);
+ }
+ return () => {
+ setPageNum(1);
+ };
+ }, []);
+
+ useEffect(() => {
+ updateModalHeight(); // Run on initial render
+
+ window.addEventListener("resize", updateModalHeight); // Run on window resize
+ return () => {
+ window.removeEventListener("resize", updateModalHeight);
+ };
+ }, []);
+
+ return (
+
+
+ {pageNum === 1 ? : }
+
+
+ );
+};
+
+TosModal.displayName = "TosModal";
+
+export { TosModal };
diff --git a/frontend/app/modals/typeaheadmodal.less b/frontend/app/modals/typeaheadmodal.less
new file mode 100644
index 000000000..4e896a7b3
--- /dev/null
+++ b/frontend/app/modals/typeaheadmodal.less
@@ -0,0 +1,115 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+@import "../mixins.less";
+
+.type-ahead-modal-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: transparent;
+ z-index: var(--zindex-typeahead-modal-backdrop);
+}
+
+.type-ahead-modal {
+ position: absolute;
+ z-index: var(--zindex-typeahead-modal);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ border-radius: 6px;
+ border: 1px solid var(--modal-border-color);
+ background: var(--modal-bg-color);
+ box-shadow: 0px 13px 16px 0px rgba(0, 0, 0, 0.4);
+ padding: 6px;
+ flex-direction: column;
+
+ .label {
+ opacity: 0.5;
+ font-size: 13px;
+ white-space: nowrap;
+ }
+
+ .input {
+ border: none;
+ border-bottom: none;
+ height: 24px;
+ border-radius: 0;
+
+ input {
+ width: 100%;
+ flex-shrink: 0;
+ padding: 4px 6px;
+ height: 24px;
+ }
+
+ .input-decoration.end-position {
+ margin: 6px;
+
+ i {
+ opacity: 0.3;
+ }
+ }
+ }
+
+ &.has-suggestions {
+ .input {
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ }
+ }
+
+ .suggestions-wrapper {
+ width: 100%;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+
+ .suggestion-header {
+ font-size: 11px;
+ font-style: normal;
+ font-weight: 500;
+ line-height: 12px;
+ opacity: 0.7;
+ letter-spacing: 0.11px;
+ padding: 4px 0px 0px 4px;
+ }
+
+ .suggestion-item {
+ width: 100%;
+ cursor: pointer;
+ display: flex;
+ padding: 6px 8px;
+ align-items: center;
+ gap: 8px;
+ align-self: stretch;
+ border-radius: 4px;
+
+ &.selected {
+ background-color: rgb(from var(--accent-color) r g b / 0.5);
+ color: var(--main-text-color);
+ }
+
+ &:hover:not(.selected) {
+ background-color: var(--highlight-bg-color);
+ }
+
+ .typeahead-item-name {
+ .ellipsis();
+ display: flex;
+ gap: 8px;
+ font-size: 11px;
+ font-weight: 400;
+ line-height: 14px;
+
+ i {
+ display: inline-block;
+ position: relative;
+ top: 2px;
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx
new file mode 100644
index 000000000..c9920cad4
--- /dev/null
+++ b/frontend/app/modals/typeaheadmodal.tsx
@@ -0,0 +1,257 @@
+import { Input } from "@/app/element/input";
+import { InputDecoration } from "@/app/element/inputdecoration";
+import { useDimensions } from "@/app/hook/useDimensions";
+import { makeIconClass } from "@/util/util";
+import clsx from "clsx";
+import React, { forwardRef, useLayoutEffect, useRef } from "react";
+import ReactDOM from "react-dom";
+
+import "./typeaheadmodal.less";
+
+interface SuggestionsProps {
+ suggestions?: SuggestionsType[];
+ onSelect?: (_: string) => void;
+ selectIndex: number;
+}
+
+const Suggestions = forwardRef(
+ ({ suggestions, onSelect, selectIndex }: SuggestionsProps, ref) => {
+ const renderIcon = (icon: string | React.ReactNode, color: string) => {
+ if (typeof icon === "string") {
+ return ;
+ }
+ return icon;
+ };
+
+ const renderItem = (item: SuggestionBaseItem | SuggestionConnectionItem, index: number) => (
+ {
+ if ("onSelect" in item && item.onSelect) {
+ item.onSelect(item.value);
+ } else {
+ onSelect(item.value);
+ }
+ }}
+ className={clsx("suggestion-item", { selected: selectIndex === index })}
+ >
+
+ {item.icon &&
+ renderIcon(item.icon, "iconColor" in item && item.iconColor ? item.iconColor : "inherit")}
+ {item.label}
+
+
+ );
+
+ let fullIndex = -1;
+ return (
+
+ {suggestions.map((item, index) => {
+ if ("headerText" in item) {
+ return (
+
+ {item.headerText &&
{item.headerText}
}
+ {item.items.map((subItem, subIndex) => {
+ fullIndex += 1;
+ return renderItem(subItem, fullIndex);
+ })}
+
+ );
+ }
+ return renderItem(item as SuggestionBaseItem, index);
+ })}
+
+ );
+ }
+);
+
+interface TypeAheadModalProps {
+ anchorRef: React.RefObject;
+ blockRef?: React.RefObject;
+ suggestions?: SuggestionsType[];
+ label?: string;
+ className?: string;
+ value?: string;
+ onChange?: (_: string) => void;
+ onSelect?: (_: string) => void;
+ onClickBackdrop?: () => void;
+ onKeyDown?: (_) => void;
+ giveFocusRef?: React.MutableRefObject<() => boolean>;
+ autoFocus?: boolean;
+ selectIndex?: number;
+}
+
+const TypeAheadModal = ({
+ className,
+ suggestions,
+ label,
+ anchorRef,
+ blockRef,
+ value,
+ onChange,
+ onSelect,
+ onKeyDown,
+ onClickBackdrop,
+ giveFocusRef,
+ autoFocus,
+ selectIndex,
+}: TypeAheadModalProps) => {
+ const { width, height } = useDimensions(blockRef);
+ const modalRef = useRef(null);
+ const inputRef = useRef(null);
+ const realInputRef = useRef(null);
+ const suggestionsWrapperRef = useRef(null);
+ const suggestionsRef = useRef(null);
+
+ useLayoutEffect(() => {
+ if (!modalRef.current || !inputRef.current || !suggestionsRef.current || !suggestionsWrapperRef.current) {
+ return;
+ }
+
+ const modalStyles = window.getComputedStyle(modalRef.current);
+ const paddingTop = parseFloat(modalStyles.paddingTop) || 0;
+ const paddingBottom = parseFloat(modalStyles.paddingBottom) || 0;
+ const borderTop = parseFloat(modalStyles.borderTopWidth) || 0;
+ const borderBottom = parseFloat(modalStyles.borderBottomWidth) || 0;
+ const modalPadding = paddingTop + paddingBottom;
+ const modalBorder = borderTop + borderBottom;
+
+ const suggestionsWrapperStyles = window.getComputedStyle(suggestionsWrapperRef.current);
+ const suggestionsWrapperMarginTop = parseFloat(suggestionsWrapperStyles.marginTop) || 0;
+
+ const inputHeight = inputRef.current.getBoundingClientRect().height;
+ let suggestionsTotalHeight = 0;
+
+ const suggestionItems = suggestionsRef.current.children;
+ for (let i = 0; i < suggestionItems.length; i++) {
+ suggestionsTotalHeight += suggestionItems[i].getBoundingClientRect().height;
+ }
+
+ const totalHeight =
+ modalPadding + modalBorder + inputHeight + suggestionsTotalHeight + suggestionsWrapperMarginTop;
+ const maxHeight = height * 0.8;
+ const computedHeight = totalHeight > maxHeight ? maxHeight : totalHeight;
+
+ modalRef.current.style.height = `${computedHeight}px`;
+
+ suggestionsWrapperRef.current.style.height = `${computedHeight - inputHeight - modalPadding - modalBorder - suggestionsWrapperMarginTop}px`;
+ }, [height, suggestions]);
+
+ useLayoutEffect(() => {
+ if (!blockRef.current || !modalRef.current) return;
+
+ const blockRect = blockRef.current.getBoundingClientRect();
+ const anchorRect = anchorRef.current.getBoundingClientRect();
+
+ const minGap = 20;
+
+ const availableWidth = blockRect.width - minGap * 2;
+ let modalWidth = 300;
+
+ if (modalWidth > availableWidth) {
+ modalWidth = availableWidth;
+ }
+
+ let leftPosition = anchorRect.left - blockRect.left;
+
+ const modalRightEdge = leftPosition + modalWidth;
+ const blockRightEdge = blockRect.width - (minGap - 4);
+
+ if (modalRightEdge > blockRightEdge) {
+ leftPosition -= modalRightEdge - blockRightEdge;
+ }
+
+ if (leftPosition < minGap) {
+ leftPosition = minGap;
+ }
+
+ modalRef.current.style.width = `${modalWidth}px`;
+ modalRef.current.style.left = `${leftPosition}px`;
+ }, [width]);
+
+ useLayoutEffect(() => {
+ if (giveFocusRef) {
+ giveFocusRef.current = () => {
+ realInputRef.current?.focus();
+ return true;
+ };
+ }
+ return () => {
+ if (giveFocusRef) {
+ giveFocusRef.current = null;
+ }
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ if (anchorRef.current && modalRef.current) {
+ const parentElement = anchorRef.current.closest(".block-frame-default-header");
+ modalRef.current.style.top = `${parentElement?.getBoundingClientRect().height}px`;
+ }
+ }, []);
+
+ const renderBackdrop = (onClick) =>
;
+
+ const handleKeyDown = (e) => {
+ onKeyDown && onKeyDown(e);
+ };
+
+ const handleChange = (value) => {
+ onChange && onChange(value);
+ };
+
+ const handleSelect = (value) => {
+ onSelect && onSelect(value);
+ };
+
+ const renderModal = () => (
+
+ {renderBackdrop(onClickBackdrop)}
+
0 })}
+ >
+
+
+
+ ),
+ }}
+ />
+
0 ? "8px" : "0",
+ overflowY: "auto",
+ }}
+ >
+ {suggestions?.length > 0 && (
+
+ )}
+
+
+
+ );
+
+ if (blockRef && blockRef.current == null) {
+ return null;
+ }
+
+ return ReactDOM.createPortal(renderModal(), blockRef.current);
+};
+
+export { TypeAheadModal };
diff --git a/frontend/app/modals/userinputmodal.less b/frontend/app/modals/userinputmodal.less
new file mode 100644
index 000000000..8cdb338c2
--- /dev/null
+++ b/frontend/app/modals/userinputmodal.less
@@ -0,0 +1,45 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.userinput-header {
+ font-weight: bold;
+ color: var(--main-text-color);
+ padding-bottom: 10px;
+}
+
+.userinput-body {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 1rem;
+ margin: 0 1rem 1rem 1rem;
+
+ font: var(--fixed-font);
+ color: var(--main-text-color);
+
+ .userinput-markdown {
+ color: inherit;
+ }
+
+ .userinput-text {
+ }
+
+ .userinput-inputbox {
+ resize: none;
+ background-color: var(--panel-bg-color);
+ border-radius: 6px;
+ margin: 0;
+ border: var(--border-color);
+ padding: 5px 0 5px 16px;
+ min-height: 30px;
+ color: inherit;
+
+ &:hover {
+ cursor: text;
+ }
+
+ &:focus {
+ outline-color: var(--accent-color);
+ }
+ }
+}
diff --git a/frontend/app/modals/userinputmodal.tsx b/frontend/app/modals/userinputmodal.tsx
new file mode 100644
index 000000000..d3f40af2a
--- /dev/null
+++ b/frontend/app/modals/userinputmodal.tsx
@@ -0,0 +1,123 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import "./userinputmodal.less";
+
+const UserInputModal = (userInputRequest: UserInputRequest) => {
+ const [responseText, setResponseText] = useState("");
+ const [countdown, setCountdown] = useState(Math.floor(userInputRequest.timeoutms / 1000));
+ const checkboxStatus = useRef(false);
+
+ const handleSendCancel = useCallback(() => {
+ UserInputService.SendUserInputResponse({
+ type: "userinputresp",
+ requestid: userInputRequest.requestid,
+ errormsg: "Canceled by the user",
+ });
+ modalsModel.popModal();
+ }, [responseText, userInputRequest]);
+
+ const handleSendText = useCallback(() => {
+ UserInputService.SendUserInputResponse({
+ type: "userinputresp",
+ requestid: userInputRequest.requestid,
+ text: responseText,
+ checkboxstat: checkboxStatus.current,
+ });
+ modalsModel.popModal();
+ }, [responseText, userInputRequest]);
+
+ const handleSendConfirm = useCallback(() => {
+ UserInputService.SendUserInputResponse({
+ type: "userinputresp",
+ requestid: userInputRequest.requestid,
+ confirm: true,
+ checkboxstat: checkboxStatus.current,
+ });
+ modalsModel.popModal();
+ }, [userInputRequest]);
+
+ const handleSubmit = useCallback(() => {
+ switch (userInputRequest.responsetype) {
+ case "text":
+ handleSendText();
+ break;
+ case "confirm":
+ handleSendConfirm();
+ break;
+ }
+ }, [handleSendConfirm, handleSendText, userInputRequest.responsetype]);
+
+ const handleKeyDown = useCallback(
+ (waveEvent: WaveKeyboardEvent): boolean => {
+ if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
+ handleSendCancel();
+ return;
+ }
+ if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
+ handleSubmit();
+ return true;
+ }
+ },
+ [handleSendCancel, handleSubmit]
+ );
+
+ const queryText = useMemo(() => {
+ if (userInputRequest.markdown) {
+ return ;
+ }
+ return {userInputRequest.querytext} ;
+ }, [userInputRequest.markdown, userInputRequest.querytext]);
+
+ const inputBox = useMemo(() => {
+ if (userInputRequest.responsetype === "confirm") {
+ return <>>;
+ }
+ return (
+ setResponseText(e.target.value)}
+ value={responseText}
+ maxLength={400}
+ className="userinput-inputbox"
+ autoFocus={true}
+ onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
+ />
+ );
+ }, [userInputRequest.responsetype, userInputRequest.publictext, responseText, handleKeyDown, setResponseText]);
+
+ useEffect(() => {
+ let timeout: ReturnType;
+ if (countdown <= 0) {
+ timeout = setTimeout(() => {
+ handleSendCancel();
+ }, 300);
+ } else {
+ timeout = setTimeout(() => {
+ setCountdown(countdown - 1);
+ }, 1000);
+ }
+ return () => clearTimeout(timeout);
+ }, [countdown]);
+
+ return (
+ handleSubmit()} onCancel={() => handleSendCancel()} onClose={() => handleSendCancel()}>
+ {userInputRequest.title + ` (${countdown}s)`}
+
+ {queryText}
+ {inputBox}
+
+
+ );
+};
+
+UserInputModal.displayName = "UserInputModal";
+
+export { UserInputModal };
diff --git a/frontend/app/reset.less b/frontend/app/reset.less
new file mode 100644
index 000000000..aa0063590
--- /dev/null
+++ b/frontend/app/reset.less
@@ -0,0 +1,32 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+}
+
+body {
+ line-height: 1.2;
+ -webkit-font-smoothing: antialiased;
+}
+
+img,
+picture,
+video,
+canvas,
+svg {
+ display: block;
+}
+
+input,
+button,
+textarea,
+select {
+ font: inherit;
+}
diff --git a/frontend/app/store/contextmenu.ts b/frontend/app/store/contextmenu.ts
new file mode 100644
index 000000000..fac411cf1
--- /dev/null
+++ b/frontend/app/store/contextmenu.ts
@@ -0,0 +1,49 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { getApi } from "./global";
+
+class ContextMenuModelType {
+ handlers: Map void> = new Map(); // id -> handler
+
+ constructor() {
+ getApi().onContextMenuClick(this.handleContextMenuClick.bind(this));
+ }
+
+ handleContextMenuClick(id: string): void {
+ const handler = this.handlers.get(id);
+ if (handler) {
+ handler();
+ }
+ }
+
+ _convertAndRegisterMenu(menu: ContextMenuItem[]): ElectronContextMenuItem[] {
+ const electronMenuItems: ElectronContextMenuItem[] = [];
+ for (const item of menu) {
+ const electronItem: ElectronContextMenuItem = {
+ role: item.role,
+ type: item.type,
+ label: item.label,
+ id: crypto.randomUUID(),
+ };
+ if (item.click) {
+ this.handlers.set(electronItem.id, item.click);
+ }
+ if (item.submenu) {
+ electronItem.submenu = this._convertAndRegisterMenu(item.submenu);
+ }
+ electronMenuItems.push(electronItem);
+ }
+ return electronMenuItems;
+ }
+
+ showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent): void {
+ this.handlers.clear();
+ const electronMenuItems = this._convertAndRegisterMenu(menu);
+ getApi().showContextMenu(electronMenuItems);
+ }
+}
+
+const ContextMenuModel = new ContextMenuModelType();
+
+export { ContextMenuModel, ContextMenuModelType };
diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts
new file mode 100644
index 000000000..4b352d56d
--- /dev/null
+++ b/frontend/app/store/global.ts
@@ -0,0 +1,559 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ getLayoutModelForActiveTab,
+ getLayoutModelForTabById,
+ LayoutTreeActionType,
+ LayoutTreeInsertNodeAction,
+ newLayoutNode,
+} from "@/layout/index";
+import { getWebServerEndpoint } from "@/util/endpoints";
+import { fetch } from "@/util/fetchutil";
+import { getPrefixedSettings, isBlank } from "@/util/util";
+import { atom, Atom, createStore, PrimitiveAtom, useAtomValue } from "jotai";
+import { modalsModel } from "./modalmodel";
+import { ClientService, ObjectService } from "./services";
+import * as WOS from "./wos";
+import { getFileSubject, waveEventSubscribe } from "./wps";
+
+let PLATFORM: NodeJS.Platform = "darwin";
+const globalStore = createStore();
+let atoms: GlobalAtomsType;
+let globalEnvironment: "electron" | "renderer";
+const blockComponentModelMap = new Map();
+const Counters = new Map();
+const ConnStatusMap = new Map>();
+
+type GlobalInitOptions = {
+ platform: NodeJS.Platform;
+ windowId: string;
+ clientId: string;
+ environment: "electron" | "renderer";
+};
+
+function initGlobal(initOpts: GlobalInitOptions) {
+ globalEnvironment = initOpts.environment;
+ setPlatform(initOpts.platform);
+ initGlobalAtoms(initOpts);
+}
+
+function setPlatform(platform: NodeJS.Platform) {
+ PLATFORM = platform;
+}
+
+function initGlobalAtoms(initOpts: GlobalInitOptions) {
+ const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom;
+ const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom;
+ const uiContextAtom = atom((get) => {
+ const windowData = get(windowDataAtom);
+ const uiContext: UIContext = {
+ windowid: get(atoms.windowId),
+ activetabid: windowData?.activetabid,
+ };
+ return uiContext;
+ }) as Atom;
+
+ const isFullScreenAtom = atom(false) as PrimitiveAtom;
+ try {
+ getApi().onFullScreenChange((isFullScreen) => {
+ globalStore.set(isFullScreenAtom, isFullScreen);
+ });
+ } catch (_) {
+ // do nothing
+ }
+
+ const showAboutModalAtom = atom(false) as PrimitiveAtom;
+ try {
+ getApi().onMenuItemAbout(() => {
+ modalsModel.pushModal("AboutModal");
+ });
+ } catch (_) {
+ // do nothing
+ }
+
+ const clientAtom: Atom = atom((get) => {
+ const clientId = get(clientIdAtom);
+ if (clientId == null) {
+ return null;
+ }
+ return WOS.getObjectValue(WOS.makeORef("client", clientId), get);
+ });
+ const windowDataAtom: Atom = atom((get) => {
+ const windowId = get(windowIdAtom);
+ if (windowId == null) {
+ return null;
+ }
+ const rtn = WOS.getObjectValue(WOS.makeORef("window", windowId), get);
+ return rtn;
+ });
+ const workspaceAtom: Atom = atom((get) => {
+ const windowData = get(windowDataAtom);
+ if (windowData == null) {
+ return null;
+ }
+ return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
+ });
+ const fullConfigAtom = atom(null) as PrimitiveAtom;
+ const settingsAtom = atom((get) => {
+ return get(fullConfigAtom)?.settings ?? {};
+ }) as Atom;
+ const tabAtom: Atom = atom((get) => {
+ const windowData = get(windowDataAtom);
+ if (windowData == null) {
+ return null;
+ }
+ return WOS.getObjectValue(WOS.makeORef("tab", windowData.activetabid), get);
+ });
+ const activeTabIdAtom: Atom = atom((get) => {
+ const windowData = get(windowDataAtom);
+ if (windowData == null) {
+ return null;
+ }
+ return windowData.activetabid;
+ });
+ const controlShiftDelayAtom = atom(false);
+ const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom;
+ try {
+ globalStore.set(updaterStatusAtom, getApi().getUpdaterStatus());
+ getApi().onUpdaterStatusChange((status) => {
+ globalStore.set(updaterStatusAtom, status);
+ });
+ } catch (_) {
+ // do nothing
+ }
+
+ const reducedMotionSettingAtom = atom((get) => get(settingsAtom)?.["window:reducedmotion"]);
+ const reducedMotionSystemPreferenceAtom = atom(false);
+
+ // Composite of the prefers-reduced-motion media query and the window:reducedmotion user setting.
+ const prefersReducedMotionAtom = atom((get) => {
+ const reducedMotionSetting = get(reducedMotionSettingAtom);
+ const reducedMotionSystemPreference = get(reducedMotionSystemPreferenceAtom);
+ return reducedMotionSetting || reducedMotionSystemPreference;
+ });
+
+ // Set up a handler for changes to the prefers-reduced-motion media query.
+ if (globalThis.window != null) {
+ const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ globalStore.set(reducedMotionSystemPreferenceAtom, !reducedMotionQuery || reducedMotionQuery.matches);
+ reducedMotionQuery?.addEventListener("change", () => {
+ globalStore.set(reducedMotionSystemPreferenceAtom, reducedMotionQuery.matches);
+ });
+ }
+
+ const typeAheadModalAtom = atom({});
+ const modalOpen = atom(false);
+ const allConnStatusAtom = atom((get) => {
+ const connStatuses = Array.from(ConnStatusMap.values()).map((atom) => get(atom));
+ return connStatuses;
+ });
+ const flashErrorsAtom = atom([]);
+ atoms = {
+ // initialized in wave.ts (will not be null inside of application)
+ windowId: windowIdAtom,
+ clientId: clientIdAtom,
+ uiContext: uiContextAtom,
+ client: clientAtom,
+ waveWindow: windowDataAtom,
+ workspace: workspaceAtom,
+ fullConfigAtom,
+ settingsAtom,
+ tabAtom,
+ activeTabId: activeTabIdAtom,
+ isFullScreen: isFullScreenAtom,
+ controlShiftDelayAtom,
+ updaterStatusAtom,
+ prefersReducedMotionAtom,
+ typeAheadModalAtom,
+ modalOpen,
+ allConnStatus: allConnStatusAtom,
+ flashErrors: flashErrorsAtom,
+ };
+}
+
+function initGlobalWaveEventSubs() {
+ waveEventSubscribe(
+ {
+ eventType: "waveobj:update",
+ handler: (event) => {
+ // console.log("waveobj:update wave event handler", event);
+ const update: WaveObjUpdate = event.data;
+ WOS.updateWaveObject(update);
+ },
+ },
+ {
+ eventType: "config",
+ handler: (event) => {
+ // console.log("config wave event handler", event);
+ const fullConfig = (event.data as WatcherUpdate).fullconfig;
+ globalStore.set(atoms.fullConfigAtom, fullConfig);
+ },
+ },
+ {
+ eventType: "userinput",
+ handler: (event) => {
+ // console.log("userinput event handler", event);
+ const data: UserInputRequest = event.data;
+ modalsModel.pushModal("UserInputModal", { ...data });
+ },
+ },
+ {
+ eventType: "blockfile",
+ handler: (event) => {
+ // console.log("blockfile event update", event);
+ const fileData: WSFileEventData = event.data;
+ const fileSubject = getFileSubject(fileData.zoneid, fileData.filename);
+ if (fileSubject != null) {
+ fileSubject.next(fileData);
+ }
+ },
+ }
+ );
+}
+
+const blockCache = new Map>();
+
+function useBlockCache(blockId: string, name: string, makeFn: () => T): T {
+ let blockMap = blockCache.get(blockId);
+ if (blockMap == null) {
+ blockMap = new Map();
+ blockCache.set(blockId, blockMap);
+ }
+ let value = blockMap.get(name);
+ if (value == null) {
+ value = makeFn();
+ blockMap.set(name, value);
+ }
+ return value as T;
+}
+
+const settingsAtomCache = new Map>();
+
+function useSettingsKeyAtom(key: T): Atom {
+ let settingsKeyAtom = settingsAtomCache.get(key) as Atom;
+ if (settingsKeyAtom == null) {
+ settingsKeyAtom = atom((get) => {
+ const settings = get(atoms.settingsAtom);
+ if (settings == null) {
+ return null;
+ }
+ return settings[key];
+ });
+ settingsAtomCache.set(key, settingsKeyAtom);
+ }
+ return settingsKeyAtom;
+}
+
+function useSettingsPrefixAtom(prefix: string): Atom {
+ // TODO: use a shallow equal here to make this more efficient
+ let settingsPrefixAtom = settingsAtomCache.get(prefix + ":") as Atom;
+ if (settingsPrefixAtom == null) {
+ settingsPrefixAtom = atom((get) => {
+ const settings = get(atoms.settingsAtom);
+ if (settings == null) {
+ return {};
+ }
+ return getPrefixedSettings(settings, prefix);
+ });
+ settingsAtomCache.set(prefix + ":", settingsPrefixAtom);
+ }
+ return settingsPrefixAtom;
+}
+
+const blockAtomCache = new Map>>();
+
+function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom {
+ let blockCache = blockAtomCache.get(blockId);
+ if (blockCache == null) {
+ blockCache = new Map>();
+ blockAtomCache.set(blockId, blockCache);
+ }
+ let atom = blockCache.get(name);
+ if (atom == null) {
+ atom = makeFn();
+ blockCache.set(name, atom);
+ console.log("New BlockAtom", blockId, name);
+ }
+ return atom as Atom;
+}
+
+function useBlockDataLoaded(blockId: string): boolean {
+ const loadedAtom = useBlockAtom(blockId, "block-loaded", () => {
+ return WOS.getWaveObjectLoadingAtom(WOS.makeORef("block", blockId));
+ });
+ return useAtomValue(loadedAtom);
+}
+
+/**
+ * Get the preload api.
+ */
+function getApi(): ElectronApi {
+ return (window as any).api;
+}
+
+async function createBlock(blockDef: BlockDef, magnified = false): Promise {
+ const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
+ const blockId = await ObjectService.CreateBlock(blockDef, rtOpts);
+ const insertNodeAction: LayoutTreeInsertNodeAction = {
+ type: LayoutTreeActionType.InsertNode,
+ node: newLayoutNode(undefined, undefined, undefined, { blockId }),
+ magnified,
+ focused: true,
+ };
+ const activeTabId = globalStore.get(atoms.uiContext).activetabid;
+ const layoutModel = getLayoutModelForTabById(activeTabId);
+ layoutModel.treeReducer(insertNodeAction);
+ return blockId;
+}
+
+// when file is not found, returns {data: null, fileInfo: null}
+async function fetchWaveFile(
+ zoneId: string,
+ fileName: string,
+ offset?: number
+): Promise<{ data: Uint8Array; fileInfo: WaveFile }> {
+ const usp = new URLSearchParams();
+ usp.set("zoneid", zoneId);
+ usp.set("name", fileName);
+ if (offset != null) {
+ usp.set("offset", offset.toString());
+ }
+ const resp = await fetch(getWebServerEndpoint() + "/wave/file?" + usp.toString());
+ if (!resp.ok) {
+ if (resp.status === 404) {
+ return { data: null, fileInfo: null };
+ }
+ throw new Error("error getting wave file: " + resp.statusText);
+ }
+ if (resp.status == 204) {
+ return { data: null, fileInfo: null };
+ }
+ const fileInfo64 = resp.headers.get("X-ZoneFileInfo");
+ if (fileInfo64 == null) {
+ throw new Error(`missing zone file info for ${zoneId}:${fileName}`);
+ }
+ const fileInfo = JSON.parse(atob(fileInfo64));
+ const data = await resp.arrayBuffer();
+ return { data: new Uint8Array(data), fileInfo };
+}
+
+function setNodeFocus(nodeId: string) {
+ const layoutModel = getLayoutModelForActiveTab();
+ layoutModel.focusNode(nodeId);
+}
+
+const objectIdWeakMap = new WeakMap();
+let objectIdCounter = 0;
+function getObjectId(obj: any): number {
+ if (!objectIdWeakMap.has(obj)) {
+ objectIdWeakMap.set(obj, objectIdCounter++);
+ }
+ return objectIdWeakMap.get(obj);
+}
+
+let cachedIsDev: boolean = null;
+
+function isDev() {
+ if (cachedIsDev == null) {
+ cachedIsDev = getApi().getIsDev();
+ }
+ return cachedIsDev;
+}
+
+let cachedUserName: string = null;
+
+function getUserName(): string {
+ if (cachedUserName == null) {
+ cachedUserName = getApi().getUserName();
+ }
+ return cachedUserName;
+}
+
+let cachedHostName: string = null;
+
+function getHostName(): string {
+ if (cachedHostName == null) {
+ cachedHostName = getApi().getHostName();
+ }
+ return cachedHostName;
+}
+
+/**
+ * Open a link in a new window, or in a new web widget. The user can set all links to open in a new web widget using the `web:openlinksinternally` setting.
+ * @param uri The link to open.
+ * @param forceOpenInternally Force the link to open in a new web widget.
+ */
+async function openLink(uri: string, forceOpenInternally = false) {
+ if (forceOpenInternally || globalStore.get(atoms.settingsAtom)?.["web:openlinksinternally"]) {
+ const blockDef: BlockDef = {
+ meta: {
+ view: "web",
+ url: uri,
+ },
+ };
+ await createBlock(blockDef);
+ } else {
+ getApi().openExternal(uri);
+ }
+}
+
+function registerBlockComponentModel(blockId: string, bcm: BlockComponentModel) {
+ blockComponentModelMap.set(blockId, bcm);
+}
+
+function unregisterBlockComponentModel(blockId: string) {
+ blockComponentModelMap.delete(blockId);
+}
+
+function getBlockComponentModel(blockId: string): BlockComponentModel {
+ return blockComponentModelMap.get(blockId);
+}
+
+function refocusNode(blockId: string) {
+ if (blockId == null) {
+ return;
+ }
+ const layoutModel = getLayoutModelForActiveTab();
+ const layoutNodeId = layoutModel.getNodeByBlockId(blockId);
+ if (layoutNodeId?.id == null) {
+ return;
+ }
+ layoutModel.focusNode(layoutNodeId.id);
+ const bcm = getBlockComponentModel(blockId);
+ const ok = bcm?.viewModel?.giveFocus?.();
+ if (!ok) {
+ const inputElem = document.getElementById(`${blockId}-dummy-focus`);
+ inputElem?.focus();
+ }
+}
+
+function countersClear() {
+ Counters.clear();
+}
+
+function counterInc(name: string, incAmt: number = 1) {
+ let count = Counters.get(name) ?? 0;
+ count += incAmt;
+ Counters.set(name, count);
+}
+
+function countersPrint() {
+ let outStr = "";
+ for (const [name, count] of Counters.entries()) {
+ outStr += `${name}: ${count}\n`;
+ }
+ console.log(outStr);
+}
+
+async function loadConnStatus() {
+ const connStatusArr = await ClientService.GetAllConnStatus();
+ if (connStatusArr == null) {
+ return;
+ }
+ for (const connStatus of connStatusArr) {
+ const curAtom = getConnStatusAtom(connStatus.connection);
+ globalStore.set(curAtom, connStatus);
+ }
+}
+
+function subscribeToConnEvents() {
+ waveEventSubscribe({
+ eventType: "connchange",
+ handler: (event: WaveEvent) => {
+ try {
+ const connStatus = event.data as ConnStatus;
+ if (connStatus == null || isBlank(connStatus.connection)) {
+ return;
+ }
+ console.log("connstatus update", connStatus);
+ let curAtom = getConnStatusAtom(connStatus.connection);
+ globalStore.set(curAtom, connStatus);
+ } catch (e) {
+ console.log("connchange error", e);
+ }
+ },
+ });
+}
+
+function getConnStatusAtom(conn: string): PrimitiveAtom {
+ let rtn = ConnStatusMap.get(conn);
+ if (rtn == null) {
+ if (isBlank(conn)) {
+ // create a fake "local" status atom that's always connected
+ const connStatus: ConnStatus = {
+ connection: conn,
+ connected: true,
+ error: null,
+ status: "connected",
+ hasconnected: true,
+ activeconnnum: 0,
+ };
+ rtn = atom(connStatus);
+ } else {
+ const connStatus: ConnStatus = {
+ connection: conn,
+ connected: false,
+ error: null,
+ status: "disconnected",
+ hasconnected: false,
+ activeconnnum: 0,
+ };
+ rtn = atom(connStatus);
+ }
+ ConnStatusMap.set(conn, rtn);
+ }
+ return rtn;
+}
+
+function pushFlashError(ferr: FlashErrorType) {
+ if (ferr.expiration == null) {
+ ferr.expiration = Date.now() + 5000;
+ }
+ ferr.id = crypto.randomUUID();
+ globalStore.set(atoms.flashErrors, (prev) => {
+ return [...prev, ferr];
+ });
+}
+
+function removeFlashError(id: string) {
+ globalStore.set(atoms.flashErrors, (prev) => {
+ return prev.filter((ferr) => ferr.id !== id);
+ });
+}
+
+export {
+ atoms,
+ counterInc,
+ countersClear,
+ countersPrint,
+ createBlock,
+ fetchWaveFile,
+ getApi,
+ getBlockComponentModel,
+ getConnStatusAtom,
+ getHostName,
+ getObjectId,
+ getUserName,
+ globalStore,
+ initGlobal,
+ initGlobalWaveEventSubs,
+ isDev,
+ loadConnStatus,
+ openLink,
+ PLATFORM,
+ pushFlashError,
+ refocusNode,
+ registerBlockComponentModel,
+ removeFlashError,
+ setNodeFocus,
+ setPlatform,
+ subscribeToConnEvents,
+ unregisterBlockComponentModel,
+ useBlockAtom,
+ useBlockCache,
+ useBlockDataLoaded,
+ useSettingsKeyAtom,
+ useSettingsPrefixAtom,
+ WOS,
+};
diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts
new file mode 100644
index 000000000..0aafbe174
--- /dev/null
+++ b/frontend/app/store/keymodel.ts
@@ -0,0 +1,306 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { atoms, createBlock, getApi, getBlockComponentModel, globalStore, refocusNode, WOS } from "@/app/store/global";
+import * as services from "@/app/store/services";
+import {
+ deleteLayoutModelForTab,
+ getLayoutModelForActiveTab,
+ getLayoutModelForTab,
+ getLayoutModelForTabById,
+ NavigateDirection,
+} from "@/layout/index";
+import * as keyutil from "@/util/keyutil";
+import * as jotai from "jotai";
+
+const simpleControlShiftAtom = jotai.atom(false);
+const globalKeyMap = new Map boolean>();
+
+function getSimpleControlShiftAtom() {
+ return simpleControlShiftAtom;
+}
+
+function setControlShift() {
+ globalStore.set(simpleControlShiftAtom, true);
+ setTimeout(() => {
+ const simpleState = globalStore.get(simpleControlShiftAtom);
+ if (simpleState) {
+ globalStore.set(atoms.controlShiftDelayAtom, true);
+ }
+ }, 400);
+}
+
+function unsetControlShift() {
+ globalStore.set(simpleControlShiftAtom, false);
+ globalStore.set(atoms.controlShiftDelayAtom, false);
+}
+
+function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
+ if (globalStore.get(atoms.modalOpen)) {
+ return false;
+ }
+ const activeElem = document.activeElement;
+ if (activeElem != null && activeElem instanceof HTMLElement) {
+ if (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA" || activeElem.contentEditable == "true") {
+ if (activeElem.classList.contains("dummy-focus")) {
+ return true;
+ }
+ if (keyutil.isInputEvent(e)) {
+ return false;
+ }
+ return true;
+ }
+ }
+ return true;
+}
+
+function genericClose(tabId: string) {
+ const tabORef = WOS.makeORef("tab", tabId);
+ const tabAtom = WOS.getWaveObjectAtom(tabORef);
+ const tabData = globalStore.get(tabAtom);
+ if (tabData == null) {
+ return;
+ }
+ if (tabData.blockids == null || tabData.blockids.length == 0) {
+ // close tab
+ services.WindowService.CloseTab(tabId);
+ deleteLayoutModelForTab(tabId);
+ return;
+ }
+ const layoutModel = getLayoutModelForTab(tabAtom);
+ layoutModel.closeFocusedNode();
+}
+
+function switchBlockByBlockNum(index: number) {
+ const layoutModel = getLayoutModelForActiveTab();
+ if (!layoutModel) {
+ return;
+ }
+ layoutModel.switchNodeFocusByBlockNum(index);
+}
+
+function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
+ const layoutModel = getLayoutModelForTabById(tabId);
+ layoutModel.switchNodeFocusInDirection(direction);
+}
+
+function switchTabAbs(index: number) {
+ const ws = globalStore.get(atoms.workspace);
+ const newTabIdx = index - 1;
+ if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) {
+ return;
+ }
+ const newActiveTabId = ws.tabids[newTabIdx];
+ services.ObjectService.SetActiveTab(newActiveTabId);
+}
+
+function switchTab(offset: number) {
+ const ws = globalStore.get(atoms.workspace);
+ const activeTabId = globalStore.get(atoms.tabAtom).oid;
+ let tabIdx = -1;
+ for (let i = 0; i < ws.tabids.length; i++) {
+ if (ws.tabids[i] == activeTabId) {
+ tabIdx = i;
+ break;
+ }
+ }
+ if (tabIdx == -1) {
+ return;
+ }
+ const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length;
+ const newActiveTabId = ws.tabids[newTabIdx];
+ services.ObjectService.SetActiveTab(newActiveTabId);
+}
+
+function handleCmdI() {
+ const layoutModel = getLayoutModelForActiveTab();
+ const focusedNode = globalStore.get(layoutModel.focusedNode);
+ if (focusedNode == null) {
+ // focus a node
+ layoutModel.focusFirstNode();
+ return;
+ }
+ const blockId = focusedNode?.data?.blockId;
+ if (blockId == null) {
+ return;
+ }
+ refocusNode(blockId);
+}
+
+async function handleCmdN() {
+ const termBlockDef: BlockDef = {
+ meta: {
+ view: "term",
+ controller: "shell",
+ },
+ };
+ const layoutModel = getLayoutModelForActiveTab();
+ const focusedNode = globalStore.get(layoutModel.focusedNode);
+ if (focusedNode != null) {
+ const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId));
+ const blockData = globalStore.get(blockAtom);
+ if (blockData?.meta?.view == "term") {
+ if (blockData?.meta?.["cmd:cwd"] != null) {
+ termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"];
+ }
+ }
+ if (blockData?.meta?.connection != null) {
+ termBlockDef.meta.connection = blockData.meta.connection;
+ }
+ }
+ await createBlock(termBlockDef);
+}
+
+function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
+ const handled = handleGlobalWaveKeyboardEvents(waveEvent);
+ if (handled) {
+ return true;
+ }
+ const layoutModel = getLayoutModelForActiveTab();
+ const focusedNode = globalStore.get(layoutModel.focusedNode);
+ const blockId = focusedNode?.data?.blockId;
+ if (blockId != null && shouldDispatchToBlock(waveEvent)) {
+ const bcm = getBlockComponentModel(blockId);
+ if (bcm.openSwitchConnection != null) {
+ if (keyutil.checkKeyPressed(waveEvent, "Cmd:g")) {
+ bcm.openSwitchConnection();
+ return true;
+ }
+ }
+ const viewModel = bcm?.viewModel;
+ if (viewModel?.keyDownHandler) {
+ const handledByBlock = viewModel.keyDownHandler(waveEvent);
+ if (handledByBlock) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+function registerControlShiftStateUpdateHandler() {
+ getApi().onControlShiftStateUpdate((state: boolean) => {
+ if (state) {
+ setControlShift();
+ } else {
+ unsetControlShift();
+ }
+ });
+}
+
+function registerElectronReinjectKeyHandler() {
+ getApi().onReinjectKey((event: WaveKeyboardEvent) => {
+ appHandleKeyDown(event);
+ });
+}
+
+function tryReinjectKey(event: WaveKeyboardEvent): boolean {
+ return appHandleKeyDown(event);
+}
+
+function registerGlobalKeys() {
+ globalKeyMap.set("Cmd:]", () => {
+ switchTab(1);
+ return true;
+ });
+ globalKeyMap.set("Shift:Cmd:]", () => {
+ switchTab(1);
+ return true;
+ });
+ globalKeyMap.set("Cmd:[", () => {
+ switchTab(-1);
+ return true;
+ });
+ globalKeyMap.set("Shift:Cmd:[", () => {
+ switchTab(-1);
+ return true;
+ });
+ globalKeyMap.set("Cmd:n", () => {
+ handleCmdN();
+ return true;
+ });
+ globalKeyMap.set("Cmd:i", () => {
+ handleCmdI();
+ return true;
+ });
+ globalKeyMap.set("Cmd:t", () => {
+ const workspace = globalStore.get(atoms.workspace);
+ const newTabName = `T${workspace.tabids.length + 1}`;
+ services.ObjectService.AddTabToWorkspace(newTabName, true);
+ return true;
+ });
+ globalKeyMap.set("Cmd:w", () => {
+ const tabId = globalStore.get(atoms.activeTabId);
+ genericClose(tabId);
+ return true;
+ });
+ globalKeyMap.set("Cmd:m", () => {
+ const layoutModel = getLayoutModelForActiveTab();
+ const focusedNode = globalStore.get(layoutModel.focusedNode);
+ if (focusedNode != null) {
+ layoutModel.magnifyNodeToggle(focusedNode.id);
+ }
+ return true;
+ });
+ globalKeyMap.set("Ctrl:Shift:ArrowUp", () => {
+ const tabId = globalStore.get(atoms.activeTabId);
+ switchBlockInDirection(tabId, NavigateDirection.Up);
+ return true;
+ });
+ globalKeyMap.set("Ctrl:Shift:ArrowDown", () => {
+ const tabId = globalStore.get(atoms.activeTabId);
+ switchBlockInDirection(tabId, NavigateDirection.Down);
+ return true;
+ });
+ globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => {
+ const tabId = globalStore.get(atoms.activeTabId);
+ switchBlockInDirection(tabId, NavigateDirection.Left);
+ return true;
+ });
+ globalKeyMap.set("Ctrl:Shift:ArrowRight", () => {
+ const tabId = globalStore.get(atoms.activeTabId);
+ switchBlockInDirection(tabId, NavigateDirection.Right);
+ return true;
+ });
+ for (let idx = 1; idx <= 9; idx++) {
+ globalKeyMap.set(`Cmd:${idx}`, () => {
+ switchTabAbs(idx);
+ return true;
+ });
+ globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => {
+ switchBlockByBlockNum(idx);
+ return true;
+ });
+ globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => {
+ switchBlockByBlockNum(idx);
+ return true;
+ });
+ }
+ const allKeys = Array.from(globalKeyMap.keys());
+ // special case keys, handled by web view
+ allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft");
+ getApi().registerGlobalWebviewKeys(allKeys);
+}
+
+// these keyboard events happen *anywhere*, even if you have focus in an input or somewhere else.
+function handleGlobalWaveKeyboardEvents(waveEvent: WaveKeyboardEvent): boolean {
+ for (const key of globalKeyMap.keys()) {
+ if (keyutil.checkKeyPressed(waveEvent, key)) {
+ const handler = globalKeyMap.get(key);
+ if (handler == null) {
+ return false;
+ }
+ return handler(waveEvent);
+ }
+ }
+}
+
+export {
+ appHandleKeyDown,
+ getSimpleControlShiftAtom,
+ registerControlShiftStateUpdateHandler,
+ registerElectronReinjectKeyHandler,
+ registerGlobalKeys,
+ tryReinjectKey,
+ unsetControlShift,
+};
diff --git a/frontend/app/store/modalmodel.ts b/frontend/app/store/modalmodel.ts
new file mode 100644
index 000000000..8fae9cd5c
--- /dev/null
+++ b/frontend/app/store/modalmodel.ts
@@ -0,0 +1,43 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import * as jotai from "jotai";
+import { globalStore } from "./global";
+
+class ModalsModel {
+ modalsAtom: jotai.PrimitiveAtom>;
+ tosOpen: jotai.PrimitiveAtom;
+
+ constructor() {
+ this.tosOpen = jotai.atom(false);
+ this.modalsAtom = jotai.atom([]);
+ }
+
+ pushModal = (displayName: string, props?: any) => {
+ const modals = globalStore.get(this.modalsAtom);
+ globalStore.set(this.modalsAtom, [...modals, { displayName, props }]);
+ };
+
+ popModal = (callback?: () => void) => {
+ const modals = globalStore.get(this.modalsAtom);
+ if (modals.length > 0) {
+ const updatedModals = modals.slice(0, -1);
+ globalStore.set(this.modalsAtom, updatedModals);
+ if (callback) callback();
+ }
+ };
+
+ hasOpenModals(): boolean {
+ const modals = globalStore.get(this.modalsAtom);
+ return modals.length > 0;
+ }
+
+ isModalOpen(displayName: string): boolean {
+ const modals = globalStore.get(this.modalsAtom);
+ return modals.some((modal) => modal.displayName === displayName);
+ }
+}
+
+const modalsModel = new ModalsModel();
+
+export { modalsModel };
diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts
new file mode 100644
index 000000000..549ea73c2
--- /dev/null
+++ b/frontend/app/store/services.ts
@@ -0,0 +1,172 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// generated by cmd/generate/main-generatets.go
+
+import * as WOS from "./wos";
+
+// blockservice.BlockService (block)
+class BlockServiceType {
+ GetControllerStatus(arg2: string): Promise {
+ return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments))
+ }
+ SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise {
+ return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments))
+ }
+ SaveWaveAiData(arg2: string, arg3: OpenAIPromptMessageType[]): Promise {
+ return WOS.callBackendService("block", "SaveWaveAiData", Array.from(arguments))
+ }
+}
+
+export const BlockService = new BlockServiceType();
+
+// clientservice.ClientService (client)
+class ClientServiceType {
+ // @returns object updates
+ AgreeTos(): Promise {
+ return WOS.callBackendService("client", "AgreeTos", Array.from(arguments))
+ }
+ FocusWindow(arg2: string): Promise {
+ return WOS.callBackendService("client", "FocusWindow", Array.from(arguments))
+ }
+ GetAllConnStatus(): Promise {
+ return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments))
+ }
+ GetClientData(): Promise {
+ return WOS.callBackendService("client", "GetClientData", Array.from(arguments))
+ }
+ GetTab(arg1: string): Promise {
+ return WOS.callBackendService("client", "GetTab", Array.from(arguments))
+ }
+ GetWindow(arg1: string): Promise {
+ return WOS.callBackendService("client", "GetWindow", Array.from(arguments))
+ }
+ GetWorkspace(arg1: string): Promise {
+ return WOS.callBackendService("client", "GetWorkspace", Array.from(arguments))
+ }
+ MakeWindow(): Promise {
+ return WOS.callBackendService("client", "MakeWindow", Array.from(arguments))
+ }
+}
+
+export const ClientService = new ClientServiceType();
+
+// fileservice.FileService (file)
+class FileServiceType {
+ // delete file
+ DeleteFile(connection: string, path: string): Promise {
+ return WOS.callBackendService("file", "DeleteFile", Array.from(arguments))
+ }
+ GetFullConfig(): Promise {
+ return WOS.callBackendService("file", "GetFullConfig", Array.from(arguments))
+ }
+ GetWaveFile(arg1: string, arg2: string): Promise {
+ return WOS.callBackendService("file", "GetWaveFile", Array.from(arguments))
+ }
+
+ // read file
+ ReadFile(connection: string, path: string): Promise {
+ return WOS.callBackendService("file", "ReadFile", Array.from(arguments))
+ }
+
+ // save file
+ SaveFile(connection: string, path: string, data64: string): Promise {
+ return WOS.callBackendService("file", "SaveFile", Array.from(arguments))
+ }
+
+ // get file info
+ StatFile(connection: string, path: string): Promise {
+ return WOS.callBackendService("file", "StatFile", Array.from(arguments))
+ }
+}
+
+export const FileService = new FileServiceType();
+
+// objectservice.ObjectService (object)
+class ObjectServiceType {
+ // @returns tabId (and object updates)
+ AddTabToWorkspace(tabName: string, activateTab: boolean): Promise {
+ return WOS.callBackendService("object", "AddTabToWorkspace", Array.from(arguments))
+ }
+
+ // @returns blockId (and object updates)
+ CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise {
+ return WOS.callBackendService("object", "CreateBlock", Array.from(arguments))
+ }
+
+ // @returns object updates
+ DeleteBlock(blockId: string): Promise {
+ return WOS.callBackendService("object", "DeleteBlock", Array.from(arguments))
+ }
+
+ // get wave object by oref
+ GetObject(oref: string): Promise {
+ return WOS.callBackendService("object", "GetObject", Array.from(arguments))
+ }
+
+ // @returns objects
+ GetObjects(orefs: string[]): Promise {
+ return WOS.callBackendService("object", "GetObjects", Array.from(arguments))
+ }
+
+ // @returns object updates
+ SetActiveTab(tabId: string): Promise {
+ return WOS.callBackendService("object", "SetActiveTab", Array.from(arguments))
+ }
+
+ // @returns object updates
+ UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise {
+ return WOS.callBackendService("object", "UpdateObject", Array.from(arguments))
+ }
+
+ // @returns object updates
+ UpdateObjectMeta(oref: string, meta: MetaType): Promise {
+ return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments))
+ }
+
+ // @returns object updates
+ UpdateTabName(tabId: string, name: string): Promise {
+ return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments))
+ }
+
+ // @returns object updates
+ UpdateWorkspaceTabIds(workspaceId: string, tabIds: string[]): Promise {
+ return WOS.callBackendService("object", "UpdateWorkspaceTabIds", Array.from(arguments))
+ }
+}
+
+export const ObjectService = new ObjectServiceType();
+
+// userinputservice.UserInputService (userinput)
+class UserInputServiceType {
+ SendUserInputResponse(arg1: UserInputResponse): Promise {
+ return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments))
+ }
+}
+
+export const UserInputService = new UserInputServiceType();
+
+// windowservice.WindowService (window)
+class WindowServiceType {
+ // @returns object updates
+ CloseTab(arg3: string): Promise {
+ return WOS.callBackendService("window", "CloseTab", Array.from(arguments))
+ }
+ CloseWindow(arg2: string): Promise {
+ return WOS.callBackendService("window", "CloseWindow", Array.from(arguments))
+ }
+
+ // move block to new window
+ // @returns object updates
+ MoveBlockToNewWindow(currentTabId: string, blockId: string): Promise {
+ return WOS.callBackendService("window", "MoveBlockToNewWindow", Array.from(arguments))
+ }
+
+ // @returns object updates
+ SetWindowPosAndSize(arg2: string, arg3: Point, arg4: WinSize): Promise {
+ return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments))
+ }
+}
+
+export const WindowService = new WindowServiceType();
+
diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts
new file mode 100644
index 000000000..eb1b6bc4b
--- /dev/null
+++ b/frontend/app/store/wos.ts
@@ -0,0 +1,297 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// WaveObjectStore
+
+import { getWebServerEndpoint } from "@/util/endpoints";
+import { fetch } from "@/util/fetchutil";
+import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai";
+import { useEffect } from "react";
+import { atoms, globalStore } from "./global";
+import { ObjectService } from "./services";
+
+type WaveObjectDataItemType = {
+ value: T;
+ loading: boolean;
+};
+
+type WaveObjectValue = {
+ pendingPromise: Promise;
+ dataAtom: PrimitiveAtom>;
+ refCount: number;
+ holdTime: number;
+};
+
+function splitORef(oref: string): [string, string] {
+ const parts = oref.split(":");
+ if (parts.length != 2) {
+ throw new Error("invalid oref");
+ }
+ return [parts[0], parts[1]];
+}
+
+function isBlank(str: string): boolean {
+ return str == null || str == "";
+}
+
+function isBlankNum(num: number): boolean {
+ return num == null || isNaN(num) || num == 0;
+}
+
+function isValidWaveObj(val: WaveObj): boolean {
+ if (val == null) {
+ return false;
+ }
+ if (isBlank(val.otype) || isBlank(val.oid) || isBlankNum(val.version)) {
+ return false;
+ }
+ return true;
+}
+
+function makeORef(otype: string, oid: string): string {
+ if (isBlank(otype) || isBlank(oid)) {
+ return null;
+ }
+ return `${otype}:${oid}`;
+}
+
+function GetObject(oref: string): Promise {
+ return callBackendService("object", "GetObject", [oref], true);
+}
+
+function debugLogBackendCall(methodName: string, durationStr: string, args: any[]) {
+ durationStr = "| " + durationStr;
+ if (methodName == "object.UpdateObject" && args.length > 0) {
+ console.log("[service] object.UpdateObject", args[0].otype, args[0].oid, durationStr, args[0]);
+ return;
+ }
+ if (methodName == "object.GetObject" && args.length > 0) {
+ console.log("[service] object.GetObject", args[0], durationStr);
+ return;
+ }
+ if (methodName == "file.StatFile" && args.length >= 2) {
+ console.log("[service] file.StatFile", args[1], durationStr);
+ return;
+ }
+ console.log("[service]", methodName, durationStr);
+}
+
+function callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise {
+ const startTs = Date.now();
+ let uiContext: UIContext = null;
+ if (!noUIContext) {
+ uiContext = globalStore.get(atoms.uiContext);
+ }
+ const waveCall: WebCallType = {
+ service: service,
+ method: method,
+ args: args,
+ uicontext: uiContext,
+ };
+ // usp is just for debugging (easier to filter URLs)
+ const methodName = `${service}.${method}`;
+ const usp = new URLSearchParams();
+ usp.set("service", service);
+ usp.set("method", method);
+ const url = getWebServerEndpoint() + "/wave/service?" + usp.toString();
+ const fetchPromise = fetch(url, {
+ method: "POST",
+ body: JSON.stringify(waveCall),
+ });
+ const prtn = fetchPromise
+ .then((resp) => {
+ if (!resp.ok) {
+ throw new Error(`call ${methodName} failed: ${resp.status} ${resp.statusText}`);
+ }
+ return resp.json();
+ })
+ .then((respData: WebReturnType) => {
+ if (respData == null) {
+ return null;
+ }
+ if (respData.updates != null) {
+ updateWaveObjects(respData.updates);
+ }
+ if (respData.error != null) {
+ throw new Error(`call ${methodName} error: ${respData.error}`);
+ }
+ const durationStr = Date.now() - startTs + "ms";
+ debugLogBackendCall(methodName, durationStr, args);
+ return respData.data;
+ });
+ return prtn;
+}
+
+const waveObjectValueCache = new Map>();
+
+function clearWaveObjectCache() {
+ waveObjectValueCache.clear();
+}
+
+const defaultHoldTime = 5000; // 5-seconds
+
+function createWaveValueObject(oref: string, shouldFetch: boolean): WaveObjectValue {
+ const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 };
+ wov.dataAtom = atom({ value: null, loading: true });
+ if (!shouldFetch) {
+ return wov;
+ }
+ const startTs = Date.now();
+ const localPromise = GetObject(oref);
+ wov.pendingPromise = localPromise;
+ localPromise.then((val) => {
+ if (wov.pendingPromise != localPromise) {
+ return;
+ }
+ const [otype, oid] = splitORef(oref);
+ if (val != null) {
+ if (val["otype"] != otype) {
+ throw new Error("GetObject returned wrong type");
+ }
+ if (val["oid"] != oid) {
+ throw new Error("GetObject returned wrong id");
+ }
+ }
+ wov.pendingPromise = null;
+ globalStore.set(wov.dataAtom, { value: val, loading: false });
+ console.log("WaveObj resolved", oref, Date.now() - startTs + "ms");
+ });
+ return wov;
+}
+
+function getWaveObjectValue(oref: string, createIfMissing = true): WaveObjectValue {
+ let wov = waveObjectValueCache.get(oref);
+ if (wov === undefined && createIfMissing) {
+ wov = createWaveValueObject(oref, true);
+ waveObjectValueCache.set(oref, wov);
+ }
+ return wov;
+}
+
+function loadAndPinWaveObject(oref: string): Promise {
+ const wov = getWaveObjectValue(oref);
+ wov.refCount++;
+ if (wov.pendingPromise == null) {
+ const dataValue = globalStore.get(wov.dataAtom);
+ return Promise.resolve(dataValue.value);
+ }
+ return wov.pendingPromise;
+}
+
+function getWaveObjectAtom(oref: string): WritableWaveObjectAtom {
+ const wov = getWaveObjectValue(oref);
+ return atom(
+ (get) => get(wov.dataAtom).value,
+ (_get, set, value: T) => {
+ setObjectValue(value, set, true);
+ }
+ );
+}
+
+function getWaveObjectLoadingAtom(oref: string): Atom {
+ const wov = getWaveObjectValue(oref);
+ return atom((get) => {
+ const dataValue = get(wov.dataAtom);
+ if (dataValue.loading) {
+ return null;
+ }
+ return dataValue.loading;
+ });
+}
+
+function useWaveObjectValue(oref: string): [T, boolean] {
+ const wov = getWaveObjectValue(oref);
+ useEffect(() => {
+ wov.refCount++;
+ return () => {
+ wov.refCount--;
+ };
+ }, [oref]);
+ const atomVal = useAtomValue(wov.dataAtom);
+ return [atomVal.value, atomVal.loading];
+}
+
+function updateWaveObject(update: WaveObjUpdate) {
+ if (update == null) {
+ return;
+ }
+ const oref = makeORef(update.otype, update.oid);
+ const wov = getWaveObjectValue(oref);
+ if (update.updatetype == "delete") {
+ console.log("WaveObj deleted", oref);
+ globalStore.set(wov.dataAtom, { value: null, loading: false });
+ } else {
+ if (!isValidWaveObj(update.obj)) {
+ console.log("invalid wave object update", update);
+ return;
+ }
+ const curValue: WaveObjectDataItemType = globalStore.get(wov.dataAtom);
+ if (curValue.value != null && curValue.value.version >= update.obj.version) {
+ return;
+ }
+ console.log("WaveObj updated", oref);
+ globalStore.set(wov.dataAtom, { value: update.obj, loading: false });
+ }
+ wov.holdTime = Date.now() + defaultHoldTime;
+ return;
+}
+
+function updateWaveObjects(vals: WaveObjUpdate[]) {
+ for (const val of vals) {
+ updateWaveObject(val);
+ }
+}
+
+function cleanWaveObjectCache() {
+ const now = Date.now();
+ for (const [oref, wov] of waveObjectValueCache) {
+ if (wov.refCount == 0 && wov.holdTime < now) {
+ waveObjectValueCache.delete(oref);
+ }
+ }
+}
+
+// gets the value of a WaveObject from the cache.
+// should provide getFn if it is available (e.g. inside of a jotai atom)
+// otherwise it will use the globalStore.get function
+function getObjectValue(oref: string, getFn?: Getter): T {
+ const wov = getWaveObjectValue(oref);
+ if (getFn == null) {
+ getFn = globalStore.get;
+ }
+ const atomVal = getFn(wov.dataAtom);
+ return atomVal.value;
+}
+
+// sets the value of a WaveObject in the cache.
+// should provide setFn if it is available (e.g. inside of a jotai atom)
+// otherwise it will use the globalStore.set function
+function setObjectValue(value: T, setFn?: Setter, pushToServer?: boolean) {
+ const oref = makeORef(value.otype, value.oid);
+ const wov = getWaveObjectValue(oref, false);
+ if (wov === undefined) {
+ return;
+ }
+ if (setFn === undefined) {
+ setFn = globalStore.set;
+ }
+ setFn(wov.dataAtom, { value: value, loading: false });
+ if (pushToServer) {
+ ObjectService.UpdateObject(value, false);
+ }
+}
+
+export {
+ callBackendService,
+ cleanWaveObjectCache,
+ clearWaveObjectCache,
+ getObjectValue,
+ getWaveObjectAtom,
+ getWaveObjectLoadingAtom,
+ loadAndPinWaveObject,
+ makeORef,
+ setObjectValue,
+ updateWaveObject,
+ updateWaveObjects,
+ useWaveObjectValue,
+};
diff --git a/frontend/app/store/wps.ts b/frontend/app/store/wps.ts
new file mode 100644
index 000000000..28dc2cf55
--- /dev/null
+++ b/frontend/app/store/wps.ts
@@ -0,0 +1,143 @@
+import { isBlank } from "@/util/util";
+import { Subject } from "rxjs";
+import { sendRawRpcMessage } from "./wshrpcutil";
+
+type WaveEventSubject = {
+ handler: (event: WaveEvent) => void;
+ scope?: string;
+};
+
+type WaveEventSubjectContainer = WaveEventSubject & {
+ id: string;
+};
+
+type WaveEventSubscription = WaveEventSubject & {
+ eventType: string;
+};
+
+type WaveEventUnsubscribe = {
+ id: string;
+ eventType: string;
+};
+
+// key is "eventType" or "eventType|oref"
+const fileSubjects = new Map>();
+const waveEventSubjects = new Map();
+
+function wpsReconnectHandler() {
+ for (const eventType of waveEventSubjects.keys()) {
+ updateWaveEventSub(eventType);
+ }
+}
+
+function makeWaveReSubCommand(eventType: string): RpcMessage {
+ let subjects = waveEventSubjects.get(eventType);
+ if (subjects == null) {
+ return { command: "eventunsub", data: eventType };
+ }
+ let subreq: SubscriptionRequest = { event: eventType, scopes: [], allscopes: false };
+ for (const scont of subjects) {
+ if (isBlank(scont.scope)) {
+ subreq.allscopes = true;
+ subreq.scopes = [];
+ break;
+ }
+ subreq.scopes.push(scont.scope);
+ }
+ return { command: "eventsub", data: subreq };
+}
+
+function updateWaveEventSub(eventType: string) {
+ const command = makeWaveReSubCommand(eventType);
+ // console.log("updateWaveEventSub", eventType, command);
+ sendRawRpcMessage(command);
+}
+
+function waveEventSubscribe(...subscriptions: WaveEventSubscription[]): () => void {
+ const unsubs: WaveEventUnsubscribe[] = [];
+ const eventTypeSet = new Set();
+ for (const subscription of subscriptions) {
+ // console.log("waveEventSubscribe", subscription);
+ if (subscription.handler == null) {
+ return;
+ }
+ const id: string = crypto.randomUUID();
+ let subjects = waveEventSubjects.get(subscription.eventType);
+ if (subjects == null) {
+ subjects = [];
+ waveEventSubjects.set(subscription.eventType, subjects);
+ }
+ const subcont: WaveEventSubjectContainer = { id, handler: subscription.handler, scope: subscription.scope };
+ subjects.push(subcont);
+ unsubs.push({ id, eventType: subscription.eventType });
+ eventTypeSet.add(subscription.eventType);
+ }
+ for (const eventType of eventTypeSet) {
+ updateWaveEventSub(eventType);
+ }
+ return () => waveEventUnsubscribe(...unsubs);
+}
+
+function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) {
+ const eventTypeSet = new Set();
+ for (const unsubscribe of unsubscribes) {
+ let subjects = waveEventSubjects.get(unsubscribe.eventType);
+ if (subjects == null) {
+ return;
+ }
+ const idx = subjects.findIndex((s) => s.id === unsubscribe.id);
+ if (idx === -1) {
+ return;
+ }
+ subjects.splice(idx, 1);
+ if (subjects.length === 0) {
+ waveEventSubjects.delete(unsubscribe.eventType);
+ }
+ eventTypeSet.add(unsubscribe.eventType);
+ }
+
+ for (const eventType of eventTypeSet) {
+ updateWaveEventSub(eventType);
+ }
+}
+
+function getFileSubject(zoneId: string, fileName: string): SubjectWithRef {
+ const subjectKey = zoneId + "|" + fileName;
+ let subject = fileSubjects.get(subjectKey);
+ if (subject == null) {
+ subject = new Subject() as any;
+ subject.refCount = 0;
+ subject.release = () => {
+ subject.refCount--;
+ if (subject.refCount === 0) {
+ subject.complete();
+ fileSubjects.delete(subjectKey);
+ }
+ };
+ fileSubjects.set(subjectKey, subject);
+ }
+ subject.refCount++;
+ return subject;
+}
+
+function handleWaveEvent(event: WaveEvent) {
+ // console.log("handleWaveEvent", event);
+ const subjects = waveEventSubjects.get(event.event);
+ if (subjects == null) {
+ return;
+ }
+ for (const scont of subjects) {
+ if (isBlank(scont.scope)) {
+ scont.handler(event);
+ continue;
+ }
+ if (event.scopes == null) {
+ continue;
+ }
+ if (event.scopes.includes(scont.scope)) {
+ scont.handler(event);
+ }
+ }
+}
+
+export { getFileSubject, handleWaveEvent, waveEventSubscribe, waveEventUnsubscribe, wpsReconnectHandler };
diff --git a/frontend/app/store/ws.ts b/frontend/app/store/ws.ts
new file mode 100644
index 000000000..5062af8f6
--- /dev/null
+++ b/frontend/app/store/ws.ts
@@ -0,0 +1,210 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import debug from "debug";
+import { sprintf } from "sprintf-js";
+import type { WebSocket as ElectronWebSocketType } from "ws";
+
+let ElectronWebSocket: typeof ElectronWebSocketType;
+const AuthKeyHeader = "X-AuthKey";
+
+if (typeof window === "undefined") {
+ try {
+ const WebSocket = require("ws") as typeof ElectronWebSocketType;
+ ElectronWebSocket = WebSocket;
+ } catch (e) {}
+}
+
+const dlog = debug("wave:ws");
+
+const WarnWebSocketSendSize = 1024 * 1024; // 1MB
+const MaxWebSocketSendSize = 5 * 1024 * 1024; // 5MB
+const reconnectHandlers: (() => void)[] = [];
+
+function addWSReconnectHandler(handler: () => void) {
+ reconnectHandlers.push(handler);
+}
+
+function removeWSReconnectHandler(handler: () => void) {
+ const index = this.reconnectHandlers.indexOf(handler);
+ if (index > -1) {
+ reconnectHandlers.splice(index, 1);
+ }
+}
+
+type WSEventCallback = (arg0: WSEventType) => void;
+
+class WSControl {
+ wsConn: WebSocket | ElectronWebSocketType;
+ open: boolean;
+ opening: boolean = false;
+ reconnectTimes: number = 0;
+ msgQueue: any[] = [];
+ windowId: string;
+ messageCallback: WSEventCallback;
+ watchSessionId: string = null;
+ watchScreenId: string = null;
+ wsLog: string[] = [];
+ baseHostPort: string;
+ lastReconnectTime: number = 0;
+ authKey: string = null; // used only by electron
+
+ constructor(baseHostPort: string, windowId: string, messageCallback: WSEventCallback, authKey?: string) {
+ this.baseHostPort = baseHostPort;
+ this.messageCallback = messageCallback;
+ this.windowId = windowId;
+ this.open = false;
+ this.authKey = authKey;
+ setInterval(this.sendPing.bind(this), 5000);
+ }
+
+ connectNow(desc: string) {
+ if (this.open) {
+ return;
+ }
+ this.lastReconnectTime = Date.now();
+ dlog("try reconnect:", desc);
+ this.opening = true;
+ if (ElectronWebSocket) {
+ this.wsConn = new ElectronWebSocket(this.baseHostPort + "/ws?windowid=" + this.windowId, {
+ headers: { [AuthKeyHeader]: this.authKey },
+ });
+ } else {
+ this.wsConn = new WebSocket(this.baseHostPort + "/ws?windowid=" + this.windowId);
+ }
+ this.wsConn.onopen = this.onopen.bind(this);
+ this.wsConn.onmessage = this.onmessage.bind(this);
+ this.wsConn.onclose = this.onclose.bind(this);
+ // turns out onerror is not necessary (onclose always follows onerror)
+ // this.wsConn.onerror = this.onerror;
+ }
+
+ reconnect(forceClose?: boolean) {
+ if (this.open) {
+ if (forceClose) {
+ this.wsConn.close(); // this will force a reconnect
+ }
+ return;
+ }
+ this.reconnectTimes++;
+ if (this.reconnectTimes > 20) {
+ dlog("cannot connect, giving up");
+ return;
+ }
+ const timeoutArr = [0, 0, 2, 5, 10, 10, 30, 60];
+ let timeout = 60;
+ if (this.reconnectTimes < timeoutArr.length) {
+ timeout = timeoutArr[this.reconnectTimes];
+ }
+ if (Date.now() - this.lastReconnectTime < 500) {
+ timeout = 1;
+ }
+ if (timeout > 0) {
+ dlog(sprintf("sleeping %ds", timeout));
+ }
+ setTimeout(() => {
+ this.connectNow(String(this.reconnectTimes));
+ }, timeout * 1000);
+ }
+
+ onclose(event: any) {
+ // console.log("close", event);
+ if (event.wasClean) {
+ dlog("connection closed");
+ } else {
+ dlog("connection error/disconnected");
+ }
+ if (this.open || this.opening) {
+ this.open = false;
+ this.opening = false;
+ this.reconnect();
+ }
+ }
+
+ onopen() {
+ dlog("connection open");
+ this.open = true;
+ this.opening = false;
+ for (let handler of reconnectHandlers) {
+ handler();
+ }
+ this.runMsgQueue();
+ // reconnectTimes is reset in onmessage:hello
+ }
+
+ runMsgQueue() {
+ if (!this.open) {
+ return;
+ }
+ if (this.msgQueue.length == 0) {
+ return;
+ }
+ const msg = this.msgQueue.shift();
+ this.sendMessage(msg);
+ setTimeout(() => {
+ this.runMsgQueue();
+ }, 100);
+ }
+
+ onmessage(event: any) {
+ let eventData = null;
+ if (event.data != null) {
+ eventData = JSON.parse(event.data);
+ }
+ if (eventData == null) {
+ return;
+ }
+ if (eventData.type == "ping") {
+ this.wsConn.send(JSON.stringify({ type: "pong", stime: Date.now() }));
+ return;
+ }
+ if (eventData.type == "pong") {
+ // nothing
+ return;
+ }
+ if (eventData.type == "hello") {
+ this.reconnectTimes = 0;
+ return;
+ }
+ if (this.messageCallback) {
+ try {
+ this.messageCallback(eventData);
+ } catch (e) {
+ console.log("[error] messageCallback", e);
+ }
+ }
+ }
+
+ sendPing() {
+ if (!this.open) {
+ return;
+ }
+ this.wsConn.send(JSON.stringify({ type: "ping", stime: Date.now() }));
+ }
+
+ sendMessage(data: WSCommandType) {
+ if (!this.open) {
+ return;
+ }
+ const msg = JSON.stringify(data);
+ const byteSize = new Blob([msg]).size;
+ if (byteSize > MaxWebSocketSendSize) {
+ console.log("ws message too large", byteSize, data.wscommand, msg.substring(0, 100));
+ return;
+ }
+ if (byteSize > WarnWebSocketSendSize) {
+ console.log("ws message large", byteSize, data.wscommand, msg.substring(0, 100));
+ }
+ this.wsConn.send(msg);
+ }
+
+ pushMessage(data: WSCommandType) {
+ if (!this.open) {
+ this.msgQueue.push(data);
+ return;
+ }
+ this.sendMessage(data);
+ }
+}
+
+export { addWSReconnectHandler, removeWSReconnectHandler, WSControl };
diff --git a/frontend/app/store/wshclient.ts b/frontend/app/store/wshclient.ts
new file mode 100644
index 000000000..93e7a7e38
--- /dev/null
+++ b/frontend/app/store/wshclient.ts
@@ -0,0 +1,155 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { sendRpcCommand, sendRpcResponse } from "@/app/store/wshrpcutil";
+import * as util from "@/util/util";
+
+const notFoundLogMap = new Map();
+
+class RpcResponseHelper {
+ client: WshClient;
+ cmdMsg: RpcMessage;
+ done: boolean;
+
+ constructor(client: WshClient, cmdMsg: RpcMessage) {
+ this.client = client;
+ this.cmdMsg = cmdMsg;
+ // if reqid is null, no response required
+ this.done = cmdMsg.reqid == null;
+ }
+
+ sendResponse(msg: RpcMessage) {
+ if (this.done || util.isBlank(this.cmdMsg.reqid)) {
+ return;
+ }
+ if (msg == null) {
+ msg = {};
+ }
+ msg.resid = this.cmdMsg.reqid;
+ msg.source = this.client.routeId;
+ sendRpcResponse(msg);
+ if (!msg.cont) {
+ this.done = true;
+ this.client.openRpcs.delete(this.cmdMsg.reqid);
+ }
+ }
+}
+
+class WshClient {
+ routeId: string;
+ openRpcs: Map = new Map();
+
+ constructor(routeId: string) {
+ this.routeId = routeId;
+ }
+
+ wshRpcCall(command: string, data: any, opts: RpcOpts): Promise {
+ const msg: RpcMessage = {
+ command: command,
+ data: data,
+ source: this.routeId,
+ };
+ if (!opts?.noresponse) {
+ msg.reqid = crypto.randomUUID();
+ }
+ if (opts?.timeout) {
+ msg.timeout = opts.timeout;
+ }
+ if (opts?.route) {
+ msg.route = opts.route;
+ }
+ const rpcGen = sendRpcCommand(this.openRpcs, msg);
+ if (rpcGen == null) {
+ return null;
+ }
+ const respMsgPromise = rpcGen.next(true); // pass true to force termination of rpc after 1 response (not streaming)
+ return respMsgPromise.then((msg: IteratorResult) => {
+ return msg.value;
+ });
+ }
+
+ wshRpcStream(command: string, data: any, opts: RpcOpts): AsyncGenerator {
+ if (opts?.noresponse) {
+ throw new Error("noresponse not supported for responsestream calls");
+ }
+ const msg: RpcMessage = {
+ command: command,
+ data: data,
+ reqid: crypto.randomUUID(),
+ source: this.routeId,
+ };
+ if (opts?.timeout) {
+ msg.timeout = opts.timeout;
+ }
+ if (opts?.route) {
+ msg.route = opts.route;
+ }
+ const rpcGen = sendRpcCommand(this.openRpcs, msg);
+ return rpcGen;
+ }
+
+ async handleIncomingCommand(msg: RpcMessage) {
+ // TODO implement a timeout (setTimeout + sendResponse)
+ const helper = new RpcResponseHelper(this, msg);
+ const handlerName = `handle_${msg.command}`;
+ try {
+ let result: any = null;
+ let prtn: any = null;
+ if (handlerName in this) {
+ prtn = this[handlerName](helper, msg.data);
+ } else {
+ prtn = this.handle_default(helper, msg);
+ }
+ if (prtn instanceof Promise) {
+ result = await prtn;
+ } else {
+ result = prtn;
+ }
+ if (!helper.done) {
+ helper.sendResponse({ data: result });
+ }
+ } catch (e) {
+ if (!helper.done) {
+ helper.sendResponse({ error: e.message });
+ } else {
+ console.log(`rpc-client[${this.routeId}] command[${msg.command}] error`, e.message);
+ }
+ } finally {
+ if (!helper.done) {
+ helper.sendResponse({});
+ }
+ }
+ return;
+ }
+
+ recvRpcMessage(msg: RpcMessage) {
+ const isRequest = msg.command != null || msg.reqid != null;
+ if (isRequest) {
+ this.handleIncomingCommand(msg);
+ return;
+ }
+ if (msg.resid == null) {
+ console.log("rpc response missing resid", msg);
+ return;
+ }
+ const entry = this.openRpcs.get(msg.resid);
+ if (entry == null) {
+ if (!notFoundLogMap.has(msg.resid)) {
+ notFoundLogMap.set(msg.resid, true);
+ console.log("rpc response generator not found", msg);
+ }
+ return;
+ }
+ entry.msgFn(msg);
+ }
+
+ async handle_message(helper: RpcResponseHelper, data: CommandMessageData): Promise {
+ console.log(`rpc:message[${this.routeId}]`, data?.message);
+ }
+
+ async handle_default(helper: RpcResponseHelper, msg: RpcMessage): Promise {
+ throw new Error(`rpc command "${msg.command}" not supported by [${this.routeId}]`);
+ }
+}
+
+export { RpcResponseHelper, WshClient };
diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts
new file mode 100644
index 000000000..5d7f194cd
--- /dev/null
+++ b/frontend/app/store/wshclientapi.ts
@@ -0,0 +1,222 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// generated by cmd/generate/main-generatets.go
+
+import { WshClient } from "./wshclient";
+
+// WshServerCommandToDeclMap
+class RpcApiType {
+ // command "authenticate" [call]
+ AuthenticateCommand(client: WshClient, data: string, opts?: RpcOpts): Promise {
+ return client.wshRpcCall("authenticate", data, opts);
+ }
+
+ // command "blockinfo" [call]
+ BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise