Merge remote-tracking branch 'thenextwave/main' into wave8

This commit is contained in:
sawka 2024-09-18 12:49:35 -07:00
commit 14f0223277
341 changed files with 61105 additions and 0 deletions

10
.editorconfig Normal file
View 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
View File

@ -0,0 +1 @@
* text=lf

31
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
nodeLinker: node-modules

128
CODE_OF_CONDUCT.md Normal file
View 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
View 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
View 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.

1
NOTICE Normal file
View File

@ -0,0 +1 @@
Copyright 2024, Command Line Inc.

65
README.md Normal file
View 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 &mdash; https://www.waveterm.dev
- Download Page &mdash; https://www.waveterm.dev/download
- Documentation &mdash; https://docs.waveterm.dev/
- Blog &mdash; https://blog.waveterm.dev/
- Discord Community &mdash; 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
build/icons.icns Normal file

Binary file not shown.

View 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()
}

View 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
View 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
View 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
View 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)
}
}

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

View 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")
}

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

View 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")
}
}

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

View 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
}
},
}

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

View 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")
}

View 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")
}

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

View 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
View 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)
}

View 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)
}

View 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))
},
}

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

View File

@ -0,0 +1,3 @@
DROP TABLE db_wave_file;
DROP TABLE db_file_data;

View 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)
);

View File

@ -0,0 +1,7 @@
DROP TABLE db_client;
DROP TABLE db_workspace;
DROP TABLE db_tab;
DROP TABLE db_block;

View 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
);

View File

@ -0,0 +1 @@
DROP TABLE db_layout;

View File

@ -0,0 +1,5 @@
CREATE TABLE db_layout (
oid varchar(36) PRIMARY KEY,
version int NOT NULL,
data json NOT NULL
);

View File

@ -0,0 +1 @@
DROP TABLE db_activity;

View 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 '-'
);

View File

@ -0,0 +1 @@
DROP TABLE history_migrated;

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

View 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

View 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

View 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

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

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

View 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">
&lrm;{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()}>
&lrm;{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 };

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

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

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

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

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

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

View 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}</>;
}
}
}

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

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

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

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

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

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

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

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

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

View 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