mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
Merge remote-tracking branch 'thenextwave/main' into wave8
This commit is contained in:
commit
14f0223277
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@ -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
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=lf
|
31
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
@ -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.
|
14
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/feature-request.md
vendored
Normal file
@ -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.
|
39
.github/dependabot.yml
vendored
Normal file
39
.github/dependabot.yml
vendored
Normal file
@ -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"
|
151
.github/workflows/build-helper.yml
vendored
Normal file
151
.github/workflows/build-helper.yml
vendored
Normal file
@ -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
|
83
.github/workflows/bump-version.yml
vendored
Normal file
83
.github/workflows/bump-version.yml
vendored
Normal file
@ -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 }}
|
114
.github/workflows/codeql.yml
vendored
Normal file
114
.github/workflows/codeql.yml
vendored
Normal file
@ -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}}"
|
25
.github/workflows/publish-release.yml
vendored
Normal file
25
.github/workflows/publish-release.yml
vendored
Normal file
@ -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
|
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -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
|
8
.prettierignore
Normal file
8
.prettierignore
Normal file
@ -0,0 +1,8 @@
|
||||
build
|
||||
bin
|
||||
.git
|
||||
frontend/dist
|
||||
frontend/node_modules
|
||||
*.min.*
|
||||
frontend/app/store/services.ts
|
||||
frontend/types/gotypes.d.ts
|
17
.storybook/global.css
Normal file
17
.storybook/global.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
41
.storybook/main.ts
Normal file
41
.storybook/main.ts
Normal file
@ -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}
|
||||
<meta name="robots" content="noindex" />
|
||||
`,
|
||||
|
||||
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;
|
29
.storybook/preview.tsx
Normal file
29
.storybook/preview.tsx
Normal file
@ -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) => (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Story />
|
||||
</DndProvider>
|
||||
),
|
||||
],
|
||||
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default preview;
|
9
.vscode/extensions.json
vendored
Normal file
9
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"golang.go",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"vitest.explorer",
|
||||
"task.vscode-task"
|
||||
]
|
||||
}
|
40
.vscode/settings.json
vendored
Normal file
40
.vscode/settings.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
18
.vscode/tasks.json
vendored
Normal file
18
.vscode/tasks.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@ -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.
|
47
CONTRIBUTING.md
Normal file
47
CONTRIBUTING.md
Normal file
@ -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
|
201
LICENSE
Normal file
201
LICENSE
Normal file
@ -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.
|
65
README.md
Normal file
65
README.md
Normal file
@ -0,0 +1,65 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./assets/waveterm-logo-horizontal-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./assets/waveterm-logo-horizontal-light.png">
|
||||
<img alt="Wave Terminal Logo" src="./assets/waveterm-logo-horizontal-light.png" width="240" height="80" style="max-width: 100%;">
|
||||
</picture>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
# 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).
|
64
RELEASES.md
Normal file
64
RELEASES.md
Normal file
@ -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/<version>`. 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:<download or publish>:<version> -- --profile <aws-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`.
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@ -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. **
|
274
Taskfile.yml
Normal file
274
Taskfile.yml
Normal file
@ -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
|
BIN
assets/wave-screenshot.png
Normal file
BIN
assets/wave-screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
BIN
build/appicon.png
Normal file
BIN
build/appicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
build/icons.icns
Normal file
BIN
build/icons.icns
Normal file
Binary file not shown.
79
cmd/generatego/main-generatego.go
Normal file
79
cmd/generatego/main-generatego.go
Normal file
@ -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()
|
||||
}
|
132
cmd/generatets/main-generatets.go
Normal file
132
cmd/generatets/main-generatets.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
286
cmd/server/main-server.go
Normal file
286
cmd/server/main-server.go
Normal file
@ -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)
|
||||
}
|
55
cmd/test/test-main.go
Normal file
55
cmd/test/test-main.go
Normal file
@ -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(`<div>clicked</div>`, nil)
|
||||
}
|
||||
clickFn := func() {
|
||||
log.Printf("run clickFn\n")
|
||||
setClicked(true)
|
||||
}
|
||||
return vdom.Bind(
|
||||
`
|
||||
<div>
|
||||
<h1>hello world</h1>
|
||||
<Button onClick="#bind:clickFn">hello</Button>
|
||||
<bind key="clickedDiv"/>
|
||||
</div>
|
||||
`,
|
||||
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(`
|
||||
<div className="#bind:clName" ref="#bind:ref" onClick="#bind:onClick">
|
||||
<bind key="children"/>
|
||||
</div>
|
||||
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]})
|
||||
}
|
||||
|
||||
func main() {
|
||||
wshutil.SetTermRawModeAndInstallShutdownHandlers(true)
|
||||
defer wshutil.RestoreTermState()
|
||||
}
|
132
cmd/wsh/cmd/wshcmd-conn.go
Normal file
132
cmd/wsh/cmd/wshcmd-conn.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
32
cmd/wsh/cmd/wshcmd-connserver.go
Normal file
32
cmd/wsh/cmd/wshcmd-connserver.go
Normal file
@ -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
|
||||
}
|
52
cmd/wsh/cmd/wshcmd-deleteblock.go
Normal file
52
cmd/wsh/cmd/wshcmd-deleteblock.go
Normal file
@ -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")
|
||||
}
|
75
cmd/wsh/cmd/wshcmd-editor.go
Normal file
75
cmd/wsh/cmd/wshcmd-editor.go
Normal file
@ -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
|
||||
}
|
68
cmd/wsh/cmd/wshcmd-getmeta.go
Normal file
68
cmd/wsh/cmd/wshcmd-getmeta.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
43
cmd/wsh/cmd/wshcmd-html.go
Normal file
43
cmd/wsh/cmd/wshcmd-html.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
34
cmd/wsh/cmd/wshcmd-rcfiles.go
Normal file
34
cmd/wsh/cmd/wshcmd-rcfiles.go
Normal file
@ -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
|
||||
}
|
||||
},
|
||||
}
|
53
cmd/wsh/cmd/wshcmd-readfile.go
Normal file
53
cmd/wsh/cmd/wshcmd-readfile.go
Normal file
@ -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))
|
||||
}
|
182
cmd/wsh/cmd/wshcmd-root.go
Normal file
182
cmd/wsh/cmd/wshcmd-root.go
Normal file
@ -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
|
||||
}
|
||||
}
|
39
cmd/wsh/cmd/wshcmd-setconfig.go
Normal file
39
cmd/wsh/cmd/wshcmd-setconfig.go
Normal file
@ -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")
|
||||
}
|
93
cmd/wsh/cmd/wshcmd-setmeta.go
Normal file
93
cmd/wsh/cmd/wshcmd-setmeta.go
Normal file
@ -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")
|
||||
}
|
62
cmd/wsh/cmd/wshcmd-shell-unix.go
Normal file
62
cmd/wsh/cmd/wshcmd-shell-unix.go
Normal file
@ -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"
|
||||
}
|
27
cmd/wsh/cmd/wshcmd-shell-win.go
Normal file
27
cmd/wsh/cmd/wshcmd-shell-win.go
Normal file
@ -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")
|
||||
}
|
44
cmd/wsh/cmd/wshcmd-ssh.go
Normal file
44
cmd/wsh/cmd/wshcmd-ssh.go
Normal file
@ -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)
|
||||
}
|
70
cmd/wsh/cmd/wshcmd-term.go
Normal file
70
cmd/wsh/cmd/wshcmd-term.go
Normal file
@ -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)
|
||||
}
|
23
cmd/wsh/cmd/wshcmd-version.go
Normal file
23
cmd/wsh/cmd/wshcmd-version.go
Normal file
@ -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))
|
||||
},
|
||||
}
|
91
cmd/wsh/cmd/wshcmd-view.go
Normal file
91
cmd/wsh/cmd/wshcmd-view.go
Normal file
@ -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
|
||||
}
|
||||
}
|
120
cmd/wsh/cmd/wshcmd-web.go
Normal file
120
cmd/wsh/cmd/wshcmd-web.go
Normal file
@ -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
|
||||
}
|
19
cmd/wsh/main-wsh.go
Normal file
19
cmd/wsh/main-wsh.go
Normal file
@ -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()
|
||||
}
|
12
db/db.go
Normal file
12
db/db.go
Normal file
@ -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
|
3
db/migrations-filestore/000001_init.down.sql
Normal file
3
db/migrations-filestore/000001_init.down.sql
Normal file
@ -0,0 +1,3 @@
|
||||
DROP TABLE db_wave_file;
|
||||
|
||||
DROP TABLE db_file_data;
|
19
db/migrations-filestore/000001_init.up.sql
Normal file
19
db/migrations-filestore/000001_init.up.sql
Normal file
@ -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)
|
||||
);
|
||||
|
7
db/migrations-wstore/000001_init.down.sql
Normal file
7
db/migrations-wstore/000001_init.down.sql
Normal file
@ -0,0 +1,7 @@
|
||||
DROP TABLE db_client;
|
||||
|
||||
DROP TABLE db_workspace;
|
||||
|
||||
DROP TABLE db_tab;
|
||||
|
||||
DROP TABLE db_block;
|
30
db/migrations-wstore/000001_init.up.sql
Normal file
30
db/migrations-wstore/000001_init.up.sql
Normal file
@ -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
|
||||
);
|
||||
|
1
db/migrations-wstore/000002_init.down.sql
Normal file
1
db/migrations-wstore/000002_init.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE db_layout;
|
5
db/migrations-wstore/000002_init.up.sql
Normal file
5
db/migrations-wstore/000002_init.up.sql
Normal file
@ -0,0 +1,5 @@
|
||||
CREATE TABLE db_layout (
|
||||
oid varchar(36) PRIMARY KEY,
|
||||
version int NOT NULL,
|
||||
data json NOT NULL
|
||||
);
|
1
db/migrations-wstore/000003_activity.down.sql
Normal file
1
db/migrations-wstore/000003_activity.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE db_activity;
|
11
db/migrations-wstore/000003_activity.up.sql
Normal file
11
db/migrations-wstore/000003_activity.up.sql
Normal file
@ -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 '-'
|
||||
);
|
1
db/migrations-wstore/000004_history.down.sql
Normal file
1
db/migrations-wstore/000004_history.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE history_migrated;
|
9
db/migrations-wstore/000004_history.up.sql
Normal file
9
db/migrations-wstore/000004_history.up.sql
Normal file
@ -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
|
||||
);
|
103
electron-builder.config.cjs
Normal file
103
electron-builder.config.cjs
Normal file
@ -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;
|
75
electron.vite.config.ts
Normal file
75
electron.vite.config.ts
Normal file
@ -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" }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
23
emain/authkey.ts
Normal file
23
emain/authkey.ts
Normal file
@ -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 });
|
||||
});
|
||||
}
|
64
emain/emain-web.ts
Normal file
64
emain/emain-web.ts
Normal file
@ -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<WebContents> {
|
||||
const prtn = new Promise<WebContents>((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<string[]> {
|
||||
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;
|
||||
}
|
31
emain/emain-wsh.ts
Normal file
31
emain/emain-wsh.ts
Normal file
@ -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<void> };
|
||||
|
||||
export class ElectronWshClientType extends WshClient {
|
||||
constructor() {
|
||||
super("electron");
|
||||
}
|
||||
|
||||
async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise<string[]> {
|
||||
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;
|
||||
}
|
||||
}
|
881
emain/emain.ts
Normal file
881
emain/emain.ts
Normal file
@ -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<void> };
|
||||
|
||||
let waveSrvReadyResolve = (value: boolean) => {};
|
||||
const waveSrvReady: Promise<boolean> = 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<boolean> {
|
||||
let pResolve: (value: boolean) => void;
|
||||
let pReject: (reason?: any) => void;
|
||||
const rtnPromise = new Promise<boolean>((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<Electron.WebContentsWillNavigateEventParams>, 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<Electron.WebContentsWillFrameNavigateEventParams>) {
|
||||
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<number, boolean>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
});
|
191
emain/menu.ts
Normal file
191
emain/menu.ts
Normal file
@ -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<void>;
|
||||
relaunchBrowserWindows: () => Promise<void>;
|
||||
};
|
||||
|
||||
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 };
|
76
emain/platform.ts
Normal file
76
emain/platform.ts
Normal file
@ -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,
|
||||
};
|
50
emain/preload.ts
Normal file
50
emain/preload.ts
Normal file
@ -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);
|
||||
});
|
212
emain/updater.ts
Normal file
212
emain/updater.ts
Normal file
@ -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;
|
||||
}
|
21
eslint.config.js
Normal file
21
eslint.config.js
Normal file
@ -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];
|
147
frontend/app/app.less
Normal file
147
frontend/app/app.less
Normal file
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
361
frontend/app/app.tsx
Normal file
361
frontend/app/app.tsx
Normal file
@ -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 (
|
||||
<Provider store={globalStore}>
|
||||
<AppInner />
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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<HTMLDivElement>) {
|
||||
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<HTMLDivElement>(null);
|
||||
const tabId = jotai.useAtomValue(atoms.activeTabId);
|
||||
const [tabData] = useWaveObjectValue<Tab>(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 <div ref={bgRef} className="app-background" style={style} />;
|
||||
}
|
||||
|
||||
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<string>(null);
|
||||
const [ticker, setTicker] = React.useState<number>(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) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flash-error-container">
|
||||
{flashErrors.map((err, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx("flash-error", { hovered: hoveredId === err.id })}
|
||||
onClick={() => copyError(err.id)}
|
||||
onMouseEnter={() => setHoveredId(err.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
title="Click to Copy Error Message"
|
||||
>
|
||||
<div className="flash-error-scroll">
|
||||
{err.title != null ? <div className="flash-error-title">{err.title}</div> : null}
|
||||
{err.message != null ? (
|
||||
<div className="flash-error-message">{convertNewlinesToBreaks(err.message)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="mainapp">
|
||||
<AppBackground />
|
||||
<CenteredDiv>invalid configuration, client or window was not loaded</CenteredDiv>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("mainapp", PLATFORM, {
|
||||
fullscreen: isFullScreen,
|
||||
"prefers-reduced-motion": prefersReducedMotion,
|
||||
})}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<AppBackground />
|
||||
<AppKeyHandlers />
|
||||
<AppFocusHandler />
|
||||
<AppSettingsUpdater />
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Workspace />
|
||||
</DndProvider>
|
||||
<FlashError />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { App };
|
18
frontend/app/asset/dots-anim-4.svg
Normal file
18
frontend/app/asset/dots-anim-4.svg
Normal file
@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="12" width="12" viewBox="0 0 16 16">
|
||||
<title>dots anim 4</title>
|
||||
<g class="nc-icon-wrapper">
|
||||
<g class="nc-loop-dots-4-16-icon-f">
|
||||
<circle cx="3" cy="8" r="2"></circle>
|
||||
<circle cx="8" cy="8" r="2" data-color="color-2"></circle>
|
||||
<circle cx="13" cy="8" r="2"></circle>
|
||||
</g>
|
||||
<style>
|
||||
.nc-loop-dots-4-16-icon-f{--animation-duration:0.8s}
|
||||
.nc-loop-dots-4-16-icon-f *{opacity:.4;transform:scale(.75);animation:nc-loop-dots-4-anim var(--animation-duration) infinite}
|
||||
.nc-loop-dots-4-16-icon-f :nth-child(1){transform-origin:3px 8px;animation-delay:-.3s;animation-delay:calc(var(--animation-duration)/-2.666)}
|
||||
.nc-loop-dots-4-16-icon-f :nth-child(2){transform-origin:8px 8px;animation-delay:-.15s;animation-delay:calc(var(--animation-duration)/-5.333)}
|
||||
.nc-loop-dots-4-16-icon-f :nth-child(3){transform-origin:13px 8px}
|
||||
@keyframes nc-loop-dots-4-anim{0%,100%{opacity:.4;transform:scale(.75)}50%{opacity:1;transform:scale(1)}}
|
||||
</style>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 982 B |
34
frontend/app/asset/logo.svg
Normal file
34
frontend/app/asset/logo.svg
Normal file
@ -0,0 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="34" viewBox="0 0 48 34" fill="none">
|
||||
<path
|
||||
d="M15.4158 15.5474C13.148 15.5474 11.8942 17.0243 11.2121 20.3289L0.960938 18.852C2.21467 8.03354 6.41837 2.79046 14.7336 2.79046C20.8916 2.79046 26.9206 7.46123 29.8706 7.46123C32.1568 7.46123 33.3921 5.87353 34.0743 2.67969L44.3254 4.15661C43.1823 14.9935 38.868 20.2366 30.5528 20.2366C24.3025 20.2182 18.4948 15.5474 15.4158 15.5474Z"
|
||||
fill="url(#paint0_linear_1073_4777)"
|
||||
/>
|
||||
<path
|
||||
d="M18.322 26.1166C16.0542 26.1166 14.8005 27.5935 14.1183 30.8982L3.86719 29.4212C5.12092 18.6028 9.32462 13.3412 17.6398 13.3412C23.7795 13.3412 29.8269 18.012 32.7768 18.012C35.0631 18.012 36.2984 16.4243 36.9806 13.2305L47.2317 14.7074C46.0886 25.5443 41.7742 30.7874 33.459 30.7874C27.2088 30.7874 21.401 26.1166 18.322 26.1166Z"
|
||||
fill="url(#paint1_linear_1073_4777)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_1073_4777"
|
||||
x1="0.963297"
|
||||
y1="11.5059"
|
||||
x2="44.3342"
|
||||
y2="11.5059"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.1418" stop-color="#1F4D22" />
|
||||
<stop offset="0.8656" stop-color="#418D31" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_1073_4777"
|
||||
x1="3.87034"
|
||||
y1="22.0719"
|
||||
x2="47.2413"
|
||||
y2="22.0719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.2223" stop-color="#418D31" />
|
||||
<stop offset="0.7733" stop-color="#58C142" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
9
frontend/app/asset/magnify.svg
Normal file
9
frontend/app/asset/magnify.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="svg2" version="1.1" fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g2" transform="matrix(1 0 0 1 -4 -4)" fill="#000">
|
||||
<path id="arrow1" class="arrow"
|
||||
d="m10.208 13.003c0.4262 0 0.7723 0.34592 0.7723 0.77195v5.4527c0 0.42604-0.3461 0.77195-0.7723 0.77195-0.42619 0-0.77227-0.34592-0.77227-0.77195v-4.6825l-4.6832-0.0017c-0.4262 0-0.77228-0.34592-0.77228-0.77185 0-0.42604 0.34608-0.77195 0.77228-0.77195h5.4554v0.0034z" />
|
||||
<path id="arrow2" class="arrow"
|
||||
d="m13.772 10.997c-0.42624 0-0.77233-0.34581-0.77233-0.77184v-5.4527c0-0.426 0.34609-0.77191 0.77233-0.77191 0.42614 0 0.77223 0.34591 0.77223 0.77191v4.6826l4.6832 0.0017c0.42625 0 0.77223 0.34591 0.77223 0.77187 0 0.42603-0.34599 0.77195-0.77223 0.77195h-5.4554v-0.0035z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 831 B |
416
frontend/app/block/block.less
Normal file
416
frontend/app/block/block.less
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
279
frontend/app/block/block.tsx
Normal file
279
frontend/app/block/block.tsx
Normal file
@ -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<HTMLDivElement>,
|
||||
contentRef: React.RefObject<HTMLDivElement>,
|
||||
blockView: string,
|
||||
viewModel: ViewModel
|
||||
): JSX.Element {
|
||||
if (util.isBlank(blockView)) {
|
||||
return <CenteredDiv>No View</CenteredDiv>;
|
||||
}
|
||||
if (blockView === "term") {
|
||||
return <TerminalView key={blockId} blockId={blockId} model={viewModel as TermViewModel} />;
|
||||
}
|
||||
if (blockView === "preview") {
|
||||
return (
|
||||
<PreviewView
|
||||
key={blockId}
|
||||
blockId={blockId}
|
||||
blockRef={blockRef}
|
||||
contentRef={contentRef}
|
||||
model={viewModel as PreviewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (blockView === "plot") {
|
||||
return <PlotView key={blockId} />;
|
||||
}
|
||||
if (blockView === "web") {
|
||||
return <WebView key={blockId} blockId={blockId} model={viewModel as WebViewModel} />;
|
||||
}
|
||||
if (blockView === "waveai") {
|
||||
return <WaveAi key={blockId} blockId={blockId} model={viewModel as WaveAiModel} />;
|
||||
}
|
||||
if (blockView === "cpuplot") {
|
||||
return <CpuPlotView key={blockId} blockId={blockId} model={viewModel as CpuPlotViewModel} />;
|
||||
}
|
||||
if (blockView == "help") {
|
||||
return <HelpView key={blockId} model={viewModel as HelpViewModel} />;
|
||||
}
|
||||
return <CenteredDiv>Invalid View "{blockView}"</CenteredDiv>;
|
||||
}
|
||||
|
||||
function makeDefaultViewModel(blockId: string, viewType: string): ViewModel {
|
||||
const blockDataAtom = WOS.getWaveObjectAtom<Block>(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<Block>(WOS.makeORef("block", nodeModel.blockId));
|
||||
if (!blockData) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<BlockFrame
|
||||
key={nodeModel.blockId}
|
||||
nodeModel={nodeModel}
|
||||
preview={true}
|
||||
blockModel={null}
|
||||
viewModel={viewModel}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
|
||||
counterInc("render-BlockFull");
|
||||
const focusElemRef = React.useRef<HTMLInputElement>(null);
|
||||
const blockRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [blockClicked, setBlockClicked] = React.useState(false);
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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<Dimensions>();
|
||||
|
||||
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<React.CSSProperties>(() => {
|
||||
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<HTMLDivElement, Element>) => {
|
||||
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 (
|
||||
<BlockFrame
|
||||
key={nodeModel.blockId}
|
||||
nodeModel={nodeModel}
|
||||
preview={false}
|
||||
blockModel={blockModel}
|
||||
viewModel={viewModel}
|
||||
>
|
||||
<div key="focuselem" className="block-focuselem">
|
||||
<input
|
||||
type="text"
|
||||
value=""
|
||||
ref={focusElemRef}
|
||||
id={`${nodeModel.blockId}-dummy-focus`} // don't change this name (used in refocusNode)
|
||||
className="dummy-focus"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div key="content" className="block-content" ref={contentRef} style={blockContentStyle}>
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={<CenteredDiv>Loading...</CenteredDiv>}>{viewElem}</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</BlockFrame>
|
||||
);
|
||||
});
|
||||
|
||||
const Block = React.memo((props: BlockProps) => {
|
||||
counterInc("render-Block");
|
||||
counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8));
|
||||
const [blockData, loading] = WOS.useWaveObjectValue<Block>(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 <BlockPreview {...props} viewModel={viewModel} />;
|
||||
}
|
||||
return <BlockFull {...props} viewModel={viewModel} />;
|
||||
});
|
||||
|
||||
export { Block };
|
743
frontend/app/block/blockframe.tsx
Normal file
743
frontend/app/block/blockframe.tsx
Normal file
@ -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<HTMLDivElement>,
|
||||
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 <div className="block-frame-view-icon">{getBlockHeaderIcon(viewIcon, blockData)}</div>;
|
||||
} else {
|
||||
return <IconButton decl={viewIconUnion} className="block-frame-view-icon" />;
|
||||
}
|
||||
}
|
||||
|
||||
const OptMagnifyButton = React.memo(
|
||||
({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => {
|
||||
const magnifyDecl: IconButtonDecl = {
|
||||
elemtype: "iconbutton",
|
||||
icon: <MagnifyIcon enabled={magnified} />,
|
||||
title: magnified ? "Minimize" : "Magnify",
|
||||
click: toggleMagnify,
|
||||
disabled,
|
||||
};
|
||||
return <IconButton key="magnify" decl={magnifyDecl} className="block-frame-magnify" />;
|
||||
}
|
||||
);
|
||||
|
||||
function computeEndIcons(
|
||||
viewModel: ViewModel,
|
||||
nodeModel: NodeModel,
|
||||
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => 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) => <IconButton key={idx} decl={button} />));
|
||||
}
|
||||
const settingsDecl: IconButtonDecl = {
|
||||
elemtype: "iconbutton",
|
||||
icon: "cog",
|
||||
title: "Settings",
|
||||
click: onContextMenu,
|
||||
};
|
||||
endIconsElem.push(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />);
|
||||
endIconsElem.push(
|
||||
<OptMagnifyButton
|
||||
key="unmagnify"
|
||||
magnified={magnified}
|
||||
toggleMagnify={nodeModel.toggleMagnify}
|
||||
disabled={magnifyDisabled}
|
||||
/>
|
||||
);
|
||||
const closeDecl: IconButtonDecl = {
|
||||
elemtype: "iconbutton",
|
||||
icon: "xmark-large",
|
||||
title: "Close",
|
||||
click: nodeModel.onClose,
|
||||
};
|
||||
endIconsElem.push(<IconButton key="close" decl={closeDecl} className="block-frame-default-close" />);
|
||||
return endIconsElem;
|
||||
}
|
||||
|
||||
const BlockFrame_Header = ({
|
||||
nodeModel,
|
||||
viewModel,
|
||||
preview,
|
||||
connBtnRef,
|
||||
changeConnModalAtom,
|
||||
error,
|
||||
}: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom<boolean>; error?: Error }) => {
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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<HTMLDivElement>) => {
|
||||
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 = <IconButton decl={preIconButton} className="block-frame-preicon-button" />;
|
||||
}
|
||||
|
||||
const headerTextElems: JSX.Element[] = [];
|
||||
if (typeof headerTextUnion === "string") {
|
||||
if (!util.isBlank(headerTextUnion)) {
|
||||
headerTextElems.push(
|
||||
<div key="text" className="block-frame-text">
|
||||
‎{headerTextUnion}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (Array.isArray(headerTextUnion)) {
|
||||
headerTextElems.push(...renderHeaderElements(headerTextUnion, preview));
|
||||
}
|
||||
headerTextElems.unshift(<ControllerStatusIcon key="connstatus" blockId={nodeModel.blockId} />);
|
||||
if (error != null) {
|
||||
const copyHeaderErr = () => {
|
||||
navigator.clipboard.writeText(error.message + "\n" + error.stack);
|
||||
};
|
||||
headerTextElems.push(
|
||||
<div className="iconbutton disabled" key="controller-status" onClick={copyHeaderErr}>
|
||||
<i
|
||||
className="fa-sharp fa-solid fa-triangle-exclamation"
|
||||
title={"Error Rendering View Header: " + error.message}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}>
|
||||
{preIconButtonElem}
|
||||
<div className="block-frame-default-header-iconview">
|
||||
{viewIconElem}
|
||||
<div className="block-frame-view-type">{viewName}</div>
|
||||
{showBlockIds && <div className="block-frame-blockid">[{nodeModel.blockId.substring(0, 8)}]</div>}
|
||||
</div>
|
||||
{manageConnection && (
|
||||
<ConnectionButton
|
||||
ref={connBtnRef}
|
||||
key="connbutton"
|
||||
connection={blockData?.meta?.connection}
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
/>
|
||||
)}
|
||||
<div className="block-frame-textelems-wrapper">{headerTextElems}</div>
|
||||
<div className="block-frame-end-icons">{endIconsElem}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => {
|
||||
if (elem.elemtype == "iconbutton") {
|
||||
return <IconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
|
||||
} else if (elem.elemtype == "input") {
|
||||
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
|
||||
} else if (elem.elemtype == "text") {
|
||||
return (
|
||||
<div className={clsx("block-frame-text", elem.className)}>
|
||||
<span ref={preview ? null : elem.ref} onClick={() => elem?.onClick()}>
|
||||
‎{elem.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (elem.elemtype == "textbutton") {
|
||||
return (
|
||||
<Button className={elem.className} onClick={(e) => elem.onClick(e)}>
|
||||
{elem.text}
|
||||
</Button>
|
||||
);
|
||||
} else if (elem.elemtype == "div") {
|
||||
return (
|
||||
<div
|
||||
className={clsx("block-frame-div", elem.className)}
|
||||
onMouseOver={elem.onMouseOver}
|
||||
onMouseOut={elem.onMouseOut}
|
||||
>
|
||||
{elem.children.map((child, childIdx) => (
|
||||
<HeaderTextElem elem={child} key={childIdx} preview={preview} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 = <HeaderTextElem elem={elem} key={idx} preview={preview} />;
|
||||
if (renderedElement) {
|
||||
headerTextElems.push(renderedElement);
|
||||
}
|
||||
}
|
||||
return headerTextElems;
|
||||
}
|
||||
|
||||
const ConnStatusOverlay = React.memo(
|
||||
({
|
||||
nodeModel,
|
||||
viewModel,
|
||||
changeConnModalAtom,
|
||||
}: {
|
||||
nodeModel: NodeModel;
|
||||
viewModel: ViewModel;
|
||||
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
|
||||
}) => {
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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<HTMLDivElement>(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 = <i className="fa-sharp fa-solid fa-rotate-right"></i>;
|
||||
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 (
|
||||
<div className="connstatus-overlay" ref={overlayRef}>
|
||||
<div className="connstatus-content">
|
||||
<div className={clsx("connstatus-status-icon-wrapper", { "has-error": showError })}>
|
||||
{showIcon && <i className="fa-solid fa-triangle-exclamation"></i>}
|
||||
<div className="connstatus-status">
|
||||
<div className="connstatus-status-text">{statusText}</div>
|
||||
{showError ? <div className="connstatus-error">error: {connStatus.error}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showReconnect ? (
|
||||
<div className="connstatus-actions">
|
||||
<Button className={reconClassName} onClick={handleTryReconnect}>
|
||||
{reconDisplay}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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<Block>(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 = (
|
||||
<div className="block-mask-inner">
|
||||
<div className="bignum">{blockNum}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={clsx("block-mask", { "show-block-mask": showBlockMask })} style={style}>
|
||||
{innerElem}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const BlockFrame_Default_Component = (props: BlockFrameProps) => {
|
||||
const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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<boolean>;
|
||||
const connModalOpen = jotai.useAtomValue(changeConnModalAtom);
|
||||
|
||||
const connBtnRef = React.useRef<HTMLDivElement>();
|
||||
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 = <div className="block-frame-preview">{viewIconElem}</div>;
|
||||
const headerElem = (
|
||||
<BlockFrame_Header {...props} connBtnRef={connBtnRef} changeConnModalAtom={changeConnModalAtom} />
|
||||
);
|
||||
const headerElemNoView = React.cloneElement(headerElem, { viewModel: null });
|
||||
return (
|
||||
<div
|
||||
className={clsx("block", "block-frame-default", "block-" + nodeModel.blockId, {
|
||||
"block-focused": isFocused || preview,
|
||||
"block-preview": preview,
|
||||
"block-no-highlight": numBlocksInTab === 1,
|
||||
})}
|
||||
data-blockid={nodeModel.blockId}
|
||||
onClick={blockModel?.onClick}
|
||||
onFocusCapture={blockModel?.onFocusCapture}
|
||||
ref={blockModel?.blockRef}
|
||||
>
|
||||
<BlockMask nodeModel={nodeModel} />
|
||||
{preview || viewModel == null ? null : (
|
||||
<ConnStatusOverlay
|
||||
nodeModel={nodeModel}
|
||||
viewModel={viewModel}
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
/>
|
||||
)}
|
||||
<div className="block-frame-default-inner" style={innerStyle}>
|
||||
<ErrorBoundary fallback={headerElemNoView}>{headerElem}</ErrorBoundary>
|
||||
{preview ? previewElem : children}
|
||||
</div>
|
||||
{preview || viewModel == null || !connModalOpen ? null : (
|
||||
<ChangeConnectionBlockModal
|
||||
blockId={nodeModel.blockId}
|
||||
nodeModel={nodeModel}
|
||||
viewModel={viewModel}
|
||||
blockRef={blockModel?.blockRef}
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
connBtnRef={connBtnRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangeConnectionBlockModal = React.memo(
|
||||
({
|
||||
blockId,
|
||||
viewModel,
|
||||
blockRef,
|
||||
connBtnRef,
|
||||
changeConnModalAtom,
|
||||
nodeModel,
|
||||
}: {
|
||||
blockId: string;
|
||||
viewModel: ViewModel;
|
||||
blockRef: React.RefObject<HTMLDivElement>;
|
||||
connBtnRef: React.RefObject<HTMLDivElement>;
|
||||
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
|
||||
nodeModel: NodeModel;
|
||||
}) => {
|
||||
const [connSelected, setConnSelected] = React.useState("");
|
||||
const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom);
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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<Array<string>>([]);
|
||||
const allConnStatus = jotai.useAtomValue(atoms.allConnStatus);
|
||||
const [rowIndex, setRowIndex] = React.useState(0);
|
||||
const connStatusMap = new Map<string, ConnStatus>();
|
||||
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<string> = [];
|
||||
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<SuggestionConnectionItem> = [];
|
||||
if (createNew) {
|
||||
priorityItems.push(newConnectionSuggestion);
|
||||
}
|
||||
if (showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error")) {
|
||||
priorityItems.push(reconnectSuggestion);
|
||||
}
|
||||
const prioritySuggestions: SuggestionConnectionScope = {
|
||||
headerText: "",
|
||||
items: priorityItems,
|
||||
};
|
||||
const localName = getUserName() + "@" + getHostName();
|
||||
const localSuggestion: SuggestionConnectionScope = {
|
||||
headerText: "Local",
|
||||
items: [],
|
||||
};
|
||||
if (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<SuggestionsType> = [];
|
||||
if (prioritySuggestions.items.length > 0) {
|
||||
suggestions.push(prioritySuggestions);
|
||||
}
|
||||
if (localSuggestion.items.length > 0) {
|
||||
suggestions.push(localSuggestion);
|
||||
}
|
||||
if (remoteSuggestions.items.length > 0) {
|
||||
suggestions.push(remoteSuggestions);
|
||||
}
|
||||
|
||||
let selectionList: Array<SuggestionConnectionItem> = [
|
||||
...prioritySuggestions.items,
|
||||
...localSuggestion.items,
|
||||
...remoteSuggestions.items,
|
||||
];
|
||||
|
||||
// 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 (
|
||||
<TypeAheadModal
|
||||
blockRef={blockRef}
|
||||
anchorRef={connBtnRef}
|
||||
suggestions={suggestions}
|
||||
onSelect={(selected: string) => {
|
||||
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<Block>(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 <FrameElem {...props} numBlocksInTab={numBlocks} />;
|
||||
});
|
||||
|
||||
export { BlockFrame, NumActiveConnColors };
|
24
frontend/app/block/blocktypes.ts
Normal file
24
frontend/app/block/blocktypes.ts
Normal file
@ -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<HTMLDivElement>;
|
||||
blockRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export interface BlockFrameProps {
|
||||
blockModel?: BlockComponentModel2;
|
||||
nodeModel?: NodeModel;
|
||||
viewModel?: ViewModel;
|
||||
preview: boolean;
|
||||
numBlocksInTab?: number;
|
||||
children?: React.ReactNode;
|
||||
connBtnRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
308
frontend/app/block/blockutil.tsx
Normal file
308
frontend/app/block/blockutil.tsx
Normal file
@ -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(<i key={match.index} className={iconClass} />);
|
||||
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 = <i key="icon" style={iconStyle} className={clsx(`block-frame-icon`, iconClass)} />;
|
||||
}
|
||||
return blockIconElem;
|
||||
}
|
||||
|
||||
interface ConnectionButtonProps {
|
||||
connection: string;
|
||||
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
|
||||
}
|
||||
|
||||
export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string }) => {
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
||||
const hasController = !util.isBlank(blockData?.meta?.controller);
|
||||
const [controllerStatus, setControllerStatus] = React.useState<BlockControllerRuntimeStatus>(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 = (
|
||||
<div className="iconbutton disabled" key="controller-status">
|
||||
<i className="fa-sharp fa-solid fa-triangle-exclamation" title="Shell Process Is Not Running" />
|
||||
</div>
|
||||
);
|
||||
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<HTMLDivElement, ConnectionButtonProps>(
|
||||
({ 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 = (
|
||||
<i
|
||||
className={clsx(util.makeIconClass("laptop", false), "fa-stack-1x")}
|
||||
style={{ color: color, marginRight: 2 }}
|
||||
/>
|
||||
);
|
||||
} 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 = (
|
||||
<div className="connecting-svg">
|
||||
<DotsSvg />
|
||||
</div>
|
||||
);
|
||||
} 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 = (
|
||||
<i
|
||||
className={clsx(util.makeIconClass(iconName, false), "fa-stack-1x")}
|
||||
style={{ color: color, marginRight: 2 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx("connection-button")} onClick={clickHandler} title={titleText}>
|
||||
<span className={clsx("fa-stack connection-icon-box", shouldSpin ? "fa-spin" : null)}>
|
||||
{connIconElem}
|
||||
<i
|
||||
className="fa-slash fa-solid fa-stack-1x"
|
||||
style={{
|
||||
color: color,
|
||||
marginRight: "2px",
|
||||
textShadow: "0 1px black, 0 1.5px black",
|
||||
opacity: showDisconnectedSlash ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
{isLocal ? null : <div className="connection-name">{connection}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const Input = React.memo(
|
||||
({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => {
|
||||
const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl;
|
||||
return (
|
||||
<div className="input-wrapper">
|
||||
<input
|
||||
ref={
|
||||
!preview
|
||||
? ref
|
||||
: undefined /* don't wire up the input field if the preview block is being rendered */
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e)}
|
||||
onKeyDown={(e) => onKeyDown(e)}
|
||||
onFocus={(e) => onFocus(e)}
|
||||
onBlur={(e) => onBlur(e)}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
304
frontend/app/element/button.less
Normal file
304
frontend/app/element/button.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
51
frontend/app/element/button.tsx
Normal file
51
frontend/app/element/button.tsx
Normal file
@ -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<HTMLButtonElement> {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
target?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const Button = memo(
|
||||
forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ children, disabled, source, className = "", ...props }: ButtonProps, ref) => {
|
||||
const btnRef = useRef<HTMLButtonElement>(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 (
|
||||
<button
|
||||
ref={btnRef}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={clsx("button", finalClassName, {
|
||||
disabled,
|
||||
})}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{childrenArray}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { Button };
|
11
frontend/app/element/copybutton.less
Normal file
11
frontend/app/element/copybutton.less
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
59
frontend/app/element/copybutton.tsx
Normal file
59
frontend/app/element/copybutton.tsx
Normal file
@ -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<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
const CopyButton = ({ title, className, onClick }: CopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<IconButton
|
||||
decl={{
|
||||
elemtype: "iconbutton",
|
||||
icon: isCopied ? "check" : "copy",
|
||||
title,
|
||||
className: clsx("copy-button", { copied: isCopied }),
|
||||
click: handleOnClick,
|
||||
}}
|
||||
className={className}
|
||||
></IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export { CopyButton };
|
32
frontend/app/element/errorboundary.tsx
Normal file
32
frontend/app/element/errorboundary.tsx
Normal file
@ -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 <pre className="error-boundary">{errorMsg}</pre>;
|
||||
} else {
|
||||
return <>{this.props.children}</>;
|
||||
}
|
||||
}
|
||||
}
|
25
frontend/app/element/iconbutton.less
Normal file
25
frontend/app/element/iconbutton.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
20
frontend/app/element/iconbutton.tsx
Normal file
20
frontend/app/element/iconbutton.tsx
Normal file
@ -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<HTMLDivElement>(null);
|
||||
useLongClick(buttonRef, decl.click, decl.longClick, decl.disabled);
|
||||
return (
|
||||
<div
|
||||
ref={buttonRef}
|
||||
className={clsx("iconbutton", className, decl.className, { disabled: decl.disabled })}
|
||||
title={decl.title}
|
||||
style={{ color: decl.iconColor ?? "inherit" }}
|
||||
>
|
||||
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true)} /> : decl.icon}
|
||||
</div>
|
||||
);
|
||||
});
|
86
frontend/app/element/input.less
Normal file
86
frontend/app/element/input.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
176
frontend/app/element/input.tsx
Normal file
176
frontend/app/element/input.tsx
Normal file
@ -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<HTMLInputElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
decoration?: InputDecorationProps;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
isNumber?: boolean;
|
||||
inputRef?: React.MutableRefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
(
|
||||
{
|
||||
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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("input", className, {
|
||||
focused: focused,
|
||||
error: error,
|
||||
disabled: disabled,
|
||||
"no-label": !label,
|
||||
})}
|
||||
onFocus={handleComponentFocus}
|
||||
onBlur={handleComponentBlur}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
|
||||
<div className="input-inner">
|
||||
{label && (
|
||||
<label
|
||||
className={clsx("input-inner-label", {
|
||||
float: hasContent || focused || placeholder,
|
||||
"offset-left": decoration?.startDecoration,
|
||||
})}
|
||||
htmlFor={label}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={clsx("input-inner-input", {
|
||||
"offset-left": decoration?.startDecoration,
|
||||
})}
|
||||
ref={handleSetInputRef}
|
||||
id={label}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{decoration?.endDecoration && <>{decoration.endDecoration}</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { Input };
|
||||
export type { InputDecorationProps, InputProps };
|
21
frontend/app/element/inputdecoration.less
Normal file
21
frontend/app/element/inputdecoration.less
Normal file
@ -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;
|
||||
}
|
28
frontend/app/element/inputdecoration.tsx
Normal file
28
frontend/app/element/inputdecoration.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className={clsx("input-decoration", {
|
||||
"start-position": position === "start",
|
||||
"end-position": position === "end",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { InputDecoration };
|
21
frontend/app/element/linkbutton.less
Normal file
21
frontend/app/element/linkbutton.less
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
37
frontend/app/element/linkbutton.tsx
Normal file
37
frontend/app/element/linkbutton.tsx
Normal file
@ -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<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
const LinkButton = ({ leftIcon, rightIcon, children, className, ...rest }: LinkButtonProps) => {
|
||||
return (
|
||||
<a {...rest} className={clsx("link-button", className)}>
|
||||
<span className="button-inner">
|
||||
{leftIcon && <span className="icon-left">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="icon-right">{rightIcon}</span>}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export { LinkButton };
|
31
frontend/app/element/magnify.less
Normal file
31
frontend/app/element/magnify.less
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
frontend/app/element/magnify.stories.tsx
Normal file
28
frontend/app/element/magnify.stories.tsx
Normal file
@ -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<typeof MagnifyIcon>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Enabled: Story = {
|
||||
args: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user