diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..984c2c6fc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.{js,jsx,ts,tsx,cjs,json,yml,yaml,css,less}] +charset = utf-8 +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..fbd75d33c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..3db62b1a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,31 @@ +--- +name: Bug Report +about: Create a bug report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. MacOS/Linux, x64 or arm64] + - Version [e.g. v0.5.0] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..9eb042ae7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,14 @@ +--- +name: Feature Request +about: Suggest a new idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..80a999287 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,39 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "friday" + time: "09:00" + timezone: "America/Los_Angeles" + groups: + dev-dependencies: + dependency-type: "development" + exclude-patterns: + - "*storybook*" + - "*electron*" + storybook: + patterns: + - "*storybook*" + prod-dependencies: + dependency-type: "production" + exclude-patterns: + - "*electron*" + electron: + patterns: + - "*electron*" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "friday" + time: "09:00" + timezone: "America/Los_Angeles" + - package-ecosystem: "github-actions" + directory: "/.github/workflows" + schedule: + interval: "weekly" + day: "friday" + time: "09:00" + timezone: "America/Los_Angeles" diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml new file mode 100644 index 000000000..108e0f482 --- /dev/null +++ b/.github/workflows/build-helper.yml @@ -0,0 +1,151 @@ +# Build Helper workflow - Builds, signs, and packages binaries for each supported platform, then uploads to a staging bucket in S3 for wider distribution. +# For more information on the macOS signing and notarization, see https://www.electron.build/code-signing and https://www.electron.build/configuration/mac +# For more information on the Windows Code Signing, see https://docs.digicert.com/en/digicert-keylocker/ci-cd-integrations/plugins/github-custom-action-for-keypair-signing.html and https://docs.digicert.com/en/digicert-keylocker/signing-tools/sign-authenticode-with-electron-builder-using-ksp-integration.html + +name: Build Helper +run-name: Build ${{ github.ref_name }} +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" +env: + GO_VERSION: "1.22.5" + NODE_VERSION: "22.5.1" +jobs: + runbuild: + permissions: + contents: write + outputs: + version: ${{ steps.set-version.outputs.WAVETERM_VERSION }} + strategy: + matrix: + include: + - platform: "darwin" + runner: "macos-latest-xlarge" + - platform: "linux" + runner: "ubuntu-latest" + - platform: "linux" + runner: ubuntu-24.04-arm64-16core + - platform: "windows" + runner: "windows-latest" + # - platform: "windows" + # runner: "windows-11-arm64-16core" + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - name: Install Linux Build Dependencies (Linux only) + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools + + # The pre-installed version of the AWS CLI has a segfault problem so we'll install it via Homebrew instead. + - name: Upgrade AWS CLI (Mac only) + if: matrix.platform == 'darwin' + run: brew update && brew install awscli + + # The version of FPM that comes bundled with electron-builder doesn't include a Linux ARM target. Installing Gems onto the runner is super quick so we'll just do this for all targets. + - name: Install FPM (not Windows) + if: matrix.platform != 'windows' + run: sudo gem install fpm + - name: Install FPM (Windows only) + if: matrix.platform == 'windows' + run: gem install fpm + + # General build dependencies + - uses: actions/setup-go@v5 + with: + go-version: ${{env.GO_VERSION}} + cache-dependency-path: | + go.sum + - uses: actions/setup-node@v4 + with: + node-version: ${{env.NODE_VERSION}} + - name: Install Yarn + run: | + corepack enable + yarn install + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Set Version" + id: set-version + run: echo "WAVETERM_VERSION=$(task version)" >> "$GITHUB_OUTPUT" + shell: bash + + # Windows Code Signing Setup + - name: Set up certificate (Windows only) + if: matrix.platform == 'windows' + run: | + echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 + shell: bash + - name: Set signing variables (Windows only) + if: matrix.platform == 'windows' + id: variables + run: | + echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" + echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" + echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_OUTPUT" + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH + shell: bash + - name: Setup Keylocker KSP (Windows only) + if: matrix.platform == 'windows' + run: | + curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o Keylockertools-windows-x64.msi + msiexec /i Keylockertools-windows-x64.msi /quiet /qn + C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user + smctl windows certsync + shell: cmd + + # Build and upload packages + - name: Build (not Windows) + if: matrix.platform != 'windows' + run: task package + env: + USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. + CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}} + CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD_2 }} + APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID_2 }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD_2 }} + APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID_2 }} + - name: Build (Windows only) + if: matrix.platform == 'windows' + run: task package + env: + USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. + CSC_LINK: ${{ steps.variables.outputs.SM_CLIENT_CERT_FILE }} + CSC_KEY_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell + - name: Upload to S3 staging + run: task artifacts:upload + env: + AWS_ACCESS_KEY_ID: "${{ secrets.ARTIFACTS_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.ARTIFACTS_KEY_SECRET }}" + AWS_DEFAULT_REGION: us-west-2 + + - name: Create draft release + uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ contains(github.ref_name, '-beta') }} + name: Wave Terminal ${{ github.ref_name }} Release + generate_release_notes: true + draft: true + files: | + make/*.zip + make/*.dmg + make/*.exe + make/*.msi + make/*.rpm + make/*.deb + make/*.pacman + make/*.snap + make/*.flatpak + make/*.AppImage diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 000000000..fd6cd78d0 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,83 @@ +# Workflow to manage bumping the package version and pushing it to the target branch with a new tag. +# This workflow uses a GitHub App to bypass branch protection and uses the GitHub API directly to ensure commits and tags are signed. +# For more information, see this doc: https://github.com/Nautilus-Cyberneering/pygithub/blob/main/docs/how_to_sign_automatic_commits_in_github_actions.md + +name: Bump Version +run-name: "branch: ${{ github.ref_name }}; semver-bump: ${{ inputs.bump }}; prerelease: ${{ inputs.is-prerelease }}" +on: + workflow_dispatch: + inputs: + bump: + description: SemVer Bump + required: true + type: choice + default: none + options: + - none + - patch + - minor + - major + is-prerelease: + description: Is Prerelease + required: true + type: boolean + default: true +env: + NODE_VERSION: "22.5.1" +jobs: + bump-version: + runs-on: ubuntu-latest + steps: + - name: Get App Token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ vars.WAVE_BUILDER_APPID }} + private-key: ${{ secrets.WAVE_BUILDER_KEY }} + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + # General build dependencies + - uses: actions/setup-node@v4 + with: + node-version: ${{env.NODE_VERSION}} + - name: Install Yarn + run: | + corepack enable + yarn install + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Bump Version: ${{ inputs.bump }}" + id: bump-version + run: echo "WAVETERM_VERSION=$( task version -- ${{ inputs.bump }} ${{inputs.is-prerelease}} )" >> "$GITHUB_OUTPUT" + shell: bash + + - name: "Push version bump: ${{ steps.bump-version.outputs.WAVETERM_VERSION }}" + run: | + # Create a new commit for the package version bump in package.json + export VERSION=${{ steps.bump-version.outputs.WAVETERM_VERSION }} + export MESSAGE="chore: bump package version to $VERSION" + export FILE=package.json + export BRANCH=${{github.ref_name}} + export SHA=$( git rev-parse $BRANCH:$FILE ) + export CONTENT=$( base64 -i $FILE ) + gh api --method PUT /repos/:owner/:repo/contents/$FILE \ + --field branch="$BRANCH" \ + --field message="$MESSAGE" \ + --field content="$CONTENT" \ + --field sha="$SHA" + + # Fetch the new commit and create a tag referencing it + git fetch + export TAG_SHA=$( git rev-parse origin/$BRANCH ) + gh api --method POST /repos/:owner/:repo/git/refs \ + --field ref="refs/tags/v$VERSION" \ + --field sha="$TAG_SHA" + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..f7786e4b9 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,114 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "36 5 * * 5" + +env: + NODE_VERSION: "21.5.0" + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["go", "javascript-typescript"] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache-dependency-path: | + go.sum + + - uses: actions/setup-node@v4 + with: + node-version: ${{env.NODE_VERSION}} + - name: Install yarn + run: | + corepack enable + yarn install + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - name: Generate bindings + run: task generate + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild (not Go) + if: matrix.language != 'go' + uses: github/codeql-action/autobuild@v3 + + - name: Build (Go only) + if: matrix.language == 'go' + run: | + task build:server + task build:wsh + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..3d7fdc978 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,25 @@ +# Workflow to copy artifacts from the staging bucket to the release bucket when a new GitHub Release is published. + +name: Publish Release +run-name: Publish ${{ github.ref_name }} +on: + release: + types: [published] +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Publish from staging + if: startsWith(github.ref, 'refs/tags/') + run: "task artifacts:publish:${{ github.ref_name }}" + env: + AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" + AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" + AWS_DEFAULT_REGION: us-west-2 + shell: bash diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ec8952a17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +.task +frontend/dist +dist/ +dist-dev/ +frontend/node_modules +node_modules/ +frontend/bindings +bindings/ +*.log +bin/ +*.dmg +*.exe +.DS_Store +*~ +out/ +make/ +artifacts/ + +# Yarn Modern +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + + +*storybook.log + +test-results.xml diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..885ce6337 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +build +bin +.git +frontend/dist +frontend/node_modules +*.min.* +frontend/app/store/services.ts +frontend/types/gotypes.d.ts diff --git a/.storybook/global.css b/.storybook/global.css new file mode 100644 index 000000000..7a132c094 --- /dev/null +++ b/.storybook/global.css @@ -0,0 +1,17 @@ +body { + height: 100vh; + padding: 0; +} + +#storybook-root { + height: 100%; +} + +.grid-item { + background-color: aquamarine; + border: 1px black solid; + + &.react-grid-placeholder { + background-color: orange; + } +} diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 000000000..b1af7817e --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,41 @@ +import type { StorybookConfig } from "@storybook/react-vite"; +import type { ElectronViteConfig } from "electron-vite"; +import type { UserConfig } from "vite"; + +const config: StorybookConfig = { + stories: ["../frontend/**/*.mdx", "../frontend/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@chromatic-com/storybook", + "@storybook/addon-interactions", + ], + + core: {}, + + framework: { + name: "@storybook/react-vite", + options: {}, + }, + + docs: {}, + + managerHead: (head) => ` + ${head} + + `, + + typescript: { + reactDocgen: "react-docgen-typescript", + }, + + async viteFinal(config) { + const { mergeConfig } = await import("vite"); + const { tsImport } = await import("tsx/esm/api"); + const electronViteConfig = (await tsImport("../electron.vite.config.ts", import.meta.url)) + .default as ElectronViteConfig; + return mergeConfig(config, electronViteConfig.renderer as UserConfig); + }, +}; +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 000000000..a6c2c09bb --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,29 @@ +// organize-imports-ignore +import type { Preview } from "@storybook/react"; +import React from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import "./global.css"; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + + decorators: [ + (Story) => ( + + + + ), + ], + + tags: ["autodocs"], +}; + +export default preview; diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..23561f94b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "golang.go", + "dbaeumer.vscode-eslint", + "vitest.explorer", + "task.vscode-task" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..17dd10412 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,40 @@ +{ + "editor.formatOnSave": true, + "editor.detectIndentation": false, + "editor.formatOnPaste": true, + "editor.tabSize": 4, + "editor.insertSpaces": false, + "prettier.useEditorConfig": true, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[less]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.insertSpaces": true, + "editor.autoIndent": "keep" + }, + "[go]": { + "editor.defaultFormatter": "golang.go" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..03571dc88 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Storybook", + "type": "shell", + "command": "yarn storybook", + "presentation": { + "reveal": "silent", + "panel": "shared" + }, + "runOptions": { + "instanceLimit": 1, + "runOn": "folderOpen" + } + } + ] +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 000000000..3186f3f07 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..a7cae03be --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +coc@commandline.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..55a1d00e7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to Wave Terminal + +We welcome and value contributions to Wave Terminal! Wave is an open source project, always open for contributors. There are several ways you can contribute: + * Submit issues related to bugs or new feature requests + * Fix outstanding [issues](https://github.com/wavetermdev/waveterm/issues) with the existing code + * Contribute to [documentation](https://github.com/wavetermdev/waveterm-docs) + * Spread the word on social media (tag us on [LinkedIn](https://www.linkedin.com/company/commandlinedev), [Twitter/X](https://twitter.com/commandlinedev)) + * Or simply ⭐️ the repository to show your appreciation + +However you choose to contribute, please be mindful and respect our [code of conduct](./CODE_OF_CONDUCT.md). + +> All contributions are highly appreciated! 🥰 + +## Before You Start +We accept patches in the form of github pull requests. If you are new to github, please review this [github pull request guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests). + +### Contributor License Agreement +Contributions to this project must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution, this simply gives us permission to use and redistribute your contributions as part of the project. + +> On submission of your first pull request you will be prompted to sign the CLA confirming your original code contribution and that you own the intellectual property. + +### Style guide +The project uses American English. + +Coding style and formatting is automated for each pull request. We use [Prettier](https://prettier.io/). + +## How to contribute + + * For minor changes, you are welcome to [open a pull request](https://github.com/wavetermdev/waveterm/pulls). + * For major changes, please [create an issue](https://github.com/wavetermdev/waveterm/issues/new) first. + * If you are looking for a place to start take a look at [open issues](https://github.com/wavetermdev/waveterm/issues). + * Join the [Discord channel](https://discord.gg/XfvZ334gwU) to collaborate with the community on your contribution. + + +### Development Environment + +To build and run wave term locally see instructions below: + * [MacOS build instructions](./BUILD.md) + * [Linux build instructions](./build-linux.md) + +### Create a Pull Request + +Guidelines: + * Before writing any code, please look through existing PRs or issues to make sure nobody is already working on the same thing. + * Develop features on a branch - do not work on the main branch + * For anything but minor fixes, please submit tests and documentation + * Please reference the issue in the pull request diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..b207beb17 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2024 Command Line Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..50edd27dc --- /dev/null +++ b/NOTICE @@ -0,0 +1 @@ +Copyright 2024, Command Line Inc. diff --git a/README.md b/README.md new file mode 100644 index 000000000..a32d26c6a --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +

+ + + + Wave Terminal Logo + +
+

+ +# Wave Terminal + +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) + +Wave is an open-source terminal that can launch graphical widgets, controlled and integrated directly with the CLI. It includes a base terminal, directory browser, file previews (images, media, markdown), a graphical editor (for code/text files), a web browser, and integrated AI chat. + +Wave isn't just another terminal emulator; it's a rethink on how terminals are built. For too long there has been a disconnect between the CLI and the web. If you want fast, keyboard-accessible, easy-to-write applications, you use the CLI, but if you want graphical interfaces, native widgets, copy/paste, scrolling, variable font sizes, then you'd have to turn to the web. Wave's goal is to bridge that gap. + +![WaveTerm Screenshot](./assets/wave-screenshot.png) + +## Installation + +Wave Terminal works on MacOS, Linux, and Windows. + +Install Wave Terminal from: [www.waveterm.dev/download](https://www.waveterm.dev/download) + +Also available as a homebrew cask for MacOS: + +``` +brew install --cask wave +``` + +## Links + +- Homepage — https://www.waveterm.dev +- Download Page — https://www.waveterm.dev/download +- Documentation — https://docs.waveterm.dev/ +- Blog — https://blog.waveterm.dev/ +- Discord Community — https://discord.gg/XfvZ334gwU + +## Building from Source + +We use `task` to build Wave. + +```bash +brew install go-task +``` + +Once task is installed you can run this command to build and launch the development version of Wave. Note that the development database and settings are kept in a separate folder from the production version (~/.waveterm-dev) to prevent cross-corruption. + +```bash +task electron:dev +``` + +## Contributing + +Wave uses Github Issues for issue tracking. + +Find more information in our [Contributions Guide](CONTRIBUTING.md), which includes: + +- [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) +- [Contribution guidelines](CONTRIBUTING.md#before-you-start) + +## License + +Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./acknowledgements/README.md). diff --git a/RELEASES.md b/RELEASES.md new file mode 100644 index 000000000..2bc7dc917 --- /dev/null +++ b/RELEASES.md @@ -0,0 +1,64 @@ +# Building for release + +## Step-by-step guide + +1. Go to the [Actions tab](https://github.com/wavetermdev/thenextwave/actions) and select "Bump Version" from the left sidebar. +2. Click on "Run workflow". You will see two options: + - "SemVer Bump": This defaults to `none`. Adjust this if you want to increment the version number according to semantic versioning rules (`patch`, `minor`, `major`). + - "Is Prerelease": This defaults to `true`. If set to `true`, a `-beta.X` version will be appended to the end of the version. If one is already present and the base SemVer is not being incremented, the `-beta` version will be incremented (i.e. `0.1.13-beta.0` to `0.1.13-beta.1`). +3. After "Bump Version" a "Build Helper" run will kick off automatically for the new version. When this completes, it will generate a draft GitHub Release with all the built artifacts. +4. Review the artifacts in the release and test them locally. +5. When you are confident that the build is good, edit the GitHub Release to add a changelog and release summary and publish the release. +6. The new version will be published to our release feed automatically when the GitHub Release is published. If the build is a prerelease, it will only release to users subscribed to the `beta` channel. If it is a general release, it will be released to all users. + +## Details + +### Bump Version workflow + +All releases start by first bumping the package version and creating a new Git tag. We have a workflow set up to automate this. + +To run it, trigger a new run of the [Bump Version workflow](https://github.com/wavetermdev/thenextwave/actions/workflows/bump-version.yml). When triggering the run, you will be prompted to select a version bump type, either `none`, `patch`, `minor`, or `major`, and whether the version is prerelease or not. This determines how much the version number is incremented. + +See [`version.cjs`](../../version.cjs) for more details on how this works. + +Once the tag has been created, a new [Build Helper](#build-helper-workflow) run will be automatically queued to generate the artifacts. + +### Build Helper workflow + +Our release builds are managed by the [Build Helper workflow](https://github.com/wavetermdev/thenextwave/actions/workflows/build-helper.yml). + +Under the hood, this will call the `package` task in [`Taskfile.yml`](../../Taskfile.yml), which will build the `wavesrv` and `wsh` binaries, then the frontend and Electron codebases using Vite, then it will call `electron-builder` to generate the distributable app packages. The configuration for `electron-builder` is defined in [`electron-builder.config.cjs`](../../electron-builder.config.cjs). + +This will also sign and notarize the macOS app package. + +Once a build is complete, it will be placed in `s3://waveterm-github-artifacts/staging-w2/`. It can be downloaded for testing using the `artifacts:download:*` task. When you are ready to publish the artifacts to the public release feed, use the `artifacts:publish:*` task to directly copy the artifacts from the staging bucket to the releases bucket. + +You will need to configure an AWS CLI profile with write permissions for the S3 buckets in order for the script to work. You should invoke the tasks as follows: + +```bash +task artifacts:: -- --profile +``` + +### Automatic updates + +Thanks to [`electron-updater`](https://www.electron.build/auto-update.html), we are able to provide automatic app updates for macOS, Linux, and Windows, as long as the app was distributed as a DMG, AppImage, RPM, or DEB file (all Windows targets support auto updates). + +With each release, YAML files will be produced that point to the newest release for the current channel. These also include file sizes and checksums to aid in validating the packages. The app will check these files in our S3 bucket every hour to see if a new version is available. + +#### Update channels + +We utilize update channels to roll out beta and stable releases. These are determined based on the package versioning [described above](#bump-version-workflow). Users can select their update channel using the `autoupdate:channel` setting in Wave. See [here](https://www.electron.build/tutorials/release-using-channels.html) for more information. + +#### Homebrew + +Homebrew is automatically bumped when new artifacts are published. + +#### Linux + +We do not currently submit the Linux packages to any of the package repositories. We are working on addressing this in the near future. + +### `electron-build` configuration + +Most of our configuration is fairly standard. The main exception to this is that we exclude our Go binaries from the ASAR archive that Electron generates. ASAR files cannot be executed by NodeJS because they are not seen as files and therefore cannot be executed via a Shell command. More information can be found [here](https://www.electronjs.org/docs/latest/tutorial/asar-archives#executing-binaries-inside-asar-archive). + +We also exclude most of our `node_modules` from packaging, as Vite handles packaging of any dependencies for us. The one exception is `monaco-editor`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..10f45830a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +## Reporting Security Issues + +To report vulnerabilities or security concerns, please email us at: [security@commandline.dev](mailto:security@commandline.dev) + +** Please do not report security vulnerabilities through public github issues. ** \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 000000000..8e4884821 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,274 @@ +# Copyright 2024, Command Line Inc. +# SPDX-License-Identifier: Apache-2.0 + +version: "3" + +vars: + APP_NAME: "TheNextWave" + BIN_DIR: "bin" + VERSION: + sh: node version.cjs + RM: '{{if eq OS "windows"}}cmd --% /c del /S{{else}}rm {{end}}' + RMRF: '{{if eq OS "windows"}}powershell Remove-Item -Force -Recurse{{else}}rm -rf{{end}}' + DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}' + ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 + RELEASES_BUCKET: dl.waveterm.dev/releases-w2 + +tasks: + electron:dev: + desc: Run the Electron application via the Vite dev server (enables hot reloading). + cmds: + - yarn dev + deps: + - yarn + - build:backend + env: + WCLOUD_ENDPOINT: "https://ot2e112zx5.execute-api.us-west-2.amazonaws.com/dev" + WCLOUD_WS_ENDPOINT: "wss://5lfzlg5crl.execute-api.us-west-2.amazonaws.com/dev/" + + electron:start: + desc: Run the Electron application directly. + cmds: + - yarn start + deps: + - yarn + - build:backend + + package: + desc: Package the application for the current platform. + cmds: + - cmd: '{{.RMRF}} "make"' + ignore_error: true + - yarn build:prod && yarn electron-builder -c electron-builder.config.cjs -p never + deps: + - yarn + - build:backend + + build:backend: + desc: Build the wavesrv and wsh components. + cmds: + - task: build:server + - task: build:wsh + + build:server: + desc: Build the wavesrv component. + deps: + - generate + - build:server:linux + - build:server:macos + - build:server:windows + + build:server:macos: + desc: Build the wavesrv component for macOS (Darwin) platforms (generates artifacts for both arm64 and amd64). + status: + - exit {{if eq OS "darwin"}}1{{else}}0{{end}} + cmds: + - cmd: "{{.RM}} dist/bin/wavesrv*" + ignore_error: true + - task: build:server:internal + vars: + ARCHS: arm64,amd64 + + build:server:windows: + desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture). + status: + - exit {{if eq OS "windows"}}1{{else}}0{{end}} + cmds: + - cmd: "{{.RM}} dist/bin/wavesrv*" + ignore_error: true + - task: build:server:internal + vars: + ARCHS: + sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} + + build:server:linux: + desc: Build the wavesrv component for Linux platforms (only generates artifacts for the current architecture). + status: + - exit {{if eq OS "linux"}}1{{else}}0{{end}} + cmds: + - cmd: "{{.RM}} dist/bin/wavesrv*" + ignore_error: true + - task: build:server:internal + vars: + ARCHS: + sh: echo {{if eq "arm" ARCH}}arm64{{else}}{{ARCH}}{{end}} + GO_LDFLAGS: -linkmode 'external' -extldflags=-static + + build:server:internal: + requires: + vars: + - ARCHS + cmds: + - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} go build -tags "osusergo,netcgo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go + for: + var: ARCHS + split: "," + as: GOARCH + sources: + - "cmd/server/*.go" + - "pkg/**/*.go" + generates: + - dist/bin/wavesrv.*{{exeExt}} + deps: + - go:mod:tidy + internal: true + + build:wsh: + desc: Build the wsh component for all possible targets. + cmds: + - cmd: "{{.RM}} dist/bin/wsh*" + ignore_error: true + - task: build:wsh:internal + vars: + GOOS: darwin + GOARCH: arm64 + - task: build:wsh:internal + vars: + GOOS: darwin + GOARCH: amd64 + - task: build:wsh:internal + vars: + GOOS: linux + GOARCH: arm64 + - task: build:wsh:internal + vars: + GOOS: linux + GOARCH: amd64 + - task: build:wsh:internal + vars: + GOOS: windows + GOARCH: amd64 + - task: build:wsh:internal + vars: + GOOS: windows + GOARCH: arm64 + deps: + - generate + + dev:installwsh: + desc: quick shortcut to rebuild wsh and install for macos arm64 + requires: + vars: + - VERSION + cmds: + - task: build:wsh:internal + vars: + GOOS: darwin + GOARCH: arm64 + - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/.waveterm-dev/bin/wsh + + build:wsh:internal: + vars: + EXT: + sh: echo {{if eq .GOOS "windows"}}.exe{{end}} + NORMALIZEDARCH: + sh: echo {{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}} + requires: + vars: + - GOOS + - GOARCH + - VERSION + sources: + - "cmd/wsh/**/*.go" + - "pkg/**/*.go" + generates: + - dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} + cmds: + - (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) + deps: + - go:mod:tidy + internal: true + + generate: + desc: Generate Typescript bindings for the Go backend. + cmds: + - go run cmd/generatets/main-generatets.go + - go run cmd/generatego/main-generatego.go + sources: + - "cmd/generatego/*.go" + - "cmd/generatets/*.go" + - "pkg/service/**/*.go" + - "pkg/waveobj/*.go" + - "pkg/wconfig/**/*.go" + - "pkg/wstore/*.go" + - "pkg/wshrpc/**/*.go" + - "pkg/tsgen/**/*.go" + - "pkg/gogen/**/*.go" + - "pkg/wconfig/**/*.go" + - "pkg/eventbus/eventbus.go" + generates: + - frontend/types/gotypes.d.ts + - pkg/wshrpc/wshclient/wshclient.go + - frontend/app/store/services.ts + - frontend/app/store/wshserver.ts + + version: + desc: Get the current package version, or bump version if args are present. To pass args to `version.cjs`, add them after `--`. + summary: | + If no arguments are present, the current version will be returned. + If only a single argument is given, the following are valid inputs: + - `none`: No-op. + - `patch`: Bumps the patch version. + - `minor`: Bumps the minor version. + - `major`: Bumps the major version. + - '1', 'true': Bumps the prerelease version. + If two arguments are given, the first argument must be either `none`, `patch`, `minor`, or `major`. The second argument must be `1` or `true` to bump the prerelease version. + cmd: node version.cjs {{.CLI_ARGS}} + + artifacts:upload: + desc: Uploads build artifacts to the staging bucket in S3. To add additional AWS CLI arguments, add them after `--`. + vars: + ORIGIN: "make/" + DESTINATION: "{{.ARTIFACTS_BUCKET}}/{{.VERSION}}" + cmd: aws s3 cp {{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive --exclude "*/*" --exclude "builder-*.yml" {{.CLI_ARGS}} + + artifacts:download:*: + desc: Downloads the specified artifacts version from the staging bucket. To add additional AWS CLI arguments, add them after `--`. + vars: + DL_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' + ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.DL_VERSION}}" + DESTINATION: "artifacts/{{.DL_VERSION}}" + cmds: + - '{{.RMRF}} "{{.DESTINATION}}"' + - aws s3 cp s3://{{.ORIGIN}}/ {{.DESTINATION}}/ --recursive {{.CLI_ARGS}} + + artifacts:publish:*: + desc: Publishes the specified artifacts version from the staging bucket to the releases bucket. To add additional AWS CLI arguments, add them after `--`. + vars: + UP_VERSION: '{{ replace "v" "" (index .MATCH 0)}}' + ORIGIN: "{{.ARTIFACTS_BUCKET}}/{{.UP_VERSION}}" + DESTINATION: "{{.RELEASES_BUCKET}}" + cmd: | + OUTPUT=$(aws s3 cp s3://{{.ORIGIN}}/ s3://{{.DESTINATION}}/ --recursive {{.CLI_ARGS}}) + + for line in $OUTPUT; do + PREFIX=${line%%{{.DESTINATION}}*} + SUFFIX=${line:${#PREFIX}} + if [[ -n "$SUFFIX" ]]; then + echo "https://$SUFFIX" + fi + done + + yarn: + desc: Runs `yarn` + internal: true + generates: + - node_modules/**/* + - yarn.lock + - .yarn/* + sources: + - yarn.lock + - package.json + - .yarnrc.yml + cmds: + - yarn + + go:mod:tidy: + desc: Runs `go mod tidy` + internal: true + generates: + - go.sum + sources: + - go.mod + cmds: + - go mod tidy diff --git a/assets/wave-screenshot.png b/assets/wave-screenshot.png new file mode 100644 index 000000000..920e95380 Binary files /dev/null and b/assets/wave-screenshot.png differ diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 000000000..a10809fb0 Binary files /dev/null and b/build/appicon.png differ diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 000000000..523704eaa Binary files /dev/null and b/build/icon.ico differ diff --git a/build/icons.icns b/build/icons.icns new file mode 100644 index 000000000..79cd56e67 Binary files /dev/null and b/build/icons.icns differ diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go new file mode 100644 index 000000000..9c4a172f8 --- /dev/null +++ b/cmd/generatego/main-generatego.go @@ -0,0 +1,79 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "reflect" + "strings" + + "github.com/wavetermdev/waveterm/pkg/gogen" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const WshClientFileName = "pkg/wshrpc/wshclient/wshclient.go" +const WaveObjMetaConstsFileName = "pkg/waveobj/metaconsts.go" +const SettingsMetaConstsFileName = "pkg/wconfig/metaconsts.go" + +func GenerateWshClient() { + fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) + var buf strings.Builder + gogen.GenerateBoilerplate(&buf, "wshclient", []string{ + "github.com/wavetermdev/waveterm/pkg/wshutil", + "github.com/wavetermdev/waveterm/pkg/wshrpc", + "github.com/wavetermdev/waveterm/pkg/waveobj", + "github.com/wavetermdev/waveterm/pkg/wconfig", + "github.com/wavetermdev/waveterm/pkg/wps", + }) + wshDeclMap := wshrpc.GenerateWshCommandDeclMap() + for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { + methodDecl := wshDeclMap[key] + if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { + gogen.GenMethod_ResponseStream(&buf, methodDecl) + } else if methodDecl.CommandType == wshrpc.RpcType_Call { + gogen.GenMethod_Call(&buf, methodDecl) + } else { + panic("unsupported command type " + methodDecl.CommandType) + } + } + buf.WriteString("\n") + err := os.WriteFile(WshClientFileName, []byte(buf.String()), 0644) + if err != nil { + panic(err) + } +} + +func GenerateWaveObjMetaConsts() { + fmt.Fprintf(os.Stderr, "generating waveobj meta consts file to %s\n", WaveObjMetaConstsFileName) + var buf strings.Builder + gogen.GenerateBoilerplate(&buf, "waveobj", []string{}) + gogen.GenerateMetaMapConsts(&buf, "MetaKey_", reflect.TypeOf(waveobj.MetaTSType{})) + buf.WriteString("\n") + err := os.WriteFile(WaveObjMetaConstsFileName, []byte(buf.String()), 0644) + if err != nil { + panic(err) + } +} + +func GenerateSettingsMetaConsts() { + fmt.Fprintf(os.Stderr, "generating settings meta consts file to %s\n", SettingsMetaConstsFileName) + var buf strings.Builder + gogen.GenerateBoilerplate(&buf, "wconfig", []string{}) + gogen.GenerateMetaMapConsts(&buf, "ConfigKey_", reflect.TypeOf(wconfig.SettingsType{})) + buf.WriteString("\n") + err := os.WriteFile(SettingsMetaConstsFileName, []byte(buf.String()), 0644) + if err != nil { + panic(err) + } +} + +func main() { + GenerateWshClient() + GenerateWaveObjMetaConsts() + GenerateSettingsMetaConsts() +} diff --git a/cmd/generatets/main-generatets.go b/cmd/generatets/main-generatets.go new file mode 100644 index 000000000..446312adc --- /dev/null +++ b/cmd/generatets/main-generatets.go @@ -0,0 +1,132 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "reflect" + "sort" + "strings" + + "github.com/wavetermdev/waveterm/pkg/service" + "github.com/wavetermdev/waveterm/pkg/tsgen" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +func generateTypesFile(tsTypesMap map[reflect.Type]string) error { + fd, err := os.Create("frontend/types/gotypes.d.ts") + if err != nil { + return err + } + defer fd.Close() + fmt.Fprintf(os.Stderr, "generating types file to %s\n", fd.Name()) + tsgen.GenerateWaveObjTypes(tsTypesMap) + err = tsgen.GenerateServiceTypes(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating service types: %v\n", err) + os.Exit(1) + } + err = tsgen.GenerateWshServerTypes(tsTypesMap) + if err != nil { + return fmt.Errorf("error generating wsh server types: %w", err) + } + fmt.Fprintf(fd, "// Copyright 2024, Command Line Inc.\n") + fmt.Fprintf(fd, "// SPDX-License-Identifier: Apache-2.0\n\n") + fmt.Fprintf(fd, "// generated by cmd/generate/main-generatets.go\n\n") + fmt.Fprintf(fd, "declare global {\n\n") + var keys []reflect.Type + for key := range tsTypesMap { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + iname, _ := tsgen.TypeToTSType(keys[i], tsTypesMap) + jname, _ := tsgen.TypeToTSType(keys[j], tsTypesMap) + return iname < jname + }) + for _, key := range keys { + // don't output generic types + if strings.Index(key.Name(), "[") != -1 { + continue + } + tsCode := tsTypesMap[key] + istr := utilfn.IndentString(" ", tsCode) + fmt.Fprint(fd, istr) + } + fmt.Fprintf(fd, "}\n\n") + fmt.Fprintf(fd, "export {}\n") + return nil +} + +func generateServicesFile(tsTypesMap map[reflect.Type]string) error { + fd, err := os.Create("frontend/app/store/services.ts") + if err != nil { + return err + } + defer fd.Close() + fmt.Fprintf(os.Stderr, "generating services file to %s\n", fd.Name()) + fmt.Fprintf(fd, "// Copyright 2024, Command Line Inc.\n") + fmt.Fprintf(fd, "// SPDX-License-Identifier: Apache-2.0\n\n") + fmt.Fprintf(fd, "// generated by cmd/generate/main-generatets.go\n\n") + fmt.Fprintf(fd, "import * as WOS from \"./wos\";\n\n") + orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap) + for _, serviceName := range orderedKeys { + serviceObj := service.ServiceMap[serviceName] + svcStr := tsgen.GenerateServiceClass(serviceName, serviceObj, tsTypesMap) + fmt.Fprint(fd, svcStr) + fmt.Fprint(fd, "\n") + } + return nil +} + +func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error { + fd, err := os.Create("frontend/app/store/wshclientapi.ts") + if err != nil { + return err + } + defer fd.Close() + declMap := wshrpc.GenerateWshCommandDeclMap() + fmt.Fprintf(os.Stderr, "generating wshclientapi file to %s\n", fd.Name()) + fmt.Fprintf(fd, "// Copyright 2024, Command Line Inc.\n") + fmt.Fprintf(fd, "// SPDX-License-Identifier: Apache-2.0\n\n") + fmt.Fprintf(fd, "// generated by cmd/generate/main-generatets.go\n\n") + fmt.Fprintf(fd, "import { WshClient } from \"./wshclient\";\n\n") + orderedKeys := utilfn.GetOrderedMapKeys(declMap) + fmt.Fprintf(fd, "// WshServerCommandToDeclMap\n") + fmt.Fprintf(fd, "class RpcApiType {\n") + for _, methodDecl := range orderedKeys { + methodDecl := declMap[methodDecl] + methodStr := tsgen.GenerateWshClientApiMethod(methodDecl, tsTypeMap) + fmt.Fprint(fd, methodStr) + fmt.Fprintf(fd, "\n") + } + fmt.Fprintf(fd, "}\n\n") + fmt.Fprintf(fd, "export const RpcApi = new RpcApiType();\n") + return nil +} + +func main() { + err := service.ValidateServiceMap() + if err != nil { + fmt.Fprintf(os.Stderr, "Error validating service map: %v\n", err) + os.Exit(1) + } + tsTypesMap := make(map[reflect.Type]string) + err = generateTypesFile(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating types file: %v\n", err) + os.Exit(1) + } + err = generateServicesFile(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating services file: %v\n", err) + os.Exit(1) + } + err = generateWshClientApiFile(tsTypesMap) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating wshserver file: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go new file mode 100644 index 000000000..cbe4b974a --- /dev/null +++ b/cmd/server/main-server.go @@ -0,0 +1,286 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "runtime/debug" + "strconv" + + "runtime" + "sync" + "syscall" + "time" + + "github.com/wavetermdev/waveterm/pkg/authkey" + "github.com/wavetermdev/waveterm/pkg/blockcontroller" + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/service" + "github.com/wavetermdev/waveterm/pkg/telemetry" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wcloud" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wcore" + "github.com/wavetermdev/waveterm/pkg/web" + "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +// these are set at build time +var WaveVersion = "0.0.0" +var BuildTime = "0" + +const InitialTelemetryWait = 30 * time.Second +const TelemetryTick = 10 * time.Minute +const TelemetryInterval = 4 * time.Hour + +const ReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID" + +var shutdownOnce sync.Once + +func doShutdown(reason string) { + shutdownOnce.Do(func() { + log.Printf("shutting down: %s\n", reason) + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + go blockcontroller.StopAllBlockControllers() + shutdownActivityUpdate() + sendTelemetryWrapper() + // TODO deal with flush in progress + filestore.WFS.FlushCache(ctx) + watcher := wconfig.GetWatcher() + if watcher != nil { + watcher.Close() + } + time.Sleep(500 * time.Millisecond) + os.Exit(0) + }) +} + +func installShutdownSignalHandlers() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) + go func() { + for sig := range sigCh { + doShutdown(fmt.Sprintf("got signal %v", sig)) + break + } + }() +} + +// watch stdin, kill server if stdin is closed +func stdinReadWatch() { + buf := make([]byte, 1024) + for { + _, err := os.Stdin.Read(buf) + if err != nil { + doShutdown(fmt.Sprintf("stdin closed/error (%v)", err)) + break + } + } +} + +func configWatcher() { + watcher := wconfig.GetWatcher() + if watcher != nil { + watcher.Start() + } +} + +func telemetryLoop() { + var nextSend int64 + time.Sleep(InitialTelemetryWait) + for { + if time.Now().Unix() > nextSend { + nextSend = time.Now().Add(TelemetryInterval).Unix() + sendTelemetryWrapper() + } + time.Sleep(TelemetryTick) + } +} + +func sendTelemetryWrapper() { + defer func() { + r := recover() + if r == nil { + return + } + log.Printf("[error] in sendTelemetryWrapper: %v\n", r) + debug.PrintStack() + }() + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + log.Printf("[error] getting client data for telemetry: %v\n", err) + return + } + err = wcloud.SendTelemetry(ctx, client.OID) + if err != nil { + log.Printf("[error] sending telemetry: %v\n", err) + } +} + +func startupActivityUpdate() { + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + activity := telemetry.ActivityUpdate{ + Startup: 1, + } + activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) + err := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here) + if err != nil { + log.Printf("error updating startup activity: %v\n", err) + } +} + +func shutdownActivityUpdate() { + activity := telemetry.ActivityUpdate{Shutdown: 1} + ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) + defer cancelFn() + err := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous) + if err != nil { + log.Printf("error updating shutdown activity: %v\n", err) + } +} + +func createMainWshClient() { + rpc := wshserver.GetMainRpcClient() + wshutil.DefaultRouter.RegisterRoute(wshutil.DefaultRoute, rpc) + wps.Broker.SetClient(wshutil.DefaultRouter) + localConnWsh := wshutil.MakeWshRpc(nil, nil, wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, &wshremote.ServerImpl{}) + go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) + wshutil.DefaultRouter.RegisterRoute(wshutil.MakeConnectionRouteId(wshrpc.LocalConnName), localConnWsh) +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.SetPrefix("[wavesrv] ") + wavebase.WaveVersion = WaveVersion + wavebase.BuildTime = BuildTime + + err := authkey.SetAuthKeyFromEnv() + if err != nil { + log.Printf("error setting auth key: %v\n", err) + return + } + err = service.ValidateServiceMap() + if err != nil { + log.Printf("error validating service map: %v\n", err) + return + } + err = wavebase.EnsureWaveHomeDir() + if err != nil { + log.Printf("error ensuring wave home dir: %v\n", err) + return + } + err = wavebase.EnsureWaveDBDir() + if err != nil { + log.Printf("error ensuring wave db dir: %v\n", err) + return + } + err = wavebase.EnsureWaveConfigDir() + if err != nil { + log.Printf("error ensuring wave config dir: %v\n", err) + return + } + waveLock, err := wavebase.AcquireWaveLock() + if err != nil { + log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) + return + } + defer func() { + err = waveLock.Unlock() + if err != nil { + log.Printf("error releasing wave lock: %v\n", err) + } + }() + log.Printf("wave version: %s (%s)\n", WaveVersion, BuildTime) + log.Printf("wave home dir: %s\n", wavebase.GetWaveHomeDir()) + err = filestore.InitFilestore() + if err != nil { + log.Printf("error initializing filestore: %v\n", err) + return + } + err = wstore.InitWStore() + if err != nil { + log.Printf("error initializing wstore: %v\n", err) + return + } + migrateErr := wstore.TryMigrateOldHistory() + if migrateErr != nil { + log.Printf("error migrating old history: %v\n", migrateErr) + } + go func() { + err := shellutil.InitCustomShellStartupFiles() + if err != nil { + log.Printf("error initializing wsh and shell-integration files: %v\n", err) + } + }() + window, firstRun, err := wcore.EnsureInitialData() + if err != nil { + log.Printf("error ensuring initial data: %v\n", err) + return + } + if window != nil { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + if !firstRun { + err = wlayout.BootstrapNewWindowLayout(ctx, window) + if err != nil { + log.Panicf("error applying new window layout: %v\n", err) + return + } + } + } + createMainWshClient() + installShutdownSignalHandlers() + startupActivityUpdate() + go stdinReadWatch() + go telemetryLoop() + configWatcher() + webListener, err := web.MakeTCPListener("web") + if err != nil { + log.Printf("error creating web listener: %v\n", err) + return + } + wsListener, err := web.MakeTCPListener("websocket") + if err != nil { + log.Printf("error creating websocket listener: %v\n", err) + return + } + go web.RunWebSocketServer(wsListener) + unixListener, err := web.MakeUnixListener() + if err != nil { + log.Printf("error creating unix listener: %v\n", err) + return + } + go func() { + pidStr := os.Getenv(ReadySignalPidVarName) + if pidStr != "" { + _, err := strconv.Atoi(pidStr) + if err == nil { + if BuildTime == "" { + BuildTime = "0" + } + // use fmt instead of log here to make sure it goes directly to stderr + fmt.Fprintf(os.Stderr, "WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\n", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime) + } + } + }() + go wshutil.RunWshRpcOverListener(unixListener) + web.RunWebServer(webListener) // blocking + runtime.KeepAlive(waveLock) +} diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go new file mode 100644 index 000000000..10d1933fe --- /dev/null +++ b/cmd/test/test-main.go @@ -0,0 +1,55 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "log" + + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +func Page(ctx context.Context, props map[string]any) any { + clicked, setClicked := vdom.UseState(ctx, false) + var clickedDiv *vdom.Elem + if clicked { + clickedDiv = vdom.Bind(`
clicked
`, nil) + } + clickFn := func() { + log.Printf("run clickFn\n") + setClicked(true) + } + return vdom.Bind( + ` +
+

hello world

+ + +
+`, + map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, + ) +} + +func Button(ctx context.Context, props map[string]any) any { + ref := vdom.UseRef(ctx, nil) + clName, setClName := vdom.UseState(ctx, "button") + vdom.UseEffect(ctx, func() func() { + fmt.Printf("Button useEffect\n") + setClName("button mounted") + return nil + }, nil) + return vdom.Bind(` +
+ +
+ `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) +} + +func main() { + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) + defer wshutil.RestoreTermState() +} diff --git a/cmd/wsh/cmd/wshcmd-conn.go b/cmd/wsh/cmd/wshcmd-conn.go new file mode 100644 index 000000000..c7f991056 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-conn.go @@ -0,0 +1,132 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/remote" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var connCmd = &cobra.Command{ + Use: "conn [status|reinstall|disconnect|connect|ensure] [connection-name]", + Short: "implements connection commands", + Args: cobra.RangeArgs(1, 2), + RunE: connRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(connCmd) +} + +func connStatus() error { + resp, err := wshclient.ConnStatusCommand(RpcClient, nil) + if err != nil { + return fmt.Errorf("getting connection status: %w", err) + } + if len(resp) == 0 { + WriteStdout("no connections\n") + return nil + } + WriteStdout("%-30s %-12s\n", "connection", "status") + WriteStdout("----------------------------------------------\n") + for _, conn := range resp { + str := fmt.Sprintf("%-30s %-12s", conn.Connection, conn.Status) + if conn.Error != "" { + str += fmt.Sprintf(" (%s)", conn.Error) + } + str += "\n" + WriteStdout("%s\n", str) + } + return nil +} + +func connDisconnectAll() error { + resp, err := wshclient.ConnStatusCommand(RpcClient, nil) + if err != nil { + return fmt.Errorf("getting connection status: %w", err) + } + if len(resp) == 0 { + return nil + } + for _, conn := range resp { + if conn.Status == "connected" { + err := connDisconnect(conn.Connection) + if err != nil { + WriteStdout("error disconnecting %q: %v\n", conn.Connection, err) + } + } + } + return nil +} + +func connEnsure(connName string) error { + err := wshclient.ConnEnsureCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("ensuring connection: %w", err) + } + WriteStdout("wsh ensured on connection %q\n", connName) + return nil +} + +func connReinstall(connName string) error { + err := wshclient.ConnReinstallWshCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("reinstalling connection: %w", err) + } + WriteStdout("wsh reinstalled on connection %q\n", connName) + return nil +} + +func connDisconnect(connName string) error { + err := wshclient.ConnDisconnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 10000}) + if err != nil { + return fmt.Errorf("disconnecting %q error: %w", connName, err) + } + WriteStdout("disconnected %q\n", connName) + return nil +} + +func connConnect(connName string) error { + err := wshclient.ConnConnectCommand(RpcClient, connName, &wshrpc.RpcOpts{Timeout: 60000}) + if err != nil { + return fmt.Errorf("connecting connection: %w", err) + } + WriteStdout("connected connection %q\n", connName) + return nil +} + +func connRun(cmd *cobra.Command, args []string) error { + connCmd := args[0] + var connName string + if connCmd != "status" && connCmd != "disconnectall" { + if len(args) < 2 { + return fmt.Errorf("connection name is required %q", connCmd) + } + connName = args[1] + _, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("cannot parse connection name: %w", err) + } + } + if connCmd == "status" { + return connStatus() + } else if connCmd == "ensure" { + return connEnsure(connName) + } else if connCmd == "reinstall" { + return connReinstall(connName) + } else if connCmd == "disconnect" { + return connDisconnect(connName) + } else if connCmd == "disconnectall" { + return connDisconnectAll() + } else if connCmd == "connect" { + return connConnect(connName) + } else { + return fmt.Errorf("unknown command %q", connCmd) + } +} diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go new file mode 100644 index 000000000..cc00a694e --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -0,0 +1,32 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" +) + +var serverCmd = &cobra.Command{ + Use: "connserver", + Hidden: true, + Short: "remote server to power wave blocks", + Args: cobra.NoArgs, + Run: serverRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(serverCmd) +} + +func serverRun(cmd *cobra.Command, args []string) { + WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) + go wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) + RpcClient.SetServerImpl(&wshremote.ServerImpl{LogWriter: os.Stdout}) + + select {} // run forever +} diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go new file mode 100644 index 000000000..6d30d272b --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-deleteblock.go @@ -0,0 +1,52 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +var deleteBlockCmd = &cobra.Command{ + Use: "deleteblock", + Short: "delete a block", + Args: cobra.ExactArgs(1), + Run: deleteBlockRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(deleteBlockCmd) +} + +func deleteBlockRun(cmd *cobra.Command, args []string) { + oref := args[0] + if oref == "" { + WriteStderr("[error] oref is required\n") + return + } + err := validateEasyORef(oref) + if err != nil { + WriteStderr("[error]%v\n", err) + return + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + WriteStderr("[error] resolving oref: %v\n", err) + return + } + if fullORef.OType != "block" { + WriteStderr("[error] oref is not a block\n") + return + } + deleteBlockData := &wshrpc.CommandDeleteBlockData{ + BlockId: fullORef.OID, + } + _, err = RpcClient.SendRpcRequest(wshrpc.Command_DeleteBlock, deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + WriteStderr("[error] deleting block: %v\n", err) + return + } + WriteStdout("block deleted\n") +} diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go new file mode 100644 index 000000000..0a32af9fd --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-editor.go @@ -0,0 +1,75 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "io/fs" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var editMagnified bool + +var editorCmd = &cobra.Command{ + Use: "editor", + Short: "edit a file (blocks until editor is closed)", + Args: cobra.ExactArgs(1), + Run: editorRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + editCmd.Flags().BoolVarP(&editMagnified, "magnified", "m", false, "open view in magnified mode") + rootCmd.AddCommand(editorCmd) +} + +func editorRun(cmd *cobra.Command, args []string) { + fileArg := args[0] + absFile, err := filepath.Abs(fileArg) + if err != nil { + WriteStderr("[error] getting absolute path: %v\n", err) + return + } + _, err = os.Stat(absFile) + if err == fs.ErrNotExist { + WriteStderr("[error] file does not exist: %q\n", absFile) + return + } + if err != nil { + WriteStderr("[error] getting file info: %v\n", err) + return + } + wshCmd := wshrpc.CommandCreateBlockData{ + BlockDef: &waveobj.BlockDef{ + Meta: map[string]any{ + waveobj.MetaKey_View: "preview", + waveobj.MetaKey_File: absFile, + waveobj.MetaKey_Edit: true, + }, + }, + Magnified: editMagnified, + } + if RpcContext.Conn != "" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn + } + blockRef, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + WriteStderr("[error] running view command: %v\r\n", err) + return + } + doneCh := make(chan bool) + RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { + if event.HasScope(blockRef.String()) { + close(doneCh) + } + }) + wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{blockRef.String()}}, nil) + <-doneCh +} diff --git a/cmd/wsh/cmd/wshcmd-getmeta.go b/cmd/wsh/cmd/wshcmd-getmeta.go new file mode 100644 index 000000000..f30fabba4 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-getmeta.go @@ -0,0 +1,68 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var getMetaCmd = &cobra.Command{ + Use: "getmeta {blockid|blocknum|this} [key]", + Short: "get metadata for an entity", + Args: cobra.RangeArgs(1, 2), + Run: getMetaRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(getMetaCmd) +} + +func getMetaRun(cmd *cobra.Command, args []string) { + oref := args[0] + if oref == "" { + WriteStderr("[error] oref is required") + return + } + err := validateEasyORef(oref) + if err != nil { + WriteStderr("[error] %v\n", err) + return + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + WriteStderr("[error] resolving oref: %v\n", err) + return + } + resp, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ORef: *fullORef}, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + WriteStderr("[error] getting metadata: %v\n", err) + return + } + if len(args) > 1 { + val, ok := resp[args[1]] + if !ok { + return + } + outBArr, err := json.MarshalIndent(val, "", " ") + if err != nil { + WriteStderr("[error] formatting metadata: %v\n", err) + return + } + outStr := string(outBArr) + WriteStdout(outStr + "\n") + } else { + outBArr, err := json.MarshalIndent(resp, "", " ") + if err != nil { + WriteStderr("[error] formatting metadata: %v\n", err) + return + } + outStr := string(outBArr) + WriteStdout(outStr + "\n") + } +} diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go new file mode 100644 index 000000000..6bffc992e --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -0,0 +1,43 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +func init() { + rootCmd.AddCommand(htmlCmd) +} + +var htmlCmd = &cobra.Command{ + Use: "html", + Hidden: true, + Short: "Launch a demo html-mode terminal", + Run: htmlRun, + PreRunE: preRunSetupRpcClient, +} + +func htmlRun(cmd *cobra.Command, args []string) { + defer wshutil.DoShutdown("normal exit", 0, true) + setTermHtmlMode() + for { + var buf [1]byte + _, err := WrappedStdin.Read(buf[:]) + if err != nil { + wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true) + } + if buf[0] == 0x03 { + wshutil.DoShutdown("read Ctrl-C from stdin", 1, true) + break + } + if buf[0] == 'x' { + wshutil.DoShutdown("read 'x' from stdin", 0, true) + break + } + } +} diff --git a/cmd/wsh/cmd/wshcmd-rcfiles.go b/cmd/wsh/cmd/wshcmd-rcfiles.go new file mode 100644 index 000000000..84a432d1f --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-rcfiles.go @@ -0,0 +1,34 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "path/filepath" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +var WshBinDir = ".waveterm/bin" + +func init() { + rootCmd.AddCommand(rcfilesCmd) +} + +var rcfilesCmd = &cobra.Command{ + Use: "rcfiles", + Hidden: true, + Short: "Generate the rc files needed for various shells", + Run: func(cmd *cobra.Command, args []string) { + home := wavebase.GetHomeDir() + waveDir := filepath.Join(home, ".waveterm") + winBinDir := filepath.Join(waveDir, "bin") + err := shellutil.InitRcFiles(waveDir, winBinDir) + if err != nil { + WriteStderr(err.Error()) + return + } + }, +} diff --git a/cmd/wsh/cmd/wshcmd-readfile.go b/cmd/wsh/cmd/wshcmd-readfile.go new file mode 100644 index 000000000..ccda2798c --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-readfile.go @@ -0,0 +1,53 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var readFileCmd = &cobra.Command{ + Use: "readfile", + Short: "read a blockfile", + Args: cobra.ExactArgs(2), + Run: runReadFile, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(readFileCmd) +} + +func runReadFile(cmd *cobra.Command, args []string) { + oref := args[0] + if oref == "" { + WriteStderr("[error] oref is required\n") + return + } + err := validateEasyORef(oref) + if err != nil { + WriteStderr("[error] %v\n", err) + return + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + WriteStderr("error resolving oref: %v\n", err) + return + } + resp64, err := wshclient.FileReadCommand(RpcClient, wshrpc.CommandFileData{ZoneId: fullORef.OID, FileName: args[1]}, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + WriteStderr("[error] reading file: %v\n", err) + return + } + resp, err := base64.StdEncoding.DecodeString(resp64) + if err != nil { + WriteStderr("[error] decoding file: %v\n", err) + return + } + WriteStdout(string(resp)) +} diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go new file mode 100644 index 000000000..411ff08b2 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -0,0 +1,182 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "io" + "os" + "regexp" + "runtime/debug" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +var ( + rootCmd = &cobra.Command{ + Use: "wsh", + Short: "CLI tool to control Wave Terminal", + Long: `wsh is a small utility that lets you do cool things with Wave Terminal, right from the command line`, + SilenceUsage: true, + } +) + +var usingHtmlMode bool +var WrappedStdin io.Reader = os.Stdin +var RpcClient *wshutil.WshRpc +var RpcContext wshrpc.RpcContext +var UsingTermWshMode bool + +func extraShutdownFn() { + if usingHtmlMode { + cmd := &wshrpc.CommandSetMetaData{ + Meta: map[string]any{"term:mode": nil}, + } + RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil) + time.Sleep(10 * time.Millisecond) + } +} + +func WriteStderr(fmtStr string, args ...interface{}) { + output := fmt.Sprintf(fmtStr, args...) + if UsingTermWshMode { + output = strings.ReplaceAll(output, "\n", "\r\n") + } + fmt.Fprint(os.Stderr, output) +} + +func WriteStdout(fmtStr string, args ...interface{}) { + output := fmt.Sprintf(fmtStr, args...) + if UsingTermWshMode { + output = strings.ReplaceAll(output, "\n", "\r\n") + } + fmt.Print(output) +} + +func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { + err := setupRpcClient(nil) + if err != nil { + return err + } + return nil +} + +// returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) +func setupRpcClient(serverImpl wshutil.ServerImpl) error { + jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) + if jwtToken == "" { + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) + UsingTermWshMode = true + RpcClient, WrappedStdin = wshutil.SetupTerminalRpcClient(serverImpl) + return nil + } + rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) + if err != nil { + return fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + RpcContext = *rpcCtx + sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) + if err != nil { + return fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + RpcClient, err = wshutil.SetupDomainSocketRpcClient(sockName, serverImpl) + if err != nil { + return fmt.Errorf("error setting up domain socket rpc client: %v", err) + } + wshclient.AuthenticateCommand(RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true}) + // note we don't modify WrappedStdin here (just use os.Stdin) + return nil +} + +func setTermHtmlMode() { + wshutil.SetExtraShutdownFunc(extraShutdownFn) + cmd := &wshrpc.CommandSetMetaData{ + Meta: map[string]any{"term:mode": "html"}, + } + err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error setting html mode: %v\r\n", err) + } + usingHtmlMode = true +} + +var oidRe = regexp.MustCompile(`^[0-9a-f]{8}$`) + +func validateEasyORef(oref string) error { + if oref == "this" { + return nil + } + if num, err := strconv.Atoi(oref); err == nil && num >= 1 { + return nil + } + if strings.Contains(oref, ":") { + _, err := waveobj.ParseORef(oref) + if err != nil { + return fmt.Errorf("invalid ORef: %v", err) + } + return nil + } + if len(oref) == 8 { + if !oidRe.MatchString(oref) { + return fmt.Errorf("invalid short OID format, must only use 0-9a-f: %q", oref) + } + return nil + } + _, err := uuid.Parse(oref) + if err != nil { + return fmt.Errorf("invalid object reference (must be UUID, or a positive integer): %v", err) + } + return nil +} + +func isFullORef(orefStr string) bool { + _, err := waveobj.ParseORef(orefStr) + return err == nil +} + +func resolveSimpleId(id string) (*waveobj.ORef, error) { + if isFullORef(id) { + orefObj, err := waveobj.ParseORef(id) + if err != nil { + return nil, fmt.Errorf("error parsing full ORef: %v", err) + } + return &orefObj, nil + } + rtnData, err := wshclient.ResolveIdsCommand(RpcClient, wshrpc.CommandResolveIdsData{Ids: []string{id}}, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return nil, fmt.Errorf("error resolving ids: %v", err) + } + oref, ok := rtnData.ResolvedIds[id] + if !ok { + return nil, fmt.Errorf("id not found: %q", id) + } + return &oref, nil +} + +// Execute executes the root command. +func Execute() { + defer func() { + r := recover() + if r != nil { + WriteStderr("[panic] %v\n", r) + debug.PrintStack() + wshutil.DoShutdown("", 1, true) + } else { + wshutil.DoShutdown("", 0, false) + } + }() + err := rootCmd.Execute() + if err != nil { + wshutil.DoShutdown("", 1, true) + return + } +} diff --git a/cmd/wsh/cmd/wshcmd-setconfig.go b/cmd/wsh/cmd/wshcmd-setconfig.go new file mode 100644 index 000000000..7c859e20a --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-setconfig.go @@ -0,0 +1,39 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var setConfigCmd = &cobra.Command{ + Use: "setconfig", + Short: "set config", + Args: cobra.MinimumNArgs(1), + Run: setConfigRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(setConfigCmd) +} + +func setConfigRun(cmd *cobra.Command, args []string) { + metaSetsStrs := args[:] + meta, err := parseMetaSets(metaSetsStrs) + if err != nil { + WriteStderr("[error] %v\n", err) + return + } + commandData := wconfig.MetaSettingsType{MetaMapType: meta} + err = wshclient.SetConfigCommand(RpcClient, commandData, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + WriteStderr("[error] setting config: %v\n", err) + return + } + WriteStdout("config set\n") +} diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go new file mode 100644 index 000000000..c400363dd --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-setmeta.go @@ -0,0 +1,93 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +var setMetaCmd = &cobra.Command{ + Use: "setmeta {blockid|blocknum|this} key=value ...", + Short: "set metadata for an entity", + Args: cobra.MinimumNArgs(2), + Run: setMetaRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(setMetaCmd) +} + +func parseMetaSets(metaSets []string) (map[string]interface{}, error) { + meta := make(map[string]interface{}) + for _, metaSet := range metaSets { + fields := strings.Split(metaSet, "=") + if len(fields) != 2 { + return nil, fmt.Errorf("invalid meta set: %q", metaSet) + } + setVal := fields[1] + if setVal == "" || setVal == "null" { + meta[fields[0]] = nil + } else if setVal == "true" { + meta[fields[0]] = true + } else if setVal == "false" { + meta[fields[0]] = false + } else if setVal[0] == '[' || setVal[0] == '{' { + var val interface{} + err := json.Unmarshal([]byte(setVal), &val) + if err != nil { + return nil, fmt.Errorf("invalid json value: %v", err) + } + meta[fields[0]] = val + } else { + fval, err := strconv.ParseFloat(setVal, 64) + if err == nil { + meta[fields[0]] = fval + } else { + meta[fields[0]] = setVal + } + } + } + return meta, nil +} + +func setMetaRun(cmd *cobra.Command, args []string) { + oref := args[0] + metaSetsStrs := args[1:] + if oref == "" { + WriteStderr("[error] oref is required\n") + return + } + err := validateEasyORef(oref) + if err != nil { + WriteStderr("[error] %v\n", err) + return + } + meta, err := parseMetaSets(metaSetsStrs) + if err != nil { + WriteStderr("[error] %v\n", err) + return + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + WriteStderr("[error] resolving oref: %v\n", err) + return + } + setMetaWshCmd := &wshrpc.CommandSetMetaData{ + ORef: *fullORef, + Meta: meta, + } + _, err = RpcClient.SendRpcRequest(wshrpc.Command_SetMeta, setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + WriteStderr("[error] setting metadata: %v\n", err) + return + } + WriteStdout("metadata set\n") +} diff --git a/cmd/wsh/cmd/wshcmd-shell-unix.go b/cmd/wsh/cmd/wshcmd-shell-unix.go new file mode 100644 index 000000000..d378c4b07 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-shell-unix.go @@ -0,0 +1,62 @@ +//go:build !windows + +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "os" + "os/user" + "runtime" + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" +) + +func init() { + rootCmd.AddCommand(shellCmd) +} + +var shellCmd = &cobra.Command{ + Use: "shell", + Hidden: true, + Short: "Print the login shell of this user", + Run: func(cmd *cobra.Command, args []string) { + WriteStdout(shellCmdInner()) + }, +} + +func shellCmdInner() string { + if runtime.GOOS == "darwin" { + return shellutil.GetMacUserShell() + "\n" + } + user, err := user.Current() + if err != nil { + return "/bin/bash\n" + } + + passwd, err := os.Open("/etc/passwd") + if err != nil { + return "/bin/bash\n" + } + + scanner := bufio.NewScanner(passwd) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + parts := strings.Split(line, ":") + + if len(parts) != 7 { + continue + } + + if parts[0] == user.Username { + return parts[6] + "\n" + } + } + // none found + return "bin/bash\n" +} diff --git a/cmd/wsh/cmd/wshcmd-shell-win.go b/cmd/wsh/cmd/wshcmd-shell-win.go new file mode 100644 index 000000000..367d50b71 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-shell-win.go @@ -0,0 +1,27 @@ +//go:build windows + +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(shellCmd) +} + +var shellCmd = &cobra.Command{ + Use: "shell", + Hidden: true, + Short: "Print the login shell of this user", + Run: func(cmd *cobra.Command, args []string) { + shellCmdInner() + }, +} + +func shellCmdInner() { + WriteStderr("not implemented/n") +} diff --git a/cmd/wsh/cmd/wshcmd-ssh.go b/cmd/wsh/cmd/wshcmd-ssh.go new file mode 100644 index 000000000..980479918 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-ssh.go @@ -0,0 +1,44 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var sshCmd = &cobra.Command{ + Use: "ssh", + Short: "connect this terminal to a remote host", + Args: cobra.ExactArgs(1), + Run: sshRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(sshCmd) +} + +func sshRun(cmd *cobra.Command, args []string) { + sshArg := args[0] + blockId := RpcContext.BlockId + if blockId == "" { + WriteStderr("[error] cannot determine blockid (not in JWT)\n") + return + } + data := wshrpc.CommandSetMetaData{ + ORef: waveobj.MakeORef(waveobj.OType_Block, blockId), + Meta: map[string]any{ + waveobj.MetaKey_Connection: sshArg, + }, + } + err := wshclient.SetMetaCommand(RpcClient, data, nil) + if err != nil { + WriteStderr("[error] setting switching connection: %v\n", err) + return + } + WriteStderr("switched connection to %q\n", sshArg) +} diff --git a/cmd/wsh/cmd/wshcmd-term.go b/cmd/wsh/cmd/wshcmd-term.go new file mode 100644 index 000000000..7cd3288bb --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-term.go @@ -0,0 +1,70 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var termMagnified bool + +var termCmd = &cobra.Command{ + Use: "term", + Short: "open a terminal in directory", + Args: cobra.RangeArgs(0, 1), + Run: termRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + termCmd.Flags().BoolVarP(&termMagnified, "magnified", "m", false, "open view in magnified mode") + rootCmd.AddCommand(termCmd) +} + +func termRun(cmd *cobra.Command, args []string) { + var cwd string + if len(args) > 0 { + cwd = args[0] + cwd = wavebase.ExpandHomeDir(cwd) + } else { + var err error + cwd, err = os.Getwd() + if err != nil { + WriteStderr("[error] getting current directory: %v\n", err) + return + } + } + var err error + cwd, err = filepath.Abs(cwd) + if err != nil { + WriteStderr("[error] getting absolute path: %v\n", err) + return + } + createBlockData := wshrpc.CommandCreateBlockData{ + BlockDef: &waveobj.BlockDef{ + Meta: map[string]interface{}{ + waveobj.MetaKey_View: "term", + waveobj.MetaKey_CmdCwd: cwd, + waveobj.MetaKey_Controller: "shell", + }, + }, + Magnified: termMagnified, + } + if RpcContext.Conn != "" { + createBlockData.BlockDef.Meta[waveobj.MetaKey_Connection] = RpcContext.Conn + } + oref, err := wshclient.CreateBlockCommand(RpcClient, createBlockData, nil) + if err != nil { + WriteStderr("[error] creating new terminal block: %v\n", err) + return + } + WriteStdout("terminal block created: %s\n", oref) +} diff --git a/cmd/wsh/cmd/wshcmd-version.go b/cmd/wsh/cmd/wshcmd-version.go new file mode 100644 index 000000000..fadbcd8ec --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-version.go @@ -0,0 +1,23 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +func init() { + rootCmd.AddCommand(versionCmd) +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of wsh", + Run: func(cmd *cobra.Command, args []string) { + WriteStdout(fmt.Sprintf("wsh v%s\n", wavebase.WaveVersion)) + }, +} diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go new file mode 100644 index 000000000..0aed5d482 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-view.go @@ -0,0 +1,91 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +var viewMagnified bool + +var viewCmd = &cobra.Command{ + Use: "view {file|directory|URL}", + Short: "preview/edit a file or directory", + Args: cobra.ExactArgs(1), + Run: viewRun, + PreRunE: preRunSetupRpcClient, +} + +var editCmd = &cobra.Command{ + Use: "edit {file}", + Short: "edit a file", + Args: cobra.ExactArgs(1), + Run: viewRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + viewCmd.Flags().BoolVarP(&viewMagnified, "magnified", "m", false, "open view in magnified mode") + rootCmd.AddCommand(viewCmd) + rootCmd.AddCommand(editCmd) +} + +func viewRun(cmd *cobra.Command, args []string) { + fileArg := args[0] + conn := RpcContext.Conn + var wshCmd *wshrpc.CommandCreateBlockData + if strings.HasPrefix(fileArg, "http://") || strings.HasPrefix(fileArg, "https://") { + wshCmd = &wshrpc.CommandCreateBlockData{ + BlockDef: &waveobj.BlockDef{ + Meta: map[string]any{ + waveobj.MetaKey_View: "web", + waveobj.MetaKey_Url: fileArg, + }, + }, + Magnified: viewMagnified, + } + } else { + absFile, err := filepath.Abs(fileArg) + if err != nil { + WriteStderr("[error] getting absolute path: %v\n", err) + return + } + _, err = os.Stat(absFile) + if err == fs.ErrNotExist { + WriteStderr("[error] file does not exist: %q\n", absFile) + return + } + if err != nil { + WriteStderr("[error] getting file info: %v\n", err) + return + } + wshCmd = &wshrpc.CommandCreateBlockData{ + BlockDef: &waveobj.BlockDef{ + Meta: map[string]interface{}{ + waveobj.MetaKey_View: "preview", + waveobj.MetaKey_File: absFile, + }, + }, + Magnified: viewMagnified, + } + if cmd.Use == "edit" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true + } + if conn != "" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn + } + } + _, err := RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + WriteStderr("[error] running view command: %v\r\n", err) + return + } +} diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go new file mode 100644 index 000000000..bd2985931 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -0,0 +1,120 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +var webCmd = &cobra.Command{ + Use: "web [open|get|set]", + Short: "web commands", + PersistentPreRunE: preRunSetupRpcClient, +} + +var webOpenCmd = &cobra.Command{ + Use: "open url", + Short: "open a url a web widget", + Args: cobra.ExactArgs(1), + RunE: webOpenRun, +} + +var webGetCmd = &cobra.Command{ + Use: "get [--inner] [--all] [--json] blockid css-selector", + Short: "get the html for a css selector", + Args: cobra.ExactArgs(2), + Hidden: true, + RunE: webGetRun, +} + +var webGetInner bool +var webGetAll bool +var webGetJson bool +var webOpenMagnified bool + +func init() { + webOpenCmd.Flags().BoolVarP(&webOpenMagnified, "magnified", "m", false, "open view in magnified mode") + webCmd.AddCommand(webOpenCmd) + webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)") + webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)") + webGetCmd.Flags().BoolVarP(&webGetJson, "json", "", false, "output as json") + webCmd.AddCommand(webGetCmd) + rootCmd.AddCommand(webCmd) +} + +func webGetRun(cmd *cobra.Command, args []string) error { + oref := args[0] + if oref == "" { + return fmt.Errorf("blockid not specified") + } + err := validateEasyORef(oref) + if err != nil { + return err + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + return fmt.Errorf("resolving blockid: %w", err) + } + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil) + if err != nil { + return fmt.Errorf("getting block info: %w", err) + } + if blockInfo.Meta.GetString(waveobj.MetaKey_View, "") != "web" { + return fmt.Errorf("block %s is not a web block", fullORef.OID) + } + data := wshrpc.CommandWebSelectorData{ + WindowId: blockInfo.WindowId, + BlockId: fullORef.OID, + TabId: blockInfo.TabId, + Selector: args[1], + Opts: &wshrpc.WebSelectorOpts{ + Inner: webGetInner, + All: webGetAll, + }, + } + output, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: 5000, + }) + if err != nil { + return err + } + if webGetJson { + barr, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("json encoding: %w", err) + } + WriteStdout("%s\n", string(barr)) + } else { + for _, item := range output { + WriteStdout("%s\n", item) + } + } + return nil +} + +func webOpenRun(cmd *cobra.Command, args []string) error { + wshCmd := wshrpc.CommandCreateBlockData{ + BlockDef: &waveobj.BlockDef{ + Meta: map[string]any{ + waveobj.MetaKey_View: "web", + waveobj.MetaKey_Url: args[0], + }, + }, + Magnified: webOpenMagnified, + } + oref, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, nil) + if err != nil { + return fmt.Errorf("creating block: %w", err) + } + WriteStdout("created block %s\n", oref) + return nil +} diff --git a/cmd/wsh/main-wsh.go b/cmd/wsh/main-wsh.go new file mode 100644 index 000000000..cbf262f06 --- /dev/null +++ b/cmd/wsh/main-wsh.go @@ -0,0 +1,19 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/wavetermdev/waveterm/cmd/wsh/cmd" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +// set by main-server.go +var WaveVersion = "0.0.0" +var BuildTime = "0" + +func main() { + wavebase.WaveVersion = WaveVersion + wavebase.BuildTime = BuildTime + cmd.Execute() +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 000000000..d2a3604b8 --- /dev/null +++ b/db/db.go @@ -0,0 +1,12 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package db + +import "embed" + +//go:embed migrations-filestore/*.sql +var FilestoreMigrationFS embed.FS + +//go:embed migrations-wstore/*.sql +var WStoreMigrationFS embed.FS diff --git a/db/migrations-filestore/000001_init.down.sql b/db/migrations-filestore/000001_init.down.sql new file mode 100644 index 000000000..534c404e7 --- /dev/null +++ b/db/migrations-filestore/000001_init.down.sql @@ -0,0 +1,3 @@ +DROP TABLE db_wave_file; + +DROP TABLE db_file_data; diff --git a/db/migrations-filestore/000001_init.up.sql b/db/migrations-filestore/000001_init.up.sql new file mode 100644 index 000000000..af9fcf8c0 --- /dev/null +++ b/db/migrations-filestore/000001_init.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE db_wave_file ( + zoneid varchar(36) NOT NULL, + name varchar(200) NOT NULL, + size bigint NOT NULL, + createdts bigint NOT NULL, + modts bigint NOT NULL, + opts json NOT NULL, + meta json NOT NULL, + PRIMARY KEY (zoneid, name) +); + +CREATE TABLE db_file_data ( + zoneid varchar(36) NOT NULL, + name varchar(200) NOT NULL, + partidx int NOT NULL, + data blob NOT NULL, + PRIMARY KEY(zoneid, name, partidx) +); + diff --git a/db/migrations-wstore/000001_init.down.sql b/db/migrations-wstore/000001_init.down.sql new file mode 100644 index 000000000..177ce0860 --- /dev/null +++ b/db/migrations-wstore/000001_init.down.sql @@ -0,0 +1,7 @@ +DROP TABLE db_client; + +DROP TABLE db_workspace; + +DROP TABLE db_tab; + +DROP TABLE db_block; diff --git a/db/migrations-wstore/000001_init.up.sql b/db/migrations-wstore/000001_init.up.sql new file mode 100644 index 000000000..34c3b8832 --- /dev/null +++ b/db/migrations-wstore/000001_init.up.sql @@ -0,0 +1,30 @@ +CREATE TABLE db_client ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_window ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_workspace ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_tab ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + +CREATE TABLE db_block ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); + diff --git a/db/migrations-wstore/000002_init.down.sql b/db/migrations-wstore/000002_init.down.sql new file mode 100644 index 000000000..f7ef05cd1 --- /dev/null +++ b/db/migrations-wstore/000002_init.down.sql @@ -0,0 +1 @@ +DROP TABLE db_layout; diff --git a/db/migrations-wstore/000002_init.up.sql b/db/migrations-wstore/000002_init.up.sql new file mode 100644 index 000000000..b0a05456c --- /dev/null +++ b/db/migrations-wstore/000002_init.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE db_layout ( + oid varchar(36) PRIMARY KEY, + version int NOT NULL, + data json NOT NULL +); diff --git a/db/migrations-wstore/000003_activity.down.sql b/db/migrations-wstore/000003_activity.down.sql new file mode 100644 index 000000000..f355a3156 --- /dev/null +++ b/db/migrations-wstore/000003_activity.down.sql @@ -0,0 +1 @@ +DROP TABLE db_activity; \ No newline at end of file diff --git a/db/migrations-wstore/000003_activity.up.sql b/db/migrations-wstore/000003_activity.up.sql new file mode 100644 index 000000000..142922bac --- /dev/null +++ b/db/migrations-wstore/000003_activity.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE db_activity ( + day varchar(20) PRIMARY KEY, + uploaded boolean NOT NULL, + tdata json NOT NULL, + tzname varchar(50) NOT NULL, + tzoffset int NOT NULL, + clientversion varchar(20) NOT NULL, + clientarch varchar(20) NOT NULL, + buildtime varchar(20) NOT NULL DEFAULT '-', + osrelease varchar(20) NOT NULL DEFAULT '-' +); \ No newline at end of file diff --git a/db/migrations-wstore/000004_history.down.sql b/db/migrations-wstore/000004_history.down.sql new file mode 100644 index 000000000..556e9b40a --- /dev/null +++ b/db/migrations-wstore/000004_history.down.sql @@ -0,0 +1 @@ +DROP TABLE history_migrated; \ No newline at end of file diff --git a/db/migrations-wstore/000004_history.up.sql b/db/migrations-wstore/000004_history.up.sql new file mode 100644 index 000000000..6b0f2a684 --- /dev/null +++ b/db/migrations-wstore/000004_history.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE history_migrated ( + historyid varchar(36) PRIMARY KEY, + ts bigint NOT NULL, + remotename varchar(200) NOT NULL, + haderror boolean NOT NULL, + cmdstr text NOT NULL, + exitcode int NULL DEFAULT NULL, + durationms int NULL DEFAULT NULL +); diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs new file mode 100644 index 000000000..8ff672a26 --- /dev/null +++ b/electron-builder.config.cjs @@ -0,0 +1,103 @@ +const { Arch } = require("electron-builder"); +const pkg = require("./package.json"); +const fs = require("fs"); +const path = require("path"); + +/** + * @type {import('electron-builder').Configuration} + * @see https://www.electron.build/configuration/configuration + */ +const config = { + appId: pkg.build.appId, + productName: pkg.productName, + executableName: pkg.name, + artifactName: "${productName}-${platform}-${arch}-${version}.${ext}", + generateUpdatesFilesForAllChannels: true, + npmRebuild: false, + nodeGypRebuild: false, + electronCompile: false, + files: [ + { + from: "./dist", + to: "./dist", + filter: ["**/*", "!bin/*", "bin/wavesrv.${arch}*", "bin/wsh*"], + }, + { + from: ".", + to: ".", + filter: ["package.json"], + }, + "!node_modules", // We don't need electron-builder to package in Node modules as Vite has already bundled any code that our program is using. + ], + directories: { + output: "make", + }, + asarUnpack: [ + "dist/bin/**/*", // wavesrv and wsh binaries + ], + mac: { + target: [ + { + target: "zip", + arch: ["universal", "arm64", "x64"], + }, + { + target: "dmg", + arch: ["universal", "arm64", "x64"], + }, + ], + icon: "build/icons.icns", + category: "public.app-category.developer-tools", + minimumSystemVersion: "10.15.0", + mergeASARs: true, + singleArchFiles: "dist/bin/wavesrv.*", + }, + linux: { + artifactName: "${name}-${platform}-${arch}-${version}.${ext}", + category: "TerminalEmulator", + icon: "build/icons.icns", + target: ["zip", "deb", "rpm", "AppImage", "pacman"], + synopsis: pkg.description, + description: null, + desktop: { + Name: pkg.productName, + Comment: pkg.description, + Keywords: "developer;terminal;emulator;", + category: "Development;Utility;", + }, + }, + win: { + icon: "build/icons.icns", + publisherName: "Command Line Inc", + target: ["nsis", "msi", "zip"], + certificateSubjectName: "Command Line Inc", + certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH, + signingHashAlgorithms: ["sha256"], + }, + appImage: { + license: "LICENSE", + }, + publish: { + provider: "generic", + url: "https://dl.waveterm.dev/releases-w2", + }, + afterPack: (context) => { + // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary. + if (context.electronPlatformName === "darwin" && context.arch === Arch.universal) { + const packageBinDir = path.resolve( + context.appOutDir, + `${pkg.name}.app/Contents/Resources/app.asar.unpacked/dist/bin` + ); + + // Reapply file permissions to the wavesrv binaries in the final app package + fs.readdirSync(packageBinDir, { + recursive: true, + withFileTypes: true, + }) + .filter((f) => f.isFile() && f.name.startsWith("wavesrv")) + .forEach((f) => fs.chmodSync(path.resolve(f.parentPath ?? f.path, f.name), 0o755)); // 0o755 corresponds to -rwxr-xr-x + } + }, +}; + +module.exports = config; diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 000000000..2a7ab6fe0 --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,75 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "electron-vite"; +import flow from "rollup-plugin-flow"; +import { ViteImageOptimizer } from "vite-plugin-image-optimizer"; +import { viteStaticCopy } from "vite-plugin-static-copy"; +import svgr from "vite-plugin-svgr"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + main: { + root: ".", + build: { + rollupOptions: { + input: { + index: "emain/emain.ts", + }, + }, + outDir: "dist/main", + }, + plugins: [tsconfigPaths(), flow()], + resolve: { + alias: { + "@": "frontend", + }, + }, + }, + preload: { + root: ".", + build: { + sourcemap: true, + rollupOptions: { + input: { + index: "emain/preload.ts", + }, + output: { + format: "cjs", + }, + }, + outDir: "dist/preload", + }, + plugins: [tsconfigPaths(), flow()], + }, + renderer: { + root: ".", + build: { + target: "es6", + sourcemap: true, + outDir: "dist/frontend", + rollupOptions: { + input: { + index: "index.html", + }, + }, + }, + server: { + open: false, + }, + plugins: [ + ViteImageOptimizer(), + tsconfigPaths(), + svgr({ + svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true }, + include: "**/*.svg", + }), + react({}), + flow(), + viteStaticCopy({ + targets: [{ src: "node_modules/monaco-editor/min/vs/*", dest: "monaco" }], + }), + ], + }, +}); diff --git a/emain/authkey.ts b/emain/authkey.ts new file mode 100644 index 000000000..257794253 --- /dev/null +++ b/emain/authkey.ts @@ -0,0 +1,23 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ipcMain } from "electron"; +import { getWebServerEndpoint, getWSServerEndpoint } from "../frontend/util/endpoints"; + +const AuthKeyHeader = "X-AuthKey"; +export const AuthKeyEnv = "AUTH_KEY"; +export const AuthKey = crypto.randomUUID(); + +ipcMain.on("get-auth-key", (event) => { + event.returnValue = AuthKey; +}); + +export function configureAuthKeyRequestInjection(session: Electron.Session) { + const filter: Electron.WebRequestFilter = { + urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`], + }; + session.webRequest.onBeforeSendHeaders(filter, (details, callback) => { + details.requestHeaders[AuthKeyHeader] = AuthKey; + callback({ requestHeaders: details.requestHeaders }); + }); +} diff --git a/emain/emain-web.ts b/emain/emain-web.ts new file mode 100644 index 000000000..842e266bf --- /dev/null +++ b/emain/emain-web.ts @@ -0,0 +1,64 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BrowserWindow, ipcMain, webContents, WebContents } from "electron"; + +export function getWebContentsByBlockId(win: BrowserWindow, tabId: string, blockId: string): Promise { + const prtn = new Promise((resolve, reject) => { + const randId = Math.floor(Math.random() * 1000000000).toString(); + const respCh = `getWebContentsByBlockId-${randId}`; + win.webContents.send("webcontentsid-from-blockid", blockId, respCh); + ipcMain.once(respCh, (event, webContentsId) => { + if (webContentsId == null) { + resolve(null); + return; + } + const wc = webContents.fromId(parseInt(webContentsId)); + resolve(wc); + }); + setTimeout(() => { + reject(new Error("timeout waiting for response")); + }, 2000); + }); + return prtn; +} + +function escapeSelector(selector: string): string { + return selector + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/'/g, "\\'") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +export type WebGetOpts = { + all?: boolean; + inner?: boolean; +}; + +export async function webGetSelector(wc: WebContents, selector: string, opts?: WebGetOpts): Promise { + if (!wc || !selector) { + return null; + } + const escapedSelector = escapeSelector(selector); + const queryMethod = opts?.all ? "querySelectorAll" : "querySelector"; + const prop = opts?.inner ? "innerHTML" : "outerHTML"; + const execExpr = ` + (() => { + const toArr = x => (x instanceof NodeList) ? Array.from(x) : (x ? [x] : []); + try { + const result = document.${queryMethod}("${escapedSelector}"); + const value = toArr(result).map(el => el.${prop}); + return { value }; + } catch (error) { + return { error: error.message }; + } + })()`; + const results = await wc.executeJavaScript(execExpr); + if (results.error) { + throw new Error(results.error); + } + return results.value; +} diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts new file mode 100644 index 000000000..45812456e --- /dev/null +++ b/emain/emain-wsh.ts @@ -0,0 +1,31 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import electron from "electron"; +import { RpcResponseHelper, WshClient } from "../frontend/app/store/wshclient"; +import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; + +type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; + +export class ElectronWshClientType extends WshClient { + constructor() { + super("electron"); + } + + async handle_webselector(rh: RpcResponseHelper, data: CommandWebSelectorData): Promise { + if (!data.tabid || !data.blockid || !data.windowid) { + throw new Error("tabid and blockid are required"); + } + const windows = electron.BrowserWindow.getAllWindows(); + const win = windows.find((w) => (w as WaveBrowserWindow).waveWindowId === data.windowid); + if (win == null) { + throw new Error(`no window found with id ${data.windowid}`); + } + const wc = await getWebContentsByBlockId(win, data.tabid, data.blockid); + if (wc == null) { + throw new Error(`no webcontents found with blockid ${data.blockid}`); + } + const rtn = await webGetSelector(wc, data.selector, data.opts); + return rtn; + } +} diff --git a/emain/emain.ts b/emain/emain.ts new file mode 100644 index 000000000..5138cb19a --- /dev/null +++ b/emain/emain.ts @@ -0,0 +1,881 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { FastAverageColor } from "fast-average-color"; +import fs from "fs"; +import * as child_process from "node:child_process"; +import * as path from "path"; +import { PNG } from "pngjs"; +import * as readline from "readline"; +import { sprintf } from "sprintf-js"; +import { debounce } from "throttle-debounce"; +import * as util from "util"; +import winston from "winston"; +import { initGlobal } from "../frontend/app/store/global"; +import * as services from "../frontend/app/store/services"; +import { initElectronWshrpc } from "../frontend/app/store/wshrpcutil"; +import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; +import { fetch } from "../frontend/util/fetchutil"; +import * as keyutil from "../frontend/util/keyutil"; +import { fireAndForget } from "../frontend/util/util"; +import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; +import { ElectronWshClientType } from "./emain-wsh"; +import { getAppMenu } from "./menu"; +import { + getElectronAppBasePath, + getGoAppBasePath, + getWaveHomeDir, + getWaveSrvCwd, + getWaveSrvPath, + isDev, + isDevVite, + unameArch, + unamePlatform, +} from "./platform"; +import { configureAutoUpdater, updater } from "./updater"; + +let ElectronWshClient = new ElectronWshClientType(); +const electronApp = electron.app; +let WaveVersion = "unknown"; // set by WAVESRV-ESTART +let WaveBuildTime = 0; // set by WAVESRV-ESTART + +const WaveAppPathVarName = "WAVETERM_APP_PATH"; +const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; +electron.nativeTheme.themeSource = "dark"; + +type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; + +let waveSrvReadyResolve = (value: boolean) => {}; +const waveSrvReady: Promise = new Promise((resolve, _) => { + waveSrvReadyResolve = resolve; +}); +let globalIsQuitting = false; +let globalIsStarting = true; +let globalIsRelaunching = false; + +// for activity updates +let wasActive = true; +let wasInFg = true; + +let webviewFocusId: number = null; // set to the getWebContentsId of the webview that has focus (null if not focused) +let webviewKeys: string[] = []; // the keys to trap when webview has focus + +let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; + +const waveHome = getWaveHomeDir(); + +const oldConsoleLog = console.log; + +const loggerTransports: winston.transport[] = [ + new winston.transports.File({ filename: path.join(getWaveHomeDir(), "waveapp.log"), level: "info" }), +]; +if (isDev) { + loggerTransports.push(new winston.transports.Console()); +} +const loggerConfig = { + level: "info", + format: winston.format.combine( + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.printf((info) => `${info.timestamp} ${info.message}`) + ), + transports: loggerTransports, +}; +const logger = winston.createLogger(loggerConfig); +function log(...msg: any[]) { + try { + logger.info(util.format(...msg)); + } catch (e) { + oldConsoleLog(...msg); + } +} +console.log = log; +console.log( + sprintf( + "waveterm-app starting, WAVETERM_HOME=%s, electronpath=%s gopath=%s arch=%s/%s", + waveHome, + getElectronAppBasePath(), + getGoAppBasePath(), + unamePlatform, + unameArch + ) +); +if (isDev) { + console.log("waveterm-app WAVETERM_DEV set"); +} + +initGlobal({ windowId: null, clientId: null, platform: unamePlatform, environment: "electron" }); + +function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow { + const windowId = event.sender.id; + return electron.BrowserWindow.fromId(windowId); +} + +function setCtrlShift(wc: Electron.WebContents, state: boolean) { + wc.send("control-shift-state-update", state); +} + +function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: WaveKeyboardEvent) { + if (waveEvent.type == "keyup") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift") { + setCtrlShift(sender, false); + } + if (waveEvent.key == "Meta") { + if (waveEvent.control && waveEvent.shift) { + setCtrlShift(sender, true); + } + } + return; + } + if (waveEvent.type == "keydown") { + if (waveEvent.key === "Control" || waveEvent.key === "Shift" || waveEvent.key === "Meta") { + if (waveEvent.control && waveEvent.shift && !waveEvent.meta) { + // Set the control and shift without the Meta key + setCtrlShift(sender, true); + } else { + // Unset if Meta is pressed + setCtrlShift(sender, false); + } + } + return; + } +} + +function handleCtrlShiftFocus(sender: Electron.WebContents, focused: boolean) { + if (!focused) { + setCtrlShift(sender, false); + } +} + +function runWaveSrv(): Promise { + let pResolve: (value: boolean) => void; + let pReject: (reason?: any) => void; + const rtnPromise = new Promise((argResolve, argReject) => { + pResolve = argResolve; + pReject = argReject; + }); + const envCopy = { ...process.env }; + envCopy[WaveAppPathVarName] = getGoAppBasePath(); + envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); + envCopy[AuthKeyEnv] = AuthKey; + const waveSrvCmd = getWaveSrvPath(); + console.log("trying to run local server", waveSrvCmd); + const proc = child_process.spawn(getWaveSrvPath(), { + cwd: getWaveSrvCwd(), + env: envCopy, + }); + proc.on("exit", (e) => { + if (globalIsQuitting || updater?.status == "installing") { + return; + } + console.log("wavesrv exited, shutting down"); + electronApp.quit(); + }); + proc.on("spawn", (e) => { + console.log("spawned wavesrv"); + waveSrvProc = proc; + pResolve(true); + }); + proc.on("error", (e) => { + console.log("error running wavesrv", e); + pReject(e); + }); + const rlStdout = readline.createInterface({ + input: proc.stdout, + terminal: false, + }); + rlStdout.on("line", (line) => { + console.log(line); + }); + const rlStderr = readline.createInterface({ + input: proc.stderr, + terminal: false, + }); + rlStderr.on("line", (line) => { + if (line.includes("WAVESRV-ESTART")) { + const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.\-]+) buildtime:(\d+)/gm.exec( + line + ); + if (startParams == null) { + console.log("error parsing WAVESRV-ESTART line", line); + electronApp.quit(); + return; + } + process.env[WSServerEndpointVarName] = startParams[1]; + process.env[WebServerEndpointVarName] = startParams[2]; + WaveVersion = startParams[3]; + WaveBuildTime = parseInt(startParams[4]); + waveSrvReadyResolve(true); + return; + } + if (line.startsWith("WAVESRV-EVENT:")) { + const evtJson = line.slice("WAVESRV-EVENT:".length); + try { + const evtMsg: WSEventType = JSON.parse(evtJson); + handleWSEvent(evtMsg); + } catch (e) { + console.log("error handling WAVESRV-EVENT", e); + } + return; + } + console.log(line); + }); + return rtnPromise; +} + +async function handleWSEvent(evtMsg: WSEventType) { + console.log("handleWSEvent", evtMsg?.eventtype); + if (evtMsg.eventtype == "electron:newwindow") { + const windowId: string = evtMsg.data; + const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; + if (windowData == null) { + return; + } + const clientData = await services.ClientService.GetClientData(); + const fullConfig = await services.FileService.GetFullConfig(); + const newWin = createBrowserWindow(clientData.oid, windowData, fullConfig); + await newWin.readyPromise; + newWin.show(); + } else if (evtMsg.eventtype == "electron:closewindow") { + if (evtMsg.data === undefined) return; + const windows = electron.BrowserWindow.getAllWindows(); + for (const window of windows) { + if ((window as any).waveWindowId === evtMsg.data) { + // Bypass the "Are you sure?" dialog, since this event is called when there's no more tabs for the window. + window.destroy(); + } + } + } else { + console.log("unhandled electron ws eventtype", evtMsg.eventtype); + } +} + +async function mainResizeHandler(_: any, windowId: string, win: WaveBrowserWindow) { + if (win == null || win.isDestroyed() || win.fullScreen) { + return; + } + const bounds = win.getBounds(); + try { + await services.WindowService.SetWindowPosAndSize( + windowId, + { x: bounds.x, y: bounds.y }, + { width: bounds.width, height: bounds.height } + ); + } catch (e) { + console.log("error resizing window", e); + } +} + +function shNavHandler(event: Electron.Event, url: string) { + if (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html")) { + // this is a dev-mode hot-reload, ignore it + console.log("allowing hot-reload of index.html"); + return; + } + event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { + console.log("open external, shNav", url); + electron.shell.openExternal(url); + } else { + console.log("navigation canceled", url); + } +} + +function shFrameNavHandler(event: Electron.Event) { + if (!event.frame?.parent) { + // only use this handler to process iframe events (non-iframe events go to shNavHandler) + return; + } + const url = event.url; + console.log(`frame-navigation url=${url} frame=${event.frame.name}`); + if (event.frame.name == "webview") { + // "webview" links always open in new window + // this will *not* effect the initial load because srcdoc does not count as an electron navigation + console.log("open external, frameNav", url); + event.preventDefault(); + electron.shell.openExternal(url); + return; + } + if ( + event.frame.name == "pdfview" && + (url.startsWith("blob:file:///") || url.startsWith(getWebServerEndpoint() + "/wave/stream-file?")) + ) { + // allowed + return; + } + event.preventDefault(); + console.log("frame navigation canceled"); +} + +// note, this does not *show* the window. +// to show, await win.readyPromise and then win.show() +function createBrowserWindow(clientId: string, waveWindow: WaveWindow, fullConfig: FullConfigType): WaveBrowserWindow { + let winWidth = waveWindow?.winsize?.width; + let winHeight = waveWindow?.winsize?.height; + let winPosX = waveWindow.pos.x; + let winPosY = waveWindow.pos.y; + if (winWidth == null || winWidth == 0) { + const primaryDisplay = electron.screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + winWidth = width - winPosX - 100; + if (winWidth > 2000) { + winWidth = 2000; + } + } + if (winHeight == null || winHeight == 0) { + const primaryDisplay = electron.screen.getPrimaryDisplay(); + const { height } = primaryDisplay.workAreaSize; + winHeight = height - winPosY - 100; + if (winHeight > 1200) { + winHeight = 1200; + } + } + let winBounds = { + x: winPosX, + y: winPosY, + width: winWidth, + height: winHeight, + }; + winBounds = ensureBoundsAreVisible(winBounds); + const winOpts: Electron.BrowserWindowConstructorOptions = { + titleBarStyle: unamePlatform === "darwin" ? "hiddenInset" : "hidden", + titleBarOverlay: + unamePlatform !== "darwin" + ? { + symbolColor: "white", + color: "#00000000", + } + : false, + x: winBounds.x, + y: winBounds.y, + width: winBounds.width, + height: winBounds.height, + minWidth: 400, + minHeight: 300, + icon: + unamePlatform == "linux" + ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") + : undefined, + webPreferences: { + preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), + webviewTag: true, + }, + show: false, + autoHideMenuBar: true, + }; + const settings = fullConfig?.settings; + const isTransparent = settings?.["window:transparent"] ?? false; + const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); + if (isTransparent) { + winOpts.transparent = true; + } else if (isBlur) { + switch (unamePlatform) { + case "win32": { + winOpts.backgroundMaterial = "acrylic"; + break; + } + case "darwin": { + winOpts.vibrancy = "fullscreen-ui"; + break; + } + } + } else { + winOpts.backgroundColor = "#222222"; + } + const bwin = new electron.BrowserWindow(winOpts); + (bwin as any).waveWindowId = waveWindow.oid; + let readyResolve: (value: void) => void; + (bwin as any).readyPromise = new Promise((resolve, _) => { + readyResolve = resolve; + }); + const win: WaveBrowserWindow = bwin as WaveBrowserWindow; + const usp = new URLSearchParams(); + usp.set("clientid", clientId); + usp.set("windowid", waveWindow.oid); + const indexHtml = "index.html"; + if (isDevVite) { + console.log("running as dev server"); + win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html?${usp.toString()}`); + } else { + console.log("running as file"); + win.loadFile(path.join(getElectronAppBasePath(), "frontend", indexHtml), { search: usp.toString() }); + } + win.once("ready-to-show", () => { + readyResolve(); + }); + win.webContents.on("will-navigate", shNavHandler); + win.webContents.on("will-frame-navigate", shFrameNavHandler); + win.webContents.on("did-attach-webview", (event, wc) => { + wc.setWindowOpenHandler((details) => { + win.webContents.send("webview-new-window", wc.id, details); + return { action: "deny" }; + }); + }); + win.webContents.on("before-input-event", (e, input) => { + const waveEvent = keyutil.adaptFromElectronKeyEvent(input); + // console.log("WIN bie", waveEvent.type, waveEvent.code); + handleCtrlShiftState(win.webContents, waveEvent); + if (win.isFocused()) { + wasActive = true; + } + }); + win.on( + "resize", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on( + "move", + debounce(400, (e) => mainResizeHandler(e, waveWindow.oid, win)) + ); + win.on("focus", () => { + wasInFg = true; + wasActive = true; + if (globalIsStarting) { + return; + } + console.log("focus", waveWindow.oid); + services.ClientService.FocusWindow(waveWindow.oid); + }); + win.on("blur", () => { + handleCtrlShiftFocus(win.webContents, false); + }); + win.on("enter-full-screen", async () => { + win.webContents.send("fullscreen-change", true); + }); + win.on("leave-full-screen", async () => { + win.webContents.send("fullscreen-change", false); + }); + win.on("close", (e) => { + if (globalIsQuitting || updater?.status == "installing") { + return; + } + const numWindows = electron.BrowserWindow.getAllWindows().length; + if (numWindows == 1) { + return; + } + const choice = electron.dialog.showMessageBoxSync(win, { + type: "question", + buttons: ["Cancel", "Yes"], + title: "Confirm", + message: "Are you sure you want to close this window (all tabs and blocks will be deleted)?", + }); + if (choice === 0) { + e.preventDefault(); + } + }); + win.on("closed", () => { + if (globalIsQuitting || updater?.status == "installing") { + return; + } + const numWindows = electron.BrowserWindow.getAllWindows().length; + if (numWindows == 0) { + return; + } + services.WindowService.CloseWindow(waveWindow.oid); + }); + win.webContents.on("zoom-changed", (e) => { + win.webContents.send("zoom-changed"); + }); + win.webContents.setWindowOpenHandler(({ url, frameName }) => { + if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { + console.log("openExternal fallback", url); + electron.shell.openExternal(url); + } + console.log("window-open denied", url); + return { action: "deny" }; + }); + configureAuthKeyRequestInjection(win.webContents.session); + return win; +} + +function isWindowFullyVisible(bounds: electron.Rectangle): boolean { + const displays = electron.screen.getAllDisplays(); + + // Helper function to check if a point is inside any display + function isPointInDisplay(x: number, y: number) { + for (const display of displays) { + const { x: dx, y: dy, width, height } = display.bounds; + if (x >= dx && x < dx + width && y >= dy && y < dy + height) { + return true; + } + } + return false; + } + + // Check all corners of the window + const topLeft = isPointInDisplay(bounds.x, bounds.y); + const topRight = isPointInDisplay(bounds.x + bounds.width, bounds.y); + const bottomLeft = isPointInDisplay(bounds.x, bounds.y + bounds.height); + const bottomRight = isPointInDisplay(bounds.x + bounds.width, bounds.y + bounds.height); + + return topLeft && topRight && bottomLeft && bottomRight; +} + +function findDisplayWithMostArea(bounds: electron.Rectangle): electron.Display { + const displays = electron.screen.getAllDisplays(); + let maxArea = 0; + let bestDisplay = null; + + for (let display of displays) { + const { x, y, width, height } = display.bounds; + const overlapX = Math.max(0, Math.min(bounds.x + bounds.width, x + width) - Math.max(bounds.x, x)); + const overlapY = Math.max(0, Math.min(bounds.y + bounds.height, y + height) - Math.max(bounds.y, y)); + const overlapArea = overlapX * overlapY; + + if (overlapArea > maxArea) { + maxArea = overlapArea; + bestDisplay = display; + } + } + + return bestDisplay; +} + +function adjustBoundsToFitDisplay(bounds: electron.Rectangle, display: electron.Display): electron.Rectangle { + const { x: dx, y: dy, width: dWidth, height: dHeight } = display.workArea; + let { x, y, width, height } = bounds; + + // Adjust width and height to fit within the display's work area + width = Math.min(width, dWidth); + height = Math.min(height, dHeight); + + // Adjust x to ensure the window fits within the display + if (x < dx) { + x = dx; + } else if (x + width > dx + dWidth) { + x = dx + dWidth - width; + } + + // Adjust y to ensure the window fits within the display + if (y < dy) { + y = dy; + } else if (y + height > dy + dHeight) { + y = dy + dHeight - height; + } + return { x, y, width, height }; +} + +function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle { + if (!isWindowFullyVisible(bounds)) { + let targetDisplay = findDisplayWithMostArea(bounds); + + if (!targetDisplay) { + targetDisplay = electron.screen.getPrimaryDisplay(); + } + + return adjustBoundsToFitDisplay(bounds, targetDisplay); + } + return bounds; +} + +// Listen for the open-external event from the renderer process +electron.ipcMain.on("open-external", (event, url) => { + if (url && typeof url === "string") { + electron.shell.openExternal(url).catch((err) => { + console.error(`Failed to open URL ${url}:`, err); + }); + } else { + console.error("Invalid URL received in open-external event:", url); + } +}); + +electron.ipcMain.on("download", (event, payload) => { + const window = electron.BrowserWindow.fromWebContents(event.sender); + const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath); + window.webContents.downloadURL(streamingUrl); +}); + +electron.ipcMain.on("get-cursor-point", (event) => { + const window = electron.BrowserWindow.fromWebContents(event.sender); + const screenPoint = electron.screen.getCursorScreenPoint(); + const windowRect = window.getContentBounds(); + const retVal: Electron.Point = { + x: screenPoint.x - windowRect.x, + y: screenPoint.y - windowRect.y, + }; + event.returnValue = retVal; +}); + +electron.ipcMain.on("get-env", (event, varName) => { + event.returnValue = process.env[varName] ?? null; +}); + +electron.ipcMain.on("get-about-modal-details", (event) => { + event.returnValue = { version: WaveVersion, buildTime: WaveBuildTime } as AboutModalDetails; +}); + +const hasBeforeInputRegisteredMap = new Map(); + +electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { + webviewFocusId = focusedId; + console.log("webview-focus", focusedId); + if (focusedId == null) { + return; + } + const parentWc = event.sender; + const webviewWc = electron.webContents.fromId(focusedId); + if (webviewWc == null) { + webviewFocusId = null; + return; + } + if (!hasBeforeInputRegisteredMap.get(focusedId)) { + hasBeforeInputRegisteredMap.set(focusedId, true); + webviewWc.on("before-input-event", (e, input) => { + let waveEvent = keyutil.adaptFromElectronKeyEvent(input); + // console.log(`WEB ${focusedId}`, waveEvent.type, waveEvent.code); + handleCtrlShiftState(parentWc, waveEvent); + if (webviewFocusId != focusedId) { + return; + } + if (input.type != "keyDown") { + return; + } + for (let keyDesc of webviewKeys) { + if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { + e.preventDefault(); + parentWc.send("reinject-key", waveEvent); + console.log("webview reinject-key", keyDesc); + return; + } + } + }); + webviewWc.on("destroyed", () => { + hasBeforeInputRegisteredMap.delete(focusedId); + }); + } +}); + +electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { + webviewKeys = keys ?? []; +}); + +if (unamePlatform !== "darwin") { + const fac = new FastAverageColor(); + + electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { + const zoomFactor = event.sender.getZoomFactor(); + const electronRect: Electron.Rectangle = { + x: rect.left * zoomFactor, + y: rect.top * zoomFactor, + height: rect.height * zoomFactor, + width: rect.width * zoomFactor, + }; + const overlay = await event.sender.capturePage(electronRect); + const overlayBuffer = overlay.toPNG(); + const png = PNG.sync.read(overlayBuffer); + const color = fac.prepareResult(fac.getColorFromArray4(png.data)); + const window = electron.BrowserWindow.fromWebContents(event.sender); + window.setTitleBarOverlay({ + color: unamePlatform === "linux" ? color.rgba : "#00000000", // Windows supports a true transparent overlay, so we don't need to set a background color. + symbolColor: color.isDark ? "white" : "black", + }); + }); +} + +async function createNewWaveWindow(): Promise { + const clientData = await services.ClientService.GetClientData(); + const fullConfig = await services.FileService.GetFullConfig(); + let recreatedWindow = false; + if (electron.BrowserWindow.getAllWindows().length === 0 && clientData?.windowids?.length >= 1) { + // reopen the first window + const existingWindowId = clientData.windowids[0]; + const existingWindowData = (await services.ObjectService.GetObject("window:" + existingWindowId)) as WaveWindow; + if (existingWindowData != null) { + const win = createBrowserWindow(clientData.oid, existingWindowData, fullConfig); + await win.readyPromise; + win.show(); + recreatedWindow = true; + } + } + if (recreatedWindow) { + return; + } + const newWindow = await services.ClientService.MakeWindow(); + const newBrowserWindow = createBrowserWindow(clientData.oid, newWindow, fullConfig); + await newBrowserWindow.readyPromise; + newBrowserWindow.show(); +} + +electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); + +electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { + const window = electron.BrowserWindow.fromWebContents(event.sender); + if (menuDefArr?.length === 0) { + return; + } + const menu = menuDefArr ? convertMenuDefArrToMenu(menuDefArr) : instantiateAppMenu(); + const { x, y } = electron.screen.getCursorScreenPoint(); + const windowPos = window.getPosition(); + + menu.popup({ window, x: x - windowPos[0], y: y - windowPos[1] }); + event.returnValue = true; +}); + +async function logActiveState() { + const activeState = { fg: wasInFg, active: wasActive, open: true }; + const url = new URL(getWebServerEndpoint() + "/wave/log-active-state"); + try { + const resp = await fetch(url, { method: "post", body: JSON.stringify(activeState) }); + if (!resp.ok) { + console.log("error logging active state", resp.status, resp.statusText); + return; + } + } catch (e) { + console.log("error logging active state", e); + } finally { + // for next iteration + wasInFg = electron.BrowserWindow.getFocusedWindow()?.isFocused() ?? false; + wasActive = false; + } +} + +// this isn't perfect, but gets the job done without being complicated +function runActiveTimer() { + logActiveState(); + setTimeout(runActiveTimer, 60000); +} + +function convertMenuDefArrToMenu(menuDefArr: ElectronContextMenuItem[]): electron.Menu { + const menuItems: electron.MenuItem[] = []; + for (const menuDef of menuDefArr) { + const menuItemTemplate: electron.MenuItemConstructorOptions = { + role: menuDef.role as any, + label: menuDef.label, + type: menuDef.type, + click: (_, window) => { + (window as electron.BrowserWindow)?.webContents?.send("contextmenu-click", menuDef.id); + }, + }; + if (menuDef.submenu != null) { + menuItemTemplate.submenu = convertMenuDefArrToMenu(menuDef.submenu); + } + const menuItem = new electron.MenuItem(menuItemTemplate); + menuItems.push(menuItem); + } + return electron.Menu.buildFromTemplate(menuItems); +} + +function instantiateAppMenu(): electron.Menu { + return getAppMenu({ createNewWaveWindow, relaunchBrowserWindows }); +} + +function makeAppMenu() { + const menu = instantiateAppMenu(); + electron.Menu.setApplicationMenu(menu); +} + +electronApp.on("window-all-closed", () => { + if (globalIsRelaunching) { + return; + } + if (unamePlatform !== "darwin") { + electronApp.quit(); + } +}); +electronApp.on("before-quit", () => { + globalIsQuitting = true; + updater?.stop(); +}); +process.on("SIGINT", () => { + console.log("Caught SIGINT, shutting down"); + electronApp.quit(); +}); +process.on("SIGHUP", () => { + console.log("Caught SIGHUP, shutting down"); + electronApp.quit(); +}); +process.on("SIGTERM", () => { + console.log("Caught SIGTERM, shutting down"); + electronApp.quit(); +}); +let caughtException = false; +process.on("uncaughtException", (error) => { + if (caughtException) { + return; + } + logger.error("Uncaught Exception, shutting down: ", error); + caughtException = true; + // Optionally, handle cleanup or exit the app + electronApp.quit(); +}); + +async function relaunchBrowserWindows(): Promise { + globalIsRelaunching = true; + const windows = electron.BrowserWindow.getAllWindows(); + for (const window of windows) { + window.removeAllListeners(); + window.close(); + } + globalIsRelaunching = false; + + const clientData = await services.ClientService.GetClientData(); + const fullConfig = await services.FileService.GetFullConfig(); + const wins: WaveBrowserWindow[] = []; + for (const windowId of clientData.windowids.slice().reverse()) { + const windowData: WaveWindow = (await services.ObjectService.GetObject("window:" + windowId)) as WaveWindow; + if (windowData == null) { + services.WindowService.CloseWindow(windowId).catch((e) => { + /* ignore */ + }); + continue; + } + const win = createBrowserWindow(clientData.oid, windowData, fullConfig); + wins.push(win); + } + for (const win of wins) { + await win.readyPromise; + console.log("show", win.waveWindowId); + win.show(); + } +} + +process.on("uncaughtException", (error) => { + console.error("Uncaught Exception:", error); + console.error("Stack Trace:", error.stack); + electron.app.quit(); +}); + +async function appMain() { + const startTs = Date.now(); + const instanceLock = electronApp.requestSingleInstanceLock(); + if (!instanceLock) { + console.log("waveterm-app could not get single-instance-lock, shutting down"); + electronApp.quit(); + return; + } + const waveHomeDir = getWaveHomeDir(); + if (!fs.existsSync(waveHomeDir)) { + fs.mkdirSync(waveHomeDir); + } + makeAppMenu(); + try { + await runWaveSrv(); + } catch (e) { + console.log(e.toString()); + } + const ready = await waveSrvReady; + console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); + await electronApp.whenReady(); + configureAuthKeyRequestInjection(electron.session.defaultSession); + await relaunchBrowserWindows(); + await configureAutoUpdater(); + setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe + try { + initElectronWshrpc(ElectronWshClient, AuthKey); + } catch (e) { + console.log("error initializing wshrpc", e); + } + + globalIsStarting = false; + + electronApp.on("activate", async () => { + if (electron.BrowserWindow.getAllWindows().length === 0) { + await createNewWaveWindow(); + } + }); +} + +appMain().catch((e) => { + console.log("appMain error", e); + electronApp.quit(); +}); diff --git a/emain/menu.ts b/emain/menu.ts new file mode 100644 index 000000000..646162b3d --- /dev/null +++ b/emain/menu.ts @@ -0,0 +1,191 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as electron from "electron"; +import { fireAndForget } from "../frontend/util/util"; +import { unamePlatform } from "./platform"; +import { updater } from "./updater"; + +type AppMenuCallbacks = { + createNewWaveWindow: () => Promise; + relaunchBrowserWindows: () => Promise; +}; + +function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { + const fileMenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "New Window", + accelerator: "CommandOrControl+Shift+N", + click: () => fireAndForget(callbacks.createNewWaveWindow), + }, + { + role: "close", + accelerator: "", // clear the accelerator + click: () => { + electron.BrowserWindow.getFocusedWindow()?.close(); + }, + }, + ]; + const appMenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "About Wave Terminal", + click: (_, window) => { + window?.webContents.send("menu-item-about"); + }, + }, + { + label: "Check for Updates", + click: () => { + fireAndForget(() => updater?.checkForUpdates(true)); + }, + }, + { + type: "separator", + }, + ]; + if (unamePlatform === "darwin") { + appMenu.push( + { + role: "services", + }, + { + type: "separator", + }, + { + role: "hide", + }, + { + role: "hideOthers", + }, + { + type: "separator", + } + ); + } + appMenu.push({ + role: "quit", + }); + const editMenu: Electron.MenuItemConstructorOptions[] = [ + { + role: "undo", + accelerator: unamePlatform === "darwin" ? "Command+Z" : "", + }, + { + role: "redo", + accelerator: unamePlatform === "darwin" ? "Command+Shift+Z" : "", + }, + { + type: "separator", + }, + { + role: "cut", + accelerator: unamePlatform === "darwin" ? "Command+X" : "", + }, + { + role: "copy", + accelerator: unamePlatform === "darwin" ? "Command+C" : "", + }, + { + role: "paste", + accelerator: unamePlatform === "darwin" ? "Command+V" : "", + }, + { + role: "pasteAndMatchStyle", + accelerator: unamePlatform === "darwin" ? "Command+Shift+V" : "", + }, + { + role: "delete", + }, + { + role: "selectAll", + accelerator: unamePlatform === "darwin" ? "Command+A" : "", + }, + ]; + + const viewMenu: Electron.MenuItemConstructorOptions[] = [ + { + role: "forceReload", + }, + { + label: "Relaunch All Windows", + click: () => { + callbacks.relaunchBrowserWindows(); + }, + }, + { + role: "toggleDevTools", + }, + { + type: "separator", + }, + { + label: "Actual Size", + accelerator: "CommandOrControl+0", + click: (_, window) => { + window.webContents.setZoomFactor(1); + }, + }, + { + label: "Zoom In", + accelerator: "CommandOrControl+=", + click: (_, window) => { + window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2); + }, + }, + { + label: "Zoom In (hidden)", + accelerator: "CommandOrControl+Shift+=", + click: (_, window) => { + window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2); + }, + visible: false, + acceleratorWorksWhenHidden: true, + }, + { + label: "Zoom Out", + accelerator: "CommandOrControl+-", + click: (_, window) => { + window.webContents.setZoomFactor(window.webContents.getZoomFactor() - 0.2); + }, + }, + { + type: "separator", + }, + { + role: "togglefullscreen", + }, + ]; + const windowMenu: Electron.MenuItemConstructorOptions[] = [ + { role: "minimize", accelerator: "" }, + { role: "zoom" }, + { type: "separator" }, + { role: "front" }, + { type: "separator" }, + { role: "window" }, + ]; + const menuTemplate: Electron.MenuItemConstructorOptions[] = [ + { + role: "appMenu", + submenu: appMenu, + }, + { + role: "fileMenu", + submenu: fileMenu, + }, + { + role: "editMenu", + submenu: editMenu, + }, + { + role: "viewMenu", + submenu: viewMenu, + }, + { + role: "windowMenu", + submenu: windowMenu, + }, + ]; + return electron.Menu.buildFromTemplate(menuTemplate); +} + +export { getAppMenu }; diff --git a/emain/platform.ts b/emain/platform.ts new file mode 100644 index 000000000..209ea1b59 --- /dev/null +++ b/emain/platform.ts @@ -0,0 +1,76 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { app, ipcMain } from "electron"; +import os from "os"; +import path from "path"; +import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; +import * as keyutil from "../frontend/util/keyutil"; + +const isDev = !app.isPackaged; +const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; +if (isDev) { + process.env[WaveDevVarName] = "1"; +} +if (isDevVite) { + process.env[WaveDevViteVarName] = "1"; +} + +app.setName(isDev ? "TheNextWave (Dev)" : "TheNextWave"); +const unamePlatform = process.platform; +const unameArch: string = process.arch; +keyutil.setKeyUtilPlatform(unamePlatform); + +ipcMain.on("get-is-dev", (event) => { + event.returnValue = isDev; +}); +ipcMain.on("get-platform", (event, url) => { + event.returnValue = unamePlatform; +}); +ipcMain.on("get-user-name", (event) => { + const userInfo = os.userInfo(); + event.returnValue = userInfo.username; +}); +ipcMain.on("get-host-name", (event) => { + event.returnValue = os.hostname(); +}); + +// must match golang +function getWaveHomeDir() { + return path.join(os.homedir(), isDev ? ".waveterm-dev" : ".waveterm"); +} + +function getElectronAppBasePath(): string { + return path.dirname(import.meta.dirname); +} + +function getGoAppBasePath(): string { + return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked"); +} + +const wavesrvBinName = `wavesrv.${unameArch}`; + +function getWaveSrvPath(): string { + if (process.platform === "win32") { + const winBinName = `${wavesrvBinName}.exe`; + const appPath = path.join(getGoAppBasePath(), "bin", winBinName); + return `${appPath}`; + } + return path.join(getGoAppBasePath(), "bin", wavesrvBinName); +} + +function getWaveSrvCwd(): string { + return getWaveHomeDir(); +} + +export { + getElectronAppBasePath, + getGoAppBasePath, + getWaveHomeDir, + getWaveSrvCwd, + getWaveSrvPath, + isDev, + isDevVite, + unameArch, + unamePlatform, +}; diff --git a/emain/preload.ts b/emain/preload.ts new file mode 100644 index 000000000..e92972923 --- /dev/null +++ b/emain/preload.ts @@ -0,0 +1,50 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { contextBridge, ipcRenderer, WebviewTag } from "electron"; + +contextBridge.exposeInMainWorld("api", { + getAuthKey: () => ipcRenderer.sendSync("get-auth-key"), + getIsDev: () => ipcRenderer.sendSync("get-is-dev"), + getPlatform: () => ipcRenderer.sendSync("get-platform"), + getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), + getUserName: () => ipcRenderer.sendSync("get-user-name"), + getHostName: () => ipcRenderer.sendSync("get-host-name"), + getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), + openNewWindow: () => ipcRenderer.send("open-new-window"), + showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), + onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)), + downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), + openExternal: (url) => { + if (url && typeof url === "string") { + ipcRenderer.send("open-external", url); + } else { + console.error("Invalid URL passed to openExternal:", url); + } + }, + getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), + onFullScreenChange: (callback) => + ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), + onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), + getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"), + installAppUpdate: () => ipcRenderer.send("install-app-update"), + onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), + updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), + onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), + setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused), + registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), + onControlShiftStateUpdate: (callback) => + ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), +}); + +// Custom event for "new-window" +ipcRenderer.on("webview-new-window", (e, webContentsId, details) => { + const event = new CustomEvent("new-window", { detail: details }); + document.getElementById("webview").dispatchEvent(event); +}); + +ipcRenderer.on("webcontentsid-from-blockid", (e, blockId, responseCh) => { + const webviewElem: WebviewTag = document.querySelector("div[data-blockid='" + blockId + "'] webview"); + const wcId = webviewElem?.dataset?.webcontentsid; + ipcRenderer.send(responseCh, wcId); +}); diff --git a/emain/updater.ts b/emain/updater.ts new file mode 100644 index 000000000..f9784d933 --- /dev/null +++ b/emain/updater.ts @@ -0,0 +1,212 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BrowserWindow, dialog, ipcMain, Notification } from "electron"; +import { autoUpdater } from "electron-updater"; +import { FileService } from "../frontend/app/store/services"; +import { isDev } from "../frontend/util/isdev"; +import { fireAndForget } from "../frontend/util/util"; + +export let updater: Updater; + +export class Updater { + autoCheckInterval: NodeJS.Timeout | null; + intervalms: number; + autoCheckEnabled: boolean; + availableUpdateReleaseName: string | null; + availableUpdateReleaseNotes: string | null; + private _status: UpdaterStatus; + lastUpdateCheck: Date; + + constructor(settings: SettingsType) { + this.intervalms = settings["autoupdate:intervalms"]; + this.autoCheckEnabled = settings["autoupdate:enabled"]; + + this._status = "up-to-date"; + this.lastUpdateCheck = new Date(0); + this.autoCheckInterval = null; + this.availableUpdateReleaseName = null; + + autoUpdater.autoInstallOnAppQuit = settings["autoupdate:installonquit"]; + + // Only update the release channel if it's specified, otherwise use the one configured in the artifact. + const channel = settings["autoupdate:channel"]; + if (channel) { + autoUpdater.channel = channel; + } + + autoUpdater.removeAllListeners(); + + autoUpdater.on("error", (err) => { + console.log("updater error"); + console.log(err); + this.status = "error"; + }); + + autoUpdater.on("checking-for-update", () => { + console.log("checking-for-update"); + this.status = "checking"; + }); + + autoUpdater.on("update-available", () => { + console.log("update-available; downloading..."); + }); + + autoUpdater.on("update-not-available", () => { + console.log("update-not-available"); + }); + + autoUpdater.on("update-downloaded", (event) => { + console.log("update-downloaded", [event]); + this.availableUpdateReleaseName = event.releaseName; + this.availableUpdateReleaseNotes = event.releaseNotes as string | null; + + // Display the update banner and create a system notification + this.status = "ready"; + const updateNotification = new Notification({ + title: "Wave Terminal", + body: "A new version of Wave Terminal is ready to install.", + }); + updateNotification.on("click", () => { + fireAndForget(() => this.promptToInstallUpdate()); + }); + updateNotification.show(); + }); + } + + /** + * The status of the Updater. + */ + get status(): UpdaterStatus { + return this._status; + } + + private set status(value: UpdaterStatus) { + this._status = value; + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send("app-update-status", value); + }); + } + + /** + * Check for updates and start the background update check, if configured. + */ + async start() { + if (this.autoCheckEnabled) { + console.log("starting updater"); + this.autoCheckInterval = setInterval(() => { + fireAndForget(() => this.checkForUpdates(false)); + }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed. + await this.checkForUpdates(false); + } + } + + /** + * Stop the background update check, if configured. + */ + stop() { + console.log("stopping updater"); + if (this.autoCheckInterval) { + clearInterval(this.autoCheckInterval); + this.autoCheckInterval = null; + } + } + + /** + * Checks if the configured interval time has passed since the last update check, and if so, checks for updates using the `autoUpdater` object + * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check. + */ + async checkForUpdates(userInput: boolean) { + const now = new Date(); + + // Run an update check always if the user requests it, otherwise only if there's an active update check interval and enough time has elapsed. + if ( + userInput || + (this.autoCheckInterval && + (!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms)) + ) { + const result = await autoUpdater.checkForUpdates(); + + // If the user requested this check and we do not have an available update, let them know with a popup dialog. No need to tell them if there is an update, because we show a banner once the update is ready to install. + if (userInput && !result.downloadPromise) { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + message: "There are currently no updates available.", + }; + dialog.showMessageBox(BrowserWindow.getFocusedWindow(), dialogOpts); + } + + // Only update the last check time if this is an automatic check. This ensures the interval remains consistent. + if (!userInput) this.lastUpdateCheck = now; + } + } + + /** + * Prompts the user to install the downloaded application update and restarts the application + */ + async promptToInstallUpdate() { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + buttons: ["Restart", "Later"], + title: "Application Update", + message: process.platform === "win32" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName, + detail: "A new version has been downloaded. Restart the application to apply the updates.", + }; + + const allWindows = BrowserWindow.getAllWindows(); + if (allWindows.length > 0) { + await dialog + .showMessageBox(BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) + .then(({ response }) => { + if (response === 0) { + this.installUpdate(); + } + }); + } + } + + /** + * Restarts the app and installs an update if it is available. + */ + installUpdate() { + if (this.status == "ready") { + this.status = "installing"; + autoUpdater.quitAndInstall(); + } + } +} + +ipcMain.on("install-app-update", () => fireAndForget(() => updater?.promptToInstallUpdate())); +ipcMain.on("get-app-update-status", (event) => { + event.returnValue = updater?.status; +}); + +let autoUpdateLock = false; + +/** + * Configures the auto-updater based on the user's preference + */ +export async function configureAutoUpdater() { + if (isDev()) { + console.log("skipping auto-updater in dev mode"); + return; + } + + // simple lock to prevent multiple auto-update configuration attempts, this should be very rare + if (autoUpdateLock) { + console.log("auto-update configuration already in progress, skipping"); + return; + } + autoUpdateLock = true; + + try { + console.log("Configuring updater"); + const settings = (await FileService.GetFullConfig()).settings; + updater = new Updater(settings); + await updater.start(); + } catch (e) { + console.warn("error configuring updater", e.toString()); + } + + autoUpdateLock = false; +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..bce3896fe --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +// @ts-check + +import eslint from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import tseslint from "typescript-eslint"; + +const baseConfig = tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended); + +const customConfig = { + ...baseConfig, + overrides: [ + { + files: ["emain/emain.ts", "electron.vite.config.ts"], + env: { + node: true, + }, + }, + ], +}; + +export default [customConfig, eslintConfigPrettier]; diff --git a/frontend/app/app.less b/frontend/app/app.less new file mode 100644 index 000000000..d4ef24a77 --- /dev/null +++ b/frontend/app/app.less @@ -0,0 +1,147 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +@import "./reset.less"; +@import "./theme.less"; + +body { + display: flex; + flex-direction: row; + width: 100vw; + height: 100vh; + color: var(--main-text-color); + font: var(--base-font); + overflow: hidden; + background: var(--main-bg-color); + -webkit-font-smoothing: auto; + backface-visibility: hidden; + transform: translateZ(0); +} + +a.plain-link { + color: var(--secondary-text-color); +} + +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +*::-webkit-scrollbar-track { + background-color: var(--scrollbar-background-color); +} + +*::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb-color); + border-radius: 4px; + margin: 0 1px 0 1px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover-color); +} + +.flex-spacer { + flex-grow: 1; +} + +.text-fixed { + font: var(--fixed-font); +} + +#main, +.mainapp { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + .app-background { + pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +.error-boundary { + color: var(--error-color); +} + +/* OverlayScrollbars styling */ +.os-scrollbar { + --os-handle-bg: var(--scrollbar-thumb-color); + --os-handle-bg-hover: var(--scrollbar-thumb-hover-color); + --os-handle-bg-active: var(--scrollbar-thumb-active-color); +} + +.scrollbar-hide-until-hover { + *::-webkit-scrollbar-thumb, + *::-webkit-scrollbar-track { + display: none; + } + + *::-webkit-scrollbar-corner { + display: none; + } + + *:hover::-webkit-scrollbar-thumb { + display: block; + } +} + +a { + color: var(--accent-color); +} + +.prefers-reduced-motion { + * { + transition-duration: none !important; + transition-timing-function: none !important; + transition-property: none !important; + transition-delay: none !important; + } +} + +.flash-error-container { + position: absolute; + right: 10px; + bottom: 10px; + z-index: var(--zindex-flash-error-container); + display: flex; + flex-direction: column; + gap: 10px; + + .flash-error { + background: var(--error-color); + color: var(--main-text-color); + border-radius: 4px; + padding: 10px; + display: flex; + flex-direction: column; + width: 280px; + border: 1px solid transparent; + max-height: 100px; + cursor: pointer; + + .flash-error-scroll { + overflow-y: auto; + display: flex; + flex-direction: column; + } + + &.hovered { + border: 1px solid var(--main-text-color); + } + + .flash-error-title { + font-weight: bold; + margin-bottom: 5px; + } + + .flash-error-message { + } + } +} diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx new file mode 100644 index 000000000..9f5c5da12 --- /dev/null +++ b/frontend/app/app.tsx @@ -0,0 +1,361 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useWaveObjectValue } from "@/app/store/wos"; +import { Workspace } from "@/app/workspace/workspace"; +import { ContextMenuModel } from "@/store/contextmenu"; +import { PLATFORM, WOS, atoms, getApi, globalStore, removeFlashError, useSettingsPrefixAtom } from "@/store/global"; +import { appHandleKeyDown } from "@/store/keymodel"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { getElemAsStr } from "@/util/focusutil"; +import * as keyutil from "@/util/keyutil"; +import * as util from "@/util/util"; +import useResizeObserver from "@react-hook/resize-observer"; +import clsx from "clsx"; +import Color from "color"; +import * as csstree from "css-tree"; +import debug from "debug"; +import * as jotai from "jotai"; +import "overlayscrollbars/overlayscrollbars.css"; +import * as React from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { debounce } from "throttle-debounce"; +import "./app.less"; +import { CenteredDiv } from "./element/quickelems"; + +const dlog = debug("wave:app"); +const focusLog = debug("wave:focus"); + +const App = () => { + let Provider = jotai.Provider; + return ( + + + + ); +}; + +function isContentEditableBeingEdited() { + const activeElement = document.activeElement; + return ( + activeElement && + activeElement.getAttribute("contenteditable") !== null && + activeElement.getAttribute("contenteditable") !== "false" + ); +} + +function canEnablePaste() { + const activeElement = document.activeElement; + return activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || isContentEditableBeingEdited(); +} + +function canEnableCopy() { + const sel = window.getSelection(); + return !util.isBlank(sel?.toString()); +} + +function canEnableCut() { + const sel = window.getSelection(); + if (document.activeElement?.classList.contains("xterm-helper-textarea")) { + return false; + } + return !util.isBlank(sel?.toString()) && canEnablePaste(); +} + +function handleContextMenu(e: React.MouseEvent) { + e.preventDefault(); + const canPaste = canEnablePaste(); + const canCopy = canEnableCopy(); + const canCut = canEnableCut(); + if (!canPaste && !canCopy && !canCut) { + return; + } + let menu: ContextMenuItem[] = []; + if (canCut) { + menu.push({ label: "Cut", role: "cut" }); + } + if (canCopy) { + menu.push({ label: "Copy", role: "copy" }); + } + if (canPaste) { + menu.push({ label: "Paste", role: "paste" }); + } + ContextMenuModel.showContextMenu(menu, e); +} + +function AppSettingsUpdater() { + const windowSettings = useSettingsPrefixAtom("window"); + React.useEffect(() => { + const isTransparentOrBlur = + (windowSettings?.["window:transparent"] || windowSettings?.["window:blur"]) ?? false; + const opacity = util.boundNumber(windowSettings?.["window:opacity"] ?? 0.8, 0, 1); + let baseBgColor = windowSettings?.["window:bgcolor"]; + if (isTransparentOrBlur) { + document.body.classList.add("is-transparent"); + const rootStyles = getComputedStyle(document.documentElement); + if (baseBgColor == null) { + baseBgColor = rootStyles.getPropertyValue("--main-bg-color").trim(); + } + const color = new Color(baseBgColor); + const rgbaColor = color.alpha(opacity).string(); + document.body.style.backgroundColor = rgbaColor; + } else { + document.body.classList.remove("is-transparent"); + document.body.style.opacity = null; + } + }, [windowSettings]); + return null; +} + +function appFocusIn(e: FocusEvent) { + focusLog("focusin", getElemAsStr(e.target), "<=", getElemAsStr(e.relatedTarget)); +} + +function appFocusOut(e: FocusEvent) { + focusLog("focusout", getElemAsStr(e.target), "=>", getElemAsStr(e.relatedTarget)); +} + +function appSelectionChange(e: Event) { + const selection = document.getSelection(); + focusLog("selectionchange", getElemAsStr(selection.anchorNode)); +} + +function AppFocusHandler() { + return null; + + // for debugging + React.useEffect(() => { + document.addEventListener("focusin", appFocusIn); + document.addEventListener("focusout", appFocusOut); + document.addEventListener("selectionchange", appSelectionChange); + const ivId = setInterval(() => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLElement) { + focusLog("activeElement", getElemAsStr(activeElement)); + } + }, 2000); + return () => { + document.removeEventListener("focusin", appFocusIn); + document.removeEventListener("focusout", appFocusOut); + document.removeEventListener("selectionchange", appSelectionChange); + clearInterval(ivId); + }; + }); + return null; +} + +function encodeFileURL(file: string) { + const webEndpoint = getWebServerEndpoint(); + return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`; +} + +function processBackgroundUrls(cssText: string): string { + if (util.isBlank(cssText)) { + return null; + } + cssText = cssText.trim(); + if (cssText.endsWith(";")) { + cssText = cssText.slice(0, -1); + } + const attrRe = /^background(-image):\s*/; + cssText = cssText.replace(attrRe, ""); + const ast = csstree.parse("background: " + cssText, { + context: "declaration", + }); + let hasJSUrl = false; + csstree.walk(ast, { + visit: "Url", + enter(node) { + const originalUrl = node.value.trim(); + if (originalUrl.startsWith("javascript:")) { + hasJSUrl = true; + return; + } + const newUrl = encodeFileURL(originalUrl); + node.value = newUrl; + }, + }); + if (hasJSUrl) { + console.log("invalid background, contains a 'javascript' protocol url which is not allowed"); + return null; + } + const rtnStyle = csstree.generate(ast); + if (rtnStyle == null) { + return null; + } + return rtnStyle.replace(/^background:\s*/, ""); +} + +function AppBackground() { + const bgRef = React.useRef(null); + const tabId = jotai.useAtomValue(atoms.activeTabId); + const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId)); + const bgAttr = tabData?.meta?.bg; + const style: React.CSSProperties = {}; + if (!util.isBlank(bgAttr)) { + try { + const processedBg = processBackgroundUrls(bgAttr); + if (!util.isBlank(processedBg)) { + const opacity = util.boundNumber(tabData?.meta?.["bg:opacity"], 0, 1) ?? 0.5; + style.opacity = opacity; + style.background = processedBg; + const blendMode = tabData?.meta?.["bg:blendmode"]; + if (!util.isBlank(blendMode)) { + style.backgroundBlendMode = blendMode; + } + } + } catch (e) { + console.error("error processing background", e); + } + } + const getAvgColor = React.useCallback( + debounce(30, () => { + if ( + bgRef.current && + PLATFORM !== "darwin" && + bgRef.current && + "windowControlsOverlay" in window.navigator + ) { + const titlebarRect: Dimensions = (window.navigator.windowControlsOverlay as any).getTitlebarAreaRect(); + const bgRect = bgRef.current.getBoundingClientRect(); + if (titlebarRect && bgRect) { + const windowControlsLeft = titlebarRect.width - titlebarRect.height; + const windowControlsRect: Dimensions = { + top: titlebarRect.top, + left: windowControlsLeft, + height: titlebarRect.height, + width: bgRect.width - bgRect.left - windowControlsLeft, + }; + getApi().updateWindowControlsOverlay(windowControlsRect); + } + } + }), + [bgRef, style] + ); + React.useLayoutEffect(getAvgColor, [getAvgColor]); + useResizeObserver(bgRef, getAvgColor); + + return
; +} + +const AppKeyHandlers = () => { + React.useEffect(() => { + const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); + document.addEventListener("keydown", staticKeyDownHandler); + + return () => { + document.removeEventListener("keydown", staticKeyDownHandler); + }; + }, []); + return null; +}; + +const FlashError = () => { + const flashErrors = jotai.useAtomValue(atoms.flashErrors); + const [hoveredId, setHoveredId] = React.useState(null); + const [ticker, setTicker] = React.useState(0); + + React.useEffect(() => { + if (flashErrors.length == 0 || hoveredId != null) { + return; + } + const now = Date.now(); + for (let ferr of flashErrors) { + if (ferr.expiration == null || ferr.expiration < now) { + removeFlashError(ferr.id); + } + } + setTimeout(() => setTicker(ticker + 1), 1000); + }, [flashErrors, ticker, hoveredId]); + + if (flashErrors.length == 0) { + return null; + } + + function copyError(id: string) { + const ferr = flashErrors.find((f) => f.id === id); + if (ferr == null) { + return; + } + let text = ""; + if (ferr.title != null) { + text += ferr.title; + } + if (ferr.message != null) { + if (text.length > 0) { + text += "\n"; + } + text += ferr.message; + } + navigator.clipboard.writeText(text); + } + + function convertNewlinesToBreaks(text) { + return text.split("\n").map((part, index) => ( + + {part} +
+
+ )); + } + + return ( +
+ {flashErrors.map((err, idx) => ( +
copyError(err.id)} + onMouseEnter={() => setHoveredId(err.id)} + onMouseLeave={() => setHoveredId(null)} + title="Click to Copy Error Message" + > +
+ {err.title != null ?
{err.title}
: null} + {err.message != null ? ( +
{convertNewlinesToBreaks(err.message)}
+ ) : null} +
+
+ ))} +
+ ); +}; + +const AppInner = () => { + const prefersReducedMotion = jotai.useAtomValue(atoms.prefersReducedMotionAtom); + const client = jotai.useAtomValue(atoms.client); + const windowData = jotai.useAtomValue(atoms.waveWindow); + const isFullScreen = jotai.useAtomValue(atoms.isFullScreen); + + if (client == null || windowData == null) { + return ( +
+ + invalid configuration, client or window was not loaded +
+ ); + } + + return ( +
+ + + + + + + + +
+ ); +}; + +export { App }; diff --git a/frontend/app/asset/dots-anim-4.svg b/frontend/app/asset/dots-anim-4.svg new file mode 100644 index 000000000..028aaf349 --- /dev/null +++ b/frontend/app/asset/dots-anim-4.svg @@ -0,0 +1,18 @@ + +dots anim 4 + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/asset/logo.svg b/frontend/app/asset/logo.svg new file mode 100644 index 000000000..51c1d820d --- /dev/null +++ b/frontend/app/asset/logo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/asset/magnify.svg b/frontend/app/asset/magnify.svg new file mode 100644 index 000000000..09a4919ca --- /dev/null +++ b/frontend/app/asset/magnify.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less new file mode 100644 index 000000000..7151084aa --- /dev/null +++ b/frontend/app/block/block.less @@ -0,0 +1,416 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +@import "../mixins.less"; + +.block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + overflow: hidden; + min-height: 0; + border-radius: var(--block-border-radius); + + .block-frame-icon { + margin-right: 0.5em; + } + + .block-content { + position: relative; + display: flex; + flex-grow: 1; + width: 100%; + overflow: hidden; + min-height: 0; + padding: 5px; + } + + .block-focuselem { + height: 0; + width: 0; + input { + width: 0; + height: 0; + opacity: 0; + pointer-events: none; + } + } + + .block-header-animation-wrap { + max-height: 0; + transition: + max-height 0.3s ease-out, + opacity 0.3s ease-out; + overflow: hidden; + position: absolute; + top: 0; + width: 100%; + height: 30px; + z-index: var(--zindex-header-hover); + + &.is-showing { + max-height: 30px; + } + } + + &.block-preview.block-frame-default .block-frame-default-inner .block-frame-default-header { + background-color: rgba(0, 0, 0, 0.7); + } + + &.block-frame-default { + position: relative; + padding: 1px; + + .block-frame-default-inner { + background-color: var(--block-bg-color); + width: 100%; + height: 100%; + border-radius: var(--block-border-radius); + display: flex; + flex-direction: column; + + .block-frame-default-header { + max-height: var(--header-height); + min-height: var(--header-height); + display: flex; + padding: 4px 5px 4px 10px; + align-items: center; + gap: 8px; + font: var(--header-font); + border-bottom: 1px solid var(--border-color); + border-radius: var(--block-border-radius) var(--block-border-radius) 0 0; + + .block-frame-default-header-iconview { + display: flex; + flex-shrink: 3; + min-width: 17px; + align-items: center; + gap: 8px; + overflow-x: hidden; + + .block-frame-view-icon { + font-size: var(--header-icon-size); + opacity: 0.5; + width: var(--header-icon-width); + i { + margin-right: 0; + } + } + + .block-frame-view-type { + overflow-x: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; + } + + .block-frame-blockid { + opacity: 0.5; + } + } + + .block-frame-text { + .ellipsis(); + font: var(--fixed-font); + font-size: 11px; + opacity: 0.7; + flex-grow: 1; + + &.preview-filename { + direction: rtl; + text-align: left; + span { + cursor: pointer; + + &:hover { + background: var(--highlight-bg-color); + } + } + } + } + + .connection-button { + display: flex; + align-items: center; + flex-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + font-weight: 400; + color: var(--main-text-color); + border-radius: 2px; + padding: auto; + + &:hover { + background-color: var(--highlight-bg-color); + } + + .connection-icon-box { + flex: 1 1 auto; + overflow: hidden; + } + + .connection-name { + flex: 1 2 auto; + overflow: hidden; + padding-right: 4px; + } + + .connecting-svg { + position: relative; + top: 5px; + left: 9px; + svg { + fill: var(--warning-color); + } + } + } + + .block-frame-textelems-wrapper { + display: flex; + flex: 1 2 auto; + min-width: 0; + gap: 8px; + align-items: center; + + .block-frame-div { + display: flex; + width: 100%; + height: 100%; + justify-content: space-between; + align-items: center; + + .input-wrapper { + flex-grow: 1; + + input { + background-color: transparent; + outline: none; + border: none; + color: var(--app-text-color); + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + opacity: 0.7; + font-weight: 500; + } + } + + .button { + margin-left: 3px; + } + + // webview specific. for refresh button + .iconbutton { + height: 100%; + width: 27px; + display: flex; + align-items: center; + justify-content: center; + } + } + + .block-frame-div-url, + .block-frame-div-search { + background: rgba(255, 255, 255, 0.1); + + input { + opacity: 1; + } + } + } + + .block-frame-end-icons { + display: flex; + flex-shrink: 0; + + .iconbutton { + display: flex; + width: 24px; + padding: 4px 6px; + align-items: center; + } + + .block-frame-magnify { + justify-content: center; + align-items: center; + padding: 0; + + svg { + #arrow1, + #arrow2 { + fill: var(--main-text-color); + } + } + } + } + } + + .block-frame-preview { + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + flex-grow: 1; + border-bottom-left-radius: var(--block-border-radius); + border-bottom-right-radius: var(--block-border-radius); + display: flex; + align-items: center; + justify-content: center; + + .iconbutton { + opacity: 0.7; + font-size: 45px; + margin: -30px 0 0 0; + } + } + } + + .connstatus-overlay { + position: absolute; + top: calc(var(--header-height) + 6px); + left: 6px; + right: 6px; + z-index: var(--zindex-block-mask-inner); + display: flex; + align-items: center; + justify-content: flex-start; + flex-direction: column; + overflow: hidden; + background: rgba(230, 186, 30, 0.2); + backdrop-filter: blur(50px); + border-radius: 6px; + box-shadow: 0px 13px 16px 0px rgba(0, 0, 0, 0.4); + + .connstatus-content { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 10px 8px 10px 12px; + width: 100%; + font: var(--base-font); + color: var(--secondary-text-color); + gap: 12px; + + .connstatus-status-icon-wrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + flex-grow: 1; + min-width: 0; + + &.has-error { + align-items: flex-start; + } + + > i { + color: #e6ba1e; + font-size: 16px; + } + + .connstatus-status { + .ellipsis(); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex-grow: 1; + width: 100%; + + .connstatus-status-text { + .ellipsis(); + max-width: 100%; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.11px; + color: white; + } + + .connstatus-error { + .ellipsis(); + width: 94%; + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 15px; + letter-spacing: 0.11px; + } + } + } + + .connstatus-actions { + display: flex; + align-items: flex-start; + justify-content: center; + gap: 6px; + + button { + i { + font-size: 11px; + opacity: 0.7; + } + } + + .button:last-child { + margin-top: 1.5px; + } + } + } + } + + .block-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 2px solid transparent; + pointer-events: none; + padding: 2px; + border-radius: calc(var(--block-border-radius) + 2px); + z-index: var(--zindex-block-mask-inner); + + &.show-block-mask { + user-select: none; + pointer-events: auto; + } + + &.show-block-mask .block-mask-inner { + margin-top: var(--header-height); // TODO fix this magic + background-color: rgba(0, 0, 0, 0.5); + height: calc(100% - var(--header-height)); + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + .bignum { + margin-top: -15%; + font-size: 60px; + font-weight: bold; + opacity: 0.7; + } + } + } + + &.block-focused { + .block-mask { + border: 2px solid var(--accent-color); + } + + &.block-no-highlight, + &.block-preview { + .block-mask { + border: 2px solid rgba(255, 255, 255, 0.1); + } + } + } + } +} diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx new file mode 100644 index 000000000..27c1c6ed3 --- /dev/null +++ b/frontend/app/block/block.tsx @@ -0,0 +1,279 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes"; +import { PlotView } from "@/app/view/plotview/plotview"; +import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview"; +import { ErrorBoundary } from "@/element/errorboundary"; +import { CenteredDiv } from "@/element/quickelems"; +import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index"; +import { + counterInc, + getBlockComponentModel, + registerBlockComponentModel, + unregisterBlockComponentModel, +} from "@/store/global"; +import * as WOS from "@/store/wos"; +import { getElemAsStr } from "@/util/focusutil"; +import * as util from "@/util/util"; +import { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel } from "@/view/cpuplot/cpuplot"; +import { HelpView, HelpViewModel, makeHelpViewModel } from "@/view/helpview/helpview"; +import { TermViewModel, TerminalView, makeTerminalModel } from "@/view/term/term"; +import { WaveAi, WaveAiModel, makeWaveAiViewModel } from "@/view/waveai/waveai"; +import { WebView, WebViewModel, makeWebViewModel } from "@/view/webview/webview"; +import * as jotai from "jotai"; +import * as React from "react"; +import "./block.less"; +import { BlockFrame } from "./blockframe"; +import { blockViewToIcon, blockViewToName } from "./blockutil"; + +type FullBlockProps = { + preview: boolean; + nodeModel: NodeModel; + viewModel: ViewModel; +}; + +function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel { + if (blockView === "term") { + return makeTerminalModel(blockId); + } + if (blockView === "preview") { + return makePreviewModel(blockId, nodeModel); + } + if (blockView === "web") { + return makeWebViewModel(blockId, nodeModel); + } + if (blockView === "waveai") { + return makeWaveAiViewModel(blockId); + } + if (blockView === "cpuplot") { + return makeCpuPlotViewModel(blockId); + } + if (blockView === "help") { + return makeHelpViewModel(); + } + return makeDefaultViewModel(blockId, blockView); +} + +function getViewElem( + blockId: string, + blockRef: React.RefObject, + contentRef: React.RefObject, + blockView: string, + viewModel: ViewModel +): JSX.Element { + if (util.isBlank(blockView)) { + return No View; + } + if (blockView === "term") { + return ; + } + if (blockView === "preview") { + return ( + + ); + } + if (blockView === "plot") { + return ; + } + if (blockView === "web") { + return ; + } + if (blockView === "waveai") { + return ; + } + if (blockView === "cpuplot") { + return ; + } + if (blockView == "help") { + return ; + } + return Invalid View "{blockView}"; +} + +function makeDefaultViewModel(blockId: string, viewType: string): ViewModel { + const blockDataAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + let viewModel: ViewModel = { + viewType: viewType, + viewIcon: jotai.atom((get) => { + const blockData = get(blockDataAtom); + return blockViewToIcon(blockData?.meta?.view); + }), + viewName: jotai.atom((get) => { + const blockData = get(blockDataAtom); + return blockViewToName(blockData?.meta?.view); + }), + viewText: jotai.atom((get) => { + const blockData = get(blockDataAtom); + return blockData?.meta?.title; + }), + preIconButton: jotai.atom(null), + endIconButtons: jotai.atom(null), + }; + return viewModel; +} + +const BlockPreview = React.memo(({ nodeModel, viewModel }: FullBlockProps) => { + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + if (!blockData) { + return null; + } + return ( + + ); +}); + +const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => { + counterInc("render-BlockFull"); + const focusElemRef = React.useRef(null); + const blockRef = React.useRef(null); + const contentRef = React.useRef(null); + const [blockClicked, setBlockClicked] = React.useState(false); + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const isFocused = jotai.useAtomValue(nodeModel.isFocused); + const disablePointerEvents = jotai.useAtomValue(nodeModel.disablePointerEvents); + const innerRect = useDebouncedNodeInnerRect(nodeModel); + + React.useLayoutEffect(() => { + setBlockClicked(isFocused); + }, [isFocused]); + + React.useLayoutEffect(() => { + if (!blockClicked) { + return; + } + setBlockClicked(false); + const focusWithin = blockRef.current?.contains(document.activeElement); + if (!focusWithin) { + setFocusTarget(); + } + if (!isFocused) { + console.log("blockClicked focus", nodeModel.blockId); + nodeModel.focusNode(); + } + }, [blockClicked, isFocused]); + + const setBlockClickedTrue = React.useCallback(() => { + setBlockClicked(true); + }, []); + + const [blockContentOffset, setBlockContentOffset] = React.useState(); + + React.useEffect(() => { + if (blockRef.current && contentRef.current) { + const blockRect = blockRef.current.getBoundingClientRect(); + const contentRect = contentRef.current.getBoundingClientRect(); + setBlockContentOffset({ + top: 0, + left: 0, + width: blockRect.width - contentRect.width, + height: blockRect.height - contentRect.height, + }); + } + }, [blockRef, contentRef]); + + const blockContentStyle = React.useMemo(() => { + const retVal: React.CSSProperties = { + pointerEvents: disablePointerEvents ? "none" : undefined, + }; + if (innerRect?.width && innerRect.height && blockContentOffset) { + retVal.width = `calc(${innerRect?.width} - ${blockContentOffset.width}px)`; + retVal.height = `calc(${innerRect?.height} - ${blockContentOffset.height}px)`; + } + return retVal; + }, [innerRect, disablePointerEvents, blockContentOffset]); + + const viewElem = React.useMemo( + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel), + [nodeModel.blockId, blockData?.meta?.view, viewModel] + ); + + const handleChildFocus = React.useCallback( + (event: React.FocusEvent) => { + console.log("setFocusedChild", nodeModel.blockId, getElemAsStr(event.target)); + if (!isFocused) { + console.log("focusedChild focus", nodeModel.blockId); + nodeModel.focusNode(); + } + }, + [isFocused] + ); + + const setFocusTarget = React.useCallback(() => { + const ok = viewModel?.giveFocus?.(); + if (ok) { + return; + } + focusElemRef.current?.focus({ preventScroll: true }); + }, []); + + const blockModel: BlockComponentModel2 = { + onClick: setBlockClickedTrue, + onFocusCapture: handleChildFocus, + blockRef: blockRef, + }; + + return ( + +
+ {}} + /> +
+
+ + Loading...}>{viewElem} + +
+
+ ); +}); + +const Block = React.memo((props: BlockProps) => { + counterInc("render-Block"); + counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8)); + const [blockData, loading] = WOS.useWaveObjectValue(WOS.makeORef("block", props.nodeModel.blockId)); + const bcm = getBlockComponentModel(props.nodeModel.blockId); + let viewModel = bcm?.viewModel; + if (viewModel == null || viewModel.viewType != blockData?.meta?.view) { + viewModel = makeViewModel(props.nodeModel.blockId, blockData?.meta?.view, props.nodeModel); + registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); + } + React.useEffect(() => { + return () => { + unregisterBlockComponentModel(props.nodeModel.blockId); + }; + }, []); + if (loading || util.isBlank(props.nodeModel.blockId) || blockData == null) { + return null; + } + if (props.preview) { + return ; + } + return ; +}); + +export { Block }; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx new file mode 100644 index 000000000..664ad2859 --- /dev/null +++ b/frontend/app/block/blockframe.tsx @@ -0,0 +1,743 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + blockViewToIcon, + blockViewToName, + computeConnColorNum, + ConnectionButton, + ControllerStatusIcon, + getBlockHeaderIcon, + Input, +} from "@/app/block/blockutil"; +import { Button } from "@/app/element/button"; +import { useWidth } from "@/app/hook/useWidth"; +import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; +import { ContextMenuModel } from "@/app/store/contextmenu"; +import { + atoms, + getBlockComponentModel, + getConnStatusAtom, + getHostName, + getUserName, + globalStore, + refocusNode, + useBlockAtom, + useSettingsKeyAtom, + WOS, +} from "@/app/store/global"; +import * as services from "@/app/store/services"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { ErrorBoundary } from "@/element/errorboundary"; +import { IconButton } from "@/element/iconbutton"; +import { MagnifyIcon } from "@/element/magnify"; +import { NodeModel } from "@/layout/index"; +import * as keyutil from "@/util/keyutil"; +import * as util from "@/util/util"; +import clsx from "clsx"; +import * as jotai from "jotai"; +import * as React from "react"; +import { BlockFrameProps } from "./blocktypes"; + +const NumActiveConnColors = 8; + +function handleHeaderContextMenu( + e: React.MouseEvent, + blockData: Block, + viewModel: ViewModel, + magnified: boolean, + onMagnifyToggle: () => void, + onClose: () => void +) { + e.preventDefault(); + e.stopPropagation(); + let menu: ContextMenuItem[] = [ + { + label: magnified ? "Un-Magnify Block" : "Magnify Block", + click: () => { + onMagnifyToggle(); + }, + }, + { + label: "Move to New Window", + click: () => { + const currentTabId = globalStore.get(atoms.activeTabId); + try { + services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); + } catch (e) { + console.error("error moving block to new window", e); + } + }, + }, + { type: "separator" }, + { + label: "Copy BlockId", + click: () => { + navigator.clipboard.writeText(blockData.oid); + }, + }, + ]; + const extraItems = viewModel?.getSettingsMenuItems?.(); + if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems); + menu.push( + { type: "separator" }, + { + label: "Close Block", + click: onClose, + } + ); + ContextMenuModel.showContextMenu(menu, e); +} + +function getViewIconElem(viewIconUnion: string | IconButtonDecl, blockData: Block): JSX.Element { + if (viewIconUnion == null || typeof viewIconUnion === "string") { + const viewIcon = viewIconUnion as string; + return
{getBlockHeaderIcon(viewIcon, blockData)}
; + } else { + return ; + } +} + +const OptMagnifyButton = React.memo( + ({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => { + const magnifyDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: , + title: magnified ? "Minimize" : "Magnify", + click: toggleMagnify, + disabled, + }; + return ; + } +); + +function computeEndIcons( + viewModel: ViewModel, + nodeModel: NodeModel, + onContextMenu: (e: React.MouseEvent) => void +): JSX.Element[] { + const endIconsElem: JSX.Element[] = []; + const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons); + const magnified = jotai.useAtomValue(nodeModel.isMagnified); + const numLeafs = jotai.useAtomValue(nodeModel.numLeafs); + const magnifyDisabled = numLeafs <= 1; + + if (endIconButtons && endIconButtons.length > 0) { + endIconsElem.push(...endIconButtons.map((button, idx) => )); + } + const settingsDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "cog", + title: "Settings", + click: onContextMenu, + }; + endIconsElem.push(); + endIconsElem.push( + + ); + const closeDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "xmark-large", + title: "Close", + click: nodeModel.onClose, + }; + endIconsElem.push(); + return endIconsElem; +} + +const BlockFrame_Header = ({ + nodeModel, + viewModel, + preview, + connBtnRef, + changeConnModalAtom, + error, +}: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom; error?: Error }) => { + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(blockData?.meta?.view); + const showBlockIds = jotai.useAtomValue(useSettingsKeyAtom("blockheader:showblockids")); + const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); + const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); + const headerTextUnion = util.useAtomValueSafe(viewModel?.viewText); + const magnified = jotai.useAtomValue(nodeModel.isMagnified); + const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); + const dragHandleRef = preview ? null : nodeModel.dragHandleRef; + + const onContextMenu = React.useCallback( + (e: React.MouseEvent) => { + handleHeaderContextMenu(e, blockData, viewModel, magnified, nodeModel.toggleMagnify, nodeModel.onClose); + }, + [magnified] + ); + + const endIconsElem = computeEndIcons(viewModel, nodeModel, onContextMenu); + const viewIconElem = getViewIconElem(viewIconUnion, blockData); + let preIconButtonElem: JSX.Element = null; + if (preIconButton) { + preIconButtonElem = ; + } + + const headerTextElems: JSX.Element[] = []; + if (typeof headerTextUnion === "string") { + if (!util.isBlank(headerTextUnion)) { + headerTextElems.push( +
+ ‎{headerTextUnion} +
+ ); + } + } else if (Array.isArray(headerTextUnion)) { + headerTextElems.push(...renderHeaderElements(headerTextUnion, preview)); + } + headerTextElems.unshift(); + if (error != null) { + const copyHeaderErr = () => { + navigator.clipboard.writeText(error.message + "\n" + error.stack); + }; + headerTextElems.push( +
+ +
+ ); + } + + return ( +
+ {preIconButtonElem} +
+ {viewIconElem} +
{viewName}
+ {showBlockIds &&
[{nodeModel.blockId.substring(0, 8)}]
} +
+ {manageConnection && ( + + )} +
{headerTextElems}
+
{endIconsElem}
+
+ ); +}; + +const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => { + if (elem.elemtype == "iconbutton") { + return ; + } else if (elem.elemtype == "input") { + return ; + } else if (elem.elemtype == "text") { + return ( +
+ elem?.onClick()}> + ‎{elem.text} + +
+ ); + } else if (elem.elemtype == "textbutton") { + return ( + + ); + } else if (elem.elemtype == "div") { + return ( +
+ {elem.children.map((child, childIdx) => ( + + ))} +
+ ); + } + return null; +}); + +function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): JSX.Element[] { + const headerTextElems: JSX.Element[] = []; + for (let idx = 0; idx < headerTextUnion.length; idx++) { + const elem = headerTextUnion[idx]; + const renderedElement = ; + if (renderedElement) { + headerTextElems.push(renderedElement); + } + } + return headerTextElems; +} + +const ConnStatusOverlay = React.memo( + ({ + nodeModel, + viewModel, + changeConnModalAtom, + }: { + nodeModel: NodeModel; + viewModel: ViewModel; + changeConnModalAtom: jotai.PrimitiveAtom; + }) => { + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const [connModalOpen] = jotai.useAtom(changeConnModalAtom); + const connName = blockData.meta?.connection; + const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); + const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); + const overlayRef = React.useRef(null); + const width = useWidth(overlayRef); + const [showError, setShowError] = React.useState(false); + const blockNum = jotai.useAtomValue(nodeModel.blockNum); + + React.useEffect(() => { + if (width) { + const hasError = !util.isBlank(connStatus.error); + const showError = hasError && width >= 250 && connStatus.status != "connecting"; + setShowError(showError); + } + }, [width, connStatus, setShowError]); + + const handleTryReconnect = React.useCallback(() => { + const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connName, { timeout: 60000 }); + prtn.catch((e) => console.log("error reconnecting", connName, e)); + }, [connName]); + + let statusText = `Disconnected from "${connName}"`; + let showReconnect = true; + if (connStatus.status == "connecting") { + statusText = `Connecting to "${connName}"...`; + showReconnect = false; + } + let reconDisplay = null; + let reconClassName = "outlined grey"; + if (width && width < 350) { + reconDisplay = ; + reconClassName = clsx(reconClassName, "font-size-12 vertical-padding-5 horizontal-padding-6"); + } else { + reconDisplay = "Reconnect"; + reconClassName = clsx(reconClassName, "font-size-11 vertical-padding-3 horizontal-padding-7"); + } + const showIcon = connStatus.status != "connecting"; + + if (isLayoutMode || connStatus.status == "connected" || connModalOpen) { + return null; + } + + return ( +
+
+
+ {showIcon && } +
+
{statusText}
+ {showError ?
error: {connStatus.error}
: null} +
+
+ {showReconnect ? ( +
+ +
+ ) : null} +
+
+ ); + } +); + +const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { + const isFocused = jotai.useAtomValue(nodeModel.isFocused); + const blockNum = jotai.useAtomValue(nodeModel.blockNum); + const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const style: React.CSSProperties = {}; + let showBlockMask = false; + + if (!isFocused && blockData?.meta?.["frame:bordercolor"]) { + style.borderColor = blockData.meta["frame:bordercolor"]; + } + if (isFocused && blockData?.meta?.["frame:bordercolor:focused"]) { + style.borderColor = blockData.meta["frame:bordercolor:focused"]; + } + let innerElem = null; + if (isLayoutMode) { + showBlockMask = true; + innerElem = ( +
+
{blockNum}
+
+ ); + } + return ( +
+ {innerElem} +
+ ); +}); + +const BlockFrame_Default_Component = (props: BlockFrameProps) => { + const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props; + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const isFocused = jotai.useAtomValue(nodeModel.isFocused); + const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); + const customBg = util.useAtomValueSafe(viewModel?.blockBg); + const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); + const changeConnModalAtom = useBlockAtom(nodeModel.blockId, "changeConn", () => { + return jotai.atom(false); + }) as jotai.PrimitiveAtom; + const connModalOpen = jotai.useAtomValue(changeConnModalAtom); + + const connBtnRef = React.useRef(); + React.useEffect(() => { + if (!manageConnection) { + return; + } + const bcm = getBlockComponentModel(nodeModel.blockId); + if (bcm != null) { + bcm.openSwitchConnection = () => { + globalStore.set(changeConnModalAtom, true); + }; + } + return () => { + const bcm = getBlockComponentModel(nodeModel.blockId); + if (bcm != null) { + bcm.openSwitchConnection = null; + } + }; + }, [manageConnection]); + React.useEffect(() => { + // on mount, if manageConnection, call ConnEnsure + if (!manageConnection || blockData == null || preview) { + return; + } + const connName = blockData?.meta?.connection; + if (!util.isBlank(connName)) { + console.log("ensure conn", nodeModel.blockId, connName); + RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }).catch((e) => { + console.log("error ensuring connection", nodeModel.blockId, connName, e); + }); + } + }, [manageConnection, blockData]); + + const viewIconElem = getViewIconElem(viewIconUnion, blockData); + const innerStyle: React.CSSProperties = {}; + if (!preview && customBg?.bg != null) { + innerStyle.background = customBg.bg; + if (customBg["bg:opacity"] != null) { + innerStyle.opacity = customBg["bg:opacity"]; + } + if (customBg["bg:blendmode"] != null) { + innerStyle.backgroundBlendMode = customBg["bg:blendmode"]; + } + } + const previewElem =
{viewIconElem}
; + const headerElem = ( + + ); + const headerElemNoView = React.cloneElement(headerElem, { viewModel: null }); + return ( +
+ + {preview || viewModel == null ? null : ( + + )} +
+ {headerElem} + {preview ? previewElem : children} +
+ {preview || viewModel == null || !connModalOpen ? null : ( + + )} +
+ ); +}; + +const ChangeConnectionBlockModal = React.memo( + ({ + blockId, + viewModel, + blockRef, + connBtnRef, + changeConnModalAtom, + nodeModel, + }: { + blockId: string; + viewModel: ViewModel; + blockRef: React.RefObject; + connBtnRef: React.RefObject; + changeConnModalAtom: jotai.PrimitiveAtom; + nodeModel: NodeModel; + }) => { + const [connSelected, setConnSelected] = React.useState(""); + const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom); + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused); + const connection = blockData?.meta?.connection; + const connStatusAtom = getConnStatusAtom(connection); + const connStatus = jotai.useAtomValue(connStatusAtom); + const [connList, setConnList] = React.useState>([]); + const allConnStatus = jotai.useAtomValue(atoms.allConnStatus); + const [rowIndex, setRowIndex] = React.useState(0); + const connStatusMap = new Map(); + let maxActiveConnNum = 1; + for (const conn of allConnStatus) { + if (conn.activeconnnum > maxActiveConnNum) { + maxActiveConnNum = conn.activeconnnum; + } + connStatusMap.set(conn.connection, conn); + } + React.useEffect(() => { + if (!changeConnModalOpen) { + setConnList([]); + return; + } + const prtn = RpcApi.ConnListCommand(WindowRpcClient, { timeout: 2000 }); + prtn.then((newConnList) => { + setConnList(newConnList ?? []); + }).catch((e) => console.log("unable to load conn list from backend. using blank list: ", e)); + }, [changeConnModalOpen, setConnList]); + + const changeConnection = React.useCallback( + async (connName: string) => { + if (connName == "") { + connName = null; + } + if (connName == blockData?.meta?.connection) { + return; + } + const oldCwd = blockData?.meta?.file ?? ""; + let newCwd: string; + if (oldCwd == "") { + newCwd = ""; + } else { + newCwd = "~"; + } + await RpcApi.SetMetaCommand(WindowRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { connection: connName, file: newCwd }, + }); + try { + await RpcApi.ConnEnsureCommand(WindowRpcClient, connName, { timeout: 60000 }); + } catch (e) { + console.log("error connecting", blockId, connName, e); + } + }, + [blockId, blockData] + ); + + let createNew: boolean = true; + let showLocal: boolean = true; + let showReconnect: boolean = true; + if (connSelected == "") { + createNew = false; + } else { + showLocal = false; + showReconnect = false; + } + const filteredList: Array = []; + for (const conn of connList) { + if (conn === connSelected) { + createNew = false; + } + if (conn.includes(connSelected)) { + filteredList.push(conn); + } + } + // priority handles special suggestions when necessary + // for instance, when reconnecting + const newConnectionSuggestion: SuggestionConnectionItem = { + status: "connected", + icon: "plus", + iconColor: "var(--conn-icon-color)", + label: `${connSelected} (New Connection)`, + value: "", + onSelect: (_: string) => { + changeConnection(connSelected); + globalStore.set(changeConnModalAtom, false); + }, + }; + const reconnectSuggestion: SuggestionConnectionItem = { + status: "connected", + icon: "arrow-right-arrow-left", + iconColor: "var(--grey-text-color)", + label: `Reconnect to ${connStatus.connection}`, + value: "", + onSelect: async (_: string) => { + const prtn = RpcApi.ConnConnectCommand(WindowRpcClient, connStatus.connection, { timeout: 60000 }); + prtn.catch((e) => console.log("error reconnecting", connStatus.connection, e)); + }, + }; + const priorityItems: Array = []; + if (createNew) { + priorityItems.push(newConnectionSuggestion); + } + if (showReconnect && (connStatus.status == "disconnected" || connStatus.status == "error")) { + priorityItems.push(reconnectSuggestion); + } + const prioritySuggestions: SuggestionConnectionScope = { + headerText: "", + items: priorityItems, + }; + const localName = getUserName() + "@" + getHostName(); + const localSuggestion: SuggestionConnectionScope = { + headerText: "Local", + items: [], + }; + if (showLocal) { + localSuggestion.items.push({ + status: "connected", + icon: "laptop", + iconColor: "var(--grey-text-color)", + value: "", + label: localName, + }); + } + const remoteItems = filteredList.map((connName) => { + const connStatus = connStatusMap.get(connName); + const connColorNum = computeConnColorNum(connStatus); + const item: SuggestionConnectionItem = { + status: "connected", + icon: "arrow-right-arrow-left", + iconColor: + connStatus?.status == "connected" + ? `var(--conn-icon-color-${connColorNum})` + : "var(--grey-text-color)", + value: connName, + label: connName, + }; + return item; + }); + const remoteSuggestions: SuggestionConnectionScope = { + headerText: "Remote", + items: remoteItems, + }; + + let suggestions: Array = []; + if (prioritySuggestions.items.length > 0) { + suggestions.push(prioritySuggestions); + } + if (localSuggestion.items.length > 0) { + suggestions.push(localSuggestion); + } + if (remoteSuggestions.items.length > 0) { + suggestions.push(remoteSuggestions); + } + + let selectionList: Array = [ + ...prioritySuggestions.items, + ...localSuggestion.items, + ...remoteSuggestions.items, + ]; + + // quick way to change icon color when highlighted + selectionList = selectionList.map((item, index) => { + if (index == rowIndex && item.iconColor == "var(--grey-text-color)") { + item.iconColor = "var(--main-text-color)"; + } + return item; + }); + + const handleTypeAheadKeyDown = React.useCallback( + (waveEvent: WaveKeyboardEvent): boolean => { + if (keyutil.checkKeyPressed(waveEvent, "Enter")) { + const rowItem = selectionList[rowIndex]; + if ("onSelect" in rowItem && rowItem.onSelect) { + rowItem.onSelect(rowItem.value); + } else { + changeConnection(rowItem.value); + globalStore.set(changeConnModalAtom, false); + } + } + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + globalStore.set(changeConnModalAtom, false); + setConnSelected(""); + refocusNode(blockId); + return true; + } + if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) { + setRowIndex((idx) => Math.max(idx - 1, 0)); + return true; + } + if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) { + setRowIndex((idx) => Math.min(idx + 1, filteredList.length)); + return true; + } + }, + [changeConnModalAtom, viewModel, blockId, connSelected, selectionList] + ); + React.useEffect(() => { + setRowIndex((idx) => Math.min(idx, filteredList.length)); + }, [selectionList, setRowIndex]); + // this check was also moved to BlockFrame to prevent all the above code from running unnecessarily + if (!changeConnModalOpen) { + return null; + } + return ( + { + changeConnection(selected); + globalStore.set(changeConnModalAtom, false); + }} + selectIndex={rowIndex} + autoFocus={isNodeFocused} + onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)} + onChange={(current: string) => setConnSelected(current)} + value={connSelected} + label="Connect to (username@host)..." + onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)} + /> + ); + } +); + +const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof BlockFrame_Default_Component; + +const BlockFrame = React.memo((props: BlockFrameProps) => { + const blockId = props.nodeModel.blockId; + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const tabData = jotai.useAtomValue(atoms.tabAtom); + + if (!blockId || !blockData) { + return null; + } + const FrameElem = BlockFrame_Default; + const numBlocks = tabData?.blockids?.length ?? 0; + return ; +}); + +export { BlockFrame, NumActiveConnColors }; diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts new file mode 100644 index 000000000..2340f9d17 --- /dev/null +++ b/frontend/app/block/blocktypes.ts @@ -0,0 +1,24 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { NodeModel } from "@/layout/index"; +export interface BlockProps { + preview: boolean; + nodeModel: NodeModel; +} + +export interface BlockComponentModel2 { + onClick?: () => void; + onFocusCapture?: React.FocusEventHandler; + blockRef?: React.RefObject; +} + +export interface BlockFrameProps { + blockModel?: BlockComponentModel2; + nodeModel?: NodeModel; + viewModel?: ViewModel; + preview: boolean; + numBlocksInTab?: number; + children?: React.ReactNode; + connBtnRef?: React.RefObject; +} diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx new file mode 100644 index 000000000..911514ed6 --- /dev/null +++ b/frontend/app/block/blockutil.tsx @@ -0,0 +1,308 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { NumActiveConnColors } from "@/app/block/blockframe"; +import { getConnStatusAtom, WOS } from "@/app/store/global"; +import * as services from "@/app/store/services"; +import { makeORef } from "@/app/store/wos"; +import { waveEventSubscribe } from "@/store/wps"; +import * as util from "@/util/util"; +import clsx from "clsx"; +import * as jotai from "jotai"; +import * as React from "react"; +import DotsSvg from "../asset/dots-anim-4.svg"; + +export const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/; + +export function blockViewToIcon(view: string): string { + if (view == "term") { + return "terminal"; + } + if (view == "preview") { + return "file"; + } + if (view == "web") { + return "globe"; + } + if (view == "waveai") { + return "sparkles"; + } + if (view == "help") { + return "circle-question"; + } + return "square"; +} + +export function blockViewToName(view: string): string { + if (util.isBlank(view)) { + return "(No View)"; + } + if (view == "term") { + return "Terminal"; + } + if (view == "preview") { + return "Preview"; + } + if (view == "web") { + return "Web"; + } + if (view == "waveai") { + return "WaveAI"; + } + if (view == "help") { + return "Help"; + } + return view; +} + +export function processTitleString(titleString: string): React.ReactNode[] { + if (titleString == null) { + return null; + } + const tagRegex = /<(\/)?([a-z]+)(?::([#a-z0-9@-]+))?>/g; + let lastIdx = 0; + let match; + let partsStack = [[]]; + while ((match = tagRegex.exec(titleString)) != null) { + const lastPart = partsStack[partsStack.length - 1]; + const before = titleString.substring(lastIdx, match.index); + lastPart.push(before); + lastIdx = match.index + match[0].length; + const [_, isClosing, tagName, tagParam] = match; + if (tagName == "icon" && !isClosing) { + if (tagParam == null) { + continue; + } + const iconClass = util.makeIconClass(tagParam, false); + if (iconClass == null) { + continue; + } + lastPart.push(); + continue; + } + if (tagName == "c" || tagName == "color") { + if (isClosing) { + if (partsStack.length <= 1) { + continue; + } + partsStack.pop(); + continue; + } + if (tagParam == null) { + continue; + } + if (!tagParam.match(colorRegex)) { + continue; + } + let children = []; + const rtag = React.createElement("span", { key: match.index, style: { color: tagParam } }, children); + lastPart.push(rtag); + partsStack.push(children); + continue; + } + if (tagName == "i" || tagName == "b") { + if (isClosing) { + if (partsStack.length <= 1) { + continue; + } + partsStack.pop(); + continue; + } + let children = []; + const rtag = React.createElement(tagName, { key: match.index }, children); + lastPart.push(rtag); + partsStack.push(children); + continue; + } + } + partsStack[partsStack.length - 1].push(titleString.substring(lastIdx)); + return partsStack[0]; +} + +export function getBlockHeaderIcon(blockIcon: string, blockData: Block): React.ReactNode { + let blockIconElem: React.ReactNode = null; + if (util.isBlank(blockIcon)) { + blockIcon = "square"; + } + let iconColor = blockData?.meta?.["icon:color"]; + if (iconColor && !iconColor.match(colorRegex)) { + iconColor = null; + } + let iconStyle = null; + if (!util.isBlank(iconColor)) { + iconStyle = { color: iconColor }; + } + const iconClass = util.makeIconClass(blockIcon, true); + if (iconClass != null) { + blockIconElem = ; + } + return blockIconElem; +} + +interface ConnectionButtonProps { + connection: string; + changeConnModalAtom: jotai.PrimitiveAtom; +} + +export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string }) => { + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const hasController = !util.isBlank(blockData?.meta?.controller); + const [controllerStatus, setControllerStatus] = React.useState(null); + const [gotInitialStatus, setGotInitialStatus] = React.useState(false); + const connection = blockData?.meta?.connection ?? "local"; + const connStatusAtom = getConnStatusAtom(connection); + const connStatus = jotai.useAtomValue(connStatusAtom); + React.useEffect(() => { + if (!hasController) { + return; + } + const initialRTStatus = services.BlockService.GetControllerStatus(blockId); + initialRTStatus.then((rts) => { + setGotInitialStatus(true); + setControllerStatus(rts); + }); + const unsubFn = waveEventSubscribe({ + eventType: "controllerstatus", + scope: makeORef("block", blockId), + handler: (event) => { + const cstatus: BlockControllerRuntimeStatus = event.data; + setControllerStatus(cstatus); + }, + }); + return () => { + unsubFn(); + }; + }, [hasController]); + if (!hasController || !gotInitialStatus) { + return null; + } + if (controllerStatus?.shellprocstatus == "running") { + return null; + } + if (connStatus?.status != "connected") { + return null; + } + const controllerStatusElem = ( +
+ +
+ ); + return controllerStatusElem; +}); + +export function computeConnColorNum(connStatus: ConnStatus): number { + // activeconnnum is 1-indexed, so we need to adjust for when mod is 0 + const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors; + if (connColorNum == 0) { + return NumActiveConnColors; + } + return connColorNum; +} + +export const ConnectionButton = React.memo( + React.forwardRef( + ({ connection, changeConnModalAtom }: ConnectionButtonProps, ref) => { + const [connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); + const isLocal = util.isBlank(connection); + const connStatusAtom = getConnStatusAtom(connection); + const connStatus = jotai.useAtomValue(connStatusAtom); + let showDisconnectedSlash = false; + let connIconElem: React.ReactNode = null; + const connColorNum = computeConnColorNum(connStatus); + let color = `var(--conn-icon-color-${connColorNum})`; + const clickHandler = function () { + setConnModalOpen(true); + }; + let titleText = null; + let shouldSpin = false; + if (isLocal) { + color = "var(--grey-text-color)"; + titleText = "Connected to Local Machine"; + connIconElem = ( + + ); + } else { + titleText = "Connected to " + connection; + let iconName = "arrow-right-arrow-left"; + let iconSvg = null; + if (connStatus?.status == "connecting") { + color = "var(--warning-color)"; + titleText = "Connecting to " + connection; + shouldSpin = false; + iconSvg = ( +
+ +
+ ); + } else if (connStatus?.status == "error") { + color = "var(--error-color)"; + titleText = "Error connecting to " + connection; + if (connStatus?.error != null) { + titleText += " (" + connStatus.error + ")"; + } + showDisconnectedSlash = true; + } else if (!connStatus?.connected) { + color = "var(--grey-text-color)"; + titleText = "Disconnected from " + connection; + showDisconnectedSlash = true; + } + if (iconSvg != null) { + connIconElem = iconSvg; + } else { + connIconElem = ( + + ); + } + } + + return ( +
+ + {connIconElem} + + + {isLocal ? null :
{connection}
} +
+ ); + } + ) +); + +export const Input = React.memo( + ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => { + const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; + return ( +
+ onChange(e)} + onKeyDown={(e) => onKeyDown(e)} + onFocus={(e) => onFocus(e)} + onBlur={(e) => onBlur(e)} + onDragStart={(e) => e.preventDefault()} + /> +
+ ); + } +); diff --git a/frontend/app/element/button.less b/frontend/app/element/button.less new file mode 100644 index 000000000..8d74b84b9 --- /dev/null +++ b/frontend/app/element/button.less @@ -0,0 +1,304 @@ +/* Copyright 2024, Command Line Inc. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +.button { + // override default button appearance + border: 1px solid transparent; + outline: 1px solid transparent; + border: none; + + cursor: pointer; + display: flex; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 20px; + padding-right: 20px; + align-items: center; + gap: 4px; + border-radius: 6px; + height: auto; + line-height: 16px; + white-space: nowrap; + user-select: none; + font-size: 14px; + font-weight: normal; + transition: all 0.3s ease; + + &.solid { + &.green { + color: #000000; + background-color: var(--accent-color); + border: 1px solid #29f200; + &:hover { + color: #000000; + background-color: #29f200; + } + } + + &.grey { + background-color: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--main-text-color); + &:hover { + color: var(--main-text-color); + background-color: rgba(255, 255, 255, 0.09); + } + } + + &.red { + background-color: #cc0000; + border: 1px solid #fc3131; + color: var(--main-text-color); + &:hover { + background-color: #f93939; + } + } + + &.yellow { + color: #000000; + background-color: #c4a000; + border: 1px solid #fce94f; + &:hover { + color: #000000; + background-color: #fce94f; + } + } + } + + &.outlined { + background-color: transparent; + &.green { + color: var(--accent-color); + border: 1px solid var(--accent-color); + &:hover { + color: #29f200; + border: 1px solid #29f200; + } + } + + &.grey { + border: 1px solid rgba(255, 255, 255, 0.6); + color: rgba(255, 255, 255, 0.6); + &:hover { + color: var(--main-text-color); + border: 1px solid var(--main-text-color); + } + } + + &.red { + border: 1px solid #cc0000; + color: #cc0000; + &:hover { + color: #ff3c3c; + border: 1px solid #ff3c3c; + } + } + + &.yellow { + color: #c4a000; + border: 1px solid #c4a000; + &:hover { + color: #fce94f; + border: 1px solid #fce94f; + } + } + } + + &.ghost { + background-color: transparent; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 8px; + padding-right: 8px; + + &.green { + border: none; + color: var(--accent-color); + &:hover { + color: #29f200; + } + } + + &.grey { + border: none; + color: rgba(255, 255, 255, 0.6); + &:hover { + color: var(--main-text-color); + } + } + + &.red { + border: none; + color: #cc0000; + &:hover { + color: #fc3131; + } + } + + &.yellow { + border: none; + color: #c4a000; + &:hover { + color: #fce94f; + } + } + } + + &.disabled { + cursor: default; + opacity: 0.5; + } + + &:focus, + &.focus { + outline: 1px solid var(--success-color); + outline-offset: 2px; + } + + // customs styles here + &.border-radius-2 { + border-radius: 4px; + } + + &.border-radius-3 { + border-radius: 4px; + } + + &.border-radius-4 { + border-radius: 4px; + } + + &.border-radius-5 { + border-radius: 4px; + } + + &.border-radius-6 { + border-radius: 4px; + } + + &.border-radius-10 { + border-radius: 10px; + } + + &.vertical-padding-0 { + padding-top: 0px; + padding-bottom: 0px; + } + + &.vertical-padding-1 { + padding-top: 1px; + padding-bottom: 1px; + } + + &.vertical-padding-2 { + padding-top: 2px; + padding-bottom: 2px; + } + + &.vertical-padding-3 { + padding-top: 3px; + padding-bottom: 3px; + } + + &.vertical-padding-4 { + padding-top: 4px; + padding-bottom: 4px; + } + + &.vertical-padding-5 { + padding-top: 5px; + padding-bottom: 5px; + } + + &.vertical-padding-6 { + padding-top: 6px; + padding-bottom: 6px; + } + + &.vertical-padding-7 { + padding-top: 7px; + padding-bottom: 7px; + } + + &.vertical-padding-8 { + padding-top: 8px; + padding-bottom: 8px; + } + + &.vertical-padding-9 { + padding-top: 9px; + padding-bottom: 9px; + } + + &.vertical-padding-10 { + padding-top: 10px; + padding-bottom: 10px; + } + + &.horizontal-padding-0 { + padding-left: 0px; + padding-right: 0px; + } + + &.horizontal-padding-1 { + padding-left: 1px; + padding-right: 1px; + } + + &.horizontal-padding-2 { + padding-left: 2px; + padding-right: 2px; + } + + &.horizontal-padding-3 { + padding-left: 3px; + padding-right: 3px; + } + + &.horizontal-padding-4 { + padding-left: 4px; + padding-right: 4px; + } + + &.horizontal-padding-5 { + padding-left: 5px; + padding-right: 5px; + } + + &.horizontal-padding-6 { + padding-left: 6px; + padding-right: 6px; + } + + &.horizontal-padding-7 { + padding-left: 7px; + padding-right: 7px; + } + + &.horizontal-padding-8 { + padding-left: 8px; + padding-right: 8px; + } + + &.horizontal-padding-9 { + padding-left: 9px; + padding-right: 9px; + } + + &.horizontal-padding-10 { + padding-left: 10px; + padding-right: 10px; + } + + &.font-size-11 { + font-size: 11px; + } + + &.font-weight-500 { + font-weight: 500; + } + + &.font-weight-600 { + font-weight: 600; + } +} diff --git a/frontend/app/element/button.tsx b/frontend/app/element/button.tsx new file mode 100644 index 000000000..75403e34b --- /dev/null +++ b/frontend/app/element/button.tsx @@ -0,0 +1,51 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; +import { Children, forwardRef, memo, ReactNode, useImperativeHandle, useRef } from "react"; + +import "./button.less"; + +interface ButtonProps extends React.ButtonHTMLAttributes { + className?: string; + children?: ReactNode; + target?: string; + source?: string; +} + +const Button = memo( + forwardRef( + ({ children, disabled, source, className = "", ...props }: ButtonProps, ref) => { + const btnRef = useRef(null); + useImperativeHandle(ref, () => btnRef.current as HTMLButtonElement); + + const childrenArray = Children.toArray(children); + + // Check if the className contains any of the categories: solid, outlined, or ghost + const containsButtonCategory = /(solid|outline|ghost)/.test(className); + // If no category is present, default to 'solid' + const categoryClassName = containsButtonCategory ? className : `solid ${className}`; + + // Check if the className contains any of the color options: green, grey, red, or yellow + const containsColor = /(green|grey|red|yellow)/.test(categoryClassName); + // If no color is present, default to 'green' + const finalClassName = containsColor ? categoryClassName : `green ${categoryClassName}`; + + return ( + + ); + } + ) +); + +export { Button }; diff --git a/frontend/app/element/copybutton.less b/frontend/app/element/copybutton.less new file mode 100644 index 000000000..1689a0f97 --- /dev/null +++ b/frontend/app/element/copybutton.less @@ -0,0 +1,11 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.copy-button { + &.copied { + opacity: 1; + i { + color: var(--success-color); + } + } +} diff --git a/frontend/app/element/copybutton.tsx b/frontend/app/element/copybutton.tsx new file mode 100644 index 000000000..a5e26bc5e --- /dev/null +++ b/frontend/app/element/copybutton.tsx @@ -0,0 +1,59 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { clsx } from "clsx"; +import { useEffect, useRef, useState } from "react"; +import "./copybutton.less"; +import { IconButton } from "./iconbutton"; + +type CopyButtonProps = { + title: string; + className?: string; + onClick: (e: React.MouseEvent) => void; +}; + +const CopyButton = ({ title, className, onClick }: CopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(null); + + const handleOnClick = (e: React.MouseEvent) => { + if (isCopied) { + return; + } + setIsCopied(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setIsCopied(false); + timeoutRef.current = null; + }, 2000); + + if (onClick) { + onClick(e); + } + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return ( + + ); +}; + +export { CopyButton }; diff --git a/frontend/app/element/errorboundary.tsx b/frontend/app/element/errorboundary.tsx new file mode 100644 index 000000000..d69c00a7b --- /dev/null +++ b/frontend/app/element/errorboundary.tsx @@ -0,0 +1,32 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import React, { ReactNode } from "react"; + +export class ErrorBoundary extends React.Component< + { children: ReactNode; fallback?: React.ReactElement & { error?: Error } }, + { error: Error } +> { + constructor(props) { + super(props); + this.state = { error: null }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + this.setState({ error: error }); + } + + render() { + const { fallback } = this.props; + const { error } = this.state; + if (error) { + if (fallback != null) { + return React.cloneElement(fallback as any, { error }); + } + const errorMsg = `Error: ${error?.message}\n\n${error?.stack}`; + return
{errorMsg}
; + } else { + return <>{this.props.children}; + } + } +} diff --git a/frontend/app/element/iconbutton.less b/frontend/app/element/iconbutton.less new file mode 100644 index 000000000..d781a23a9 --- /dev/null +++ b/frontend/app/element/iconbutton.less @@ -0,0 +1,25 @@ +.iconbutton { + cursor: pointer; + opacity: 0.7; + align-items: center; + + &.bulb { + color: var(--bulb-color); + opacity: 1; + + &:hover i::before { + content: "\f672"; + position: relative; + left: -1px; + } + } + + &:hover { + opacity: 1; + } + + &.disabled { + cursor: default; + opacity: 0.45 !important; + } +} diff --git a/frontend/app/element/iconbutton.tsx b/frontend/app/element/iconbutton.tsx new file mode 100644 index 000000000..772eb55ef --- /dev/null +++ b/frontend/app/element/iconbutton.tsx @@ -0,0 +1,20 @@ +import { useLongClick } from "@/app/hook/useLongClick"; +import { makeIconClass } from "@/util/util"; +import clsx from "clsx"; +import { memo, useRef } from "react"; +import "./iconbutton.less"; + +export const IconButton = memo(({ decl, className }: { decl: IconButtonDecl; className?: string }) => { + const buttonRef = useRef(null); + useLongClick(buttonRef, decl.click, decl.longClick, decl.disabled); + return ( +
+ {typeof decl.icon === "string" ? : decl.icon} +
+ ); +}); diff --git a/frontend/app/element/input.less b/frontend/app/element/input.less new file mode 100644 index 000000000..7cd13ab29 --- /dev/null +++ b/frontend/app/element/input.less @@ -0,0 +1,86 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.input { + display: flex; + align-items: center; + border-radius: 6px; + position: relative; + min-height: 24px; + min-width: 50px; + width: 100%; + gap: 6px; + border: 2px solid var(--form-element-border-color); + background: var(--form-element-bg-color); + + &:hover { + cursor: text; + } + + &.focused { + border-color: var(--form-element-primary-color); + } + + &.disabled { + opacity: 0.75; + } + + &.error { + border-color: var(--form-element-error-color); + } + + &-inner { + display: flex; + flex-direction: column; + position: relative; + flex-grow: 1; + --inner-padding: 5px 0 5px 16px; + + &-label { + padding: var(--inner-padding); + margin-bottom: -10px; + font-size: 12.5px; + transition: all 0.3s; + color: var(--form-element-label-color); + line-height: 10px; + user-select: none; + + &.float { + font-size: 10px; + top: 5px; + } + + &.offset-left { + left: 0; + } + } + + &-input { + width: 100%; + height: 100%; + border: none; + padding: var(--inner-padding); + font-size: 12px; + outline: none; + background-color: transparent; + color: var(--form-element-text-color); + line-height: 20px; + + &.offset-left { + padding: 5px 16px 5px 0; + } + + &:placeholder-shown { + user-select: none; + } + } + } + + &.no-label { + height: 34px; + + input { + height: 32px; + } + } +} diff --git a/frontend/app/element/input.tsx b/frontend/app/element/input.tsx new file mode 100644 index 000000000..9b550a53d --- /dev/null +++ b/frontend/app/element/input.tsx @@ -0,0 +1,176 @@ +import { clsx } from "clsx"; +import React, { forwardRef, useEffect, useRef, useState } from "react"; + +import "./input.less"; + +interface InputDecorationProps { + startDecoration?: React.ReactNode; + endDecoration?: React.ReactNode; +} + +interface InputProps { + label?: string; + value?: string; + className?: string; + onChange?: (value: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + defaultValue?: string; + decoration?: InputDecorationProps; + required?: boolean; + maxLength?: number; + autoFocus?: boolean; + disabled?: boolean; + isNumber?: boolean; + inputRef?: React.MutableRefObject; +} + +const Input = forwardRef( + ( + { + label, + value, + className, + onChange, + onKeyDown, + onFocus, + onBlur, + placeholder, + defaultValue = "", + decoration, + required, + maxLength, + autoFocus, + disabled, + isNumber, + inputRef, + }: InputProps, + ref + ) => { + const [focused, setFocused] = useState(false); + const [internalValue, setInternalValue] = useState(defaultValue); + const [error, setError] = useState(false); + const [hasContent, setHasContent] = useState(Boolean(value || defaultValue)); + const internalInputRef = useRef(null); + + useEffect(() => { + if (value !== undefined) { + setFocused(Boolean(value)); + } + }, [value]); + + const handleComponentFocus = () => { + if (internalInputRef.current && !internalInputRef.current.contains(document.activeElement)) { + internalInputRef.current.focus(); + } + }; + + const handleComponentBlur = () => { + if (internalInputRef.current?.contains(document.activeElement)) { + internalInputRef.current.blur(); + } + }; + + const handleSetInputRef = (elem: HTMLInputElement) => { + if (inputRef) { + inputRef.current = elem; + } + internalInputRef.current = elem; + }; + + const handleFocus = () => { + setFocused(true); + onFocus && onFocus(); + }; + + const handleBlur = () => { + if (internalInputRef.current) { + const inputValue = internalInputRef.current.value; + if (required && !inputValue) { + setError(true); + setFocused(false); + } else { + setError(false); + setFocused(false); + } + } + onBlur && onBlur(); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + + if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) { + return; + } + + if (required && !inputValue) { + setError(true); + setHasContent(false); + } else { + setError(false); + setHasContent(Boolean(inputValue)); + } + + if (value === undefined) { + setInternalValue(inputValue); + } + + onChange && onChange(inputValue); + }; + + const inputValue = value ?? internalValue; + + return ( +
+ {decoration?.startDecoration && <>{decoration.startDecoration}} +
+ {label && ( + + )} + +
+ {decoration?.endDecoration && <>{decoration.endDecoration}} +
+ ); + } +); + +export { Input }; +export type { InputDecorationProps, InputProps }; diff --git a/frontend/app/element/inputdecoration.less b/frontend/app/element/inputdecoration.less new file mode 100644 index 000000000..bf4366f92 --- /dev/null +++ b/frontend/app/element/inputdecoration.less @@ -0,0 +1,21 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.input-decoration { + display: flex; + align-items: center; + justify-content: center; + + i { + font-size: 13px; + color: var(--form-element-icon-color); + } +} + +.input-decoration.start-position { + margin: 0 4px 0 16px; +} + +.input-decoration.end-position { + margin: 0 16px 0 8px; +} diff --git a/frontend/app/element/inputdecoration.tsx b/frontend/app/element/inputdecoration.tsx new file mode 100644 index 000000000..1a92773d0 --- /dev/null +++ b/frontend/app/element/inputdecoration.tsx @@ -0,0 +1,28 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { clsx } from "clsx"; +import * as React from "react"; + +import "./inputdecoration.less"; + +interface InputDecorationProps { + position?: "start" | "end"; + children: React.ReactNode; +} + +const InputDecoration = (props: InputDecorationProps) => { + const { children, position = "end" } = props; + return ( +
+ {children} +
+ ); +}; + +export { InputDecoration }; diff --git a/frontend/app/element/linkbutton.less b/frontend/app/element/linkbutton.less new file mode 100644 index 000000000..bb6827008 --- /dev/null +++ b/frontend/app/element/linkbutton.less @@ -0,0 +1,21 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +&.link-button { + text-decoration: none; + + .button-inner { + display: flex; + border-radius: 6px; + padding: 8px 12px; + background-color: var(--button-grey-bg); + border: 1px solid var(--button-grey-border-color); + color: var(--button-grey-text-color); + i { + margin-right: 4px; + } + &:hover { + color: var(--button-grey-text-color); + } + } +} diff --git a/frontend/app/element/linkbutton.tsx b/frontend/app/element/linkbutton.tsx new file mode 100644 index 000000000..4918024fc --- /dev/null +++ b/frontend/app/element/linkbutton.tsx @@ -0,0 +1,37 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { clsx } from "clsx"; +import * as React from "react"; + +import "./linkbutton.less"; + +interface LinkButtonProps { + href: string; + rel?: string; + target?: string; + children: React.ReactNode; + disabled?: boolean; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + style?: React.CSSProperties; + autoFocus?: boolean; + className?: string; + termInline?: boolean; + title?: string; + onClick?: (e: React.MouseEvent) => void; +} + +const LinkButton = ({ leftIcon, rightIcon, children, className, ...rest }: LinkButtonProps) => { + return ( + + + {leftIcon && {leftIcon}} + {children} + {rightIcon && {rightIcon}} + + + ); +}; + +export { LinkButton }; diff --git a/frontend/app/element/magnify.less b/frontend/app/element/magnify.less new file mode 100644 index 000000000..111674fdd --- /dev/null +++ b/frontend/app/element/magnify.less @@ -0,0 +1,31 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.magnify-icon { + display: inline-block; + width: 15px; + height: 15px; + svg { + #arrow1 { + transform: rotate(180deg); + transform-origin: calc(29.167% + 4px) calc(70.833% + 4px); // account for path offset in the svg itself + } + #arrow2 { + transform: rotate(-180deg); + transform-origin: calc(70.833% + 4px) calc(29.167% + 4px); + } + #arrow1, + #arrow2 { + transition: transform 300ms ease-in; + transition-delay: 100ms; + } + } + &.enabled { + svg { + #arrow1, + #arrow2 { + transform: rotate(0deg); + } + } + } +} diff --git a/frontend/app/element/magnify.stories.tsx b/frontend/app/element/magnify.stories.tsx new file mode 100644 index 000000000..8244feae4 --- /dev/null +++ b/frontend/app/element/magnify.stories.tsx @@ -0,0 +1,28 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { Meta, StoryObj } from "@storybook/react"; +import { MagnifyIcon } from "./magnify"; + +const meta = { + title: "Icons/Magnify", + component: MagnifyIcon, + args: { + enabled: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Enabled: Story = { + args: { + enabled: true, + }, +}; + +export const Disabled: Story = { + args: { + enabled: false, + }, +}; diff --git a/frontend/app/element/magnify.tsx b/frontend/app/element/magnify.tsx new file mode 100644 index 000000000..7ccbb3e0f --- /dev/null +++ b/frontend/app/element/magnify.tsx @@ -0,0 +1,18 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; +import MagnifySVG from "../asset/magnify.svg"; +import "./magnify.less"; + +interface MagnifyIconProps { + enabled: boolean; +} + +export function MagnifyIcon({ enabled }: MagnifyIconProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/app/element/markdown.less b/frontend/app/element/markdown.less new file mode 100644 index 000000000..c41b9bedc --- /dev/null +++ b/frontend/app/element/markdown.less @@ -0,0 +1,160 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.markdown { + display: flex; + flex-direction: row; + overflow: hidden; + height: 100%; + width: 100%; + .content { + height: 100%; + width: 100%; + overflow: scroll; + line-height: 1.5; + color: var(--app-text-color); + font-family: var(--markdown-font); + font-size: 14px; + overflow-wrap: break-word; + + .heading { + &:first-of-type { + margin-top: 0 !important; + } + color: var(--app-text-color); + margin-top: 16px; + margin-bottom: 8px; + } + + strong { + color: var(--app-text-color); + } + + a { + color: #32afff; + } + + ul { + list-style-type: disc; + list-style-position: outside; + margin-left: 16px; + } + + ol { + list-style-position: outside; + margin-left: 19px; + } + + blockquote { + margin: 4px 10px 4px 10px; + border-radius: 3px; + background-color: var(--panel-bg-color); + padding: 2px 4px 2px 6px; + } + + pre.codeblock { + background-color: var(--panel-bg-color); + margin: 4px 10px; + padding: 0.4em 0.7em; + border-radius: 4px; + position: relative; + + code { + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + overflow: auto; + overflow: hidden; + background-color: transparent; + } + + .codeblock-actions { + visibility: hidden; + display: flex; + position: absolute; + top: 0; + right: 0; + border-radius: 4px; + backdrop-filter: blur(8px); + margin: 2px 2px; + padding: 4px 4px; + align-items: center; + justify-content: flex-end; + gap: 4px; + } + + &:hover .codeblock-actions { + visibility: visible; + } + } + + code { + color: var(--main-text-color); + font: var(--fixed-font); + border-radius: 4px; + } + + pre.selected { + outline: 2px solid var(--accent-color); + } + + .heading { + font-weight: semibold; + padding-top: 6px; + } + + .heading.is-1 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 6px; + font-size: 2em; + } + .heading.is-2 { + border-bottom: 1px solid var(--border-color); + padding-bottom: 6px; + font-size: 1.5em; + } + .heading.is-3 { + font-size: 1.25em; + } + .heading.is-4 { + font-size: 1em; + } + .heading.is-5 { + font-size: 0.875em; + } + .heading.is-6 { + font-size: 0.85em; + } + } + + // The TOC view should scroll independently of the contents view. + .toc { + max-width: 40%; + height: 100%; + overflow: scroll; + border-left: 1px solid var(--border-color); + .toc-inner { + height: fit-content; + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: 5px; + text-wrap: wrap; + + h4 { + padding-left: 5px; + } + + .toc-item { + cursor: pointer; + --indent-factor: 1; + + // The 5px offset in the padding will ensure that when the text in the item wraps, it indents slightly. + // The indent factor is set in the React code and denotes the depth of the item in the TOC tree. + padding-left: calc((var(--indent-factor) - 1) * 10px + 5px); + text-indent: -5px; + } + } + } +} diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx new file mode 100644 index 000000000..1f1d64689 --- /dev/null +++ b/frontend/app/element/markdown.tsx @@ -0,0 +1,289 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { CopyButton } from "@/app/element/copybutton"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util"; +import { clsx } from "clsx"; +import { Atom } from "jotai"; +import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import ReactMarkdown, { Components } from "react-markdown"; +import rehypeHighlight from "rehype-highlight"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import rehypeSlug from "rehype-slug"; +import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; +import remarkGfm from "remark-gfm"; +import { openLink } from "../store/global"; +import { IconButton } from "./iconbutton"; +import "./markdown.less"; + +const Link = ({ + setFocusedHeading, + props, +}: { + props: React.AnchorHTMLAttributes; + setFocusedHeading: (href: string) => void; +}) => { + const onClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (props.href.startsWith("#")) { + setFocusedHeading(props.href); + } else { + openLink(props.href); + } + }; + return ( + + {props.children} + + ); +}; + +const Heading = ({ props, hnum }: { props: React.HTMLAttributes; hnum: number }) => { + return ( +
+ {props.children} +
+ ); +}; + +const Code = ({ className, children }: { className: string; children: React.ReactNode }) => { + return {children}; +}; + +type CodeBlockProps = { + children: React.ReactNode; + onClickExecute?: (cmd: string) => void; +}; + +const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => { + const getTextContent = (children: any): string => { + if (typeof children === "string") { + return children; + } else if (Array.isArray(children)) { + return children.map(getTextContent).join(""); + } else if (children.props && children.props.children) { + return getTextContent(children.props.children); + } + return ""; + }; + + const handleCopy = async (e: React.MouseEvent) => { + let textToCopy = getTextContent(children); + textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline + await navigator.clipboard.writeText(textToCopy); + }; + + const handleExecute = (e: React.MouseEvent) => { + let textToCopy = getTextContent(children); + textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline + if (onClickExecute) { + onClickExecute(textToCopy); + return; + } + }; + + return ( +
+            {children}
+            
+ + {onClickExecute && ( + + )} +
+
+ ); +}; + +const MarkdownSource = (props: React.HTMLAttributes) => { + return null; +}; + +const MarkdownImg = ({ + props, + resolveOpts, +}: { + props: React.ImgHTMLAttributes; + resolveOpts: MarkdownResolveOpts; +}) => { + const [resolvedSrc, setResolvedSrc] = useState(props.src); + const [resolvedStr, setResolvedStr] = useState(null); + const [resolving, setResolving] = useState(true); + + useEffect(() => { + if (props.src.startsWith("http://") || props.src.startsWith("https://")) { + setResolving(false); + setResolvedSrc(props.src); + setResolvedStr(null); + return; + } + if (props.src.startsWith("data:image/")) { + setResolving(false); + setResolvedSrc(props.src); + setResolvedStr(null); + return; + } + if (resolveOpts == null) { + setResolving(false); + setResolvedSrc(null); + setResolvedStr(`[img:${props.src}]`); + return; + } + const resolveFn = async () => { + const route = makeConnRoute(resolveOpts.connName); + const fileInfo = await RpcApi.RemoteFileJoinCommand(WindowRpcClient, [resolveOpts.baseDir, props.src], { + route: route, + }); + const usp = new URLSearchParams(); + usp.set("path", fileInfo.path); + if (!isBlank(resolveOpts.connName)) { + usp.set("connection", resolveOpts.connName); + } + const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); + setResolvedSrc(streamingUrl); + setResolvedStr(null); + setResolving(false); + }; + resolveFn(); + }, [props.src]); + + if (resolving) { + return null; + } + if (resolvedStr != null) { + return {resolvedStr}; + } + if (resolvedSrc != null) { + return ; + } + return [img]; +}; + +type MarkdownProps = { + text?: string; + textAtom?: Atom | Atom>; + showTocAtom?: Atom; + style?: React.CSSProperties; + className?: string; + onClickExecute?: (cmd: string) => void; + resolveOpts?: MarkdownResolveOpts; +}; + +const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts, onClickExecute }: MarkdownProps) => { + const textAtomValue = useAtomValueSafe(textAtom); + const tocRef = useRef([]); + const showToc = useAtomValueSafe(showTocAtom) ?? false; + const contentsOsRef = useRef(null); + const [focusedHeading, setFocusedHeading] = useState(null); + + // Ensure uniqueness of ids between MD preview instances. + const [idPrefix] = useState(crypto.randomUUID()); + + useEffect(() => { + if (focusedHeading && contentsOsRef.current && contentsOsRef.current.osInstance()) { + const { viewport } = contentsOsRef.current.osInstance().elements(); + const heading = document.getElementById(idPrefix + focusedHeading.slice(1)); + if (heading) { + const headingBoundingRect = heading.getBoundingClientRect(); + const viewportBoundingRect = viewport.getBoundingClientRect(); + const headingTop = headingBoundingRect.top - viewportBoundingRect.top; + viewport.scrollBy({ top: headingTop }); + } + } + }, [focusedHeading]); + + const markdownComponents: Partial = { + a: (props: React.HTMLAttributes) => ( + + ), + h1: (props: React.HTMLAttributes) => , + h2: (props: React.HTMLAttributes) => , + h3: (props: React.HTMLAttributes) => , + h4: (props: React.HTMLAttributes) => , + h5: (props: React.HTMLAttributes) => , + h6: (props: React.HTMLAttributes) => , + img: (props: React.HTMLAttributes) => , + source: (props: React.HTMLAttributes) => , + code: Code, + pre: (props: React.HTMLAttributes) => ( + + ), + }; + + const toc = useMemo(() => { + if (showToc && tocRef.current.length > 0) { + return tocRef.current.map((item) => { + return ( + setFocusedHeading(item.href)} + > + {item.value} + + ); + }); + } + }, [showToc, tocRef]); + + text = textAtomValue ?? text; + + return ( +
+ + + rehypeSanitize({ + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + span: [ + ...(defaultSchema.attributes?.span || []), + // Allow all class names starting with `hljs-`. + ["className", /^hljs-./], + // Alternatively, to allow only certain class names: + // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] + ], + }, + tagNames: [...(defaultSchema.tagNames || []), "span"], + }), + () => rehypeSlug({ prefix: idPrefix }), + ]} + components={markdownComponents} + > + {text} + + + {toc && ( + +
+

Table of Contents

+ {toc} +
+
+ )} +
+ ); +}; + +export { Markdown }; diff --git a/frontend/app/element/modal.less b/frontend/app/element/modal.less new file mode 100644 index 000000000..5339d03fb --- /dev/null +++ b/frontend/app/element/modal.less @@ -0,0 +1,57 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.modal-container { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100%; + z-index: var(--zindex-elem-modal); + background-color: rgba(21, 23, 21, 0.7); + + .modal { + display: flex; + flex-direction: column; + border-radius: 10px; + padding: 0; + width: 80%; + margin-top: 25vh; + margin-left: auto; + margin-right: auto; + background: var(--main-bg-color); + border: 1px solid var(--border-color); + + .modal-header { + display: flex; + flex-direction: column; + padding: 20px 20px 10px; + border-bottom: 1px solid var(--border-color); + + .modal-title { + margin: 0 0 5px; + color: var(--main-text-color); + font-size: var(--title-font-size); + } + + p { + margin: 0; + font-size: 0.8rem; + color: var(--secondary-text-color); + } + } + + .modal-content { + padding: 20px; + overflow: auto; + } + + .modal-footer { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding: 15px 20px; + gap: 20px; + } + } +} diff --git a/frontend/app/element/modal.tsx b/frontend/app/element/modal.tsx new file mode 100644 index 000000000..173ba5f5b --- /dev/null +++ b/frontend/app/element/modal.tsx @@ -0,0 +1,83 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/element/button"; +import React from "react"; + +import "./modal.less"; + +interface ModalProps { + id?: string; + children: React.ReactNode; + onClickOut: () => void; +} + +function Modal({ children, onClickOut, id = "modal", ...otherProps }: ModalProps) { + const handleOutsideClick = (e: React.SyntheticEvent) => { + if (typeof onClickOut === "function" && (e.target as Element).className === "modal-container") { + onClickOut(); + } + }; + + return ( +
+ + {children} + +
+ ); +} + +interface ModalContentProps { + children: React.ReactNode; +} + +function ModalContent({ children }: ModalContentProps) { + return
{children}
; +} + +interface ModalHeaderProps { + title: React.ReactNode; + description?: string; +} + +function ModalHeader({ title, description }: ModalHeaderProps) { + return ( +
+ {typeof title === "string" ?

{title}

: title} + {description &&

{description}

} +
+ ); +} + +interface ModalFooterProps { + children: React.ReactNode; +} + +function ModalFooter({ children }: ModalFooterProps) { + return
{children}
; +} + +interface WaveModalProps { + title: string; + description?: string; + id?: string; + onSubmit: () => void; + onCancel: () => void; + buttonLabel?: string; + children: React.ReactNode; +} + +function WaveModal({ title, description, onSubmit, onCancel, buttonLabel = "Ok", children }: WaveModalProps) { + return ( + + + {children} + + + + + ); +} + +export { WaveModal }; diff --git a/frontend/app/element/quickelems.less b/frontend/app/element/quickelems.less new file mode 100644 index 000000000..b4f845323 --- /dev/null +++ b/frontend/app/element/quickelems.less @@ -0,0 +1,12 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.centered-div { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + overflow: hidden; +} diff --git a/frontend/app/element/quickelems.tsx b/frontend/app/element/quickelems.tsx new file mode 100644 index 000000000..c4bf8b926 --- /dev/null +++ b/frontend/app/element/quickelems.tsx @@ -0,0 +1,19 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import React from "react"; +import "./quickelems.less"; + +function CenteredLoadingDiv() { + return loading...; +} + +function CenteredDiv({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +export { CenteredDiv, CenteredLoadingDiv }; diff --git a/frontend/app/element/quicktips.less b/frontend/app/element/quicktips.less new file mode 100644 index 000000000..9a88ee51d --- /dev/null +++ b/frontend/app/element/quicktips.less @@ -0,0 +1,83 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.tips-wrapper { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + + .tips-section { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 5px; + + .tip-section-header { + font-weight: bold; + margin-bottom: 5px; + margin-top: 10px; + font-size: 16px; + + &:first-child { + margin-top: 0; + } + } + + .tip { + display: flex; + flex-direction: row; + align-items: center; + + code { + padding: 0.1em 0.4em; + background-color: var(--highlight-bg-color); + } + + .keybinding-group { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 5px; + margin-right: 5px; + + &:first-child { + margin-left: 0; + } + } + + .keybinding { + display: inline-block; + padding: 0.1em 0.4em; + margin: 0 0.1em; + font: var(--fixed-font); + font-size: 0.85em; + color: var(--keybinding-color); + background-color: var(--keybinding-bg-color); + border-radius: 4px; + border: 1px solid var(--keybinding-border-color); + box-shadow: none; + } + + .icon-wrap { + background-color: var(--highlight-bg-color); + padding: 2px; + color: var(--secondary-text-color); + font-size: 12px; + border-radius: 2px; + margin-right: 5px; + + svg { + position: relative; + top: 3px; + left: 1px; + height: 13px; + #arrow1, + #arrow2 { + fill: var(--main-text-color); + } + } + } + } + } +} diff --git a/frontend/app/element/quicktips.tsx b/frontend/app/element/quicktips.tsx new file mode 100644 index 000000000..6036d8ff9 --- /dev/null +++ b/frontend/app/element/quicktips.tsx @@ -0,0 +1,152 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { MagnifyIcon } from "@/app/element/magnify"; +import { PLATFORM } from "@/app/store/global"; +import "./quicktips.less"; + +const KeyBinding = ({ keyDecl }: { keyDecl: string }) => { + const parts = keyDecl.split(":"); + const elems: React.ReactNode[] = []; + for (let part of parts) { + if (part === "Cmd") { + if (PLATFORM === "darwin") { + elems.push( +
+ ⌘ Cmd +
+ ); + } else { + elems.push( +
+ Alt +
+ ); + } + continue; + } + if (part == "Ctrl") { + elems.push( +
+ ^ Ctrl +
+ ); + continue; + } + if (part == "Shift") { + elems.push( +
+ ⇧ Shift +
+ ); + continue; + } + if (part == "Arrows") { + elems.push( +
+ ← +
+ ); + elems.push( +
+ → +
+ ); + elems.push( +
+ ↑ +
+ ); + elems.push( +
+ ↓ +
+ ); + continue; + } + if (part == "Digit") { + elems.push( +
+ Number (1-9) +
+ ); + continue; + } + elems.push( +
+ {part.toUpperCase()} +
+ ); + } + return
{elems}
; +}; + +const QuickTips = () => { + return ( +
+
+
Header Icons
+
+
+ +
+ Connect to a remote server + +
+
+
+ +
+ Magnify a Block +
+
+
+ +
+ Block Settings +
+
+
+ +
+ Close Block +
+ +
Important Keybindings
+ +
+ + New Tab +
+
+ + New Terminal Block +
+
+ + Navigate Between Blocks +
+
+ + Focus Nth Block +
+
+ + Switch To Nth Tab +
+ +
wsh commands
+
+
+ wsh view [filename|url] +
+ Run this command in the terminal to preview a file, directory, or web URL. +
+
+
+
+
+ ); +}; + +export { KeyBinding, QuickTips }; diff --git a/frontend/app/element/toggle.less b/frontend/app/element/toggle.less new file mode 100644 index 000000000..e66dbc43c --- /dev/null +++ b/frontend/app/element/toggle.less @@ -0,0 +1,62 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.check-toggle-wrapper { + user-select: none; + display: flex; + height: 100%; + align-items: center; + justify-content: center; + + .checkbox-toggle { + position: relative; + display: inline-block; + width: 32px; + height: 20px; + + input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + cursor: pointer; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: var(--toggle-bg-color); + transition: 0.5s; + border-radius: 33px; + } + + .slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 2px; + background-color: var(--toggle-thumb-color); + transition: 0.5s; + border-radius: 50%; + } + + input:checked + .slider { + background-color: var(--toggle-checked-bg-color); + } + + input:checked + .slider:before { + transform: translateX(11px); + } + } + + label, + .toggle-label { + cursor: pointer; + padding: 0 5px; + } +} diff --git a/frontend/app/element/toggle.tsx b/frontend/app/element/toggle.tsx new file mode 100644 index 000000000..939776a56 --- /dev/null +++ b/frontend/app/element/toggle.tsx @@ -0,0 +1,46 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useRef } from "react"; +import "./toggle.less"; + +interface ToggleProps { + checked: boolean; + onChange: (value: boolean) => void; + label?: string; + id?: string; +} + +const Toggle = ({ checked, onChange, label, id }: ToggleProps) => { + const inputRef = useRef(null); + + const handleChange = (e: any) => { + if (onChange != null) { + onChange(e.target.checked); + } + }; + + const handleLabelClick = () => { + if (inputRef.current) { + inputRef.current.click(); + } + }; + + const inputId = id || `toggle-${Math.random().toString(36).substr(2, 9)}`; + + return ( +
+ + {label && ( + + {label} + + )} +
+ ); +}; + +export { Toggle }; diff --git a/frontend/app/element/typingindicator.less b/frontend/app/element/typingindicator.less new file mode 100644 index 000000000..bf7474013 --- /dev/null +++ b/frontend/app/element/typingindicator.less @@ -0,0 +1,46 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +@dot-width: 11px; +@dot-color: var(--success-color); +@speed: 1.5s; + +.typing { + position: relative; + height: @dot-width; + + span { + content: ""; + animation: blink @speed infinite; + animation-fill-mode: both; + height: @dot-width; + width: @dot-width; + background: @dot-color; + position: absolute; + left: 0; + top: 0; + border-radius: 50%; + + &:nth-child(2) { + animation-delay: 0.2s; + margin-left: @dot-width * 1.5; + } + + &:nth-child(3) { + animation-delay: 0.4s; + margin-left: @dot-width * 3; + } + } +} + +@keyframes blink { + 0% { + opacity: 0.1; + } + 20% { + opacity: 1; + } + 100% { + opacity: 0.1; + } +} diff --git a/frontend/app/element/typingindicator.tsx b/frontend/app/element/typingindicator.tsx new file mode 100644 index 000000000..1f63d7676 --- /dev/null +++ b/frontend/app/element/typingindicator.tsx @@ -0,0 +1,21 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { clsx } from "clsx"; + +import "./typingindicator.less"; + +type TypingIndicatorProps = { + className?: string; +}; +const TypingIndicator = ({ className }: TypingIndicatorProps) => { + return ( +
+ + + +
+ ); +}; + +export { TypingIndicator }; diff --git a/frontend/app/element/windowdrag.less b/frontend/app/element/windowdrag.less new file mode 100644 index 000000000..795e7d645 --- /dev/null +++ b/frontend/app/element/windowdrag.less @@ -0,0 +1,7 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.window-drag { + -webkit-app-region: drag; + z-index: var(--zindex-window-drag); +} diff --git a/frontend/app/element/windowdrag.tsx b/frontend/app/element/windowdrag.tsx new file mode 100644 index 000000000..3c356d06a --- /dev/null +++ b/frontend/app/element/windowdrag.tsx @@ -0,0 +1,22 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { clsx } from "clsx"; +import React, { forwardRef } from "react"; + +import "./windowdrag.less"; + +interface WindowDragProps { + className?: string; + children?: React.ReactNode; +} + +const WindowDrag = forwardRef(({ children, className }, ref) => { + return ( +
+ {children} +
+ ); +}); + +export { WindowDrag }; diff --git a/frontend/app/hook/useDimensions.tsx b/frontend/app/hook/useDimensions.tsx new file mode 100644 index 000000000..ac9f9086f --- /dev/null +++ b/frontend/app/hook/useDimensions.tsx @@ -0,0 +1,67 @@ +import useResizeObserver from "@react-hook/resize-observer"; +import { useCallback, useRef, useState } from "react"; +import { debounce } from "throttle-debounce"; + +/** + * Get the current dimensions for the specified element, and whether it is currently changing size. Update when the element resizes. + * @param ref The reference to the element to observe. + * @param delay The debounce delay to use for updating the dimensions. + * @returns The dimensions of the element, and direction in which the dimensions are changing. + */ +const useDimensions = (ref: React.RefObject, delay = 0) => { + const [dimensions, setDimensions] = useState<{ + height: number | null; + width: number | null; + widthDirection?: string; + heightDirection?: string; + }>({ + height: null, + width: null, + }); + + const previousDimensions = useRef<{ height: number | null; width: number | null }>({ + height: null, + width: null, + }); + + const updateDimensions = useCallback((entry: ResizeObserverEntry) => { + const parentHeight = entry.contentRect.height; + const parentWidth = entry.contentRect.width; + + let widthDirection = ""; + let heightDirection = ""; + + if (previousDimensions.current.width !== null && previousDimensions.current.height !== null) { + if (parentWidth > previousDimensions.current.width) { + widthDirection = "expanding"; + } else if (parentWidth < previousDimensions.current.width) { + widthDirection = "shrinking"; + } else { + widthDirection = "unchanged"; + } + + if (parentHeight > previousDimensions.current.height) { + heightDirection = "expanding"; + } else if (parentHeight < previousDimensions.current.height) { + heightDirection = "shrinking"; + } else { + heightDirection = "unchanged"; + } + } + + previousDimensions.current = { height: parentHeight, width: parentWidth }; + + setDimensions({ height: parentHeight, width: parentWidth, widthDirection, heightDirection }); + }, []); + + const fUpdateDimensions = useCallback(delay > 0 ? debounce(delay, updateDimensions) : updateDimensions, [ + updateDimensions, + delay, + ]); + + useResizeObserver(ref, fUpdateDimensions); + + return dimensions; +}; + +export { useDimensions }; diff --git a/frontend/app/hook/useHeight.tsx b/frontend/app/hook/useHeight.tsx new file mode 100644 index 000000000..e6ce41c31 --- /dev/null +++ b/frontend/app/hook/useHeight.tsx @@ -0,0 +1,28 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import useResizeObserver from "@react-hook/resize-observer"; +import { useCallback, useState } from "react"; +import { debounce } from "throttle-debounce"; + +/** + * Get the height of the specified element and update it when the element resizes. + * @param ref The reference to the element to observe. + * @param delay The debounce delay to use for updating the height. + * @returns The current height of the element, or null if the element is not yet mounted. + */ +const useHeight = (ref: React.RefObject, delay = 0) => { + const [height, setHeight] = useState(null); + + const updateHeight = useCallback((entry: ResizeObserverEntry) => { + setHeight(entry.contentRect.height); + }, []); + + const fUpdateHeight = useCallback(delay > 0 ? debounce(delay, updateHeight) : updateHeight, [updateHeight, delay]); + + useResizeObserver(ref, fUpdateHeight); + + return height; +}; + +export { useHeight }; diff --git a/frontend/app/hook/useLongClick.tsx b/frontend/app/hook/useLongClick.tsx new file mode 100644 index 000000000..5b71563fd --- /dev/null +++ b/frontend/app/hook/useLongClick.tsx @@ -0,0 +1,59 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useLongClick = (ref, onClick, onLongClick, disabled = false, ms = 300) => { + const timerRef = useRef(null); + const [longClickTriggered, setLongClickTriggered] = useState(false); + + const startPress = useCallback( + (e: React.MouseEvent) => { + if (onLongClick == null) { + return; + } + setLongClickTriggered(false); + timerRef.current = setTimeout(() => { + setLongClickTriggered(true); + onLongClick?.(e); + }, ms); + }, + [onLongClick, ms] + ); + + const stopPress = useCallback(() => { + clearTimeout(timerRef.current); + }, []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (longClickTriggered) { + e.preventDefault(); + e.stopPropagation(); + return; + } + onClick?.(e); + }, + [longClickTriggered, onClick] + ); + + useEffect(() => { + const element = ref.current; + + if (!element || disabled) return; + + element.addEventListener("mousedown", startPress); + element.addEventListener("mouseup", stopPress); + element.addEventListener("mouseleave", stopPress); + element.addEventListener("click", handleClick); + + return () => { + element.removeEventListener("mousedown", startPress); + element.removeEventListener("mouseup", stopPress); + element.removeEventListener("mouseleave", stopPress); + element.removeEventListener("click", handleClick); + }; + }, [ref.current, startPress, stopPress, handleClick]); + + return ref; +}; diff --git a/frontend/app/hook/useWidth.tsx b/frontend/app/hook/useWidth.tsx new file mode 100644 index 000000000..c7007d7f4 --- /dev/null +++ b/frontend/app/hook/useWidth.tsx @@ -0,0 +1,28 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import useResizeObserver from "@react-hook/resize-observer"; +import { useCallback, useState } from "react"; +import { debounce } from "throttle-debounce"; + +/** + * Get the width of the specified element and update it when the element resizes. + * @param ref The reference to the element to observe. + * @param delay The debounce delay to use for updating the width. + * @returns The current width of the element, or null if the element is not yet mounted. + */ +const useWidth = (ref: React.RefObject, delay = 0) => { + const [width, setWidth] = useState(null); + + const updateWidth = useCallback((entry: ResizeObserverEntry) => { + setWidth(entry.contentRect.width); + }, []); + + const fUpdateWidth = useCallback(delay > 0 ? debounce(delay, updateWidth) : updateWidth, [updateWidth, delay]); + + useResizeObserver(ref, fUpdateWidth); + + return width; +}; + +export { useWidth }; diff --git a/frontend/app/mixins.less b/frontend/app/mixins.less new file mode 100644 index 000000000..9178b3971 --- /dev/null +++ b/frontend/app/mixins.less @@ -0,0 +1,9 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.ellipsis() { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/frontend/app/modals/about.less b/frontend/app/modals/about.less new file mode 100644 index 000000000..9b4a2b302 --- /dev/null +++ b/frontend/app/modals/about.less @@ -0,0 +1,61 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.about-modal { + width: 445px; + padding-bottom: 34px; + + .modal-content { + margin-bottom: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 24px; + + .section-wrapper { + display: flex; + flex-direction: column; + gap: 26px; + width: 100%; + } + + .section { + align-items: center; + gap: 16px; + align-self: stretch; + width: 100%; + text-align: center; + + &.logo-section { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; + + .app-name { + font-size: 25px; + } + + .text-standard { + line-height: 20px; + } + } + } + + .section:nth-child(3) { + display: flex; + align-items: flex-start; + gap: 7px; + + .wave-button-link { + display: flex; + align-items: center; + + i { + font-size: 16px; + } + } + } + } +} diff --git a/frontend/app/modals/about.tsx b/frontend/app/modals/about.tsx new file mode 100644 index 000000000..589df36b0 --- /dev/null +++ b/frontend/app/modals/about.tsx @@ -0,0 +1,71 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import Logo from "@/app/asset/logo.svg"; +import { LinkButton } from "@/app/element/linkbutton"; +import { modalsModel } from "@/app/store/modalmodel"; +import { Modal } from "./modal"; + +import { isDev } from "@/util/isdev"; +import { useState } from "react"; +import { getApi } from "../store/global"; +import "./about.less"; + +interface AboutModalProps {} + +const AboutModal = ({}: AboutModalProps) => { + const currentDate = new Date(); + const [details] = useState(() => getApi().getAboutModalDetails()); + + return ( + modalsModel.popModal()}> +
+
+ +
Wave Terminal
+
+ Open-Source AI-Native Terminal +
+ Built for Seamless Workflows +
+
+
+ Client Version {details.version} ({isDev() ? "dev-" : ""} + {details.buildTime}) +
+
+ } + > + Github + + } + > + Website + + } + > + Acknowledgements + +
+
© {currentDate.getFullYear()} Command Line Inc.
+
+ + ); +}; + +AboutModal.displayName = "AboutModal"; + +export { AboutModal }; diff --git a/frontend/app/modals/modal.less b/frontend/app/modals/modal.less new file mode 100644 index 000000000..46ebe01a6 --- /dev/null +++ b/frontend/app/modals/modal.less @@ -0,0 +1,72 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.modal-wrapper { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: var(--zindex-modal-wrapper); + + .modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(21, 23, 21, 0.7); + z-index: var(--zindex-modal-backdrop); + } +} + +.modal { + position: relative; + z-index: var(--zindex-modal); + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 24px 16px 16px; + border-radius: 8px; + border: 0.5px solid var(--modal-border-color); + background: var(--modal-bg-color); + box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25); + + .modal-close-btn { + position: absolute; + right: 8px; + top: 8px; + padding: 8px 12px; + + i { + font-size: 18px; + } + } + + .content-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + + .modal-content { + width: 100%; + padding: 0px 20px; + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + width: 100%; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + + .button:last-child { + margin-left: 8px; + } + } +} diff --git a/frontend/app/modals/modal.tsx b/frontend/app/modals/modal.tsx new file mode 100644 index 000000000..bdf14602b --- /dev/null +++ b/frontend/app/modals/modal.tsx @@ -0,0 +1,111 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import clsx from "clsx"; +import { forwardRef } from "react"; +import ReactDOM from "react-dom"; + +import "./modal.less"; + +interface ModalProps { + children?: React.ReactNode; + okLabel?: string; + cancelLabel?: string; + className?: string; + onClickBackdrop?: () => void; + onOk?: () => void; + onCancel?: () => void; + onClose?: () => void; +} + +const Modal = forwardRef( + ({ children, className, cancelLabel, okLabel, onCancel, onOk, onClose, onClickBackdrop }: ModalProps, ref) => { + const renderBackdrop = (onClick) =>
; + + const renderFooter = () => { + return onOk || onCancel; + }; + + const renderModal = () => ( +
+ {renderBackdrop(onClickBackdrop)} +
+ +
+ {children} +
+ {renderFooter() && ( + + )} +
+
+ ); + + return ReactDOM.createPortal(renderModal(), document.getElementById("main")); + } +); + +interface ModalContentProps { + children: React.ReactNode; +} + +function ModalContent({ children }: ModalContentProps) { + return
{children}
; +} + +interface ModalFooterProps { + okLabel?: string; + cancelLabel?: string; + onOk?: () => void; + onCancel?: () => void; +} + +const ModalFooter = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }: ModalFooterProps) => { + return ( +
+ {onCancel && ( + + )} + {onOk && } +
+ ); +}; + +interface FlexiModalProps { + children?: React.ReactNode; + className?: string; + onClickBackdrop?: () => void; +} + +interface FlexiModalComponent + extends React.ForwardRefExoticComponent> { + Content: typeof ModalContent; + Footer: typeof ModalFooter; +} + +const FlexiModal = forwardRef( + ({ children, className, onClickBackdrop }: FlexiModalProps, ref) => { + const renderBackdrop = (onClick: () => void) =>
; + + const renderModal = () => ( +
+ {renderBackdrop(onClickBackdrop)} +
+ {children} +
+
+ ); + + return ReactDOM.createPortal(renderModal(), document.getElementById("main")!); + } +); + +(FlexiModal as FlexiModalComponent).Content = ModalContent; +(FlexiModal as FlexiModalComponent).Footer = ModalFooter; + +export { FlexiModal, Modal }; diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx new file mode 100644 index 000000000..f77d790f2 --- /dev/null +++ b/frontend/app/modals/modalregistry.tsx @@ -0,0 +1,16 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { AboutModal } from "./about"; +import { TosModal } from "./tos"; +import { UserInputModal } from "./userinputmodal"; + +const modalRegistry: { [key: string]: React.ComponentType } = { + [TosModal.displayName || "TosModal"]: TosModal, + [UserInputModal.displayName || "UserInputModal"]: UserInputModal, + [AboutModal.displayName || "AboutModal"]: AboutModal, +}; + +export const getModalComponent = (key: string): React.ComponentType | undefined => { + return modalRegistry[key]; +}; diff --git a/frontend/app/modals/modalsrenderer.tsx b/frontend/app/modals/modalsrenderer.tsx new file mode 100644 index 000000000..0cc3cbf83 --- /dev/null +++ b/frontend/app/modals/modalsrenderer.tsx @@ -0,0 +1,37 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atoms, globalStore } from "@/store/global"; +import { modalsModel } from "@/store/modalmodel"; +import * as jotai from "jotai"; +import { useEffect } from "react"; +import { getModalComponent } from "./modalregistry"; +import { TosModal } from "./tos"; + +const ModalsRenderer = () => { + const clientData = jotai.useAtomValue(atoms.client); + const [tosOpen, setTosOpen] = jotai.useAtom(modalsModel.tosOpen); + const [modals] = jotai.useAtom(modalsModel.modalsAtom); + const rtn: JSX.Element[] = []; + for (const modal of modals) { + const ModalComponent = getModalComponent(modal.displayName); + if (ModalComponent) { + rtn.push(); + } + } + if (tosOpen) { + rtn.push(); + } + useEffect(() => { + if (!clientData.tosagreed) { + setTosOpen(true); + } + }, [clientData]); + useEffect(() => { + globalStore.set(atoms.modalOpen, rtn.length > 0); + }, [rtn]); + + return <>{rtn}; +}; + +export { ModalsRenderer }; diff --git a/frontend/app/modals/tos.less b/frontend/app/modals/tos.less new file mode 100644 index 000000000..1540a139c --- /dev/null +++ b/frontend/app/modals/tos.less @@ -0,0 +1,242 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.tos-modal { + width: 560px; + border-radius: 10px; + padding: 0; + + .modal-inner { + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 30px; + width: 100%; + + header.tos-header { + flex-direction: column; + gap: 8px; + border-bottom: none; + padding: 0; + margin-bottom: 36px; + width: 100%; + + .logo { + margin-bottom: 10px; + display: flex; + justify-content: center; + } + + .modal-title { + text-align: center; + font-size: 25px; + font-weight: 400; + color: var(--main-text-color); + } + + .modal-subtitle { + color: var(--main-text-color); + text-align: center; + + font-style: normal; + font-weight: 300; + line-height: 20px; + } + } + + .tos-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 32px; + width: 100%; + margin-bottom: 0; + margin-bottom: 20px; + + .check-toggle-wrapper .toggle-label { + color: var(--secondary-text-color); + } + + .tips-wrapper { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + + .tips-section { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 5px; + + .tip-section-header { + font-weight: bold; + margin-bottom: 5px; + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + + .tip { + display: flex; + flex-direction: row; + align-items: center; + + .keybinding2 { + font: var(--fixed-font); + background-color: var(--highlight-bg-color); + color: var(--main-text-color); + padding: 2px 8px; + border-radius: 4px; + } + + .keybinding-group { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 5px; + margin-right: 5px; + + &:first-child { + margin-left: 0; + } + } + + .keybinding { + display: inline-block; + padding: 0.1em 0.4em; + margin: 0 0.1em; + font-family: "SF Pro Text", "Segoe UI", sans-serif; + font-size: 0.85em; + color: #e0e0e0; + background-color: #333; + border-radius: 4px; + border: 1px solid #444; + box-shadow: none; + } + + .keybinding3 { + color: black; + display: inline-block; + padding: 0.2em 0.4em; + min-width: 24px; + height: 22px; + margin: 0 0.1em; + font-family: "SF Pro Text", "Segoe UI", sans-serif; + font-size: 0.9em; + border: 1px solid #aaa; + border-radius: 4px; + background-color: #ddd; + color: var(--invert-text-color); + box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.1); + text-align: center; + } + + .icon-wrap { + background-color: var(--highlight-bg-color); + padding: 2px; + color: var(--secondary-text-color); + font-size: 12px; + border-radius: 2px; + margin-right: 5px; + + svg { + position: relative; + top: 3px; + left: 1px; + height: 13px; + #arrow1, + #arrow2 { + fill: var(--main-text-color); + } + } + } + } + } + } + + .content-section { + display: flex; + width: 100%; + align-items: center; + gap: 18px; + + .icon-wrapper { + .icon { + font-size: 32px; + color: rgba(255, 255, 255, 0.5); + } + + .fa-people-group { + font-size: 25px; + } + } + + .content-section-inner { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + flex: 1 0 0; + + .content-section-title { + color: var(--main-text-color); + font-style: normal; + line-height: 18px; + font-size: 16px; + margin-bottom: 5px; + } + + .content-section-text { + color: var(--secondary-text-color); + font-style: normal; + line-height: 20px; + + b { + color: var(--main-text-color); + } + } + + .content-section-field { + display: flex; + align-items: center; + gap: 8px; + } + + .check-toggle-wrapper { + margin-top: 5px; + + label { + font-size: 13px; + } + } + } + } + } + + footer { + .content-section-text { + text-align: center; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + } + + .button-wrapper { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + button { + font-size: 14px; + } + + button.disabled-button { + cursor: default; + } + } + } + } +} diff --git a/frontend/app/modals/tos.tsx b/frontend/app/modals/tos.tsx new file mode 100644 index 000000000..c7233f7c0 --- /dev/null +++ b/frontend/app/modals/tos.tsx @@ -0,0 +1,206 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import Logo from "@/app/asset/logo.svg"; +import { Button } from "@/app/element/button"; +import { Toggle } from "@/app/element/toggle"; +import * as services from "@/store/services"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import { useEffect, useRef, useState } from "react"; +import { FlexiModal } from "./modal"; + +import { QuickTips } from "@/app/element/quicktips"; +import { atoms } from "@/app/store/global"; +import { modalsModel } from "@/app/store/modalmodel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import "./tos.less"; + +const pageNumAtom: PrimitiveAtom = atom(1); + +const ModalPage1 = () => { + const settings = useAtomValue(atoms.settingsAtom); + const clientData = useAtomValue(atoms.client); + const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen); + const [telemetryEnabled, setTelemetryEnabled] = useState(!!settings["telemetry:enabled"]); + const setPageNum = useSetAtom(pageNumAtom); + + const acceptTos = () => { + if (!clientData.tosagreed) { + services.ClientService.AgreeTos(); + } + setPageNum(2); + }; + + const setTelemetry = (value: boolean) => { + RpcApi.SetConfigCommand(WindowRpcClient, { "telemetry:enabled": value }) + .then(() => { + setTelemetryEnabled(value); + }) + .catch((error) => { + console.error("failed to set telemetry:", error); + }); + }; + + const label = telemetryEnabled ? "Telemetry Enabled" : "Telemetry Disabled"; + + return ( + <> +
+
+ +
+
Welcome to Wave Terminal
+
+
+
+
+ + + +
+
+
Support us on GitHub
+
+ We're open source and committed to providing a free terminal for individual users. + Please show your support by giving us a star on{" "} + + Github (wavetermdev/waveterm) + +
+
+
+
+
+ + + +
+
+
Join our Community
+
+ Get help, submit feature requests, report bugs, or just chat with fellow terminal + enthusiasts. +
+ + Join the Wave Discord Channel + +
+
+
+
+
+ +
+
+
Telemetry
+
+ We collect minimal anonymous{" "} + + telemetry data + {" "} + to help us understand how people are using Wave ( + + Privacy Policy + + ). +
+ +
+
+
+
+
+ +
+
+ + ); +}; + +const ModalPage2 = () => { + const [tosOpen, setTosOpen] = useAtom(modalsModel.tosOpen); + + const handleGetStarted = () => { + setTosOpen(false); + }; + + return ( + <> +
+
+ +
+
Icons and Keybindings
+
+
+ +
+
+
+ +
+
+ + ); +}; + +const TosModal = () => { + const modalRef = useRef(null); + const [pageNum, setPageNum] = useAtom(pageNumAtom); + const clientData = useAtomValue(atoms.client); + + const updateModalHeight = () => { + const windowHeight = window.innerHeight; + if (modalRef.current) { + const modalHeight = modalRef.current.offsetHeight; + const maxHeight = windowHeight * 0.9; + if (maxHeight < modalHeight) { + modalRef.current.style.height = `${maxHeight}px`; + } else { + modalRef.current.style.height = "auto"; + } + } + }; + + useEffect(() => { + // on unmount, always reset pagenum + if (clientData.tosagreed) { + setPageNum(2); + } + return () => { + setPageNum(1); + }; + }, []); + + useEffect(() => { + updateModalHeight(); // Run on initial render + + window.addEventListener("resize", updateModalHeight); // Run on window resize + return () => { + window.removeEventListener("resize", updateModalHeight); + }; + }, []); + + return ( + + + {pageNum === 1 ? : } + + + ); +}; + +TosModal.displayName = "TosModal"; + +export { TosModal }; diff --git a/frontend/app/modals/typeaheadmodal.less b/frontend/app/modals/typeaheadmodal.less new file mode 100644 index 000000000..4e896a7b3 --- /dev/null +++ b/frontend/app/modals/typeaheadmodal.less @@ -0,0 +1,115 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +@import "../mixins.less"; + +.type-ahead-modal-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: transparent; + z-index: var(--zindex-typeahead-modal-backdrop); +} + +.type-ahead-modal { + position: absolute; + z-index: var(--zindex-typeahead-modal); + display: flex; + flex-direction: column; + align-items: flex-start; + border-radius: 6px; + border: 1px solid var(--modal-border-color); + background: var(--modal-bg-color); + box-shadow: 0px 13px 16px 0px rgba(0, 0, 0, 0.4); + padding: 6px; + flex-direction: column; + + .label { + opacity: 0.5; + font-size: 13px; + white-space: nowrap; + } + + .input { + border: none; + border-bottom: none; + height: 24px; + border-radius: 0; + + input { + width: 100%; + flex-shrink: 0; + padding: 4px 6px; + height: 24px; + } + + .input-decoration.end-position { + margin: 6px; + + i { + opacity: 0.3; + } + } + } + + &.has-suggestions { + .input { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } + } + + .suggestions-wrapper { + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 10px; + + .suggestion-header { + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 12px; + opacity: 0.7; + letter-spacing: 0.11px; + padding: 4px 0px 0px 4px; + } + + .suggestion-item { + width: 100%; + cursor: pointer; + display: flex; + padding: 6px 8px; + align-items: center; + gap: 8px; + align-self: stretch; + border-radius: 4px; + + &.selected { + background-color: rgb(from var(--accent-color) r g b / 0.5); + color: var(--main-text-color); + } + + &:hover:not(.selected) { + background-color: var(--highlight-bg-color); + } + + .typeahead-item-name { + .ellipsis(); + display: flex; + gap: 8px; + font-size: 11px; + font-weight: 400; + line-height: 14px; + + i { + display: inline-block; + position: relative; + top: 2px; + } + } + } + } +} diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx new file mode 100644 index 000000000..c9920cad4 --- /dev/null +++ b/frontend/app/modals/typeaheadmodal.tsx @@ -0,0 +1,257 @@ +import { Input } from "@/app/element/input"; +import { InputDecoration } from "@/app/element/inputdecoration"; +import { useDimensions } from "@/app/hook/useDimensions"; +import { makeIconClass } from "@/util/util"; +import clsx from "clsx"; +import React, { forwardRef, useLayoutEffect, useRef } from "react"; +import ReactDOM from "react-dom"; + +import "./typeaheadmodal.less"; + +interface SuggestionsProps { + suggestions?: SuggestionsType[]; + onSelect?: (_: string) => void; + selectIndex: number; +} + +const Suggestions = forwardRef( + ({ suggestions, onSelect, selectIndex }: SuggestionsProps, ref) => { + const renderIcon = (icon: string | React.ReactNode, color: string) => { + if (typeof icon === "string") { + return ; + } + return icon; + }; + + const renderItem = (item: SuggestionBaseItem | SuggestionConnectionItem, index: number) => ( +
{ + if ("onSelect" in item && item.onSelect) { + item.onSelect(item.value); + } else { + onSelect(item.value); + } + }} + className={clsx("suggestion-item", { selected: selectIndex === index })} + > +
+ {item.icon && + renderIcon(item.icon, "iconColor" in item && item.iconColor ? item.iconColor : "inherit")} + {item.label} +
+
+ ); + + let fullIndex = -1; + return ( +
+ {suggestions.map((item, index) => { + if ("headerText" in item) { + return ( +
+ {item.headerText &&
{item.headerText}
} + {item.items.map((subItem, subIndex) => { + fullIndex += 1; + return renderItem(subItem, fullIndex); + })} +
+ ); + } + return renderItem(item as SuggestionBaseItem, index); + })} +
+ ); + } +); + +interface TypeAheadModalProps { + anchorRef: React.RefObject; + blockRef?: React.RefObject; + suggestions?: SuggestionsType[]; + label?: string; + className?: string; + value?: string; + onChange?: (_: string) => void; + onSelect?: (_: string) => void; + onClickBackdrop?: () => void; + onKeyDown?: (_) => void; + giveFocusRef?: React.MutableRefObject<() => boolean>; + autoFocus?: boolean; + selectIndex?: number; +} + +const TypeAheadModal = ({ + className, + suggestions, + label, + anchorRef, + blockRef, + value, + onChange, + onSelect, + onKeyDown, + onClickBackdrop, + giveFocusRef, + autoFocus, + selectIndex, +}: TypeAheadModalProps) => { + const { width, height } = useDimensions(blockRef); + const modalRef = useRef(null); + const inputRef = useRef(null); + const realInputRef = useRef(null); + const suggestionsWrapperRef = useRef(null); + const suggestionsRef = useRef(null); + + useLayoutEffect(() => { + if (!modalRef.current || !inputRef.current || !suggestionsRef.current || !suggestionsWrapperRef.current) { + return; + } + + const modalStyles = window.getComputedStyle(modalRef.current); + const paddingTop = parseFloat(modalStyles.paddingTop) || 0; + const paddingBottom = parseFloat(modalStyles.paddingBottom) || 0; + const borderTop = parseFloat(modalStyles.borderTopWidth) || 0; + const borderBottom = parseFloat(modalStyles.borderBottomWidth) || 0; + const modalPadding = paddingTop + paddingBottom; + const modalBorder = borderTop + borderBottom; + + const suggestionsWrapperStyles = window.getComputedStyle(suggestionsWrapperRef.current); + const suggestionsWrapperMarginTop = parseFloat(suggestionsWrapperStyles.marginTop) || 0; + + const inputHeight = inputRef.current.getBoundingClientRect().height; + let suggestionsTotalHeight = 0; + + const suggestionItems = suggestionsRef.current.children; + for (let i = 0; i < suggestionItems.length; i++) { + suggestionsTotalHeight += suggestionItems[i].getBoundingClientRect().height; + } + + const totalHeight = + modalPadding + modalBorder + inputHeight + suggestionsTotalHeight + suggestionsWrapperMarginTop; + const maxHeight = height * 0.8; + const computedHeight = totalHeight > maxHeight ? maxHeight : totalHeight; + + modalRef.current.style.height = `${computedHeight}px`; + + suggestionsWrapperRef.current.style.height = `${computedHeight - inputHeight - modalPadding - modalBorder - suggestionsWrapperMarginTop}px`; + }, [height, suggestions]); + + useLayoutEffect(() => { + if (!blockRef.current || !modalRef.current) return; + + const blockRect = blockRef.current.getBoundingClientRect(); + const anchorRect = anchorRef.current.getBoundingClientRect(); + + const minGap = 20; + + const availableWidth = blockRect.width - minGap * 2; + let modalWidth = 300; + + if (modalWidth > availableWidth) { + modalWidth = availableWidth; + } + + let leftPosition = anchorRect.left - blockRect.left; + + const modalRightEdge = leftPosition + modalWidth; + const blockRightEdge = blockRect.width - (minGap - 4); + + if (modalRightEdge > blockRightEdge) { + leftPosition -= modalRightEdge - blockRightEdge; + } + + if (leftPosition < minGap) { + leftPosition = minGap; + } + + modalRef.current.style.width = `${modalWidth}px`; + modalRef.current.style.left = `${leftPosition}px`; + }, [width]); + + useLayoutEffect(() => { + if (giveFocusRef) { + giveFocusRef.current = () => { + realInputRef.current?.focus(); + return true; + }; + } + return () => { + if (giveFocusRef) { + giveFocusRef.current = null; + } + }; + }, []); + + useLayoutEffect(() => { + if (anchorRef.current && modalRef.current) { + const parentElement = anchorRef.current.closest(".block-frame-default-header"); + modalRef.current.style.top = `${parentElement?.getBoundingClientRect().height}px`; + } + }, []); + + const renderBackdrop = (onClick) =>
; + + const handleKeyDown = (e) => { + onKeyDown && onKeyDown(e); + }; + + const handleChange = (value) => { + onChange && onChange(value); + }; + + const handleSelect = (value) => { + onSelect && onSelect(value); + }; + + const renderModal = () => ( +
+ {renderBackdrop(onClickBackdrop)} +
0 })} + > + + + + ), + }} + /> +
0 ? "8px" : "0", + overflowY: "auto", + }} + > + {suggestions?.length > 0 && ( + + )} +
+
+
+ ); + + if (blockRef && blockRef.current == null) { + return null; + } + + return ReactDOM.createPortal(renderModal(), blockRef.current); +}; + +export { TypeAheadModal }; diff --git a/frontend/app/modals/userinputmodal.less b/frontend/app/modals/userinputmodal.less new file mode 100644 index 000000000..8cdb338c2 --- /dev/null +++ b/frontend/app/modals/userinputmodal.less @@ -0,0 +1,45 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.userinput-header { + font-weight: bold; + color: var(--main-text-color); + padding-bottom: 10px; +} + +.userinput-body { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + margin: 0 1rem 1rem 1rem; + + font: var(--fixed-font); + color: var(--main-text-color); + + .userinput-markdown { + color: inherit; + } + + .userinput-text { + } + + .userinput-inputbox { + resize: none; + background-color: var(--panel-bg-color); + border-radius: 6px; + margin: 0; + border: var(--border-color); + padding: 5px 0 5px 16px; + min-height: 30px; + color: inherit; + + &:hover { + cursor: text; + } + + &:focus { + outline-color: var(--accent-color); + } + } +} diff --git a/frontend/app/modals/userinputmodal.tsx b/frontend/app/modals/userinputmodal.tsx new file mode 100644 index 000000000..d3f40af2a --- /dev/null +++ b/frontend/app/modals/userinputmodal.tsx @@ -0,0 +1,123 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Modal } from "@/app/modals/modal"; +import { Markdown } from "@/element/markdown"; +import { modalsModel } from "@/store/modalmodel"; +import * as keyutil from "@/util/keyutil"; +import { UserInputService } from "../store/services"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import "./userinputmodal.less"; + +const UserInputModal = (userInputRequest: UserInputRequest) => { + const [responseText, setResponseText] = useState(""); + const [countdown, setCountdown] = useState(Math.floor(userInputRequest.timeoutms / 1000)); + const checkboxStatus = useRef(false); + + const handleSendCancel = useCallback(() => { + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + errormsg: "Canceled by the user", + }); + modalsModel.popModal(); + }, [responseText, userInputRequest]); + + const handleSendText = useCallback(() => { + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + text: responseText, + checkboxstat: checkboxStatus.current, + }); + modalsModel.popModal(); + }, [responseText, userInputRequest]); + + const handleSendConfirm = useCallback(() => { + UserInputService.SendUserInputResponse({ + type: "userinputresp", + requestid: userInputRequest.requestid, + confirm: true, + checkboxstat: checkboxStatus.current, + }); + modalsModel.popModal(); + }, [userInputRequest]); + + const handleSubmit = useCallback(() => { + switch (userInputRequest.responsetype) { + case "text": + handleSendText(); + break; + case "confirm": + handleSendConfirm(); + break; + } + }, [handleSendConfirm, handleSendText, userInputRequest.responsetype]); + + const handleKeyDown = useCallback( + (waveEvent: WaveKeyboardEvent): boolean => { + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + handleSendCancel(); + return; + } + if (keyutil.checkKeyPressed(waveEvent, "Enter")) { + handleSubmit(); + return true; + } + }, + [handleSendCancel, handleSubmit] + ); + + const queryText = useMemo(() => { + if (userInputRequest.markdown) { + return ; + } + return {userInputRequest.querytext}; + }, [userInputRequest.markdown, userInputRequest.querytext]); + + const inputBox = useMemo(() => { + if (userInputRequest.responsetype === "confirm") { + return <>; + } + return ( + setResponseText(e.target.value)} + value={responseText} + maxLength={400} + className="userinput-inputbox" + autoFocus={true} + onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)} + /> + ); + }, [userInputRequest.responsetype, userInputRequest.publictext, responseText, handleKeyDown, setResponseText]); + + useEffect(() => { + let timeout: ReturnType; + if (countdown <= 0) { + timeout = setTimeout(() => { + handleSendCancel(); + }, 300); + } else { + timeout = setTimeout(() => { + setCountdown(countdown - 1); + }, 1000); + } + return () => clearTimeout(timeout); + }, [countdown]); + + return ( + handleSubmit()} onCancel={() => handleSendCancel()} onClose={() => handleSendCancel()}> +
{userInputRequest.title + ` (${countdown}s)`}
+
+ {queryText} + {inputBox} +
+
+ ); +}; + +UserInputModal.displayName = "UserInputModal"; + +export { UserInputModal }; diff --git a/frontend/app/reset.less b/frontend/app/reset.less new file mode 100644 index 000000000..aa0063590 --- /dev/null +++ b/frontend/app/reset.less @@ -0,0 +1,32 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +body { + line-height: 1.2; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; +} + +input, +button, +textarea, +select { + font: inherit; +} diff --git a/frontend/app/store/contextmenu.ts b/frontend/app/store/contextmenu.ts new file mode 100644 index 000000000..fac411cf1 --- /dev/null +++ b/frontend/app/store/contextmenu.ts @@ -0,0 +1,49 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getApi } from "./global"; + +class ContextMenuModelType { + handlers: Map void> = new Map(); // id -> handler + + constructor() { + getApi().onContextMenuClick(this.handleContextMenuClick.bind(this)); + } + + handleContextMenuClick(id: string): void { + const handler = this.handlers.get(id); + if (handler) { + handler(); + } + } + + _convertAndRegisterMenu(menu: ContextMenuItem[]): ElectronContextMenuItem[] { + const electronMenuItems: ElectronContextMenuItem[] = []; + for (const item of menu) { + const electronItem: ElectronContextMenuItem = { + role: item.role, + type: item.type, + label: item.label, + id: crypto.randomUUID(), + }; + if (item.click) { + this.handlers.set(electronItem.id, item.click); + } + if (item.submenu) { + electronItem.submenu = this._convertAndRegisterMenu(item.submenu); + } + electronMenuItems.push(electronItem); + } + return electronMenuItems; + } + + showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent): void { + this.handlers.clear(); + const electronMenuItems = this._convertAndRegisterMenu(menu); + getApi().showContextMenu(electronMenuItems); + } +} + +const ContextMenuModel = new ContextMenuModelType(); + +export { ContextMenuModel, ContextMenuModelType }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts new file mode 100644 index 000000000..4b352d56d --- /dev/null +++ b/frontend/app/store/global.ts @@ -0,0 +1,559 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + getLayoutModelForActiveTab, + getLayoutModelForTabById, + LayoutTreeActionType, + LayoutTreeInsertNodeAction, + newLayoutNode, +} from "@/layout/index"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { fetch } from "@/util/fetchutil"; +import { getPrefixedSettings, isBlank } from "@/util/util"; +import { atom, Atom, createStore, PrimitiveAtom, useAtomValue } from "jotai"; +import { modalsModel } from "./modalmodel"; +import { ClientService, ObjectService } from "./services"; +import * as WOS from "./wos"; +import { getFileSubject, waveEventSubscribe } from "./wps"; + +let PLATFORM: NodeJS.Platform = "darwin"; +const globalStore = createStore(); +let atoms: GlobalAtomsType; +let globalEnvironment: "electron" | "renderer"; +const blockComponentModelMap = new Map(); +const Counters = new Map(); +const ConnStatusMap = new Map>(); + +type GlobalInitOptions = { + platform: NodeJS.Platform; + windowId: string; + clientId: string; + environment: "electron" | "renderer"; +}; + +function initGlobal(initOpts: GlobalInitOptions) { + globalEnvironment = initOpts.environment; + setPlatform(initOpts.platform); + initGlobalAtoms(initOpts); +} + +function setPlatform(platform: NodeJS.Platform) { + PLATFORM = platform; +} + +function initGlobalAtoms(initOpts: GlobalInitOptions) { + const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; + const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom; + const uiContextAtom = atom((get) => { + const windowData = get(windowDataAtom); + const uiContext: UIContext = { + windowid: get(atoms.windowId), + activetabid: windowData?.activetabid, + }; + return uiContext; + }) as Atom; + + const isFullScreenAtom = atom(false) as PrimitiveAtom; + try { + getApi().onFullScreenChange((isFullScreen) => { + globalStore.set(isFullScreenAtom, isFullScreen); + }); + } catch (_) { + // do nothing + } + + const showAboutModalAtom = atom(false) as PrimitiveAtom; + try { + getApi().onMenuItemAbout(() => { + modalsModel.pushModal("AboutModal"); + }); + } catch (_) { + // do nothing + } + + const clientAtom: Atom = atom((get) => { + const clientId = get(clientIdAtom); + if (clientId == null) { + return null; + } + return WOS.getObjectValue(WOS.makeORef("client", clientId), get); + }); + const windowDataAtom: Atom = atom((get) => { + const windowId = get(windowIdAtom); + if (windowId == null) { + return null; + } + const rtn = WOS.getObjectValue(WOS.makeORef("window", windowId), get); + return rtn; + }); + const workspaceAtom: Atom = atom((get) => { + const windowData = get(windowDataAtom); + if (windowData == null) { + return null; + } + return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); + }); + const fullConfigAtom = atom(null) as PrimitiveAtom; + const settingsAtom = atom((get) => { + return get(fullConfigAtom)?.settings ?? {}; + }) as Atom; + const tabAtom: Atom = atom((get) => { + const windowData = get(windowDataAtom); + if (windowData == null) { + return null; + } + return WOS.getObjectValue(WOS.makeORef("tab", windowData.activetabid), get); + }); + const activeTabIdAtom: Atom = atom((get) => { + const windowData = get(windowDataAtom); + if (windowData == null) { + return null; + } + return windowData.activetabid; + }); + const controlShiftDelayAtom = atom(false); + const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; + try { + globalStore.set(updaterStatusAtom, getApi().getUpdaterStatus()); + getApi().onUpdaterStatusChange((status) => { + globalStore.set(updaterStatusAtom, status); + }); + } catch (_) { + // do nothing + } + + const reducedMotionSettingAtom = atom((get) => get(settingsAtom)?.["window:reducedmotion"]); + const reducedMotionSystemPreferenceAtom = atom(false); + + // Composite of the prefers-reduced-motion media query and the window:reducedmotion user setting. + const prefersReducedMotionAtom = atom((get) => { + const reducedMotionSetting = get(reducedMotionSettingAtom); + const reducedMotionSystemPreference = get(reducedMotionSystemPreferenceAtom); + return reducedMotionSetting || reducedMotionSystemPreference; + }); + + // Set up a handler for changes to the prefers-reduced-motion media query. + if (globalThis.window != null) { + const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + globalStore.set(reducedMotionSystemPreferenceAtom, !reducedMotionQuery || reducedMotionQuery.matches); + reducedMotionQuery?.addEventListener("change", () => { + globalStore.set(reducedMotionSystemPreferenceAtom, reducedMotionQuery.matches); + }); + } + + const typeAheadModalAtom = atom({}); + const modalOpen = atom(false); + const allConnStatusAtom = atom((get) => { + const connStatuses = Array.from(ConnStatusMap.values()).map((atom) => get(atom)); + return connStatuses; + }); + const flashErrorsAtom = atom([]); + atoms = { + // initialized in wave.ts (will not be null inside of application) + windowId: windowIdAtom, + clientId: clientIdAtom, + uiContext: uiContextAtom, + client: clientAtom, + waveWindow: windowDataAtom, + workspace: workspaceAtom, + fullConfigAtom, + settingsAtom, + tabAtom, + activeTabId: activeTabIdAtom, + isFullScreen: isFullScreenAtom, + controlShiftDelayAtom, + updaterStatusAtom, + prefersReducedMotionAtom, + typeAheadModalAtom, + modalOpen, + allConnStatus: allConnStatusAtom, + flashErrors: flashErrorsAtom, + }; +} + +function initGlobalWaveEventSubs() { + waveEventSubscribe( + { + eventType: "waveobj:update", + handler: (event) => { + // console.log("waveobj:update wave event handler", event); + const update: WaveObjUpdate = event.data; + WOS.updateWaveObject(update); + }, + }, + { + eventType: "config", + handler: (event) => { + // console.log("config wave event handler", event); + const fullConfig = (event.data as WatcherUpdate).fullconfig; + globalStore.set(atoms.fullConfigAtom, fullConfig); + }, + }, + { + eventType: "userinput", + handler: (event) => { + // console.log("userinput event handler", event); + const data: UserInputRequest = event.data; + modalsModel.pushModal("UserInputModal", { ...data }); + }, + }, + { + eventType: "blockfile", + handler: (event) => { + // console.log("blockfile event update", event); + const fileData: WSFileEventData = event.data; + const fileSubject = getFileSubject(fileData.zoneid, fileData.filename); + if (fileSubject != null) { + fileSubject.next(fileData); + } + }, + } + ); +} + +const blockCache = new Map>(); + +function useBlockCache(blockId: string, name: string, makeFn: () => T): T { + let blockMap = blockCache.get(blockId); + if (blockMap == null) { + blockMap = new Map(); + blockCache.set(blockId, blockMap); + } + let value = blockMap.get(name); + if (value == null) { + value = makeFn(); + blockMap.set(name, value); + } + return value as T; +} + +const settingsAtomCache = new Map>(); + +function useSettingsKeyAtom(key: T): Atom { + let settingsKeyAtom = settingsAtomCache.get(key) as Atom; + if (settingsKeyAtom == null) { + settingsKeyAtom = atom((get) => { + const settings = get(atoms.settingsAtom); + if (settings == null) { + return null; + } + return settings[key]; + }); + settingsAtomCache.set(key, settingsKeyAtom); + } + return settingsKeyAtom; +} + +function useSettingsPrefixAtom(prefix: string): Atom { + // TODO: use a shallow equal here to make this more efficient + let settingsPrefixAtom = settingsAtomCache.get(prefix + ":") as Atom; + if (settingsPrefixAtom == null) { + settingsPrefixAtom = atom((get) => { + const settings = get(atoms.settingsAtom); + if (settings == null) { + return {}; + } + return getPrefixedSettings(settings, prefix); + }); + settingsAtomCache.set(prefix + ":", settingsPrefixAtom); + } + return settingsPrefixAtom; +} + +const blockAtomCache = new Map>>(); + +function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { + let blockCache = blockAtomCache.get(blockId); + if (blockCache == null) { + blockCache = new Map>(); + blockAtomCache.set(blockId, blockCache); + } + let atom = blockCache.get(name); + if (atom == null) { + atom = makeFn(); + blockCache.set(name, atom); + console.log("New BlockAtom", blockId, name); + } + return atom as Atom; +} + +function useBlockDataLoaded(blockId: string): boolean { + const loadedAtom = useBlockAtom(blockId, "block-loaded", () => { + return WOS.getWaveObjectLoadingAtom(WOS.makeORef("block", blockId)); + }); + return useAtomValue(loadedAtom); +} + +/** + * Get the preload api. + */ +function getApi(): ElectronApi { + return (window as any).api; +} + +async function createBlock(blockDef: BlockDef, magnified = false): Promise { + const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + const blockId = await ObjectService.CreateBlock(blockDef, rtOpts); + const insertNodeAction: LayoutTreeInsertNodeAction = { + type: LayoutTreeActionType.InsertNode, + node: newLayoutNode(undefined, undefined, undefined, { blockId }), + magnified, + focused: true, + }; + const activeTabId = globalStore.get(atoms.uiContext).activetabid; + const layoutModel = getLayoutModelForTabById(activeTabId); + layoutModel.treeReducer(insertNodeAction); + return blockId; +} + +// when file is not found, returns {data: null, fileInfo: null} +async function fetchWaveFile( + zoneId: string, + fileName: string, + offset?: number +): Promise<{ data: Uint8Array; fileInfo: WaveFile }> { + const usp = new URLSearchParams(); + usp.set("zoneid", zoneId); + usp.set("name", fileName); + if (offset != null) { + usp.set("offset", offset.toString()); + } + const resp = await fetch(getWebServerEndpoint() + "/wave/file?" + usp.toString()); + if (!resp.ok) { + if (resp.status === 404) { + return { data: null, fileInfo: null }; + } + throw new Error("error getting wave file: " + resp.statusText); + } + if (resp.status == 204) { + return { data: null, fileInfo: null }; + } + const fileInfo64 = resp.headers.get("X-ZoneFileInfo"); + if (fileInfo64 == null) { + throw new Error(`missing zone file info for ${zoneId}:${fileName}`); + } + const fileInfo = JSON.parse(atob(fileInfo64)); + const data = await resp.arrayBuffer(); + return { data: new Uint8Array(data), fileInfo }; +} + +function setNodeFocus(nodeId: string) { + const layoutModel = getLayoutModelForActiveTab(); + layoutModel.focusNode(nodeId); +} + +const objectIdWeakMap = new WeakMap(); +let objectIdCounter = 0; +function getObjectId(obj: any): number { + if (!objectIdWeakMap.has(obj)) { + objectIdWeakMap.set(obj, objectIdCounter++); + } + return objectIdWeakMap.get(obj); +} + +let cachedIsDev: boolean = null; + +function isDev() { + if (cachedIsDev == null) { + cachedIsDev = getApi().getIsDev(); + } + return cachedIsDev; +} + +let cachedUserName: string = null; + +function getUserName(): string { + if (cachedUserName == null) { + cachedUserName = getApi().getUserName(); + } + return cachedUserName; +} + +let cachedHostName: string = null; + +function getHostName(): string { + if (cachedHostName == null) { + cachedHostName = getApi().getHostName(); + } + return cachedHostName; +} + +/** + * Open a link in a new window, or in a new web widget. The user can set all links to open in a new web widget using the `web:openlinksinternally` setting. + * @param uri The link to open. + * @param forceOpenInternally Force the link to open in a new web widget. + */ +async function openLink(uri: string, forceOpenInternally = false) { + if (forceOpenInternally || globalStore.get(atoms.settingsAtom)?.["web:openlinksinternally"]) { + const blockDef: BlockDef = { + meta: { + view: "web", + url: uri, + }, + }; + await createBlock(blockDef); + } else { + getApi().openExternal(uri); + } +} + +function registerBlockComponentModel(blockId: string, bcm: BlockComponentModel) { + blockComponentModelMap.set(blockId, bcm); +} + +function unregisterBlockComponentModel(blockId: string) { + blockComponentModelMap.delete(blockId); +} + +function getBlockComponentModel(blockId: string): BlockComponentModel { + return blockComponentModelMap.get(blockId); +} + +function refocusNode(blockId: string) { + if (blockId == null) { + return; + } + const layoutModel = getLayoutModelForActiveTab(); + const layoutNodeId = layoutModel.getNodeByBlockId(blockId); + if (layoutNodeId?.id == null) { + return; + } + layoutModel.focusNode(layoutNodeId.id); + const bcm = getBlockComponentModel(blockId); + const ok = bcm?.viewModel?.giveFocus?.(); + if (!ok) { + const inputElem = document.getElementById(`${blockId}-dummy-focus`); + inputElem?.focus(); + } +} + +function countersClear() { + Counters.clear(); +} + +function counterInc(name: string, incAmt: number = 1) { + let count = Counters.get(name) ?? 0; + count += incAmt; + Counters.set(name, count); +} + +function countersPrint() { + let outStr = ""; + for (const [name, count] of Counters.entries()) { + outStr += `${name}: ${count}\n`; + } + console.log(outStr); +} + +async function loadConnStatus() { + const connStatusArr = await ClientService.GetAllConnStatus(); + if (connStatusArr == null) { + return; + } + for (const connStatus of connStatusArr) { + const curAtom = getConnStatusAtom(connStatus.connection); + globalStore.set(curAtom, connStatus); + } +} + +function subscribeToConnEvents() { + waveEventSubscribe({ + eventType: "connchange", + handler: (event: WaveEvent) => { + try { + const connStatus = event.data as ConnStatus; + if (connStatus == null || isBlank(connStatus.connection)) { + return; + } + console.log("connstatus update", connStatus); + let curAtom = getConnStatusAtom(connStatus.connection); + globalStore.set(curAtom, connStatus); + } catch (e) { + console.log("connchange error", e); + } + }, + }); +} + +function getConnStatusAtom(conn: string): PrimitiveAtom { + let rtn = ConnStatusMap.get(conn); + if (rtn == null) { + if (isBlank(conn)) { + // create a fake "local" status atom that's always connected + const connStatus: ConnStatus = { + connection: conn, + connected: true, + error: null, + status: "connected", + hasconnected: true, + activeconnnum: 0, + }; + rtn = atom(connStatus); + } else { + const connStatus: ConnStatus = { + connection: conn, + connected: false, + error: null, + status: "disconnected", + hasconnected: false, + activeconnnum: 0, + }; + rtn = atom(connStatus); + } + ConnStatusMap.set(conn, rtn); + } + return rtn; +} + +function pushFlashError(ferr: FlashErrorType) { + if (ferr.expiration == null) { + ferr.expiration = Date.now() + 5000; + } + ferr.id = crypto.randomUUID(); + globalStore.set(atoms.flashErrors, (prev) => { + return [...prev, ferr]; + }); +} + +function removeFlashError(id: string) { + globalStore.set(atoms.flashErrors, (prev) => { + return prev.filter((ferr) => ferr.id !== id); + }); +} + +export { + atoms, + counterInc, + countersClear, + countersPrint, + createBlock, + fetchWaveFile, + getApi, + getBlockComponentModel, + getConnStatusAtom, + getHostName, + getObjectId, + getUserName, + globalStore, + initGlobal, + initGlobalWaveEventSubs, + isDev, + loadConnStatus, + openLink, + PLATFORM, + pushFlashError, + refocusNode, + registerBlockComponentModel, + removeFlashError, + setNodeFocus, + setPlatform, + subscribeToConnEvents, + unregisterBlockComponentModel, + useBlockAtom, + useBlockCache, + useBlockDataLoaded, + useSettingsKeyAtom, + useSettingsPrefixAtom, + WOS, +}; diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts new file mode 100644 index 000000000..0aafbe174 --- /dev/null +++ b/frontend/app/store/keymodel.ts @@ -0,0 +1,306 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atoms, createBlock, getApi, getBlockComponentModel, globalStore, refocusNode, WOS } from "@/app/store/global"; +import * as services from "@/app/store/services"; +import { + deleteLayoutModelForTab, + getLayoutModelForActiveTab, + getLayoutModelForTab, + getLayoutModelForTabById, + NavigateDirection, +} from "@/layout/index"; +import * as keyutil from "@/util/keyutil"; +import * as jotai from "jotai"; + +const simpleControlShiftAtom = jotai.atom(false); +const globalKeyMap = new Map boolean>(); + +function getSimpleControlShiftAtom() { + return simpleControlShiftAtom; +} + +function setControlShift() { + globalStore.set(simpleControlShiftAtom, true); + setTimeout(() => { + const simpleState = globalStore.get(simpleControlShiftAtom); + if (simpleState) { + globalStore.set(atoms.controlShiftDelayAtom, true); + } + }, 400); +} + +function unsetControlShift() { + globalStore.set(simpleControlShiftAtom, false); + globalStore.set(atoms.controlShiftDelayAtom, false); +} + +function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean { + if (globalStore.get(atoms.modalOpen)) { + return false; + } + const activeElem = document.activeElement; + if (activeElem != null && activeElem instanceof HTMLElement) { + if (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA" || activeElem.contentEditable == "true") { + if (activeElem.classList.contains("dummy-focus")) { + return true; + } + if (keyutil.isInputEvent(e)) { + return false; + } + return true; + } + } + return true; +} + +function genericClose(tabId: string) { + const tabORef = WOS.makeORef("tab", tabId); + const tabAtom = WOS.getWaveObjectAtom(tabORef); + const tabData = globalStore.get(tabAtom); + if (tabData == null) { + return; + } + if (tabData.blockids == null || tabData.blockids.length == 0) { + // close tab + services.WindowService.CloseTab(tabId); + deleteLayoutModelForTab(tabId); + return; + } + const layoutModel = getLayoutModelForTab(tabAtom); + layoutModel.closeFocusedNode(); +} + +function switchBlockByBlockNum(index: number) { + const layoutModel = getLayoutModelForActiveTab(); + if (!layoutModel) { + return; + } + layoutModel.switchNodeFocusByBlockNum(index); +} + +function switchBlockInDirection(tabId: string, direction: NavigateDirection) { + const layoutModel = getLayoutModelForTabById(tabId); + layoutModel.switchNodeFocusInDirection(direction); +} + +function switchTabAbs(index: number) { + const ws = globalStore.get(atoms.workspace); + const newTabIdx = index - 1; + if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) { + return; + } + const newActiveTabId = ws.tabids[newTabIdx]; + services.ObjectService.SetActiveTab(newActiveTabId); +} + +function switchTab(offset: number) { + const ws = globalStore.get(atoms.workspace); + const activeTabId = globalStore.get(atoms.tabAtom).oid; + let tabIdx = -1; + for (let i = 0; i < ws.tabids.length; i++) { + if (ws.tabids[i] == activeTabId) { + tabIdx = i; + break; + } + } + if (tabIdx == -1) { + return; + } + const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length; + const newActiveTabId = ws.tabids[newTabIdx]; + services.ObjectService.SetActiveTab(newActiveTabId); +} + +function handleCmdI() { + const layoutModel = getLayoutModelForActiveTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + if (focusedNode == null) { + // focus a node + layoutModel.focusFirstNode(); + return; + } + const blockId = focusedNode?.data?.blockId; + if (blockId == null) { + return; + } + refocusNode(blockId); +} + +async function handleCmdN() { + const termBlockDef: BlockDef = { + meta: { + view: "term", + controller: "shell", + }, + }; + const layoutModel = getLayoutModelForActiveTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + if (focusedNode != null) { + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId)); + const blockData = globalStore.get(blockAtom); + if (blockData?.meta?.view == "term") { + if (blockData?.meta?.["cmd:cwd"] != null) { + termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"]; + } + } + if (blockData?.meta?.connection != null) { + termBlockDef.meta.connection = blockData.meta.connection; + } + } + await createBlock(termBlockDef); +} + +function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { + const handled = handleGlobalWaveKeyboardEvents(waveEvent); + if (handled) { + return true; + } + const layoutModel = getLayoutModelForActiveTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + const blockId = focusedNode?.data?.blockId; + if (blockId != null && shouldDispatchToBlock(waveEvent)) { + const bcm = getBlockComponentModel(blockId); + if (bcm.openSwitchConnection != null) { + if (keyutil.checkKeyPressed(waveEvent, "Cmd:g")) { + bcm.openSwitchConnection(); + return true; + } + } + const viewModel = bcm?.viewModel; + if (viewModel?.keyDownHandler) { + const handledByBlock = viewModel.keyDownHandler(waveEvent); + if (handledByBlock) { + return true; + } + } + } + return false; +} + +function registerControlShiftStateUpdateHandler() { + getApi().onControlShiftStateUpdate((state: boolean) => { + if (state) { + setControlShift(); + } else { + unsetControlShift(); + } + }); +} + +function registerElectronReinjectKeyHandler() { + getApi().onReinjectKey((event: WaveKeyboardEvent) => { + appHandleKeyDown(event); + }); +} + +function tryReinjectKey(event: WaveKeyboardEvent): boolean { + return appHandleKeyDown(event); +} + +function registerGlobalKeys() { + globalKeyMap.set("Cmd:]", () => { + switchTab(1); + return true; + }); + globalKeyMap.set("Shift:Cmd:]", () => { + switchTab(1); + return true; + }); + globalKeyMap.set("Cmd:[", () => { + switchTab(-1); + return true; + }); + globalKeyMap.set("Shift:Cmd:[", () => { + switchTab(-1); + return true; + }); + globalKeyMap.set("Cmd:n", () => { + handleCmdN(); + return true; + }); + globalKeyMap.set("Cmd:i", () => { + handleCmdI(); + return true; + }); + globalKeyMap.set("Cmd:t", () => { + const workspace = globalStore.get(atoms.workspace); + const newTabName = `T${workspace.tabids.length + 1}`; + services.ObjectService.AddTabToWorkspace(newTabName, true); + return true; + }); + globalKeyMap.set("Cmd:w", () => { + const tabId = globalStore.get(atoms.activeTabId); + genericClose(tabId); + return true; + }); + globalKeyMap.set("Cmd:m", () => { + const layoutModel = getLayoutModelForActiveTab(); + const focusedNode = globalStore.get(layoutModel.focusedNode); + if (focusedNode != null) { + layoutModel.magnifyNodeToggle(focusedNode.id); + } + return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowUp", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Up); + return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowDown", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Down); + return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Left); + return true; + }); + globalKeyMap.set("Ctrl:Shift:ArrowRight", () => { + const tabId = globalStore.get(atoms.activeTabId); + switchBlockInDirection(tabId, NavigateDirection.Right); + return true; + }); + for (let idx = 1; idx <= 9; idx++) { + globalKeyMap.set(`Cmd:${idx}`, () => { + switchTabAbs(idx); + return true; + }); + globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => { + switchBlockByBlockNum(idx); + return true; + }); + globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => { + switchBlockByBlockNum(idx); + return true; + }); + } + const allKeys = Array.from(globalKeyMap.keys()); + // special case keys, handled by web view + allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft"); + getApi().registerGlobalWebviewKeys(allKeys); +} + +// these keyboard events happen *anywhere*, even if you have focus in an input or somewhere else. +function handleGlobalWaveKeyboardEvents(waveEvent: WaveKeyboardEvent): boolean { + for (const key of globalKeyMap.keys()) { + if (keyutil.checkKeyPressed(waveEvent, key)) { + const handler = globalKeyMap.get(key); + if (handler == null) { + return false; + } + return handler(waveEvent); + } + } +} + +export { + appHandleKeyDown, + getSimpleControlShiftAtom, + registerControlShiftStateUpdateHandler, + registerElectronReinjectKeyHandler, + registerGlobalKeys, + tryReinjectKey, + unsetControlShift, +}; diff --git a/frontend/app/store/modalmodel.ts b/frontend/app/store/modalmodel.ts new file mode 100644 index 000000000..8fae9cd5c --- /dev/null +++ b/frontend/app/store/modalmodel.ts @@ -0,0 +1,43 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as jotai from "jotai"; +import { globalStore } from "./global"; + +class ModalsModel { + modalsAtom: jotai.PrimitiveAtom>; + tosOpen: jotai.PrimitiveAtom; + + constructor() { + this.tosOpen = jotai.atom(false); + this.modalsAtom = jotai.atom([]); + } + + pushModal = (displayName: string, props?: any) => { + const modals = globalStore.get(this.modalsAtom); + globalStore.set(this.modalsAtom, [...modals, { displayName, props }]); + }; + + popModal = (callback?: () => void) => { + const modals = globalStore.get(this.modalsAtom); + if (modals.length > 0) { + const updatedModals = modals.slice(0, -1); + globalStore.set(this.modalsAtom, updatedModals); + if (callback) callback(); + } + }; + + hasOpenModals(): boolean { + const modals = globalStore.get(this.modalsAtom); + return modals.length > 0; + } + + isModalOpen(displayName: string): boolean { + const modals = globalStore.get(this.modalsAtom); + return modals.some((modal) => modal.displayName === displayName); + } +} + +const modalsModel = new ModalsModel(); + +export { modalsModel }; diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts new file mode 100644 index 000000000..549ea73c2 --- /dev/null +++ b/frontend/app/store/services.ts @@ -0,0 +1,172 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// generated by cmd/generate/main-generatets.go + +import * as WOS from "./wos"; + +// blockservice.BlockService (block) +class BlockServiceType { + GetControllerStatus(arg2: string): Promise { + return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments)) + } + SaveTerminalState(arg2: string, arg3: string, arg4: string, arg5: number): Promise { + return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments)) + } + SaveWaveAiData(arg2: string, arg3: OpenAIPromptMessageType[]): Promise { + return WOS.callBackendService("block", "SaveWaveAiData", Array.from(arguments)) + } +} + +export const BlockService = new BlockServiceType(); + +// clientservice.ClientService (client) +class ClientServiceType { + // @returns object updates + AgreeTos(): Promise { + return WOS.callBackendService("client", "AgreeTos", Array.from(arguments)) + } + FocusWindow(arg2: string): Promise { + return WOS.callBackendService("client", "FocusWindow", Array.from(arguments)) + } + GetAllConnStatus(): Promise { + return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments)) + } + GetClientData(): Promise { + return WOS.callBackendService("client", "GetClientData", Array.from(arguments)) + } + GetTab(arg1: string): Promise { + return WOS.callBackendService("client", "GetTab", Array.from(arguments)) + } + GetWindow(arg1: string): Promise { + return WOS.callBackendService("client", "GetWindow", Array.from(arguments)) + } + GetWorkspace(arg1: string): Promise { + return WOS.callBackendService("client", "GetWorkspace", Array.from(arguments)) + } + MakeWindow(): Promise { + return WOS.callBackendService("client", "MakeWindow", Array.from(arguments)) + } +} + +export const ClientService = new ClientServiceType(); + +// fileservice.FileService (file) +class FileServiceType { + // delete file + DeleteFile(connection: string, path: string): Promise { + return WOS.callBackendService("file", "DeleteFile", Array.from(arguments)) + } + GetFullConfig(): Promise { + return WOS.callBackendService("file", "GetFullConfig", Array.from(arguments)) + } + GetWaveFile(arg1: string, arg2: string): Promise { + return WOS.callBackendService("file", "GetWaveFile", Array.from(arguments)) + } + + // read file + ReadFile(connection: string, path: string): Promise { + return WOS.callBackendService("file", "ReadFile", Array.from(arguments)) + } + + // save file + SaveFile(connection: string, path: string, data64: string): Promise { + return WOS.callBackendService("file", "SaveFile", Array.from(arguments)) + } + + // get file info + StatFile(connection: string, path: string): Promise { + return WOS.callBackendService("file", "StatFile", Array.from(arguments)) + } +} + +export const FileService = new FileServiceType(); + +// objectservice.ObjectService (object) +class ObjectServiceType { + // @returns tabId (and object updates) + AddTabToWorkspace(tabName: string, activateTab: boolean): Promise { + return WOS.callBackendService("object", "AddTabToWorkspace", Array.from(arguments)) + } + + // @returns blockId (and object updates) + CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise { + return WOS.callBackendService("object", "CreateBlock", Array.from(arguments)) + } + + // @returns object updates + DeleteBlock(blockId: string): Promise { + return WOS.callBackendService("object", "DeleteBlock", Array.from(arguments)) + } + + // get wave object by oref + GetObject(oref: string): Promise { + return WOS.callBackendService("object", "GetObject", Array.from(arguments)) + } + + // @returns objects + GetObjects(orefs: string[]): Promise { + return WOS.callBackendService("object", "GetObjects", Array.from(arguments)) + } + + // @returns object updates + SetActiveTab(tabId: string): Promise { + return WOS.callBackendService("object", "SetActiveTab", Array.from(arguments)) + } + + // @returns object updates + UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise { + return WOS.callBackendService("object", "UpdateObject", Array.from(arguments)) + } + + // @returns object updates + UpdateObjectMeta(oref: string, meta: MetaType): Promise { + return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) + } + + // @returns object updates + UpdateTabName(tabId: string, name: string): Promise { + return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments)) + } + + // @returns object updates + UpdateWorkspaceTabIds(workspaceId: string, tabIds: string[]): Promise { + return WOS.callBackendService("object", "UpdateWorkspaceTabIds", Array.from(arguments)) + } +} + +export const ObjectService = new ObjectServiceType(); + +// userinputservice.UserInputService (userinput) +class UserInputServiceType { + SendUserInputResponse(arg1: UserInputResponse): Promise { + return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments)) + } +} + +export const UserInputService = new UserInputServiceType(); + +// windowservice.WindowService (window) +class WindowServiceType { + // @returns object updates + CloseTab(arg3: string): Promise { + return WOS.callBackendService("window", "CloseTab", Array.from(arguments)) + } + CloseWindow(arg2: string): Promise { + return WOS.callBackendService("window", "CloseWindow", Array.from(arguments)) + } + + // move block to new window + // @returns object updates + MoveBlockToNewWindow(currentTabId: string, blockId: string): Promise { + return WOS.callBackendService("window", "MoveBlockToNewWindow", Array.from(arguments)) + } + + // @returns object updates + SetWindowPosAndSize(arg2: string, arg3: Point, arg4: WinSize): Promise { + return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments)) + } +} + +export const WindowService = new WindowServiceType(); + diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts new file mode 100644 index 000000000..eb1b6bc4b --- /dev/null +++ b/frontend/app/store/wos.ts @@ -0,0 +1,297 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// WaveObjectStore + +import { getWebServerEndpoint } from "@/util/endpoints"; +import { fetch } from "@/util/fetchutil"; +import { atom, Atom, Getter, PrimitiveAtom, Setter, useAtomValue } from "jotai"; +import { useEffect } from "react"; +import { atoms, globalStore } from "./global"; +import { ObjectService } from "./services"; + +type WaveObjectDataItemType = { + value: T; + loading: boolean; +}; + +type WaveObjectValue = { + pendingPromise: Promise; + dataAtom: PrimitiveAtom>; + refCount: number; + holdTime: number; +}; + +function splitORef(oref: string): [string, string] { + const parts = oref.split(":"); + if (parts.length != 2) { + throw new Error("invalid oref"); + } + return [parts[0], parts[1]]; +} + +function isBlank(str: string): boolean { + return str == null || str == ""; +} + +function isBlankNum(num: number): boolean { + return num == null || isNaN(num) || num == 0; +} + +function isValidWaveObj(val: WaveObj): boolean { + if (val == null) { + return false; + } + if (isBlank(val.otype) || isBlank(val.oid) || isBlankNum(val.version)) { + return false; + } + return true; +} + +function makeORef(otype: string, oid: string): string { + if (isBlank(otype) || isBlank(oid)) { + return null; + } + return `${otype}:${oid}`; +} + +function GetObject(oref: string): Promise { + return callBackendService("object", "GetObject", [oref], true); +} + +function debugLogBackendCall(methodName: string, durationStr: string, args: any[]) { + durationStr = "| " + durationStr; + if (methodName == "object.UpdateObject" && args.length > 0) { + console.log("[service] object.UpdateObject", args[0].otype, args[0].oid, durationStr, args[0]); + return; + } + if (methodName == "object.GetObject" && args.length > 0) { + console.log("[service] object.GetObject", args[0], durationStr); + return; + } + if (methodName == "file.StatFile" && args.length >= 2) { + console.log("[service] file.StatFile", args[1], durationStr); + return; + } + console.log("[service]", methodName, durationStr); +} + +function callBackendService(service: string, method: string, args: any[], noUIContext?: boolean): Promise { + const startTs = Date.now(); + let uiContext: UIContext = null; + if (!noUIContext) { + uiContext = globalStore.get(atoms.uiContext); + } + const waveCall: WebCallType = { + service: service, + method: method, + args: args, + uicontext: uiContext, + }; + // usp is just for debugging (easier to filter URLs) + const methodName = `${service}.${method}`; + const usp = new URLSearchParams(); + usp.set("service", service); + usp.set("method", method); + const url = getWebServerEndpoint() + "/wave/service?" + usp.toString(); + const fetchPromise = fetch(url, { + method: "POST", + body: JSON.stringify(waveCall), + }); + const prtn = fetchPromise + .then((resp) => { + if (!resp.ok) { + throw new Error(`call ${methodName} failed: ${resp.status} ${resp.statusText}`); + } + return resp.json(); + }) + .then((respData: WebReturnType) => { + if (respData == null) { + return null; + } + if (respData.updates != null) { + updateWaveObjects(respData.updates); + } + if (respData.error != null) { + throw new Error(`call ${methodName} error: ${respData.error}`); + } + const durationStr = Date.now() - startTs + "ms"; + debugLogBackendCall(methodName, durationStr, args); + return respData.data; + }); + return prtn; +} + +const waveObjectValueCache = new Map>(); + +function clearWaveObjectCache() { + waveObjectValueCache.clear(); +} + +const defaultHoldTime = 5000; // 5-seconds + +function createWaveValueObject(oref: string, shouldFetch: boolean): WaveObjectValue { + const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 }; + wov.dataAtom = atom({ value: null, loading: true }); + if (!shouldFetch) { + return wov; + } + const startTs = Date.now(); + const localPromise = GetObject(oref); + wov.pendingPromise = localPromise; + localPromise.then((val) => { + if (wov.pendingPromise != localPromise) { + return; + } + const [otype, oid] = splitORef(oref); + if (val != null) { + if (val["otype"] != otype) { + throw new Error("GetObject returned wrong type"); + } + if (val["oid"] != oid) { + throw new Error("GetObject returned wrong id"); + } + } + wov.pendingPromise = null; + globalStore.set(wov.dataAtom, { value: val, loading: false }); + console.log("WaveObj resolved", oref, Date.now() - startTs + "ms"); + }); + return wov; +} + +function getWaveObjectValue(oref: string, createIfMissing = true): WaveObjectValue { + let wov = waveObjectValueCache.get(oref); + if (wov === undefined && createIfMissing) { + wov = createWaveValueObject(oref, true); + waveObjectValueCache.set(oref, wov); + } + return wov; +} + +function loadAndPinWaveObject(oref: string): Promise { + const wov = getWaveObjectValue(oref); + wov.refCount++; + if (wov.pendingPromise == null) { + const dataValue = globalStore.get(wov.dataAtom); + return Promise.resolve(dataValue.value); + } + return wov.pendingPromise; +} + +function getWaveObjectAtom(oref: string): WritableWaveObjectAtom { + const wov = getWaveObjectValue(oref); + return atom( + (get) => get(wov.dataAtom).value, + (_get, set, value: T) => { + setObjectValue(value, set, true); + } + ); +} + +function getWaveObjectLoadingAtom(oref: string): Atom { + const wov = getWaveObjectValue(oref); + return atom((get) => { + const dataValue = get(wov.dataAtom); + if (dataValue.loading) { + return null; + } + return dataValue.loading; + }); +} + +function useWaveObjectValue(oref: string): [T, boolean] { + const wov = getWaveObjectValue(oref); + useEffect(() => { + wov.refCount++; + return () => { + wov.refCount--; + }; + }, [oref]); + const atomVal = useAtomValue(wov.dataAtom); + return [atomVal.value, atomVal.loading]; +} + +function updateWaveObject(update: WaveObjUpdate) { + if (update == null) { + return; + } + const oref = makeORef(update.otype, update.oid); + const wov = getWaveObjectValue(oref); + if (update.updatetype == "delete") { + console.log("WaveObj deleted", oref); + globalStore.set(wov.dataAtom, { value: null, loading: false }); + } else { + if (!isValidWaveObj(update.obj)) { + console.log("invalid wave object update", update); + return; + } + const curValue: WaveObjectDataItemType = globalStore.get(wov.dataAtom); + if (curValue.value != null && curValue.value.version >= update.obj.version) { + return; + } + console.log("WaveObj updated", oref); + globalStore.set(wov.dataAtom, { value: update.obj, loading: false }); + } + wov.holdTime = Date.now() + defaultHoldTime; + return; +} + +function updateWaveObjects(vals: WaveObjUpdate[]) { + for (const val of vals) { + updateWaveObject(val); + } +} + +function cleanWaveObjectCache() { + const now = Date.now(); + for (const [oref, wov] of waveObjectValueCache) { + if (wov.refCount == 0 && wov.holdTime < now) { + waveObjectValueCache.delete(oref); + } + } +} + +// gets the value of a WaveObject from the cache. +// should provide getFn if it is available (e.g. inside of a jotai atom) +// otherwise it will use the globalStore.get function +function getObjectValue(oref: string, getFn?: Getter): T { + const wov = getWaveObjectValue(oref); + if (getFn == null) { + getFn = globalStore.get; + } + const atomVal = getFn(wov.dataAtom); + return atomVal.value; +} + +// sets the value of a WaveObject in the cache. +// should provide setFn if it is available (e.g. inside of a jotai atom) +// otherwise it will use the globalStore.set function +function setObjectValue(value: T, setFn?: Setter, pushToServer?: boolean) { + const oref = makeORef(value.otype, value.oid); + const wov = getWaveObjectValue(oref, false); + if (wov === undefined) { + return; + } + if (setFn === undefined) { + setFn = globalStore.set; + } + setFn(wov.dataAtom, { value: value, loading: false }); + if (pushToServer) { + ObjectService.UpdateObject(value, false); + } +} + +export { + callBackendService, + cleanWaveObjectCache, + clearWaveObjectCache, + getObjectValue, + getWaveObjectAtom, + getWaveObjectLoadingAtom, + loadAndPinWaveObject, + makeORef, + setObjectValue, + updateWaveObject, + updateWaveObjects, + useWaveObjectValue, +}; diff --git a/frontend/app/store/wps.ts b/frontend/app/store/wps.ts new file mode 100644 index 000000000..28dc2cf55 --- /dev/null +++ b/frontend/app/store/wps.ts @@ -0,0 +1,143 @@ +import { isBlank } from "@/util/util"; +import { Subject } from "rxjs"; +import { sendRawRpcMessage } from "./wshrpcutil"; + +type WaveEventSubject = { + handler: (event: WaveEvent) => void; + scope?: string; +}; + +type WaveEventSubjectContainer = WaveEventSubject & { + id: string; +}; + +type WaveEventSubscription = WaveEventSubject & { + eventType: string; +}; + +type WaveEventUnsubscribe = { + id: string; + eventType: string; +}; + +// key is "eventType" or "eventType|oref" +const fileSubjects = new Map>(); +const waveEventSubjects = new Map(); + +function wpsReconnectHandler() { + for (const eventType of waveEventSubjects.keys()) { + updateWaveEventSub(eventType); + } +} + +function makeWaveReSubCommand(eventType: string): RpcMessage { + let subjects = waveEventSubjects.get(eventType); + if (subjects == null) { + return { command: "eventunsub", data: eventType }; + } + let subreq: SubscriptionRequest = { event: eventType, scopes: [], allscopes: false }; + for (const scont of subjects) { + if (isBlank(scont.scope)) { + subreq.allscopes = true; + subreq.scopes = []; + break; + } + subreq.scopes.push(scont.scope); + } + return { command: "eventsub", data: subreq }; +} + +function updateWaveEventSub(eventType: string) { + const command = makeWaveReSubCommand(eventType); + // console.log("updateWaveEventSub", eventType, command); + sendRawRpcMessage(command); +} + +function waveEventSubscribe(...subscriptions: WaveEventSubscription[]): () => void { + const unsubs: WaveEventUnsubscribe[] = []; + const eventTypeSet = new Set(); + for (const subscription of subscriptions) { + // console.log("waveEventSubscribe", subscription); + if (subscription.handler == null) { + return; + } + const id: string = crypto.randomUUID(); + let subjects = waveEventSubjects.get(subscription.eventType); + if (subjects == null) { + subjects = []; + waveEventSubjects.set(subscription.eventType, subjects); + } + const subcont: WaveEventSubjectContainer = { id, handler: subscription.handler, scope: subscription.scope }; + subjects.push(subcont); + unsubs.push({ id, eventType: subscription.eventType }); + eventTypeSet.add(subscription.eventType); + } + for (const eventType of eventTypeSet) { + updateWaveEventSub(eventType); + } + return () => waveEventUnsubscribe(...unsubs); +} + +function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) { + const eventTypeSet = new Set(); + for (const unsubscribe of unsubscribes) { + let subjects = waveEventSubjects.get(unsubscribe.eventType); + if (subjects == null) { + return; + } + const idx = subjects.findIndex((s) => s.id === unsubscribe.id); + if (idx === -1) { + return; + } + subjects.splice(idx, 1); + if (subjects.length === 0) { + waveEventSubjects.delete(unsubscribe.eventType); + } + eventTypeSet.add(unsubscribe.eventType); + } + + for (const eventType of eventTypeSet) { + updateWaveEventSub(eventType); + } +} + +function getFileSubject(zoneId: string, fileName: string): SubjectWithRef { + const subjectKey = zoneId + "|" + fileName; + let subject = fileSubjects.get(subjectKey); + if (subject == null) { + subject = new Subject() as any; + subject.refCount = 0; + subject.release = () => { + subject.refCount--; + if (subject.refCount === 0) { + subject.complete(); + fileSubjects.delete(subjectKey); + } + }; + fileSubjects.set(subjectKey, subject); + } + subject.refCount++; + return subject; +} + +function handleWaveEvent(event: WaveEvent) { + // console.log("handleWaveEvent", event); + const subjects = waveEventSubjects.get(event.event); + if (subjects == null) { + return; + } + for (const scont of subjects) { + if (isBlank(scont.scope)) { + scont.handler(event); + continue; + } + if (event.scopes == null) { + continue; + } + if (event.scopes.includes(scont.scope)) { + scont.handler(event); + } + } +} + +export { getFileSubject, handleWaveEvent, waveEventSubscribe, waveEventUnsubscribe, wpsReconnectHandler }; diff --git a/frontend/app/store/ws.ts b/frontend/app/store/ws.ts new file mode 100644 index 000000000..5062af8f6 --- /dev/null +++ b/frontend/app/store/ws.ts @@ -0,0 +1,210 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import debug from "debug"; +import { sprintf } from "sprintf-js"; +import type { WebSocket as ElectronWebSocketType } from "ws"; + +let ElectronWebSocket: typeof ElectronWebSocketType; +const AuthKeyHeader = "X-AuthKey"; + +if (typeof window === "undefined") { + try { + const WebSocket = require("ws") as typeof ElectronWebSocketType; + ElectronWebSocket = WebSocket; + } catch (e) {} +} + +const dlog = debug("wave:ws"); + +const WarnWebSocketSendSize = 1024 * 1024; // 1MB +const MaxWebSocketSendSize = 5 * 1024 * 1024; // 5MB +const reconnectHandlers: (() => void)[] = []; + +function addWSReconnectHandler(handler: () => void) { + reconnectHandlers.push(handler); +} + +function removeWSReconnectHandler(handler: () => void) { + const index = this.reconnectHandlers.indexOf(handler); + if (index > -1) { + reconnectHandlers.splice(index, 1); + } +} + +type WSEventCallback = (arg0: WSEventType) => void; + +class WSControl { + wsConn: WebSocket | ElectronWebSocketType; + open: boolean; + opening: boolean = false; + reconnectTimes: number = 0; + msgQueue: any[] = []; + windowId: string; + messageCallback: WSEventCallback; + watchSessionId: string = null; + watchScreenId: string = null; + wsLog: string[] = []; + baseHostPort: string; + lastReconnectTime: number = 0; + authKey: string = null; // used only by electron + + constructor(baseHostPort: string, windowId: string, messageCallback: WSEventCallback, authKey?: string) { + this.baseHostPort = baseHostPort; + this.messageCallback = messageCallback; + this.windowId = windowId; + this.open = false; + this.authKey = authKey; + setInterval(this.sendPing.bind(this), 5000); + } + + connectNow(desc: string) { + if (this.open) { + return; + } + this.lastReconnectTime = Date.now(); + dlog("try reconnect:", desc); + this.opening = true; + if (ElectronWebSocket) { + this.wsConn = new ElectronWebSocket(this.baseHostPort + "/ws?windowid=" + this.windowId, { + headers: { [AuthKeyHeader]: this.authKey }, + }); + } else { + this.wsConn = new WebSocket(this.baseHostPort + "/ws?windowid=" + this.windowId); + } + this.wsConn.onopen = this.onopen.bind(this); + this.wsConn.onmessage = this.onmessage.bind(this); + this.wsConn.onclose = this.onclose.bind(this); + // turns out onerror is not necessary (onclose always follows onerror) + // this.wsConn.onerror = this.onerror; + } + + reconnect(forceClose?: boolean) { + if (this.open) { + if (forceClose) { + this.wsConn.close(); // this will force a reconnect + } + return; + } + this.reconnectTimes++; + if (this.reconnectTimes > 20) { + dlog("cannot connect, giving up"); + return; + } + const timeoutArr = [0, 0, 2, 5, 10, 10, 30, 60]; + let timeout = 60; + if (this.reconnectTimes < timeoutArr.length) { + timeout = timeoutArr[this.reconnectTimes]; + } + if (Date.now() - this.lastReconnectTime < 500) { + timeout = 1; + } + if (timeout > 0) { + dlog(sprintf("sleeping %ds", timeout)); + } + setTimeout(() => { + this.connectNow(String(this.reconnectTimes)); + }, timeout * 1000); + } + + onclose(event: any) { + // console.log("close", event); + if (event.wasClean) { + dlog("connection closed"); + } else { + dlog("connection error/disconnected"); + } + if (this.open || this.opening) { + this.open = false; + this.opening = false; + this.reconnect(); + } + } + + onopen() { + dlog("connection open"); + this.open = true; + this.opening = false; + for (let handler of reconnectHandlers) { + handler(); + } + this.runMsgQueue(); + // reconnectTimes is reset in onmessage:hello + } + + runMsgQueue() { + if (!this.open) { + return; + } + if (this.msgQueue.length == 0) { + return; + } + const msg = this.msgQueue.shift(); + this.sendMessage(msg); + setTimeout(() => { + this.runMsgQueue(); + }, 100); + } + + onmessage(event: any) { + let eventData = null; + if (event.data != null) { + eventData = JSON.parse(event.data); + } + if (eventData == null) { + return; + } + if (eventData.type == "ping") { + this.wsConn.send(JSON.stringify({ type: "pong", stime: Date.now() })); + return; + } + if (eventData.type == "pong") { + // nothing + return; + } + if (eventData.type == "hello") { + this.reconnectTimes = 0; + return; + } + if (this.messageCallback) { + try { + this.messageCallback(eventData); + } catch (e) { + console.log("[error] messageCallback", e); + } + } + } + + sendPing() { + if (!this.open) { + return; + } + this.wsConn.send(JSON.stringify({ type: "ping", stime: Date.now() })); + } + + sendMessage(data: WSCommandType) { + if (!this.open) { + return; + } + const msg = JSON.stringify(data); + const byteSize = new Blob([msg]).size; + if (byteSize > MaxWebSocketSendSize) { + console.log("ws message too large", byteSize, data.wscommand, msg.substring(0, 100)); + return; + } + if (byteSize > WarnWebSocketSendSize) { + console.log("ws message large", byteSize, data.wscommand, msg.substring(0, 100)); + } + this.wsConn.send(msg); + } + + pushMessage(data: WSCommandType) { + if (!this.open) { + this.msgQueue.push(data); + return; + } + this.sendMessage(data); + } +} + +export { addWSReconnectHandler, removeWSReconnectHandler, WSControl }; diff --git a/frontend/app/store/wshclient.ts b/frontend/app/store/wshclient.ts new file mode 100644 index 000000000..93e7a7e38 --- /dev/null +++ b/frontend/app/store/wshclient.ts @@ -0,0 +1,155 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { sendRpcCommand, sendRpcResponse } from "@/app/store/wshrpcutil"; +import * as util from "@/util/util"; + +const notFoundLogMap = new Map(); + +class RpcResponseHelper { + client: WshClient; + cmdMsg: RpcMessage; + done: boolean; + + constructor(client: WshClient, cmdMsg: RpcMessage) { + this.client = client; + this.cmdMsg = cmdMsg; + // if reqid is null, no response required + this.done = cmdMsg.reqid == null; + } + + sendResponse(msg: RpcMessage) { + if (this.done || util.isBlank(this.cmdMsg.reqid)) { + return; + } + if (msg == null) { + msg = {}; + } + msg.resid = this.cmdMsg.reqid; + msg.source = this.client.routeId; + sendRpcResponse(msg); + if (!msg.cont) { + this.done = true; + this.client.openRpcs.delete(this.cmdMsg.reqid); + } + } +} + +class WshClient { + routeId: string; + openRpcs: Map = new Map(); + + constructor(routeId: string) { + this.routeId = routeId; + } + + wshRpcCall(command: string, data: any, opts: RpcOpts): Promise { + const msg: RpcMessage = { + command: command, + data: data, + source: this.routeId, + }; + if (!opts?.noresponse) { + msg.reqid = crypto.randomUUID(); + } + if (opts?.timeout) { + msg.timeout = opts.timeout; + } + if (opts?.route) { + msg.route = opts.route; + } + const rpcGen = sendRpcCommand(this.openRpcs, msg); + if (rpcGen == null) { + return null; + } + const respMsgPromise = rpcGen.next(true); // pass true to force termination of rpc after 1 response (not streaming) + return respMsgPromise.then((msg: IteratorResult) => { + return msg.value; + }); + } + + wshRpcStream(command: string, data: any, opts: RpcOpts): AsyncGenerator { + if (opts?.noresponse) { + throw new Error("noresponse not supported for responsestream calls"); + } + const msg: RpcMessage = { + command: command, + data: data, + reqid: crypto.randomUUID(), + source: this.routeId, + }; + if (opts?.timeout) { + msg.timeout = opts.timeout; + } + if (opts?.route) { + msg.route = opts.route; + } + const rpcGen = sendRpcCommand(this.openRpcs, msg); + return rpcGen; + } + + async handleIncomingCommand(msg: RpcMessage) { + // TODO implement a timeout (setTimeout + sendResponse) + const helper = new RpcResponseHelper(this, msg); + const handlerName = `handle_${msg.command}`; + try { + let result: any = null; + let prtn: any = null; + if (handlerName in this) { + prtn = this[handlerName](helper, msg.data); + } else { + prtn = this.handle_default(helper, msg); + } + if (prtn instanceof Promise) { + result = await prtn; + } else { + result = prtn; + } + if (!helper.done) { + helper.sendResponse({ data: result }); + } + } catch (e) { + if (!helper.done) { + helper.sendResponse({ error: e.message }); + } else { + console.log(`rpc-client[${this.routeId}] command[${msg.command}] error`, e.message); + } + } finally { + if (!helper.done) { + helper.sendResponse({}); + } + } + return; + } + + recvRpcMessage(msg: RpcMessage) { + const isRequest = msg.command != null || msg.reqid != null; + if (isRequest) { + this.handleIncomingCommand(msg); + return; + } + if (msg.resid == null) { + console.log("rpc response missing resid", msg); + return; + } + const entry = this.openRpcs.get(msg.resid); + if (entry == null) { + if (!notFoundLogMap.has(msg.resid)) { + notFoundLogMap.set(msg.resid, true); + console.log("rpc response generator not found", msg); + } + return; + } + entry.msgFn(msg); + } + + async handle_message(helper: RpcResponseHelper, data: CommandMessageData): Promise { + console.log(`rpc:message[${this.routeId}]`, data?.message); + } + + async handle_default(helper: RpcResponseHelper, msg: RpcMessage): Promise { + throw new Error(`rpc command "${msg.command}" not supported by [${this.routeId}]`); + } +} + +export { RpcResponseHelper, WshClient }; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts new file mode 100644 index 000000000..5d7f194cd --- /dev/null +++ b/frontend/app/store/wshclientapi.ts @@ -0,0 +1,222 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// generated by cmd/generate/main-generatets.go + +import { WshClient } from "./wshclient"; + +// WshServerCommandToDeclMap +class RpcApiType { + // command "authenticate" [call] + AuthenticateCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("authenticate", data, opts); + } + + // command "blockinfo" [call] + BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("blockinfo", data, opts); + } + + // command "connconnect" [call] + ConnConnectCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("connconnect", data, opts); + } + + // command "conndisconnect" [call] + ConnDisconnectCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("conndisconnect", data, opts); + } + + // command "connensure" [call] + ConnEnsureCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("connensure", data, opts); + } + + // command "connlist" [call] + ConnListCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("connlist", null, opts); + } + + // command "connreinstallwsh" [call] + ConnReinstallWshCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("connreinstallwsh", data, opts); + } + + // command "connstatus" [call] + ConnStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("connstatus", null, opts); + } + + // command "controllerinput" [call] + ControllerInputCommand(client: WshClient, data: CommandBlockInputData, opts?: RpcOpts): Promise { + return client.wshRpcCall("controllerinput", data, opts); + } + + // command "controllerresync" [call] + ControllerResyncCommand(client: WshClient, data: CommandControllerResyncData, opts?: RpcOpts): Promise { + return client.wshRpcCall("controllerresync", data, opts); + } + + // command "controllerstop" [call] + ControllerStopCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("controllerstop", data, opts); + } + + // command "createblock" [call] + CreateBlockCommand(client: WshClient, data: CommandCreateBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("createblock", data, opts); + } + + // command "deleteblock" [call] + DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("deleteblock", data, opts); + } + + // command "eventpublish" [call] + EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { + return client.wshRpcCall("eventpublish", data, opts); + } + + // command "eventreadhistory" [call] + EventReadHistoryCommand(client: WshClient, data: CommandEventReadHistoryData, opts?: RpcOpts): Promise { + return client.wshRpcCall("eventreadhistory", data, opts); + } + + // command "eventrecv" [call] + EventRecvCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { + return client.wshRpcCall("eventrecv", data, opts); + } + + // command "eventsub" [call] + EventSubCommand(client: WshClient, data: SubscriptionRequest, opts?: RpcOpts): Promise { + return client.wshRpcCall("eventsub", data, opts); + } + + // command "eventunsub" [call] + EventUnsubCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("eventunsub", data, opts); + } + + // command "eventunsuball" [call] + EventUnsubAllCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("eventunsuball", null, opts); + } + + // command "fileappend" [call] + FileAppendCommand(client: WshClient, data: CommandFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("fileappend", data, opts); + } + + // command "fileappendijson" [call] + FileAppendIJsonCommand(client: WshClient, data: CommandAppendIJsonData, opts?: RpcOpts): Promise { + return client.wshRpcCall("fileappendijson", data, opts); + } + + // command "fileread" [call] + FileReadCommand(client: WshClient, data: CommandFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("fileread", data, opts); + } + + // command "filewrite" [call] + FileWriteCommand(client: WshClient, data: CommandFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("filewrite", data, opts); + } + + // command "getmeta" [call] + GetMetaCommand(client: WshClient, data: CommandGetMetaData, opts?: RpcOpts): Promise { + return client.wshRpcCall("getmeta", data, opts); + } + + // command "message" [call] + MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise { + return client.wshRpcCall("message", data, opts); + } + + // command "remotefiledelete" [call] + RemoteFileDeleteCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("remotefiledelete", data, opts); + } + + // command "remotefileinfo" [call] + RemoteFileInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("remotefileinfo", data, opts); + } + + // command "remotefilejoin" [call] + RemoteFileJoinCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise { + return client.wshRpcCall("remotefilejoin", data, opts); + } + + // command "remotestreamcpudata" [responsestream] + RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + return client.wshRpcStream("remotestreamcpudata", null, opts); + } + + // command "remotestreamfile" [responsestream] + RemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator { + return client.wshRpcStream("remotestreamfile", data, opts); + } + + // command "remotewritefile" [call] + RemoteWriteFileCommand(client: WshClient, data: CommandRemoteWriteFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("remotewritefile", data, opts); + } + + // command "resolveids" [call] + ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise { + return client.wshRpcCall("resolveids", data, opts); + } + + // command "routeannounce" [call] + RouteAnnounceCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("routeannounce", null, opts); + } + + // command "routeunannounce" [call] + RouteUnannounceCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("routeunannounce", null, opts); + } + + // command "setconfig" [call] + SetConfigCommand(client: WshClient, data: SettingsType, opts?: RpcOpts): Promise { + return client.wshRpcCall("setconfig", data, opts); + } + + // command "setmeta" [call] + SetMetaCommand(client: WshClient, data: CommandSetMetaData, opts?: RpcOpts): Promise { + return client.wshRpcCall("setmeta", data, opts); + } + + // command "setview" [call] + SetViewCommand(client: WshClient, data: CommandBlockSetViewData, opts?: RpcOpts): Promise { + return client.wshRpcCall("setview", data, opts); + } + + // command "streamcpudata" [responsestream] + StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { + return client.wshRpcStream("streamcpudata", data, opts); + } + + // command "streamtest" [responsestream] + StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + return client.wshRpcStream("streamtest", null, opts); + } + + // command "streamwaveai" [responsestream] + StreamWaveAiCommand(client: WshClient, data: OpenAiStreamRequest, opts?: RpcOpts): AsyncGenerator { + return client.wshRpcStream("streamwaveai", data, opts); + } + + // command "test" [call] + TestCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("test", data, opts); + } + + // command "webselector" [call] + WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { + return client.wshRpcCall("webselector", data, opts); + } + +} + +export const RpcApi = new RpcApiType(); diff --git a/frontend/app/store/wshrouter.ts b/frontend/app/store/wshrouter.ts new file mode 100644 index 000000000..2679c9d10 --- /dev/null +++ b/frontend/app/store/wshrouter.ts @@ -0,0 +1,152 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { handleWaveEvent } from "@/app/store/wps"; +import debug from "debug"; +import * as util from "../../util/util"; + +const dlog = debug("wave:router"); + +const SysRouteName = "sys"; + +type RouteInfo = { + rpcId: string; + sourceRouteId: string; + destRouteId: string; +}; + +function makeWindowRouteId(windowId: string): string { + return `window:${windowId}`; +} + +function makeFeBlockRouteId(feBlockId: string): string { + return `feblock:${feBlockId}`; +} + +class WshRouter { + routeMap: Map; // routeid -> client + upstreamClient: AbstractWshClient; + rpcMap: Map; // rpcid -> routeinfo + + constructor(upstreamClient: AbstractWshClient) { + this.routeMap = new Map(); + this.rpcMap = new Map(); + if (upstreamClient == null) { + throw new Error("upstream client cannot be null"); + } + this.upstreamClient = upstreamClient; + } + + reannounceRoutes() { + for (const [routeId, client] of this.routeMap) { + const announceMsg: RpcMessage = { + command: "routeannounce", + data: routeId, + source: routeId, + }; + this.upstreamClient.recvRpcMessage(announceMsg); + } + } + + // returns true if the message was sent + _sendRoutedMessage(msg: RpcMessage, destRouteId: string) { + const client = this.routeMap.get(destRouteId); + if (client) { + client.recvRpcMessage(msg); + return; + } + // there should always an upstream client + if (!this.upstreamClient) { + throw new Error(`no upstream client for message: ${msg}`); + } + this.upstreamClient?.recvRpcMessage(msg); + } + + _registerRouteInfo(reqid: string, sourceRouteId: string, destRouteId: string) { + dlog("registering route info", reqid, sourceRouteId, destRouteId); + if (util.isBlank(reqid)) { + return; + } + const routeInfo: RouteInfo = { + rpcId: reqid, + sourceRouteId: sourceRouteId, + destRouteId: destRouteId, + }; + this.rpcMap.set(reqid, routeInfo); + } + + recvRpcMessage(msg: RpcMessage) { + dlog("router received message", msg); + // we are a terminal node by definition, so we don't need to process with announce/unannounce messages + if (msg.command == "routeannounce" || msg.command == "routeunannounce") { + return; + } + // handle events + if (msg.command == "eventrecv") { + handleWaveEvent(msg.data); + return; + } + if (!util.isBlank(msg.command)) { + // send + register routeinfo + if (!util.isBlank(msg.reqid)) { + this._registerRouteInfo(msg.reqid, msg.source, msg.route); + } + this._sendRoutedMessage(msg, msg.route); + return; + } + if (!util.isBlank(msg.reqid)) { + const routeInfo = this.rpcMap.get(msg.reqid); + if (!routeInfo) { + // no route info, discard + dlog("no route info for reqid, discarding", msg); + return; + } + this._sendRoutedMessage(msg, routeInfo.destRouteId); + return; + } + if (!util.isBlank(msg.resid)) { + const routeInfo = this.rpcMap.get(msg.resid); + if (!routeInfo) { + // no route info, discard + dlog("no route info for resid, discarding", msg); + return; + } + this._sendRoutedMessage(msg, routeInfo.sourceRouteId); + if (!msg.cont) { + dlog("deleting route info", msg.resid); + this.rpcMap.delete(msg.resid); + } + return; + } + dlog("bad rpc message recevied by router, no command, reqid, or resid (discarding)", msg); + } + + registerRoute(routeId: string, client: AbstractWshClient) { + if (routeId == SysRouteName) { + throw new Error(`Cannot register route with reserved name (${routeId})`); + } + dlog("registering route: ", routeId); + // announce + const announceMsg: RpcMessage = { + command: "routeannounce", + data: routeId, + source: routeId, + }; + this.upstreamClient.recvRpcMessage(announceMsg); + this.routeMap.set(routeId, client); + } + + unregisterRoute(routeId: string) { + dlog("unregister route: ", routeId); + // unannounce + const unannounceMsg: RpcMessage = { + command: "routeunannounce", + data: routeId, + source: routeId, + }; + this.upstreamClient?.recvRpcMessage(unannounceMsg); + this.routeMap.delete(routeId); + } +} + +export { makeFeBlockRouteId, makeWindowRouteId, WshRouter }; diff --git a/frontend/app/store/wshrpcutil.ts b/frontend/app/store/wshrpcutil.ts new file mode 100644 index 000000000..1f5c1c797 --- /dev/null +++ b/frontend/app/store/wshrpcutil.ts @@ -0,0 +1,167 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { wpsReconnectHandler } from "@/app/store/wps"; +import { WshClient } from "@/app/store/wshclient"; +import { makeWindowRouteId, WshRouter } from "@/app/store/wshrouter"; +import { getWSServerEndpoint } from "@/util/endpoints"; +import { addWSReconnectHandler, WSControl } from "./ws"; + +let globalWS: WSControl; +let DefaultRouter: WshRouter; +let WindowRpcClient: WshClient; + +async function* rpcResponseGenerator( + openRpcs: Map, + command: string, + reqid: string, + timeout: number +): AsyncGenerator { + const msgQueue: RpcMessage[] = []; + let signalFn: () => void; + let signalPromise = new Promise((resolve) => (signalFn = resolve)); + let timeoutId: NodeJS.Timeout = null; + if (timeout > 0) { + timeoutId = setTimeout(() => { + msgQueue.push({ resid: reqid, error: "EC-TIME: timeout waiting for response" }); + signalFn(); + }, timeout); + } + const msgFn = (msg: RpcMessage) => { + msgQueue.push(msg); + signalFn(); + // reset signal promise + signalPromise = new Promise((resolve) => (signalFn = resolve)); + }; + openRpcs.set(reqid, { + reqId: reqid, + startTs: Date.now(), + command: command, + msgFn: msgFn, + }); + yield null; + try { + while (true) { + while (msgQueue.length > 0) { + const msg = msgQueue.shift()!; + if (msg.error != null) { + throw new Error(msg.error); + } + if (!msg.cont && msg.data == null) { + return; + } + const shouldTerminate = yield msg.data; + if (shouldTerminate) { + sendRpcCancel(reqid); + return; + } + if (!msg.cont) { + return; + } + } + await signalPromise; + } + } finally { + openRpcs.delete(reqid); + if (timeoutId != null) { + clearTimeout(timeoutId); + } + } +} + +function sendRpcCancel(reqid: string) { + const rpcMsg: RpcMessage = { reqid: reqid, cancel: true }; + DefaultRouter.recvRpcMessage(rpcMsg); +} + +function sendRpcResponse(msg: RpcMessage) { + DefaultRouter.recvRpcMessage(msg); +} + +function sendRpcCommand( + openRpcs: Map, + msg: RpcMessage +): AsyncGenerator { + DefaultRouter.recvRpcMessage(msg); + if (msg.reqid == null) { + return null; + } + const rtnGen = rpcResponseGenerator(openRpcs, msg.command, msg.reqid, msg.timeout); + rtnGen.next(); // start the generator (run the initialization/registration logic, throw away the result) + return rtnGen; +} + +function sendRawRpcMessage(msg: RpcMessage) { + const wsMsg: WSRpcCommand = { wscommand: "rpc", message: msg }; + sendWSCommand(wsMsg); +} + +async function consumeGenerator(gen: AsyncGenerator) { + let idx = 0; + try { + for await (const msg of gen) { + console.log("gen", idx, msg); + idx++; + } + const result = await gen.return(undefined); + console.log("gen done", result.value); + } catch (e) { + console.log("gen error", e); + } +} + +if (globalThis.window != null) { + globalThis["consumeGenerator"] = consumeGenerator; +} + +function initElectronWshrpc(electronClient: WshClient, authKey: string) { + DefaultRouter = new WshRouter(new UpstreamWshRpcProxy()); + const handleFn = (event: WSEventType) => { + DefaultRouter.recvRpcMessage(event.data); + }; + globalWS = new WSControl(getWSServerEndpoint(), "electron", handleFn, authKey); + globalWS.connectNow("connectWshrpc"); + DefaultRouter.registerRoute(electronClient.routeId, electronClient); + addWSReconnectHandler(() => { + DefaultRouter.reannounceRoutes(); + }); + addWSReconnectHandler(wpsReconnectHandler); +} + +function initWshrpc(windowId: string): WSControl { + DefaultRouter = new WshRouter(new UpstreamWshRpcProxy()); + const handleFn = (event: WSEventType) => { + DefaultRouter.recvRpcMessage(event.data); + }; + globalWS = new WSControl(getWSServerEndpoint(), windowId, handleFn); + globalWS.connectNow("connectWshrpc"); + WindowRpcClient = new WshClient(makeWindowRouteId(windowId)); + DefaultRouter.registerRoute(WindowRpcClient.routeId, WindowRpcClient); + addWSReconnectHandler(() => { + DefaultRouter.reannounceRoutes(); + }); + addWSReconnectHandler(wpsReconnectHandler); + return globalWS; +} + +function sendWSCommand(cmd: WSCommandType) { + globalWS?.pushMessage(cmd); +} + +class UpstreamWshRpcProxy implements AbstractWshClient { + recvRpcMessage(msg: RpcMessage): void { + const wsMsg: WSRpcCommand = { wscommand: "rpc", message: msg }; + globalWS?.pushMessage(wsMsg); + } +} + +export { + DefaultRouter, + initElectronWshrpc, + initWshrpc, + sendRawRpcMessage, + sendRpcCommand, + sendRpcResponse, + sendWSCommand, + WindowRpcClient, +}; diff --git a/frontend/app/tab/tab.less b/frontend/app/tab/tab.less new file mode 100644 index 000000000..61a56da9d --- /dev/null +++ b/frontend/app/tab/tab.less @@ -0,0 +1,95 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.tab { + position: absolute; + width: 130px; + height: calc(100% - 1px); + padding: 6px 3px 0px; + box-sizing: border-box; + font-weight: bold; + color: var(--secondary-text-color); + opacity: 0; + + .tab-inner { + position: relative; + width: 100%; + height: 100%; + white-space: nowrap; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + border: 0.5px solid rgba(255, 255, 255, 0.08); + } + + &.animate { + transition: + transform 0.3s ease, + background-color 0.3s ease-in-out; + } + + &.active { + .tab-inner { + border: 0.5px solid rgba(255, 255, 255, 0.2); + background: radial-gradient(ellipse 92px 32px at bottom, rgba(118, 255, 53, 0.3) 0%, transparent 100%), + rgba(255, 255, 255, 0.2); + } + + .name { + color: var(--main-text-color); + } + } + + .name { + position: absolute; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0); + user-select: none; + z-index: var(--zindex-tab-name); + font-size: 11px; + font-weight: 500; + text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.25); + + &.focused { + outline: none; + border: 1px solid rgba(255, 255, 255, 0.179); + padding: 2px 6px; + border-radius: 2px; + } + } + + .close { + visibility: hidden; + position: absolute; + top: 50%; + right: 4px; + transform: translate3d(0, -50%, 0); + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: var(--zindex-tab-name); + padding: 1px 2px; + } + + &:hover .close { + visibility: visible; + } +} + +@keyframes expandWidthAndFadeIn { + from { + width: var(--initial-tab-width); + opacity: 0; + } + to { + width: var(--final-tab-width); + opacity: 1; + } +} + +.tab.new-tab { + animation: expandWidthAndFadeIn 0.1s forwards; +} diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx new file mode 100644 index 000000000..f1c4b21b9 --- /dev/null +++ b/frontend/app/tab/tab.tsx @@ -0,0 +1,218 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/element/button"; +import { ContextMenuModel } from "@/store/contextmenu"; +import * as services from "@/store/services"; +import * as WOS from "@/store/wos"; +import { clsx } from "clsx"; +import * as React from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; + +import { atoms, globalStore } from "@/app/store/global"; +import "./tab.less"; + +interface TabProps { + id: string; + active: boolean; + isFirst: boolean; + isBeforeActive: boolean; + isDragging: boolean; + tabWidth: number; + isNew: boolean; + onSelect: () => void; + onClose: (event: React.MouseEvent | null) => void; + onDragStart: (event: React.MouseEvent) => void; + onLoaded: () => void; +} + +const Tab = React.memo( + forwardRef( + ( + { + id, + active, + isFirst, + isBeforeActive, + isDragging, + tabWidth, + isNew, + onLoaded, + onSelect, + onClose, + onDragStart, + }, + ref + ) => { + const [tabData, tabLoading] = WOS.useWaveObjectValue(WOS.makeORef("tab", id)); + const [originalName, setOriginalName] = useState(""); + const [isEditable, setIsEditable] = useState(false); + + const editableRef = useRef(null); + const editableTimeoutRef = useRef(); + const loadedRef = useRef(false); + const tabRef = useRef(null); + + useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); + + useEffect(() => { + if (tabData?.name) { + setOriginalName(tabData.name); + } + }, [tabData]); + + useEffect(() => { + return () => { + if (editableTimeoutRef.current) { + clearTimeout(editableTimeoutRef.current); + } + }; + }, []); + + const handleDoubleClick = (event) => { + event.stopPropagation(); + setIsEditable(true); + editableTimeoutRef.current = setTimeout(() => { + if (editableRef.current) { + editableRef.current.focus(); + document.execCommand("selectAll", false); + } + }, 0); + }; + + const handleBlur = () => { + let newText = editableRef.current.innerText.trim(); + newText = newText || originalName; + editableRef.current.innerText = newText; + setIsEditable(false); + services.ObjectService.UpdateTabName(id, newText); + }; + + const handleKeyDown = (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "a") { + event.preventDefault(); + if (editableRef.current) { + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(editableRef.current); + selection.removeAllRanges(); + selection.addRange(range); + } + return; + } + // this counts glyphs, not characters + const curLen = Array.from(editableRef.current.innerText).length; + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (editableRef.current.innerText.trim() === "") { + editableRef.current.innerText = originalName; + } + editableRef.current.blur(); + } else if (event.key === "Escape") { + editableRef.current.innerText = originalName; + editableRef.current.blur(); + event.preventDefault(); + event.stopPropagation(); + } else if (curLen >= 10 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + useEffect(() => { + if (!loadedRef.current) { + onLoaded(); + loadedRef.current = true; + } + }, [onLoaded]); + + useEffect(() => { + if (tabRef.current && isNew) { + const initialWidth = `${(tabWidth / 3) * 2}px`; + tabRef.current.style.setProperty("--initial-tab-width", initialWidth); + tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`); + } + }, [isNew, tabWidth]); + + // Prevent drag from being triggered on mousedown + const handleMouseDownOnClose = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + function handleContextMenu(e: React.MouseEvent) { + e.preventDefault(); + let menu: ContextMenuItem[] = []; + const fullConfig = globalStore.get(atoms.fullConfigAtom); + const bgPresets: string[] = []; + for (const key in fullConfig?.presets ?? {}) { + if (key.startsWith("bg@")) { + bgPresets.push(key); + } + } + bgPresets.sort((a, b) => { + const aOrder = fullConfig.presets[a]["display:order"] ?? 0; + const bOrder = fullConfig.presets[b]["display:order"] ?? 0; + return aOrder - bOrder; + }); + menu.push({ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) }); + menu.push({ type: "separator" }); + if (bgPresets.length > 0) { + const submenu: ContextMenuItem[] = []; + const oref = WOS.makeORef("tab", id); + for (const presetName of bgPresets) { + const preset = fullConfig.presets[presetName]; + if (preset == null) { + continue; + } + submenu.push({ + label: preset["display:name"] ?? presetName, + click: () => { + services.ObjectService.UpdateObjectMeta(oref, preset); + }, + }); + } + menu.push({ label: "Backgrounds", type: "submenu", submenu }); + menu.push({ type: "separator" }); + } + menu.push({ label: "Close Tab", click: () => onClose(null) }); + ContextMenuModel.showContextMenu(menu, e); + } + + return ( +
+
+
+ {tabData?.name} +
+ +
+
+ ); + } + ) +); + +export { Tab }; diff --git a/frontend/app/tab/tabbar.less b/frontend/app/tab/tabbar.less new file mode 100644 index 000000000..b431b5ca5 --- /dev/null +++ b/frontend/app/tab/tabbar.less @@ -0,0 +1,109 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.tab-bar-wrapper { + --default-indent: 10px; + --darwin-not-fullscreen-indent: 74px; +} + +.darwin:not(.fullscreen) .tab-bar-wrapper { + .window-drag.left { + width: var(--darwin-not-fullscreen-indent); + } +} + +.tab-bar-wrapper { + position: relative; + user-select: none; + display: flex; + flex-direction: row; + width: env(titlebar-area-width); + + .tabs-wrapper { + transition: var(--tabs-wrapper-transition); + height: 32px; + } + + .tab-bar { + position: relative; // Needed for absolute positioning of child tabs + height: 33px; + } + + .dev-label, + .app-menu-button { + height: 100%; + font-size: 26px; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + margin: 2px 2px 0 0; + } + + .app-menu-button { + cursor: pointer; + color: var(--secondary-text-color); + + &:hover { + color: var(--main-text-color); + } + } + + .dev-label { + color: var(--accent-color); + } + + .add-tab-btn { + width: 22px; + height: 100%; + cursor: pointer; + font-size: 14px; + text-align: center; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.5; + + &:hover { + opacity: 1; + } + + i { + overflow: hidden; + margin-top: 5px; + font-size: 11px; + } + } + + .window-drag { + height: 100%; + width: var(--default-indent); + flex-shrink: 0; + + &.right { + flex-grow: 1; + } + } + + // Customize scrollbar styles + .os-theme-dark, + .os-theme-light { + box-sizing: border-box; + --os-size: 2px; + --os-padding-perpendicular: 0px; + --os-padding-axis: 0px; + --os-track-border-radius: 2px; + --os-handle-interactive-area-offset: 0px; + --os-handle-border-radius: 2px; + } + + .update-available-button { + height: 80%; + opacity: 0.7; + margin: auto 4px; + color: black; + background-color: var(--accent-color); + flex: 0 0 fit-content; + } +} diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx new file mode 100644 index 000000000..0dabb1d0b --- /dev/null +++ b/frontend/app/tab/tabbar.tsx @@ -0,0 +1,535 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WindowDrag } from "@/element/windowdrag"; +import { deleteLayoutModelForTab } from "@/layout/index"; +import { atoms, getApi, isDev, PLATFORM } from "@/store/global"; +import * as services from "@/store/services"; +import { useAtomValue } from "jotai"; +import { OverlayScrollbars } from "overlayscrollbars"; +import React, { createRef, useCallback, useEffect, useRef, useState } from "react"; +import { debounce } from "throttle-debounce"; +import { Button } from "../element/button"; +import { Tab } from "./tab"; +import "./tabbar.less"; + +const TAB_DEFAULT_WIDTH = 130; +const TAB_MIN_WIDTH = 100; +const DRAGGER_RIGHT_MIN_WIDTH = 74; +const OS_OPTIONS = { + overflow: { + x: "scroll", + y: "hidden", + }, + scrollbars: { + theme: "os-theme-dark", + visibility: "auto", + autoHide: "leave", + autoHideDelay: 1300, + autoHideSuspend: false, + dragScroll: true, + clickScroll: false, + pointers: ["mouse", "touch", "pen"], + }, +}; + +interface TabBarProps { + workspace: Workspace; +} + +const TabBar = React.memo(({ workspace }: TabBarProps) => { + const [tabIds, setTabIds] = useState([]); + const [dragStartPositions, setDragStartPositions] = useState([]); + const [draggingTab, setDraggingTab] = useState(); + const [tabsLoaded, setTabsLoaded] = useState({}); + // const [scrollable, setScrollable] = useState(false); + // const [tabWidth, setTabWidth] = useState(TAB_DEFAULT_WIDTH); + const [newTabId, setNewTabId] = useState(null); + + const tabbarWrapperRef = useRef(null); + const tabBarRef = useRef(null); + const tabsWrapperRef = useRef(null); + const tabRefs = useRef[]>([]); + const addBtnRef = useRef(null); + const draggingRemovedRef = useRef(false); + const draggingTabDataRef = useRef({ + tabId: "", + ref: { current: null }, + tabStartX: 0, + tabIndex: 0, + initialOffsetX: null, + totalScrollOffset: null, + dragged: false, + }); + const osInstanceRef = useRef(null); + const draggerRightRef = useRef(null); + const draggerLeftRef = useRef(null); + const tabWidthRef = useRef(TAB_DEFAULT_WIDTH); + const scrollableRef = useRef(false); + const updateStatusButtonRef = useRef(null); + const prevAllLoadedRef = useRef(false); + + const windowData = useAtomValue(atoms.waveWindow); + const { activetabid } = windowData; + + const isFullScreen = useAtomValue(atoms.isFullScreen); + + const appUpdateStatus = useAtomValue(atoms.updaterStatusAtom); + + let prevDelta: number; + let prevDragDirection: string; + + // Update refs when tabIds change + useEffect(() => { + tabRefs.current = tabIds.map((_, index) => tabRefs.current[index] || createRef()); + }, [tabIds]); + + useEffect(() => { + if (workspace) { + // Compare current tabIds with new workspace.tabids + const currentTabIds = new Set(tabIds); + const newTabIds = new Set(workspace.tabids); + + const areEqual = + currentTabIds.size === newTabIds.size && [...currentTabIds].every((id) => newTabIds.has(id)); + + if (!areEqual) { + setTabIds(workspace.tabids); + } + } + }, [workspace, tabIds]); + + const saveTabsPosition = useCallback(() => { + const tabs = tabRefs.current; + if (tabs === null) return; + + const newStartPositions: number[] = []; + let cumulativeLeft = 0; // Start from the left edge + + tabRefs.current.forEach((ref) => { + if (ref.current) { + newStartPositions.push(cumulativeLeft); + cumulativeLeft += ref.current.getBoundingClientRect().width; // Add each tab's actual width to the cumulative position + } + }); + + setDragStartPositions(newStartPositions); + }, []); + + const setSizeAndPosition = (animate?: boolean) => { + const tabBar = tabBarRef.current; + if (tabBar === null) return; + + const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width; + const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width; + const addBtnWidth = addBtnRef.current.getBoundingClientRect().width; + const updateStatusLabelWidth = updateStatusButtonRef.current?.getBoundingClientRect().width ?? 0; + const spaceForTabs = + tabbarWrapperWidth - (windowDragLeftWidth + DRAGGER_RIGHT_MIN_WIDTH + addBtnWidth + updateStatusLabelWidth); + + const numberOfTabs = tabIds.length; + const totalDefaultTabWidth = numberOfTabs * TAB_DEFAULT_WIDTH; + const minTotalTabWidth = numberOfTabs * TAB_MIN_WIDTH; + const tabWidth = tabWidthRef.current; + const scrollable = scrollableRef.current; + let newTabWidth = tabWidth; + let newScrollable = scrollable; + + if (spaceForTabs < totalDefaultTabWidth && spaceForTabs > minTotalTabWidth) { + newTabWidth = TAB_MIN_WIDTH; + } else if (minTotalTabWidth > spaceForTabs) { + // Case where tabs cannot shrink further, make the tab bar scrollable + newTabWidth = TAB_MIN_WIDTH; + newScrollable = true; + } else if (totalDefaultTabWidth > spaceForTabs) { + // Case where resizing is needed due to limited container width + newTabWidth = spaceForTabs / numberOfTabs; + newScrollable = false; + } else { + // Case where tabs were previously shrunk or there is enough space for default width tabs + newTabWidth = TAB_DEFAULT_WIDTH; + newScrollable = false; + } + + // Apply the calculated width and position to all tabs + tabRefs.current.forEach((ref, index) => { + if (ref.current) { + if (animate) { + ref.current.classList.add("animate"); + } else { + ref.current.classList.remove("animate"); + } + ref.current.style.width = `${newTabWidth}px`; + ref.current.style.transform = `translate3d(${index * newTabWidth}px,0,0)`; + ref.current.style.opacity = "1"; + } + }); + + // Update the state with the new tab width if it has changed + if (newTabWidth !== tabWidth) { + tabWidthRef.current = newTabWidth; + } + // Update the state with the new scrollable state if it has changed + if (newScrollable !== scrollable) { + scrollableRef.current = newScrollable; + } + // Initialize/destroy overlay scrollbars + if (newScrollable) { + osInstanceRef.current = OverlayScrollbars(tabBarRef.current, { ...(OS_OPTIONS as any) }); + } else { + if (osInstanceRef.current) { + osInstanceRef.current.destroy(); + } + } + }; + + const handleResizeTabs = useCallback(() => { + setSizeAndPosition(); + debounce(100, () => saveTabsPosition())(); + }, [tabIds, newTabId, isFullScreen]); + + useEffect(() => { + window.addEventListener("resize", () => handleResizeTabs()); + return () => { + window.removeEventListener("resize", () => handleResizeTabs()); + }; + }, [handleResizeTabs]); + + useEffect(() => { + // Check if all tabs are loaded + const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]); + if (allLoaded) { + setSizeAndPosition(newTabId === null && prevAllLoadedRef.current); + saveTabsPosition(); + if (!prevAllLoadedRef.current) { + prevAllLoadedRef.current = true; + } + } + }, [tabIds, tabsLoaded, newTabId, saveTabsPosition]); + + const getDragDirection = (currentX: number) => { + let dragDirection; + if (currentX - prevDelta > 0) { + dragDirection = "+"; + } else if (currentX - prevDelta === 0) { + dragDirection = prevDragDirection; + } else { + dragDirection = "-"; + } + prevDelta = currentX; + prevDragDirection = dragDirection; + return dragDirection; + }; + + const getNewTabIndex = (currentX: number, tabIndex: number, dragDirection: string) => { + let newTabIndex = tabIndex; + const tabWidth = tabWidthRef.current; + if (dragDirection === "+") { + // Dragging to the right + for (let i = tabIndex + 1; i < tabIds.length; i++) { + const otherTabStart = dragStartPositions[i]; + if (currentX + tabWidth > otherTabStart + tabWidth / 2) { + newTabIndex = i; + } + } + } else { + // Dragging to the left + for (let i = tabIndex - 1; i >= 0; i--) { + const otherTabEnd = dragStartPositions[i] + tabWidth; + if (currentX < otherTabEnd - tabWidth / 2) { + newTabIndex = i; + } + } + } + return newTabIndex; + }; + + const handleMouseMove = (event: MouseEvent) => { + const { tabId, ref, tabStartX } = draggingTabDataRef.current; + + let initialOffsetX = draggingTabDataRef.current.initialOffsetX; + let totalScrollOffset = draggingTabDataRef.current.totalScrollOffset; + if (initialOffsetX === null) { + initialOffsetX = event.clientX - tabStartX; + draggingTabDataRef.current.initialOffsetX = initialOffsetX; + } + let currentX = event.clientX - initialOffsetX - totalScrollOffset; + let tabBarRectWidth = tabBarRef.current.getBoundingClientRect().width; + // for macos, it's offset to make space for the window buttons + const tabBarRectLeftOffset = tabBarRef.current.getBoundingClientRect().left; + const incrementDecrement = tabBarRectLeftOffset * 0.05; + const dragDirection = getDragDirection(currentX); + const scrollable = scrollableRef.current; + const tabWidth = tabWidthRef.current; + + // Scroll the tab bar if the dragged tab overflows the container bounds + if (scrollable) { + const { viewport } = osInstanceRef.current.elements(); + const currentScrollLeft = viewport.scrollLeft; + + if (event.clientX <= tabBarRectLeftOffset) { + viewport.scrollLeft = Math.max(0, currentScrollLeft - incrementDecrement); // Scroll left + if (viewport.scrollLeft !== currentScrollLeft) { + // Only adjust if the scroll actually changed + draggingTabDataRef.current.totalScrollOffset += currentScrollLeft - viewport.scrollLeft; + } + } else if (event.clientX >= tabBarRectWidth + tabBarRectLeftOffset) { + viewport.scrollLeft = Math.min(viewport.scrollWidth, currentScrollLeft + incrementDecrement); // Scroll right + if (viewport.scrollLeft !== currentScrollLeft) { + // Only adjust if the scroll actually changed + draggingTabDataRef.current.totalScrollOffset -= viewport.scrollLeft - currentScrollLeft; + } + } + } + + // Re-calculate currentX after potential scroll adjustment + initialOffsetX = draggingTabDataRef.current.initialOffsetX; + totalScrollOffset = draggingTabDataRef.current.totalScrollOffset; + currentX = event.clientX - initialOffsetX - totalScrollOffset; + + setDraggingTab((prev) => (prev !== tabId ? tabId : prev)); + + // Check if the tab has moved 5 pixels + if (Math.abs(currentX - tabStartX) >= 50) { + draggingTabDataRef.current.dragged = true; + } + + // Constrain movement within the container bounds + if (tabBarRef.current) { + const numberOfTabs = tabIds.length; + const totalDefaultTabWidth = numberOfTabs * TAB_DEFAULT_WIDTH; + if (totalDefaultTabWidth < tabBarRectWidth) { + // Set to the total default tab width if there's vacant space + tabBarRectWidth = totalDefaultTabWidth; + } else if (scrollable) { + // Set to the scrollable width if the tab bar is scrollable + tabBarRectWidth = tabsWrapperRef.current.scrollWidth; + } + + const minLeft = 0; + const maxRight = tabBarRectWidth - tabWidth; + + // Adjust currentX to stay within bounds + currentX = Math.min(Math.max(currentX, minLeft), maxRight); + } + + ref.current!.style.transform = `translate3d(${currentX}px,0,0)`; + ref.current!.style.zIndex = "100"; + + const tabIndex = draggingTabDataRef.current.tabIndex; + const newTabIndex = getNewTabIndex(currentX, tabIndex, dragDirection); + + if (newTabIndex !== tabIndex) { + // Remove the dragged tab if not already done + if (!draggingRemovedRef.current) { + tabIds.splice(tabIndex, 1); + draggingRemovedRef.current = true; + } + + // Find current index of the dragged tab in tempTabs + const currentIndexOfDraggingTab = tabIds.indexOf(tabId); + + // Move the dragged tab to its new position + if (currentIndexOfDraggingTab !== -1) { + tabIds.splice(currentIndexOfDraggingTab, 1); + } + tabIds.splice(newTabIndex, 0, tabId); + + // Update visual positions of the tabs + tabIds.forEach((localTabId, index) => { + const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === localTabId); + if (ref.current && localTabId !== tabId) { + ref.current.style.transform = `translate3d(${index * tabWidth}px,0,0)`; + ref.current.classList.add("animate"); + } + }); + + draggingTabDataRef.current.tabIndex = newTabIndex; + } + }; + + const handleMouseUp = (event: MouseEvent) => { + const { tabIndex, dragged } = draggingTabDataRef.current; + + // Update the final position of the dragged tab + const draggingTab = tabIds[tabIndex]; + const tabWidth = tabWidthRef.current; + const finalLeftPosition = tabIndex * tabWidth; + const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === draggingTab); + if (ref.current) { + ref.current.classList.add("animate"); + ref.current.style.transform = `translate3d(${finalLeftPosition}px,0,0)`; + } + + if (dragged) { + debounce(300, () => { + // Reset styles + tabRefs.current.forEach((ref) => { + ref.current.style.zIndex = "0"; + ref.current.classList.remove("animate"); + }); + // Reset dragging state + setDraggingTab(null); + // Update workspace tab ids + services.ObjectService.UpdateWorkspaceTabIds(workspace.oid, tabIds); + })(); + } else { + // Reset styles + tabRefs.current.forEach((ref) => { + ref.current.style.zIndex = "0"; + ref.current.classList.remove("animate"); + }); + // Reset dragging state + setDraggingTab(null); + } + + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("mousemove", handleMouseMove); + draggingRemovedRef.current = false; + }; + + const handleDragStart = useCallback( + (event: React.MouseEvent, tabId: string, ref: React.RefObject) => { + if (event.button !== 0) return; + + const tabIndex = tabIds.indexOf(tabId); + const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab + + if (ref.current) { + draggingTabDataRef.current = { + tabId: ref.current.dataset.tabId, + ref, + tabStartX, + tabIndex, + initialOffsetX: null, + totalScrollOffset: 0, + dragged: false, + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + } + }, + [tabIds, dragStartPositions] + ); + + const handleSelectTab = (tabId: string) => { + if (!draggingTabDataRef.current.dragged) { + services.ObjectService.SetActiveTab(tabId); + } + }; + + const handleAddTab = () => { + const newTabName = `T${tabIds.length + 1}`; + services.ObjectService.AddTabToWorkspace(newTabName, true).then((tabId) => { + setTabIds([...tabIds, tabId]); + setNewTabId(tabId); + }); + services.ObjectService.GetObject; + tabsWrapperRef.current.style.transition; + tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease"); + + debounce(30, () => { + if (scrollableRef.current) { + const { viewport } = osInstanceRef.current.elements(); + viewport.scrollLeft = tabIds.length * tabWidthRef.current; + } + })(); + + debounce(100, () => setNewTabId(null))(); + }; + + const handleCloseTab = (event: React.MouseEvent | null, tabId: string) => { + event?.stopPropagation(); + services.WindowService.CloseTab(tabId); + tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease"); + deleteLayoutModelForTab(tabId); + }; + + const handleTabLoaded = useCallback((tabId) => { + setTabsLoaded((prev) => { + if (!prev[tabId]) { + // Only update if the tab isn't already marked as loaded + return { ...prev, [tabId]: true }; + } + return prev; + }); + }, []); + + const isBeforeActive = (tabId: string) => { + return tabIds.indexOf(tabId) === tabIds.indexOf(activetabid) - 1; + }; + + function onEllipsisClick() { + getApi().showContextMenu(); + } + + const tabsWrapperWidth = tabIds.length * tabWidthRef.current; + const devLabel = isDev() ? ( +
+ +
+ ) : undefined; + const appMenuButton = + PLATFORM !== "darwin" ? ( +
+ +
+ ) : undefined; + + function onUpdateAvailableClick() { + getApi().installAppUpdate(); + } + + let updateAvailableLabel: React.ReactNode = null; + if (appUpdateStatus === "ready") { + updateAvailableLabel = ( + + ); + } + + return ( +
+ + {appMenuButton} + {devLabel} +
+
+ {tabIds.map((tabId, index) => { + return ( + handleSelectTab(tabId)} + active={activetabid === tabId} + onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} + onClose={(event) => handleCloseTab(event, tabId)} + onLoaded={() => handleTabLoaded(tabId)} + isBeforeActive={isBeforeActive(tabId)} + isDragging={draggingTab === tabId} + tabWidth={tabWidthRef.current} + isNew={tabId === newTabId} + /> + ); + })} +
+
+
+ +
+ + {updateAvailableLabel} +
+ ); +}); + +export { TabBar }; diff --git a/frontend/app/tab/tabcontent.less b/frontend/app/tab/tabcontent.less new file mode 100644 index 000000000..fdab4154d --- /dev/null +++ b/frontend/app/tab/tabcontent.less @@ -0,0 +1,36 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.tabcontent { + display: flex; + flex-direction: row; + flex-grow: 1; + min-height: 0; + width: 100%; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + padding-top: 3px; + padding-right: 3px; + + .block-container { + display: flex; + flex-direction: row; + flex: 1 0 0; + height: 100%; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 4px; + } +} + +.drag-preview { + display: block; + width: 100px; + height: 20px; + border-radius: 2px; + background-color: aquamarine; + color: black; + text-align: center; +} diff --git a/frontend/app/tab/tabcontent.tsx b/frontend/app/tab/tabcontent.tsx new file mode 100644 index 000000000..cca7dba98 --- /dev/null +++ b/frontend/app/tab/tabcontent.tsx @@ -0,0 +1,83 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { CenteredDiv } from "@/element/quickelems"; +import { ContentRenderer, NodeModel, PreviewRenderer, TileLayout } from "@/layout/index"; +import { TileLayoutContents } from "@/layout/lib/types"; +import { atoms, getApi } from "@/store/global"; +import * as services from "@/store/services"; +import * as WOS from "@/store/wos"; +import { atom, useAtomValue } from "jotai"; +import * as React from "react"; +import { useMemo } from "react"; +import "./tabcontent.less"; + +const tileGapSizeAtom = atom((get) => { + const settings = get(atoms.settingsAtom); + return settings["window:tilegapsize"]; +}); + +const TabContent = React.memo(({ tabId }: { tabId: string }) => { + const oref = useMemo(() => WOS.makeORef("tab", tabId), [tabId]); + const loadingAtom = useMemo(() => WOS.getWaveObjectLoadingAtom(oref), [oref]); + const tabLoading = useAtomValue(loadingAtom); + const tabAtom = useMemo(() => WOS.getWaveObjectAtom(oref), [oref]); + const tabData = useAtomValue(tabAtom); + const tileGapSize = useAtomValue(tileGapSizeAtom); + + const tileLayoutContents = useMemo(() => { + const renderContent: ContentRenderer = (nodeModel: NodeModel) => { + return ; + }; + + const renderPreview: PreviewRenderer = (nodeModel: NodeModel) => { + return ; + }; + + function onNodeDelete(data: TabLayoutData) { + return services.ObjectService.DeleteBlock(data.blockId); + } + + return { + renderContent, + renderPreview, + tabId, + onNodeDelete, + gapSizePx: tileGapSize, + } as TileLayoutContents; + }, [tabId, tileGapSize]); + + if (tabLoading) { + return ( +
+ Tab Loading +
+ ); + } + + if (!tabData) { + return ( +
+ Tab Not Found +
+ ); + } + + if (tabData?.blockids?.length == 0) { + return
; + } + + return ( +
+ +
+ ); +}); + +export { TabContent }; diff --git a/frontend/app/theme.less b/frontend/app/theme.less new file mode 100644 index 000000000..87b61c6a8 --- /dev/null +++ b/frontend/app/theme.less @@ -0,0 +1,149 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Used for syntax highlighting in markdown +@import url("../../node_modules/highlight.js/styles/github-dark-dimmed.min.css"); + +:root { + --main-text-color: #f7f7f7; + --title-font-size: 18px; + --secondary-text-color: rgb(195, 200, 194); + --grey-text-color: #666; + --main-bg-color: rgb(34, 34, 34); + --border-color: rgba(255, 255, 255, 0.16); + --base-font: normal 14px / normal "Inter", sans-serif; + --fixed-font: normal 12px / normal "Hack", monospace; + --accent-color: rgb(88, 193, 66); + --panel-bg-color: rgba(31, 33, 31, 0.5); + --highlight-bg-color: rgba(255, 255, 255, 0.2); + --markdown-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + --error-color: rgb(229, 77, 46); + --warning-color: rgb(224, 185, 86); + --success-color: rgb(78, 154, 6); + --hover-bg-color: rgba(255, 255, 255, 0.1); + --block-bg-color: rgba(0, 0, 0, 0.5); + --block-bg-solid-color: rgb(0, 0, 0); + --block-border-radius: 8px; + + --keybinding-color: #e0e0e0; + --keybinding-bg-color: #333; + --keybinding-border-color: #444; + + /* scrollbar colors */ + --scrollbar-background-color: transparent; + --scrollbar-thumb-color: rgba(255, 255, 255, 0.15); + --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5); + --scrollbar-thumb-active-color: rgba(255, 255, 255, 0.6); + + --header-font: 700 11px / normal "Inter", sans-serif; + --header-icon-size: 14px; + --header-icon-width: 16px; + --header-height: 30px; + + --tab-green: rgb(88, 193, 66); + + /* z-index values */ + --zindex-header-hover: 100; + --zindex-termstickers: 20; + --zindex-modal: 2; + --zindex-modal-wrapper: 500; + --zindex-modal-backdrop: 1; + --zindex-typeahead-modal: 100; + --zindex-typeahead-modal-backdrop: 90; + --zindex-elem-modal: 100; + --zindex-window-drag: 100; + --zindex-tab-name: 3; + --zindex-layout-display-container: 0; + --zindex-layout-last-magnified-node: 1; + --zindex-layout-resize-handle: 2; + --zindex-layout-placeholder-container: 3; + --zindex-layout-overlay-container: 4; + --zindex-layout-magnified-node: 5; + --zindex-block-mask-inner: 10; + --zindex-flash-error-container: 550; + + // z-indexes in xterm.css + // xterm-helpers: 5 + // xterm-helper-textarea: -5 + // composition-view: 1 + // xterm-message: 10 + // xterm-decoration: 6 + // xterm-decoration-top-layer: 7 + // xterm-decoration-overview-ruler: 8 + // xterm-decoration-top: 2 + + // modal colors + --modal-bg-color: #232323; + --modal-header-bottom-border-color: rgba(241, 246, 243, 0.15); + --modal-border-color: rgba(255, 255, 255, 0.12); /* toggle colors */ + --toggle-bg-color: var(--border-color); + + --toggle-thumb-color: var(--main-text-color); + --toggle-checked-bg-color: var(--accent-color); + + // link color + --link-color: #58c142; + + // form colors + --form-element-border-color: rgba(241, 246, 243, 0.15); + --form-element-bg-color: var(--main-bg-color); + --form-element-text-color: var(--main-text-color); + --form-element-primary-text-color: var(--main-text-color); + --form-element-primary-color: var(--accent-color); + --form-element-secondary-color: rgba(255, 255, 255, 0.2); + --form-element-error-color: var(--error-color); + + --conn-icon-color: #53b4ea; + --conn-icon-color-1: #53b4ea; + --conn-icon-color-2: #aa67ff; + --conn-icon-color-3: #fda7fd; + --conn-icon-color-4: #ef476f; + --conn-icon-color-5: #497bf8; + --conn-icon-color-6: #ffa24e; + --conn-icon-color-7: #dbde52; + --conn-icon-color-8: #58c142; + + --bulb-color: rgb(255, 221, 51); + + // term colors (16 + 6) form the base terminal theme + // for consistency these colors should be used by plugins/applications + --term-black: #000000; + --term-red: #cc0000; + --term-green: #4e9a06; + --term-yellow: #c4a000; + --term-blue: #3465a4; + --term-magenta: #bc3fbc; + --term-cyan: #06989a; + --term-white: #d0d0d0; + --term-bright-black: #555753; + --term-bright-red: #ef2929; + --term-bright-green: #58c142; + --term-bright-yellow: #fce94f; + --term-bright-blue: #32afff; + --term-bright-magenta: #ad7fa8; + --term-bright-cyan: #34e2e2; + --term-bright-white: #e7e7e7; + + --term-gray: #8b918a; // not an official terminal color + --term-cmdtext: #ffffff; + --term-foreground: #d3d7cf; + --term-background: #000000; + --term-selection-background: #ffffff60; + --term-cursor-accent: #000000; + + // button colors + --button-focus-border-color: rgba(88, 193, 66, 0.5); + --button-green-text-color: var(--term-black); + --button-green-bg: var(--term-green); + --button-green-border-color: rgb(26, 52, 21); + --button-grey-text-color: var(--main-text-color); + --button-grey-bg: rgba(255, 255, 255, 0.04); + --button-grey-border-color: rgba(255, 255, 255, 0.1); + --button-red-text-color: var(--main-text-color); + --button-red-bg: var(--term-red); + --button-red-border-color: #ff1818; + --button-yellow-text-color: var(--term-black); + --button-yellow-bg: var(--term-yellow); + --button-yellow-border-color: #fbd93f; +} diff --git a/frontend/app/view/codeeditor/codeeditor.less b/frontend/app/view/codeeditor/codeeditor.less new file mode 100644 index 000000000..c5896e058 --- /dev/null +++ b/frontend/app/view/codeeditor/codeeditor.less @@ -0,0 +1,29 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.code-editor-wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + align-items: center; + justify-content: center; + + .code-editor { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + + .monaco-editor .slider { + background: rgba(255, 255, 255, 0.4); + border-radius: 4px; + transition: background 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.6); + } + } + } +} diff --git a/frontend/app/view/codeeditor/codeeditor.tsx b/frontend/app/view/codeeditor/codeeditor.tsx new file mode 100644 index 000000000..dc544678d --- /dev/null +++ b/frontend/app/view/codeeditor/codeeditor.tsx @@ -0,0 +1,140 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atoms } from "@/app/store/global"; +import loader from "@monaco-editor/loader"; +import { Editor, Monaco } from "@monaco-editor/react"; +import { atom, useAtomValue } from "jotai"; +import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; +import React, { useMemo, useRef } from "react"; +import "./codeeditor.less"; + +// there is a global monaco variable (TODO get the correct TS type) +declare var monaco: Monaco; + +export function loadMonaco() { + loader.config({ paths: { vs: "monaco" } }); + loader + .init() + .then(() => { + monaco.editor.defineTheme("wave-theme-dark", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#00000000", + "editorStickyScroll.background": "#00000055", + "minimap.background": "#00000077", + focusBorder: "#00000000", + }, + }); + monaco.editor.defineTheme("wave-theme-light", { + base: "vs", + inherit: true, + rules: [], + colors: { + "editor.background": "#fefefe", + focusBorder: "#00000000", + }, + }); + + // Disable default validation errors for typescript and javascript + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: true, + }); + }) + .catch((e) => { + console.error("error loading monaco", e); + }); +} + +function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions { + const opts: MonacoTypes.editor.IEditorOptions = { + scrollBeyondLastLine: false, + fontSize: 12, + fontFamily: "Hack", + smoothScrolling: true, + scrollbar: { + useShadows: false, + verticalScrollbarSize: 5, + horizontalScrollbarSize: 5, + }, + minimap: { + enabled: true, + }, + stickyScroll: { + enabled: false, + }, + }; + return opts; +} + +interface CodeEditorProps { + text: string; + filename: string; + language?: string; + onChange?: (text: string) => void; + onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => () => void; +} + +const minimapEnabledAtom = atom((get) => { + const settings = get(atoms.settingsAtom); + return settings["editor:minimapenabled"] ?? false; +}); + +const stickyScrollEnabledAtom = atom((get) => { + const settings = get(atoms.settingsAtom); + return settings["editor:stickyscrollenabled"] ?? false; +}); + +export function CodeEditor({ text, language, filename, onChange, onMount }: CodeEditorProps) { + const divRef = useRef(null); + const unmountRef = useRef<() => void>(null); + const minimapEnabled = useAtomValue(minimapEnabledAtom); + const stickyScrollEnabled = useAtomValue(stickyScrollEnabledAtom); + const theme = "wave-theme-dark"; + + React.useEffect(() => { + return () => { + // unmount function + if (unmountRef.current) { + unmountRef.current(); + } + }; + }, []); + + function handleEditorChange(text: string, ev: MonacoTypes.editor.IModelContentChangedEvent) { + if (onChange) { + onChange(text); + } + } + + function handleEditorOnMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) { + if (onMount) { + unmountRef.current = onMount(editor, monaco); + } + } + + const editorOpts = useMemo(() => { + const opts = defaultEditorOptions(); + opts.minimap.enabled = minimapEnabled; + opts.stickyScroll.enabled = stickyScrollEnabled; + return opts; + }, [minimapEnabled, stickyScrollEnabled]); + + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/app/view/cpuplot/cpuplot.less b/frontend/app/view/cpuplot/cpuplot.less new file mode 100644 index 000000000..4f950dc63 --- /dev/null +++ b/frontend/app/view/cpuplot/cpuplot.less @@ -0,0 +1,9 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.plot-view { + display: flex; + justify-content: center; + align-items: stretch; + width: 100%; +} diff --git a/frontend/app/view/cpuplot/cpuplot.tsx b/frontend/app/view/cpuplot/cpuplot.tsx new file mode 100644 index 000000000..eb060cf58 --- /dev/null +++ b/frontend/app/view/cpuplot/cpuplot.tsx @@ -0,0 +1,297 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useHeight } from "@/app/hook/useHeight"; +import { useWidth } from "@/app/hook/useWidth"; +import { getConnStatusAtom, globalStore, WOS } from "@/store/global"; +import * as util from "@/util/util"; +import * as Plot from "@observablehq/plot"; +import dayjs from "dayjs"; +import * as htl from "htl"; +import * as jotai from "jotai"; +import * as React from "react"; + +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { WindowRpcClient } from "@/app/store/wshrpcutil"; +import "./cpuplot.less"; + +const DefaultNumPoints = 120; + +type DataItem = { + ts: number; + [k: string]: number; +}; + +const SysInfoMetricNames = { + cpu: "CPU %", + "mem:total": "Memory Total", + "mem:used": "Memory Used", + "mem:free": "Memory Free", + "mem:available": "Memory Available", +}; +for (let i = 0; i < 32; i++) { + SysInfoMetricNames[`cpu:${i}`] = `CPU[${i}] %`; +} + +function convertWaveEventToDataItem(event: WaveEvent): DataItem { + const eventData: TimeSeriesData = event.data; + if (eventData == null || eventData.ts == null || eventData.values == null) { + return null; + } + const dataItem = { ts: eventData.ts }; + for (const key in eventData.values) { + dataItem[key] = eventData.values[key]; + } + return dataItem; +} + +class CpuPlotViewModel { + viewType: string; + blockAtom: jotai.Atom; + termMode: jotai.Atom; + htmlElemFocusRef: React.RefObject; + blockId: string; + viewIcon: jotai.Atom; + viewText: jotai.Atom; + viewName: jotai.Atom; + dataAtom: jotai.PrimitiveAtom>; + addDataAtom: jotai.WritableAtom; + incrementCount: jotai.WritableAtom>; + loadingAtom: jotai.PrimitiveAtom; + numPoints: jotai.Atom; + metrics: jotai.Atom; + connection: jotai.Atom; + manageConnection: jotai.Atom; + connStatus: jotai.Atom; + + constructor(blockId: string) { + this.viewType = "cpuplot"; + this.blockId = blockId; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.addDataAtom = jotai.atom(null, (get, set, points) => { + const targetLen = get(this.numPoints) + 1; + let data = get(this.dataAtom); + try { + if (data.length > targetLen) { + data = data.slice(data.length - targetLen); + } + if (data.length < targetLen) { + const defaultData = this.getDefaultData(); + data = [...defaultData.slice(defaultData.length - targetLen + data.length), ...data]; + } + const newData = [...data.slice(points.length), ...points]; + set(this.dataAtom, newData); + } catch (e) { + console.log("Error adding data to cpuplot", e); + } + }); + this.manageConnection = jotai.atom(true); + this.loadingAtom = jotai.atom(true); + this.numPoints = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const metaNumPoints = blockData?.meta?.["graph:numpoints"]; + if (metaNumPoints == null || metaNumPoints <= 0) { + return DefaultNumPoints; + } + return metaNumPoints; + }); + this.metrics = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const metrics = blockData?.meta?.["graph:metrics"]; + if (metrics == null || !Array.isArray(metrics)) { + return ["cpu"]; + } + return metrics; + }); + this.viewIcon = jotai.atom((get) => { + return "chart-line"; // should not be hardcoded + }); + this.viewName = jotai.atom((get) => { + return "CPU %"; // should not be hardcoded + }); + this.incrementCount = jotai.atom(null, async (get, set) => { + const meta = get(this.blockAtom).meta; + const count = meta.count ?? 0; + await RpcApi.SetMetaCommand(WindowRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { count: count + 1 }, + }); + }); + this.connection = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const connValue = blockData?.meta?.connection; + if (util.isBlank(connValue)) { + return "local"; + } + return connValue; + }); + this.dataAtom = jotai.atom(this.getDefaultData()); + this.loadInitialData(); + this.connStatus = jotai.atom((get) => { + const blockData = get(this.blockAtom); + const connName = blockData?.meta?.connection; + const connAtom = getConnStatusAtom(connName); + return get(connAtom); + }); + } + + async loadInitialData() { + globalStore.set(this.loadingAtom, true); + try { + const numPoints = globalStore.get(this.numPoints); + const connName = globalStore.get(this.connection); + const initialData = await RpcApi.EventReadHistoryCommand(WindowRpcClient, { + event: "sysinfo", + scope: connName, + maxitems: numPoints, + }); + if (initialData == null) { + return; + } + const initialDataItems: DataItem[] = initialData.map(convertWaveEventToDataItem); + globalStore.set(this.addDataAtom, initialDataItems); + } catch (e) { + console.log("Error loading initial data for cpuplot", e); + } finally { + globalStore.set(this.loadingAtom, false); + } + } + + getDefaultData(): Array { + // set it back one to avoid backwards line being possible + const numPoints = globalStore.get(this.numPoints); + const currentTime = Date.now() - 1000; + const points: DataItem[] = []; + for (let i = numPoints; i > -1; i--) { + points.push({ ts: currentTime - i * 1000 }); + } + return points; + } +} + +function makeCpuPlotViewModel(blockId: string): CpuPlotViewModel { + const cpuPlotViewModel = new CpuPlotViewModel(blockId); + return cpuPlotViewModel; +} + +const plotColors = ["#58C142", "#FFC107", "#FF5722", "#2196F3", "#9C27B0", "#00BCD4", "#FFEB3B", "#795548"]; + +type CpuPlotViewProps = { + blockId: string; + model: CpuPlotViewModel; +}; + +function CpuPlotView({ model, blockId }: CpuPlotViewProps) { + const connName = jotai.useAtomValue(model.connection); + const lastConnName = React.useRef(connName); + const connStatus = jotai.useAtomValue(model.connStatus); + const addPlotData = jotai.useSetAtom(model.addDataAtom); + const loading = jotai.useAtomValue(model.loadingAtom); + + React.useEffect(() => { + if (connStatus?.status != "connected") { + return; + } + if (lastConnName.current !== connName) { + lastConnName.current = connName; + model.loadInitialData(); + } + const unsubFn = waveEventSubscribe({ + eventType: "sysinfo", + scope: connName, + handler: (event) => { + const loading = globalStore.get(model.loadingAtom); + if (loading) { + return; + } + const dataItem = convertWaveEventToDataItem(event); + addPlotData([dataItem]); + }, + }); + return () => { + unsubFn(); + }; + }, [connName]); + React.useEffect(() => {}, [connName]); + if (connStatus?.status != "connected") { + return null; + } + if (loading) { + return null; + } + return ; +} + +const CpuPlotViewInner = React.memo(({ model }: CpuPlotViewProps) => { + const containerRef = React.useRef(); + const plotData = jotai.useAtomValue(model.dataAtom); + const parentHeight = useHeight(containerRef); + const parentWidth = useWidth(containerRef); + const yvals = jotai.useAtomValue(model.metrics); + + React.useEffect(() => { + const marks: Plot.Markish[] = []; + marks.push( + () => htl.svg` + + + + + ` + ); + if (yvals.length == 0) { + // nothing + } else if (yvals.length == 1) { + marks.push( + Plot.lineY(plotData, { + stroke: plotColors[0], + strokeWidth: 2, + x: "ts", + y: yvals[0], + }) + ); + marks.push( + Plot.areaY(plotData, { + fill: "url(#gradient)", + x: "ts", + y: yvals[0], + }) + ); + } else { + let idx = 0; + for (const yval of yvals) { + marks.push( + Plot.lineY(plotData, { + stroke: plotColors[idx % plotColors.length], + strokeWidth: 1, + x: "ts", + y: yval, + }) + ); + idx++; + } + } + const plot = Plot.plot({ + x: { grid: true, label: "time", tickFormat: (d) => `${dayjs.unix(d / 1000).format("HH:mm:ss")}` }, + y: { label: "%", domain: [0, 100] }, + width: parentWidth, + height: parentHeight, + marks: marks, + }); + + if (plot !== undefined) { + containerRef.current.append(plot); + } + + return () => { + if (plot !== undefined) { + plot.remove(); + } + }; + }, [plotData, parentHeight, parentWidth]); + + return
; +}); + +export { CpuPlotView, CpuPlotViewModel, makeCpuPlotViewModel }; diff --git a/frontend/app/view/helpview/helpview.less b/frontend/app/view/helpview/helpview.less new file mode 100644 index 000000000..0c5048f47 --- /dev/null +++ b/frontend/app/view/helpview/helpview.less @@ -0,0 +1,17 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.help-view { + width: 100%; + padding: 0 5px; + + .title { + font-weight: bold; + } + + code { + font: var(--fixed-font); + background-color: var(--highlight-bg-color); + padding: 0 5px; + } +} diff --git a/frontend/app/view/helpview/helpview.tsx b/frontend/app/view/helpview/helpview.tsx new file mode 100644 index 000000000..48c804f3a --- /dev/null +++ b/frontend/app/view/helpview/helpview.tsx @@ -0,0 +1,214 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Markdown } from "@/app/element/markdown"; +import { globalStore } from "@/app/store/global"; +import { Atom, atom, PrimitiveAtom } from "jotai"; +import "./helpview.less"; + +const helpText = ` +## Blocks +Every individual Component is contained in its own block. These can be added, removed, moved and resized. Each block has its own header which can be right clicked to reveal more operations you can do with that block. + +### How to Add a Block +Adding a block can be done using the widget bar on the right hand side of the window. This will add a block of the selected type to the current tab. + +### How to Close a Block +Blocks can be closed by clicking the ✕ button on the right side of the header. Alternatively, the currently focused block can be closed by pressing \`Cmd + w\`. + +### How to Navigate Blocks +At most, it is possible to have one block be focused. Depending on the type of block, this allows you to directly interact with the content in that block. A focused block is always outlined with a distinct border. A block may be focused by clicking on it. Alternatively, you can change the focused block by pressing Ctrl + Shift + ↑, Ctrl + Shift + ↓, Ctrl + Shift + ←, or Ctrl + Shift + →to navigate relative to the currently selected block. +1 +### How to Magnify Blocks +Magnifying a block will pop the block out in front of everything else. You can magnify using the header icon, or with \`Cmd + m\`. + +### How to Reorganize Blocks +By dragging and dropping their headers, blocks can be moved to different locations in the layout. This effectively allows you to reorganize your screen however you see fit. When dragging, you will see a preview of the block that is being dragged. When the block is over a valid drop point, the area where it would be moved to will turn green. Releasing the click will place the block there and reflow the other blocks around it. If you see a green box cover half of two different blocks, the drop will place the block between the two. If you see the green box cover half of one block at the edge of the screen, the block will be placed between that block and the edge of the screen. If you see the green box cover one block entirely, the two blocks will swap locations. + +### How to Resize Blocks +Hovering the mouse between two blocks changes your cursor to ↔ and reveals a green line dividing the blocks. By dragging and dropping this green line, you are able to resize the blocks adjacent to it. + +## Types of Blocks + +### Term +The usual terminal you know and love. We add a few plugins via the \`wsh\` command that you can read more about further below. + +### Preview +Preview is the generic type of block used for viewing files. This can take many different forms based on the type of file being viewed. +You can use \`wsh view [path]\` from any Wave terminal window to open a preview block with the contents of the specified path (e.g. \`wsh view .\` or \`wsh view ~/myimage.jpg\`). + +#### Directory +When looking at a directory, preview will show a file viewer much like MacOS' *Finder* application or Windows' *File Explorer* application. This variant is slightly more geared toward software development with the focus on seeing what is shown by the \`ls -alh\` command. + +##### View a New File +The simplest way to view a new file is to double click its row in the file viewer. Alternatively, while the block is focused, you can use the ↑ and ↓ arrow keys to select a row and press enter to preview the associated file. + +##### View the Parent Directory +In the directory view, this is as simple as opening the \`..\` file as if it were a regular file. This can be done with the method above. You can also use the keyboard shortcut \`Cmd + ArrowUp\`. + +##### Navigate Back and Forward +When looking at a file, you can navigate back by clicking the back button in the block header or the keyboard shortcut \`Cmd + ArrowLeft\`. You can always navigate back and forward using \`Cmd + ArrowLeft\` and \`Cmd + ArrowRight\`. + +##### Filter the List of Files +While the block is focused, you can filter by filename by typing a substring of the filename you're working for. To clear the filter, you can click the ✕ on the filter dropdown or press esc. + +##### Sort by a File Column +To sort a file by a specific column, click on the header for that column. If you click the header again, it will reverse the sort order. + +##### Hide and Show Hidden Files +At the right of the block header, there is an 👁️ button. Clicking this button hides and shows hidden files. + +##### Refresh the Directory +At the right of the block header, there is a refresh button. Clicking this button refreshes the directory contents. + +##### Navigate to Common Directories +At the left of the block header, there is a file icon. Clicking and holding on this icon opens a menu where you can select a common folder to navigate to. The available options are *Home*, *Desktop*, *Downloads*, and *Root*. + +##### Open a New Terminal in the Current Directory +If you right click the header of the block (alternatively, click the gear icon), one of the menu items listed is **Open Terminal in New Block**. This will create a new terminal block at your current directory. + +##### Open a New Terminal in a Child Directory +If you want to open a terminal for a child directory instead, you can right click on that file's row to get the **Open Terminal in New Block** option. Clicking this will open a terminal at that directory. Note that this option is only available for children that are directories. + +##### Open a New Preview for a Child +To open a new Preview Block for a Child, you can right click on that file's row and select the **Open Preview in New Block** option. + +#### Markdown +Opening a markdown file will bring up a view of the rendered markdown. These files cannot be edited in the preview at this time. + +#### Images/Media +Opening a picture will bring up the image of that picture. Opening a video will bring up a player that lets you watch the video. + +### Codeedit +Opening most text files will open Codeedit to either view or edit the file. It is technically part of the Preview block, but it is important enough to be singled out. +After opening a codeedit block, it is often useful to magnify it (\`Cmd + m\`) to get a larger view. You can then +use the hotkeys below to switch to edit mode, make your edits, save, and then use \`Cmd + w\` to close the block (all without using the mouse!). + +#### Switch to Edit Mode +To switch to edit mode, click the edit button to the right of the header. This lets you edit the file contents with a regular monaco editor. +You can also switch to edit mode by pressing \`Cmd + e\`. + +#### Save an Edit +Once an edit has been made in **edit mode**, click the save button to the right of the header to save the contents. +You can also save by pressing \`Cmd + s\`. + +#### Exit Edit Mode Without Saving +To exit **edit mode** without saving, click the cancel button to the right of the header. +You can also exit without saving by pressing \`Cmd + r\`. + +### AI + +#### How to Ask an LLM a Question +Asking a question is as simple as typing a message in the prompt and pressing enter. By default, we forward messages to the *gpt-4o-mini* model through our server. + +#### How To Change The Model +See *settings help* for more info on how to configure your model. + +### Web +The Web block is basically a simple web browser. The forward and backwards navigation have been added to the header. +You can use \`wsh\` to interact with the web block's URL (see the wsh section below). + +### Cpu % +A small plot displaying the % of CPU in use over time. This is an example of a block that is capable of plotting streamed data. We plan to make this more generic in the future. + +## Tabs +Tabs are ways to organize your blocks into separate screens. They mostly work the way you're used to in other apps. + +### Create a New Tab +A tab can be created by clicking the plus button to the right of your currently existing tabs + +### Delete a Tab +Hovering a tab reveals an ✕ button to the right side of it. Clicking it removes the tab. Note that this will also remove the instances of the blocks it contains. + +### Change a Tab Name +Double clicking the current tab name makes it possible to change the name of your tab. You are limited to 10 glyphs in your tab name. Note that we say glyphs because it is possible to use multiple-character glyphs including emojis in your tab name. + +### Reorganize Tabs +Tabs can be reorganized by dragging and dropping them to the left and right of other tabs. + +## Theming +It is possible to style each tab individually. This is most-easily done by right clicking on your tab and going to the background menu. From there, you can select from five different pre-made styles. + +It is possible to get more fine-grained control of the styles as well. See *settings help* for more info. + +## wsh command + +The wsh command is always available from wave terminal windows. It is a powerful tool for interacting with Wave blocks and can bridge data between your CLI and the widget GUIs. + +### view +You can open a preview block with the contents of any file or directory by running: + +\`\`\` +wsh view [path] +\`\`\` + +You can use this command to easily preview images, markdown files, and directories. For code/text files this will open +a codeedit block which you can use to quickly edit the file using Wave's embedded graphical editor. + +### edit + +\`\`\` +wsh editor [path] +\`\`\` + +This will open up codeedit for the specified file. This is useful for quickly editing files on a local or remote machine in our graphical editor. This command will wait until the file is closed before exiting (unlike \`view\`) so you can set your \`$EDITOR\` to \`wsh edit\` for a seamless experience. You can combine this with a \`-m\` flag to open the editor in magnified mode. + +### getmeta + +You can view the metadata of any block by running: + +\`\`\` +wsh getmeta [blockid] +\`\`\` + +This is especially useful for preview and web blocks as you can see the file or url that they are pointing to and use that in your CLI scripts. + +### setmeta + +You can update any metadata key value pair for blocks (and tabs) by using the setmeta command: + +\`\`\` +wsh setmeta [blockid] [key]=[value] +wsh setmeta [blockid] file=~/myfile.txt +wsh setmeta [blockid] url=https://waveterm.dev/ +\`\`\` + +You can get block and tab ids by right clicking on the appropriate block and selecting "Copy BlockId". When you +update the metadata for a preview or web block you'll see the changes reflected instantly in the block. + +Other useful metadata values to override block titles, icons, colors, themes, etc. can be found in the documentation. + +`; + +class HelpViewModel implements ViewModel { + viewType: string; + showTocAtom: PrimitiveAtom; + endIconButtons: Atom; + + constructor() { + this.viewType = "help"; + this.showTocAtom = atom(false); + this.endIconButtons = atom([ + { + elemtype: "iconbutton", + icon: "book", + title: "Table of Contents", + click: () => this.showTocToggle(), + }, + ] as IconButtonDecl[]); + } + + showTocToggle() { + globalStore.set(this.showTocAtom, !globalStore.get(this.showTocAtom)); + } +} + +function makeHelpViewModel() { + return new HelpViewModel(); +} + +function HelpView({ model }: { model: HelpViewModel }) { + return ; +} + +export { HelpView, HelpViewModel, makeHelpViewModel }; diff --git a/frontend/app/view/plotview/plotview.less b/frontend/app/view/plotview/plotview.less new file mode 100644 index 000000000..9f12d37a4 --- /dev/null +++ b/frontend/app/view/plotview/plotview.less @@ -0,0 +1,22 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.plot-view { + width: 100%; + + .plot-window { + display: flex; + justify-content: center; + } + + .plot-config { + width: 100%; + margin: 0; + padding: 0; + resize: none; + height: 50vh; + background-color: var(--panel-bg-color); + color: var(--main-text-color); + font: var(--fixed-font); + } +} diff --git a/frontend/app/view/plotview/plotview.tsx b/frontend/app/view/plotview/plotview.tsx new file mode 100644 index 000000000..077af2659 --- /dev/null +++ b/frontend/app/view/plotview/plotview.tsx @@ -0,0 +1,136 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/element/button"; +import { WaveModal } from "@/element/modal"; +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import * as React from "react"; + +import "./plotview.less"; + +function PlotWindow() { + return
; +} + +function PlotConfig() { + return ; +} + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + +function evalAsync(Plot: any, d3: any, funcText: string): Promise { + return new Promise((resolve, reject) => { + new AsyncFunction( + "resolve", + "reject", + "Plot", + "d3", + `try { await ${funcText}; resolve(); } catch(e) { reject(e); } }` + )(resolve, reject, Plot, d3); + }); +} + +function PlotView() { + const containerRef = React.useRef(); + const [plotDef, setPlotDef] = React.useState(); + const [savedDef, setSavedDef] = React.useState(); + const [modalUp, setModalUp] = React.useState(false); + /* + const [data, setData] = React.useState(); + + React.useEffect(() => { + d3.csv("/plotdata/congress.csv", d3.autoType).then(setData); + }, []); + */ + + React.useEffect(() => { + // replace start + /* + d3.csv("/plotdata/congress.csv", d3.autoType).then((out) => data = out); + return Plot.plot({ + aspectRatio: 1, + x: { label: "Age (years)" }, + y: { + grid: true, + label: "← Women · Men →", + labelAnchor: "center", + tickFormat: Math.abs, + }, + marks: [ + Plot.dot( + data, + Plot.stackY2({ + x: (d) => 2023 - d.birthday.getUTCFullYear(), + y: (d) => (d.gender === "M" ? 1 : -1), + fill: "gender", + title: "full_name", + }) + ), + Plot.ruleY([0]), + ], + }); + */ + // replace end + let plot; + let plotErr; + try { + console.log(plotDef); + plot = new Function("Plot", "d3", plotDef)(Plot, d3); + //plot = new Function("Plot", "data", "d3", plotDef)(Plot, data, d3); + //evalAsync(Plot, d3, plotDef).then((out) => (plot = out)); + } catch (e) { + plotErr = e; + console.log("error: ", e); + return; + } + console.log(plot); + + if (plot !== undefined) { + containerRef.current.append(plot); + } else { + // todo + } + + return () => { + if (plot !== undefined) { + plot.remove(); + } + }; + }, [plotDef]); + + const handleOpen = React.useCallback(() => { + setSavedDef(plotDef); + setModalUp(true); + }, []); + + const handleCancel = React.useCallback(() => { + setPlotDef(savedDef); + setModalUp(false); + }, []); + + const handleSave = React.useCallback(() => { + setModalUp(false); + }, []); + + return ( +
+ +
+ {modalUp && ( + + + ); + } +); + +const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { + const { messages, sendMessage } = model.useWaveAi(); + const waveaiRef = useRef(null); + const chatWindowRef = useRef(null); + const osRef = useRef(null); + const inputRef = useRef(null); + + const [value, setValue] = useState(""); + const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); + + const termFontSize: number = 14; + const windowDims = useDimensions(chatWindowRef); + const msgWidths = {}; + const locked = useAtomValue(model.locked); + msgWidths["--aichat-msg-width"] = windowDims.width * 0.85; + + // a weird workaround to initialize ansynchronously + useEffect(() => { + model.populateMessages(); + }, []); + + const handleTextAreaChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + + const updatePreTagOutline = (clickedPre?: HTMLElement | null) => { + const pres = chatWindowRef.current?.querySelectorAll("pre"); + if (!pres) return; + + pres.forEach((preElement, idx) => { + if (preElement === clickedPre) { + setSelectedBlockIdx(idx); + } else { + preElement.style.outline = "none"; + } + }); + + if (clickedPre) { + clickedPre.style.outline = outline; + } + }; + + useEffect(() => { + if (selectedBlockIdx !== null) { + const pres = chatWindowRef.current?.querySelectorAll("pre"); + if (pres && pres[selectedBlockIdx]) { + pres[selectedBlockIdx].style.outline = outline; + } + } + }, [selectedBlockIdx]); + + const handleTextAreaMouseDown = () => { + updatePreTagOutline(); + setSelectedBlockIdx(null); + }; + + const handleEnterKeyPressed = useCallback(() => { + // using globalStore to avoid potential timing problems + // useAtom means the component must rerender once before + // the unlock is detected. this automatically checks on the + // callback firing instead + const locked = globalStore.get(model.locked); + if (locked || value === "") return; + + sendMessage(value); + setValue(""); + setSelectedBlockIdx(null); + }, [messages, value]); + + const handleContainerClick = (event: React.MouseEvent) => { + inputRef.current?.focus(); + + const target = event.target as HTMLElement; + if ( + target.closest(".copy-button") || + target.closest(".fa-square-terminal") || + target.closest(".waveai-input") + ) { + return; + } + + const pre = target.closest("pre"); + updatePreTagOutline(pre); + }; + + const updateScrollTop = () => { + const pres = chatWindowRef.current?.querySelectorAll("pre"); + if (!pres || selectedBlockIdx === null) return; + + const block = pres[selectedBlockIdx]; + if (!block || !osRef.current?.osInstance()) return; + + const { viewport, scrollOffsetElement } = osRef.current?.osInstance().elements(); + const chatWindowTop = scrollOffsetElement.scrollTop; + const chatWindowHeight = chatWindowRef.current.clientHeight; + const chatWindowBottom = chatWindowTop + chatWindowHeight; + const elemTop = block.offsetTop; + const elemBottom = elemTop + block.offsetHeight; + const elementIsInView = elemBottom <= chatWindowBottom && elemTop >= chatWindowTop; + + if (!elementIsInView) { + let scrollPosition; + if (elemBottom > chatWindowBottom) { + scrollPosition = elemTop - chatWindowHeight + block.offsetHeight + 15; + } else if (elemTop < chatWindowTop) { + scrollPosition = elemTop - 15; + } + viewport.scrollTo({ + behavior: "auto", + top: scrollPosition, + }); + } + }; + + const shouldSelectCodeBlock = (key: "ArrowUp" | "ArrowDown") => { + const textarea = inputRef.current; + const cursorPosition = textarea?.selectionStart || 0; + const textBeforeCursor = textarea?.value.slice(0, cursorPosition) || ""; + + return ( + (textBeforeCursor.indexOf("\n") === -1 && cursorPosition === 0 && key === "ArrowUp") || + selectedBlockIdx !== null + ); + }; + + const handleArrowUpPressed = (e: React.KeyboardEvent) => { + if (shouldSelectCodeBlock("ArrowUp")) { + e.preventDefault(); + const pres = chatWindowRef.current?.querySelectorAll("pre"); + let blockIndex = selectedBlockIdx; + if (!pres) return; + if (blockIndex === null) { + setSelectedBlockIdx(pres.length - 1); + } else if (blockIndex > 0) { + blockIndex--; + setSelectedBlockIdx(blockIndex); + } + updateScrollTop(); + } + }; + + const handleArrowDownPressed = (e: React.KeyboardEvent) => { + if (shouldSelectCodeBlock("ArrowDown")) { + e.preventDefault(); + const pres = chatWindowRef.current?.querySelectorAll("pre"); + let blockIndex = selectedBlockIdx; + if (!pres) return; + if (blockIndex === null) return; + if (blockIndex < pres.length - 1 && blockIndex >= 0) { + setSelectedBlockIdx(++blockIndex); + updateScrollTop(); + } else { + inputRef.current.focus(); + setSelectedBlockIdx(null); + } + updateScrollTop(); + } + }; + + const handleTextAreaKeyDown = (e: React.KeyboardEvent) => { + const waveEvent = adaptFromReactOrNativeKeyEvent(e); + if (checkKeyPressed(waveEvent, "Enter")) { + e.preventDefault(); + handleEnterKeyPressed(); + } else if (checkKeyPressed(waveEvent, "ArrowUp")) { + handleArrowUpPressed(e); + } else if (checkKeyPressed(waveEvent, "ArrowDown")) { + handleArrowDownPressed(e); + } + }; + + let buttonClass = "waveai-submit-button"; + let buttonIcon = makeIconClass("arrow-up", false); + let buttonTitle = "run"; + if (locked) { + buttonClass = "waveai-submit-button stop"; + buttonIcon = makeIconClass("stop", false); + buttonTitle = "stop"; + } + const handleButtonPress = useCallback(() => { + if (locked) { + model.cancel = true; + } else { + handleEnterKeyPressed(); + } + }, [locked, handleEnterKeyPressed]); + + return ( +
+ +
+
+ +
+ +
+
+ ); +}; + +export { makeWaveAiViewModel, WaveAi }; diff --git a/frontend/app/view/webview/webview.less b/frontend/app/view/webview/webview.less new file mode 100644 index 000000000..74377d9e3 --- /dev/null +++ b/frontend/app/view/webview/webview.less @@ -0,0 +1,9 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.webview { + height: 100%; + width: 100%; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx new file mode 100644 index 000000000..a8ebc17ed --- /dev/null +++ b/frontend/app/view/webview/webview.tsx @@ -0,0 +1,458 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getApi, openLink } from "@/app/store/global"; +import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; +import { NodeModel } from "@/layout/index"; +import { WOS, globalStore } from "@/store/global"; +import * as services from "@/store/services"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import { fireAndForget } from "@/util/util"; +import clsx from "clsx"; +import { WebviewTag } from "electron"; +import * as jotai from "jotai"; +import React, { memo, useEffect, useState } from "react"; +import { debounce } from "throttle-debounce"; +import "./webview.less"; + +export class WebViewModel implements ViewModel { + viewType: string; + blockId: string; + blockAtom: jotai.Atom; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + viewText: jotai.Atom; + url: jotai.PrimitiveAtom; + urlInputFocused: jotai.PrimitiveAtom; + isLoading: jotai.PrimitiveAtom; + urlWrapperClassName: jotai.PrimitiveAtom; + refreshIcon: jotai.PrimitiveAtom; + webviewRef: React.RefObject; + urlInputRef: React.RefObject; + nodeModel: NodeModel; + + constructor(blockId: string, nodeModel: NodeModel) { + this.nodeModel = nodeModel; + this.viewType = "web"; + this.blockId = blockId; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + + this.url = jotai.atom(); + this.urlWrapperClassName = jotai.atom(""); + this.urlInputFocused = jotai.atom(false); + this.isLoading = jotai.atom(false); + this.refreshIcon = jotai.atom("rotate-right"); + this.viewIcon = jotai.atom("globe"); + this.viewName = jotai.atom("Web"); + this.urlInputRef = React.createRef(); + this.webviewRef = React.createRef(); + + this.viewText = jotai.atom((get) => { + let url = get(this.blockAtom)?.meta?.url || ""; + const currUrl = get(this.url); + if (currUrl !== undefined) { + url = currUrl; + } + return [ + { + elemtype: "iconbutton", + icon: "chevron-left", + click: this.handleBack.bind(this), + disabled: this.shouldDisabledBackButton(), + }, + { + elemtype: "iconbutton", + icon: "chevron-right", + click: this.handleForward.bind(this), + disabled: this.shouldDisabledForwardButton(), + }, + { + elemtype: "div", + className: clsx("block-frame-div-url", get(this.urlWrapperClassName)), + onMouseOver: this.handleUrlWrapperMouseOver.bind(this), + onMouseOut: this.handleUrlWrapperMouseOut.bind(this), + children: [ + { + elemtype: "input", + value: url, + ref: this.urlInputRef, + className: "url-input", + onChange: this.handleUrlChange.bind(this), + onKeyDown: this.handleKeyDown.bind(this), + onFocus: this.handleFocus.bind(this), + onBlur: this.handleBlur.bind(this), + }, + { + elemtype: "iconbutton", + icon: get(this.refreshIcon), + click: this.handleRefresh.bind(this), + }, + ], + }, + ] as HeaderElem[]; + }); + } + + /** + * Whether the back button in the header should be disabled. + * @returns True if the WebView cannot go back or if the WebView call fails. False otherwise. + */ + shouldDisabledBackButton() { + try { + return !this.webviewRef.current?.canGoBack(); + } catch (_) {} + return true; + } + + /** + * Whether the forward button in the header should be disabled. + * @returns True if the WebView cannot go forward or if the WebView call fails. False otherwise. + */ + shouldDisabledForwardButton() { + try { + return !this.webviewRef.current?.canGoForward(); + } catch (_) {} + return true; + } + + handleUrlWrapperMouseOver(e: React.MouseEvent) { + const urlInputFocused = globalStore.get(this.urlInputFocused); + if (e.type === "mouseover" && !urlInputFocused) { + globalStore.set(this.urlWrapperClassName, "hovered"); + } + } + + handleUrlWrapperMouseOut(e: React.MouseEvent) { + const urlInputFocused = globalStore.get(this.urlInputFocused); + if (e.type === "mouseout" && !urlInputFocused) { + globalStore.set(this.urlWrapperClassName, ""); + } + } + + handleBack(e?: React.MouseEvent) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + this.webviewRef.current?.goBack(); + } + + handleForward(e?: React.MouseEvent) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + this.webviewRef.current?.goForward(); + } + + handleRefresh(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + try { + if (this.webviewRef.current) { + if (globalStore.get(this.isLoading)) { + this.webviewRef.current.stop(); + } else { + this.webviewRef.current.reload(); + } + } + } catch (e) { + console.warn("handleRefresh catch", e); + } + } + + handleUrlChange(event: React.ChangeEvent) { + globalStore.set(this.url, event.target.value); + } + + handleKeyDown(event: React.KeyboardEvent) { + const waveEvent = adaptFromReactOrNativeKeyEvent(event); + if (checkKeyPressed(waveEvent, "Enter")) { + const url = globalStore.get(this.url); + this.loadUrl(url, "enter"); + this.urlInputRef.current?.blur(); + return; + } + if (checkKeyPressed(waveEvent, "Escape")) { + this.webviewRef.current?.focus(); + } + } + + handleFocus(event: React.FocusEvent) { + globalStore.set(this.urlWrapperClassName, "focused"); + globalStore.set(this.urlInputFocused, true); + this.urlInputRef.current.focus(); + event.target.select(); + } + + handleBlur(event: React.FocusEvent) { + globalStore.set(this.urlWrapperClassName, ""); + globalStore.set(this.urlInputFocused, false); + } + + /** + * Update the URL in the state when a navigation event has occurred. + * @param url The URL that has been navigated to. + */ + handleNavigate(url: string) { + services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url }); + globalStore.set(this.url, url); + } + + ensureUrlScheme(url: string) { + if (/^(http|https):/.test(url)) { + // If the URL starts with http: or https:, return it as is + return url; + } + + // Check if the URL looks like a local URL + const isLocal = /^(localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?$/.test(url.split("/")[0]); + + if (isLocal) { + // If it is a local URL, ensure it has http:// scheme + return `http://${url}`; + } + + // Check if the URL looks like a domain + const domainRegex = /^[a-z0-9.-]+\.[a-z]{2,}$/i; + const isDomain = domainRegex.test(url.split("/")[0]); + + if (isDomain) { + // If it looks like a domain, ensure it has https:// scheme + return `https://${url}`; + } + + // Otherwise, treat it as a search query + return `https://www.google.com/search?q=${encodeURIComponent(url)}`; + } + + normalizeUrl(url: string) { + if (!url) { + return url; + } + + try { + const parsedUrl = new URL(url); + if (parsedUrl.hostname.startsWith("www.")) { + parsedUrl.hostname = parsedUrl.hostname.slice(4); + } + return parsedUrl.href; + } catch (e) { + return url.replace(/\/+$/, "") + "/"; + } + } + + /** + * Load a new URL in the webview. + * @param newUrl The new URL to load in the webview. + */ + loadUrl(newUrl: string, reason: string) { + const nextUrl = this.ensureUrlScheme(newUrl); + console.log("webview loadUrl", reason, nextUrl, "cur=", this.webviewRef?.current.getURL()); + if (newUrl != nextUrl) { + globalStore.set(this.url, nextUrl); + } + if (!this.webviewRef.current) { + return; + } + if (this.webviewRef.current.getURL() != nextUrl) { + this.webviewRef.current.loadURL(nextUrl); + } + } + + /** + * Get the current URL from the state. + * @returns The URL from the state. + */ + getUrl() { + return globalStore.get(this.url); + } + + setRefreshIcon(refreshIcon: string) { + globalStore.set(this.refreshIcon, refreshIcon); + } + + setIsLoading(isLoading: boolean) { + globalStore.set(this.isLoading, isLoading); + } + + giveFocus(): boolean { + const ctrlShiftState = globalStore.get(getSimpleControlShiftAtom()); + if (ctrlShiftState) { + // this is really weird, we don't get keyup events from webview + const unsubFn = globalStore.sub(getSimpleControlShiftAtom(), () => { + const state = globalStore.get(getSimpleControlShiftAtom()); + if (!state) { + unsubFn(); + const isStillFocused = globalStore.get(this.nodeModel.isFocused); + if (isStillFocused) { + this.webviewRef.current?.focus(); + } + } + }); + return false; + } + this.webviewRef.current?.focus(); + return true; + } + + keyDownHandler(e: WaveKeyboardEvent): boolean { + if (checkKeyPressed(e, "Cmd:l")) { + this.urlInputRef?.current?.focus(); + this.urlInputRef?.current?.select(); + return true; + } + if (checkKeyPressed(e, "Cmd:r")) { + this.webviewRef?.current?.reload(); + return true; + } + if (checkKeyPressed(e, "Cmd:ArrowLeft")) { + this.handleBack(null); + return true; + } + if (checkKeyPressed(e, "Cmd:ArrowRight")) { + this.handleForward(null); + return true; + } + return false; + } + + getSettingsMenuItems() { + return [ + { + label: this.webviewRef.current?.isDevToolsOpened() ? "Close DevTools" : "Open DevTools", + click: async () => { + if (this.webviewRef.current) { + if (this.webviewRef.current.isDevToolsOpened()) { + this.webviewRef.current.closeDevTools(); + } else { + this.webviewRef.current.openDevTools(); + } + } + }, + }, + ]; + } +} + +function makeWebViewModel(blockId: string, nodeModel: NodeModel): WebViewModel { + const webviewModel = new WebViewModel(blockId, nodeModel); + return webviewModel; +} + +interface WebViewProps { + blockId: string; + model: WebViewModel; +} + +const WebView = memo(({ model }: WebViewProps) => { + const blockData = jotai.useAtomValue(model.blockAtom); + const metaUrl = blockData?.meta?.url; + const metaUrlRef = React.useRef(metaUrl); + + // The initial value of the block metadata URL when the component first renders. Used to set the starting src value for the webview. + const [metaUrlInitial] = useState(metaUrl); + + const [webContentsId, setWebContentsId] = useState(null); + const [domReady, setDomReady] = useState(false); + + useEffect(() => { + if (model.webviewRef.current && domReady) { + const wcId = model.webviewRef.current.getWebContentsId?.(); + if (wcId) { + setWebContentsId(wcId); + } + } + }, [model.webviewRef.current, domReady]); + + // Load a new URL if the block metadata is updated. + useEffect(() => { + if (metaUrlRef.current != metaUrl) { + metaUrlRef.current = metaUrl; + model.loadUrl(metaUrl, "meta"); + } + }, [metaUrl]); + + useEffect(() => { + const webview = model.webviewRef.current; + + if (webview) { + const navigateListener = (e: any) => { + model.handleNavigate(e.url); + }; + const newWindowHandler = (e: any) => { + e.preventDefault(); + const newUrl = e.detail.url; + console.log("webview new-window event:", newUrl); + fireAndForget(() => openLink(newUrl, true)); + }; + const startLoadingHandler = () => { + model.setRefreshIcon("xmark-large"); + model.setIsLoading(true); + webview.style.backgroundColor = "transparent"; + }; + const stopLoadingHandler = () => { + model.setRefreshIcon("rotate-right"); + model.setIsLoading(false); + debounce(1000, () => { + webview.style.backgroundColor = "white"; + })(); + }; + const failLoadHandler = (e: any) => { + if (e.errorCode === -3) { + console.warn("Suppressed ERR_ABORTED error", e); + } else { + console.error(`Failed to load ${e.validatedURL}: ${e.errorDescription}`); + } + }; + const webviewFocus = () => { + getApi().setWebviewFocus(webview.getWebContentsId()); + model.nodeModel.focusNode(); + }; + const webviewBlur = () => { + getApi().setWebviewFocus(null); + }; + const handleDomReady = () => { + setDomReady(true); + }; + + webview.addEventListener("did-navigate-in-page", navigateListener); + webview.addEventListener("did-navigate", navigateListener); + webview.addEventListener("did-start-loading", startLoadingHandler); + webview.addEventListener("did-stop-loading", stopLoadingHandler); + webview.addEventListener("new-window", newWindowHandler); + webview.addEventListener("did-fail-load", failLoadHandler); + webview.addEventListener("focus", webviewFocus); + webview.addEventListener("blur", webviewBlur); + webview.addEventListener("dom-ready", handleDomReady); + + // Clean up event listeners on component unmount + return () => { + webview.removeEventListener("did-navigate", navigateListener); + webview.removeEventListener("did-navigate-in-page", navigateListener); + webview.removeEventListener("new-window", newWindowHandler); + webview.removeEventListener("did-fail-load", failLoadHandler); + webview.removeEventListener("did-start-loading", startLoadingHandler); + webview.removeEventListener("did-stop-loading", stopLoadingHandler); + webview.removeEventListener("focus", webviewFocus); + webview.removeEventListener("blur", webviewBlur); + webview.removeEventListener("dom-ready", handleDomReady); + }; + } + }, []); + + return ( + + ); +}); + +export { WebView, makeWebViewModel }; diff --git a/frontend/app/workspace/workspace.less b/frontend/app/workspace/workspace.less new file mode 100644 index 000000000..19294d6d8 --- /dev/null +++ b/frontend/app/workspace/workspace.less @@ -0,0 +1,70 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.workspace { + display: flex; + flex-direction: column; + width: 100%; + flex-grow: 1; + overflow: hidden; + + .workspace-tabcontent { + display: flex; + flex-direction: row; + flex-grow: 1; + overflow: hidden; + } + + .workspace-widgets { + display: flex; + flex-direction: column; + width: 50px; + overflow: hidden; + padding-top: 4px; + padding-bottom: 4px; + margin-left: -4px; + user-select: none; + + .widget { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + padding: 8px 2px 8px 0px; + color: var(--secondary-text-color); + font-size: 20px; + overflow: hidden; + border-radius: 4px; + + &:hover:not(.no-hover) { + background-color: var(--highlight-bg-color); + cursor: pointer; + color: white !important; + } + + .widget-icon { + } + + .widget-label { + font-size: 10px; + margin-top: 3px; + width: 100%; + padding: 0 2px; + text-align: center; + white-space: nowrap; + overflow: hidden; + } + } + + .widget-divider { + margin-right: 2px; + height: 2px; + background-color: var(--border-color); + } + + .widget-spacer { + flex-grow: 1; + } + } +} diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx new file mode 100644 index 000000000..5e6692353 --- /dev/null +++ b/frontend/app/workspace/workspace.tsx @@ -0,0 +1,127 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ModalsRenderer } from "@/app/modals/modalsrenderer"; +import { TabBar } from "@/app/tab/tabbar"; +import { TabContent } from "@/app/tab/tabcontent"; +import { atoms, createBlock } from "@/store/global"; +import * as util from "@/util/util"; +import * as jotai from "jotai"; +import * as React from "react"; +import { CenteredDiv } from "../element/quickelems"; + +import { ErrorBoundary } from "@/app/element/errorboundary"; +import "./workspace.less"; + +const iconRegex = /^[a-z0-9-]+$/; + +function keyLen(obj: Object): number { + if (obj == null) { + return 0; + } + return Object.keys(obj).length; +} + +function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] { + if (wmap == null) { + return []; + } + const wlist = Object.values(wmap); + wlist.sort((a, b) => { + return a["display:order"] - b["display:order"]; + }); + return wlist; +} + +const Widgets = React.memo(() => { + const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); + const newWidgetModalVisible = React.useState(false); + const helpWidget: WidgetConfigType = { + icon: "circle-question", + label: "help", + blockdef: { + meta: { + view: "help", + }, + }, + }; + const showHelp = fullConfig?.settings?.["widget:showhelp"] ?? true; + const showDivider = keyLen(fullConfig?.defaultwidgets) > 0 && keyLen(fullConfig?.widgets) > 0; + const defaultWidgets = sortByDisplayOrder(fullConfig?.defaultwidgets); + const widgets = sortByDisplayOrder(fullConfig?.widgets); + return ( +
+ {defaultWidgets.map((data, idx) => ( + + ))} + {showDivider ?
: null} + {widgets?.map((data, idx) => )} + {showHelp ? ( + <> +
+ + + ) : null} +
+ ); +}); + +async function handleWidgetSelect(blockDef: BlockDef) { + createBlock(blockDef); +} + +function isIconValid(icon: string): boolean { + if (util.isBlank(icon)) { + return false; + } + return icon.match(iconRegex) != null; +} + +function getIconClass(icon: string): string { + if (!isIconValid(icon)) { + return "fa fa-regular fa-browser fa-fw"; + } + return `fa fa-solid fa-${icon} fa-fw`; +} + +const Widget = React.memo(({ widget }: { widget: WidgetConfigType }) => { + return ( +
handleWidgetSelect(widget.blockdef)} + title={widget.description || widget.label} + > +
+ +
+ {!util.isBlank(widget.label) ?
{widget.label}
: null} +
+ ); +}); + +const WorkspaceElem = React.memo(() => { + const windowData = jotai.useAtomValue(atoms.waveWindow); + const activeTabId = windowData?.activetabid; + const ws = jotai.useAtomValue(atoms.workspace); + + return ( +
+ +
+ + {activeTabId == "" ? ( + No Active Tab + ) : ( + <> + + + + + )} + +
+
+ ); +}); + +export { WorkspaceElem as Workspace }; diff --git a/frontend/layout/index.ts b/frontend/layout/index.ts new file mode 100644 index 000000000..57ce26312 --- /dev/null +++ b/frontend/layout/index.ts @@ -0,0 +1,70 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TileLayout } from "./lib/TileLayout"; +import { LayoutModel } from "./lib/layoutModel"; +import { + deleteLayoutModelForTab, + getLayoutModelForActiveTab, + getLayoutModelForTab, + getLayoutModelForTabById, + useDebouncedNodeInnerRect, + useLayoutModel, +} from "./lib/layoutModelHooks"; +import { newLayoutNode } from "./lib/layoutNode"; +import type { + ContentRenderer, + LayoutNode, + LayoutTreeAction, + LayoutTreeClearPendingAction, + LayoutTreeCommitPendingAction, + LayoutTreeComputeMoveNodeAction, + LayoutTreeDeleteNodeAction, + LayoutTreeFocusNodeAction, + LayoutTreeInsertNodeAction, + LayoutTreeInsertNodeAtIndexAction, + LayoutTreeMagnifyNodeToggleAction, + LayoutTreeMoveNodeAction, + LayoutTreeResizeNodeAction, + LayoutTreeSetPendingAction, + LayoutTreeStateSetter, + LayoutTreeSwapNodeAction, + NodeModel, + PreviewRenderer, +} from "./lib/types"; +import { DropDirection, LayoutTreeActionType, NavigateDirection } from "./lib/types"; + +export { + deleteLayoutModelForTab, + DropDirection, + getLayoutModelForActiveTab, + getLayoutModelForTab, + getLayoutModelForTabById, + LayoutModel, + LayoutTreeActionType, + NavigateDirection, + newLayoutNode, + TileLayout, + useDebouncedNodeInnerRect, + useLayoutModel, +}; +export type { + ContentRenderer, + LayoutNode, + LayoutTreeAction, + LayoutTreeClearPendingAction, + LayoutTreeCommitPendingAction, + LayoutTreeComputeMoveNodeAction, + LayoutTreeDeleteNodeAction, + LayoutTreeFocusNodeAction, + LayoutTreeInsertNodeAction, + LayoutTreeInsertNodeAtIndexAction, + LayoutTreeMagnifyNodeToggleAction, + LayoutTreeMoveNodeAction, + LayoutTreeResizeNodeAction, + LayoutTreeSetPendingAction, + LayoutTreeStateSetter, + LayoutTreeSwapNodeAction, + NodeModel, + PreviewRenderer, +}; diff --git a/frontend/layout/lib/TileLayout.stories.tsx.archive b/frontend/layout/lib/TileLayout.stories.tsx.archive new file mode 100644 index 000000000..e995e0868 --- /dev/null +++ b/frontend/layout/lib/TileLayout.stories.tsx.archive @@ -0,0 +1,132 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { Meta, StoryObj } from "@storybook/react"; + +import { TileLayout } from "./TileLayout.jsx"; + +import { atom } from "jotai"; +import { useState } from "react"; +import { newLayoutNode } from "./layoutNode.js"; +import "./tilelayout.stories.less"; +import { + LayoutNode, + LayoutTreeActionType, + LayoutTreeInsertNodeAction, + LayoutTreeState, + NodeModel, + WritableLayoutTreeStateAtom, +} from "./types.js"; + +const renderTestData = (data: string) =>
{data}
; + +function newLayoutTreeStateAtom(node: LayoutNode): WritableLayoutTreeStateAtom { + return atom({ rootNode: node } as LayoutTreeState); +} + +function renderContent(nodeModel: NodeModel) { + return ( +
+ {renderTestData(nodeModel.blockId)} +
+ ); +} + +const meta = { + title: "TileLayout", + args: { + layoutTreeStateAtom: newLayoutTreeStateAtom( + newLayoutNode(undefined, undefined, undefined, { + blockId: "Hello world!", + }) + ), + contents: { + renderContent, + renderPreview: renderContent, + tabId: "", + }, + }, + component: TileLayout, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + layoutTreeStateAtom: newLayoutTreeStateAtom( + newLayoutNode(undefined, undefined, undefined, { blockId: "Hello world!" }) + ), + }, +}; + +export const More: Story = { + args: { + layoutTreeStateAtom: newLayoutTreeStateAtom( + newLayoutNode(undefined, undefined, [ + newLayoutNode(undefined, undefined, undefined, { name: "Hello world1!" }), + newLayoutNode(undefined, undefined, undefined, { name: "Hello world2!" }), + newLayoutNode(undefined, undefined, [ + newLayoutNode(undefined, undefined, undefined, { name: "Hello world3!" }), + newLayoutNode(undefined, undefined, undefined, { name: "Hello world4!" }), + ]), + ]) + ), + }, +}; + +const evenMoreRootNode = newLayoutNode(undefined, undefined, [ + newLayoutNode(undefined, undefined, undefined, { name: "Hello world1!" }), + newLayoutNode(undefined, undefined, [ + newLayoutNode(undefined, undefined, undefined, { name: "Hello world2!" }), + newLayoutNode(undefined, undefined, undefined, { name: "Hello world3!" }), + ]), + newLayoutNode(undefined, undefined, [ + newLayoutNode(undefined, undefined, undefined, { name: "Hello world4!" }), + newLayoutNode(undefined, undefined, undefined, { name: "Hello world5!" }), + newLayoutNode(undefined, undefined, [ + newLayoutNode(undefined, undefined, undefined, { name: "Hello world6!" }), + newLayoutNode(undefined, undefined, undefined, { name: "Hello world7!" }), + newLayoutNode(undefined, undefined, undefined, { name: "Hello world8!" }), + ]), + ]), +]); + +export const EvenMore: Story = { + args: { + layoutTreeStateAtom: newLayoutTreeStateAtom(evenMoreRootNode), + }, +}; + +const addNodeAtom = newLayoutTreeStateAtom(evenMoreRootNode); + +export const AddNode: Story = { + render: () => { + const [, dispatch] = useLayoutTreeStateReducerAtom(addNodeAtom); + const [numAddedNodes, setNumAddedNodes] = useState(0); + const dispatchAddNode = () => { + const newNode = newLayoutNode(undefined, undefined, undefined, { + name: "New Node" + numAddedNodes, + }); + const insertNodeAction: LayoutTreeInsertNodeAction = { + type: LayoutTreeActionType.InsertNode, + node: newNode, + }; + dispatch(insertNodeAction); + setNumAddedNodes(numAddedNodes + 1); + }; + return ( +
+
+ +
+ } + contents={meta.args.contents} + /> +
+ ); + }, +}; diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx new file mode 100644 index 000000000..eefdfa03c --- /dev/null +++ b/frontend/layout/lib/TileLayout.tsx @@ -0,0 +1,451 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; +import { toPng } from "html-to-image"; +import { Atom, useAtomValue, useSetAtom } from "jotai"; +import React, { + CSSProperties, + ReactNode, + Suspense, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { DropTargetMonitor, XYCoord, useDrag, useDragLayer, useDrop } from "react-dnd"; +import { debounce, throttle } from "throttle-debounce"; +import { useDevicePixelRatio } from "use-device-pixel-ratio"; +import { LayoutModel } from "./layoutModel"; +import { useNodeModel, useTileLayout } from "./layoutModelHooks"; +import "./tilelayout.less"; +import { + LayoutNode, + LayoutTreeActionType, + LayoutTreeComputeMoveNodeAction, + ResizeHandleProps, + TileLayoutContents, +} from "./types"; +import { determineDropDirection } from "./utils"; + +export interface TileLayoutProps { + /** + * The atom containing the layout tree state. + */ + tabAtom: Atom; + + /** + * callbacks and information about the contents (or styling) of the TileLayout or contents + */ + contents: TileLayoutContents; + + /** + * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook. + * @returns The cursor position relative to the current window. + */ + getCursorPoint?: () => Point; +} + +const DragPreviewWidth = 300; +const DragPreviewHeight = 300; + +function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutProps) { + const layoutModel = useTileLayout(tabAtom, contents); + const overlayTransform = useAtomValue(layoutModel.overlayTransform); + const setActiveDrag = useSetAtom(layoutModel.activeDrag); + const setReady = useSetAtom(layoutModel.ready); + const isResizing = useAtomValue(layoutModel.isResizing); + + const { activeDrag, dragClientOffset } = useDragLayer((monitor) => ({ + activeDrag: monitor.isDragging(), + dragClientOffset: monitor.getClientOffset(), + })); + + useEffect(() => { + setActiveDrag(activeDrag); + }, [setActiveDrag, activeDrag]); + + const checkForCursorBounds = useCallback( + debounce(100, (dragClientOffset: XYCoord) => { + const cursorPoint = dragClientOffset ?? getCursorPoint?.(); + if (cursorPoint && layoutModel.displayContainerRef?.current) { + const displayContainerRect = layoutModel.displayContainerRef.current.getBoundingClientRect(); + const normalizedX = cursorPoint.x - displayContainerRect.x; + const normalizedY = cursorPoint.y - displayContainerRect.y; + if ( + normalizedX <= 0 || + normalizedX >= displayContainerRect.width || + normalizedY <= 0 || + normalizedY >= displayContainerRect.height + ) { + layoutModel.treeReducer({ type: LayoutTreeActionType.ClearPendingAction }); + } + } + }), + [getCursorPoint] + ); + + // Effect to detect when the cursor leaves the TileLayout hit trap so we can remove any placeholders. This cannot be done using pointer capture + // because that conflicts with the DnD layer. + useEffect(() => checkForCursorBounds(dragClientOffset), [dragClientOffset]); + + // Ensure that we don't see any jostling in the layout when we're rendering it the first time. + // `animate` will be disabled until after the transforms have all applied the first time. + const [animate, setAnimate] = useState(false); + useEffect(() => { + setTimeout(() => { + setAnimate(true); + setReady(true); + }, 50); + }, []); + + const gapSizePx = useAtomValue(layoutModel.gapSizePx); + const animationTimeS = useAtomValue(layoutModel.animationTimeS); + const tileStyle = useMemo( + () => + ({ + "--gap-size-px": `${gapSizePx}px`, + "--animation-time-s": `${animationTimeS}s`, + }) as CSSProperties, + [gapSizePx, animationTimeS] + ); + + return ( + +
+
+ + +
+ + +
+
+ ); +} + +export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; + +interface DisplayNodesWrapperProps { + /** + * The layout tree state. + */ + layoutModel: LayoutModel; +} + +const DisplayNodesWrapper = ({ layoutModel }: DisplayNodesWrapperProps) => { + const leafs = useAtomValue(layoutModel.leafs); + + return useMemo( + () => + leafs.map((node) => { + return ; + }), + [leafs] + ); +}; + +interface DisplayNodeProps { + layoutModel: LayoutModel; + /** + * The leaf node object, containing the data needed to display the leaf contents to the user. + */ + node: LayoutNode; +} + +const dragItemType = "TILE_ITEM"; + +/** + * The draggable and displayable portion of a leaf node in a layout tree. + */ +const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { + const nodeModel = useNodeModel(layoutModel, node); + const tileNodeRef = useRef(null); + const previewRef = useRef(null); + const addlProps = useAtomValue(nodeModel.additionalProps); + const devicePixelRatio = useDevicePixelRatio(); + + const [{ isDragging }, drag, dragPreview] = useDrag( + () => ({ + type: dragItemType, + item: () => node, + canDrag: () => !addlProps?.isMagnifiedNode, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [node, addlProps] + ); + + const [previewElementGeneration, setPreviewElementGeneration] = useState(0); + const previewElement = useMemo(() => { + setPreviewElementGeneration(previewElementGeneration + 1); + return ( +
+
+ {layoutModel.renderPreview?.(nodeModel)} +
+
+ ); + }, [devicePixelRatio, nodeModel]); + + const [previewImage, setPreviewImage] = useState(null); + const [previewImageGeneration, setPreviewImageGeneration] = useState(0); + const generatePreviewImage = useCallback(() => { + const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10; + const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10; + if (previewImage !== null && previewElementGeneration === previewImageGeneration) { + dragPreview(previewImage, { offsetY, offsetX }); + } else if (previewRef.current) { + setPreviewImageGeneration(previewElementGeneration); + toPng(previewRef.current).then((url) => { + const img = new Image(); + img.src = url; + setPreviewImage(img); + dragPreview(img, { offsetY, offsetX }); + }); + } + }, [ + dragPreview, + previewRef.current, + previewElementGeneration, + previewImageGeneration, + previewImage, + devicePixelRatio, + ]); + + const leafContent = useMemo(() => { + return ( +
+ {layoutModel.renderContent(nodeModel)} +
+ ); + }, [nodeModel]); + + // Register the display node as a draggable item + useEffect(() => { + drag(nodeModel.dragHandleRef); + }, [drag, nodeModel.dragHandleRef.current]); + + return ( +
event.stopPropagation()} + > + {leafContent} + {previewElement} +
+ ); +}; + +interface OverlayNodeWrapperProps { + layoutModel: LayoutModel; +} + +const OverlayNodeWrapper = memo(({ layoutModel }: OverlayNodeWrapperProps) => { + const leafs = useAtomValue(layoutModel.leafs); + const overlayTransform = useAtomValue(layoutModel.overlayTransform); + + const overlayNodes = useMemo( + () => + leafs.map((node) => { + return ; + }), + [leafs] + ); + + return ( +
+ {overlayNodes} +
+ ); +}); + +interface OverlayNodeProps { + /** + * The layout tree state. + */ + layoutModel: LayoutModel; + node: LayoutNode; +} + +/** + * An overlay representing the true flexbox layout of the LayoutTreeState. This holds the drop targets for moving around nodes and is used to calculate the + * dimensions of the corresponding DisplayNode for each LayoutTreeState leaf. + */ +const OverlayNode = memo(({ node, layoutModel }: OverlayNodeProps) => { + const nodeModel = useNodeModel(layoutModel, node); + const additionalProps = useAtomValue(nodeModel.additionalProps); + const overlayRef = useRef(null); + + const [, drop] = useDrop( + () => ({ + accept: dragItemType, + canDrop: (_, monitor) => { + const dragItem = monitor.getItem(); + if (monitor.isOver({ shallow: true }) && dragItem.id !== node.id) { + return true; + } + return false; + }, + drop: (_, monitor) => { + if (!monitor.didDrop()) { + layoutModel.onDrop(); + } + }, + hover: throttle(50, (_, monitor: DropTargetMonitor) => { + if (monitor.isOver({ shallow: true })) { + if (monitor.canDrop() && layoutModel.displayContainerRef?.current && additionalProps?.rect) { + const dragItem = monitor.getItem(); + // console.log("computing operation", layoutNode, dragItem, additionalProps.rect); + const offset = monitor.getClientOffset(); + const containerRect = layoutModel.displayContainerRef.current.getBoundingClientRect(); + offset.x -= containerRect.x; + offset.y -= containerRect.y; + layoutModel.treeReducer({ + type: LayoutTreeActionType.ComputeMove, + nodeId: node.id, + nodeToMoveId: dragItem.id, + direction: determineDropDirection(additionalProps.rect, offset), + } as LayoutTreeComputeMoveNodeAction); + } else { + layoutModel.treeReducer({ + type: LayoutTreeActionType.ClearPendingAction, + }); + } + } + }), + }), + [node.id, additionalProps?.rect, layoutModel.displayContainerRef, layoutModel.onDrop, layoutModel.treeReducer] + ); + + // Register the overlay node as a drop target + useEffect(() => { + drop(overlayRef); + }, []); + + return
; +}); + +interface ResizeHandleWrapperProps { + layoutModel: LayoutModel; +} + +const ResizeHandleWrapper = memo(({ layoutModel }: ResizeHandleWrapperProps) => { + const resizeHandles = useAtomValue(layoutModel.resizeHandles) as Atom[]; + + return resizeHandles.map((resizeHandleAtom, i) => ( + + )); +}); + +interface ResizeHandleComponentProps { + resizeHandleAtom: Atom; + layoutModel: LayoutModel; +} + +const ResizeHandle = memo(({ resizeHandleAtom, layoutModel }: ResizeHandleComponentProps) => { + const resizeHandleProps = useAtomValue(resizeHandleAtom); + const resizeHandleRef = useRef(null); + + // The pointer currently captured, or undefined. + const [trackingPointer, setTrackingPointer] = useState(undefined); + + // Calculates the new size of the two nodes on either side of the handle, based on the position of the cursor + const handlePointerMove = useCallback( + throttle(10, (event: React.PointerEvent) => { + if (trackingPointer === event.pointerId) { + const { clientX, clientY } = event; + layoutModel.onResizeMove(resizeHandleProps, clientX, clientY); + } + }), + [trackingPointer, layoutModel.onResizeMove, resizeHandleProps] + ); + + // We want to use pointer capture so the operation continues even if the pointer leaves the bounds of the handle + function onPointerDown(event: React.PointerEvent) { + resizeHandleRef.current?.setPointerCapture(event.pointerId); + } + + // This indicates that we're ready to start tracking the resize operation via the pointer + function onPointerCapture(event: React.PointerEvent) { + setTrackingPointer(event.pointerId); + } + + // We want to wait a bit before committing the pending resize operation in case some events haven't arrived yet. + const onPointerRelease = useCallback( + debounce(30, (event: React.PointerEvent) => { + setTrackingPointer(undefined); + layoutModel.onResizeEnd(); + }), + [layoutModel] + ); + + return ( +
+
+
+ ); +}); + +interface PlaceholderProps { + /** + * The layout tree state. + */ + layoutModel: LayoutModel; + /** + * Any styling to apply to the placeholder container div. + */ + style: React.CSSProperties; +} + +/** + * An overlay to preview pending actions on the layout tree. + */ +const Placeholder = memo(({ layoutModel, style }: PlaceholderProps) => { + const [placeholderOverlay, setPlaceholderOverlay] = useState(null); + const placeholderTransform = useAtomValue(layoutModel.placeholderTransform); + + useEffect(() => { + if (placeholderTransform) { + setPlaceholderOverlay(
); + } else { + setPlaceholderOverlay(null); + } + }, [placeholderTransform]); + + return ( +
+ {placeholderOverlay} +
+ ); +}); diff --git a/frontend/layout/lib/layoutAtom.ts b/frontend/layout/lib/layoutAtom.ts new file mode 100644 index 000000000..53d0526f2 --- /dev/null +++ b/frontend/layout/lib/layoutAtom.ts @@ -0,0 +1,56 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WOS } from "@/app/store/global"; +import { Atom, atom, Getter } from "jotai"; +import { LayoutTreeState, WritableLayoutTreeStateAtom } from "./types"; + +const layoutStateAtomMap: WeakMap, WritableLayoutTreeStateAtom> = new WeakMap(); + +function getLayoutStateAtomFromTab(tabAtom: Atom, get: Getter): WritableWaveObjectAtom { + const tabData = get(tabAtom); + if (!tabData) return; + const layoutStateOref = WOS.makeORef("layout", tabData.layoutstate); + const layoutStateAtom = WOS.getWaveObjectAtom(layoutStateOref); + return layoutStateAtom; +} + +export function withLayoutTreeStateAtomFromTab(tabAtom: Atom): WritableLayoutTreeStateAtom { + if (layoutStateAtomMap.has(tabAtom)) { + return layoutStateAtomMap.get(tabAtom); + } + const generationAtom = atom(1); + const treeStateAtom: WritableLayoutTreeStateAtom = atom( + (get) => { + const stateAtom = getLayoutStateAtomFromTab(tabAtom, get); + if (!stateAtom) return; + const layoutStateData = get(stateAtom); + const layoutTreeState: LayoutTreeState = { + rootNode: layoutStateData?.rootnode, + focusedNodeId: layoutStateData?.focusednodeid, + magnifiedNodeId: layoutStateData?.magnifiednodeid, + pendingBackendActions: layoutStateData?.pendingbackendactions, + generation: get(generationAtom), + }; + return layoutTreeState; + }, + (get, set, value) => { + if (get(generationAtom) < value.generation) { + const stateAtom = getLayoutStateAtomFromTab(tabAtom, get); + if (!stateAtom) return; + const waveObjVal = get(stateAtom); + waveObjVal.rootnode = value.rootNode; + waveObjVal.magnifiednodeid = value.magnifiedNodeId; + waveObjVal.focusednodeid = value.focusedNodeId; + waveObjVal.leaforder = value.leafOrder; // only set leaforder, never get it, since this value is driven by the frontend + waveObjVal.pendingbackendactions = value.pendingBackendActions?.length + ? value.pendingBackendActions + : undefined; + set(generationAtom, value.generation); + set(stateAtom, waveObjVal); + } + } + ); + layoutStateAtomMap.set(tabAtom, treeStateAtom); + return treeStateAtom; +} diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts new file mode 100644 index 000000000..7408de599 --- /dev/null +++ b/frontend/layout/lib/layoutModel.ts @@ -0,0 +1,1164 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atomWithThrottle, boundNumber } from "@/util/util"; +import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; +import { splitAtom } from "jotai/utils"; +import { createRef, CSSProperties } from "react"; +import { debounce } from "throttle-debounce"; +import { balanceNode, findNode, newLayoutNode, walkNodes } from "./layoutNode"; +import { + clearTree, + computeMoveNode, + deleteNode, + focusNode, + insertNode, + insertNodeAtIndex, + magnifyNodeToggle, + moveNode, + resizeNode, + swapNode, +} from "./layoutTree"; +import { + ContentRenderer, + FlexDirection, + LayoutNode, + LayoutNodeAdditionalProps, + LayoutTreeAction, + LayoutTreeActionType, + LayoutTreeClearTreeAction, + LayoutTreeComputeMoveNodeAction, + LayoutTreeDeleteNodeAction, + LayoutTreeFocusNodeAction, + LayoutTreeInsertNodeAction, + LayoutTreeInsertNodeAtIndexAction, + LayoutTreeMagnifyNodeToggleAction, + LayoutTreeMoveNodeAction, + LayoutTreeResizeNodeAction, + LayoutTreeSetPendingAction, + LayoutTreeState, + LayoutTreeSwapNodeAction, + NavigateDirection, + NodeModel, + PreviewRenderer, + ResizeHandleProps, + TileLayoutContents, + WritableLayoutTreeStateAtom, +} from "./types"; +import { getCenter, navigateDirectionToOffset, setTransform } from "./utils"; + +interface ResizeContext { + handleId: string; + pixelToSizeRatio: number; + displayContainerRect?: Dimensions; + resizeHandleStartPx: number; + beforeNodeId: string; + beforeNodeStartSize: number; + afterNodeId: string; + afterNodeStartSize: number; +} + +const DefaultGapSizePx = 3; +const MinNodeSizePx = 40; +const DefaultAnimationTimeS = 0.15; + +export class LayoutModel { + /** + * The jotai atom for persisting the tree state to the backend and retrieving updates from the backend. + */ + treeStateAtom: WritableLayoutTreeStateAtom; + /** + * The tree state as it is persisted on the backend. + */ + treeState: LayoutTreeState; + /** + * The last-recorded tree state generation. + */ + lastTreeStateGeneration: number; + /** + * The jotai getter that is used to read atom values. + */ + getter: Getter; + /** + * The jotai setter that is used to update atom values. + */ + setter: Setter; + /** + * Callback that is invoked to render the block associated with a leaf node. + */ + renderContent?: ContentRenderer; + /** + * Callback that is invoked to render the drag preview for a leaf node. + */ + renderPreview?: PreviewRenderer; + /** + * Callback that is invoked when a node is closed. + */ + onNodeDelete?: (data: TabLayoutData) => Promise; + /** + * The size of the gap between nodes in CSS pixels. + */ + gapSizePx: PrimitiveAtom; + + /** + * The time a transition animation takes, in seconds. + */ + animationTimeS: PrimitiveAtom; + + /** + * List of nodes that are leafs and should be rendered as a DisplayNode. + */ + leafs: PrimitiveAtom; + /** + * An ordered list of node ids starting from the top left corner to the bottom right corner. + */ + leafOrder: PrimitiveAtom; + /** + * Atom representing the number of leaf nodes in a layout. + */ + numLeafs: Atom; + /** + * A map of node models for currently-active leafs. + */ + private nodeModels: Map; + + /** + * Split atom containing the properties of all of the resize handles that should be placed in the layout. + */ + resizeHandles: SplitAtom; + /** + * Layout node derived properties that are not persisted to the backend. + * @see updateTreeHelper for the logic to update these properties. + */ + additionalProps: PrimitiveAtom>; + /** + * Set if there is currently an uncommitted action pending on the layout tree. + * @see LayoutTreeActionType for the different types of actions. + */ + pendingTreeAction: AtomWithThrottle; + /** + * Whether a node is currently being dragged. + */ + activeDrag: PrimitiveAtom; + /** + * Whether the overlay container should be shown. + * @see overlayTransform contains the actual CSS transform that moves the overlay into view. + */ + showOverlay: PrimitiveAtom; + /** + * Whether the nodes within the layout should be displaying content. + */ + ready: PrimitiveAtom; + + /** + * RefObject for the display container, that holds the display nodes. This is used to get the size of the whole layout. + */ + displayContainerRef: React.RefObject; + /** + * CSS properties for the placeholder element. + */ + placeholderTransform: Atom; + /** + * CSS properties for the overlay container. + */ + overlayTransform: Atom; + + /** + * The currently focused node. + */ + private focusedNodeIdStack: string[]; + /** + * Atom pointing to the currently focused node. + */ + focusedNode: Atom; + /** + * The currently magnified node. + */ + magnifiedNodeId: string; + /** + * The last node to be magnified, other than the current magnified node, if set. This node should sit at a higher z-index than the others so that it floats above the other nodes as it returns to its original position. + */ + lastMagnifiedNodeId: string; + + /** + * The size of the resize handles, in CSS pixels. + * The resize handle size is double the gap size, or double the default gap size, whichever is greater. + * @see gapSizePx @see DefaultGapSizePx + */ + private resizeHandleSizePx: Atom; + /** + * A context used by the resize handles to keep track of precomputed values for the current resize operation. + */ + private resizeContext?: ResizeContext; + /** + * True if a resize handle is currently being dragged or the whole TileLayout container is being resized. + */ + isResizing: Atom; + /** + * True if the whole TileLayout container is being resized. + */ + private isContainerResizing: PrimitiveAtom; + + constructor( + treeStateAtom: WritableLayoutTreeStateAtom, + getter: Getter, + setter: Setter, + renderContent?: ContentRenderer, + renderPreview?: PreviewRenderer, + onNodeDelete?: (data: TabLayoutData) => Promise, + gapSizePx?: number, + animationTimeS?: number + ) { + this.treeStateAtom = treeStateAtom; + this.getter = getter; + this.setter = setter; + this.renderContent = renderContent; + this.renderPreview = renderPreview; + this.onNodeDelete = onNodeDelete; + this.gapSizePx = atom(gapSizePx ?? DefaultGapSizePx); + this.resizeHandleSizePx = atom((get) => { + const gapSizePx = get(this.gapSizePx); + return 2 * (gapSizePx > 5 ? gapSizePx : DefaultGapSizePx); + }); + this.animationTimeS = atom(animationTimeS ?? DefaultAnimationTimeS); + this.lastTreeStateGeneration = -1; + + this.leafs = atom([]); + this.leafOrder = atom([]); + this.numLeafs = atom((get) => get(this.leafOrder).length); + + this.nodeModels = new Map(); + this.additionalProps = atom({}); + + const resizeHandleListAtom = atom((get) => { + const addlProps = get(this.additionalProps); + return Object.values(addlProps) + .flatMap((props) => props.resizeHandles) + .filter((v) => v); + }); + this.resizeHandles = splitAtom(resizeHandleListAtom); + this.isContainerResizing = atom(false); + this.isResizing = atom((get) => { + const pendingAction = get(this.pendingTreeAction.throttledValueAtom); + const isWindowResizing = get(this.isContainerResizing); + return isWindowResizing || pendingAction?.type === LayoutTreeActionType.ResizeNode; + }); + + this.displayContainerRef = createRef(); + this.activeDrag = atom(false); + this.showOverlay = atom(false); + this.ready = atom(false); + this.overlayTransform = atom((get) => { + const activeDrag = get(this.activeDrag); + const showOverlay = get(this.showOverlay); + if (this.displayContainerRef.current) { + const displayBoundingRect = this.displayContainerRef.current.getBoundingClientRect(); + const newOverlayOffset = displayBoundingRect.top + 2 * displayBoundingRect.height; + const newTransform = setTransform( + { + top: activeDrag || showOverlay ? 0 : newOverlayOffset, + left: 0, + width: displayBoundingRect.width, + height: displayBoundingRect.height, + }, + false + ); + return newTransform; + } + }); + + this.focusedNode = atom((get) => { + const treeState = get(this.treeStateAtom); + if (treeState.focusedNodeId == null) { + return null; + } + return findNode(treeState.rootNode, treeState.focusedNodeId); + }); + this.focusedNodeIdStack = []; + + this.pendingTreeAction = atomWithThrottle(null, 10); + this.placeholderTransform = atom((get: Getter) => { + const pendingAction = get(this.pendingTreeAction.throttledValueAtom); + return this.getPlaceholderTransform(pendingAction); + }); + + this.onTreeStateAtomUpdated(true); + } + + /** + * Register TileLayout callbacks that should be called on various state changes. + * @param contents Contains callbacks provided by the TileLayout component. + */ + registerTileLayout(contents: TileLayoutContents) { + this.renderContent = contents.renderContent; + this.renderPreview = contents.renderPreview; + this.onNodeDelete = contents.onNodeDelete; + if (contents.gapSizePx !== undefined) { + this.setter(this.gapSizePx, contents.gapSizePx); + } + } + + /** + * Perform an action against the layout tree state. + * @param action The action to perform. + */ + treeReducer(action: LayoutTreeAction) { + switch (action.type) { + case LayoutTreeActionType.ComputeMove: + this.setter( + this.pendingTreeAction.throttledValueAtom, + computeMoveNode(this.treeState, action as LayoutTreeComputeMoveNodeAction) + ); + break; + case LayoutTreeActionType.Move: + moveNode(this.treeState, action as LayoutTreeMoveNodeAction); + break; + case LayoutTreeActionType.InsertNode: + insertNode(this.treeState, action as LayoutTreeInsertNodeAction); + break; + case LayoutTreeActionType.InsertNodeAtIndex: + insertNodeAtIndex(this.treeState, action as LayoutTreeInsertNodeAtIndexAction); + break; + case LayoutTreeActionType.DeleteNode: + deleteNode(this.treeState, action as LayoutTreeDeleteNodeAction); + break; + case LayoutTreeActionType.Swap: + swapNode(this.treeState, action as LayoutTreeSwapNodeAction); + break; + case LayoutTreeActionType.ResizeNode: + resizeNode(this.treeState, action as LayoutTreeResizeNodeAction); + break; + case LayoutTreeActionType.SetPendingAction: { + const pendingAction = (action as LayoutTreeSetPendingAction).action; + if (pendingAction) { + this.setter(this.pendingTreeAction.throttledValueAtom, pendingAction); + } else { + console.warn("No new pending action provided"); + } + break; + } + case LayoutTreeActionType.ClearPendingAction: + this.setter(this.pendingTreeAction.throttledValueAtom, undefined); + break; + case LayoutTreeActionType.CommitPendingAction: { + const pendingAction = this.getter(this.pendingTreeAction.currentValueAtom); + if (!pendingAction) { + console.error("unable to commit pending action, does not exist"); + break; + } + this.treeReducer(pendingAction); + this.setter(this.pendingTreeAction.throttledValueAtom, undefined); + break; + } + case LayoutTreeActionType.FocusNode: + focusNode(this.treeState, action as LayoutTreeFocusNodeAction); + break; + case LayoutTreeActionType.MagnifyNodeToggle: + magnifyNodeToggle(this.treeState, action as LayoutTreeMagnifyNodeToggleAction); + break; + case LayoutTreeActionType.ClearTree: { + clearTree(this.treeState); + } + default: + console.error("Invalid reducer action", this.treeState, action); + } + if (this.lastTreeStateGeneration < this.treeState.generation) { + if (this.magnifiedNodeId !== this.treeState.magnifiedNodeId) { + this.lastMagnifiedNodeId = this.magnifiedNodeId; + this.magnifiedNodeId = this.treeState.magnifiedNodeId; + } + this.updateTree(); + this.setTreeStateAtom(true); + } + } + + /** + * Callback that is invoked when the upstream tree state has been updated. This ensures the model is updated if the atom is not fully loaded when the model is first instantiated. + * @param force Whether to force the local tree state to update, regardless of whether the state is already up to date. + */ + async onTreeStateAtomUpdated(force = false) { + const treeState = this.getter(this.treeStateAtom); + // Only update the local tree state if it is different from the one in the upstream atom. This function is called even when the update was initiated by the LayoutModel, so we need to filter out false positives or we'll enter an infinite loop. + if ( + force || + !this.treeState?.rootNode || + !this.treeState?.generation || + treeState?.generation > this.treeState.generation || + treeState?.pendingBackendActions?.length + ) { + this.treeState = treeState; + + if (this.treeState.pendingBackendActions?.length) { + const actions = this.treeState.pendingBackendActions; + this.treeState.pendingBackendActions = undefined; + for (const action of actions) { + switch (action.actiontype) { + case LayoutTreeActionType.InsertNode: { + const insertNodeAction: LayoutTreeInsertNodeAction = { + type: LayoutTreeActionType.InsertNode, + node: newLayoutNode(undefined, undefined, undefined, { + blockId: action.blockid, + }), + magnified: action.magnified, + focused: action.focused, + }; + this.treeReducer(insertNodeAction); + break; + } + case LayoutTreeActionType.DeleteNode: { + const leaf = this?.getNodeByBlockId(action.blockid); + if (leaf) { + await this.closeNode(leaf.id); + } else { + console.error( + "Cannot apply eventbus layout action DeleteNode, could not find leaf node with blockId", + action.blockid + ); + } + break; + } + case LayoutTreeActionType.InsertNodeAtIndex: { + if (!action.indexarr) { + console.error( + "Cannot apply eventbus layout action InsertNodeAtIndex, indexarr field is missing." + ); + break; + } + const insertAction: LayoutTreeInsertNodeAtIndexAction = { + type: LayoutTreeActionType.InsertNodeAtIndex, + node: newLayoutNode(undefined, action.nodesize, undefined, { + blockId: action.blockid, + }), + indexArr: action.indexarr, + magnified: action.magnified, + focused: action.focused, + }; + this.treeReducer(insertAction); + break; + } + case LayoutTreeActionType.ClearTree: { + this.treeReducer({ + type: LayoutTreeActionType.ClearTree, + } as LayoutTreeClearTreeAction); + } + default: + console.warn("unsupported layout action", action); + break; + } + } + } else { + this.updateTree(); + this.setTreeStateAtom(force); + } + } + } + + /** + * Set the upstream tree state atom to the value of the local tree state. + * @param bumpGeneration Whether to bump the generation of the tree state before setting the atom. + */ + setTreeStateAtom(bumpGeneration = false) { + if (bumpGeneration) { + this.treeState.generation++; + } + this.lastTreeStateGeneration = this.treeState.generation; + this.setter(this.treeStateAtom, this.treeState); + } + + /** + * Recursively walks the tree to find leaf nodes, update the resize handles, and compute additional properties for each node. + * @param balanceTree Whether the tree should also be balanced as it is walked. This should be done if the tree state has just been updated. Defaults to true. + */ + updateTree(balanceTree = true) { + if (this.displayContainerRef.current) { + const newLeafs: LayoutNode[] = []; + const newAdditionalProps = {}; + + const pendingAction = this.getter(this.pendingTreeAction.currentValueAtom); + const resizeAction = + pendingAction?.type === LayoutTreeActionType.ResizeNode + ? (pendingAction as LayoutTreeResizeNodeAction) + : null; + const resizeHandleSizePx = this.getter(this.resizeHandleSizePx); + const callback = (node: LayoutNode) => + this.updateTreeHelper(node, newAdditionalProps, newLeafs, resizeHandleSizePx, resizeAction); + if (balanceTree) this.treeState.rootNode = balanceNode(this.treeState.rootNode, callback); + else walkNodes(this.treeState.rootNode, callback); + + this.treeState.leafOrder = getLeafOrder(newLeafs, newAdditionalProps); + this.validateFocusedNode(this.treeState.leafOrder); + this.validateMagnifiedNode(this.treeState.leafOrder, newAdditionalProps); + this.cleanupNodeModels(this.treeState.leafOrder); + this.setter( + this.leafs, + newLeafs.sort((a, b) => a.id.localeCompare(b.id)) + ); + this.setter(this.leafOrder, this.treeState.leafOrder); + this.setter(this.additionalProps, newAdditionalProps); + } + } + + /** + * Per-node callback that is invoked recursively to find leaf nodes, update the resize handles, and compute additional properties associated with the given node. + * @param node The node for which to update the resize handles and additional properties. + * @param additionalPropsMap The new map that will contain the updated additional properties for all nodes in the tree. + * @param leafs The new list that will contain all the leaf nodes in the tree. + * @param resizeAction The pending resize action, if any. Used to set temporary size values on nodes that are being resized. + */ + private updateTreeHelper( + node: LayoutNode, + additionalPropsMap: Record, + leafs: LayoutNode[], + resizeHandleSizePx: number, + resizeAction?: LayoutTreeResizeNodeAction + ) { + /** + * Gets normalized dimensions for the TileLayout container. + * @returns The normalized dimensions for the TileLayout container. + */ + const getBoundingRect: () => Dimensions = () => { + const boundingRect = this.displayContainerRef.current.getBoundingClientRect(); + return { top: 0, left: 0, width: boundingRect.width, height: boundingRect.height }; + }; + + if (!node.children?.length) { + leafs.push(node); + const addlProps = additionalPropsMap[node.id]; + if (addlProps) { + if (this.magnifiedNodeId === node.id) { + const boundingRect = getBoundingRect(); + const transform = setTransform( + { + top: boundingRect.height * 0.05, + left: boundingRect.width * 0.05, + width: boundingRect.width * 0.9, + height: boundingRect.height * 0.9, + }, + true + ); + addlProps.transform = transform; + addlProps.isMagnifiedNode = true; + } + addlProps.isLastMagnifiedNode = this.lastMagnifiedNodeId === node.id; + } + return; + } + + function getNodeSize(node: LayoutNode) { + return resizeAction?.resizeOperations.find((op) => op.nodeId === node.id)?.size ?? node.size; + } + + const additionalProps: LayoutNodeAdditionalProps = additionalPropsMap.hasOwnProperty(node.id) + ? additionalPropsMap[node.id] + : { treeKey: "0" }; + + const nodeRect: Dimensions = node.id === this.treeState.rootNode.id ? getBoundingRect() : additionalProps.rect; + const nodeIsRow = node.flexDirection === FlexDirection.Row; + const nodePixels = nodeIsRow ? nodeRect.width : nodeRect.height; + const totalChildrenSize = node.children.reduce((acc, child) => acc + getNodeSize(child), 0); + const pixelToSizeRatio = totalChildrenSize / nodePixels; + + let lastChildRect: Dimensions; + const resizeHandles: ResizeHandleProps[] = []; + node.children.forEach((child, i) => { + const childSize = getNodeSize(child); + const rect: Dimensions = { + top: !nodeIsRow && lastChildRect ? lastChildRect.top + lastChildRect.height : nodeRect.top, + left: nodeIsRow && lastChildRect ? lastChildRect.left + lastChildRect.width : nodeRect.left, + width: nodeIsRow ? childSize / pixelToSizeRatio : nodeRect.width, + height: nodeIsRow ? nodeRect.height : childSize / pixelToSizeRatio, + }; + const transform = setTransform(rect); + additionalPropsMap[child.id] = { + rect, + transform, + treeKey: additionalProps.treeKey + i, + }; + + // We only want the resize handles in between nodes, this ensures we have n-1 handles. + if (lastChildRect) { + const resizeHandleIndex = resizeHandles.length; + const halfResizeHandleSizePx = resizeHandleSizePx / 2; + const resizeHandleDimensions: Dimensions = { + top: nodeIsRow + ? lastChildRect.top + : lastChildRect.top + lastChildRect.height - halfResizeHandleSizePx, + left: nodeIsRow + ? lastChildRect.left + lastChildRect.width - halfResizeHandleSizePx + : lastChildRect.left, + width: nodeIsRow ? resizeHandleSizePx : lastChildRect.width, + height: nodeIsRow ? lastChildRect.height : resizeHandleSizePx, + }; + resizeHandles.push({ + id: `${node.id}-${resizeHandleIndex}`, + parentNodeId: node.id, + parentIndex: resizeHandleIndex, + transform: setTransform(resizeHandleDimensions, true, false), + flexDirection: node.flexDirection, + centerPx: + (nodeIsRow ? resizeHandleDimensions.left : resizeHandleDimensions.top) + halfResizeHandleSizePx, + }); + } + lastChildRect = rect; + }); + + additionalPropsMap[node.id] = { + ...additionalProps, + pixelToSizeRatio, + resizeHandles, + }; + } + + /** + * The id of the focused node in the layout. + */ + get focusedNodeId(): string { + return this.focusedNodeIdStack[0]; + } + + /** + * Checks whether the focused node id has changed and, if so, whether to update the focused node stack. If the focused node was deleted, will pop the latest value from the stack. + * @param leafOrder The new leaf order array to use when searching for stale nodes in the stack. + */ + private validateFocusedNode(leafOrder: LeafOrderEntry[]) { + if (this.treeState.focusedNodeId !== this.focusedNodeId) { + // Remove duplicates and stale entries from focus stack. + const newFocusedNodeIdStack: string[] = []; + for (const id of this.focusedNodeIdStack) { + if (leafOrder.find((leafEntry) => leafEntry.nodeid === id) && !newFocusedNodeIdStack.includes(id)) + newFocusedNodeIdStack.push(id); + } + this.focusedNodeIdStack = newFocusedNodeIdStack; + + // Update the focused node and stack based on the changes in the tree state. + if (!this.treeState.focusedNodeId) { + if (this.focusedNodeIdStack.length > 0) { + this.treeState.focusedNodeId = this.focusedNodeIdStack.shift(); + } else { + // If no nodes are in the stack, use the top left node in the layout. + this.treeState.focusedNodeId = leafOrder[0].nodeid; + } + } + this.focusedNodeIdStack.unshift(this.treeState.focusedNodeId); + } + } + + /** + * When a layout is modified and only one leaf is remaining, we need to make sure it is no longer magnified. + * @param leafOrder The new leaf order array to use when validating the number of leafs remaining. + * @param addlProps The new additional properties object for all leafs in the layout. + */ + private validateMagnifiedNode(leafOrder: LeafOrderEntry[], addlProps: Record) { + if (leafOrder.length == 1) { + const lastLeafId = leafOrder[0].nodeid; + this.treeState.magnifiedNodeId = undefined; + this.magnifiedNodeId = undefined; + + // Unset the transform for the sole leaf. + if (addlProps.hasOwnProperty(lastLeafId)) addlProps[lastLeafId].transform = undefined; + } + } + + /** + * Helper function for the placeholderTransform atom, which computes the new transform value when the pending action changes. + * @param pendingAction The new pending action value. + * @returns The computed placeholder transform. + * + * @see placeholderTransform the atom that invokes this function and persists the updated value. + */ + private getPlaceholderTransform(pendingAction: LayoutTreeAction): CSSProperties { + if (pendingAction) { + switch (pendingAction.type) { + case LayoutTreeActionType.Move: { + const action = pendingAction as LayoutTreeMoveNodeAction; + let parentId: string; + if (action.insertAtRoot) { + parentId = this.treeState.rootNode.id; + } else { + parentId = action.parentId; + } + + const parentNode = findNode(this.treeState.rootNode, parentId); + if (action.index !== undefined && parentNode) { + const targetIndex = boundNumber( + action.index - 1, + 0, + parentNode.children ? parentNode.children.length - 1 : 0 + ); + const targetNode = parentNode?.children?.at(targetIndex) ?? parentNode; + if (targetNode) { + const targetBoundingRect = this.getNodeRect(targetNode); + + // Placeholder should be either half the height or half the width of the targetNode, depending on the flex direction of the targetNode's parent. + // Default to placing the placeholder in the first half of the target node. + const placeholderDimensions: Dimensions = { + height: + parentNode.flexDirection === FlexDirection.Column + ? targetBoundingRect.height / 2 + : targetBoundingRect.height, + width: + parentNode.flexDirection === FlexDirection.Row + ? targetBoundingRect.width / 2 + : targetBoundingRect.width, + top: targetBoundingRect.top, + left: targetBoundingRect.left, + }; + + if (action.index > targetIndex) { + if (action.index >= (parentNode.children?.length ?? 1)) { + // If there are no more nodes after the specified index, place the placeholder in the second half of the target node (either right or bottom). + placeholderDimensions.top += + parentNode.flexDirection === FlexDirection.Column && + targetBoundingRect.height / 2; + placeholderDimensions.left += + parentNode.flexDirection === FlexDirection.Row && targetBoundingRect.width / 2; + } else { + // Otherwise, place the placeholder between the target node (the one after which it will be inserted) and the next node + placeholderDimensions.top += + parentNode.flexDirection === FlexDirection.Column && + (3 * targetBoundingRect.height) / 4; + placeholderDimensions.left += + parentNode.flexDirection === FlexDirection.Row && + (3 * targetBoundingRect.width) / 4; + } + } + + return setTransform(placeholderDimensions); + } + } + break; + } + case LayoutTreeActionType.Swap: { + const action = pendingAction as LayoutTreeSwapNodeAction; + const targetNodeId = action.node1Id; + const targetBoundingRect = this.getNodeRectById(targetNodeId); + const placeholderDimensions: Dimensions = { + top: targetBoundingRect.top, + left: targetBoundingRect.left, + height: targetBoundingRect.height, + width: targetBoundingRect.width, + }; + + return setTransform(placeholderDimensions); + } + default: + // No-op + break; + } + } + return; + } + + /** + * Gets the node model for the given node. + * @param node The node for which to retrieve the node model. + * @returns The node model for the given node. + */ + getNodeModel(node: LayoutNode): NodeModel { + const nodeid = node.id; + const blockId = node.data.blockId; + const addlPropsAtom = this.getNodeAdditionalPropertiesAtom(nodeid); + if (!this.nodeModels.has(nodeid)) { + this.nodeModels.set(nodeid, { + additionalProps: addlPropsAtom, + innerRect: atom((get) => { + const addlProps = get(addlPropsAtom); + const numLeafs = get(this.numLeafs); + const gapSizePx = get(this.gapSizePx); + if (numLeafs > 1 && addlProps?.rect) { + return { + width: `${addlProps.transform.width} - ${gapSizePx}px`, + height: `${addlProps.transform.height} - ${gapSizePx}px`, + } as CSSProperties; + } else { + return null; + } + }), + nodeId: nodeid, + blockId, + blockNum: atom((get) => get(this.leafOrder).findIndex((leafEntry) => leafEntry.nodeid === nodeid) + 1), + isFocused: atom((get) => { + const treeState = get(this.treeStateAtom); + const isFocused = treeState.focusedNodeId === nodeid; + return isFocused; + }), + numLeafs: this.numLeafs, + isResizing: this.isResizing, + isMagnified: atom((get) => { + const treeState = get(this.treeStateAtom); + return treeState.magnifiedNodeId === nodeid; + }), + animationTimeS: this.animationTimeS, + ready: this.ready, + disablePointerEvents: this.activeDrag, + onClose: async () => await this.closeNode(nodeid), + toggleMagnify: () => this.magnifyNodeToggle(nodeid), + focusNode: () => this.focusNode(nodeid), + dragHandleRef: createRef(), + displayContainerRef: this.displayContainerRef, + }); + } + const nodeModel = this.nodeModels.get(nodeid); + return nodeModel; + } + + /** + * Remove orphaned node models when their corresponding leaf is deleted. + * @param leafOrder The new leaf order array to use when locating orphaned nodes. + */ + private cleanupNodeModels(leafOrder: LeafOrderEntry[]) { + const orphanedNodeModels = [...this.nodeModels.keys()].filter( + (id) => !leafOrder.find((leafEntry) => leafEntry.nodeid == id) + ); + for (const id of orphanedNodeModels) { + this.nodeModels.delete(id); + } + } + + /** + * Switch focus to the next node in the given direction in the layout. + * @param direction The direction in which to switch focus. + */ + switchNodeFocusInDirection(direction: NavigateDirection) { + const curNodeId = this.focusedNodeId; + + // If no node is focused, set focus to the first leaf. + if (!curNodeId) { + this.focusNode(this.getter(this.leafOrder)[0].nodeid); + return; + } + + const offset = navigateDirectionToOffset(direction); + const nodePositions: Map = new Map(); + const leafs = this.getter(this.leafs); + const addlProps = this.getter(this.additionalProps); + for (const leaf of leafs) { + const pos = addlProps[leaf.id]?.rect; + if (pos) { + nodePositions.set(leaf.id, pos); + } + } + const curNodePos = nodePositions.get(curNodeId); + if (!curNodePos) { + return; + } + nodePositions.delete(curNodeId); + const boundingRect = this.displayContainerRef?.current.getBoundingClientRect(); + if (!boundingRect) { + return; + } + const maxX = boundingRect.left + boundingRect.width; + const maxY = boundingRect.top + boundingRect.height; + const moveAmount = 10; + const curPoint = getCenter(curNodePos); + + function findNodeAtPoint(m: Map, p: Point): string { + for (const [blockId, dimension] of m.entries()) { + if ( + p.x >= dimension.left && + p.x <= dimension.left + dimension.width && + p.y >= dimension.top && + p.y <= dimension.top + dimension.height + ) { + return blockId; + } + } + return null; + } + + while (true) { + curPoint.x += offset.x * moveAmount; + curPoint.y += offset.y * moveAmount; + if (curPoint.x < 0 || curPoint.x > maxX || curPoint.y < 0 || curPoint.y > maxY) { + return; + } + const nodeId = findNodeAtPoint(nodePositions, curPoint); + if (nodeId != null) { + this.focusNode(nodeId); + return; + } + } + } + + /** + * Switch focus to a node using the given BlockNum + * @param newBlockNum The BlockNum of the node to which focus should switch. + * @see leafOrder - the indices in this array determine BlockNum + */ + switchNodeFocusByBlockNum(newBlockNum: number) { + const leafOrder = this.getter(this.leafOrder); + const newLeafIdx = newBlockNum - 1; + if (newLeafIdx < 0 || newLeafIdx >= leafOrder.length) { + return; + } + const leaf = leafOrder[newLeafIdx]; + this.focusNode(leaf.nodeid); + } + + /** + * Set the layout to focus on the given node. + * @param nodeId The id of the node that is being focused. + */ + focusNode(nodeId: string) { + if (this.focusedNodeId === nodeId) return; + const layoutNode = findNode(this.treeState?.rootNode, nodeId); + if (!layoutNode) { + console.error("unable to focus node, cannot find it in tree", nodeId); + return; + } + const action: LayoutTreeFocusNodeAction = { + type: LayoutTreeActionType.FocusNode, + nodeId: nodeId, + }; + + this.treeReducer(action); + } + + focusFirstNode() { + const leafOrder = this.getter(this.leafOrder); + if (leafOrder.length > 0) { + this.focusNode(leafOrder[0].nodeid); + } + } + + /** + * Toggle magnification of a given node. + * @param nodeId The id of the node that is being magnified. + */ + magnifyNodeToggle(nodeId: string) { + const action: LayoutTreeMagnifyNodeToggleAction = { + type: LayoutTreeActionType.MagnifyNodeToggle, + nodeId: nodeId, + }; + + this.treeReducer(action); + } + + /** + * Close a given node and update the tree state. + * @param nodeId The id of the node that is being closed. + */ + async closeNode(nodeId: string) { + const nodeToDelete = findNode(this.treeState.rootNode, nodeId); + if (!nodeToDelete) { + console.error("unable to close node, cannot find it in tree", nodeId); + return; + } + const deleteAction: LayoutTreeDeleteNodeAction = { + type: LayoutTreeActionType.DeleteNode, + nodeId: nodeId, + }; + this.treeReducer(deleteAction); + await this.onNodeDelete?.(nodeToDelete.data); + } + + /** + * Shorthand function for closing the focused node in a layout. + */ + async closeFocusedNode() { + await this.closeNode(this.focusedNodeId); + } + + /** + * Callback that is invoked when a drag operation completes and the pending action should be committed. + */ + onDrop() { + if (this.getter(this.pendingTreeAction.currentValueAtom)) { + this.treeReducer({ + type: LayoutTreeActionType.CommitPendingAction, + }); + } + } + + /** + * Callback that is invoked when the TileLayout container is being resized. + */ + onContainerResize = () => { + this.updateTree(); + this.setter(this.isContainerResizing, true); + this.stopContainerResizing(); + }; + + /** + * Deferred action to restore animations once the TileLayout container is no longer being resized. + */ + stopContainerResizing = debounce(30, () => { + this.setter(this.isContainerResizing, false); + }); + + /** + * Callback to update pending node sizes when a resize handle is dragged. + * @param resizeHandle The resize handle that is being dragged. + * @param x The X coordinate of the pointer device, in CSS pixels. + * @param y The Y coordinate of the pointer device, in CSS pixels. + */ + onResizeMove(resizeHandle: ResizeHandleProps, x: number, y: number) { + const parentIsRow = resizeHandle.flexDirection === FlexDirection.Row; + + // If the resize context is out of date, update it and save it for future events. + if (this.resizeContext?.handleId !== resizeHandle.id) { + const parentNode = findNode(this.treeState.rootNode, resizeHandle.parentNodeId); + const beforeNode = parentNode.children![resizeHandle.parentIndex]; + const afterNode = parentNode.children![resizeHandle.parentIndex + 1]; + + const addlProps = this.getter(this.additionalProps); + const pixelToSizeRatio = addlProps[resizeHandle.parentNodeId]?.pixelToSizeRatio; + if (beforeNode && afterNode && pixelToSizeRatio) { + this.resizeContext = { + handleId: resizeHandle.id, + displayContainerRect: this.displayContainerRef.current?.getBoundingClientRect(), + resizeHandleStartPx: resizeHandle.centerPx, + beforeNodeId: beforeNode.id, + afterNodeId: afterNode.id, + beforeNodeStartSize: beforeNode.size, + afterNodeStartSize: afterNode.size, + pixelToSizeRatio, + }; + } else { + console.error( + "Invalid resize handle, cannot get the additional properties for the nodes in the resize handle properties." + ); + return; + } + } + + const clientPoint = parentIsRow + ? x - this.resizeContext.displayContainerRect?.left + : y - this.resizeContext.displayContainerRect?.top; + const clientDiff = (this.resizeContext.resizeHandleStartPx - clientPoint) * this.resizeContext.pixelToSizeRatio; + const minNodeSize = MinNodeSizePx * this.resizeContext.pixelToSizeRatio; + const beforeNodeSize = this.resizeContext.beforeNodeStartSize - clientDiff; + const afterNodeSize = this.resizeContext.afterNodeStartSize + clientDiff; + + // If either node will be too small after this resize, don't let it happen. + if (beforeNodeSize < minNodeSize || afterNodeSize < minNodeSize) { + return; + } + + const resizeAction: LayoutTreeResizeNodeAction = { + type: LayoutTreeActionType.ResizeNode, + resizeOperations: [ + { + nodeId: this.resizeContext.beforeNodeId, + size: beforeNodeSize, + }, + { + nodeId: this.resizeContext.afterNodeId, + size: afterNodeSize, + }, + ], + }; + const setPendingAction: LayoutTreeSetPendingAction = { + type: LayoutTreeActionType.SetPendingAction, + action: resizeAction, + }; + + this.treeReducer(setPendingAction); + this.updateTree(false); + } + + /** + * Callback to end the current resize operation and commit its pending action. + */ + onResizeEnd() { + if (this.resizeContext) { + this.resizeContext = undefined; + this.treeReducer({ type: LayoutTreeActionType.CommitPendingAction }); + } + } + + /** + * Get the layout node matching the specified blockId. + * @param blockId The blockId that the returned node should contain. + * @returns The node containing the specified blockId, null if not found. + */ + getNodeByBlockId(blockId: string): LayoutNode { + for (const leaf of this.getter(this.leafs)) { + if (leaf.data.blockId === blockId) { + return leaf; + } + } + return null; + } + + /** + * Get a jotai atom containing the additional properties associated with a given node. + * @param nodeId The ID of the node for which to retrieve the additional properties. + * @returns An atom containing the additional properties associated with the given node. + */ + getNodeAdditionalPropertiesAtom(nodeId: string): Atom { + return atom((get) => { + const addlProps = get(this.additionalProps); + if (addlProps.hasOwnProperty(nodeId)) return addlProps[nodeId]; + }); + } + + /** + * Get additional properties associated with a given node. + * @param nodeId The ID of the node for which to retrieve the additional properties. + * @returns The additional properties associated with the given node. + */ + getNodeAdditionalPropertiesById(nodeId: string): LayoutNodeAdditionalProps { + const addlProps = this.getter(this.additionalProps); + if (addlProps.hasOwnProperty(nodeId)) return addlProps[nodeId]; + } + + /** + * Get additional properties associated with a given node. + * @param node The node for which to retrieve the additional properties. + * @returns The additional properties associated with the given node. + */ + getNodeAdditionalProperties(node: LayoutNode): LayoutNodeAdditionalProps { + return this.getNodeAdditionalPropertiesById(node.id); + } + + /** + * Get the CSS transform associated with a given node. + * @param nodeId The ID of the node for which to retrieve the CSS transform. + * @returns The CSS transform associated with the given node. + */ + getNodeTransformById(nodeId: string): CSSProperties { + return this.getNodeAdditionalPropertiesById(nodeId)?.transform; + } + + /** + * Get the CSS transform associated with a given node. + * @param node The node for which to retrieve the CSS transform. + * @returns The CSS transform associated with the given node. + */ + getNodeTransform(node: LayoutNode): CSSProperties { + return this.getNodeTransformById(node.id); + } + + /** + * Get the computed dimensions in CSS pixels of a given node. + * @param nodeId The ID of the node for which to retrieve the computed dimensions. + * @returns The computed dimensions of the given node, in CSS pixels. + */ + getNodeRectById(nodeId: string): Dimensions { + return this.getNodeAdditionalPropertiesById(nodeId)?.rect; + } + + /** + * Get the computed dimensions in CSS pixels of a given node. + * @param node The node for which to retrieve the computed dimensions. + * @returns The computed dimensions of the given node, in CSS pixels. + */ + getNodeRect(node: LayoutNode): Dimensions { + return this.getNodeRectById(node.id); + } +} + +function getLeafOrder( + leafs: LayoutNode[], + additionalProps: Record +): LeafOrderEntry[] { + return leafs + .map((node) => ({ nodeid: node.id, blockid: node.data.blockId }) as LeafOrderEntry) + .sort((a, b) => { + const treeKeyA = additionalProps[a.nodeid]?.treeKey; + const treeKeyB = additionalProps[b.nodeid]?.treeKey; + if (!treeKeyA || !treeKeyB) return; + return treeKeyA.localeCompare(treeKeyB); + }); +} diff --git a/frontend/layout/lib/layoutModelHooks.ts b/frontend/layout/lib/layoutModelHooks.ts new file mode 100644 index 000000000..92d9d7459 --- /dev/null +++ b/frontend/layout/lib/layoutModelHooks.ts @@ -0,0 +1,93 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atoms, globalStore, WOS } from "@/app/store/global"; +import { fireAndForget } from "@/util/util"; +import useResizeObserver from "@react-hook/resize-observer"; +import { Atom, useAtomValue } from "jotai"; +import { CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { debounce } from "throttle-debounce"; +import { withLayoutTreeStateAtomFromTab } from "./layoutAtom"; +import { LayoutModel } from "./layoutModel"; +import { LayoutNode, NodeModel, TileLayoutContents } from "./types"; + +const layoutModelMap: Map = new Map(); + +export function getLayoutModelForTab(tabAtom: Atom): LayoutModel { + const tabData = globalStore.get(tabAtom); + if (!tabData) return; + const tabId = tabData.oid; + if (layoutModelMap.has(tabId)) { + const layoutModel = layoutModelMap.get(tabData.oid); + if (layoutModel) { + return layoutModel; + } + } + const layoutTreeStateAtom = withLayoutTreeStateAtomFromTab(tabAtom); + const layoutModel = new LayoutModel(layoutTreeStateAtom, globalStore.get, globalStore.set); + globalStore.sub(layoutTreeStateAtom, () => fireAndForget(() => layoutModel.onTreeStateAtomUpdated())); + layoutModelMap.set(tabId, layoutModel); + return layoutModel; +} + +export function getLayoutModelForTabById(tabId: string) { + const tabOref = WOS.makeORef("tab", tabId); + const tabAtom = WOS.getWaveObjectAtom(tabOref); + return getLayoutModelForTab(tabAtom); +} + +export function getLayoutModelForActiveTab() { + const tabId = globalStore.get(atoms.activeTabId); + return getLayoutModelForTabById(tabId); +} + +export function deleteLayoutModelForTab(tabId: string) { + if (layoutModelMap.has(tabId)) layoutModelMap.delete(tabId); +} + +export function useLayoutModel(tabAtom: Atom): LayoutModel { + return getLayoutModelForTab(tabAtom); +} + +export function useTileLayout(tabAtom: Atom, tileContent: TileLayoutContents): LayoutModel { + // Use tab data to ensure we can reload if the tab is disposed and remade (such as during Hot Module Reloading) + useAtomValue(tabAtom); + const layoutModel = useLayoutModel(tabAtom); + useResizeObserver(layoutModel?.displayContainerRef, layoutModel?.onContainerResize); + + // Once the TileLayout is mounted, re-run the state update to get all the nodes to flow in the layout. + useLayoutEffect(() => fireAndForget(() => layoutModel.onTreeStateAtomUpdated(true)), []); + + useEffect(() => layoutModel.registerTileLayout(tileContent), [tileContent]); + return layoutModel; +} + +export function useNodeModel(layoutModel: LayoutModel, layoutNode: LayoutNode): NodeModel { + return layoutModel.getNodeModel(layoutNode); +} + +export function useDebouncedNodeInnerRect(nodeModel: NodeModel): CSSProperties { + const nodeInnerRect = useAtomValue(nodeModel.innerRect); + const animationTimeS = useAtomValue(nodeModel.animationTimeS); + const isMagnified = useAtomValue(nodeModel.isMagnified); + const isResizing = useAtomValue(nodeModel.isResizing); + const prefersReducedMotion = useAtomValue(atoms.prefersReducedMotionAtom); + const [innerRect, setInnerRect] = useState(); + + const setInnerRectDebounced = useCallback( + debounce(animationTimeS * 1000, (nodeInnerRect) => { + setInnerRect(nodeInnerRect); + }), + [animationTimeS] + ); + + useLayoutEffect(() => { + if (prefersReducedMotion || isMagnified || isResizing) { + setInnerRect(nodeInnerRect); + } else { + setInnerRectDebounced(nodeInnerRect); + } + }, [nodeInnerRect]); + + return innerRect; +} diff --git a/frontend/layout/lib/layoutNode.ts b/frontend/layout/lib/layoutNode.ts new file mode 100644 index 000000000..ed65c25a1 --- /dev/null +++ b/frontend/layout/lib/layoutNode.ts @@ -0,0 +1,285 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { DEFAULT_MAX_CHILDREN } from "./layoutTree"; +import { DefaultNodeSize, FlexDirection, LayoutNode } from "./types"; +import { reverseFlexDirection } from "./utils"; + +/** + * Creates a new node. + * @param flexDirection The flex direction for the new node. + * @param size The size for the new node. + * @param children The children for the new node. + * @param data The data for the new node. + * @returns The new node. + */ +export function newLayoutNode( + flexDirection?: FlexDirection, + size?: number, + children?: LayoutNode[], + data?: TabLayoutData +): LayoutNode { + const newNode: LayoutNode = { + id: crypto.randomUUID(), + flexDirection: flexDirection ?? FlexDirection.Row, + size: size ?? DefaultNodeSize, + children, + data, + }; + + if (!validateNode(newNode)) { + throw new Error("Invalid node"); + } + return newNode; +} + +/** + * Adds new nodes to the tree at the given index. + * @param node The parent node. + * @param idx The index to insert at. + * @param children The nodes to insert. + * @returns The updated parent node. + */ +export function addChildAt(node: LayoutNode, idx: number, ...children: LayoutNode[]) { + // console.log("adding", children, "to", node, "at index", idx); + if (children.length === 0) return; + + if (!node.children) { + addIntermediateNode(node); + } + const childrenToAdd = children.flatMap((v) => { + if (v.flexDirection !== node.flexDirection) { + return v; + } else if (v.children) { + return v.children; + } else { + v.flexDirection = reverseFlexDirection(node.flexDirection); + return v; + } + }); + + if (node.children.length <= idx) { + node.children.push(...childrenToAdd); + } else if (idx >= 0) { + node.children.splice(idx, 0, ...childrenToAdd); + } +} + +/** + * Adds an intermediate node as a direct child of the given node, moving the given node's children or data into it. + * + * If the node contains children, they are moved two levels deeper to preserve their flex direction. If the node only has data, it is moved one level deeper. + * @param node The node to add the intermediate node to. + * @returns The updated node and the node that was added. + */ +export function addIntermediateNode(node: LayoutNode): LayoutNode { + let intermediateNode: LayoutNode; + + if (node.data) { + intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, undefined, node.data); + node.children = [intermediateNode]; + node.data = undefined; + } else { + const intermediateNodeInner = newLayoutNode(node.flexDirection, undefined, node.children); + intermediateNode = newLayoutNode(reverseFlexDirection(node.flexDirection), undefined, [intermediateNodeInner]); + node.children = [intermediateNode]; + } + const intermediateNodeId = intermediateNode.id; + intermediateNode.id = node.id; + node.id = intermediateNodeId; + return intermediateNode; +} + +/** + * Attempts to remove the specified node from its parent. + * @param parent The parent node. + * @param childToRemove The node to remove. + * @param startingIndex The index in children to start the search from. + * @returns The updated parent node, or undefined if the node was not found. + */ +export function removeChild(parent: LayoutNode, childToRemove: LayoutNode, startingIndex: number = 0) { + if (!parent.children) return; + const idx = parent.children.indexOf(childToRemove, startingIndex); + if (idx === -1) return; + parent.children?.splice(idx, 1); +} + +/** + * Finds the node with the given id. + * @param node The node to search in. + * @param id The id to search for. + * @returns The node with the given id or undefined if no node with the given id was found. + */ +export function findNode(node: LayoutNode, id: string): LayoutNode | undefined { + if (!node) return; + if (node.id === id) return node; + if (!node.children) return; + for (const child of node.children) { + const result = findNode(child, id); + if (result) return result; + } + return; +} + +/** + * Finds the node whose children contains the node with the given id. + * @param node The node to start the search from. + * @param id The id to search for. + * @returns The parent node, or undefined if no node with the given id was found. + */ +export function findParent(node: LayoutNode, id: string): LayoutNode | undefined { + if (node.id === id || !node.children) return; + for (const child of node.children) { + if (child.id === id) return node; + const retVal = findParent(child, id); + if (retVal) return retVal; + } + return; +} + +/** + * Determines whether a node is valid. + * @param node The node to validate. + * @returns True if the node is valid, false otherwise. + */ +export function validateNode(node: LayoutNode): boolean { + if (!node.children == !node.data) { + console.error("Either children or data must be defined for node, not both"); + return false; + } + + if (node.children?.length === 0) { + console.error("Node cannot define an empty array of children"); + return false; + } + return true; +} + +/** + * Recursively walk the layout tree starting at the specified node. Run the specified callbacks, if any. + * @param node The node from which to start the walk. + * @param beforeWalkCallback An optional callback to run before walking a node's children. + * @param afterWalkCallback An optional callback to run after walking a node's children. + */ +export function walkNodes( + node: LayoutNode, + beforeWalkCallback?: (node: LayoutNode) => void, + afterWalkCallback?: (node: LayoutNode) => void +) { + if (!node) return; + beforeWalkCallback?.(node); + node.children?.forEach((child) => walkNodes(child, beforeWalkCallback, afterWalkCallback)); + afterWalkCallback?.(node); +} + +/** + * Recursively corrects the tree to minimize nested single-child nodes, remove invalid nodes, and correct invalid flex direction order. + * @param node The node to start the balancing from. + * @param beforeWalkCallback Any optional callback to run before walking a node's children. + * @param afterWalkCallback An optional callback to run after walking a node's children. + * @returns The corrected node. + */ +export function balanceNode( + node: LayoutNode, + beforeWalkCallback?: (node: LayoutNode) => void, + afterWalkCallback?: (node: LayoutNode) => void +): LayoutNode { + walkNodes( + node, + (node) => { + if (!validateNode(node)) throw new Error("Invalid node"); + node.children = node.children?.flatMap((child) => { + if (child.flexDirection === node.flexDirection) { + child.flexDirection = reverseFlexDirection(node.flexDirection); + } + if (child.children?.length == 1 && child.children[0].children) { + return child.children[0].children; + } + if (child.children?.length === 0) return; + return child; + }); + beforeWalkCallback?.(node); + }, + (node) => { + node.children = node.children?.filter((v) => v); + if (node.children?.length === 1 && !node.children[0].children) { + node.data = node.children[0].data; + node.id = node.children[0].id; + node.children = undefined; + } + afterWalkCallback?.(node); + } + ); + return node; +} + +/** + * Finds the first node in the tree where a new node can be inserted. + * + * This will attempt to fill each node until it has maxChildren children. If a node is full, it will move to its children and + * fill each of them until it has maxChildren children. It will ensure that each child fills evenly before moving to the next + * layer down. + * + * @param node The node to start the search from. + * @param maxChildren The maximum number of children a node can have. + * @returns The node to insert into and the index at which to insert. + */ +export function findNextInsertLocation( + node: LayoutNode, + maxChildren = DEFAULT_MAX_CHILDREN +): { node: LayoutNode; index: number } { + const insertLoc = findNextInsertLocationHelper(node, maxChildren, 1); + return { node: insertLoc?.node, index: insertLoc?.index }; +} + +/** + * Traverse the layout tree using the supplied index array to find the node to insert at. + * @param node The node to start the search from. + * @param indexArr The array of indices to aid in the traversal. + * @returns The node to insert into and the index at which to insert. + */ +export function findInsertLocationFromIndexArr( + node: LayoutNode, + indexArr: number[] +): { node: LayoutNode; index: number } { + function normalizeIndex(index: number) { + const childrenLength = node.children?.length ?? 1; + const lastChildIndex = childrenLength - 1; + if (index < 0) { + return childrenLength - Math.max(index, -childrenLength); + } + return Math.min(index, lastChildIndex); + } + if (indexArr.length == 0) { + return; + } + const nextIndex = normalizeIndex(indexArr.shift()); + if (indexArr.length == 0 || !node.children) { + return { node, index: nextIndex }; + } + return findInsertLocationFromIndexArr(node.children[nextIndex], indexArr); +} + +function findNextInsertLocationHelper( + node: LayoutNode, + maxChildren: number, + curDepth: number = 1 +): { node: LayoutNode; index: number; depth: number } { + if (!node) return; + if (!node.children) return { node, index: 1, depth: curDepth }; + let insertLocs: { node: LayoutNode; index: number; depth: number }[] = []; + if (node.children.length < maxChildren) { + insertLocs.push({ node, index: node.children.length, depth: curDepth }); + } + for (const child of node.children.slice().reverse()) { + insertLocs.push(findNextInsertLocationHelper(child, maxChildren, curDepth + 1)); + } + insertLocs = insertLocs + .filter((a) => a) + .sort((a, b) => Math.pow(a.depth, a.index + maxChildren) - Math.pow(b.depth, b.index + maxChildren)); + return insertLocs[0]; +} + +export function totalChildrenSize(node: LayoutNode): number { + return node.children?.reduce((partialSum, child) => partialSum + child.size, 0); +} diff --git a/frontend/layout/lib/layoutTree.ts b/frontend/layout/lib/layoutTree.ts new file mode 100644 index 000000000..c5a111aa2 --- /dev/null +++ b/frontend/layout/lib/layoutTree.ts @@ -0,0 +1,427 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { lazy } from "@/util/util"; +import { + addChildAt, + addIntermediateNode, + findInsertLocationFromIndexArr, + findNextInsertLocation, + findNode, + findParent, + removeChild, +} from "./layoutNode"; +import { + DefaultNodeSize, + DropDirection, + FlexDirection, + LayoutTreeActionType, + LayoutTreeComputeMoveNodeAction, + LayoutTreeDeleteNodeAction, + LayoutTreeFocusNodeAction, + LayoutTreeInsertNodeAction, + LayoutTreeInsertNodeAtIndexAction, + LayoutTreeMagnifyNodeToggleAction, + LayoutTreeMoveNodeAction, + LayoutTreeResizeNodeAction, + LayoutTreeState, + LayoutTreeSwapNodeAction, + MoveOperation, +} from "./types"; + +export const DEFAULT_MAX_CHILDREN = 5; + +/** + * Computes an operation for inserting a new node into the tree in the given direction relative to the specified node. + * + * @param layoutState The state of the tree. + * @param computeInsertAction The operation to compute. + */ +export function computeMoveNode(layoutState: LayoutTreeState, computeInsertAction: LayoutTreeComputeMoveNodeAction) { + const rootNode = layoutState.rootNode; + const { nodeId, nodeToMoveId, direction } = computeInsertAction; + if (!nodeId || !nodeToMoveId) { + console.warn("either nodeId or nodeToMoveId not set", nodeId, nodeToMoveId); + return; + } + if (direction === undefined) { + console.warn("No direction provided for insertItemInDirection"); + return; + } + + if (nodeId === nodeToMoveId) { + console.warn("Cannot compute move node action since both nodes are equal"); + return; + } + + let newMoveOperation: MoveOperation; + const parent = lazy(() => findParent(rootNode, nodeId)); + const grandparent = lazy(() => findParent(rootNode, parent().id)); + const indexInParent = lazy(() => parent()?.children.findIndex((child) => nodeId === child.id)); + const indexInGrandparent = lazy(() => grandparent()?.children.findIndex((child) => parent().id === child.id)); + const nodeToMoveParent = lazy(() => findParent(rootNode, nodeToMoveId)); + const nodeToMoveIndexInParent = lazy(() => + nodeToMoveParent()?.children.findIndex((child) => nodeToMoveId === child.id) + ); + const isRoot = rootNode.id === nodeId; + + // TODO: this should not be necessary. The drag layer is having trouble tracking changes to the LayoutNode fields, so I need to grab the node again here to get the latest data. + const node = findNode(rootNode, nodeId); + const nodeToMove = findNode(rootNode, nodeToMoveId); + + if (!node || !nodeToMove) { + console.warn("node or nodeToMove not set", nodeId, nodeToMoveId); + return; + } + + switch (direction) { + case DropDirection.OuterTop: + if (node.flexDirection === FlexDirection.Column) { + const grandparentNode = grandparent(); + if (grandparentNode) { + const index = indexInGrandparent(); + newMoveOperation = { + parentId: grandparentNode.id, + node: nodeToMove, + index, + }; + break; + } + } + case DropDirection.Top: + if (node.flexDirection === FlexDirection.Column) { + newMoveOperation = { parentId: nodeId, index: 0, node: nodeToMove }; + } else { + if (isRoot) + newMoveOperation = { + node: nodeToMove, + index: 0, + insertAtRoot: true, + }; + + const parentNode = parent(); + if (parentNode) + newMoveOperation = { + parentId: parentNode.id, + index: indexInParent() ?? 0, + node: nodeToMove, + }; + } + break; + case DropDirection.OuterBottom: + if (node.flexDirection === FlexDirection.Column) { + const grandparentNode = grandparent(); + if (grandparentNode) { + const index = indexInGrandparent() + 1; + newMoveOperation = { + parentId: grandparentNode.id, + node: nodeToMove, + index, + }; + break; + } + } + case DropDirection.Bottom: + if (node.flexDirection === FlexDirection.Column) { + newMoveOperation = { parentId: nodeId, index: 1, node: nodeToMove }; + } else { + if (isRoot) + newMoveOperation = { + node: nodeToMove, + index: 1, + insertAtRoot: true, + }; + + const parentNode = parent(); + if (parentNode) + newMoveOperation = { + parentId: parentNode.id, + index: indexInParent() + 1, + node: nodeToMove, + }; + } + break; + case DropDirection.OuterLeft: + if (node.flexDirection === FlexDirection.Row) { + const grandparentNode = grandparent(); + if (grandparentNode) { + const index = indexInGrandparent(); + newMoveOperation = { + parentId: grandparentNode.id, + node: nodeToMove, + index, + }; + break; + } + } + case DropDirection.Left: + if (node.flexDirection === FlexDirection.Row) { + newMoveOperation = { parentId: nodeId, index: 0, node: nodeToMove }; + } else { + const parentNode = parent(); + if (parentNode) + newMoveOperation = { + parentId: parentNode.id, + index: indexInParent(), + node: nodeToMove, + }; + } + break; + case DropDirection.OuterRight: + if (node.flexDirection === FlexDirection.Row) { + const grandparentNode = grandparent(); + if (grandparentNode) { + const index = indexInGrandparent() + 1; + newMoveOperation = { + parentId: grandparentNode.id, + node: nodeToMove, + index, + }; + break; + } + } + case DropDirection.Right: + if (node.flexDirection === FlexDirection.Row) { + newMoveOperation = { parentId: nodeId, index: 1, node: nodeToMove }; + } else { + const parentNode = parent(); + if (parentNode) + newMoveOperation = { + parentId: parentNode.id, + index: indexInParent() + 1, + node: nodeToMove, + }; + } + break; + case DropDirection.Center: + if (nodeId !== rootNode.id && nodeToMoveId !== rootNode.id) { + const swapAction: LayoutTreeSwapNodeAction = { + type: LayoutTreeActionType.Swap, + node1Id: nodeId, + node2Id: nodeToMoveId, + }; + return swapAction; + } else { + console.warn("cannot swap"); + } + break; + default: + throw new Error(`Invalid direction: ${direction}`); + } + + if ( + newMoveOperation?.parentId !== nodeToMoveParent()?.id || + (newMoveOperation.index !== nodeToMoveIndexInParent() && + newMoveOperation.index !== nodeToMoveIndexInParent() + 1) + ) + return { + type: LayoutTreeActionType.Move, + ...newMoveOperation, + } as LayoutTreeMoveNodeAction; +} + +export function moveNode(layoutState: LayoutTreeState, action: LayoutTreeMoveNodeAction) { + console.log("moveNode", layoutState, action); + const rootNode = layoutState.rootNode; + if (!action) { + console.error("no move node action provided"); + return; + } + if (action.parentId && action.insertAtRoot) { + console.error("parent and insertAtRoot cannot both be defined in a move node action"); + return; + } + + const node = findNode(rootNode, action.node.id) ?? action.node; + const parent = findNode(rootNode, action.parentId); + const oldParent = findParent(rootNode, action.node.id); + + let startingIndex = 0; + + // If moving under the same parent, we need to make sure that we are removing the child from its old position, not its new one. + // If the new index is before the old index, we need to start our search for the node to delete after the new index position. + // If a node is being moved under the same parent, it can keep its size. Otherwise, it should get reset. + if (oldParent && parent) { + if (oldParent.id === parent.id) { + const curIndexInParent = parent.children!.indexOf(node); + if (curIndexInParent >= action.index) { + startingIndex = action.index + 1; + } + } else { + node.size = DefaultNodeSize; + } + } + + if (!parent && action.insertAtRoot) { + if (!rootNode.children) { + addIntermediateNode(rootNode); + } + addChildAt(rootNode, action.index, node); + } else if (parent) { + addChildAt(parent, action.index, node); + } else { + throw new Error("Invalid InsertOperation"); + } + + // Remove nodeToInsert from its old parent + if (oldParent) { + removeChild(oldParent, node, startingIndex); + } + layoutState.generation++; +} + +export function insertNode(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAction) { + if (!action?.node) { + console.error("insertNode cannot run, no insert node action provided"); + return; + } + if (!layoutState.rootNode) { + layoutState.rootNode = action.node; + } else { + const insertLoc = findNextInsertLocation(layoutState.rootNode, DEFAULT_MAX_CHILDREN); + addChildAt(insertLoc.node, insertLoc.index, action.node); + if (action.magnified) { + layoutState.magnifiedNodeId = action.node.id; + layoutState.focusedNodeId = action.node.id; + } + } + if (action.focused) { + layoutState.focusedNodeId = action.node.id; + } + layoutState.generation++; +} + +export function insertNodeAtIndex(layoutState: LayoutTreeState, action: LayoutTreeInsertNodeAtIndexAction) { + if (!action?.node || !action?.indexArr) { + console.error("insertNodeAtIndex cannot run, either node or indexArr field is missing"); + return; + } + if (!layoutState.rootNode) { + layoutState.rootNode = action.node; + } else { + const insertLoc = findInsertLocationFromIndexArr(layoutState.rootNode, action.indexArr); + if (!insertLoc) { + console.error("insertNodeAtIndex unable to find insert location"); + return; + } + addChildAt(insertLoc.node, insertLoc.index + 1, action.node); + if (action.magnified) { + layoutState.magnifiedNodeId = action.node.id; + layoutState.focusedNodeId = action.node.id; + } + } + if (action.focused) { + layoutState.focusedNodeId = action.node.id; + } + layoutState.generation++; +} + +export function swapNode(layoutState: LayoutTreeState, action: LayoutTreeSwapNodeAction) { + if (!action.node1Id || !action.node2Id) { + console.error("invalid swapNode action, both node1 and node2 must be defined"); + return; + } + + if (action.node1Id === layoutState.rootNode.id || action.node2Id === layoutState.rootNode.id) { + console.error("invalid swapNode action, the root node cannot be swapped"); + return; + } + if (action.node1Id === action.node2Id) { + console.error("invalid swapNode action, node1 and node2 are equal"); + return; + } + + const parentNode1 = findParent(layoutState.rootNode, action.node1Id); + const parentNode2 = findParent(layoutState.rootNode, action.node2Id); + const parentNode1Index = parentNode1.children!.findIndex((child) => child.id === action.node1Id); + const parentNode2Index = parentNode2.children!.findIndex((child) => child.id === action.node2Id); + + const node1 = parentNode1.children![parentNode1Index]; + const node2 = parentNode2.children![parentNode2Index]; + + const node1Size = node1.size; + node1.size = node2.size; + node2.size = node1Size; + + parentNode1.children[parentNode1Index] = node2; + parentNode2.children[parentNode2Index] = node1; + layoutState.generation++; +} + +export function deleteNode(layoutState: LayoutTreeState, action: LayoutTreeDeleteNodeAction) { + if (!action?.nodeId) { + console.error("no delete node action provided"); + return; + } + if (!layoutState.rootNode) { + console.error("no root node"); + return; + } + if (layoutState.rootNode.id === action.nodeId) { + layoutState.rootNode = undefined; + } else { + const parent = findParent(layoutState.rootNode, action.nodeId); + if (parent) { + const node = parent.children.find((child) => child.id === action.nodeId); + removeChild(parent, node); + if (layoutState.focusedNodeId === node.id) { + layoutState.focusedNodeId = undefined; + } + } else { + console.error("unable to delete node, not found in tree"); + } + } + + layoutState.generation++; +} + +export function resizeNode(layoutState: LayoutTreeState, action: LayoutTreeResizeNodeAction) { + if (!action.resizeOperations) { + console.error("invalid resizeNode operation. nodeSizes array must be defined."); + } + for (const resize of action.resizeOperations) { + if (!resize.nodeId || resize.size < 0 || resize.size > 100) { + console.error("invalid resizeNode operation. nodeId must be defined and size must be between 0 and 100"); + return; + } + const node = findNode(layoutState.rootNode, resize.nodeId); + node.size = resize.size; + } + layoutState.generation++; +} + +export function focusNode(layoutState: LayoutTreeState, action: LayoutTreeFocusNodeAction) { + if (!action.nodeId) { + console.error("invalid focusNode operation, nodeId must be defined."); + return; + } + + layoutState.focusedNodeId = action.nodeId; + layoutState.generation++; +} + +export function magnifyNodeToggle(layoutState: LayoutTreeState, action: LayoutTreeMagnifyNodeToggleAction) { + if (!action.nodeId) { + console.error("invalid magnifyNodeToggle operation. nodeId must be defined."); + return; + } + if (layoutState.rootNode.id === action.nodeId) { + console.warn(`cannot toggle magnification of node ${action.nodeId} because it is the root node.`); + return; + } + if (layoutState.magnifiedNodeId === action.nodeId) { + layoutState.magnifiedNodeId = undefined; + } else { + layoutState.magnifiedNodeId = action.nodeId; + layoutState.focusedNodeId = action.nodeId; + } + layoutState.generation++; +} + +export function clearTree(layoutState: LayoutTreeState) { + layoutState.rootNode = undefined; + layoutState.leafOrder = undefined; + layoutState.focusedNodeId = undefined; + layoutState.magnifiedNodeId = undefined; + layoutState.generation++; +} diff --git a/frontend/layout/lib/nodeRefMap.ts b/frontend/layout/lib/nodeRefMap.ts new file mode 100644 index 000000000..e00402372 --- /dev/null +++ b/frontend/layout/lib/nodeRefMap.ts @@ -0,0 +1,25 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export class NodeRefMap { + private map: Map> = new Map(); + generation: number = 0; + + set(id: string, ref: React.RefObject) { + this.map.set(id, ref); + this.generation++; + } + + delete(id: string) { + if (this.map.has(id)) { + this.map.delete(id); + this.generation++; + } + } + + get(id: string): React.RefObject { + if (this.map.has(id)) { + return this.map.get(id); + } + } +} diff --git a/frontend/layout/lib/tilelayout.less b/frontend/layout/lib/tilelayout.less new file mode 100644 index 000000000..5b928855e --- /dev/null +++ b/frontend/layout/lib/tilelayout.less @@ -0,0 +1,137 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.tile-layout { + position: relative; + height: 100%; + width: 100%; + overflow: hidden; + + --gap-size-px: 5px; + + .overlay-container, + .display-container, + .placeholder-container { + position: absolute; + display: flex; + top: 0; + left: 0; + height: 100%; + width: 100%; + min-height: 4rem; + min-width: 4rem; + } + + .display-container { + z-index: var(--zindex-layout-display-container); + } + + .placeholder-container { + z-index: var(--zindex-layout-placeholder-container); + } + + .overlay-container { + z-index: var(--zindex-layout-overlay-container); + } + + .overlay-node { + display: flex; + flex: 0 1 auto; + } + + .resize-handle { + z-index: var(--zindex-layout-resize-handle); + + .line { + visibility: hidden; + } + &.flex-row { + cursor: ew-resize; + .line { + height: 100%; + width: calc(50% + 1px); + border-right: 2px solid var(--accent-color); + } + } + &.flex-column { + cursor: ns-resize; + .line { + height: calc(50% + 1px); + border-bottom: 2px solid var(--accent-color); + } + } + &:hover .line { + visibility: visible; + + // Ignore the prefers-reduced-motion override, since we are not applying a true animation here, just a delay. + transition-property: visibility !important; + transition-delay: var(--animation-time-s) !important; + } + } + + .tile-node { + border-radius: calc(var(--block-border-radius) + 2px); + overflow: hidden; + width: 100%; + height: 100%; + + &.dragging { + filter: blur(8px); + } + + &.resizing { + border: 1px solid var(--accent-color); + backdrop-filter: blur(8px); + } + + &.magnified { + background-color: var(--block-bg-solid-color); + z-index: var(--zindex-layout-magnified-node); + } + &.last-magnified { + z-index: var(--zindex-layout-last-magnified-node); + } + + .tile-leaf { + overflow: hidden; + } + + .tile-preview-container { + position: absolute; + top: 10000px; + white-space: nowrap !important; + user-select: none; + -webkit-user-select: none; + + .tile-preview { + width: 100%; + height: 100%; + } + } + + &:not(:only-child, .magnified) .tile-leaf { + padding: calc(var(--gap-size-px) / 2); + } + } + + &.animate { + .tile-node, + .placeholder { + transition-duration: var(--animation-time-s); + transition-timing-function: linear; + transition-property: transform, width, height, background-color; + } + } + + .tile-leaf, + .overlay-leaf { + height: 100%; + width: 100%; + } + + .placeholder { + background-color: var(--accent-color); + opacity: 0.5; + border-radius: calc(var(--block-border-radius) + 2px); + } +} diff --git a/frontend/layout/lib/tilelayout.stories.less b/frontend/layout/lib/tilelayout.stories.less new file mode 100644 index 000000000..b179ffc8f --- /dev/null +++ b/frontend/layout/lib/tilelayout.stories.less @@ -0,0 +1,9 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.placeholder-visible { + .overlay-container, + .placeholder-container { + transform: unset !important; + } +} diff --git a/frontend/layout/lib/types.ts b/frontend/layout/lib/types.ts new file mode 100644 index 000000000..deac3d4aa --- /dev/null +++ b/frontend/layout/lib/types.ts @@ -0,0 +1,357 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Atom, WritableAtom } from "jotai"; +import { CSSProperties } from "react"; + +export enum NavigateDirection { + Up = 0, + Right = 1, + Down = 2, + Left = 3, +} + +export enum DropDirection { + Top = 0, + Right = 1, + Bottom = 2, + Left = 3, + OuterTop = 4, + OuterRight = 5, + OuterBottom = 6, + OuterLeft = 7, + Center = 8, +} + +export enum FlexDirection { + Row = "row", + Column = "column", +} + +/** + * Represents an operation to insert a node into a tree. + */ +export type MoveOperation = { + /** + * The index at which the node will be inserted in the parent. + */ + index: number; + + /** + * The parent node. Undefined if inserting at root. + */ + parentId?: string; + + /** + * Whether the node will be inserted at the root of the tree. + */ + insertAtRoot?: boolean; + + /** + * The node to insert. + */ + node: LayoutNode; +}; + +/** + * Types of actions that modify the layout tree. + */ +export enum LayoutTreeActionType { + ComputeMove = "computemove", + Move = "move", + Swap = "swap", + SetPendingAction = "setpending", + CommitPendingAction = "commitpending", + ClearPendingAction = "clearpending", + ResizeNode = "resize", + InsertNode = "insert", + InsertNodeAtIndex = "insertatindex", + DeleteNode = "delete", + FocusNode = "focus", + MagnifyNodeToggle = "magnify", + ClearTree = "clear", +} + +/** + * Base class for actions that modify the layout tree. + */ +export interface LayoutTreeAction { + type: LayoutTreeActionType; +} + +/** + * Action for computing a move operation and saving it as a pending action in the tree state. + * + * @see MoveOperation + * @see LayoutTreeMoveNodeAction + */ +export interface LayoutTreeComputeMoveNodeAction extends LayoutTreeAction { + type: LayoutTreeActionType.ComputeMove; + nodeId: string; + nodeToMoveId: string; + direction: DropDirection; +} + +/** + * Action for moving a node within the layout tree. + * + * @see MoveOperation + */ +export interface LayoutTreeMoveNodeAction extends LayoutTreeAction, MoveOperation { + type: LayoutTreeActionType.Move; +} + +/** + * Action for swapping two nodes within the layout tree. + * + */ +export interface LayoutTreeSwapNodeAction extends LayoutTreeAction { + type: LayoutTreeActionType.Swap; + + /** + * The node that node2 will replace. + */ + node1Id: string; + /** + * The node that node1 will replace. + */ + node2Id: string; +} + +interface InsertNodeOperation { + /** + * The node to insert. + */ + node: LayoutNode; + /** + * Whether the inserted node should be magnified. + */ + magnified: boolean; + /** + * Whether the inserted node should be focused. + */ + focused: boolean; +} + +/** + * Action for inserting a new node to the layout tree. + * + */ +export interface LayoutTreeInsertNodeAction extends LayoutTreeAction, InsertNodeOperation { + type: LayoutTreeActionType.InsertNode; +} + +/** + * Action for inserting a node into the layout tree at the specified index. + */ +export interface LayoutTreeInsertNodeAtIndexAction extends LayoutTreeAction, InsertNodeOperation { + type: LayoutTreeActionType.InsertNodeAtIndex; + /** + * The array of indices to traverse when inserting the node. + * The last index is the index within the parent node where the node should be inserted. + */ + indexArr: number[]; +} + +/** + * Action for deleting a node from the layout tree. + */ +export interface LayoutTreeDeleteNodeAction extends LayoutTreeAction { + type: LayoutTreeActionType.DeleteNode; + nodeId: string; +} + +/** + * Action for setting the pendingAction field of the layout tree state. + */ +export interface LayoutTreeSetPendingAction extends LayoutTreeAction { + type: LayoutTreeActionType.SetPendingAction; + + /** + * The new value for the pending action field. + */ + action: LayoutTreeAction; +} + +/** + * Action for committing the action in the pendingAction field of the layout tree state. + */ +export interface LayoutTreeCommitPendingAction extends LayoutTreeAction { + type: LayoutTreeActionType.CommitPendingAction; +} + +/** + * Action for clearing the pendingAction field from the layout tree state. + */ +export interface LayoutTreeClearPendingAction extends LayoutTreeAction { + type: LayoutTreeActionType.ClearPendingAction; +} + +/** + * An operation to resize a node. + */ +export interface ResizeNodeOperation { + /** + * The id of the node to resize. + */ + nodeId: string; + /** + * The new size for the node. + */ + size: number; +} + +/** + * Action for resizing a node from the layout tree. + */ +export interface LayoutTreeResizeNodeAction extends LayoutTreeAction { + type: LayoutTreeActionType.ResizeNode; + + /** + * A list of node ids to update and their respective new sizes. + */ + resizeOperations: ResizeNodeOperation[]; +} + +/** + * Action for focusing a node from the layout tree. + */ +export interface LayoutTreeFocusNodeAction extends LayoutTreeAction { + type: LayoutTreeActionType.FocusNode; + + /** + * The id of the node to focus; + */ + nodeId: string; +} + +/** + * Action for toggling magnification of a node from the layout tree. + */ +export interface LayoutTreeMagnifyNodeToggleAction extends LayoutTreeAction { + type: LayoutTreeActionType.MagnifyNodeToggle; + + /** + * The id of the node to maximize; + */ + nodeId: string; +} + +/** + * Action for clearing all nodes from the layout tree. + */ +export interface LayoutTreeClearTreeAction extends LayoutTreeAction { + type: LayoutTreeActionType.ClearTree; +} + +/** + * Represents a single node in the layout tree. + */ +export interface LayoutNode { + id: string; + data?: TabLayoutData; + children?: LayoutNode[]; + flexDirection: FlexDirection; + size: number; +} + +export type LayoutTreeStateSetter = (value: LayoutState) => void; + +export type LayoutTreeState = { + rootNode: LayoutNode; + focusedNodeId?: string; + magnifiedNodeId?: string; + /** + * A computed ordered list of leafs in the layout. This value is driven by the LayoutModel and should not be read when updated from the backend. + */ + leafOrder?: LeafOrderEntry[]; + pendingBackendActions: LayoutActionData[]; + generation: number; +}; + +export type WritableLayoutTreeStateAtom = WritableAtom; + +export type ContentRenderer = (nodeModel: NodeModel) => React.ReactNode; + +export type PreviewRenderer = (nodeModel: NodeModel) => React.ReactElement; + +export const DefaultNodeSize = 10; + +/** + * contains callbacks and information about the contents (or styling) of of the TileLayout + * nothing in here is specific to the TileLayout itself + */ +export interface TileLayoutContents { + /** + * The tabId with which this TileLayout is associated. + */ + tabId?: string; + + /** + * The class name to use for the top-level div of the tile layout. + */ + className?: string; + + /** + * The gap between tiles in a layout, in CSS pixels. + */ + gapSizePx?: number; + + /** + * A callback that accepts the data from the leaf node and displays the leaf contents to the user. + */ + renderContent: ContentRenderer; + /** + * A callback that accepts the data from the leaf node and returns a preview that can be shown when the user drags a node. + */ + renderPreview?: PreviewRenderer; + /** + * A callback that is called when a node gets deleted from the LayoutTreeState. + * @param data The contents of the node that was deleted. + */ + onNodeDelete?: (data: TabLayoutData) => Promise; + /** + * A callback for getting the cursor point in reference to the current window. This removes Electron as a runtime dependency, allowing for better integration with Storybook. + * @returns The cursor position relative to the current window. + */ + getCursorPoint?: () => Point; +} + +export interface ResizeHandleProps { + id: string; + parentNodeId: string; + parentIndex: number; + centerPx: number; + transform: CSSProperties; + flexDirection: FlexDirection; +} + +export interface LayoutNodeAdditionalProps { + treeKey: string; + transform?: CSSProperties; + rect?: Dimensions; + pixelToSizeRatio?: number; + resizeHandles?: ResizeHandleProps[]; + isMagnifiedNode?: boolean; + isLastMagnifiedNode?: boolean; +} + +export interface NodeModel { + additionalProps: Atom; + innerRect: Atom; + blockNum: Atom; + numLeafs: Atom; + nodeId: string; + blockId: string; + animationTimeS: Atom; + isResizing: Atom; + isFocused: Atom; + isMagnified: Atom; + ready: Atom; + disablePointerEvents: Atom; + toggleMagnify: () => void; + focusNode: () => void; + onClose: () => void; + dragHandleRef?: React.RefObject; + displayContainerRef: React.RefObject; +} diff --git a/frontend/layout/lib/utils.ts b/frontend/layout/lib/utils.ts new file mode 100644 index 000000000..aae7b6668 --- /dev/null +++ b/frontend/layout/lib/utils.ts @@ -0,0 +1,104 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { CSSProperties } from "react"; +import { XYCoord } from "react-dnd"; +import { DropDirection, FlexDirection, NavigateDirection } from "./types"; + +export function reverseFlexDirection(flexDirection: FlexDirection): FlexDirection { + return flexDirection === FlexDirection.Row ? FlexDirection.Column : FlexDirection.Row; +} + +export function determineDropDirection(dimensions?: Dimensions, offset?: XYCoord | null): DropDirection | undefined { + // console.log("determineDropDirection", dimensions, offset); + if (!offset || !dimensions) return undefined; + const { width, height, left, top } = dimensions; + let { x, y } = offset; + x -= left; + y -= top; + + // Lies outside of the box + if (y < 0 || y > height || x < 0 || x > width) return undefined; + + // Determines if a drop point falls within the center fifth of the box, meaning we should return Center. + const centerX1 = (2 * width) / 5; + const centerX2 = (3 * width) / 5; + const centerY1 = (2 * height) / 5; + const centerY2 = (3 * height) / 5; + + if (x > centerX1 && x < centerX2 && y > centerY1 && y < centerY2) return DropDirection.Center; + + const diagonal1 = y * width - x * height; + const diagonal2 = y * width + x * height - height * width; + + // Lies on diagonal + if (diagonal1 == 0 || diagonal2 == 0) return undefined; + + let code = 0; + + if (diagonal2 > 0) { + code += 1; + } + + if (diagonal1 > 0) { + code += 2; + code = 5 - code; + } + + // Determines whether a drop is close to an edge of the box, meaning drop direction should be OuterX, instead of X + const xOuter1 = width / 5; + const xOuter2 = width - width / 5; + const yOuter1 = height / 5; + const yOuter2 = height - height / 5; + + if (y < yOuter1 || y > yOuter2 || x < xOuter1 || x > xOuter2) { + code += 4; + } + + return code; +} + +export function setTransform( + { top, left, width, height }: Dimensions, + setSize = true, + roundVals = true +): CSSProperties { + // Replace unitless items with px + const topRounded = roundVals ? Math.floor(top) : top; + const leftRounded = roundVals ? Math.floor(left) : left; + const widthRounded = roundVals ? Math.ceil(width) : width; + const heightRounded = roundVals ? Math.ceil(height) : height; + const translate = `translate3d(${leftRounded}px,${topRounded}px, 0)`; + return { + top: 0, + left: 0, + transform: translate, + WebkitTransform: translate, + MozTransform: translate, + msTransform: translate, + OTransform: translate, + width: setSize ? `${widthRounded}px` : undefined, + height: setSize ? `${heightRounded}px` : undefined, + position: "absolute", + }; +} + +export function getCenter(dimensions: Dimensions): Point { + return { + x: dimensions.left + dimensions.width / 2, + y: dimensions.top + dimensions.height / 2, + }; +} + +export function navigateDirectionToOffset(direction: NavigateDirection): Point { + switch (direction) { + case NavigateDirection.Up: + return { x: 0, y: -1 }; + case NavigateDirection.Down: + return { x: 0, y: 1 }; + case NavigateDirection.Left: + return { x: -1, y: 0 }; + case NavigateDirection.Right: + return { x: 1, y: 0 }; + } +} diff --git a/frontend/layout/tests/layoutNode.test.ts b/frontend/layout/tests/layoutNode.test.ts new file mode 100644 index 000000000..426c42568 --- /dev/null +++ b/frontend/layout/tests/layoutNode.test.ts @@ -0,0 +1,296 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { assert, test } from "vitest"; +import { addChildAt, addIntermediateNode, balanceNode, findNextInsertLocation, newLayoutNode } from "../lib/layoutNode"; +import { FlexDirection, LayoutNode } from "../lib/types"; + +test("newLayoutNode", () => { + assert.throws( + () => newLayoutNode(FlexDirection.Column), + "Invalid node", + undefined, + "calls to the constructor without data or children should fail" + ); + assert.throws( + () => newLayoutNode(FlexDirection.Column, undefined, [], { blockId: "hello" }), + "Invalid node", + undefined, + "calls to the constructor with both data and children should fail" + ); + assert.doesNotThrow( + () => newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "hello" }), + "Invalid node", + undefined, + "calls to the constructor with only data defined should succeed" + ); + assert.throws(() => newLayoutNode(FlexDirection.Column, undefined, [], undefined)), + "Invalid node", + undefined, + "calls to the constructor with empty children array should fail"; + assert.doesNotThrow(() => + newLayoutNode( + FlexDirection.Column, + undefined, + [newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "hello" })], + undefined + ) + ), + "Invalid node", + undefined, + "calls to the constructor with children array containing at least one child should succeed"; +}); + +test("addIntermediateNode", () => { + let node1: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "hello" }), + ]); + assert(node1.children![0].data!.blockId === "hello", "node1 should have one child which should have data"); + const intermediateNode1 = addIntermediateNode(node1); + assert( + node1.children !== undefined && node1.children.length === 1 && node1.children?.includes(intermediateNode1), + "node1 should have a single child intermediateNode1" + ); + assert(intermediateNode1.flexDirection === FlexDirection.Row, "intermediateNode1 should have flexDirection Row"); + assert( + intermediateNode1.children![0].children![0].data!.blockId === "hello" && + intermediateNode1.children![0].children![0].flexDirection === FlexDirection.Row, + "intermediateNode1 should have a nested child which should have data and flexDirection Row" + ); + let node2: LayoutNode = newLayoutNode(FlexDirection.Column, undefined, undefined, { + blockId: "hello", + }); + const intermediateNode2 = addIntermediateNode(node2); + assert( + node2.children !== undefined && + node2.data === undefined && + node2.children.length === 1 && + node2.children.includes(intermediateNode2), + "node2 should have no data and a single child intermediateNode2" + ); + assert( + intermediateNode2.data.blockId === "hello" && intermediateNode2.children === undefined, + "intermediateNode2 should have no children and should have data matching the old value of node2" + ); +}); + +test("addChildAt - same flexDirection, no children", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }); + let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" }); + addChildAt(node1, 1, node2); + assert(node1.data === undefined, "node1 should have no data"); + assert(node1.children!.length === 2, "node1 should have two children"); + assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); + assert(node1.children![1].id === node2.id, "node1's second child should be node2"); + assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column"); +}); + +test("addChildAt - different flexDirection, no children", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }); + let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" }); + addChildAt(node1, 1, node2); + assert(node1.data === undefined, "node1 should have no data"); + assert(node1.children!.length === 2, "node1 should have two children"); + assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); + assert(node1.children![0].data!.blockId === "node1", "node1's first child should have flexDirection Column"); + assert(node1.children![1].id === node2.id, "node1's second child should be node2"); + assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should have flexDirection Row"); +}); + +test("addChildAt - same flexDirection, first node has children, second doesn't", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), + ]); + let node2 = newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" }); + addChildAt(node1, 1, node2); + assert(node1.data === undefined, "node1 should have no data"); + assert(node1.children!.length === 2, "node1 should have two children"); + assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); + assert( + node1.children![0].flexDirection === FlexDirection.Column, + "node1's first child should have flexDirection Column" + ); + assert(node1.children![1].id === node2.id, "node1's second child should be node2"); + assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should have flexDirection Column"); +}); + +test("addChildAt - different flexDirection, first node has children, second doesn't", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), + ]); + let node2 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" }); + addChildAt(node1, 1, node2); + assert(node1.data === undefined, "node1 should have no data"); + assert(node1.children!.length === 2, "node1 should have two children"); + assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); + assert(node1.children![1].id === node2.id, "node1's second child should be node2"); + assert(node1.children![1].flexDirection === FlexDirection.Column, "node2 should now have flexDirection Column"); +}); + +test("addChildAt - same flexDirection, first node has children, second has children", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), + ]); + let node2 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2" }), + ]); + addChildAt(node1, 1, node2); + assert(node1.data === undefined, "node1 should have no data"); + assert(node1.children!.length === 2, "node1 should have two children"); + assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); + assert( + node1.children![0].flexDirection === FlexDirection.Column, + "node1's first child should have flexDirection Column" + ); + assert(node1.children![1].id === node2.children![0].id, "node1's second child should be node2's child"); + assert( + node1.children![1].flexDirection === FlexDirection.Column, + "node1's second child should have flexDirection Column" + ); +}); + +test("addChildAt - different flexDirection, first node has children, second has children", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node1" }), + ]); + let node2 = newLayoutNode(FlexDirection.Column, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2" }), + ]); + addChildAt(node1, 1, node2); + assert(node1.data === undefined, "node1 should have no data"); + assert(node1.children!.length === 2, "node1 should have two children"); + assert(node1.children![0].data!.blockId === "node1", "node1's first child should have node1's data"); + assert( + node1.children![0].flexDirection === FlexDirection.Column, + "node1's first child should have flexDirection Column" + ); + assert(node1.children![1].id === node2.id, "node1's second child should be node2"); + assert( + node1.children![1].flexDirection === FlexDirection.Column, + "node1's second child should have flexDirection Column" + ); +}); + +test("balanceNode - corrects flex directions", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1Inner1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1Inner2" }), + ]); + const newNode1 = balanceNode(node1); + assert(newNode1 !== undefined, "newNode1 should not be undefined"); + node1 = newNode1; + assert(node1.data === undefined, "node1 should have no data"); + assert(node1.children![0].flexDirection !== node1.flexDirection); +}); + +test("balanceNode - collapses nodes with single grandchild 1", () => { + let node1 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + ]), + ]); + const newNode1 = balanceNode(node1); + assert(newNode1 !== undefined, "newNode1 should not be undefined"); + node1 = newNode1; + assert(node1.children === undefined, "node1 should have no children"); + assert(node1.data!.blockId === "node1", "node1 should have data 'node1'"); +}); + +test("balanceNode - collapses nodes with single grandchild 2", () => { + let node2 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2Inner1" }), + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node2Inner2" }), + ]), + ]), + ]); + const newNode2 = balanceNode(node2); + assert(newNode2 !== undefined, "newNode2 should not be undefined"); + node2 = newNode2; + assert(node2.children!.length === 2, "node2 should have two children"); + assert(node2.children[0].data!.blockId === "node2Inner1", "node2's first child should have data 'node2Inner1'"); + // assert(leafs.length === 2, "leafs should have two leafs"); + // assert(leafs[0].data!.blockId === "node2Inner1", "leafs[0] should have data 'node2Inner1'"); + // assert(leafs[1].data!.blockId === "node2Inner2", "leafs[1] should have data 'node2Inner2'"); +}); + +test("balanceNode - collapses nodes with single grandchild 3", () => { + let node3 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, undefined, { blockId: "node3" }), + ]), + ]), + ]); + const newNode3 = balanceNode(node3); + assert(newNode3 !== undefined, "newNode3 should not be undefined"); + node3 = newNode3; + assert(node3.children === undefined, "node3 should have no children"); + assert(node3.data!.blockId === "node3", "node3 should have data 'node3'"); +}); + +test("balanceNode - collapses nodes with single grandchild 4", () => { + let node4 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Column, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node4Inner1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node4Inner2" }), + ]), + ]), + ]), + ]); + const newNode4 = balanceNode(node4); + assert(newNode4 !== undefined, "newNode4 should not be undefined"); + node4 = newNode4; + assert(node4.children!.length === 1, "node4 should have one child"); + assert(node4.children![0].children!.length === 2, "node4 should have two grandchildren"); + assert( + node4.children[0].children![0].data!.blockId === "node4Inner1", + "node4's first child should have data 'node4Inner1'" + ); +}); + +test("findNextInsertLocation", () => { + const node1 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + ]); + + const insertLoc1 = findNextInsertLocation(node1, 5); + assert(insertLoc1.node.id === node1.id, "should insert into node1"); + assert(insertLoc1.index === 4, "should insert into index 4 of node1"); + + const node2Inner5 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node2Inner5" }); + const node2 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + node2Inner5, + ]); + + const insertLoc2 = findNextInsertLocation(node2, 5); + assert(insertLoc2.node.id === node2Inner5.id, "should insert into node2Inner5"); + assert(insertLoc2.index === 1, "should insert into index 1 of node2Inner1"); + + const node3Inner5 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + ]); + const node3Inner4 = newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node3Inner4" }); + const node3 = newLayoutNode(FlexDirection.Row, undefined, [ + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + newLayoutNode(FlexDirection.Row, undefined, undefined, { blockId: "node1" }), + node3Inner4, + node3Inner5, + ]); + + const insertLoc3 = findNextInsertLocation(node3, 5); + assert(insertLoc3.node.id === node3Inner4.id, "should insert into node3Inner4"); + assert(insertLoc3.index === 1, "should insert into index 1 of node3Inner4"); +}); diff --git a/frontend/layout/tests/layoutTree.test.ts b/frontend/layout/tests/layoutTree.test.ts new file mode 100644 index 000000000..85f9b707e --- /dev/null +++ b/frontend/layout/tests/layoutTree.test.ts @@ -0,0 +1,84 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { assert, test } from "vitest"; +import { newLayoutNode } from "../lib/layoutNode"; +import { computeMoveNode, moveNode } from "../lib/layoutTree"; +import { + DropDirection, + LayoutTreeActionType, + LayoutTreeComputeMoveNodeAction, + LayoutTreeMoveNodeAction, +} from "../lib/types"; +import { newLayoutTreeState } from "./model"; + +test("layoutTreeStateReducer - compute move", () => { + let treeState = newLayoutTreeState(newLayoutNode(undefined, undefined, undefined, { blockId: "root" })); + assert(treeState.rootNode.data!.blockId === "root", "root should have no children and should have data"); + let node1 = newLayoutNode(undefined, undefined, undefined, { blockId: "node1" }); + let pendingAction = computeMoveNode(treeState, { + type: LayoutTreeActionType.ComputeMove, + nodeId: treeState.rootNode.id, + nodeToMoveId: node1.id, + direction: DropDirection.Bottom, + }); + const insertOperation = pendingAction as LayoutTreeMoveNodeAction; + assert(insertOperation.node === node1, "insert operation node should equal node1"); + assert(!insertOperation.parentId, "insert operation parent should not be defined"); + assert(insertOperation.index === 1, "insert operation index should equal 1"); + assert(insertOperation.insertAtRoot, "insert operation insertAtRoot should be true"); + moveNode(treeState, insertOperation); + assert( + treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 2, + "root node should now have no data and should have two children" + ); + assert(treeState.rootNode.children![1].data!.blockId === "node1", "root's second child should be node1"); + + let node2 = newLayoutNode(undefined, undefined, undefined, { blockId: "node2" }); + pendingAction = computeMoveNode(treeState, { + type: LayoutTreeActionType.ComputeMove, + nodeId: node1.id, + nodeToMoveId: node2.id, + direction: DropDirection.Bottom, + }); + const insertOperation2 = pendingAction as LayoutTreeMoveNodeAction; + assert(insertOperation2.node === node2, "insert operation node should equal node2"); + assert(insertOperation2.parentId === node1.id, "insert operation parent id should be node1 id"); + assert(insertOperation2.index === 1, "insert operation index should equal 1"); + assert(!insertOperation2.insertAtRoot, "insert operation insertAtRoot should be false"); + moveNode(treeState, insertOperation2); + assert( + treeState.rootNode.data === undefined && treeState.rootNode.children!.length === 2, + "root node should still have three children" + ); + assert(treeState.rootNode.children![1].children!.length === 2, "root's second child should now have two children"); +}); + +test("computeMove - noop action", () => { + let nodeToMove = newLayoutNode(undefined, undefined, undefined, { blockId: "nodeToMove" }); + let treeState = newLayoutTreeState( + newLayoutNode(undefined, undefined, [ + nodeToMove, + newLayoutNode(undefined, undefined, undefined, { blockId: "otherNode" }), + ]) + ); + let moveAction: LayoutTreeComputeMoveNodeAction = { + type: LayoutTreeActionType.ComputeMove, + nodeId: treeState.rootNode.id, + nodeToMoveId: nodeToMove.id, + direction: DropDirection.Left, + }; + let pendingAction = computeMoveNode(treeState, moveAction); + + assert(pendingAction === undefined, "inserting a node to the left of itself should not produce a pendingAction"); + + moveAction = { + type: LayoutTreeActionType.ComputeMove, + nodeId: treeState.rootNode.id, + nodeToMoveId: nodeToMove.id, + direction: DropDirection.Right, + }; + + pendingAction = computeMoveNode(treeState, moveAction); + assert(pendingAction === undefined, "inserting a node to the right of itself should not produce a pendingAction"); +}); diff --git a/frontend/layout/tests/model.ts b/frontend/layout/tests/model.ts new file mode 100644 index 000000000..1ed4509e8 --- /dev/null +++ b/frontend/layout/tests/model.ts @@ -0,0 +1,11 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { LayoutNode, LayoutTreeState } from "../lib/types"; + +export function newLayoutTreeState(rootNode: LayoutNode): LayoutTreeState { + return { + rootNode, + generation: 0, + }; +} diff --git a/frontend/layout/tests/utils.test.ts b/frontend/layout/tests/utils.test.ts new file mode 100644 index 000000000..aa439bc23 --- /dev/null +++ b/frontend/layout/tests/utils.test.ts @@ -0,0 +1,108 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { assert, test } from "vitest"; +import { DropDirection, FlexDirection } from "../lib/types"; +import { determineDropDirection, reverseFlexDirection } from "../lib/utils"; + +test("determineDropDirection", () => { + const dimensions: Dimensions = { + top: 0, + left: 0, + height: 5, + width: 5, + }; + + assert.equal( + determineDropDirection(dimensions, { + x: 2.5, + y: 1.5, + }), + DropDirection.Top + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 2.5, + y: 3.5, + }), + DropDirection.Bottom + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 3.5, + y: 2.5, + }), + DropDirection.Right + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 1.5, + y: 2.5, + }), + DropDirection.Left + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 2.5, + y: 0.5, + }), + DropDirection.OuterTop + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 4.5, + y: 2.5, + }), + DropDirection.OuterRight + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 2.5, + y: 4.5, + }), + DropDirection.OuterBottom + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 0.5, + y: 2.5, + }), + DropDirection.OuterLeft + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 2.5, + y: 2.5, + }), + DropDirection.Center + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 2.51, + y: 2.51, + }), + DropDirection.Center + ); + + assert.equal( + determineDropDirection(dimensions, { + x: 1.5, + y: 1.5, + }), + undefined + ); +}); + +test("reverseFlexDirection", () => { + assert.equal(reverseFlexDirection(FlexDirection.Row), FlexDirection.Column); + assert.equal(reverseFlexDirection(FlexDirection.Column), FlexDirection.Row); +}); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts new file mode 100644 index 000000000..e751e55ac --- /dev/null +++ b/frontend/types/custom.d.ts @@ -0,0 +1,299 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type * as jotai from "jotai"; +import type * as rxjs from "rxjs"; + +declare global { + type GlobalAtomsType = { + windowId: jotai.Atom; // readonly + clientId: jotai.Atom; // readonly + client: jotai.Atom; // driven from WOS + uiContext: jotai.Atom; // driven from windowId, activetabid, etc. + waveWindow: jotai.Atom; // driven from WOS + workspace: jotai.Atom; // driven from WOS + fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket + settingsAtom: jotai.Atom; // derrived from fullConfig + tabAtom: jotai.Atom; // driven from WOS + activeTabId: jotai.Atom; // derrived from windowDataAtom + isFullScreen: jotai.PrimitiveAtom; + controlShiftDelayAtom: jotai.PrimitiveAtom; + prefersReducedMotionAtom: jotai.Atom; + updaterStatusAtom: jotai.PrimitiveAtom; + typeAheadModalAtom: jotai.PrimitiveAtom; + modalOpen: jotai.PrimitiveAtom; + allConnStatus: jotai.Atom; + flashErrors: jotai.PrimitiveAtom; + }; + + type WritableWaveObjectAtom = jotai.WritableAtom; + + type ThrottledValueAtom = jotai.WritableAtom], void>; + + type AtomWithThrottle = { + currentValueAtom: jotai.Atom; + throttledValueAtom: ThrottledValueAtom; + }; + + type DebouncedValueAtom = jotai.WritableAtom], void>; + + type AtomWithDebounce = { + currentValueAtom: jotai.Atom; + debouncedValueAtom: DebouncedValueAtom; + }; + + type SplitAtom = Atom[]>; + type WritableSplitAtom = WritableAtom[], [SplitAtomAction], void>; + + type TabLayoutData = { + blockId: string; + }; + + type ElectronApi = { + getAuthKey(): string; + getIsDev(): boolean; + getCursorPoint: () => Electron.Point; + getPlatform: () => NodeJS.Platform; + getEnv: (varName: string) => string; + getUserName: () => string; + getHostName: () => string; + getAboutModalDetails: () => AboutModalDetails; + showContextMenu: (menu?: ElectronContextMenuItem[]) => void; + onContextMenuClick: (callback: (id: string) => void) => void; + onNavigate: (callback: (url: string) => void) => void; + onIframeNavigate: (callback: (url: string) => void) => void; + downloadFile: (path: string) => void; + openExternal: (url: string) => void; + onFullScreenChange: (callback: (isFullScreen: boolean) => void) => void; + onUpdaterStatusChange: (callback: (status: UpdaterStatus) => void) => void; + getUpdaterStatus: () => UpdaterStatus; + installAppUpdate: () => void; + onMenuItemAbout: (callback: () => void) => void; + updateWindowControlsOverlay: (rect: Dimensions) => void; + onReinjectKey: (callback: (waveEvent: WaveKeyboardEvent) => void) => void; + setWebviewFocus: (focusedId: number) => void; // focusedId si the getWebContentsId of the webview + registerGlobalWebviewKeys: (keys: string[]) => void; + onControlShiftStateUpdate: (callback: (state: boolean) => void) => void; + }; + + type ElectronContextMenuItem = { + id: string; // unique id, used for communication + label: string; + role?: string; // electron role (optional) + type?: "separator" | "normal" | "submenu"; + submenu?: ElectronContextMenuItem[]; + }; + + type ContextMenuItem = { + label?: string; + type?: "separator" | "normal" | "submenu"; + role?: string; // electron role (optional) + click?: () => void; // not required if role is set + submenu?: ContextMenuItem[]; + }; + + type KeyPressDecl = { + mods: { + Cmd?: boolean; + Option?: boolean; + Shift?: boolean; + Ctrl?: boolean; + Alt?: boolean; + Meta?: boolean; + }; + key: string; + keyType: string; + }; + + interface WaveKeyboardEvent { + type: "keydown" | "keyup" | "keypress" | "unknown"; + /** + * Equivalent to KeyboardEvent.key. + */ + key: string; + /** + * Equivalent to KeyboardEvent.code. + */ + code: string; + /** + * Equivalent to KeyboardEvent.shiftKey. + */ + shift: boolean; + /** + * Equivalent to KeyboardEvent.controlKey. + */ + control: boolean; + /** + * Equivalent to KeyboardEvent.altKey. + */ + alt: boolean; + /** + * Equivalent to KeyboardEvent.metaKey. + */ + meta: boolean; + /** + * cmd is special, on mac it is meta, on windows it is alt + */ + cmd: boolean; + /** + * option is special, on mac it is alt, on windows it is meta + */ + option: boolean; + + repeat: boolean; + /** + * Equivalent to KeyboardEvent.location. + */ + location: number; + } + + type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; + + type HeaderElem = IconButtonDecl | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton | ConnectionButton; + + type IconButtonDecl = { + elemtype: "iconbutton"; + icon: string | React.ReactNode; + iconColor?: string; + className?: string; + title?: string; + click?: (e: React.MouseEvent) => void; + longClick?: (e: React.MouseEvent) => void; + disabled?: boolean; + }; + + type HeaderTextButton = { + elemtype: "textbutton"; + text: string; + className?: string; + onClick?: (e: React.MouseEvent) => void; + }; + + type HeaderText = { + elemtype: "text"; + text: string; + ref?: React.MutableRefObject; + className?: string; + onClick?: () => void; + }; + + type HeaderInput = { + elemtype: "input"; + value: string; + className?: string; + isDisabled?: boolean; + ref?: React.MutableRefObject; + onChange?: (e: React.ChangeEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + }; + + type HeaderDiv = { + elemtype: "div"; + className?: string; + children: HeaderElem[]; + onMouseOver?: (e: React.MouseEvent) => void; + onMouseOut?: (e: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent) => void; + }; + + type ConnectionButton = { + elemtype: "connectionbutton"; + icon: string; + text: string; + iconColor: string; + onClick?: (e: React.MouseEvent) => void; + connected: boolean; + }; + + interface ViewModel { + viewType: string; + viewIcon?: jotai.Atom; + viewName?: jotai.Atom; + viewText?: jotai.Atom; + preIconButton?: jotai.Atom; + endIconButtons?: jotai.Atom; + blockBg?: jotai.Atom; + manageConnection?: jotai.Atom; + + onBack?: () => void; + onForward?: () => void; + onSearchChange?: (text: string) => void; + onSearch?: (text: string) => void; + getSettingsMenuItems?: () => ContextMenuItem[]; + giveFocus?: () => boolean; + keyDownHandler?: (e: WaveKeyboardEvent) => boolean; + } + + type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; + + // jotai doesn't export this type :/ + type Loadable = { state: "loading" } | { state: "hasData"; data: T } | { state: "hasError"; error: unknown }; + + interface Dimensions { + width: number; + height: number; + left: number; + top: number; + } + + type TypeAheadModalType = { [key: string]: boolean }; + + interface AboutModalDetails { + version: string; + buildTime: number; + } + + type BlockComponentModel = { + openSwitchConnection?: () => void; + viewModel: ViewModel; + }; + + type ConnStatusType = "connected" | "connecting" | "disconnected" | "error" | "init"; + + interface SuggestionBaseItem { + label: string; + value: string; + icon?: string | React.ReactNode; + } + + interface SuggestionConnectionItem extends SuggestionBaseItem { + status: ConnStatusType; + iconColor: string; + onSelect?: (_: string) => void; + } + + interface SuggestionConnectionScope { + headerText?: string; + items: SuggestionConnectionItem[]; + } + + type SuggestionsType = SuggestionConnectionItem | SuggestionConnectionScope; + + type MarkdownResolveOpts = { + connName: string; + baseDir: string; + }; + + type FlashErrorType = { + id: string; + icon: string; + title: string; + message: string; + expiration: number; + }; + + interface AbstractWshClient { + recvRpcMessage(msg: RpcMessage): void; + } + + type ClientRpcEntry = { + reqId: string; + startTs: number; + command: string; + msgFn: (msg: RpcMessage) => void; + }; +} + +export {}; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts new file mode 100644 index 000000000..d58e16d66 --- /dev/null +++ b/frontend/types/gotypes.d.ts @@ -0,0 +1,696 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// generated by cmd/generate/main-generatets.go + +declare global { + + // waveobj.Block + type Block = WaveObj & { + blockdef: BlockDef; + runtimeopts?: RuntimeOpts; + stickers?: StickerType[]; + }; + + // blockcontroller.BlockControllerRuntimeStatus + type BlockControllerRuntimeStatus = { + blockid: string; + shellprocstatus?: string; + shellprocconnname?: string; + }; + + // waveobj.BlockDef + type BlockDef = { + files?: {[key: string]: FileDef}; + meta?: MetaType; + }; + + // wshrpc.BlockInfoData + type BlockInfoData = { + blockid: string; + tabid: string; + windowid: string; + meta: MetaType; + }; + + // webcmd.BlockInputWSCommand + type BlockInputWSCommand = { + wscommand: "blockinput"; + blockid: string; + inputdata64: string; + }; + + // waveobj.Client + type Client = WaveObj & { + windowids: string[]; + tosagreed?: number; + historymigrated?: boolean; + }; + + // wshrpc.CommandAppendIJsonData + type CommandAppendIJsonData = { + zoneid: string; + filename: string; + data: {[key: string]: any}; + }; + + // wshrpc.CommandAuthenticateRtnData + type CommandAuthenticateRtnData = { + routeid: string; + }; + + // wshrpc.CommandBlockInputData + type CommandBlockInputData = { + blockid: string; + inputdata64?: string; + signame?: string; + termsize?: TermSize; + }; + + // wshrpc.CommandBlockSetViewData + type CommandBlockSetViewData = { + blockid: string; + view: string; + }; + + // wshrpc.CommandControllerResyncData + type CommandControllerResyncData = { + forcerestart?: boolean; + tabid: string; + blockid: string; + rtopts?: RuntimeOpts; + }; + + // wshrpc.CommandCreateBlockData + type CommandCreateBlockData = { + tabid: string; + blockdef: BlockDef; + rtopts?: RuntimeOpts; + magnified?: boolean; + }; + + // wshrpc.CommandDeleteBlockData + type CommandDeleteBlockData = { + blockid: string; + }; + + // wshrpc.CommandEventReadHistoryData + type CommandEventReadHistoryData = { + event: string; + scope: string; + maxitems: number; + }; + + // wshrpc.CommandFileData + type CommandFileData = { + zoneid: string; + filename: string; + data64?: string; + }; + + // wshrpc.CommandGetMetaData + type CommandGetMetaData = { + oref: ORef; + }; + + // wshrpc.CommandMessageData + type CommandMessageData = { + oref: ORef; + message: string; + }; + + // wshrpc.CommandRemoteStreamFileData + type CommandRemoteStreamFileData = { + path: string; + byterange?: string; + }; + + // wshrpc.CommandRemoteStreamFileRtnData + type CommandRemoteStreamFileRtnData = { + fileinfo?: FileInfo[]; + data64?: string; + }; + + // wshrpc.CommandRemoteWriteFileData + type CommandRemoteWriteFileData = { + path: string; + data64: string; + createmode?: number; + }; + + // wshrpc.CommandResolveIdsData + type CommandResolveIdsData = { + blockid: string; + ids: string[]; + }; + + // wshrpc.CommandResolveIdsRtnData + type CommandResolveIdsRtnData = { + resolvedids: {[key: string]: ORef}; + }; + + // wshrpc.CommandSetMetaData + type CommandSetMetaData = { + oref: ORef; + meta: MetaType; + }; + + // wshrpc.CommandWebSelectorData + type CommandWebSelectorData = { + windowid: string; + blockid: string; + tabid: string; + selector: string; + opts?: WebSelectorOpts; + }; + + // wconfig.ConfigError + type ConfigError = { + file: string; + err: string; + }; + + // wshrpc.ConnStatus + type ConnStatus = { + status: string; + connection: string; + connected: boolean; + hasconnected: boolean; + activeconnnum: number; + error?: string; + }; + + // wshrpc.CpuDataRequest + type CpuDataRequest = { + id: string; + count: number; + }; + + // waveobj.FileDef + type FileDef = { + filetype?: string; + path?: string; + url?: string; + content?: string; + meta?: {[key: string]: any}; + }; + + // wshrpc.FileInfo + type FileInfo = { + path: string; + dir: string; + name: string; + notfound?: boolean; + size: number; + mode: number; + modestr: string; + modtime: number; + isdir?: boolean; + mimetype?: string; + readonly?: boolean; + }; + + // filestore.FileOptsType + type FileOptsType = { + maxsize?: number; + circular?: boolean; + ijson?: boolean; + ijsonbudget?: number; + }; + + // wconfig.FullConfigType + type FullConfigType = { + settings: SettingsType; + mimetypes: {[key: string]: MimeTypeConfigType}; + defaultwidgets: {[key: string]: WidgetConfigType}; + widgets: {[key: string]: WidgetConfigType}; + presets: {[key: string]: MetaType}; + termthemes: {[key: string]: TermThemeType}; + configerrors: ConfigError[]; + }; + + // fileservice.FullFile + type FullFile = { + info: FileInfo; + data64: string; + }; + + // waveobj.LayoutActionData + type LayoutActionData = { + actiontype: string; + blockid: string; + nodesize?: number; + indexarr?: number[]; + focused: boolean; + magnified: boolean; + }; + + // waveobj.LayoutState + type LayoutState = WaveObj & { + rootnode?: any; + magnifiednodeid?: string; + focusednodeid?: string; + leaforder?: LeafOrderEntry[]; + pendingbackendactions?: LayoutActionData[]; + }; + + // waveobj.LeafOrderEntry + type LeafOrderEntry = { + nodeid: string; + blockid: string; + }; + + // waveobj.MetaTSType + type MetaType = { + view?: string; + controller?: string; + title?: string; + file?: string; + url?: string; + connection?: string; + edit?: boolean; + history?: string[]; + "history:forward"?: string[]; + "display:name"?: string; + "display:order"?: number; + icon?: string; + "icon:color"?: string; + frame?: boolean; + "frame:*"?: boolean; + "frame:bordercolor"?: string; + "frame:bordercolor:focused"?: string; + cmd?: string; + "cmd:*"?: boolean; + "cmd:interactive"?: boolean; + "cmd:login"?: boolean; + "cmd:runonstart"?: boolean; + "cmd:clearonstart"?: boolean; + "cmd:clearonrestart"?: boolean; + "cmd:env"?: {[key: string]: string}; + "cmd:cwd"?: string; + "cmd:nowsh"?: boolean; + "graph:*"?: boolean; + "graph:numpoints"?: number; + "graph:metrics"?: string[]; + bg?: string; + "bg:*"?: boolean; + "bg:opacity"?: number; + "bg:blendmode"?: string; + "term:*"?: boolean; + "term:fontsize"?: number; + "term:fontfamily"?: string; + "term:mode"?: string; + "term:theme"?: string; + count?: number; + }; + + // tsgenmeta.MethodMeta + type MethodMeta = { + Desc: string; + ArgNames: string[]; + ReturnDesc: string; + }; + + // wconfig.MimeTypeConfigType + type MimeTypeConfigType = { + icon: string; + color: string; + }; + + // waveobj.ORef + type ORef = string; + + // wshrpc.OpenAIOptsType + type OpenAIOptsType = { + model: string; + apitoken: string; + baseurl?: string; + maxtokens?: number; + maxchoices?: number; + timeout?: number; + }; + + // wshrpc.OpenAIPacketType + type OpenAIPacketType = { + type: string; + model?: string; + created?: number; + finish_reason?: string; + usage?: OpenAIUsageType; + index?: number; + text?: string; + error?: string; + }; + + // wshrpc.OpenAIPromptMessageType + type OpenAIPromptMessageType = { + role: string; + content: string; + name?: string; + }; + + // wshrpc.OpenAIUsageType + type OpenAIUsageType = { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + + // wshrpc.OpenAiStreamRequest + type OpenAiStreamRequest = { + clientid?: string; + opts: OpenAIOptsType; + prompt: OpenAIPromptMessageType[]; + }; + + // waveobj.Point + type Point = { + x: number; + y: number; + }; + + // wshutil.RpcMessage + type RpcMessage = { + command?: string; + reqid?: string; + resid?: string; + timeout?: number; + route?: string; + source?: string; + cont?: boolean; + cancel?: boolean; + error?: string; + datatype?: string; + data?: any; + }; + + // wshrpc.RpcOpts + type RpcOpts = { + timeout?: number; + noresponse?: boolean; + route?: string; + }; + + // waveobj.RuntimeOpts + type RuntimeOpts = { + termsize?: TermSize; + winsize?: WinSize; + }; + + // webcmd.SetBlockTermSizeWSCommand + type SetBlockTermSizeWSCommand = { + wscommand: "setblocktermsize"; + blockid: string; + termsize: TermSize; + }; + + // wconfig.SettingsType + type SettingsType = { + "ai:*"?: boolean; + "ai:baseurl"?: string; + "ai:apitoken"?: string; + "ai:name"?: string; + "ai:model"?: string; + "ai:maxtokens"?: number; + "ai:timeoutms"?: number; + "term:*"?: boolean; + "term:fontsize"?: number; + "term:fontfamily"?: string; + "term:disablewebgl"?: boolean; + "editor:minimapenabled"?: boolean; + "editor:stickyscrollenabled"?: boolean; + "web:*"?: boolean; + "web:openlinksinternally"?: boolean; + "blockheader:*"?: boolean; + "blockheader:showblockids"?: boolean; + "autoupdate:*"?: boolean; + "autoupdate:enabled"?: boolean; + "autoupdate:intervalms"?: number; + "autoupdate:installonquit"?: boolean; + "autoupdate:channel"?: string; + "widget:*"?: boolean; + "widget:showhelp"?: boolean; + "window:*"?: boolean; + "window:transparent"?: boolean; + "window:blur"?: boolean; + "window:opacity"?: number; + "window:bgcolor"?: string; + "window:reducedmotion"?: boolean; + "window:tilegapsize"?: number; + "telemetry:*"?: boolean; + "telemetry:enabled"?: boolean; + }; + + // waveobj.StickerClickOptsType + type StickerClickOptsType = { + sendinput?: string; + createblock?: BlockDef; + }; + + // waveobj.StickerDisplayOptsType + type StickerDisplayOptsType = { + icon: string; + imgsrc: string; + svgblob?: string; + }; + + // waveobj.StickerType + type StickerType = { + stickertype: string; + style: {[key: string]: any}; + clickopts?: StickerClickOptsType; + display: StickerDisplayOptsType; + }; + + // wps.SubscriptionRequest + type SubscriptionRequest = { + event: string; + scopes?: string[]; + allscopes?: boolean; + }; + + // waveobj.Tab + type Tab = WaveObj & { + name: string; + layoutstate: string; + blockids: string[]; + }; + + // waveobj.TermSize + type TermSize = { + rows: number; + cols: number; + }; + + // wconfig.TermThemeType + type TermThemeType = { + "display:name": string; + "display:order": number; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; + gray: string; + cmdtext: string; + foreground: string; + selectionBackground: string; + background: string; + cursorAccent: string; + }; + + // wshrpc.TimeSeriesData + type TimeSeriesData = { + ts: number; + values: {[key: string]: number}; + }; + + // waveobj.UIContext + type UIContext = { + windowid: string; + activetabid: string; + }; + + // userinput.UserInputRequest + type UserInputRequest = { + requestid: string; + querytext: string; + responsetype: string; + title: string; + markdown: boolean; + timeoutms: number; + checkboxmsg: string; + publictext: boolean; + }; + + // userinput.UserInputResponse + type UserInputResponse = { + type: string; + requestid: string; + text?: string; + confirm?: boolean; + errormsg?: string; + checkboxstat?: boolean; + }; + + // vdom.Elem + type VDomElem = { + id?: string; + tag: string; + props?: {[key: string]: any}; + children?: VDomElem[]; + text?: string; + }; + + // vdom.VDomFuncType + type VDomFuncType = { + #func: string; + #stopPropagation?: boolean; + #preventDefault?: boolean; + #keys?: string[]; + }; + + // vdom.VDomRefType + type VDomRefType = { + #ref: string; + current: any; + }; + + type WSCommandType = { + wscommand: string; + } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand ); + + // eventbus.WSEventType + type WSEventType = { + eventtype: string; + oref?: string; + data: any; + }; + + // wps.WSFileEventData + type WSFileEventData = { + zoneid: string; + filename: string; + fileop: string; + data64: string; + }; + + // webcmd.WSRpcCommand + type WSRpcCommand = { + wscommand: "rpc"; + message: RpcMessage; + }; + + // wconfig.WatcherUpdate + type WatcherUpdate = { + fullconfig: FullConfigType; + }; + + // wps.WaveEvent + type WaveEvent = { + event: string; + scopes?: string[]; + sender?: string; + persist?: number; + data?: any; + }; + + // filestore.WaveFile + type WaveFile = { + zoneid: string; + name: string; + opts: FileOptsType; + createdts: number; + size: number; + modts: number; + meta: {[key: string]: any}; + }; + + // waveobj.WaveObj + type WaveObj = { + otype: string; + oid: string; + version: number; + meta: MetaType; + }; + + // waveobj.WaveObjUpdate + type WaveObjUpdate = { + updatetype: string; + otype: string; + oid: string; + obj?: WaveObj; + }; + + // waveobj.Window + type WaveWindow = WaveObj & { + workspaceid: string; + activetabid: string; + pos: Point; + winsize: WinSize; + lastfocusts: number; + }; + + // service.WebCallType + type WebCallType = { + service: string; + method: string; + uicontext?: UIContext; + args: any[]; + }; + + // service.WebReturnType + type WebReturnType = { + success?: boolean; + error?: string; + data?: any; + updates?: WaveObjUpdate[]; + }; + + // wshrpc.WebSelectorOpts + type WebSelectorOpts = { + all?: boolean; + inner?: boolean; + }; + + // wconfig.WidgetConfigType + type WidgetConfigType = { + "display:order"?: number; + icon?: string; + color?: string; + label?: string; + description?: string; + blockdef: BlockDef; + }; + + // waveobj.WinSize + type WinSize = { + width: number; + height: number; + }; + + // waveobj.Workspace + type Workspace = WaveObj & { + name: string; + tabids: string[]; + }; + + // wshrpc.WshServerCommandMeta + type WshServerCommandMeta = { + commandtype: string; + }; + +} + +export {} diff --git a/frontend/util/endpoints.ts b/frontend/util/endpoints.ts new file mode 100644 index 000000000..bb0c3b99d --- /dev/null +++ b/frontend/util/endpoints.ts @@ -0,0 +1,12 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getEnv } from "./getenv"; +import { lazy } from "./util"; + +export const WebServerEndpointVarName = "WAVE_SERVER_WEB_ENDPOINT"; +export const WSServerEndpointVarName = "WAVE_SERVER_WS_ENDPOINT"; + +export const getWebServerEndpoint = lazy(() => `http://${getEnv(WebServerEndpointVarName)}`); + +export const getWSServerEndpoint = lazy(() => `ws://${getEnv(WSServerEndpointVarName)}`); diff --git a/frontend/util/fetchutil.ts b/frontend/util/fetchutil.ts new file mode 100644 index 000000000..7a32eab8d --- /dev/null +++ b/frontend/util/fetchutil.ts @@ -0,0 +1,22 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Utility to abstract the fetch function so the Electron net module can be used when available. + +let net: Electron.Net; + +if (typeof window === "undefined") { + try { + import("electron").then(({ net: electronNet }) => (net = electronNet)); + } catch (e) { + // do nothing + } +} + +export function fetch(input: string | GlobalRequest | URL, init?: RequestInit): Promise { + if (net) { + return net.fetch(input.toString(), init); + } else { + return globalThis.fetch(input, init); + } +} diff --git a/frontend/util/focusutil.ts b/frontend/util/focusutil.ts new file mode 100644 index 000000000..a5c9bb16d --- /dev/null +++ b/frontend/util/focusutil.ts @@ -0,0 +1,70 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0s + +import * as util from "./util"; + +export function findBlockId(element: HTMLElement): string | null { + let current: HTMLElement = element; + while (current) { + if (current.hasAttribute("data-blockid")) { + return current.getAttribute("data-blockid"); + } + current = current.parentElement; + } + return null; +} + +export function getElemAsStr(elem: EventTarget) { + if (elem == null) { + return "null"; + } + if (!(elem instanceof HTMLElement)) { + if (elem instanceof Text) { + elem = elem.parentElement; + } + if (!(elem instanceof HTMLElement)) { + return "unknown"; + } + } + const blockId = findBlockId(elem); + let rtn = elem.tagName.toLowerCase(); + if (!util.isBlank(elem.id)) { + rtn += "#" + elem.id; + } + if (!util.isBlank(elem.className)) { + rtn += "." + elem.className; + } + if (blockId != null) { + rtn += ` [${blockId.substring(0, 8)}]`; + } + return rtn; +} + +export function hasSelection() { + const sel = document.getSelection(); + return sel && sel.rangeCount > 0 && !sel.isCollapsed; +} + +export function focusedBlockId(): string { + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + const blockId = findBlockId(focused); + if (blockId) { + return blockId; + } + } + const sel = document.getSelection(); + if (sel && sel.anchorNode) { + let anchor = sel.anchorNode; + if (anchor instanceof Text) { + anchor = anchor.parentElement; + } + if (anchor instanceof HTMLElement) { + const blockId = findBlockId(anchor); + if (blockId) { + return blockId; + } + } + } + return null; +} diff --git a/frontend/util/fontutil.ts b/frontend/util/fontutil.ts new file mode 100644 index 000000000..4e90b5c6c --- /dev/null +++ b/frontend/util/fontutil.ts @@ -0,0 +1,176 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +let isJetBrainsMonoLoaded = false; +let isLatoFontLoaded = false; +let isHackFontLoaded = false; +let isHackNerdFontLoaded = false; +let isBaseFontsLoaded = false; +let isFiraCodeLoaded = false; +let isInterFontLoaded = false; + +function addToFontFaceSet(fontFaceSet: FontFaceSet, fontFace: FontFace) { + // any cast to work around typing issue + (fontFaceSet as any).add(fontFace); +} + +function loadJetBrainsMonoFont() { + if (isJetBrainsMonoLoaded) { + return; + } + isJetBrainsMonoLoaded = true; + const jbmFontNormal = new FontFace("JetBrains Mono", "url('fonts/jetbrains-mono-v13-latin-regular.woff2')", { + style: "normal", + weight: "400", + }); + const jbmFont200 = new FontFace("JetBrains Mono", "url('fonts/jetbrains-mono-v13-latin-200.woff2')", { + style: "normal", + weight: "200", + }); + const jbmFont700 = new FontFace("JetBrains Mono", "url('fonts/jetbrains-mono-v13-latin-700.woff2')", { + style: "normal", + weight: "700", + }); + addToFontFaceSet(document.fonts, jbmFontNormal); + addToFontFaceSet(document.fonts, jbmFont200); + addToFontFaceSet(document.fonts, jbmFont700); + jbmFontNormal.load(); + jbmFont200.load(); + jbmFont700.load(); +} + +function loadLatoFont() { + if (isLatoFontLoaded) { + return; + } + isLatoFontLoaded = true; + const latoFont = new FontFace("Lato", "url('fonts/lato-regular.woff')", { + style: "normal", + weight: "400", + }); + const latoFontBold = new FontFace("Lato", "url('fonts/lato-bold.woff')", { + style: "normal", + weight: "700", + }); + addToFontFaceSet(document.fonts, latoFont); + addToFontFaceSet(document.fonts, latoFontBold); + latoFont.load(); + latoFontBold.load(); +} + +function loadFiraCodeFont() { + if (isFiraCodeLoaded) { + return; + } + isFiraCodeLoaded = true; + const firaCodeRegular = new FontFace("Fira Code", "url('fonts/firacode-regular.woff2')", { + style: "normal", + weight: "400", + }); + const firaCodeBold = new FontFace("Fira Code", "url('fonts/firacode-bold.woff2')", { + style: "normal", + weight: "700", + }); + addToFontFaceSet(document.fonts, firaCodeRegular); + addToFontFaceSet(document.fonts, firaCodeBold); + firaCodeRegular.load(); + firaCodeBold.load(); +} + +function loadHackNerdFont() { + if (isHackNerdFontLoaded) { + return; + } + isHackFontLoaded = true; + const hackRegular = new FontFace("Hack", "url('fonts/hacknerdmono-regular.ttf')", { + style: "normal", + weight: "400", + }); + const hackBold = new FontFace("Hack", "url('fonts/hacknerdmono-bold.ttf')", { + style: "normal", + weight: "700", + }); + const hackItalic = new FontFace("Hack", "url('fonts/hacknerdmono-italic.ttf')", { + style: "italic", + weight: "400", + }); + const hackBoldItalic = new FontFace("Hack", "url('fonts/hacknerdmono-bolditalic.ttf')", { + style: "italic", + weight: "700", + }); + addToFontFaceSet(document.fonts, hackRegular); + addToFontFaceSet(document.fonts, hackBold); + addToFontFaceSet(document.fonts, hackItalic); + addToFontFaceSet(document.fonts, hackBoldItalic); + hackRegular.load(); + hackBold.load(); + hackItalic.load(); + hackBoldItalic.load(); +} + +function loadHackFont() { + if (isHackFontLoaded) { + return; + } + isHackFontLoaded = true; + const hackRegular = new FontFace("Hack", "url('fonts/hack-regular.woff2')", { + style: "normal", + weight: "400", + }); + const hackBold = new FontFace("Hack", "url('fonts/hack-bold.woff2')", { + style: "normal", + weight: "700", + }); + const hackItalic = new FontFace("Hack", "url('fonts/hack-italic.woff2')", { + style: "italic", + weight: "400", + }); + const hackBoldItalic = new FontFace("Hack", "url('fonts/hack-bolditalic.woff2')", { + style: "italic", + weight: "700", + }); + addToFontFaceSet(document.fonts, hackRegular); + addToFontFaceSet(document.fonts, hackBold); + addToFontFaceSet(document.fonts, hackItalic); + addToFontFaceSet(document.fonts, hackBoldItalic); + hackRegular.load(); + hackBold.load(); + hackItalic.load(); + hackBoldItalic.load(); +} + +function loadInterFont() { + if (isInterFontLoaded) { + return; + } + isInterFontLoaded = true; + const interFont = new FontFace("Inter", "url('fonts/inter-variable.woff2')", { + style: "normal", + weight: "100 900", + }); + addToFontFaceSet(document.fonts, interFont); + interFont.load(); +} + +function loadBaseFonts() { + if (isBaseFontsLoaded) { + return; + } + isBaseFontsLoaded = true; + const mmFont = new FontFace("Martian Mono", "url('fonts/MartianMono-VariableFont_wdth,wght.ttf')", { + style: "normal", + weight: "normal", + }); + addToFontFaceSet(document.fonts, mmFont); + mmFont.load(); +} + +function loadFonts() { + loadBaseFonts(); + loadInterFont(); + loadJetBrainsMonoFont(); + loadHackNerdFont(); + loadFiraCodeFont(); +} + +export { loadFonts }; diff --git a/frontend/util/getenv.ts b/frontend/util/getenv.ts new file mode 100644 index 000000000..ef5904939 --- /dev/null +++ b/frontend/util/getenv.ts @@ -0,0 +1,29 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getApi } from "@/app/store/global"; + +function getWindow(): Window { + return globalThis.window; +} + +function getProcess(): NodeJS.Process { + return globalThis.process; +} + +/** + * Gets an environment variable from the host process, either directly or via IPC if called from the browser. + * @param paramName The name of the environment variable to attempt to retrieve. + * @returns The value of the environment variable or null if not present. + */ +export function getEnv(paramName: string): string { + const win = getWindow(); + if (win != null) { + return getApi().getEnv(paramName); + } + const proc = getProcess(); + if (proc != null) { + return proc.env[paramName]; + } + return null; +} diff --git a/frontend/util/historyutil.ts b/frontend/util/historyutil.ts new file mode 100644 index 000000000..2fa490023 --- /dev/null +++ b/frontend/util/historyutil.ts @@ -0,0 +1,75 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as util from "@/util/util"; + +const MaxHistory = 20; + +// this needs to be fixed for windows +function getParentDirectory(path: string): string { + if (util.isBlank(path) == null) { + // this not great, ideally we'd never be passed a null path + return "/"; + } + if (path == "/") { + return "/"; + } + const splitPath = path.split("/"); + splitPath.pop(); + if (splitPath.length == 1 && splitPath[0] == "") { + return "/"; + } + const newPath = splitPath.join("/"); + return newPath; +} + +function goHistoryBack(curValKey: "url" | "file", curVal: string, meta: MetaType, backToParent: boolean): MetaType { + const rtnMeta: MetaType = {}; + const history = (meta?.history ?? []).slice(); + const historyForward = (meta?.["history:forward"] ?? []).slice(); + if (history == null || history.length == 0) { + if (backToParent) { + const parentDir = getParentDirectory(curVal); + if (parentDir == curVal) { + return null; + } + historyForward.unshift(curVal); + while (historyForward.length > MaxHistory) { + historyForward.pop(); + } + return { [curValKey]: parentDir, "history:forward": historyForward }; + } else { + return null; + } + } + const lastVal = history.pop(); + historyForward.unshift(curVal); + return { [curValKey]: lastVal, history: history, "history:forward": historyForward }; +} + +function goHistoryForward(curValKey: "url" | "file", curVal: string, meta: MetaType): MetaType { + const rtnMeta: MetaType = {}; + let history = (meta?.history ?? []).slice(); + const historyForward = (meta?.["history:forward"] ?? []).slice(); + if (historyForward == null || historyForward.length == 0) { + return null; + } + const lastVal = historyForward.shift(); + history.push(curVal); + if (history.length > MaxHistory) { + history.shift(); + } + return { [curValKey]: lastVal, history: history, "history:forward": historyForward }; +} + +function goHistory(curValKey: "url" | "file", curVal: string, newVal: string, meta: MetaType): MetaType { + const rtnMeta: MetaType = {}; + const history = (meta?.history ?? []).slice(); + history.push(curVal); + if (history.length > MaxHistory) { + history.shift(); + } + return { [curValKey]: newVal, history: history, "history:forward": [] }; +} + +export { getParentDirectory, goHistory, goHistoryBack, goHistoryForward }; diff --git a/frontend/util/ijson.ts b/frontend/util/ijson.ts new file mode 100644 index 000000000..5cccafdae --- /dev/null +++ b/frontend/util/ijson.ts @@ -0,0 +1,253 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// ijson values are regular JSON values: string, number, boolean, null, object, array +// path is an array of strings and numbers + +type PathType = (string | number)[]; + +var simplePathStrRe = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function formatPath(path: PathType): string { + if (path.length == 0) { + return "$"; + } + let pathStr = "$"; + for (const pathPart of path) { + if (typeof pathPart === "string") { + if (simplePathStrRe.test(pathPart)) { + pathStr += "." + pathPart; + } else { + pathStr += "[" + JSON.stringify(pathPart) + "]"; + } + } else if (typeof pathPart === "number") { + pathStr += "[" + pathPart + "]"; + } else { + pathStr += ".*"; + } + } + return pathStr; +} + +function isArray(obj: any): boolean { + return obj != null && Array.isArray(obj); +} + +function isObject(obj: any): boolean { + return obj != null && obj instanceof Object && !isArray(obj); +} + +function getPath(obj: any, path: PathType): any { + let cur = obj; + for (const pathPart of path) { + if (cur == null) { + return null; + } + if (typeof pathPart === "string") { + if (isObject(cur)) { + cur = cur[pathPart]; + } else { + return null; + } + } else if (typeof pathPart === "number") { + if (isArray(cur)) { + cur = cur[pathPart]; + } else { + return null; + } + } else { + throw new Error("Invalid path part: " + pathPart); + } + } + return cur; +} + +type SetPathOpts = { + force?: boolean; + remove?: boolean; + combinefn?: (oldVal: any, newVal: any, opts: SetPathOpts) => any; +}; + +function combineFn_arrayAppend(oldVal: any, newVal: any, opts: SetPathOpts): any { + if (oldVal == null) { + return [newVal]; + } + if (!isArray(oldVal) && !opts.force) { + throw new Error("Cannot append to non-array: " + oldVal); + } + if (!isArray(oldVal)) { + return [newVal]; + } + oldVal.push(newVal); + return oldVal; +} + +function checkPath(path: PathType): boolean { + if (!isArray(path)) { + return false; + } + for (const pathPart of path) { + if (typeof pathPart !== "string" && typeof pathPart !== "number") { + return false; + } + } + return true; +} + +function setPath(obj: any, path: PathType, value: any, opts: SetPathOpts) { + if (opts == null) { + opts = {}; + } + if (opts.remove && value != null) { + throw new Error("Cannot set value and remove at the same time"); + } + if (path == null) { + path = []; + } + if (!checkPath(path)) { + throw new Error("Invalid path: " + formatPath(path)); + } + return setPathInternal(obj, path, value, opts); +} + +function isEmpty(obj: any): boolean { + if (obj == null) { + return true; + } + if (isArray(obj)) { + return obj.length == 0; + } + if (isObject(obj)) { + for (const _ in obj) { + return false; + } + return true; + } + return false; +} + +function removeFromArr(arr: any[], idx: number): any[] { + console.log("removefromarray", arr, idx); + if (idx >= arr.length) { + return arr; + } + if (idx == arr.length - 1) { + arr.pop(); + if (arr.length == 0) { + return null; + } + return arr; + } + arr[idx] = null; + return arr; +} + +function setPathInternal(obj: any, path: PathType, value: any, opts: SetPathOpts): any { + if (path.length == 0) { + if (opts.combinefn != null) { + return opts.combinefn(obj, value, opts); + } + return value; + } + const pathPart = path[0]; + if (typeof pathPart === "string") { + if (obj == null) { + if (opts.remove) { + return null; + } + obj = {}; + } + if (!isObject(obj)) { + if (opts.force) { + obj = {}; + } else { + throw new Error("Cannot set path on non-object: " + obj); + } + } + if (opts.remove && path.length == 1) { + delete obj[pathPart]; + if (isEmpty(obj)) { + return null; + } + return obj; + } + const newVal = setPathInternal(obj[pathPart], path.slice(1), value, opts); + if (opts.remove && newVal == null) { + delete obj[pathPart]; + if (isEmpty(obj)) { + return null; + } + return obj; + } + obj[pathPart] = newVal; + return obj; + } else if (typeof pathPart === "number") { + if (pathPart < 0 || !Number.isInteger(pathPart)) { + throw new Error("Invalid path part: " + pathPart); + } + if (obj == null) { + if (opts.remove) { + return null; + } + obj = []; + } + if (!isArray(obj)) { + if (opts.force) { + obj = []; + } else { + throw new Error("Cannot set path on non-array: " + obj); + } + } + if (opts.remove && path.length == 1) { + return removeFromArr(obj, pathPart); + } + const newVal = setPathInternal(obj[pathPart], path.slice(1), value, opts); + if (opts.remove && newVal == null) { + return removeFromArr(obj, pathPart); + } + obj[pathPart] = newVal; + return obj; + } else { + throw new Error("Invalid path part: " + pathPart); + } +} + +function getCommandPath(command: object): PathType { + if (command["path"] == null) { + return []; + } + return command["path"]; +} + +function applyCommand(data: any, command: any): any { + if (command == null) { + throw new Error("Invalid command (null)"); + } + if (!isObject(command)) { + throw new Error("Invalid command (not an object): " + command); + } + const commandType = command.type; + if (commandType == null) { + throw new Error("Invalid command (no type): " + command); + } + const path = getCommandPath(command); + if (!checkPath(path)) { + throw new Error("Invalid command path: " + formatPath(path)); + } + switch (commandType) { + case "set": + return setPath(data, path, command.value, null); + + case "del": + return setPath(data, path, null, { remove: true }); + + case "append": + return setPath(data, path, command.value, { combinefn: combineFn_arrayAppend }); + + default: + throw new Error("Invalid command type: " + commandType); + } +} + +export { applyCommand, combineFn_arrayAppend, getPath, setPath }; +export type { PathType, SetPathOpts }; diff --git a/frontend/util/isdev.ts b/frontend/util/isdev.ts new file mode 100644 index 000000000..029632394 --- /dev/null +++ b/frontend/util/isdev.ts @@ -0,0 +1,20 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getEnv } from "./getenv"; +import { lazy } from "./util"; + +export const WaveDevVarName = "WAVETERM_DEV"; +export const WaveDevViteVarName = "WAVETERM_DEV_VITE"; + +/** + * Determines whether the current app instance is a development build. + * @returns True if the current app instance is a development build. + */ +export const isDev = lazy(() => !!getEnv(WaveDevVarName)); + +/** + * Determines whether the current app instance is running via the Vite dev server. + * @returns True if the app is running via the Vite dev server. + */ +export const isDevVite = lazy(() => !!getEnv(WaveDevViteVarName)); diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts new file mode 100644 index 000000000..253a35451 --- /dev/null +++ b/frontend/util/keyutil.ts @@ -0,0 +1,234 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as util from "./util"; + +const KeyTypeCodeRegex = /c{(.*)}/; +const KeyTypeKey = "key"; +const KeyTypeCode = "code"; + +let PLATFORM: NodeJS.Platform = "darwin"; +const PlatformMacOS = "darwin"; + +function setKeyUtilPlatform(platform: NodeJS.Platform) { + PLATFORM = platform; +} + +function getKeyUtilPlatform(): NodeJS.Platform { + return PLATFORM; +} + +function keydownWrapper( + fn: (waveEvent: WaveKeyboardEvent) => boolean +): (event: KeyboardEvent | React.KeyboardEvent) => void { + return (event: KeyboardEvent | React.KeyboardEvent) => { + const waveEvent = adaptFromReactOrNativeKeyEvent(event); + const rtnVal = fn(waveEvent); + if (rtnVal) { + event.preventDefault(); + event.stopPropagation(); + } + }; +} + +function parseKey(key: string): { key: string; type: string } { + let regexMatch = key.match(KeyTypeCodeRegex); + if (regexMatch != null && regexMatch.length > 1) { + let code = regexMatch[1]; + return { key: code, type: KeyTypeCode }; + } else if (regexMatch != null) { + console.log("error: regexMatch is not null yet there is no captured group: ", regexMatch, key); + } + return { key: key, type: KeyTypeKey }; +} + +function parseKeyDescription(keyDescription: string): KeyPressDecl { + let rtn = { key: "", mods: {} } as KeyPressDecl; + let keys = keyDescription.replace(/[()]/g, "").split(":"); + for (let key of keys) { + if (key == "Cmd") { + rtn.mods.Cmd = true; + } else if (key == "Shift") { + rtn.mods.Shift = true; + } else if (key == "Ctrl") { + rtn.mods.Ctrl = true; + } else if (key == "Option") { + rtn.mods.Option = true; + } else if (key == "Alt") { + rtn.mods.Alt = true; + } else if (key == "Meta") { + rtn.mods.Meta = true; + } else { + let { key: parsedKey, type: keyType } = parseKey(key); + rtn.key = parsedKey; + rtn.keyType = keyType; + if (rtn.keyType == KeyTypeKey && key.length == 1) { + // check for if key is upper case + // TODO what about unicode upper case? + if (/[A-Z]/.test(key.charAt(0))) { + // this key is an upper case A - Z - we should apply the shift key, even if it wasn't specified + rtn.mods.Shift = true; + } else if (key == " ") { + rtn.key = "Space"; + // we allow " " and "Space" to be mapped to Space key + } + } + } + } + return rtn; +} + +function notMod(keyPressMod: boolean, eventMod: boolean) { + return (keyPressMod && !eventMod) || (eventMod && !keyPressMod); +} + +function isCharacterKeyEvent(event: WaveKeyboardEvent): boolean { + if (event.alt || event.meta || event.control) { + return false; + } + return util.countGraphemes(event.key) == 1; +} + +const inputKeyMap = new Map([ + ["Backspace", true], + ["Delete", true], + ["Enter", true], + ["Space", true], + ["Tab", true], + ["ArrowLeft", true], + ["ArrowRight", true], + ["ArrowUp", true], + ["ArrowDown", true], + ["Home", true], + ["End", true], + ["PageUp", true], + ["PageDown", true], + ["Cmd:a", true], + ["Cmd:c", true], + ["Cmd:v", true], + ["Cmd:x", true], + ["Cmd:z", true], + ["Cmd:Shift:z", true], + ["Cmd:ArrowLeft", true], + ["Cmd:ArrowRight", true], + ["Cmd:Backspace", true], + ["Cmd:Delete", true], + ["Shift:ArrowLeft", true], + ["Shift:ArrowRight", true], + ["Shift:ArrowUp", true], + ["Shift:ArrowDown", true], + ["Shift:Home", true], + ["Shift:End", true], + ["Cmd:Shift:ArrowLeft", true], + ["Cmd:Shift:ArrowRight", true], + ["Cmd:Shift:ArrowUp", true], + ["Cmd:Shift:ArrowDown", true], +]); + +function isInputEvent(event: WaveKeyboardEvent): boolean { + if (isCharacterKeyEvent(event)) { + return true; + } + for (let key of inputKeyMap.keys()) { + if (checkKeyPressed(event, key)) { + return true; + } + } +} + +function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): boolean { + let keyPress = parseKeyDescription(keyDescription); + if (!keyPress.mods.Alt && notMod(keyPress.mods.Option, event.option)) { + return false; + } + if (!keyPress.mods.Meta && notMod(keyPress.mods.Cmd, event.cmd)) { + return false; + } + if (notMod(keyPress.mods.Shift, event.shift)) { + return false; + } + if (notMod(keyPress.mods.Ctrl, event.control)) { + return false; + } + if (keyPress.mods.Alt && !event.alt) { + return false; + } + if (keyPress.mods.Meta && !event.meta) { + return false; + } + let eventKey = ""; + let descKey = keyPress.key; + if (keyPress.keyType == KeyTypeCode) { + eventKey = event.code; + } + if (keyPress.keyType == KeyTypeKey) { + eventKey = event.key; + if (eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) { + // key is upper case A-Z, this means shift is applied, we want to allow + // "Shift:e" as well as "Shift:E" or "E" + eventKey = eventKey.toLocaleLowerCase(); + descKey = descKey.toLocaleLowerCase(); + } else if (eventKey == " ") { + eventKey = "Space"; + // a space key is shown as " ", we want users to be able to set space key as "Space" or " ", whichever they prefer + } + } + if (descKey != eventKey) { + return false; + } + return true; +} + +function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): WaveKeyboardEvent { + let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; + rtn.control = event.ctrlKey; + rtn.shift = event.shiftKey; + rtn.cmd = PLATFORM == PlatformMacOS ? event.metaKey : event.altKey; + rtn.option = PLATFORM == PlatformMacOS ? event.altKey : event.metaKey; + rtn.meta = event.metaKey; + rtn.alt = event.altKey; + rtn.code = event.code; + rtn.key = event.key; + rtn.location = event.location; + if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { + rtn.type = event.type; + } else { + rtn.type = "unknown"; + } + rtn.repeat = event.repeat; + return rtn; +} + +function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { + let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; + if (event.type == "keyUp") { + rtn.type = "keyup"; + } else if (event.type == "keyDown") { + rtn.type = "keydown"; + } else { + rtn.type = "unknown"; + } + rtn.control = event.control; + rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt; + rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta; + rtn.meta = event.meta; + rtn.alt = event.alt; + rtn.shift = event.shift; + rtn.repeat = event.isAutoRepeat; + rtn.location = event.location; + rtn.code = event.code; + rtn.key = event.key; + return rtn; +} + +export { + adaptFromElectronKeyEvent, + adaptFromReactOrNativeKeyEvent, + checkKeyPressed, + getKeyUtilPlatform, + isCharacterKeyEvent, + isInputEvent, + keydownWrapper, + parseKeyDescription, + setKeyUtilPlatform, +}; diff --git a/frontend/util/util.ts b/frontend/util/util.ts new file mode 100644 index 000000000..72729e855 --- /dev/null +++ b/frontend/util/util.ts @@ -0,0 +1,294 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0s + +import base64 from "base64-js"; +import clsx from "clsx"; +import { Atom, atom, Getter, SetStateAction, Setter, useAtomValue } from "jotai"; +import { debounce, throttle } from "throttle-debounce"; + +function isBlank(str: string): boolean { + return str == null || str == ""; +} + +function base64ToString(b64: string): string { + if (b64 == null) { + return null; + } + if (b64 == "") { + return ""; + } + const stringBytes = base64.toByteArray(b64); + return new TextDecoder().decode(stringBytes); +} + +function stringToBase64(input: string): string { + const stringBytes = new TextEncoder().encode(input); + return base64.fromByteArray(stringBytes); +} + +function base64ToArray(b64: string): Uint8Array { + const rawStr = atob(b64); + const rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length)); + for (let i = 0; i < rawStr.length; i++) { + rtnArr[i] = rawStr.charCodeAt(i); + } + return rtnArr; +} + +function boundNumber(num: number, min: number, max: number): number { + if (num == null || typeof num != "number" || isNaN(num)) { + return null; + } + return Math.min(Math.max(num, min), max); +} + +// works for json-like objects (arrays, objects, strings, numbers, booleans) +function jsonDeepEqual(v1: any, v2: any): boolean { + if (v1 === v2) { + return true; + } + if (typeof v1 !== typeof v2) { + return false; + } + if ((v1 == null && v2 != null) || (v1 != null && v2 == null)) { + return false; + } + if (typeof v1 === "object") { + if (Array.isArray(v1) && Array.isArray(v2)) { + if (v1.length !== v2.length) { + return false; + } + for (let i = 0; i < v1.length; i++) { + if (!jsonDeepEqual(v1[i], v2[i])) { + return false; + } + } + return true; + } else { + const keys1 = Object.keys(v1); + const keys2 = Object.keys(v2); + if (keys1.length !== keys2.length) { + return false; + } + for (let key of keys1) { + if (!jsonDeepEqual(v1[key], v2[key])) { + return false; + } + } + return true; + } + } + return false; +} + +function makeIconClass(icon: string, fw: boolean, opts?: { spin: boolean }): string { + if (icon == null) { + return null; + } + if (icon.match(/^(solid@)?[a-z0-9-]+$/)) { + // strip off "solid@" prefix if it exists + icon = icon.replace(/^solid@/, ""); + return clsx(`fa fa-sharp fa-solid fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null); + } + if (icon.match(/^regular@[a-z0-9-]+$/)) { + // strip off the "regular@" prefix if it exists + icon = icon.replace(/^regular@/, ""); + return clsx(`fa fa-sharp fa-regular fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null); + } + return null; +} + +/** + * A wrapper function for running a promise and catching any errors + * @param f The promise to run + */ +function fireAndForget(f: () => Promise) { + f()?.catch((e) => { + console.log("fireAndForget error", e); + }); +} + +const promiseWeakMap = new WeakMap, ResolvedValue>(); + +type ResolvedValue = { + pending: boolean; + error: any; + value: T; +}; + +// returns the value, pending state, and error of a promise +function getPromiseState(promise: Promise): [T, boolean, any] { + if (promise == null) { + return [null, false, null]; + } + if (promiseWeakMap.has(promise)) { + const value = promiseWeakMap.get(promise); + return [value.value, value.pending, value.error]; + } + const value: ResolvedValue = { + pending: true, + error: null, + value: null, + }; + promise.then( + (result) => { + value.pending = false; + value.error = null; + value.value = result; + }, + (error) => { + value.pending = false; + value.error = error; + } + ); + promiseWeakMap.set(promise, value); + return [value.value, value.pending, value.error]; +} + +// returns the value of a promise, or a default value if the promise is still pending (or had an error) +function getPromiseValue(promise: Promise, def: T): T { + const [value, pending, error] = getPromiseState(promise); + if (pending || error) { + return def; + } + return value; +} + +function jotaiLoadableValue(value: Loadable, def: T): T { + if (value.state === "hasData") { + return value.data; + } + return def; +} + +const NullAtom = atom(null); + +function useAtomValueSafe(atom: Atom | Atom>): T { + if (atom == null) { + return useAtomValue(NullAtom) as T; + } + return useAtomValue(atom); +} + +/** + * Simple wrapper function that lazily evaluates the provided function and caches its result for future calls. + * @param callback The function to lazily run. + * @returns The result of the function. + */ +const lazy = any>(callback: T) => { + let res: ReturnType; + let processed = false; + return (...args: Parameters): ReturnType => { + if (processed) return res; + res = callback(...args); + processed = true; + return res; + }; +}; + +/** + * Generates an external link by appending the given URL to the "https://extern?" endpoint. + * + * @param {string} url - The URL to be encoded and appended to the external link. + * @return {string} The generated external link. + */ +function makeExternLink(url: string): string { + return "https://extern?" + encodeURIComponent(url); +} + +function atomWithThrottle(initialValue: T, delayMilliseconds = 500): AtomWithThrottle { + // DO NOT EXPORT currentValueAtom as using this atom to set state can cause + // inconsistent state between currentValueAtom and throttledValueAtom + const _currentValueAtom = atom(initialValue); + + const throttledValueAtom = atom(initialValue, (get, set, update: SetStateAction) => { + const prevValue = get(_currentValueAtom); + const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update; + set(_currentValueAtom, nextValue); + throttleUpdate(get, set); + }); + + const throttleUpdate = throttle(delayMilliseconds, (get: Getter, set: Setter) => { + const curVal = get(_currentValueAtom); + set(throttledValueAtom, curVal); + }); + + return { + currentValueAtom: atom((get) => get(_currentValueAtom)), + throttledValueAtom, + }; +} + +function atomWithDebounce(initialValue: T, delayMilliseconds = 500): AtomWithDebounce { + // DO NOT EXPORT currentValueAtom as using this atom to set state can cause + // inconsistent state between currentValueAtom and debouncedValueAtom + const _currentValueAtom = atom(initialValue); + + const debouncedValueAtom = atom(initialValue, (get, set, update: SetStateAction) => { + const prevValue = get(_currentValueAtom); + const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update; + set(_currentValueAtom, nextValue); + debounceUpdate(get, set); + }); + + const debounceUpdate = debounce(delayMilliseconds, (get: Getter, set: Setter) => { + const curVal = get(_currentValueAtom); + set(debouncedValueAtom, curVal); + }); + + return { + currentValueAtom: atom((get) => get(_currentValueAtom)), + debouncedValueAtom, + }; +} + +function getPrefixedSettings(settings: SettingsType, prefix: string): SettingsType { + const rtn: SettingsType = {}; + if (settings == null || isBlank(prefix)) { + return rtn; + } + for (const key in settings) { + if (key == prefix || key.startsWith(prefix + ":")) { + rtn[key] = settings[key]; + } + } + return rtn; +} + +function countGraphemes(str: string): number { + if (str == null) { + return 0; + } + // this exists (need to hack TS to get it to not show an error) + const seg = new (Intl as any).Segmenter(undefined, { granularity: "grapheme" }); + return Array.from(seg.segment(str)).length; +} + +function makeConnRoute(conn: string): string { + if (isBlank(conn)) { + return "conn:local"; + } + return "conn:" + conn; +} + +export { + atomWithDebounce, + atomWithThrottle, + base64ToArray, + base64ToString, + boundNumber, + countGraphemes, + fireAndForget, + getPrefixedSettings, + getPromiseState, + getPromiseValue, + isBlank, + jotaiLoadableValue, + jsonDeepEqual, + lazy, + makeConnRoute, + makeExternLink, + makeIconClass, + stringToBase64, + useAtomValueSafe, +}; diff --git a/frontend/wave.ts b/frontend/wave.ts new file mode 100644 index 000000000..1d2db23af --- /dev/null +++ b/frontend/wave.ts @@ -0,0 +1,96 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { App } from "@/app/app"; +import { + registerControlShiftStateUpdateHandler, + registerElectronReinjectKeyHandler, + registerGlobalKeys, +} from "@/app/store/keymodel"; +import { modalsModel } from "@/app/store/modalmodel"; +import { FileService, ObjectService } from "@/app/store/services"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { initWshrpc, WindowRpcClient } from "@/app/store/wshrpcutil"; +import { loadMonaco } from "@/app/view/codeeditor/codeeditor"; +import { getLayoutModelForActiveTab } from "@/layout/index"; +import { + atoms, + countersClear, + countersPrint, + getApi, + globalStore, + initGlobal, + initGlobalWaveEventSubs, + loadConnStatus, + pushFlashError, + subscribeToConnEvents, +} from "@/store/global"; +import * as WOS from "@/store/wos"; +import { loadFonts } from "@/util/fontutil"; +import { setKeyUtilPlatform } from "@/util/keyutil"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; + +const platform = getApi().getPlatform(); +const urlParams = new URLSearchParams(window.location.search); +const windowId = urlParams.get("windowid"); +const clientId = urlParams.get("clientid"); + +console.log("Wave Starting"); +console.log("clientid", clientId, "windowid", windowId); + +initGlobal({ clientId, windowId, platform, environment: "renderer" }); + +setKeyUtilPlatform(platform); + +loadFonts(); +(window as any).WOS = WOS; +(window as any).globalStore = globalStore; +(window as any).globalAtoms = atoms; +(window as any).RpcApi = RpcApi; +(window as any).isFullScreen = false; +(window as any).countersPrint = countersPrint; +(window as any).countersClear = countersClear; +(window as any).getLayoutModelForActiveTab = getLayoutModelForActiveTab; +(window as any).pushFlashError = pushFlashError; +(window as any).modalsModel = modalsModel; + +document.title = `The Next Wave (${windowId.substring(0, 8)})`; + +document.addEventListener("DOMContentLoaded", async () => { + console.log("DOMContentLoaded"); + + // Init WPS event handlers + const globalWS = initWshrpc(windowId); + (window as any).globalWS = globalWS; + (window as any).WindowRpcClient = WindowRpcClient; + await loadConnStatus(); + initGlobalWaveEventSubs(); + subscribeToConnEvents(); + + // ensures client/window/workspace are loaded into the cache before rendering + const client = await WOS.loadAndPinWaveObject(WOS.makeORef("client", clientId)); + const waveWindow = await WOS.loadAndPinWaveObject(WOS.makeORef("window", windowId)); + await WOS.loadAndPinWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); + const initialTab = await WOS.loadAndPinWaveObject(WOS.makeORef("tab", waveWindow.activetabid)); + await WOS.loadAndPinWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); + + registerGlobalKeys(); + registerElectronReinjectKeyHandler(); + registerControlShiftStateUpdateHandler(); + setTimeout(loadMonaco, 30); + const fullConfig = await FileService.GetFullConfig(); + console.log("fullconfig", fullConfig); + globalStore.set(atoms.fullConfigAtom, fullConfig); + const prtn = ObjectService.SetActiveTab(waveWindow.activetabid); // no need to wait + prtn.catch((e) => { + console.log("error on initial SetActiveTab", e); + }); + const reactElem = createElement(App, null, null); + const elem = document.getElementById("main"); + const root = createRoot(elem); + document.fonts.ready.then(() => { + console.log("Wave First Render"); + root.render(reactElem); + }); +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock new file mode 100644 index 000000000..199c06d7f --- /dev/null +++ b/frontend/yarn.lock @@ -0,0 +1,919 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@^2.2.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.2": + version "7.24.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" + integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== + dependencies: + "@babel/highlight" "^7.24.2" + picocolors "^1.0.0" + +"@babel/compat-data@^7.23.5": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.4.tgz#6f102372e9094f25d908ca0d34fc74c74606059a" + integrity sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ== + +"@babel/core@^7.23.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.5.tgz#15ab5b98e101972d171aeef92ac70d8d6718f06a" + integrity sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" + "@babel/helper-compilation-targets" "^7.23.6" + "@babel/helper-module-transforms" "^7.24.5" + "@babel/helpers" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.5" + "@babel/types" "^7.24.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.5.tgz#e5afc068f932f05616b66713e28d0f04e99daeb3" + integrity sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA== + dependencies: + "@babel/types" "^7.24.5" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + +"@babel/helper-compilation-targets@^7.23.6": + version "7.23.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz#4d79069b16cbcf1461289eccfbbd81501ae39991" + integrity sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ== + dependencies: + "@babel/compat-data" "^7.23.5" + "@babel/helper-validator-option" "^7.23.5" + browserslist "^4.22.2" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-environment-visitor@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" + integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== + +"@babel/helper-function-name@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" + integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== + dependencies: + "@babel/template" "^7.22.15" + "@babel/types" "^7.23.0" + +"@babel/helper-hoist-variables@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" + integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-module-imports@^7.24.3": + version "7.24.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz#6ac476e6d168c7c23ff3ba3cf4f7841d46ac8128" + integrity sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg== + dependencies: + "@babel/types" "^7.24.0" + +"@babel/helper-module-transforms@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz#ea6c5e33f7b262a0ae762fd5986355c45f54a545" + integrity sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A== + dependencies: + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-module-imports" "^7.24.3" + "@babel/helper-simple-access" "^7.24.5" + "@babel/helper-split-export-declaration" "^7.24.5" + "@babel/helper-validator-identifier" "^7.24.5" + +"@babel/helper-plugin-utils@^7.24.0", "@babel/helper-plugin-utils@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz#a924607dd254a65695e5bd209b98b902b3b2f11a" + integrity sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ== + +"@babel/helper-simple-access@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz#50da5b72f58c16b07fbd992810be6049478e85ba" + integrity sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ== + dependencies: + "@babel/types" "^7.24.5" + +"@babel/helper-split-export-declaration@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz#b9a67f06a46b0b339323617c8c6213b9055a78b6" + integrity sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q== + dependencies: + "@babel/types" "^7.24.5" + +"@babel/helper-string-parser@^7.24.1": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" + integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== + +"@babel/helper-validator-identifier@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" + integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== + +"@babel/helper-validator-option@^7.23.5": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" + integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw== + +"@babel/helpers@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.5.tgz#fedeb87eeafa62b621160402181ad8585a22a40a" + integrity sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q== + dependencies: + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.5" + "@babel/types" "^7.24.5" + +"@babel/highlight@^7.24.2": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.5.tgz#bc0613f98e1dd0720e99b2a9ee3760194a704b6e" + integrity sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.5" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.24.0", "@babel/parser@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" + integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== + +"@babel/plugin-transform-react-jsx-self@^7.23.3": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.5.tgz#22cc7572947895c8e4cd034462e65d8ecf857756" + integrity sha512-RtCJoUO2oYrYwFPtR1/jkoBEcFuI1ae9a9IMxeyAVa3a1Ap4AnxmyIKG2b2FaJKqkidw/0cxRbWN+HOs6ZWd1w== + dependencies: + "@babel/helper-plugin-utils" "^7.24.5" + +"@babel/plugin-transform-react-jsx-source@^7.23.3": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz#a2dedb12b09532846721b5df99e52ef8dc3351d0" + integrity sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA== + dependencies: + "@babel/helper-plugin-utils" "^7.24.0" + +"@babel/template@^7.22.15", "@babel/template@^7.24.0": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" + integrity sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA== + dependencies: + "@babel/code-frame" "^7.23.5" + "@babel/parser" "^7.24.0" + "@babel/types" "^7.24.0" + +"@babel/traverse@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.5.tgz#972aa0bc45f16983bf64aa1f877b2dd0eea7e6f8" + integrity sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA== + dependencies: + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-function-name" "^7.23.0" + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/types" "^7.24.5" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.24.0", "@babel/types@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.5.tgz#7661930afc638a5383eb0c4aee59b74f38db84d7" + integrity sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ== + dependencies: + "@babel/helper-string-parser" "^7.24.1" + "@babel/helper-validator-identifier" "^7.24.5" + to-fast-properties "^2.0.0" + +"@esbuild/aix-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" + integrity sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g== + +"@esbuild/android-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" + integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== + +"@esbuild/android-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz#3b488c49aee9d491c2c8f98a909b785870d6e995" + integrity sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w== + +"@esbuild/android-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz#3b1628029e5576249d2b2d766696e50768449f98" + integrity sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg== + +"@esbuild/darwin-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz" + integrity sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA== + +"@esbuild/darwin-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz#90ed098e1f9dd8a9381695b207e1cff45540a0d0" + integrity sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA== + +"@esbuild/freebsd-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz#d71502d1ee89a1130327e890364666c760a2a911" + integrity sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw== + +"@esbuild/freebsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz#aa5ea58d9c1dd9af688b8b6f63ef0d3d60cea53c" + integrity sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw== + +"@esbuild/linux-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz#055b63725df678379b0f6db9d0fa85463755b2e5" + integrity sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A== + +"@esbuild/linux-arm@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz#76b3b98cb1f87936fbc37f073efabad49dcd889c" + integrity sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg== + +"@esbuild/linux-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" + integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== + +"@esbuild/linux-loong64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz#a6184e62bd7cdc63e0c0448b83801001653219c5" + integrity sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ== + +"@esbuild/linux-mips64el@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz#d08e39ce86f45ef8fc88549d29c62b8acf5649aa" + integrity sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA== + +"@esbuild/linux-ppc64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz#8d252f0b7756ffd6d1cbde5ea67ff8fd20437f20" + integrity sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg== + +"@esbuild/linux-riscv64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz#19f6dcdb14409dae607f66ca1181dd4e9db81300" + integrity sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg== + +"@esbuild/linux-s390x@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz#3c830c90f1a5d7dd1473d5595ea4ebb920988685" + integrity sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ== + +"@esbuild/linux-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz#86eca35203afc0d9de0694c64ec0ab0a378f6fff" + integrity sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw== + +"@esbuild/netbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz#e771c8eb0e0f6e1877ffd4220036b98aed5915e6" + integrity sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ== + +"@esbuild/openbsd-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz#9a795ae4b4e37e674f0f4d716f3e226dd7c39baf" + integrity sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ== + +"@esbuild/sunos-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz#7df23b61a497b8ac189def6e25a95673caedb03f" + integrity sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w== + +"@esbuild/win32-arm64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz#f1ae5abf9ca052ae11c1bc806fb4c0f519bacf90" + integrity sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ== + +"@esbuild/win32-ia32@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz#241fe62c34d8e8461cd708277813e1d0ba55ce23" + integrity sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ== + +"@esbuild/win32-x64@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc" + integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@rollup/rollup-android-arm-eabi@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz#1a32112822660ee104c5dd3a7c595e26100d4c2d" + integrity sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ== + +"@rollup/rollup-android-arm64@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz#5aeef206d65ff4db423f3a93f71af91b28662c5b" + integrity sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw== + +"@rollup/rollup-darwin-arm64@4.17.2": + version "4.17.2" + resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz" + integrity sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw== + +"@rollup/rollup-darwin-x64@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz#f64fc51ed12b19f883131ccbcea59fc68cbd6c0b" + integrity sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ== + +"@rollup/rollup-linux-arm-gnueabihf@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz#1a7641111be67c10111f7122d1e375d1226cbf14" + integrity sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A== + +"@rollup/rollup-linux-arm-musleabihf@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz#c93fd632923e0fee25aacd2ae414288d0b7455bb" + integrity sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg== + +"@rollup/rollup-linux-arm64-gnu@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz#fa531425dd21d058a630947527b4612d9d0b4a4a" + integrity sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A== + +"@rollup/rollup-linux-arm64-musl@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz#8acc16f095ceea5854caf7b07e73f7d1802ac5af" + integrity sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA== + +"@rollup/rollup-linux-powerpc64le-gnu@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz#94e69a8499b5cf368911b83a44bb230782aeb571" + integrity sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ== + +"@rollup/rollup-linux-riscv64-gnu@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz#7ef1c781c7e59e85a6ce261cc95d7f1e0b56db0f" + integrity sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg== + +"@rollup/rollup-linux-s390x-gnu@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz#f15775841c3232fca9b78cd25a7a0512c694b354" + integrity sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g== + +"@rollup/rollup-linux-x64-gnu@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz#b521d271798d037ad70c9f85dd97d25f8a52e811" + integrity sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ== + +"@rollup/rollup-linux-x64-musl@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz#9254019cc4baac35800991315d133cc9fd1bf385" + integrity sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q== + +"@rollup/rollup-win32-arm64-msvc@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz#27f65a89f6f52ee9426ec11e3571038e4671790f" + integrity sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA== + +"@rollup/rollup-win32-ia32-msvc@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz#a2fbf8246ed0bb014f078ca34ae6b377a90cb411" + integrity sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ== + +"@rollup/rollup-win32-x64-msvc@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz#5a2d08b81e8064b34242d5cc9973ef8dd1e60503" + integrity sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w== + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.8" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab" + integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.5.tgz#7b7502be0aa80cc4ef22978846b983edaafcd4dd" + integrity sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ== + dependencies: + "@babel/types" "^7.20.7" + +"@types/estree@1.0.5": + version "1.0.5" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +"@vitejs/plugin-react@^4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz#744d8e4fcb120fc3dbaa471dadd3483f5a304bb9" + integrity sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ== + dependencies: + "@babel/core" "^7.23.5" + "@babel/plugin-transform-react-jsx-self" "^7.23.3" + "@babel/plugin-transform-react-jsx-source" "^7.23.3" + "@types/babel__core" "^7.20.5" + react-refresh "^0.14.0" + +"@wailsio/runtime@latest": + version "3.0.0-alpha.22" + resolved "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.22.tgz" + integrity sha512-72i0cq17cLlbjHZyD3XT8VPxcDIK2RLLYaBwcUYGWCQHSuZFPdR0ZR2ghSeZnTey8h30PBtYqQzWyOTvOYN5Jw== + dependencies: + nanoid "^5.0.7" + +"@xterm/addon-fit@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz#bebf87fadd74e3af30fdcdeef47030e2592c6f55" + integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== + +"@xterm/xterm@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" + integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +browserslist@^4.22.2: + version "4.23.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" + integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== + dependencies: + caniuse-lite "^1.0.30001587" + electron-to-chromium "^1.4.668" + node-releases "^2.0.14" + update-browserslist-db "^1.0.13" + +caniuse-lite@^1.0.30001587: + version "1.0.30001617" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz#809bc25f3f5027ceb33142a7d6c40759d7a901eb" + integrity sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA== + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +copy-anything@^2.0.1: + version "2.0.6" + resolved "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz" + integrity sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw== + dependencies: + is-what "^3.14.1" + +debug@^4.1.0, debug@^4.3.1: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +electron-to-chromium@^1.4.668: + version "1.4.765" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.765.tgz#c43f651b94d9c309acf165cd0fc17e9b025de03d" + integrity sha512-70APzI2AGyJgcWVSnfJCytP2Gejptk6cIm0t5uuUfwdKN63xBIZBzD0N5l/s0hWr8tj0w/p6UaPz+hLAm+Orjw== + +errno@^0.1.1: + version "0.1.8" + resolved "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz" + integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== + dependencies: + prr "~1.0.1" + +esbuild@^0.20.1: + version "0.20.2" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz" + integrity sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g== + optionalDependencies: + "@esbuild/aix-ppc64" "0.20.2" + "@esbuild/android-arm" "0.20.2" + "@esbuild/android-arm64" "0.20.2" + "@esbuild/android-x64" "0.20.2" + "@esbuild/darwin-arm64" "0.20.2" + "@esbuild/darwin-x64" "0.20.2" + "@esbuild/freebsd-arm64" "0.20.2" + "@esbuild/freebsd-x64" "0.20.2" + "@esbuild/linux-arm" "0.20.2" + "@esbuild/linux-arm64" "0.20.2" + "@esbuild/linux-ia32" "0.20.2" + "@esbuild/linux-loong64" "0.20.2" + "@esbuild/linux-mips64el" "0.20.2" + "@esbuild/linux-ppc64" "0.20.2" + "@esbuild/linux-riscv64" "0.20.2" + "@esbuild/linux-s390x" "0.20.2" + "@esbuild/linux-x64" "0.20.2" + "@esbuild/netbsd-x64" "0.20.2" + "@esbuild/openbsd-x64" "0.20.2" + "@esbuild/sunos-x64" "0.20.2" + "@esbuild/win32-arm64" "0.20.2" + "@esbuild/win32-ia32" "0.20.2" + "@esbuild/win32-x64" "0.20.2" + +escalade@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +graceful-fs@^4.1.2: + version "4.2.11" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +image-size@~0.5.0: + version "0.5.5" + resolved "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz" + integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ== + +is-what@^3.14.1: + version "3.14.1" + resolved "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz" + integrity sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA== + +jotai@^2.8.0: + version "2.8.0" + resolved "https://registry.npmjs.org/jotai/-/jotai-2.8.0.tgz" + integrity sha512-yZNMC36FdLOksOr8qga0yLf14miCJlEThlp5DeFJNnqzm2+ZG7wLcJzoOyij5K6U6Xlc5ljQqPDlJRgqW0Y18g== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +less@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/less/-/less-4.2.0.tgz" + integrity sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA== + dependencies: + copy-anything "^2.0.1" + parse-node-version "^1.0.1" + tslib "^2.3.0" + optionalDependencies: + errno "^0.1.1" + graceful-fs "^4.1.2" + image-size "~0.5.0" + make-dir "^2.1.0" + mime "^1.4.1" + needle "^3.1.0" + source-map "~0.6.0" + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +make-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz" + integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +mime@^1.4.1: + version "1.6.0" + resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +nanoid@^5.0.7: + version "5.0.7" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz" + integrity sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ== + +needle@^3.1.0: + version "3.3.1" + resolved "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz" + integrity sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q== + dependencies: + iconv-lite "^0.6.3" + sax "^1.2.4" + +node-releases@^2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + +parse-node-version@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz" + integrity sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +postcss@^8.4.38: + version "8.4.38" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.2.0" + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz" + integrity sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw== + +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-refresh@^0.14.0: + version "0.14.2" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" + integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== + +react@^18.3.1: + version "18.3.1" + resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +rollup@^4.13.0: + version "4.17.2" + resolved "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz" + integrity sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.17.2" + "@rollup/rollup-android-arm64" "4.17.2" + "@rollup/rollup-darwin-arm64" "4.17.2" + "@rollup/rollup-darwin-x64" "4.17.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.17.2" + "@rollup/rollup-linux-arm-musleabihf" "4.17.2" + "@rollup/rollup-linux-arm64-gnu" "4.17.2" + "@rollup/rollup-linux-arm64-musl" "4.17.2" + "@rollup/rollup-linux-powerpc64le-gnu" "4.17.2" + "@rollup/rollup-linux-riscv64-gnu" "4.17.2" + "@rollup/rollup-linux-s390x-gnu" "4.17.2" + "@rollup/rollup-linux-x64-gnu" "4.17.2" + "@rollup/rollup-linux-x64-musl" "4.17.2" + "@rollup/rollup-win32-arm64-msvc" "4.17.2" + "@rollup/rollup-win32-ia32-msvc" "4.17.2" + "@rollup/rollup-win32-x64-msvc" "4.17.2" + fsevents "~2.3.2" + +rxjs@^7.8.1: + version "7.8.1" + resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sax@^1.2.4: + version "1.3.0" + resolved "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz" + integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA== + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +semver@^5.6.0: + version "5.7.2" + resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + +source-map@~0.6.0: + version "0.6.1" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== + +tslib@^2.1.0, tslib@^2.3.0: + version "2.6.2" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +update-browserslist-db@^1.0.13: + version "1.0.15" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.15.tgz#60ed9f8cba4a728b7ecf7356f641a31e3a691d97" + integrity sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA== + dependencies: + escalade "^3.1.2" + picocolors "^1.0.0" + +uuid@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + +vite@^5.0.0: + version "5.2.11" + resolved "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz" + integrity sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ== + dependencies: + esbuild "^0.20.1" + postcss "^8.4.38" + rollup "^4.13.0" + optionalDependencies: + fsevents "~2.3.3" + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..cfa26ed52 --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module github.com/wavetermdev/waveterm + +go 1.22.4 + +require ( + github.com/alexflint/go-filemutex v1.3.0 + github.com/creack/pty v1.1.21 + github.com/fsnotify/fsnotify v1.7.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-migrate/migrate/v4 v4.17.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/handlers v1.5.2 + github.com/gorilla/mux v1.8.1 + github.com/gorilla/websocket v1.5.3 + github.com/jmoiron/sqlx v1.4.0 + github.com/kevinburke/ssh_config v1.2.0 + github.com/mattn/go-sqlite3 v1.14.23 + github.com/mitchellh/mapstructure v1.5.0 + github.com/sashabaranov/go-openai v1.29.2 + github.com/sawka/txwrap v0.2.0 + github.com/shirou/gopsutil/v4 v4.24.8 + github.com/skeema/knownhosts v1.3.0 + github.com/spf13/cobra v1.8.1 + github.com/wavetermdev/htmltoken v0.1.0 + golang.org/x/crypto v0.27.0 + golang.org/x/term v0.24.0 +) + +require ( + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sys v0.25.0 // indirect +) + +replace github.com/kevinburke/ssh_config => github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 + +replace github.com/creack/pty => github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..548a6dbea --- /dev/null +++ b/go.sum @@ -0,0 +1,108 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM= +github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= +github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b h1:cLGKfKb1uk0hxI0Q8L83UAJPpeJ+gSpn3cCU/tjd3eg= +github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b/go.mod h1:KO+FcPtyLAiRC0hJwreJVvfwc7vnNz77UxBTIGHdPVk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sashabaranov/go-openai v1.29.2 h1:jYpp1wktFoOvxHnum24f/w4+DFzUdJnu83trr5+Slh0= +github.com/sashabaranov/go-openai v1.29.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94= +github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= +github.com/shirou/gopsutil/v4 v4.24.8 h1:pVQjIenQkIhqO81mwTaXjTzOMT7d3TZkf43PlVFHENI= +github.com/shirou/gopsutil/v4 v4.24.8/go.mod h1:wE0OrJtj4dG+hYkxqDH3QiBICdKSf04/npcvLLc/oRg= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= +github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q= +github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= +github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 h1:onqZrJVap1sm15AiIGTfWzdr6cEF0KdtddeuuOVhzyY= +github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.html b/index.html new file mode 100644 index 000000000..9c0e2ab0c --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + The Next Wave + + + + + + + + +
+ + diff --git a/package.json b/package.json new file mode 100644 index 000000000..ab6b82b51 --- /dev/null +++ b/package.json @@ -0,0 +1,135 @@ +{ + "name": "thenextwave", + "author": { + "name": "Command Line Inc", + "email": "info@commandline.dev" + }, + "productName": "TheNextWave", + "description": "An Open-Source, AI-Native, Terminal Built for Seamless Workflows", + "license": "Apache-2.0", + "version": "0.1.13", + "homepage": "https://waveterm.dev", + "build": { + "appId": "dev.commandline.thenextwave" + }, + "private": true, + "main": "./dist/main/index.js", + "type": "module", + "scripts": { + "dev": "electron-vite dev", + "start": "electron-vite preview", + "build:dev": "electron-vite build --mode development", + "build:prod": "electron-vite build --mode production", + "storybook": "storybook dev -p 6006 --no-open", + "build-storybook": "storybook build", + "coverage": "vitest run --coverage", + "test": "vitest", + "postinstall": "electron-builder install-app-deps" + }, + "devDependencies": { + "@chromatic-com/storybook": "^2.0.2", + "@eslint/js": "^9.10.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@storybook/addon-essentials": "^8.3.0", + "@storybook/addon-interactions": "^8.3.0", + "@storybook/addon-links": "^8.3.0", + "@storybook/blocks": "^8.3.0", + "@storybook/react": "^8.3.0", + "@storybook/react-vite": "^8.3.0", + "@storybook/test": "^8.3.0", + "@types/css-tree": "^2", + "@types/debug": "^4", + "@types/electron": "^1.6.10", + "@types/node": "^22.5.4", + "@types/papaparse": "^5", + "@types/pngjs": "^6.0.5", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@types/semver": "^7", + "@types/shell-quote": "^1", + "@types/sprintf-js": "^1", + "@types/throttle-debounce": "^5", + "@types/tinycolor2": "^1", + "@types/uuid": "^10.0.0", + "@types/ws": "^8", + "@vitejs/plugin-react-swc": "^3.7.0", + "@vitest/coverage-istanbul": "^2.1.1", + "electron": "^32.1.0", + "electron-builder": "^25.0.5", + "electron-vite": "^2.3.0", + "eslint": "^9.10.0", + "eslint-config-prettier": "^9.1.0", + "less": "^4.2.0", + "prettier": "^3.3.3", + "prettier-plugin-jsdoc": "^1.3.0", + "prettier-plugin-organize-imports": "^4.0.0", + "rollup-plugin-flow": "^1.1.1", + "semver": "^7.6.3", + "storybook": "^8.3.0", + "ts-node": "^10.9.2", + "tslib": "^2.6.3", + "tsx": "^4.19.1", + "typescript": "^5.6.2", + "typescript-eslint": "^8.5.0", + "vite": "^5.4.6", + "vite-plugin-image-optimizer": "^1.1.8", + "vite-plugin-static-copy": "^1.0.6", + "vite-plugin-svgr": "^4.2.0", + "vite-tsconfig-paths": "^5.0.1", + "vitest": "^2.1.1" + }, + "dependencies": { + "@monaco-editor/loader": "^1.4.0", + "@monaco-editor/react": "^4.6.0", + "@observablehq/plot": "^0.6.16", + "@react-hook/resize-observer": "^2.0.2", + "@table-nav/core": "^0.0.7", + "@table-nav/react": "^0.0.7", + "@tanstack/react-table": "^8.20.5", + "@types/color": "^3.0.6", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-serialize": "^0.13.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", + "base64-js": "^1.5.1", + "clsx": "^2.1.1", + "color": "^4.2.3", + "css-tree": "^3.0.0", + "dayjs": "^1.11.13", + "debug": "^4.3.7", + "electron-updater": "6.3.4", + "fast-average-color": "^9.4.0", + "htl": "^0.3.1", + "html-to-image": "^1.11.11", + "immer": "^10.1.1", + "jotai": "^2.9.3", + "monaco-editor": "^0.51.0", + "overlayscrollbars": "^2.10.0", + "overlayscrollbars-react": "^0.5.6", + "papaparse": "^5.4.1", + "pngjs": "^7.0.0", + "react": "^18.3.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "^18.3.1", + "react-frame-component": "^5.2.7", + "react-gauge-chart": "^0.5.1", + "react-markdown": "^9.0.1", + "rehype-highlight": "^7.0.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-slug": "^6.0.0", + "remark-flexible-toc": "^1.1.1", + "remark-gfm": "^4.0.0", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "sprintf-js": "^1.1.3", + "throttle-debounce": "^5.0.2", + "tinycolor2": "^1.6.0", + "use-device-pixel-ratio": "^1.1.2", + "winston": "^3.14.2", + "ws": "^8.18.0" + }, + "packageManager": "yarn@4.4.1" +} diff --git a/pkg/authkey/authkey.go b/pkg/authkey/authkey.go new file mode 100644 index 000000000..50c566d1a --- /dev/null +++ b/pkg/authkey/authkey.go @@ -0,0 +1,39 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authkey + +import ( + "fmt" + "net/http" + "os" +) + +var authkey string + +const AuthKeyEnv = "AUTH_KEY" +const AuthKeyHeader = "X-AuthKey" + +func ValidateIncomingRequest(r *http.Request) error { + reqAuthKey := r.Header.Get(AuthKeyHeader) + if reqAuthKey == "" { + return fmt.Errorf("no x-authkey header") + } + if reqAuthKey != GetAuthKey() { + return fmt.Errorf("x-authkey header is invalid") + } + return nil +} + +func SetAuthKeyFromEnv() error { + authkey = os.Getenv(AuthKeyEnv) + if authkey == "" { + return fmt.Errorf("no auth key found in environment variables") + } + os.Setenv(AuthKeyEnv, "") + return nil +} + +func GetAuthKey() string { + return authkey +} diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go new file mode 100644 index 000000000..282fdea2e --- /dev/null +++ b/pkg/blockcontroller/blockcontroller.go @@ -0,0 +1,635 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package blockcontroller + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "io/fs" + "log" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/remote" + "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/shellexec" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +const ( + BlockController_Shell = "shell" + BlockController_Cmd = "cmd" +) + +const ( + BlockFile_Term = "term" // used for main pty output + BlockFile_Html = "html" // used for alt html layout +) + +const ( + Status_Running = "running" + Status_Done = "done" +) + +const ( + DefaultTermMaxFileSize = 256 * 1024 + DefaultHtmlMaxFileSize = 256 * 1024 +) + +const DefaultTimeout = 2 * time.Second + +var globalLock = &sync.Mutex{} +var blockControllerMap = make(map[string]*BlockController) + +type BlockInputUnion struct { + InputData []byte `json:"inputdata,omitempty"` + SigName string `json:"signame,omitempty"` + TermSize *waveobj.TermSize `json:"termsize,omitempty"` +} + +type BlockController struct { + Lock *sync.Mutex + ControllerType string + TabId string + BlockId string + BlockDef *waveobj.BlockDef + CreatedHtmlFile bool + ShellProc *shellexec.ShellProc + ShellInputCh chan *BlockInputUnion + ShellProcStatus string +} + +type BlockControllerRuntimeStatus struct { + BlockId string `json:"blockid"` + ShellProcStatus string `json:"shellprocstatus,omitempty"` + ShellProcConnName string `json:"shellprocconnname,omitempty"` +} + +func (bc *BlockController) WithLock(f func()) { + bc.Lock.Lock() + defer bc.Lock.Unlock() + f() +} + +func (bc *BlockController) GetRuntimeStatus() *BlockControllerRuntimeStatus { + var rtn BlockControllerRuntimeStatus + bc.WithLock(func() { + rtn.BlockId = bc.BlockId + rtn.ShellProcStatus = bc.ShellProcStatus + if bc.ShellProc != nil { + rtn.ShellProcConnName = bc.ShellProc.ConnName + } + }) + return &rtn +} + +func (bc *BlockController) getShellProc() *shellexec.ShellProc { + bc.Lock.Lock() + defer bc.Lock.Unlock() + return bc.ShellProc +} + +type RunShellOpts struct { + TermSize waveobj.TermSize `json:"termsize,omitempty"` +} + +func (bc *BlockController) UpdateControllerAndSendUpdate(updateFn func() bool) { + var sendUpdate bool + bc.WithLock(func() { + sendUpdate = updateFn() + }) + if sendUpdate { + rtStatus := bc.GetRuntimeStatus() + log.Printf("sending blockcontroller update %#v\n", rtStatus) + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_ControllerStatus, + Scopes: []string{ + waveobj.MakeORef(waveobj.OType_Tab, bc.TabId).String(), + waveobj.MakeORef(waveobj.OType_Block, bc.BlockId).String(), + }, + Data: rtStatus, + }) + } +} + +func HandleTruncateBlockFile(blockId string, blockFile string) error { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + err := filestore.WFS.WriteFile(ctx, blockId, blockFile, nil) + if err == fs.ErrNotExist { + return nil + } + if err != nil { + return fmt.Errorf("error truncating blockfile: %w", err) + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockFile, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, blockId).String()}, + Data: &wps.WSFileEventData{ + ZoneId: blockId, + FileName: blockFile, + FileOp: wps.FileOp_Truncate, + }, + }) + return nil + +} + +func HandleAppendBlockFile(blockId string, blockFile string, data []byte) error { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + err := filestore.WFS.AppendData(ctx, blockId, blockFile, data) + if err != nil { + return fmt.Errorf("error appending to blockfile: %w", err) + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockFile, + Scopes: []string{ + waveobj.MakeORef(waveobj.OType_Block, blockId).String(), + }, + Data: &wps.WSFileEventData{ + ZoneId: blockId, + FileName: blockFile, + FileOp: wps.FileOp_Append, + Data64: base64.StdEncoding.EncodeToString(data), + }, + }) + return nil +} + +func (bc *BlockController) resetTerminalState() { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + var shouldTruncate bool + blockData, getBlockDataErr := wstore.DBMustGet[*waveobj.Block](ctx, bc.BlockId) + if getBlockDataErr == nil { + shouldTruncate = blockData.Meta.GetBool(waveobj.MetaKey_CmdClearOnRestart, false) + } + if shouldTruncate { + err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Term) + if err != nil { + log.Printf("error truncating term blockfile: %v\n", err) + } + return + } + // controller type = "shell" + var buf bytes.Buffer + // buf.WriteString("\x1b[?1049l") // disable alternative buffer + buf.WriteString("\x1b[0m") // reset attributes + buf.WriteString("\x1b[?25h") // show cursor + buf.WriteString("\x1b[?1000l") // disable mouse tracking + buf.WriteString("\r\n\r\n(restored terminal state)\r\n\r\n") + err := filestore.WFS.AppendData(ctx, bc.BlockId, BlockFile_Term, buf.Bytes()) + if err != nil { + log.Printf("error appending to blockfile (terminal reset): %v\n", err) + } +} + +func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta waveobj.MetaMapType) error { + // create a circular blockfile for the output + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + err := filestore.WFS.MakeFile(ctx, bc.BlockId, BlockFile_Term, nil, filestore.FileOptsType{MaxSize: DefaultTermMaxFileSize, Circular: true}) + if err != nil && err != fs.ErrExist { + err = fs.ErrExist + return fmt.Errorf("error creating blockfile: %w", err) + } + if err == fs.ErrExist { + // reset the terminal state + bc.resetTerminalState() + } + err = nil + bcInitStatus := bc.GetRuntimeStatus() + if bcInitStatus.ShellProcStatus == Status_Running { + return nil + } + // TODO better sync here (don't let two starts happen at the same times) + remoteName := blockMeta.GetString(waveobj.MetaKey_Connection, "") + var cmdStr string + cmdOpts := shellexec.CommandOptsType{ + Env: make(map[string]string), + } + if bc.ControllerType == BlockController_Shell { + cmdOpts.Interactive = true + cmdOpts.Login = true + cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") + if cmdOpts.Cwd != "" { + cmdOpts.Cwd = wavebase.ExpandHomeDir(cmdOpts.Cwd) + } + } else if bc.ControllerType == BlockController_Cmd { + cmdStr = blockMeta.GetString(waveobj.MetaKey_Cmd, "") + if cmdStr == "" { + return fmt.Errorf("missing cmd in block meta") + } + cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") + if cmdOpts.Cwd != "" { + cmdOpts.Cwd = wavebase.ExpandHomeDir(cmdOpts.Cwd) + } + cmdOpts.Interactive = blockMeta.GetBool(waveobj.MetaKey_CmdInteractive, false) + cmdOpts.Login = blockMeta.GetBool(waveobj.MetaKey_CmdLogin, false) + cmdEnv := blockMeta.GetMap(waveobj.MetaKey_CmdEnv) + for k, v := range cmdEnv { + if v == nil { + continue + } + if _, ok := v.(string); ok { + cmdOpts.Env[k] = v.(string) + } + if _, ok := v.(float64); ok { + cmdOpts.Env[k] = fmt.Sprintf("%v", v) + } + } + } else { + return fmt.Errorf("unknown controller type %q", bc.ControllerType) + } + var shellProc *shellexec.ShellProc + if remoteName != "" { + credentialCtx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelFunc() + + opts, err := remote.ParseOpts(remoteName) + if err != nil { + return err + } + conn := conncontroller.GetConn(credentialCtx, opts, false) + connStatus := conn.DeriveConnStatus() + if connStatus.Status != conncontroller.Status_Connected { + return fmt.Errorf("not connected, cannot start shellproc") + } + if !blockMeta.GetBool(waveobj.MetaKey_CmdNoWsh, false) { + jwtStr, err := wshutil.MakeClientJWTToken(wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId, Conn: conn.Opts.String()}, conn.GetDomainSocketName()) + if err != nil { + return fmt.Errorf("error making jwt token: %w", err) + } + cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr + } + shellProc, err = shellexec.StartRemoteShellProc(rc.TermSize, cmdStr, cmdOpts, conn) + if err != nil { + return err + } + } else { + if !blockMeta.GetBool(waveobj.MetaKey_CmdNoWsh, false) { + jwtStr, err := wshutil.MakeClientJWTToken(wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId}, wavebase.GetDomainSocketName()) + if err != nil { + return fmt.Errorf("error making jwt token: %w", err) + } + cmdOpts.Env[wshutil.WaveJwtTokenVarName] = jwtStr + } + shellProc, err = shellexec.StartShellProc(rc.TermSize, cmdStr, cmdOpts) + if err != nil { + return err + } + } + bc.UpdateControllerAndSendUpdate(func() bool { + bc.ShellProc = shellProc + bc.ShellProcStatus = Status_Running + return true + }) + shellInputCh := make(chan *BlockInputUnion, 32) + bc.ShellInputCh = shellInputCh + + // make esc sequence wshclient wshProxy + // we don't need to authenticate this wshProxy since it is coming direct + wshProxy := wshutil.MakeRpcProxy() + wshProxy.SetRpcContext(&wshrpc.RpcContext{TabId: bc.TabId, BlockId: bc.BlockId}) + wshutil.DefaultRouter.RegisterRoute(wshutil.MakeControllerRouteId(bc.BlockId), wshProxy) + ptyBuffer := wshutil.MakePtyBuffer(wshutil.WaveOSCPrefix, bc.ShellProc.Cmd, wshProxy.FromRemoteCh) + go func() { + // handles regular output from the pty (goes to the blockfile and xterm) + defer func() { + log.Printf("[shellproc] pty-read loop done\n") + bc.ShellProc.Close() + bc.WithLock(func() { + // so no other events are sent + bc.ShellInputCh = nil + }) + // to stop the inputCh loop + time.Sleep(100 * time.Millisecond) + close(shellInputCh) // don't use bc.ShellInputCh (it's nil) + }() + buf := make([]byte, 4096) + for { + nr, err := ptyBuffer.Read(buf) + if nr > 0 { + err := HandleAppendBlockFile(bc.BlockId, BlockFile_Term, buf[:nr]) + if err != nil { + log.Printf("error appending to blockfile: %v\n", err) + } + } + if err == io.EOF { + break + } + if err != nil { + log.Printf("error reading from shell: %v\n", err) + break + } + } + }() + go func() { + defer func() { + log.Printf("[shellproc] shellInputCh loop done\n") + }() + // handles input from the shellInputCh, sent to pty + // use shellInputCh instead of bc.ShellInputCh (because we want to be attached to *this* ch. bc.ShellInputCh can be updated) + for ic := range shellInputCh { + if len(ic.InputData) > 0 { + bc.ShellProc.Cmd.Write(ic.InputData) + } + if ic.TermSize != nil { + log.Printf("SETTERMSIZE: %dx%d\n", ic.TermSize.Rows, ic.TermSize.Cols) + err = setTermSize(ctx, bc.BlockId, *ic.TermSize) + if err != nil { + log.Printf("error setting pty size: %v\n", err) + } + err = bc.ShellProc.Cmd.SetSize(ic.TermSize.Rows, ic.TermSize.Cols) + if err != nil { + log.Printf("error setting pty size: %v\n", err) + } + } + } + }() + go func() { + // handles outputCh -> shellInputCh + for msg := range wshProxy.ToRemoteCh { + encodedMsg := wshutil.EncodeWaveOSCBytes(wshutil.WaveServerOSC, msg) + shellInputCh <- &BlockInputUnion{InputData: encodedMsg} + } + }() + go func() { + // wait for the shell to finish + defer func() { + wshutil.DefaultRouter.UnregisterRoute(wshutil.MakeControllerRouteId(bc.BlockId)) + bc.UpdateControllerAndSendUpdate(func() bool { + bc.ShellProcStatus = Status_Done + return true + }) + log.Printf("[shellproc] shell process wait loop done\n") + }() + waitErr := shellProc.Cmd.Wait() + exitCode := shellexec.ExitCodeFromWaitErr(waitErr) + termMsg := fmt.Sprintf("\r\nprocess finished with exit code = %d\r\n\r\n", exitCode) + //HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte("\r\n")) + HandleAppendBlockFile(bc.BlockId, BlockFile_Term, []byte(termMsg)) + shellProc.SetWaitErrorAndSignalDone(waitErr) + }() + return nil +} + +func getBoolFromMeta(meta map[string]any, key string, def bool) bool { + ival, found := meta[key] + if !found || ival == nil { + return def + } + if val, ok := ival.(bool); ok { + return val + } + return def +} + +func getTermSize(bdata *waveobj.Block) waveobj.TermSize { + if bdata.RuntimeOpts != nil { + return bdata.RuntimeOpts.TermSize + } else { + return waveobj.TermSize{ + Rows: 25, + Cols: 80, + } + } +} + +func setTermSize(ctx context.Context, blockId string, termSize waveobj.TermSize) error { + ctx = waveobj.ContextWithUpdates(ctx) + bdata, err := wstore.DBMustGet[*waveobj.Block](context.Background(), blockId) + if err != nil { + return fmt.Errorf("error getting block data: %v", err) + } + if bdata.RuntimeOpts == nil { + return fmt.Errorf("error from nil RuntimeOpts: %v", err) + } + bdata.RuntimeOpts.TermSize = termSize + updates := waveobj.ContextGetUpdatesRtn(ctx) + wps.Broker.SendUpdateEvents(updates) + return nil +} + +func (bc *BlockController) run(bdata *waveobj.Block, blockMeta map[string]any, rtOpts *waveobj.RuntimeOpts) { + controllerName := bdata.Meta.GetString(waveobj.MetaKey_Controller, "") + if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { + log.Printf("unknown controller %q\n", controllerName) + return + } + if getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdClearOnStart, false) { + err := HandleTruncateBlockFile(bc.BlockId, BlockFile_Term) + if err != nil { + log.Printf("error truncating term blockfile: %v\n", err) + } + } + runOnStart := getBoolFromMeta(blockMeta, waveobj.MetaKey_CmdRunOnStart, true) + if runOnStart { + go func() { + var termSize waveobj.TermSize + if rtOpts != nil { + termSize = rtOpts.TermSize + } else { + termSize = getTermSize(bdata) + } + err := bc.DoRunShellCommand(&RunShellOpts{TermSize: termSize}, bdata.Meta) + if err != nil { + log.Printf("error running shell: %v\n", err) + } + }() + } +} + +func (bc *BlockController) SendInput(inputUnion *BlockInputUnion) error { + var shellInputCh chan *BlockInputUnion + bc.WithLock(func() { + shellInputCh = bc.ShellInputCh + }) + if shellInputCh == nil { + return fmt.Errorf("no shell input chan") + } + shellInputCh <- inputUnion + return nil +} + +func CheckConnStatus(blockId string) error { + bdata, err := wstore.DBMustGet[*waveobj.Block](context.Background(), blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + connName := bdata.Meta.GetString(waveobj.MetaKey_Connection, "") + if connName == "" { + return nil + } + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("error parsing connection name: %w", err) + } + conn := conncontroller.GetConn(context.Background(), opts, false) + connStatus := conn.DeriveConnStatus() + if connStatus.Status != conncontroller.Status_Connected { + return fmt.Errorf("not connected: %s", connStatus.Status) + } + return nil +} + +func (bc *BlockController) StopShellProc(shouldWait bool) { + bc.Lock.Lock() + defer bc.Lock.Unlock() + if bc.ShellProc == nil || bc.ShellProcStatus == Status_Done { + return + } + bc.ShellProc.Close() + if shouldWait { + doneCh := bc.ShellProc.DoneCh + <-doneCh + } +} + +func getOrCreateBlockController(tabId string, blockId string, controllerName string) *BlockController { + var createdController bool + var bc *BlockController + defer func() { + if !createdController || bc == nil { + return + } + bc.UpdateControllerAndSendUpdate(func() bool { + return true + }) + }() + globalLock.Lock() + defer globalLock.Unlock() + bc = blockControllerMap[blockId] + if bc == nil { + bc = &BlockController{ + Lock: &sync.Mutex{}, + ControllerType: controllerName, + TabId: tabId, + BlockId: blockId, + ShellProcStatus: Status_Done, + } + blockControllerMap[blockId] = bc + createdController = true + } + return bc +} + +func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts) error { + if tabId == "" || blockId == "" { + return fmt.Errorf("invalid tabId or blockId passed to ResyncController") + } + blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "") + controllerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, "") + curBc := GetBlockController(blockId) + if controllerName == "" { + if curBc != nil { + StopBlockController(blockId) + } + return nil + } + // check if conn is different, if so, stop the current controller + if curBc != nil { + bcStatus := curBc.GetRuntimeStatus() + if bcStatus.ShellProcStatus == Status_Running && bcStatus.ShellProcConnName != connName { + StopBlockController(blockId) + } + } + // now if there is a conn, ensure it is connected + if connName != "" { + err = CheckConnStatus(blockId) + if err != nil { + return fmt.Errorf("cannot start shellproc: %w", err) + } + } + if curBc == nil { + return startBlockController(ctx, tabId, blockId, rtOpts) + } + bcStatus := curBc.GetRuntimeStatus() + if bcStatus.ShellProcStatus != Status_Running { + return startBlockController(ctx, tabId, blockId, rtOpts) + } + return nil +} + +func startBlockController(ctx context.Context, tabId string, blockId string, rtOpts *waveobj.RuntimeOpts) error { + blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + controllerName := blockData.Meta.GetString(waveobj.MetaKey_Controller, "") + if controllerName == "" { + // nothing to start + return nil + } + if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { + return fmt.Errorf("unknown controller %q", controllerName) + } + connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "") + log.Printf("start blockcontroller %s %q (%q)\n", blockId, controllerName, connName) + err = CheckConnStatus(blockId) + if err != nil { + return fmt.Errorf("cannot start shellproc: %w", err) + } + bc := getOrCreateBlockController(tabId, blockId, controllerName) + bcStatus := bc.GetRuntimeStatus() + if bcStatus.ShellProcStatus == Status_Done { + go bc.run(blockData, blockData.Meta, rtOpts) + } + return nil +} + +func StopBlockController(blockId string) { + bc := GetBlockController(blockId) + if bc == nil { + return + } + if bc.getShellProc() != nil { + bc.ShellProc.Close() + <-bc.ShellProc.DoneCh + bc.UpdateControllerAndSendUpdate(func() bool { + bc.ShellProcStatus = Status_Done + return true + }) + } + +} + +func getControllerList() []*BlockController { + globalLock.Lock() + defer globalLock.Unlock() + var rtn []*BlockController + for _, bc := range blockControllerMap { + rtn = append(rtn, bc) + } + return rtn +} + +func StopAllBlockControllers() { + clist := getControllerList() + for _, bc := range clist { + if bc.ShellProcStatus == Status_Running { + go StopBlockController(bc.BlockId) + } + } +} + +func GetBlockController(blockId string) *BlockController { + globalLock.Lock() + defer globalLock.Unlock() + return blockControllerMap[blockId] +} diff --git a/pkg/blockcontroller/shell_controller.go b/pkg/blockcontroller/shell_controller.go new file mode 100644 index 000000000..26eb62525 --- /dev/null +++ b/pkg/blockcontroller/shell_controller.go @@ -0,0 +1,4 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package blockcontroller diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go new file mode 100644 index 000000000..9c216f8ad --- /dev/null +++ b/pkg/eventbus/eventbus.go @@ -0,0 +1,90 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package eventbus + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +const ( + WSEvent_ElectronNewWindow = "electron:newwindow" + WSEvent_ElectronCloseWindow = "electron:closewindow" + WSEvent_Rpc = "rpc" +) + +type WSEventType struct { + EventType string `json:"eventtype"` + ORef string `json:"oref,omitempty"` + Data any `json:"data"` +} + +type WindowWatchData struct { + WindowWSCh chan any + WaveWindowId string + WatchedORefs map[waveobj.ORef]bool +} + +var globalLock = &sync.Mutex{} +var wsMap = make(map[string]*WindowWatchData) // websocketid => WindowWatchData + +func RegisterWSChannel(connId string, windowId string, ch chan any) { + globalLock.Lock() + defer globalLock.Unlock() + wsMap[connId] = &WindowWatchData{ + WindowWSCh: ch, + WaveWindowId: windowId, + WatchedORefs: make(map[waveobj.ORef]bool), + } +} + +func UnregisterWSChannel(connId string) { + globalLock.Lock() + defer globalLock.Unlock() + delete(wsMap, connId) +} + +func getWindowWatchesForWindowId(windowId string) []*WindowWatchData { + globalLock.Lock() + defer globalLock.Unlock() + var watches []*WindowWatchData + for _, wdata := range wsMap { + if wdata.WaveWindowId == windowId { + watches = append(watches, wdata) + } + } + return watches +} + +// TODO fix busy wait -- but we need to wait until a new window connects back with a websocket +// returns true if the window is connected +func BusyWaitForWindowId(windowId string, timeout time.Duration) bool { + endTime := time.Now().Add(timeout) + for { + if len(getWindowWatchesForWindowId(windowId)) > 0 { + return true + } + if time.Now().After(endTime) { + return false + } + time.Sleep(20 * time.Millisecond) + } +} + +func SendEventToElectron(event WSEventType) { + barr, err := json.Marshal(event) + if err != nil { + log.Printf("cannot marshal electron message: %v\n", err) + return + } + // send to electron + log.Printf("sending event to electron: %q\n", event.EventType) + fmt.Fprintf(os.Stderr, "\nWAVESRV-EVENT:%s\n", string(barr)) +} diff --git a/pkg/filestore/blockstore.go b/pkg/filestore/blockstore.go new file mode 100644 index 000000000..60980fb9e --- /dev/null +++ b/pkg/filestore/blockstore.go @@ -0,0 +1,541 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package filestore + +// the blockstore package implements a write cache for wave files +// it is not a read cache (reads still go to the DB -- unless items are in the cache) +// but all writes only go to the cache, and then the cache is periodically flushed to the DB + +import ( + "context" + "fmt" + "io/fs" + "log" + "runtime/debug" + "sync" + "sync/atomic" + "time" + + "github.com/wavetermdev/waveterm/pkg/ijson" +) + +const ( + // ijson meta keys + IJsonNumCommands = "ijson:numcmds" + IJsonIncrementalBytes = "ijson:incbytes" +) + +const ( + IJsonHighCommands = 100 + IJsonHighRatio = 3 + IJsonLowRatio = 1 + IJsonLowCommands = 10 +) + +const DefaultPartDataSize = 64 * 1024 +const DefaultFlushTime = 5 * time.Second +const NoPartIdx = -1 + +// for unit tests +var warningCount = &atomic.Int32{} +var flushErrorCount = &atomic.Int32{} + +var partDataSize int64 = DefaultPartDataSize // overridden in tests +var stopFlush = &atomic.Bool{} + +var WFS *FileStore = &FileStore{ + Lock: &sync.Mutex{}, + Cache: make(map[cacheKey]*CacheEntry), +} + +type FileOptsType struct { + MaxSize int64 `json:"maxsize,omitempty"` + Circular bool `json:"circular,omitempty"` + IJson bool `json:"ijson,omitempty"` + IJsonBudget int `json:"ijsonbudget,omitempty"` +} + +type FileMeta = map[string]any + +type WaveFile struct { + // these fields are static (not updated) + ZoneId string `json:"zoneid"` + Name string `json:"name"` + Opts FileOptsType `json:"opts"` + CreatedTs int64 `json:"createdts"` + + // these fields are mutable + Size int64 `json:"size"` + ModTs int64 `json:"modts"` + Meta FileMeta `json:"meta"` // only top-level keys can be updated (lower levels are immutable) +} + +// for regular files this is just Size +// for circular files this is min(Size, MaxSize) +func (f WaveFile) DataLength() int64 { + if f.Opts.Circular { + return minInt64(f.Size, f.Opts.MaxSize) + } + return f.Size +} + +// for regular files this is just 0 +// for circular files this is the index of the first byte of data we have +func (f WaveFile) DataStartIdx() int64 { + if f.Opts.Circular && f.Size > f.Opts.MaxSize { + return f.Size - f.Opts.MaxSize + } + return 0 +} + +// this works because lower levels are immutable +func copyMeta(meta FileMeta) FileMeta { + newMeta := make(FileMeta) + for k, v := range meta { + newMeta[k] = v + } + return newMeta +} + +func (f *WaveFile) DeepCopy() *WaveFile { + if f == nil { + return nil + } + newFile := *f + newFile.Meta = copyMeta(f.Meta) + return &newFile +} + +func (WaveFile) UseDBMap() {} + +type FileData struct { + ZoneId string `json:"zoneid"` + Name string `json:"name"` + PartIdx int `json:"partidx"` + Data []byte `json:"data"` +} + +func (FileData) UseDBMap() {} + +// synchronous (does not interact with the cache) +func (s *FileStore) MakeFile(ctx context.Context, zoneId string, name string, meta FileMeta, opts FileOptsType) error { + if opts.MaxSize < 0 { + return fmt.Errorf("max size must be non-negative") + } + if opts.Circular && opts.MaxSize <= 0 { + return fmt.Errorf("circular file must have a max size") + } + if opts.Circular && opts.IJson { + return fmt.Errorf("circular file cannot be ijson") + } + if opts.Circular { + if opts.MaxSize%partDataSize != 0 { + opts.MaxSize = (opts.MaxSize/partDataSize + 1) * partDataSize + } + } + if opts.IJsonBudget > 0 && !opts.IJson { + return fmt.Errorf("ijson budget requires ijson") + } + if opts.IJsonBudget < 0 { + return fmt.Errorf("ijson budget must be non-negative") + } + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + if entry.File != nil { + return fs.ErrExist + } + now := time.Now().UnixMilli() + file := &WaveFile{ + ZoneId: zoneId, + Name: name, + Size: 0, + CreatedTs: now, + ModTs: now, + Opts: opts, + Meta: meta, + } + return dbInsertFile(ctx, file) + }) +} + +func (s *FileStore) DeleteFile(ctx context.Context, zoneId string, name string) error { + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := dbDeleteFile(ctx, zoneId, name) + if err != nil { + return fmt.Errorf("error deleting file: %v", err) + } + entry.clear() + return nil + }) +} + +func (s *FileStore) DeleteZone(ctx context.Context, zoneId string) error { + fileNames, err := dbGetZoneFileNames(ctx, zoneId) + if err != nil { + return fmt.Errorf("error getting zone files: %v", err) + } + for _, name := range fileNames { + s.DeleteFile(ctx, zoneId, name) + } + return nil +} + +// if file doesn't exsit, returns fs.ErrNotExist +func (s *FileStore) Stat(ctx context.Context, zoneId string, name string) (*WaveFile, error) { + return withLockRtn(s, zoneId, name, func(entry *CacheEntry) (*WaveFile, error) { + file, err := entry.loadFileForRead(ctx) + if err != nil { + if err == fs.ErrNotExist { + return nil, err + } + return nil, fmt.Errorf("error getting file: %v", err) + } + return file.DeepCopy(), nil + }) +} + +func (s *FileStore) ListFiles(ctx context.Context, zoneId string) ([]*WaveFile, error) { + files, err := dbGetZoneFiles(ctx, zoneId) + if err != nil { + return nil, fmt.Errorf("error getting zone files: %v", err) + } + for idx, file := range files { + withLock(s, file.ZoneId, file.Name, func(entry *CacheEntry) error { + if entry.File != nil { + files[idx] = entry.File.DeepCopy() + } + return nil + }) + } + return files, nil +} + +func (s *FileStore) WriteMeta(ctx context.Context, zoneId string, name string, meta FileMeta, merge bool) error { + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := entry.loadFileIntoCache(ctx) + if err != nil { + return err + } + if merge { + for k, v := range meta { + if v == nil { + delete(entry.File.Meta, k) + continue + } + entry.File.Meta[k] = v + } + } else { + entry.File.Meta = meta + } + entry.File.ModTs = time.Now().UnixMilli() + return nil + }) +} + +func (s *FileStore) WriteFile(ctx context.Context, zoneId string, name string, data []byte) error { + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := entry.loadFileIntoCache(ctx) + if err != nil { + return err + } + entry.writeAt(0, data, true) + // since WriteFile can *truncate* the file, we need to flush the file to the DB immediately + return entry.flushToDB(ctx, true) + }) +} + +func (s *FileStore) WriteAt(ctx context.Context, zoneId string, name string, offset int64, data []byte) error { + if offset < 0 { + return fmt.Errorf("offset must be non-negative") + } + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := entry.loadFileIntoCache(ctx) + if err != nil { + return err + } + file := entry.File + if offset > file.Size { + return fmt.Errorf("offset is past the end of the file") + } + partMap := file.computePartMap(offset, int64(len(data))) + incompleteParts := incompletePartsFromMap(partMap) + err = entry.loadDataPartsIntoCache(ctx, incompleteParts) + if err != nil { + return err + } + entry.writeAt(offset, data, false) + return nil + }) +} + +func (s *FileStore) AppendData(ctx context.Context, zoneId string, name string, data []byte) error { + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := entry.loadFileIntoCache(ctx) + if err != nil { + return err + } + partMap := entry.File.computePartMap(entry.File.Size, int64(len(data))) + incompleteParts := incompletePartsFromMap(partMap) + if len(incompleteParts) > 0 { + err = entry.loadDataPartsIntoCache(ctx, incompleteParts) + if err != nil { + return err + } + } + entry.writeAt(entry.File.Size, data, false) + return nil + }) +} + +func metaIncrement(file *WaveFile, key string, amount int) int { + if file.Meta == nil { + file.Meta = make(FileMeta) + } + val, ok := file.Meta[key].(int) + if !ok { + val = 0 + } + newVal := val + amount + file.Meta[key] = newVal + return newVal +} + +func (s *FileStore) compactIJson(ctx context.Context, entry *CacheEntry) error { + // we don't need to lock the entry because we have the lock on the filestore + _, fullData, err := entry.readAt(ctx, 0, 0, true) + if err != nil { + return err + } + newBytes, err := ijson.CompactIJson(fullData, entry.File.Opts.IJsonBudget) + if err != nil { + return err + } + entry.writeAt(0, newBytes, true) + return nil +} + +func (s *FileStore) CompactIJson(ctx context.Context, zoneId string, name string) error { + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := entry.loadFileIntoCache(ctx) + if err != nil { + return err + } + if !entry.File.Opts.IJson { + return fmt.Errorf("file %s:%s is not an ijson file", zoneId, name) + } + return s.compactIJson(ctx, entry) + }) +} + +func (s *FileStore) AppendIJson(ctx context.Context, zoneId string, name string, command map[string]any) error { + data, err := ijson.ValidateAndMarshalCommand(command) + if err != nil { + return err + } + return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := entry.loadFileIntoCache(ctx) + if err != nil { + return err + } + if !entry.File.Opts.IJson { + return fmt.Errorf("file %s:%s is not an ijson file", zoneId, name) + } + partMap := entry.File.computePartMap(entry.File.Size, int64(len(data))) + incompleteParts := incompletePartsFromMap(partMap) + if len(incompleteParts) > 0 { + err = entry.loadDataPartsIntoCache(ctx, incompleteParts) + if err != nil { + return err + } + } + oldSize := entry.File.Size + entry.writeAt(entry.File.Size, data, false) + entry.writeAt(entry.File.Size, []byte("\n"), false) + if oldSize == 0 { + return nil + } + // check if we should compact + numCmds := metaIncrement(entry.File, IJsonNumCommands, 1) + numBytes := metaIncrement(entry.File, IJsonIncrementalBytes, len(data)+1) + incRatio := float64(numBytes) / float64(entry.File.Size) + if numCmds > IJsonHighCommands || incRatio >= IJsonHighRatio || (numCmds > IJsonLowCommands && incRatio >= IJsonLowRatio) { + err := s.compactIJson(ctx, entry) + if err != nil { + return err + } + } + return nil + }) +} + +func (s *FileStore) GetAllZoneIds(ctx context.Context) ([]string, error) { + return dbGetAllZoneIds(ctx) +} + +// returns (offset, data, error) +// we return the offset because the offset may have been adjusted if the size was too big (for circular files) +func (s *FileStore) ReadAt(ctx context.Context, zoneId string, name string, offset int64, size int64) (rtnOffset int64, rtnData []byte, rtnErr error) { + withLock(s, zoneId, name, func(entry *CacheEntry) error { + rtnOffset, rtnData, rtnErr = entry.readAt(ctx, offset, size, false) + return nil + }) + return +} + +// returns (offset, data, error) +func (s *FileStore) ReadFile(ctx context.Context, zoneId string, name string) (rtnOffset int64, rtnData []byte, rtnErr error) { + withLock(s, zoneId, name, func(entry *CacheEntry) error { + rtnOffset, rtnData, rtnErr = entry.readAt(ctx, 0, 0, true) + return nil + }) + return +} + +type FlushStats struct { + FlushDuration time.Duration + NumDirtyEntries int + NumCommitted int +} + +func (s *FileStore) FlushCache(ctx context.Context) (stats FlushStats, rtnErr error) { + wasFlushing := s.setUnlessFlushing() + if wasFlushing { + return stats, fmt.Errorf("flush already in progress") + } + defer s.setIsFlushing(false) + startTime := time.Now() + defer func() { + stats.FlushDuration = time.Since(startTime) + }() + + // get a copy of dirty keys so we can iterate without the lock + dirtyCacheKeys := s.getDirtyCacheKeys() + stats.NumDirtyEntries = len(dirtyCacheKeys) + for _, key := range dirtyCacheKeys { + err := withLock(s, key.ZoneId, key.Name, func(entry *CacheEntry) error { + return entry.flushToDB(ctx, false) + }) + if ctx.Err() != nil { + // transient error (also must stop the loop) + return stats, ctx.Err() + } + if err != nil { + return stats, fmt.Errorf("error flushing cache entry[%v]: %v", key, err) + } + stats.NumCommitted++ + } + return stats, nil +} + +/////////////////////////////////// + +func (f *WaveFile) partIdxAtOffset(offset int64) int { + partIdx := int(offset / partDataSize) + if f.Opts.Circular { + maxPart := int(f.Opts.MaxSize / partDataSize) + partIdx = partIdx % maxPart + } + return partIdx +} + +func incompletePartsFromMap(partMap map[int]int) []int { + var incompleteParts []int + for partIdx, size := range partMap { + if size != int(partDataSize) { + incompleteParts = append(incompleteParts, partIdx) + } + } + return incompleteParts +} + +func getPartIdxsFromMap(partMap map[int]int) []int { + var partIdxs []int + for partIdx := range partMap { + partIdxs = append(partIdxs, partIdx) + } + return partIdxs +} + +// returns a map of partIdx to amount of data to write to that part +func (file *WaveFile) computePartMap(startOffset int64, size int64) map[int]int { + partMap := make(map[int]int) + endOffset := startOffset + size + startFileOffset := startOffset - (startOffset % partDataSize) + for testOffset := startFileOffset; testOffset < endOffset; testOffset += partDataSize { + partIdx := file.partIdxAtOffset(testOffset) + partStartOffset := testOffset + partEndOffset := testOffset + partDataSize + partWriteStartOffset := 0 + partWriteEndOffset := int(partDataSize) + if startOffset > partStartOffset && startOffset < partEndOffset { + partWriteStartOffset = int(startOffset - partStartOffset) + } + if endOffset > partStartOffset && endOffset < partEndOffset { + partWriteEndOffset = int(endOffset - partStartOffset) + } + partMap[partIdx] = partWriteEndOffset - partWriteStartOffset + } + return partMap +} + +func (s *FileStore) getDirtyCacheKeys() []cacheKey { + s.Lock.Lock() + defer s.Lock.Unlock() + var dirtyCacheKeys []cacheKey + for key, entry := range s.Cache { + if entry.File != nil { + dirtyCacheKeys = append(dirtyCacheKeys, key) + } + } + return dirtyCacheKeys +} + +func (s *FileStore) setIsFlushing(flushing bool) { + s.Lock.Lock() + defer s.Lock.Unlock() + s.IsFlushing = flushing +} + +// returns old value of IsFlushing +func (s *FileStore) setUnlessFlushing() bool { + s.Lock.Lock() + defer s.Lock.Unlock() + if s.IsFlushing { + return true + } + s.IsFlushing = true + return false +} + +func (s *FileStore) runFlushWithNewContext() (FlushStats, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultFlushTime) + defer cancelFn() + return s.FlushCache(ctx) +} + +func (s *FileStore) runFlusher() { + defer func() { + if r := recover(); r != nil { + log.Printf("panic in filestore flusher: %v\n", r) + debug.PrintStack() + } + }() + for { + stats, err := s.runFlushWithNewContext() + if err != nil || stats.NumDirtyEntries > 0 { + log.Printf("filestore flush: %d/%d entries flushed, err:%v\n", stats.NumCommitted, stats.NumDirtyEntries, err) + } + if stopFlush.Load() { + log.Printf("filestore flusher stopping\n") + return + } + time.Sleep(DefaultFlushTime) + } +} + +func minInt64(a, b int64) int64 { + if a < b { + return a + } + return b +} diff --git a/pkg/filestore/blockstore_cache.go b/pkg/filestore/blockstore_cache.go new file mode 100644 index 000000000..f8608654b --- /dev/null +++ b/pkg/filestore/blockstore_cache.go @@ -0,0 +1,347 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package filestore + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "sync" + "time" +) + +type cacheKey struct { + ZoneId string + Name string +} + +type FileStore struct { + Lock *sync.Mutex + Cache map[cacheKey]*CacheEntry + IsFlushing bool +} + +type DataCacheEntry struct { + PartIdx int + Data []byte // capacity is always ZoneDataPartSize +} + +// if File or DataEntries are not nil then they are dirty (need to be flushed to disk) +type CacheEntry struct { + PinCount int // this is synchronzed with the FileStore lock (not the entry lock) + + Lock *sync.Mutex + ZoneId string + Name string + File *WaveFile + DataEntries map[int]*DataCacheEntry + FlushErrors int +} + +//lint:ignore U1000 used for testing +func (e *CacheEntry) dump() string { + var buf bytes.Buffer + fmt.Fprintf(&buf, "CacheEntry [ZoneId: %q, Name: %q] PinCount: %d\n", e.ZoneId, e.Name, e.PinCount) + fmt.Fprintf(&buf, " FileEntry: %v\n", e.File) + for idx, dce := range e.DataEntries { + fmt.Fprintf(&buf, " DataEntry[%d]: %q\n", idx, string(dce.Data)) + } + return buf.String() +} + +func makeDataCacheEntry(partIdx int) *DataCacheEntry { + return &DataCacheEntry{ + PartIdx: partIdx, + Data: make([]byte, 0, partDataSize), + } +} + +// will create new entries +func (s *FileStore) getEntryAndPin(zoneId string, name string) *CacheEntry { + s.Lock.Lock() + defer s.Lock.Unlock() + entry := s.Cache[cacheKey{ZoneId: zoneId, Name: name}] + if entry == nil { + entry = makeCacheEntry(zoneId, name) + s.Cache[cacheKey{ZoneId: zoneId, Name: name}] = entry + } + entry.PinCount++ + return entry +} + +func (s *FileStore) unpinEntryAndTryDelete(zoneId string, name string) { + s.Lock.Lock() + defer s.Lock.Unlock() + entry := s.Cache[cacheKey{ZoneId: zoneId, Name: name}] + if entry == nil { + return + } + entry.PinCount-- + if entry.PinCount <= 0 && entry.File == nil { + delete(s.Cache, cacheKey{ZoneId: zoneId, Name: name}) + } +} + +func (entry *CacheEntry) clear() { + entry.File = nil + entry.DataEntries = make(map[int]*DataCacheEntry) + entry.FlushErrors = 0 +} + +func (entry *CacheEntry) getOrCreateDataCacheEntry(partIdx int) *DataCacheEntry { + if entry.DataEntries[partIdx] == nil { + entry.DataEntries[partIdx] = makeDataCacheEntry(partIdx) + } + return entry.DataEntries[partIdx] +} + +// returns err if file does not exist +func (entry *CacheEntry) loadFileIntoCache(ctx context.Context) error { + if entry.File != nil { + return nil + } + file, err := entry.loadFileForRead(ctx) + if err != nil { + return err + } + entry.File = file + return nil +} + +// does not populate the cache entry, returns err if file does not exist +func (entry *CacheEntry) loadFileForRead(ctx context.Context) (*WaveFile, error) { + if entry.File != nil { + return entry.File, nil + } + file, err := dbGetZoneFile(ctx, entry.ZoneId, entry.Name) + if err != nil { + return nil, fmt.Errorf("error getting file: %w", err) + } + if file == nil { + return nil, fs.ErrNotExist + } + return file, nil +} + +func withLock(s *FileStore, zoneId string, name string, fn func(*CacheEntry) error) error { + entry := s.getEntryAndPin(zoneId, name) + defer s.unpinEntryAndTryDelete(zoneId, name) + entry.Lock.Lock() + defer entry.Lock.Unlock() + return fn(entry) +} + +func withLockRtn[T any](s *FileStore, zoneId string, name string, fn func(*CacheEntry) (T, error)) (T, error) { + var rtnVal T + rtnErr := withLock(s, zoneId, name, func(entry *CacheEntry) error { + var err error + rtnVal, err = fn(entry) + return err + }) + return rtnVal, rtnErr +} + +func (dce *DataCacheEntry) writeToPart(offset int64, data []byte) (int64, *DataCacheEntry) { + leftInPart := partDataSize - offset + toWrite := int64(len(data)) + if toWrite > leftInPart { + toWrite = leftInPart + } + if int64(len(dce.Data)) < offset+toWrite { + dce.Data = dce.Data[:offset+toWrite] + } + copy(dce.Data[offset:], data[:toWrite]) + return toWrite, dce +} + +func (entry *CacheEntry) writeAt(offset int64, data []byte, replace bool) { + if replace { + entry.File.Size = 0 + } + if entry.File.Opts.Circular { + startCirFileOffset := entry.File.Size - entry.File.Opts.MaxSize + if offset+int64(len(data)) <= startCirFileOffset { + // write is before the start of the circular file + return + } + if offset < startCirFileOffset { + // truncate data (from the front), update offset + truncateAmt := startCirFileOffset - offset + data = data[truncateAmt:] + offset += truncateAmt + } + if int64(len(data)) > entry.File.Opts.MaxSize { + // truncate data (from the front), update offset + truncateAmt := int64(len(data)) - entry.File.Opts.MaxSize + data = data[truncateAmt:] + offset += truncateAmt + } + } + endWriteOffset := offset + int64(len(data)) + if replace { + entry.DataEntries = make(map[int]*DataCacheEntry) + } + for len(data) > 0 { + partIdx := int(offset / partDataSize) + if entry.File.Opts.Circular { + maxPart := int(entry.File.Opts.MaxSize / partDataSize) + partIdx = partIdx % maxPart + } + partOffset := offset % partDataSize + partData := entry.getOrCreateDataCacheEntry(partIdx) + nw, newDce := partData.writeToPart(partOffset, data) + entry.DataEntries[partIdx] = newDce + data = data[nw:] + offset += nw + } + if endWriteOffset > entry.File.Size || replace { + entry.File.Size = endWriteOffset + } + entry.File.ModTs = time.Now().UnixMilli() +} + +// returns (realOffset, data, error) +func (entry *CacheEntry) readAt(ctx context.Context, offset int64, size int64, readFull bool) (int64, []byte, error) { + if offset < 0 { + return 0, nil, fmt.Errorf("offset cannot be negative") + } + file, err := entry.loadFileForRead(ctx) + if err != nil { + return 0, nil, err + } + if readFull { + size = file.Size - offset + } + if offset+size > file.Size { + size = file.Size - offset + } + if file.Opts.Circular { + realDataOffset := int64(0) + if file.Size > file.Opts.MaxSize { + realDataOffset = file.Size - file.Opts.MaxSize + } + if offset < realDataOffset { + truncateAmt := realDataOffset - offset + offset += truncateAmt + size -= truncateAmt + } + } + partMap := file.computePartMap(offset, size) + dataEntryMap, err := entry.loadDataPartsForRead(ctx, getPartIdxsFromMap(partMap)) + if err != nil { + return 0, nil, err + } + // combine the entries into a single byte slice + // note that we only want part of the first and last part depending on offset and size + rtnData := make([]byte, 0, size) + amtLeftToRead := size + curReadOffset := offset + for amtLeftToRead > 0 { + partIdx := file.partIdxAtOffset(curReadOffset) + partDataEntry := dataEntryMap[partIdx] + var partData []byte + if partDataEntry == nil { + partData = make([]byte, partDataSize) + } else { + partData = partDataEntry.Data[0:partDataSize] + } + partOffset := curReadOffset % partDataSize + amtToRead := minInt64(partDataSize-partOffset, amtLeftToRead) + rtnData = append(rtnData, partData[partOffset:partOffset+amtToRead]...) + amtLeftToRead -= amtToRead + curReadOffset += amtToRead + } + return offset, rtnData, nil +} + +func prunePartsWithCache(dataEntries map[int]*DataCacheEntry, parts []int) []int { + var rtn []int + for _, partIdx := range parts { + if dataEntries[partIdx] != nil { + continue + } + rtn = append(rtn, partIdx) + } + return rtn +} + +func (entry *CacheEntry) loadDataPartsIntoCache(ctx context.Context, parts []int) error { + parts = prunePartsWithCache(entry.DataEntries, parts) + if len(parts) == 0 { + // parts are already loaded + return nil + } + dbDataParts, err := dbGetFileParts(ctx, entry.ZoneId, entry.Name, parts) + if err != nil { + return fmt.Errorf("error getting data parts: %w", err) + } + for partIdx, dce := range dbDataParts { + entry.DataEntries[partIdx] = dce + } + return nil +} + +func (entry *CacheEntry) loadDataPartsForRead(ctx context.Context, parts []int) (map[int]*DataCacheEntry, error) { + if len(parts) == 0 { + return nil, nil + } + dbParts := prunePartsWithCache(entry.DataEntries, parts) + var dbDataParts map[int]*DataCacheEntry + if len(dbParts) > 0 { + var err error + dbDataParts, err = dbGetFileParts(ctx, entry.ZoneId, entry.Name, dbParts) + if err != nil { + return nil, fmt.Errorf("error getting data parts: %w", err) + } + } + rtn := make(map[int]*DataCacheEntry) + for _, partIdx := range parts { + if entry.DataEntries[partIdx] != nil { + rtn[partIdx] = entry.DataEntries[partIdx] + continue + } + if dbDataParts[partIdx] != nil { + rtn[partIdx] = dbDataParts[partIdx] + continue + } + // part not found + } + return rtn, nil +} + +func makeCacheEntry(zoneId string, name string) *CacheEntry { + return &CacheEntry{ + Lock: &sync.Mutex{}, + ZoneId: zoneId, + Name: name, + PinCount: 0, + File: nil, + DataEntries: make(map[int]*DataCacheEntry), + FlushErrors: 0, + } +} + +func (entry *CacheEntry) flushToDB(ctx context.Context, replace bool) error { + if entry.File == nil { + return nil + } + err := dbWriteCacheEntry(ctx, entry.File, entry.DataEntries, replace) + if ctx.Err() != nil { + // transient error + return ctx.Err() + } + if err != nil { + flushErrorCount.Add(1) + entry.FlushErrors++ + if entry.FlushErrors > 3 { + entry.clear() + return fmt.Errorf("too many flush errors (clearing entry): %w", err) + } + return err + } + // clear cache entry (data is now in db) + entry.clear() + return nil +} diff --git a/pkg/filestore/blockstore_dbops.go b/pkg/filestore/blockstore_dbops.go new file mode 100644 index 000000000..6338d417f --- /dev/null +++ b/pkg/filestore/blockstore_dbops.go @@ -0,0 +1,117 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package filestore + +import ( + "context" + "fmt" + "io/fs" + "os" + + "github.com/wavetermdev/waveterm/pkg/util/dbutil" +) + +// can return fs.ErrExist +func dbInsertFile(ctx context.Context, file *WaveFile) error { + // will fail if file already exists + return WithTx(ctx, func(tx *TxWrap) error { + query := "SELECT zoneid FROM db_wave_file WHERE zoneid = ? AND name = ?" + if tx.Exists(query, file.ZoneId, file.Name) { + return fs.ErrExist + } + query = "INSERT INTO db_wave_file (zoneid, name, size, createdts, modts, opts, meta) VALUES (?, ?, ?, ?, ?, ?, ?)" + tx.Exec(query, file.ZoneId, file.Name, file.Size, file.CreatedTs, file.ModTs, dbutil.QuickJson(file.Opts), dbutil.QuickJson(file.Meta)) + return nil + }) +} + +func dbDeleteFile(ctx context.Context, zoneId string, name string) error { + return WithTx(ctx, func(tx *TxWrap) error { + query := "DELETE FROM db_wave_file WHERE zoneid = ? AND name = ?" + tx.Exec(query, zoneId, name) + query = "DELETE FROM db_file_data WHERE zoneid = ? AND name = ?" + tx.Exec(query, zoneId, name) + return nil + }) +} + +func dbGetZoneFileNames(ctx context.Context, zoneId string) ([]string, error) { + return WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) { + var files []string + query := "SELECT name FROM db_wave_file WHERE zoneid = ?" + tx.Select(&files, query, zoneId) + return files, nil + }) +} + +func dbGetZoneFile(ctx context.Context, zoneId string, name string) (*WaveFile, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*WaveFile, error) { + query := "SELECT * FROM db_wave_file WHERE zoneid = ? AND name = ?" + file := dbutil.GetMappable[*WaveFile](tx, query, zoneId, name) + return file, nil + }) +} + +func dbGetAllZoneIds(ctx context.Context) ([]string, error) { + return WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) { + var ids []string + query := "SELECT DISTINCT zoneid FROM db_wave_file" + tx.Select(&ids, query) + return ids, nil + }) +} + +func dbGetFileParts(ctx context.Context, zoneId string, name string, parts []int) (map[int]*DataCacheEntry, error) { + if len(parts) == 0 { + return nil, nil + } + return WithTxRtn(ctx, func(tx *TxWrap) (map[int]*DataCacheEntry, error) { + var data []*DataCacheEntry + query := "SELECT partidx, data FROM db_file_data WHERE zoneid = ? AND name = ? AND partidx IN (SELECT value FROM json_each(?))" + tx.Select(&data, query, zoneId, name, dbutil.QuickJsonArr(parts)) + rtn := make(map[int]*DataCacheEntry) + for _, d := range data { + if cap(d.Data) != int(partDataSize) { + newData := make([]byte, len(d.Data), partDataSize) + copy(newData, d.Data) + d.Data = newData + } + rtn[d.PartIdx] = d + } + return rtn, nil + }) +} + +func dbGetZoneFiles(ctx context.Context, zoneId string) ([]*WaveFile, error) { + return WithTxRtn(ctx, func(tx *TxWrap) ([]*WaveFile, error) { + query := "SELECT * FROM db_wave_file WHERE zoneid = ?" + files := dbutil.SelectMappable[*WaveFile](tx, query, zoneId) + return files, nil + }) +} + +func dbWriteCacheEntry(ctx context.Context, file *WaveFile, dataEntries map[int]*DataCacheEntry, replace bool) error { + return WithTx(ctx, func(tx *TxWrap) error { + query := `SELECT zoneid FROM db_wave_file WHERE zoneid = ? AND name = ?` + if !tx.Exists(query, file.ZoneId, file.Name) { + // since deletion is synchronous this stops us from writing to a deleted file + return os.ErrNotExist + } + // we don't update CreatedTs or Opts + query = `UPDATE db_wave_file SET size = ?, modts = ?, meta = ? WHERE zoneid = ? AND name = ?` + tx.Exec(query, file.Size, file.ModTs, dbutil.QuickJson(file.Meta), file.ZoneId, file.Name) + if replace { + query = `DELETE FROM db_file_data WHERE zoneid = ? AND name = ?` + tx.Exec(query, file.ZoneId, file.Name) + } + dataPartQuery := `REPLACE INTO db_file_data (zoneid, name, partidx, data) VALUES (?, ?, ?, ?)` + for partIdx, dataEntry := range dataEntries { + if partIdx != dataEntry.PartIdx { + panic(fmt.Sprintf("partIdx:%d and dataEntry.PartIdx:%d do not match", partIdx, dataEntry.PartIdx)) + } + tx.Exec(dataPartQuery, file.ZoneId, file.Name, dataEntry.PartIdx, dataEntry.Data) + } + return nil + }) +} diff --git a/pkg/filestore/blockstore_dbsetup.go b/pkg/filestore/blockstore_dbsetup.go new file mode 100644 index 000000000..d828c4173 --- /dev/null +++ b/pkg/filestore/blockstore_dbsetup.go @@ -0,0 +1,82 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package filestore + +// setup for filestore db +// includes migration support and txwrap setup + +import ( + "context" + "fmt" + "log" + "path/filepath" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/migrateutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "github.com/sawka/txwrap" + + dbfs "github.com/wavetermdev/waveterm/db" +) + +const FilestoreDBName = "filestore.db" + +type TxWrap = txwrap.TxWrap + +var globalDB *sqlx.DB +var useTestingDb bool // just for testing (forces GetDB() to return an in-memory db) + +func InitFilestore() error { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + var err error + globalDB, err = MakeDB(ctx) + if err != nil { + return err + } + err = migrateutil.Migrate("filestore", globalDB.DB, dbfs.FilestoreMigrationFS, "migrations-filestore") + if err != nil { + return err + } + if !stopFlush.Load() { + go WFS.runFlusher() + } + log.Printf("filestore initialized\n") + return nil +} + +func GetDBName() string { + waveHome := wavebase.GetWaveHomeDir() + return filepath.Join(waveHome, wavebase.WaveDBDir, FilestoreDBName) +} + +func MakeDB(ctx context.Context) (*sqlx.DB, error) { + var rtn *sqlx.DB + var err error + if useTestingDb { + dbName := ":memory:" + log.Printf("[db] using in-memory db\n") + rtn, err = sqlx.Open("sqlite3", dbName) + } else { + dbName := GetDBName() + log.Printf("[db] opening db %s\n", dbName) + rtn, err = sqlx.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL&_busy_timeout=5000", dbName)) + } + if err != nil { + return nil, fmt.Errorf("opening db: %w", err) + } + rtn.DB.SetMaxOpenConns(1) + return rtn, nil +} + +func WithTx(ctx context.Context, fn func(tx *TxWrap) error) error { + return txwrap.WithTx(ctx, globalDB, fn) +} + +func WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (RT, error) { + return txwrap.WithTxRtn(ctx, globalDB, fn) +} diff --git a/pkg/filestore/blockstore_test.go b/pkg/filestore/blockstore_test.go new file mode 100644 index 000000000..d137b13a2 --- /dev/null +++ b/pkg/filestore/blockstore_test.go @@ -0,0 +1,750 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package filestore + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "log" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/ijson" +) + +func initDb(t *testing.T) { + t.Logf("initializing db for %q", t.Name()) + useTestingDb = true + partDataSize = 50 + warningCount = &atomic.Int32{} + stopFlush.Store(true) + err := InitFilestore() + if err != nil { + t.Fatalf("error initializing filestore: %v", err) + } +} + +func cleanupDb(t *testing.T) { + t.Logf("cleaning up db for %q", t.Name()) + if globalDB != nil { + globalDB.Close() + globalDB = nil + } + useTestingDb = false + partDataSize = DefaultPartDataSize + WFS.clearCache() + if warningCount.Load() > 0 { + t.Errorf("warning count: %d", warningCount.Load()) + } + if flushErrorCount.Load() > 0 { + t.Errorf("flush error count: %d", flushErrorCount.Load()) + } +} + +func (s *FileStore) getCacheSize() int { + s.Lock.Lock() + defer s.Lock.Unlock() + return len(s.Cache) +} + +func (s *FileStore) clearCache() { + s.Lock.Lock() + defer s.Lock.Unlock() + s.Cache = make(map[cacheKey]*CacheEntry) +} + +//lint:ignore U1000 used for testing +func (s *FileStore) dump() string { + s.Lock.Lock() + defer s.Lock.Unlock() + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("FileStore %d entries\n", len(s.Cache))) + for _, v := range s.Cache { + entryStr := v.dump() + buf.WriteString(entryStr) + buf.WriteString("\n") + } + return buf.String() +} + +func TestCreate(t *testing.T) { + initDb(t) + defer cleanupDb(t) + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + err := WFS.MakeFile(ctx, zoneId, "testfile", nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + file, err := WFS.Stat(ctx, zoneId, "testfile") + if err != nil { + t.Fatalf("error stating file: %v", err) + } + if file == nil { + t.Fatalf("file not found") + } + if file.ZoneId != zoneId { + t.Fatalf("zone id mismatch") + } + if file.Name != "testfile" { + t.Fatalf("name mismatch") + } + if file.Size != 0 { + t.Fatalf("size mismatch") + } + if file.CreatedTs == 0 { + t.Fatalf("created ts zero") + } + if file.ModTs == 0 { + t.Fatalf("mod ts zero") + } + if file.CreatedTs != file.ModTs { + t.Fatalf("create ts != mod ts") + } + if len(file.Meta) != 0 { + t.Fatalf("meta should have no values") + } + if file.Opts.Circular || file.Opts.IJson || file.Opts.MaxSize != 0 { + t.Fatalf("opts not empty") + } + zoneIds, err := WFS.GetAllZoneIds(ctx) + if err != nil { + t.Fatalf("error getting zone ids: %v", err) + } + if len(zoneIds) != 1 { + t.Fatalf("zone id count mismatch") + } + if zoneIds[0] != zoneId { + t.Fatalf("zone id mismatch") + } + err = WFS.DeleteFile(ctx, zoneId, "testfile") + if err != nil { + t.Fatalf("error deleting file: %v", err) + } + zoneIds, err = WFS.GetAllZoneIds(ctx) + if err != nil { + t.Fatalf("error getting zone ids: %v", err) + } + if len(zoneIds) != 0 { + t.Fatalf("zone id count mismatch") + } +} + +func containsFile(arr []*WaveFile, name string) bool { + for _, f := range arr { + if f.Name == name { + return true + } + } + return false +} + +func TestDelete(t *testing.T) { + initDb(t) + defer cleanupDb(t) + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + err := WFS.MakeFile(ctx, zoneId, "testfile", nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.DeleteFile(ctx, zoneId, "testfile") + if err != nil { + t.Fatalf("error deleting file: %v", err) + } + _, err = WFS.Stat(ctx, zoneId, "testfile") + if err == nil || !errors.Is(err, fs.ErrNotExist) { + t.Errorf("expected file not found error") + } + + // create two files in same zone, use DeleteZone to delete + err = WFS.MakeFile(ctx, zoneId, "testfile1", nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.MakeFile(ctx, zoneId, "testfile2", nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + files, err := WFS.ListFiles(ctx, zoneId) + if err != nil { + t.Fatalf("error listing files: %v", err) + } + if len(files) != 2 { + t.Fatalf("file count mismatch") + } + if !containsFile(files, "testfile1") || !containsFile(files, "testfile2") { + t.Fatalf("file names mismatch") + } + err = WFS.DeleteZone(ctx, zoneId) + if err != nil { + t.Fatalf("error deleting zone: %v", err) + } + files, err = WFS.ListFiles(ctx, zoneId) + if err != nil { + t.Fatalf("error listing files: %v", err) + } + if len(files) != 0 { + t.Fatalf("file count mismatch") + } +} + +func checkMapsEqual(t *testing.T, m1 map[string]any, m2 map[string]any, msg string) { + if len(m1) != len(m2) { + t.Errorf("%s: map length mismatch", msg) + } + for k, v := range m1 { + if m2[k] != v { + t.Errorf("%s: value mismatch for key %q", msg, k) + } + } +} + +func TestSetMeta(t *testing.T) { + initDb(t) + defer cleanupDb(t) + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + err := WFS.MakeFile(ctx, zoneId, "testfile", nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + if WFS.getCacheSize() != 0 { + t.Errorf("cache size mismatch -- should have 0 entries after create") + } + err = WFS.WriteMeta(ctx, zoneId, "testfile", map[string]any{"a": 5, "b": "hello", "q": 8}, false) + if err != nil { + t.Fatalf("error setting meta: %v", err) + } + file, err := WFS.Stat(ctx, zoneId, "testfile") + if err != nil { + t.Fatalf("error stating file: %v", err) + } + if file == nil { + t.Fatalf("file not found") + } + checkMapsEqual(t, map[string]any{"a": 5, "b": "hello", "q": 8}, file.Meta, "meta") + if WFS.getCacheSize() != 1 { + t.Errorf("cache size mismatch") + } + err = WFS.WriteMeta(ctx, zoneId, "testfile", map[string]any{"a": 6, "c": "world", "d": 7, "q": nil}, true) + if err != nil { + t.Fatalf("error setting meta: %v", err) + } + file, err = WFS.Stat(ctx, zoneId, "testfile") + if err != nil { + t.Fatalf("error stating file: %v", err) + } + if file == nil { + t.Fatalf("file not found") + } + checkMapsEqual(t, map[string]any{"a": 6, "b": "hello", "c": "world", "d": 7}, file.Meta, "meta") + + err = WFS.WriteMeta(ctx, zoneId, "testfile-notexist", map[string]any{"a": 6}, true) + if err == nil { + t.Fatalf("expected error setting meta") + } + err = nil +} + +func checkFileSize(t *testing.T, ctx context.Context, zoneId string, name string, size int64) { + file, err := WFS.Stat(ctx, zoneId, name) + if err != nil { + t.Errorf("error stating file %q: %v", name, err) + return + } + if file == nil { + t.Errorf("file %q not found", name) + return + } + if file.Size != size { + t.Errorf("size mismatch for file %q: expected %d, got %d", name, size, file.Size) + } +} + +func checkFileData(t *testing.T, ctx context.Context, zoneId string, name string, data string) { + _, rdata, err := WFS.ReadFile(ctx, zoneId, name) + if err != nil { + t.Errorf("error reading data for file %q: %v", name, err) + return + } + if string(rdata) != data { + t.Errorf("data mismatch for file %q: expected %q, got %q", name, data, string(rdata)) + } +} + +func checkFileByteCount(t *testing.T, ctx context.Context, zoneId string, name string, val byte, expected int) { + _, rdata, err := WFS.ReadFile(ctx, zoneId, name) + if err != nil { + t.Errorf("error reading data for file %q: %v", name, err) + return + } + var count int + for _, b := range rdata { + if b == val { + count++ + } + } + if count != expected { + t.Errorf("byte count mismatch for file %q: expected %d, got %d", name, expected, count) + } +} + +func checkFileDataAt(t *testing.T, ctx context.Context, zoneId string, name string, offset int64, data string) { + _, rdata, err := WFS.ReadAt(ctx, zoneId, name, offset, int64(len(data))) + if err != nil { + t.Errorf("error reading data for file %q: %v", name, err) + return + } + if string(rdata) != data { + t.Errorf("data mismatch for file %q: expected %q, got %q", name, data, string(rdata)) + } +} + +func TestAppend(t *testing.T) { + initDb(t) + defer cleanupDb(t) + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + fileName := "t2" + err := WFS.MakeFile(ctx, zoneId, fileName, nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.AppendData(ctx, zoneId, fileName, []byte("hello")) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + // fmt.Print(GBS.dump()) + checkFileSize(t, ctx, zoneId, fileName, 5) + checkFileData(t, ctx, zoneId, fileName, "hello") + err = WFS.AppendData(ctx, zoneId, fileName, []byte(" world")) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + // fmt.Print(GBS.dump()) + checkFileSize(t, ctx, zoneId, fileName, 11) + checkFileData(t, ctx, zoneId, fileName, "hello world") +} + +func TestWriteFile(t *testing.T) { + initDb(t) + defer cleanupDb(t) + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + fileName := "t3" + err := WFS.MakeFile(ctx, zoneId, fileName, nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.WriteFile(ctx, zoneId, fileName, []byte("hello world!")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileData(t, ctx, zoneId, fileName, "hello world!") + err = WFS.WriteFile(ctx, zoneId, fileName, []byte("goodbye world!")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileData(t, ctx, zoneId, fileName, "goodbye world!") + err = WFS.WriteFile(ctx, zoneId, fileName, []byte("hello")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileData(t, ctx, zoneId, fileName, "hello") + + // circular file + err = WFS.MakeFile(ctx, zoneId, "c1", nil, FileOptsType{Circular: true, MaxSize: 50}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.WriteFile(ctx, zoneId, "c1", []byte("123456789 123456789 123456789 123456789 123456789 apple")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileData(t, ctx, zoneId, "c1", "6789 123456789 123456789 123456789 123456789 apple") + err = WFS.AppendData(ctx, zoneId, "c1", []byte(" banana")) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + checkFileData(t, ctx, zoneId, "c1", "3456789 123456789 123456789 123456789 apple banana") +} + +func TestCircularWrites(t *testing.T) { + initDb(t) + defer cleanupDb(t) + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + err := WFS.MakeFile(ctx, zoneId, "c1", nil, FileOptsType{Circular: true, MaxSize: 50}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.WriteFile(ctx, zoneId, "c1", []byte("123456789 123456789 123456789 123456789 123456789 ")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileData(t, ctx, zoneId, "c1", "123456789 123456789 123456789 123456789 123456789 ") + err = WFS.AppendData(ctx, zoneId, "c1", []byte("apple")) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + checkFileData(t, ctx, zoneId, "c1", "6789 123456789 123456789 123456789 123456789 apple") + err = WFS.WriteAt(ctx, zoneId, "c1", 0, []byte("foo")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + // content should be unchanged because write is before the beginning of circular offset + checkFileData(t, ctx, zoneId, "c1", "6789 123456789 123456789 123456789 123456789 apple") + err = WFS.WriteAt(ctx, zoneId, "c1", 5, []byte("a")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileSize(t, ctx, zoneId, "c1", 55) + checkFileData(t, ctx, zoneId, "c1", "a789 123456789 123456789 123456789 123456789 apple") + err = WFS.AppendData(ctx, zoneId, "c1", []byte(" banana")) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + checkFileSize(t, ctx, zoneId, "c1", 62) + checkFileData(t, ctx, zoneId, "c1", "3456789 123456789 123456789 123456789 apple banana") + err = WFS.WriteAt(ctx, zoneId, "c1", 20, []byte("foo")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileSize(t, ctx, zoneId, "c1", 62) + checkFileData(t, ctx, zoneId, "c1", "3456789 foo456789 123456789 123456789 apple banana") + offset, _, _ := WFS.ReadFile(ctx, zoneId, "c1") + if offset != 12 { + t.Errorf("offset mismatch: expected 12, got %d", offset) + } + err = WFS.AppendData(ctx, zoneId, "c1", []byte(" world")) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + checkFileSize(t, ctx, zoneId, "c1", 68) + offset, _, _ = WFS.ReadFile(ctx, zoneId, "c1") + if offset != 18 { + t.Errorf("offset mismatch: expected 18, got %d", offset) + } + checkFileData(t, ctx, zoneId, "c1", "9 foo456789 123456789 123456789 apple banana world") + err = WFS.AppendData(ctx, zoneId, "c1", []byte(" 123456789 123456789 123456789 123456789 bar456789 123456789")) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + checkFileSize(t, ctx, zoneId, "c1", 128) + checkFileData(t, ctx, zoneId, "c1", " 123456789 123456789 123456789 bar456789 123456789") + err = withLock(WFS, zoneId, "c1", func(entry *CacheEntry) error { + if entry == nil { + return fmt.Errorf("entry not found") + } + if len(entry.DataEntries) != 1 { + return fmt.Errorf("data entries mismatch: expected 1, got %d", len(entry.DataEntries)) + } + return nil + }) + if err != nil { + t.Fatalf("error checking data entries: %v", err) + } +} + +func makeText(n int) string { + var buf bytes.Buffer + for i := 0; i < n; i++ { + buf.WriteByte(byte('0' + (i % 10))) + } + return buf.String() +} + +func TestMultiPart(t *testing.T) { + initDb(t) + defer cleanupDb(t) + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + fileName := "m2" + data := makeText(80) + err := WFS.MakeFile(ctx, zoneId, fileName, nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.AppendData(ctx, zoneId, fileName, []byte(data)) + if err != nil { + t.Fatalf("error appending data: %v", err) + } + checkFileSize(t, ctx, zoneId, fileName, 80) + checkFileData(t, ctx, zoneId, fileName, data) + _, barr, err := WFS.ReadAt(ctx, zoneId, fileName, 42, 10) + if err != nil { + t.Fatalf("error reading data: %v", err) + } + if string(barr) != data[42:52] { + t.Errorf("data mismatch: expected %q, got %q", data[42:52], string(barr)) + } + WFS.WriteAt(ctx, zoneId, fileName, 49, []byte("world")) + checkFileSize(t, ctx, zoneId, fileName, 80) + checkFileDataAt(t, ctx, zoneId, fileName, 49, "world") + checkFileDataAt(t, ctx, zoneId, fileName, 48, "8world4") +} + +func testIntMapsEq(t *testing.T, msg string, m map[int]int, expected map[int]int) { + if len(m) != len(expected) { + t.Errorf("%s: map length mismatch got:%d expected:%d", msg, len(m), len(expected)) + return + } + for k, v := range m { + if expected[k] != v { + t.Errorf("%s: value mismatch for key %d, got:%d expected:%d", msg, k, v, expected[k]) + } + } +} + +func TestComputePartMap(t *testing.T) { + partDataSize = 100 + defer func() { + partDataSize = DefaultPartDataSize + }() + file := &WaveFile{} + m := file.computePartMap(0, 250) + testIntMapsEq(t, "map1", m, map[int]int{0: 100, 1: 100, 2: 50}) + m = file.computePartMap(110, 40) + log.Printf("map2:%#v\n", m) + testIntMapsEq(t, "map2", m, map[int]int{1: 40}) + m = file.computePartMap(110, 90) + testIntMapsEq(t, "map3", m, map[int]int{1: 90}) + m = file.computePartMap(110, 91) + testIntMapsEq(t, "map4", m, map[int]int{1: 90, 2: 1}) + m = file.computePartMap(820, 340) + testIntMapsEq(t, "map5", m, map[int]int{8: 80, 9: 100, 10: 100, 11: 60}) + + // now test circular + file = &WaveFile{Opts: FileOptsType{Circular: true, MaxSize: 1000}} + m = file.computePartMap(10, 250) + testIntMapsEq(t, "map6", m, map[int]int{0: 90, 1: 100, 2: 60}) + m = file.computePartMap(990, 40) + testIntMapsEq(t, "map7", m, map[int]int{9: 10, 0: 30}) + m = file.computePartMap(990, 130) + testIntMapsEq(t, "map8", m, map[int]int{9: 10, 0: 100, 1: 20}) + m = file.computePartMap(5, 1105) + testIntMapsEq(t, "map9", m, map[int]int{0: 100, 1: 10, 2: 100, 3: 100, 4: 100, 5: 100, 6: 100, 7: 100, 8: 100, 9: 100}) + m = file.computePartMap(2005, 1105) + testIntMapsEq(t, "map9", m, map[int]int{0: 100, 1: 10, 2: 100, 3: 100, 4: 100, 5: 100, 6: 100, 7: 100, 8: 100, 9: 100}) +} + +func TestSimpleDBFlush(t *testing.T) { + initDb(t) + defer cleanupDb(t) + + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + fileName := "t1" + err := WFS.MakeFile(ctx, zoneId, fileName, nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + err = WFS.WriteFile(ctx, zoneId, fileName, []byte("hello world!")) + if err != nil { + t.Fatalf("error writing data: %v", err) + } + checkFileData(t, ctx, zoneId, fileName, "hello world!") + _, err = WFS.FlushCache(ctx) + if err != nil { + t.Fatalf("error flushing cache: %v", err) + } + if WFS.getCacheSize() != 0 { + t.Errorf("cache size mismatch") + } + checkFileData(t, ctx, zoneId, fileName, "hello world!") + if WFS.getCacheSize() != 0 { + t.Errorf("cache size mismatch (after read)") + } + checkFileDataAt(t, ctx, zoneId, fileName, 6, "world!") + checkFileSize(t, ctx, zoneId, fileName, 12) + checkFileByteCount(t, ctx, zoneId, fileName, 'l', 3) +} + +func TestConcurrentAppend(t *testing.T) { + initDb(t) + defer cleanupDb(t) + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + fileName := "t1" + err := WFS.MakeFile(ctx, zoneId, fileName, nil, FileOptsType{}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + const hexChars = "0123456789abcdef" + ch := hexChars[n] + for j := 0; j < 100; j++ { + err := WFS.AppendData(ctx, zoneId, fileName, []byte{ch}) + if err != nil { + t.Errorf("error appending data (%d): %v", n, err) + } + if j == 50 { + // ignore error here (concurrent flushing) + WFS.FlushCache(ctx) + } + } + }(i) + } + wg.Wait() + checkFileSize(t, ctx, zoneId, fileName, 1600) + checkFileByteCount(t, ctx, zoneId, fileName, 'a', 100) + checkFileByteCount(t, ctx, zoneId, fileName, 'e', 100) + WFS.FlushCache(ctx) + checkFileSize(t, ctx, zoneId, fileName, 1600) + checkFileByteCount(t, ctx, zoneId, fileName, 'a', 100) + checkFileByteCount(t, ctx, zoneId, fileName, 'e', 100) +} + +func jsonDeepEqual(d1 any, d2 any) bool { + if d1 == nil && d2 == nil { + return true + } + if d1 == nil || d2 == nil { + return false + } + t1 := reflect.TypeOf(d1) + t2 := reflect.TypeOf(d2) + if t1 != t2 { + return false + } + switch d1.(type) { + case float64: + return d1.(float64) == d2.(float64) + case string: + return d1.(string) == d2.(string) + case bool: + return d1.(bool) == d2.(bool) + case []any: + a1 := d1.([]any) + a2 := d2.([]any) + if len(a1) != len(a2) { + return false + } + for i := 0; i < len(a1); i++ { + if !jsonDeepEqual(a1[i], a2[i]) { + return false + } + } + return true + case map[string]any: + m1 := d1.(map[string]any) + m2 := d2.(map[string]any) + if len(m1) != len(m2) { + return false + } + for k, v := range m1 { + if !jsonDeepEqual(v, m2[k]) { + return false + } + } + return true + default: + return false + } +} + +func TestIJson(t *testing.T) { + initDb(t) + defer cleanupDb(t) + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + zoneId := uuid.NewString() + fileName := "ij1" + err := WFS.MakeFile(ctx, zoneId, fileName, nil, FileOptsType{IJson: true}) + if err != nil { + t.Fatalf("error creating file: %v", err) + } + rootSet := ijson.MakeSetCommand(nil, map[string]any{"tag": "div", "class": "root"}) + err = WFS.AppendIJson(ctx, zoneId, fileName, rootSet) + if err != nil { + t.Fatalf("error appending ijson: %v", err) + } + _, fullData, err := WFS.ReadFile(ctx, zoneId, fileName) + if err != nil { + t.Fatalf("error reading file: %v", err) + } + cmds, err := ijson.ParseIJson(fullData) + if err != nil { + t.Fatalf("error parsing ijson: %v", err) + } + outData, err := ijson.ApplyCommands(nil, cmds, 0) + if err != nil { + t.Fatalf("error applying ijson: %v", err) + } + if !jsonDeepEqual(rootSet["data"], outData) { + t.Errorf("data mismatch: expected %v, got %v", rootSet["data"], outData) + } + childrenAppend := ijson.MakeAppendCommand(ijson.Path{"children"}, map[string]any{"tag": "div", "class": "child"}) + err = WFS.AppendIJson(ctx, zoneId, fileName, childrenAppend) + if err != nil { + t.Fatalf("error appending ijson: %v", err) + } + _, fullData, err = WFS.ReadFile(ctx, zoneId, fileName) + if err != nil { + t.Fatalf("error reading file: %v", err) + } + cmds, err = ijson.ParseIJson(fullData) + if err != nil { + t.Fatalf("error parsing ijson: %v", err) + } + if len(cmds) != 2 { + t.Fatalf("command count mismatch: expected 2, got %d", len(cmds)) + } + outData, err = ijson.ApplyCommands(nil, cmds, 0) + if err != nil { + t.Fatalf("error applying ijson: %v", err) + } + if !jsonDeepEqual(ijson.M{"tag": "div", "class": "root", "children": ijson.A{ijson.M{"tag": "div", "class": "child"}}}, outData) { + t.Errorf("data mismatch: expected %v, got %v", rootSet["data"], outData) + } + err = WFS.CompactIJson(ctx, zoneId, fileName) + if err != nil { + t.Fatalf("error compacting ijson: %v", err) + } + _, fullData, err = WFS.ReadFile(ctx, zoneId, fileName) + if err != nil { + t.Fatalf("error reading file: %v", err) + } + cmds, err = ijson.ParseIJson(fullData) + if err != nil { + t.Fatalf("error parsing ijson: %v", err) + } + if len(cmds) != 1 { + t.Fatalf("command count mismatch: expected 1, got %d", len(cmds)) + } + outData, err = ijson.ApplyCommands(nil, cmds, 0) + if err != nil { + t.Fatalf("error applying ijson: %v", err) + } + if !jsonDeepEqual(ijson.M{"tag": "div", "class": "root", "children": ijson.A{ijson.M{"tag": "div", "class": "child"}}}, outData) { + t.Errorf("data mismatch: expected %v, got %v", rootSet["data"], outData) + } +} diff --git a/pkg/gogen/gogen.go b/pkg/gogen/gogen.go new file mode 100644 index 000000000..13da6b40b --- /dev/null +++ b/pkg/gogen/gogen.go @@ -0,0 +1,105 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gogen + +import ( + "fmt" + "reflect" + "strings" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +func GenerateBoilerplate(buf *strings.Builder, pkgName string, imports []string) { + buf.WriteString("// Copyright 2024, Command Line Inc.\n") + buf.WriteString("// SPDX-License-Identifier: Apache-2.0\n") + buf.WriteString("\n// Generated Code. DO NOT EDIT.\n\n") + buf.WriteString(fmt.Sprintf("package %s\n\n", pkgName)) + if len(imports) > 0 { + buf.WriteString("import (\n") + for _, imp := range imports { + buf.WriteString(fmt.Sprintf("\t%q\n", imp)) + } + buf.WriteString(")\n\n") + } +} + +func getBeforeColonPart(s string) string { + if colonIdx := strings.Index(s, ":"); colonIdx != -1 { + return s[:colonIdx] + } + return s +} + +func GenerateMetaMapConsts(buf *strings.Builder, constPrefix string, rtype reflect.Type) { + buf.WriteString("const (\n") + var lastBeforeColon = "" + isFirst := true + for idx := 0; idx < rtype.NumField(); idx++ { + field := rtype.Field(idx) + if field.PkgPath != "" { + continue + } + fieldName := field.Name + jsonTag := utilfn.GetJsonTag(field) + if jsonTag == "" { + jsonTag = fieldName + } + beforeColon := getBeforeColonPart(jsonTag) + if beforeColon != lastBeforeColon { + if !isFirst { + buf.WriteString("\n") + } + lastBeforeColon = beforeColon + } + cname := constPrefix + fieldName + buf.WriteString(fmt.Sprintf("\t%-40s = %q\n", cname, jsonTag)) + isFirst = false + } + buf.WriteString(")\n") +} + +func GenMethod_Call(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) { + fmt.Fprintf(buf, "// command %q, wshserver.%s\n", methodDecl.Command, methodDecl.MethodName) + var dataType string + dataVarName := "nil" + if methodDecl.CommandDataType != nil { + dataType = ", data " + methodDecl.CommandDataType.String() + dataVarName = "data" + } + returnType := "error" + respName := "_" + tParamVal := "any" + if methodDecl.DefaultResponseDataType != nil { + returnType = "(" + methodDecl.DefaultResponseDataType.String() + ", error)" + respName = "resp" + tParamVal = methodDecl.DefaultResponseDataType.String() + } + fmt.Fprintf(buf, "func %s(w *wshutil.WshRpc%s, opts *wshrpc.RpcOpts) %s {\n", methodDecl.MethodName, dataType, returnType) + fmt.Fprintf(buf, "\t%s, err := sendRpcRequestCallHelper[%s](w, %q, %s, opts)\n", respName, tParamVal, methodDecl.Command, dataVarName) + if methodDecl.DefaultResponseDataType != nil { + fmt.Fprintf(buf, "\treturn resp, err\n") + } else { + fmt.Fprintf(buf, "\treturn err\n") + } + fmt.Fprintf(buf, "}\n\n") +} + +func GenMethod_ResponseStream(buf *strings.Builder, methodDecl *wshrpc.WshRpcMethodDecl) { + fmt.Fprintf(buf, "// command %q, wshserver.%s\n", methodDecl.Command, methodDecl.MethodName) + var dataType string + dataVarName := "nil" + if methodDecl.CommandDataType != nil { + dataType = ", data " + methodDecl.CommandDataType.String() + dataVarName = "data" + } + respType := "any" + if methodDecl.DefaultResponseDataType != nil { + respType = methodDecl.DefaultResponseDataType.String() + } + fmt.Fprintf(buf, "func %s(w *wshutil.WshRpc%s, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[%s] {\n", methodDecl.MethodName, dataType, respType) + fmt.Fprintf(buf, "\treturn sendRpcRequestResponseStreamHelper[%s](w, %q, %s, opts)\n", respType, methodDecl.Command, dataVarName) + fmt.Fprintf(buf, "}\n\n") +} diff --git a/pkg/ijson/ijson.go b/pkg/ijson/ijson.go new file mode 100644 index 000000000..1a21393bb --- /dev/null +++ b/pkg/ijson/ijson.go @@ -0,0 +1,675 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// implements incremental json format +package ijson + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +// ijson values are built out of standard go building blocks: +// string, float64, bool, nil, []any, map[string]any + +// paths are arrays of strings and ints + +const ( + SetCommandStr = "set" + DelCommandStr = "del" + AppendCommandStr = "append" +) + +type Command = map[string]any +type Path = []any +type M = map[string]any +type A = []any + +// instead of defining structs for commands, we just define a command shape +// set: type, path, value +// del: type, path +// arrayappend: type, path, value + +func MakeSetCommand(path Path, value any) Command { + return Command{ + "type": SetCommandStr, + "path": path, + "data": value, + } +} + +func MakeDelCommand(path Path) Command { + return Command{ + "type": DelCommandStr, + "path": path, + } +} + +func MakeAppendCommand(path Path, value any) Command { + return Command{ + "type": AppendCommandStr, + "path": path, + "data": value, + } +} + +var pathPartKeyRe = regexp.MustCompile(`^[a-zA-Z0-9:_#-]+`) + +func ParseSimplePath(input string) ([]any, error) { + var path []any + // Scan the input string character by character + for i := 0; i < len(input); { + if input[i] == '[' { + // Handle the index + end := strings.Index(input[i:], "]") + if end == -1 { + return nil, fmt.Errorf("unmatched bracket at position %d", i) + } + index, err := strconv.Atoi(input[i+1 : i+end]) + if err != nil { + return nil, fmt.Errorf("invalid index at position %d: %v", i, err) + } + path = append(path, index) + i += end + 1 + } else { + // Handle the key + j := i + for j < len(input) && input[j] != '.' && input[j] != '[' { + j++ + } + key := input[i:j] + if !pathPartKeyRe.MatchString(key) { + return nil, fmt.Errorf("invalid key at position %d: %s", i, key) + } + path = append(path, key) + i = j + } + if i < len(input) && input[i] == '.' { + i++ + } + } + + return path, nil +} + +type PathError struct { + Err string +} + +func (e PathError) Error() string { + return "PathError: " + e.Err +} + +func MakePathTypeError(path Path, index int) error { + return PathError{fmt.Sprintf("invalid path element type:%T at index:%d (%s)", path[index], index, FormatPath(path))} +} + +func MakePathError(errStr string, path Path, index int) error { + return PathError{fmt.Sprintf("%s at index:%d (%s)", errStr, index, FormatPath(path))} +} + +type SetTypeError struct { + Err string +} + +func (e SetTypeError) Error() string { + return "SetTypeError: " + e.Err +} + +func MakeSetTypeError(errStr string, path Path, index int) error { + return SetTypeError{fmt.Sprintf("%s at index:%d (%s)", errStr, index, FormatPath(path))} +} + +type BudgetError struct { + Err string +} + +func (e BudgetError) Error() string { + return "BudgetError: " + e.Err +} + +func MakeBudgetError(errStr string, path Path, index int) error { + return BudgetError{fmt.Sprintf("%s at index:%d (%s)", errStr, index, FormatPath(path))} +} + +var simplePathStrRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +func FormatPath(path Path) string { + if len(path) == 0 { + return "$" + } + var buf bytes.Buffer + buf.WriteByte('$') + for _, elem := range path { + switch elem := elem.(type) { + case string: + if simplePathStrRe.MatchString(elem) { + buf.WriteByte('.') + buf.WriteString(elem) + } else { + buf.WriteByte('[') + buf.WriteString(strconv.Quote(elem)) + buf.WriteByte(']') + } + case int: + buf.WriteByte('[') + buf.WriteString(strconv.Itoa(elem)) + buf.WriteByte(']') + default: + // a placeholder for a bad value + buf.WriteString(".*") + } + } + return buf.String() +} + +type pathWithPos struct { + Path Path + Index int +} + +func (pp pathWithPos) isLast() bool { + return pp.Index == len(pp.Path)-1 +} + +func GetPath(data any, path []any) (any, error) { + return getPathInternal(data, pathWithPos{Path: path, Index: 0}) +} + +func getPathInternal(data any, pp pathWithPos) (any, error) { + if data == nil { + return nil, nil + } + if pp.Index >= len(pp.Path) { + return data, nil + } + pathElemAny := pp.Path[pp.Index] + switch pathElem := pathElemAny.(type) { + case string: + mapVal, ok := data.(map[string]any) + if !ok { + return nil, nil + } + return getPathInternal(mapVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}) + case int: + if pathElem < 0 { + return nil, MakePathError("negative index", pp.Path, pp.Index) + } + arrVal, ok := data.([]any) + if !ok { + return nil, nil + } + if pathElem >= len(arrVal) { + return nil, nil + } + return getPathInternal(arrVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}) + default: + return nil, MakePathTypeError(pp.Path, pp.Index) + } +} + +type CombiningFunc func(curValue any, newValue any, pp pathWithPos, opts SetPathOpts) (any, error) + +type SetPathOpts struct { + Budget int // Budget 0 is unlimited (to set a 0 value, use -1) + Force bool + Remove bool + CombineFn CombiningFunc +} + +func SetPathNoErr(data any, path Path, value any, opts *SetPathOpts) any { + ret, _ := SetPath(data, path, value, opts) + return ret +} + +func SetPath(data any, path Path, value any, opts *SetPathOpts) (any, error) { + if opts == nil { + opts = &SetPathOpts{} + } + if opts.Remove && opts.CombineFn != nil { + return nil, fmt.Errorf("SetPath: Remove and CombineFn are mutually exclusive") + } + if opts.Remove && value != nil { + return nil, fmt.Errorf("SetPath: Remove and value are mutually exclusive") + } + return setPathInternal(data, pathWithPos{Path: path, Index: 0}, value, *opts) +} + +func checkAndModifyBudget(opts *SetPathOpts, pp pathWithPos, cost int) bool { + if opts.Budget == 0 { + return true + } + opts.Budget -= cost + if opts.Budget < 0 { + return false + } + if opts.Budget == 0 { + // 0 is weird since it means unlimited, so we set it to -1 to fail the next operation + opts.Budget = -1 + } + return true +} + +func CombineFn_ArrayAppend(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { + if !checkAndModifyBudget(&opts, pp, 1) { + return nil, MakeBudgetError("trying to append to array", pp.Path, pp.Index) + } + if data == nil { + data = make([]any, 0) + } + arrVal, ok := data.([]any) + if !ok && !opts.Force { + return nil, MakeSetTypeError(fmt.Sprintf("expected array, but got %T", data), pp.Path, pp.Index) + } + if !ok { + arrVal = make([]any, 0) + } + arrVal = append(arrVal, value) + return arrVal, nil +} + +func CombineFn_SetUnless(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { + if data != nil { + return data, nil + } + return value, nil +} + +func CombineFn_Max(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { + valueFloat, ok := value.(float64) + if !ok { + return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", value), pp.Path, pp.Index) + } + if data == nil { + return value, nil + } + dataFloat, ok := data.(float64) + if !ok && !opts.Force { + return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", data), pp.Path, pp.Index) + } + if !ok { + return value, nil + } + if dataFloat > valueFloat { + return data, nil + } + return value, nil +} + +func CombineFn_Min(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { + valueFloat, ok := value.(float64) + if !ok { + return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", value), pp.Path, pp.Index) + } + if data == nil { + return value, nil + } + dataFloat, ok := data.(float64) + if !ok && !opts.Force { + return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", data), pp.Path, pp.Index) + } + if !ok { + return value, nil + } + if dataFloat < valueFloat { + return data, nil + } + return value, nil +} + +func CombineFn_Inc(data any, value any, pp pathWithPos, opts SetPathOpts) (any, error) { + valueFloat, ok := value.(float64) + if !ok { + return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", value), pp.Path, pp.Index) + } + if data == nil { + return value, nil + } + dataFloat, ok := data.(float64) + if !ok && !opts.Force { + return nil, MakeSetTypeError(fmt.Sprintf("expected float64, but got %T", data), pp.Path, pp.Index) + } + if !ok { + return value, nil + } + return dataFloat + valueFloat, nil +} + +// force will clobber existing values that don't conform to path +// so SetPath(5, ["a"], 6 true) would return {"a": 6} +func setPathInternal(data any, pp pathWithPos, value any, opts SetPathOpts) (any, error) { + if pp.Index >= len(pp.Path) { + if opts.CombineFn != nil { + return opts.CombineFn(data, value, pp, opts) + } + return value, nil + } + pathElemAny := pp.Path[pp.Index] + switch pathElem := pathElemAny.(type) { + case string: + if data == nil { + if opts.Remove { + return nil, nil + } + data = make(map[string]any) + } + mapVal, ok := data.(map[string]any) + if !ok && !opts.Force { + return nil, MakeSetTypeError(fmt.Sprintf("expected map, but got %T", data), pp.Path, pp.Index) + } + if !ok { + mapVal = make(map[string]any) + } + if opts.Remove && pp.isLast() { + delete(mapVal, pathElem) + if len(mapVal) == 0 { + return nil, nil + } + return mapVal, nil + } + if _, ok := mapVal[pathElem]; !ok { + if opts.Remove { + return mapVal, nil + } + if !checkAndModifyBudget(&opts, pp, 1) { + return nil, MakeBudgetError("trying to allocate map entry", pp.Path, pp.Index) + } + } + newVal, err := setPathInternal(mapVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}, value, opts) + if opts.Remove && newVal == nil { + delete(mapVal, pathElem) + if len(mapVal) == 0 { + return nil, nil + } + return mapVal, nil + } + mapVal[pathElem] = newVal + return mapVal, err + case int: + if pathElem < 0 { + return nil, MakePathError("negative index", pp.Path, pp.Index) + } + if data == nil { + if opts.Remove { + return nil, nil + } + if !checkAndModifyBudget(&opts, pp, pathElem+1) { + return nil, MakeBudgetError(fmt.Sprintf("trying to allocate array with %d elements", pathElem+1), pp.Path, pp.Index) + } + data = make([]any, pathElem+1) + } + arrVal, ok := data.([]any) + if !ok && !opts.Force { + return nil, MakeSetTypeError(fmt.Sprintf("expected array, but got %T", data), pp.Path, pp.Index) + } + if !ok { + if opts.Remove { + return nil, nil + } + if !checkAndModifyBudget(&opts, pp, pathElem+1) { + return nil, MakeBudgetError(fmt.Sprintf("trying to allocate array with %d elements", pathElem+1), pp.Path, pp.Index) + } + arrVal = make([]any, pathElem+1) + } + if opts.Remove && pp.isLast() { + if pathElem == len(arrVal)-1 { + arrVal = arrVal[:pathElem] + if len(arrVal) == 0 { + return nil, nil + } + return arrVal, nil + } + arrVal[pathElem] = nil + return arrVal, nil + } + entriesToAdd := pathElem + 1 - len(arrVal) + if opts.Remove && entriesToAdd > 0 { + return nil, nil + } + if !checkAndModifyBudget(&opts, pp, entriesToAdd) { + return nil, MakeBudgetError(fmt.Sprintf("trying to add %d elements to array", entriesToAdd), pp.Path, pp.Index) + } + for len(arrVal) <= pathElem { + arrVal = append(arrVal, nil) + } + newVal, err := setPathInternal(arrVal[pathElem], pathWithPos{Path: pp.Path, Index: pp.Index + 1}, value, opts) + if opts.Remove && newVal == nil && pathElem == len(arrVal)-1 { + arrVal = arrVal[:pathElem] + if len(arrVal) == 0 { + return nil, nil + } + return arrVal, nil + } + arrVal[pathElem] = newVal + return arrVal, err + default: + return nil, PathError{fmt.Sprintf("invalid path element type %T", pathElem)} + } +} + +func NormalizeNumbers(v any) any { + switch v := v.(type) { + case int: + return float64(v) + case float32: + return float64(v) + case int8: + return float64(v) + case int16: + return float64(v) + case int32: + return float64(v) + case int64: + return float64(v) + case uint: + return float64(v) + case uint8: + return float64(v) + case uint16: + return float64(v) + case uint32: + return float64(v) + case uint64: + return float64(v) + case []any: + for i, elem := range v { + v[i] = NormalizeNumbers(elem) + } + case map[string]any: + for k, elem := range v { + v[k] = NormalizeNumbers(elem) + } + } + return v + +} + +func DeepEqual(v1 any, v2 any) bool { + if v1 == nil && v2 == nil { + return true + } + if v1 == nil || v2 == nil { + return false + } + switch v1 := v1.(type) { + case bool: + v2, ok := v2.(bool) + return ok && v1 == v2 + case float64: + v2, ok := v2.(float64) + return ok && v1 == v2 + case string: + v2, ok := v2.(string) + return ok && v1 == v2 + case []any: + v2, ok := v2.([]any) + if !ok || len(v1) != len(v2) { + return false + } + for i := range v1 { + if !DeepEqual(v1[i], v2[i]) { + return false + } + } + return true + case map[string]any: + v2, ok := v2.(map[string]any) + if !ok || len(v1) != len(v2) { + return false + } + for k, v := range v1 { + if !DeepEqual(v, v2[k]) { + return false + } + } + return true + default: + // invalid data type, so just return false + return false + } +} + +func getCommandType(command Command) string { + typeVal, ok := command["type"] + if !ok { + return "" + } + typeStr, ok := typeVal.(string) + if !ok { + return "" + } + return typeStr +} + +func getCommandPath(command Command) []any { + pathVal, ok := command["path"] + if !ok { + return nil + } + path, ok := pathVal.([]any) + if !ok { + return nil + } + return path +} + +func ValidatePath(path any) error { + if path == nil { + // nil path is allowed (sets the root) + return nil + } + pathArr, ok := path.([]any) + if !ok { + return fmt.Errorf("path is not an array") + } + for idx, elem := range pathArr { + switch elem.(type) { + case string, int: + continue + default: + return fmt.Errorf("path element %d is not a string or int", idx) + } + } + return nil +} + +func ValidateAndMarshalCommand(command Command) ([]byte, error) { + cmdType := getCommandType(command) + if cmdType != SetCommandStr && cmdType != DelCommandStr && cmdType != AppendCommandStr { + return nil, fmt.Errorf("unknown ijson command type %q", cmdType) + } + path := getCommandPath(command) + err := ValidatePath(path) + if err != nil { + return nil, err + } + barr, err := json.Marshal(command) + if err != nil { + return nil, fmt.Errorf("error marshalling ijson command to json: %w", err) + } + return barr, nil +} + +func ApplyCommand(data any, command Command, budget int) (any, error) { + commandType := getCommandType(command) + if commandType == "" { + return nil, fmt.Errorf("ApplyCommand: missing type field") + } + switch commandType { + case SetCommandStr: + path := getCommandPath(command) + return SetPath(data, path, command["data"], &SetPathOpts{Budget: budget}) + case DelCommandStr: + path := getCommandPath(command) + return SetPath(data, path, nil, &SetPathOpts{Remove: true, Budget: budget}) + case AppendCommandStr: + path := getCommandPath(command) + return SetPath(data, path, command["data"], &SetPathOpts{CombineFn: CombineFn_ArrayAppend, Budget: budget}) + default: + return nil, fmt.Errorf("ApplyCommand: unknown command type %q", commandType) + } +} + +func ApplyCommands(data any, commands []Command, budget int) (any, error) { + for _, command := range commands { + var err error + data, err = ApplyCommand(data, command, budget) + if err != nil { + return nil, err + } + } + return data, nil +} + +func CompactIJson(fullData []byte, budget int) ([]byte, error) { + var newData any + for len(fullData) > 0 { + nlIdx := bytes.IndexByte(fullData, '\n') + var cmdData []byte + if nlIdx == -1 { + cmdData = fullData + fullData = nil + } else { + cmdData = fullData[:nlIdx] + fullData = fullData[nlIdx+1:] + } + var cmdMap Command + err := json.Unmarshal(cmdData, &cmdMap) + if err != nil { + return nil, fmt.Errorf("error unmarshalling ijson command: %w", err) + } + newData, err = ApplyCommand(newData, cmdMap, budget) + if err != nil { + return nil, fmt.Errorf("error applying ijson command: %w", err) + } + } + newRootCmd := MakeSetCommand(nil, newData) + return json.Marshal(newRootCmd) +} + +// returns a list of commands +func ParseIJson(fullData []byte) ([]Command, error) { + var commands []Command + for len(fullData) > 0 { + nlIdx := bytes.IndexByte(fullData, '\n') + var cmdData []byte + if nlIdx == -1 { + cmdData = fullData + fullData = nil + } else { + cmdData = fullData[:nlIdx] + fullData = fullData[nlIdx+1:] + } + var cmdMap Command + err := json.Unmarshal(cmdData, &cmdMap) + if err != nil { + return nil, fmt.Errorf("error unmarshalling ijson command: %w", err) + } + commands = append(commands, cmdMap) + } + return commands, nil +} diff --git a/pkg/ijson/ijson_test.go b/pkg/ijson/ijson_test.go new file mode 100644 index 000000000..ebcd3a7a2 --- /dev/null +++ b/pkg/ijson/ijson_test.go @@ -0,0 +1,164 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ijson + +import "testing" + +func TestDeepEqual(t *testing.T) { + if !DeepEqual(float64(1), float64(1)) { + t.Errorf("DeepEqual(1, 1) should be true") + } + if DeepEqual(float64(1), float64(2)) { + t.Errorf("DeepEqual(1, 2) should be false") + } + if !DeepEqual([]any{"a", 2.8, true, map[string]any{"c": 1.1}}, []any{"a", 2.8, true, map[string]any{"c": 1.1}}) { + t.Errorf("DeepEqual complex should be true") + } +} + +func TestGetPath(t *testing.T) { + data := []any{"a", 2.8, true, map[string]any{"c": 1.1}} + + rtn, err := GetPath(data, []any{0}) + if err != nil { + t.Errorf("GetPath failed: %v", err) + } + if rtn != "a" { + t.Errorf("GetPath failed: %v", rtn) + } + + rtn, err = GetPath(data, []any{50}) + if err != nil { + t.Errorf("GetPath failed: %v", err) + } + if rtn != nil { + t.Errorf("GetPath failed: %v", rtn) + } + + rtn, err = GetPath(data, []any{3, "c"}) + if err != nil { + t.Errorf("GetPath failed: %v", err) + } + if rtn != 1.1 { + t.Errorf("GetPath failed: %v", rtn) + } +} + +func makeValue() any { + return []any{"a", 2.8, true, map[string]any{"c": 1.1}} +} + +func TestSetPath(t *testing.T) { + rtn, err := SetPath(makeValue(), []any{0}, "b", nil) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if rtn.([]any)[0] != "b" { + t.Errorf("SetPath failed: %v", rtn) + } + rtn, err = SetPath(makeValue(), []any{10}, "b", nil) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if len(rtn.([]any)) != 11 { + t.Errorf("SetPath failed: %v", rtn) + } + rtn, _ = GetPath(rtn, []any{10}) + if rtn != "b" { + t.Errorf("SetPath failed: %v", rtn) + } + _, err = SetPath(makeValue(), []any{"a"}, "b", nil) + if err == nil { + t.Errorf("SetPath should have failed") + } + rtn, err = SetPath(makeValue(), []any{"a"}, "b", &SetPathOpts{Force: true}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if !DeepEqual(rtn, map[string]any{"a": "b"}) { + t.Errorf("SetPath failed: %v", rtn) + } + rtn, err = SetPath(makeValue(), nil, "c", &SetPathOpts{CombineFn: CombineFn_ArrayAppend}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if !DeepEqual(rtn, []any{"a", 2.8, true, map[string]any{"c": 1.1}, "c"}) { + t.Errorf("SetPath failed: %v", rtn) + } + _, err = SetPath(makeValue(), nil, "c", &SetPathOpts{CombineFn: CombineFn_ArrayAppend, Budget: -1}) + if err == nil { + t.Errorf("SetPath should have failed") + } + rtn, err = SetPath(makeValue(), []any{5000}, "c", nil) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if len(rtn.([]any)) != 5001 { + t.Errorf("SetPath failed: %v", rtn) + } + _, err = SetPath(makeValue(), []any{5000}, "c", &SetPathOpts{Budget: 1000}) + if err == nil { + t.Errorf("SetPath should have failed") + } + rtn, err = SetPath(makeValue(), []any{3, "c"}, nil, &SetPathOpts{Remove: true}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if !DeepEqual(rtn, []any{"a", 2.8, true}) { + t.Errorf("SetPath failed: %v", rtn) + } + rtn, _ = SetPath(makeValue(), []any{3}, nil, &SetPathOpts{Remove: true}) + rtn, _ = SetPath(rtn, []any{2}, nil, &SetPathOpts{Remove: true}) + rtn, _ = SetPath(rtn, []any{1}, nil, &SetPathOpts{Remove: true}) + rtn, _ = SetPath(rtn, []any{0}, nil, &SetPathOpts{Remove: true}) + if rtn != nil { + t.Errorf("SetPath failed: %v", rtn) + } + rtn, err = SetPath(makeValue(), []any{3, "d"}, 2.2, nil) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if !DeepEqual(rtn, []any{"a", 2.8, true, map[string]any{"c": 1.1, "d": 2.2}}) { + t.Errorf("SetPath failed: %v", rtn) + } + + rtn, err = SetPath(makeValue(), []any{1}, 2.2, &SetPathOpts{CombineFn: CombineFn_Inc}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if !DeepEqual(rtn, []any{"a", 5.0, true, map[string]any{"c": 1.1}}) { + t.Errorf("SetPath failed: %v", rtn) + } + + rtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_Min}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if rtn.([]any)[1] != 2.8 { + t.Errorf("SetPath failed: %v", rtn) + } + + rtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_Max}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if rtn.([]any)[1] != 500.0 { + t.Errorf("SetPath failed: %v", rtn) + } + + rtn, err = SetPath(makeValue(), []any{1}, 500.0, &SetPathOpts{CombineFn: CombineFn_SetUnless}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if rtn.([]any)[1] != 2.8 { + t.Errorf("SetPath failed: %v", rtn) + } + rtn, err = SetPath(makeValue(), []any{8}, 500.0, &SetPathOpts{CombineFn: CombineFn_SetUnless}) + if err != nil { + t.Errorf("SetPath failed: %v", err) + } + if rtn.([]any)[8] != 500.0 { + t.Errorf("SetPath failed: %v", rtn) + } +} diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go new file mode 100644 index 000000000..b3ec612ab --- /dev/null +++ b/pkg/remote/conncontroller/conncontroller.go @@ -0,0 +1,613 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conncontroller + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/kevinburke/ssh_config" + "github.com/skeema/knownhosts" + "github.com/wavetermdev/waveterm/pkg/remote" + "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "golang.org/x/crypto/ssh" +) + +const ( + Status_Init = "init" + Status_Connecting = "connecting" + Status_Connected = "connected" + Status_Disconnected = "disconnected" + Status_Error = "error" +) + +const DefaultConnectionTimeout = 60 * time.Second + +var globalLock = &sync.Mutex{} +var clientControllerMap = make(map[remote.SSHOpts]*SSHConn) +var activeConnCounter = &atomic.Int32{} + +type SSHConn struct { + Lock *sync.Mutex + Status string + Opts *remote.SSHOpts + Client *ssh.Client + SockName string + DomainSockListener net.Listener + ConnController *ssh.Session + Error string + HasWaiter *atomic.Bool + LastConnectTime int64 + ActiveConnNum int +} + +func GetAllConnStatus() []wshrpc.ConnStatus { + globalLock.Lock() + defer globalLock.Unlock() + + var connStatuses []wshrpc.ConnStatus + for _, conn := range clientControllerMap { + connStatuses = append(connStatuses, conn.DeriveConnStatus()) + } + return connStatuses +} + +func (conn *SSHConn) DeriveConnStatus() wshrpc.ConnStatus { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return wshrpc.ConnStatus{ + Status: conn.Status, + Connected: conn.Status == Status_Connected, + Connection: conn.Opts.String(), + HasConnected: (conn.LastConnectTime > 0), + ActiveConnNum: conn.ActiveConnNum, + Error: conn.Error, + } +} + +func (conn *SSHConn) FireConnChangeEvent() { + status := conn.DeriveConnStatus() + event := wps.WaveEvent{ + Event: wps.Event_ConnChange, + Scopes: []string{ + fmt.Sprintf("connection:%s", conn.GetName()), + }, + Data: status, + } + log.Printf("sending event: %+#v", event) + wps.Broker.Publish(event) +} + +func (conn *SSHConn) Close() error { + defer conn.FireConnChangeEvent() + conn.WithLock(func() { + if conn.Status == Status_Connected || conn.Status == Status_Connecting { + // if status is init, disconnected, or error don't change it + conn.Status = Status_Disconnected + } + conn.close_nolock() + }) + // we must wait for the waiter to complete + startTime := time.Now() + for conn.HasWaiter.Load() { + time.Sleep(10 * time.Millisecond) + if time.Since(startTime) > 2*time.Second { + return fmt.Errorf("timeout waiting for waiter to complete") + } + } + return nil +} + +func (conn *SSHConn) close_nolock() { + // does not set status (that should happen at another level) + if conn.DomainSockListener != nil { + conn.DomainSockListener.Close() + conn.DomainSockListener = nil + } + if conn.ConnController != nil { + conn.ConnController.Close() + conn.ConnController = nil + } + if conn.Client != nil { + conn.Client.Close() + conn.Client = nil + } +} + +func (conn *SSHConn) GetDomainSocketName() string { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.SockName +} + +func (conn *SSHConn) GetStatus() string { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.Status +} + +func (conn *SSHConn) GetName() string { + // no lock required because opts is immutable + return conn.Opts.String() +} + +func (conn *SSHConn) OpenDomainSocketListener() error { + var allowed bool + conn.WithLock(func() { + if conn.Status != Status_Connecting { + allowed = false + } else { + allowed = true + } + }) + if !allowed { + return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus()) + } + client := conn.GetClient() + randStr, err := utilfn.RandomHexString(16) // 64-bits of randomness + if err != nil { + return fmt.Errorf("error generating random string: %w", err) + } + sockName := fmt.Sprintf("/tmp/waveterm-%s.sock", randStr) + log.Printf("remote domain socket %s %q\n", conn.GetName(), conn.GetDomainSocketName()) + listener, err := client.ListenUnix(sockName) + if err != nil { + return fmt.Errorf("unable to request connection domain socket: %v", err) + } + conn.WithLock(func() { + conn.SockName = sockName + conn.DomainSockListener = listener + }) + go func() { + defer conn.WithLock(func() { + conn.DomainSockListener = nil + conn.SockName = "" + }) + wshutil.RunWshRpcOverListener(listener) + }() + return nil +} + +func (conn *SSHConn) StartConnServer() error { + var allowed bool + conn.WithLock(func() { + if conn.Status != Status_Connecting { + allowed = false + } else { + allowed = true + } + }) + if !allowed { + return fmt.Errorf("cannot start conn server for %q when status is %q", conn.GetName(), conn.GetStatus()) + } + client := conn.GetClient() + wshPath := remote.GetWshPath(client) + rpcCtx := wshrpc.RpcContext{ + ClientType: wshrpc.ClientType_ConnServer, + Conn: conn.GetName(), + } + sockName := conn.GetDomainSocketName() + jwtToken, err := wshutil.MakeClientJWTToken(rpcCtx, sockName) + if err != nil { + return fmt.Errorf("unable to create jwt token for conn controller: %w", err) + } + sshSession, err := client.NewSession() + if err != nil { + return fmt.Errorf("unable to create ssh session for conn controller: %w", err) + } + pipeRead, pipeWrite := io.Pipe() + sshSession.Stdout = pipeWrite + sshSession.Stderr = pipeWrite + shellPath, err := remote.DetectShell(client) + if err != nil { + return err + } + var cmdStr string + if remote.IsPowershell(shellPath) { + cmdStr = fmt.Sprintf("$env:%s=\"%s\"; %s connserver", wshutil.WaveJwtTokenVarName, jwtToken, wshPath) + } else { + cmdStr = fmt.Sprintf("%s=\"%s\" %s connserver", wshutil.WaveJwtTokenVarName, jwtToken, wshPath) + } + log.Printf("starting conn controller: %s\n", cmdStr) + err = sshSession.Start(cmdStr) + if err != nil { + return fmt.Errorf("unable to start conn controller: %w", err) + } + conn.WithLock(func() { + conn.ConnController = sshSession + }) + // service the I/O + go func() { + // wait for termination, clear the controller + defer conn.WithLock(func() { + conn.ConnController = nil + }) + waitErr := sshSession.Wait() + log.Printf("conn controller (%q) terminated: %v", conn.GetName(), waitErr) + }() + go func() { + readErr := wshutil.StreamToLines(pipeRead, func(line []byte) { + lineStr := string(line) + if !strings.HasSuffix(lineStr, "\n") { + lineStr += "\n" + } + log.Printf("[conncontroller:%s:output] %s", conn.GetName(), lineStr) + }) + if readErr != nil && readErr != io.EOF { + log.Printf("[conncontroller:%s] error reading output: %v\n", conn.GetName(), readErr) + } + }() + regCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + err = wshutil.DefaultRouter.WaitForRegister(regCtx, wshutil.MakeConnectionRouteId(rpcCtx.Conn)) + if err != nil { + return fmt.Errorf("timeout waiting for connserver to register") + } + time.Sleep(300 * time.Millisecond) // TODO remove this sleep (but we need to wait until connserver is "ready") + return nil +} + +type WshInstallOpts struct { + Force bool + NoUserPrompt bool +} + +func (conn *SSHConn) CheckAndInstallWsh(ctx context.Context, clientDisplayName string, opts *WshInstallOpts) error { + if opts == nil { + opts = &WshInstallOpts{} + } + client := conn.GetClient() + if client == nil { + return fmt.Errorf("client is nil") + } + // check that correct wsh extensions are installed + expectedVersion := fmt.Sprintf("wsh v%s", wavebase.WaveVersion) + clientVersion, err := remote.GetWshVersion(client) + if err == nil && clientVersion == expectedVersion && !opts.Force { + return nil + } + var queryText string + var title string + if opts.Force { + queryText = fmt.Sprintf("ReInstalling Wave Shell Extensions (%s) on `%s`\n", wavebase.WaveVersion, clientDisplayName) + title = "Install Wave Shell Extensions" + } else if err != nil { + queryText = fmt.Sprintf("Wave requires Wave Shell Extensions to be \n"+ + "installed on `%s` \n"+ + "to ensure a seamless experience. \n\n"+ + "Would you like to install them?", clientDisplayName) + title = "Install Wave Shell Extensions" + } else { + queryText = fmt.Sprintf("Wave requires the Wave Shell Extensions \n"+ + "installed on `%s` \n"+ + "to be updated from %s to %s. \n\n"+ + "Would you like to update?", clientDisplayName, clientVersion, expectedVersion) + title = "Update Wave Shell Extensions" + } + if !opts.NoUserPrompt { + request := &userinput.UserInputRequest{ + ResponseType: "confirm", + QueryText: queryText, + Title: title, + Markdown: true, + CheckBoxMsg: "Don't show me this again", + } + response, err := userinput.GetUserInput(ctx, request) + if err != nil || !response.Confirm { + return err + } + } + log.Printf("attempting to install wsh to `%s`", clientDisplayName) + clientOs, err := remote.GetClientOs(client) + if err != nil { + return err + } + clientArch, err := remote.GetClientArch(client) + if err != nil { + return err + } + // attempt to install extension + wshLocalPath := shellutil.GetWshBinaryPath(wavebase.WaveVersion, clientOs, clientArch) + err = remote.CpHostToRemote(client, wshLocalPath, "~/.waveterm/bin/wsh") + if err != nil { + return err + } + log.Printf("successfully installed wsh on %s\n", conn.GetName()) + return nil +} + +func (conn *SSHConn) GetClient() *ssh.Client { + conn.Lock.Lock() + defer conn.Lock.Unlock() + return conn.Client +} + +func (conn *SSHConn) Reconnect(ctx context.Context) error { + err := conn.Close() + if err != nil { + return err + } + return conn.Connect(ctx) +} + +func (conn *SSHConn) WaitForConnect(ctx context.Context) error { + for { + status := conn.DeriveConnStatus() + if status.Status == Status_Connected { + return nil + } + if status.Status == Status_Connecting { + select { + case <-ctx.Done(): + return fmt.Errorf("context timeout") + case <-time.After(100 * time.Millisecond): + continue + } + } + if status.Status == Status_Init || status.Status == Status_Disconnected { + return fmt.Errorf("disconnected") + } + if status.Status == Status_Error { + return fmt.Errorf("error: %v", status.Error) + } + return fmt.Errorf("unknown status: %q", status.Status) + } +} + +// does not return an error since that error is stored inside of SSHConn +func (conn *SSHConn) Connect(ctx context.Context) error { + var connectAllowed bool + conn.WithLock(func() { + if conn.Status == Status_Connecting || conn.Status == Status_Connected { + connectAllowed = false + } else { + conn.Status = Status_Connecting + conn.Error = "" + connectAllowed = true + } + }) + log.Printf("Connect %s\n", conn.GetName()) + if !connectAllowed { + return fmt.Errorf("cannot connect to %q when status is %q", conn.GetName(), conn.GetStatus()) + } + conn.FireConnChangeEvent() + err := conn.connectInternal(ctx) + conn.WithLock(func() { + if err != nil { + conn.Status = Status_Error + conn.Error = err.Error() + conn.close_nolock() + } else { + conn.Status = Status_Connected + conn.LastConnectTime = time.Now().UnixMilli() + if conn.ActiveConnNum == 0 { + conn.ActiveConnNum = int(activeConnCounter.Add(1)) + } + } + }) + conn.FireConnChangeEvent() + return err +} + +func (conn *SSHConn) WithLock(fn func()) { + conn.Lock.Lock() + defer conn.Lock.Unlock() + fn() +} + +func (conn *SSHConn) connectInternal(ctx context.Context) error { + client, err := remote.ConnectToClient(ctx, conn.Opts) //todo specify or remove opts + if err != nil { + return err + } + fmtAddr := knownhosts.Normalize(fmt.Sprintf("%s@%s", client.User(), client.RemoteAddr().String())) + clientDisplayName := fmt.Sprintf("%s (%s)", conn.GetName(), fmtAddr) + conn.WithLock(func() { + conn.Client = client + }) + err = conn.OpenDomainSocketListener() + if err != nil { + return err + } + installErr := conn.CheckAndInstallWsh(ctx, clientDisplayName, nil) + if installErr != nil { + return fmt.Errorf("conncontroller %s wsh install error: %v", conn.GetName(), installErr) + } + csErr := conn.StartConnServer() + if csErr != nil { + return fmt.Errorf("conncontroller %s start wsh connserver error: %v", conn.GetName(), csErr) + } + conn.HasWaiter.Store(true) + go conn.waitForDisconnect() + return nil +} + +func (conn *SSHConn) waitForDisconnect() { + defer conn.FireConnChangeEvent() + defer conn.HasWaiter.Store(false) + client := conn.GetClient() + if client == nil { + return + } + err := client.Wait() + conn.WithLock(func() { + // disconnects happen for a variety of reasons (like network, etc. and are typically transient) + // so we just set the status to "disconnected" here (not error) + // don't overwrite any existing error (or error status) + if err != nil && conn.Error == "" { + conn.Error = err.Error() + } + if conn.Status != Status_Error { + conn.Status = Status_Disconnected + } + conn.close_nolock() + }) +} + +func getConnInternal(opts *remote.SSHOpts) *SSHConn { + globalLock.Lock() + defer globalLock.Unlock() + rtn := clientControllerMap[*opts] + if rtn == nil { + rtn = &SSHConn{Lock: &sync.Mutex{}, Status: Status_Init, Opts: opts, HasWaiter: &atomic.Bool{}} + clientControllerMap[*opts] = rtn + } + return rtn +} + +func GetConn(ctx context.Context, opts *remote.SSHOpts, shouldConnect bool) *SSHConn { + conn := getConnInternal(opts) + if conn.Client == nil && shouldConnect { + conn.Connect(ctx) + } + return conn +} + +// Convenience function for ensuring a connection is established +func EnsureConnection(ctx context.Context, connName string) error { + if connName == "" { + return nil + } + connOpts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("error parsing connection name: %w", err) + } + conn := GetConn(ctx, connOpts, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + connStatus := conn.DeriveConnStatus() + switch connStatus.Status { + case Status_Connected: + return nil + case Status_Connecting: + return conn.WaitForConnect(ctx) + case Status_Init, Status_Disconnected: + return conn.Connect(ctx) + case Status_Error: + return fmt.Errorf("connection error: %s", connStatus.Error) + default: + return fmt.Errorf("unknown connection status %q", connStatus.Status) + } +} + +func DisconnectClient(opts *remote.SSHOpts) error { + conn := getConnInternal(opts) + if conn == nil { + return fmt.Errorf("client %q not found", opts.String()) + } + err := conn.Close() + return err +} + +func resolveSshConfigPatterns(configFiles []string) ([]string, error) { + // using two separate containers to track order and have O(1) lookups + // since go does not have an ordered map primitive + var discoveredPatterns []string + alreadyUsed := make(map[string]bool) + alreadyUsed[""] = true // this excludes the empty string from potential alias + var openedFiles []fs.File + + defer func() { + for _, openedFile := range openedFiles { + openedFile.Close() + } + }() + + var errs []error + for _, configFile := range configFiles { + fd, openErr := os.Open(configFile) + openedFiles = append(openedFiles, fd) + if fd == nil { + errs = append(errs, openErr) + continue + } + + cfg, _ := ssh_config.Decode(fd) + for _, host := range cfg.Hosts { + // for each host, find the first good alias + for _, hostPattern := range host.Patterns { + hostPatternStr := hostPattern.String() + normalized := remote.NormalizeConfigPattern(hostPatternStr) + if (!strings.Contains(hostPatternStr, "*") && !strings.Contains(hostPatternStr, "?") && !strings.Contains(hostPatternStr, "!")) || alreadyUsed[normalized] { + discoveredPatterns = append(discoveredPatterns, normalized) + alreadyUsed[normalized] = true + break + } + } + } + } + if len(errs) == len(configFiles) { + errs = append([]error{fmt.Errorf("no ssh config files could be opened: ")}, errs...) + return nil, errors.Join(errs...) + } + if len(discoveredPatterns) == 0 { + return nil, fmt.Errorf("no compatible hostnames found in ssh config files") + } + + return discoveredPatterns, nil +} + +func GetConnectionsList() ([]string, error) { + existing := GetAllConnStatus() + var currentlyRunning []string + var hasConnected []string + + // populate all lists + for _, stat := range existing { + if stat.Connected { + currentlyRunning = append(currentlyRunning, stat.Connection) + } + + if stat.HasConnected { + hasConnected = append(hasConnected, stat.Connection) + } + } + fromConfig, err := GetConnectionsFromConfig() + if err != nil { + return nil, err + } + + // sort into one final list and remove duplicates + alreadyUsed := make(map[string]struct{}) + var connList []string + + for _, subList := range [][]string{currentlyRunning, hasConnected, fromConfig} { + for _, pattern := range subList { + if _, used := alreadyUsed[pattern]; !used { + connList = append(connList, pattern) + alreadyUsed[pattern] = struct{}{} + } + } + } + + return connList, nil +} + +func GetConnectionsFromConfig() ([]string, error) { + home := wavebase.GetHomeDir() + localConfig := filepath.Join(home, ".ssh", "config") + systemConfig := filepath.Join("/etc", "ssh", "config") + sshConfigFiles := []string{localConfig, systemConfig} + ssh_config.ReloadConfigs() + + return resolveSshConfigPatterns(sshConfigFiles) +} diff --git a/pkg/remote/connutil.go b/pkg/remote/connutil.go new file mode 100644 index 000000000..2dcd64a9c --- /dev/null +++ b/pkg/remote/connutil.go @@ -0,0 +1,359 @@ +package remote + +import ( + "bytes" + "fmt" + "html/template" + "io" + "log" + "os" + "os/user" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/kevinburke/ssh_config" + "github.com/skeema/knownhosts" + "golang.org/x/crypto/ssh" +) + +var userHostRe = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9._@\\-]*@)?([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$`) + +func ParseOpts(input string) (*SSHOpts, error) { + m := userHostRe.FindStringSubmatch(input) + if m == nil { + return nil, fmt.Errorf("invalid format of user@host argument") + } + remoteUser, remoteHost, remotePortStr := m[1], m[2], m[3] + remoteUser = strings.Trim(remoteUser, "@") + var remotePort int + if remotePortStr != "" { + var err error + remotePort, err = strconv.Atoi(remotePortStr) + if err != nil { + return nil, fmt.Errorf("invalid port specified on user@host argument") + } + } + + return &SSHOpts{SSHHost: remoteHost, SSHUser: remoteUser, SSHPort: remotePort}, nil +} + +func DetectShell(client *ssh.Client) (string, error) { + wshPath := GetWshPath(client) + + session, err := client.NewSession() + if err != nil { + return "", err + } + + log.Printf("shell detecting using command: %s shell", wshPath) + out, err := session.Output(wshPath + " shell") + if err != nil { + log.Printf("unable to determine shell. defaulting to /bin/bash: %s", err) + return "/bin/bash", nil + } + log.Printf("detecting shell: %s", out) + + return fmt.Sprintf(`"%s"`, strings.TrimSpace(string(out))), nil +} + +func GetWshVersion(client *ssh.Client) (string, error) { + wshPath := GetWshPath(client) + + session, err := client.NewSession() + if err != nil { + return "", err + } + + out, err := session.Output(wshPath + " version") + if err != nil { + return "", err + } + + return strings.TrimSpace(string(out)), nil +} + +func GetWshPath(client *ssh.Client) string { + defaultPath := "~/.waveterm/bin/wsh" + + session, err := client.NewSession() + if err != nil { + log.Printf("unable to detect client's wsh path. using default. error: %v", err) + return defaultPath + } + + out, whichErr := session.Output("which wsh") + if whichErr == nil { + return strings.TrimSpace(string(out)) + } + + session, err = client.NewSession() + if err != nil { + log.Printf("unable to detect client's wsh path. using default. error: %v", err) + return defaultPath + } + + out, whereErr := session.Output("where.exe wsh") + if whereErr == nil { + return strings.TrimSpace(string(out)) + } + + // check cmd on windows since it requires an absolute path with backslashes + session, err = client.NewSession() + if err != nil { + log.Printf("unable to detect client's wsh path. using default. error: %v", err) + return defaultPath + } + + out, cmdErr := session.Output("(dir 2>&1 *``|echo %userprofile%\\.waveterm%\\.waveterm\\bin\\wsh.exe);&<# rem #>echo none") //todo + if cmdErr == nil && strings.TrimSpace(string(out)) != "none" { + return strings.TrimSpace(string(out)) + } + + // no custom install, use default path + return defaultPath +} + +func hasBashInstalled(client *ssh.Client) (bool, error) { + session, err := client.NewSession() + if err != nil { + // this is a true error that should stop further progress + return false, err + } + + out, whichErr := session.Output("which bash") + if whichErr == nil && len(out) != 0 { + return true, nil + } + + session, err = client.NewSession() + if err != nil { + // this is a true error that should stop further progress + return false, err + } + + out, whereErr := session.Output("where.exe bash") + if whereErr == nil && len(out) != 0 { + return true, nil + } + + // note: we could also check in /bin/bash explicitly + // just in case that wasn't added to the path. but if + // that's true, we will most likely have worse + // problems going forward + + return false, nil +} + +func GetClientOs(client *ssh.Client) (string, error) { + session, err := client.NewSession() + if err != nil { + return "", err + } + + out, unixErr := session.Output("uname -s") + if unixErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return formatted, nil + } + + session, err = client.NewSession() + if err != nil { + return "", err + } + + out, cmdErr := session.Output("echo %OS%") + if cmdErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return strings.Split(formatted, "_")[0], nil + } + + session, err = client.NewSession() + if err != nil { + return "", err + } + + out, psErr := session.Output("echo $env:OS") + if psErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + return strings.Split(formatted, "_")[0], nil + } + return "", fmt.Errorf("unable to determine os: {unix: %s, cmd: %s, powershell: %s}", unixErr, cmdErr, psErr) +} + +func GetClientArch(client *ssh.Client) (string, error) { + session, err := client.NewSession() + if err != nil { + return "", err + } + + out, unixErr := session.Output("uname -m") + if unixErr == nil { + formatted := strings.ToLower(string(out)) + formatted = strings.TrimSpace(formatted) + if formatted == "x86_64" { + return "x64", nil + } + return formatted, nil + } + + session, err = client.NewSession() + if err != nil { + return "", err + } + + out, cmdErr := session.Output("echo %PROCESSOR_ARCHITECTURE%") + if cmdErr == nil { + formatted := strings.ToLower(string(out)) + return strings.TrimSpace(formatted), nil + } + + session, err = client.NewSession() + if err != nil { + return "", err + } + + out, psErr := session.Output("echo $env:PROCESSOR_ARCHITECTURE") + if psErr == nil { + formatted := strings.ToLower(string(out)) + return strings.TrimSpace(formatted), nil + } + return "", fmt.Errorf("unable to determine architecture: {unix: %s, cmd: %s, powershell: %s}", unixErr, cmdErr, psErr) +} + +var installTemplateRawBash = `bash -c ' \ +mkdir -p {{.installDir}}; \ +cat > {{.tempPath}}; \ +mv {{.tempPath}} {{.installPath}}; \ +chmod a+x {{.installPath}};' \ +` + +var installTemplateRawDefault = ` \ +mkdir -p {{.installDir}}; \ +cat > {{.tempPath}}; \ +mv {{.tempPath}} {{.installPath}}; \ +chmod a+x {{.installPath}}; \ +` + +func CpHostToRemote(client *ssh.Client, sourcePath string, destPath string) error { + // warning: does not work on windows remote yet + bashInstalled, err := hasBashInstalled(client) + if err != nil { + return err + } + + var selectedTemplateRaw string + if bashInstalled { + selectedTemplateRaw = installTemplateRawBash + } else { + log.Printf("bash is not installed on remote. attempting with default shell") + selectedTemplateRaw = installTemplateRawDefault + } + + var installWords = map[string]string{ + "installDir": filepath.Dir(destPath), + "tempPath": destPath + ".temp", + "installPath": destPath, + } + + installCmd := &bytes.Buffer{} + installTemplate := template.Must(template.New("").Parse(selectedTemplateRaw)) + installTemplate.Execute(installCmd, installWords) + + session, err := client.NewSession() + if err != nil { + return err + } + + installStdin, err := session.StdinPipe() + if err != nil { + return err + } + + err = session.Start(installCmd.String()) + if err != nil { + return err + } + + input, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("cannot open local file %s to send to host: %v", sourcePath, err) + } + + go func() { + io.Copy(installStdin, input) + session.Close() // this allows the command to complete for reasons i don't fully understand + }() + + return session.Wait() +} + +func InstallClientRcFiles(client *ssh.Client) error { + path := GetWshPath(client) + log.Printf("path to wsh searched is: %s", path) + session, err := client.NewSession() + if err != nil { + // this is a true error that should stop further progress + return err + } + + _, err = session.Output(path + " rcfiles") + return err +} + +func GetHomeDir(client *ssh.Client) string { + session, err := client.NewSession() + if err != nil { + return "~" + } + + out, err := session.Output(`echo "$HOME"`) + if err == nil { + return strings.TrimSpace(string(out)) + } + + session, err = client.NewSession() + if err != nil { + return "~" + } + out, err = session.Output(`echo %userprofile%`) + if err == nil { + return strings.TrimSpace(string(out)) + } + + return "~" +} + +func IsPowershell(shellPath string) bool { + // get the base path, and then check contains + shellBase := filepath.Base(shellPath) + return strings.Contains(shellBase, "powershell") || strings.Contains(shellBase, "pwsh") +} + +func NormalizeConfigPattern(pattern string) string { + userName, err := ssh_config.GetStrict(pattern, "User") + if err != nil { + localUser, err := user.Current() + if err == nil { + userName = localUser.Username + } + } + port, err := ssh_config.GetStrict(pattern, "Port") + if err != nil { + port = "22" + } + if userName != "" { + userName += "@" + } + if port == "22" { + port = "" + } else { + port = ":" + port + } + unnormalized := fmt.Sprintf("%s%s%s", userName, pattern, port) + return knownhosts.Normalize(unnormalized) +} diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go new file mode 100644 index 000000000..a97b28f9d --- /dev/null +++ b/pkg/remote/sshclient.go @@ -0,0 +1,745 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package remote + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "fmt" + "log" + "net" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/kevinburke/ssh_config" + "github.com/skeema/knownhosts" + "github.com/wavetermdev/waveterm/pkg/trimquotes" + "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + xknownhosts "golang.org/x/crypto/ssh/knownhosts" +) + +type UserInputCancelError struct { + Err error +} + +type HostKeyAlgorithms = func(hostWithPort string) (algos []string) + +func (uice UserInputCancelError) Error() string { + return uice.Err.Error() +} + +// This exists to trick the ssh library into continuing to try +// different public keys even when the current key cannot be +// properly parsed +func createDummySigner() ([]ssh.Signer, error) { + dummyKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + dummySigner, err := ssh.NewSignerFromKey(dummyKey) + if err != nil { + return nil, err + } + return []ssh.Signer{dummySigner}, nil + +} + +// This is a workaround to only process one identity file at a time, +// even if they have passphrases. It must be combined with retryable +// authentication to work properly +// +// Despite returning an array of signers, we only ever provide one since +// it allows proper user interaction in between attempts +// +// A significant number of errors end up returning dummy values as if +// they were successes. An error in this function prevents any other +// keys from being attempted. But if there's an error because of a dummy +// file, the library can still try again with a new key. +func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent) func() ([]ssh.Signer, error) { + var identityFiles []string + existingKeys := make(map[string][]byte) + + // checking the file early prevents us from needing to send a + // dummy signer if there's a problem with the signer + for _, identityFile := range sshKeywords.IdentityFile { + privateKey, err := os.ReadFile(wavebase.ExpandHomeDir(identityFile)) + if err != nil { + // skip this key and try with the next + continue + } + existingKeys[identityFile] = privateKey + identityFiles = append(identityFiles, identityFile) + } + // require pointer to modify list in closure + identityFilesPtr := &identityFiles + + var authSockSigners []ssh.Signer + authSockSigners = append(authSockSigners, authSockSignersExt...) + authSockSignersPtr := &authSockSigners + + return func() ([]ssh.Signer, error) { + // try auth sock + if len(*authSockSignersPtr) != 0 { + authSockSigner := (*authSockSignersPtr)[0] + *authSockSignersPtr = (*authSockSignersPtr)[1:] + return []ssh.Signer{authSockSigner}, nil + } + + if len(*identityFilesPtr) == 0 { + return nil, fmt.Errorf("no identity files remaining") + } + identityFile := (*identityFilesPtr)[0] + *identityFilesPtr = (*identityFilesPtr)[1:] + privateKey, ok := existingKeys[identityFile] + if !ok { + log.Printf("error with existingKeys, this should never happen") + // skip this key and try with the next + return createDummySigner() + } + + unencryptedPrivateKey, err := ssh.ParseRawPrivateKey(privateKey) + if err == nil { + signer, err := ssh.NewSignerFromKey(unencryptedPrivateKey) + if err == nil { + if sshKeywords.AddKeysToAgent && agentClient != nil { + agentClient.Add(agent.AddedKey{ + PrivateKey: unencryptedPrivateKey, + }) + } + return []ssh.Signer{signer}, err + } + } + if _, ok := err.(*ssh.PassphraseMissingError); !ok { + // skip this key and try with the next + return createDummySigner() + } + + // batch mode deactivates user input + if sshKeywords.BatchMode { + // skip this key and try with the next + return createDummySigner() + } + + request := &userinput.UserInputRequest{ + ResponseType: "text", + QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile), + Title: "Publickey Auth + Passphrase", + } + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) + defer cancelFn() + response, err := userinput.GetUserInput(ctx, request) + if err != nil { + // this is an error where we actually do want to stop + // trying keys + return nil, UserInputCancelError{Err: err} + } + unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte([]byte(response.Text))) + if err != nil { + // skip this key and try with the next + return createDummySigner() + } + signer, err := ssh.NewSignerFromKey(unencryptedPrivateKey) + if err != nil { + // skip this key and try with the next + return createDummySigner() + } + if sshKeywords.AddKeysToAgent && agentClient != nil { + agentClient.Add(agent.AddedKey{ + PrivateKey: unencryptedPrivateKey, + }) + } + return []ssh.Signer{signer}, err + } +} + +func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) { + return func() (secret string, err error) { + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) + defer cancelFn() + queryText := fmt.Sprintf( + "Password Authentication requested from connection \n"+ + "%s\n\n"+ + "Password:", remoteDisplayName) + request := &userinput.UserInputRequest{ + ResponseType: "text", + QueryText: queryText, + Markdown: true, + Title: "Password Authentication", + } + response, err := userinput.GetUserInput(ctx, request) + if err != nil { + return "", err + } + return response.Text, nil + } +} + +func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { + return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { + if len(questions) != len(echos) { + return nil, fmt.Errorf("bad response from server: questions has len %d, echos has len %d", len(questions), len(echos)) + } + for i, question := range questions { + echo := echos[i] + answer, err := promptChallengeQuestion(connCtx, question, echo, remoteName) + if err != nil { + return nil, err + } + answers = append(answers, answer) + } + return answers, nil + } +} + +func promptChallengeQuestion(connCtx context.Context, question string, echo bool, remoteName string) (answer string, err error) { + // limited to 15 seconds for some reason. this should be investigated more + // in the future + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) + defer cancelFn() + queryText := fmt.Sprintf( + "Keyboard Interactive Authentication requested from connection \n"+ + "%s\n\n"+ + "%s", remoteName, question) + request := &userinput.UserInputRequest{ + ResponseType: "text", + QueryText: queryText, + Markdown: true, + Title: "Keyboard Interactive Authentication", + PublicText: echo, + } + response, err := userinput.GetUserInput(ctx, request) + if err != nil { + return "", err + } + return response.Text, nil +} + +func openKnownHostsForEdit(knownHostsFilename string) (*os.File, error) { + path, _ := filepath.Split(knownHostsFilename) + err := os.MkdirAll(path, 0700) + if err != nil { + return nil, err + } + return os.OpenFile(knownHostsFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) +} + +func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*userinput.UserInputResponse, error)) error { + if getUserVerification == nil { + getUserVerification = func() (*userinput.UserInputResponse, error) { + return &userinput.UserInputResponse{ + Type: "confirm", + Confirm: true, + }, nil + } + } + + path, _ := filepath.Split(knownHostsFile) + err := os.MkdirAll(path, 0700) + if err != nil { + return err + } + f, err := os.OpenFile(knownHostsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + // do not close writeable files with defer + + // this file works, so let's ask the user for permission + response, err := getUserVerification() + if err != nil { + f.Close() + return UserInputCancelError{Err: err} + } + if !response.Confirm { + f.Close() + return UserInputCancelError{Err: fmt.Errorf("canceled by the user")} + } + + _, err = f.WriteString(newLine + "\n") + if err != nil { + f.Close() + return err + } + return f.Close() +} + +func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) { + base64Key := base64.StdEncoding.EncodeToString(key.Marshal()) + queryText := fmt.Sprintf( + "The authenticity of host '%s (%s)' can't be established "+ + "as it **does not exist in any checked known_hosts files**. "+ + "The host you are attempting to connect to provides this %s key: \n"+ + "%s.\n\n"+ + "**Would you like to continue connecting?** If so, the key will be permanently "+ + "added to the file %s "+ + "to protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile) + request := &userinput.UserInputRequest{ + ResponseType: "confirm", + QueryText: queryText, + Markdown: true, + Title: "Known Hosts Key Missing", + } + return func() (*userinput.UserInputResponse, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelFn() + return userinput.GetUserInput(ctx, request) + } +} + +func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) { + base64Key := base64.StdEncoding.EncodeToString(key.Marshal()) + queryText := fmt.Sprintf( + "The authenticity of host '%s (%s)' can't be established "+ + "as **no known_hosts files could be found**. "+ + "The host you are attempting to connect to provides this %s key: \n"+ + "%s.\n\n"+ + "**Would you like to continue connecting?** If so: \n"+ + "- %s will be created \n"+ + "- the key will be added to %s\n\n"+ + "This will protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile, knownHostsFile) + request := &userinput.UserInputRequest{ + ResponseType: "confirm", + QueryText: queryText, + Markdown: true, + Title: "Known Hosts File Missing", + } + return func() (*userinput.UserInputResponse, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelFn() + return userinput.GetUserInput(ctx, request) + } +} + +func lineContainsMatch(line []byte, matches [][]byte) bool { + for _, match := range matches { + if bytes.Contains(line, match) { + return true + } + } + return false +} + +func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, HostKeyAlgorithms, error) { + ssh_config.ReloadConfigs() + rawUserKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "UserKnownHostsFile") + userKnownHostsFiles := strings.Fields(rawUserKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes + rawGlobalKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "GlobalKnownHostsFile") + globalKnownHostsFiles := strings.Fields(rawGlobalKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes + + osUser, err := user.Current() + if err != nil { + return nil, nil, err + } + var unexpandedKnownHostsFiles []string + if osUser.Username == "root" { + unexpandedKnownHostsFiles = globalKnownHostsFiles + } else { + unexpandedKnownHostsFiles = append(userKnownHostsFiles, globalKnownHostsFiles...) + } + + var knownHostsFiles []string + for _, filename := range unexpandedKnownHostsFiles { + knownHostsFiles = append(knownHostsFiles, wavebase.ExpandHomeDir(filename)) + } + + // there are no good known hosts files + if len(knownHostsFiles) == 0 { + return nil, nil, fmt.Errorf("no known_hosts files provided by ssh. defaults are overridden") + } + + var unreadableFiles []string + + // the library we use isn't very forgiving about files that are formatted + // incorrectly. if a problem file is found, it is removed from our list + // and we try again + var basicCallback ssh.HostKeyCallback + var hostKeyAlgorithms HostKeyAlgorithms + for basicCallback == nil && len(knownHostsFiles) > 0 { + keyDb, err := knownhosts.NewDB(knownHostsFiles...) + if serr, ok := err.(*os.PathError); ok { + badFile := serr.Path + unreadableFiles = append(unreadableFiles, badFile) + var okFiles []string + for _, filename := range knownHostsFiles { + if filename != badFile { + okFiles = append(okFiles, filename) + } + } + if len(okFiles) >= len(knownHostsFiles) { + return nil, nil, fmt.Errorf("problem file (%s) doesn't exist. this should not be possible", badFile) + } + knownHostsFiles = okFiles + } else if err != nil { + // TODO handle obscure problems if possible + return nil, nil, fmt.Errorf("known_hosts formatting error: %+v", err) + } else { + basicCallback = keyDb.HostKeyCallback() + hostKeyAlgorithms = keyDb.HostKeyAlgorithms + } + } + + if basicCallback == nil { + basicCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return &xknownhosts.KeyError{} + } + // need to return nil here to avoid null pointer from attempting to call + // the one provided by the db if nothing was found + hostKeyAlgorithms = func(hostWithPort string) (algos []string) { + return nil + } + } + + waveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := basicCallback(hostname, remote, key) + if err == nil { + // success + return nil + } else if _, ok := err.(*xknownhosts.RevokedError); ok { + // revoked credentials are refused outright + return err + } else if _, ok := err.(*xknownhosts.KeyError); !ok { + // this is an unknown error (note the !ok is opposite of usual) + return err + } + serr, _ := err.(*xknownhosts.KeyError) + if len(serr.Want) == 0 { + // the key was not found + + // try to write to a file that could be read + err := fmt.Errorf("placeholder, should not be returned") // a null value here can cause problems with empty slice + for _, filename := range knownHostsFiles { + newLine := xknownhosts.Line([]string{xknownhosts.Normalize(hostname)}, key) + getUserVerification := createUnknownKeyVerifier(filename, hostname, remote.String(), key) + err = writeToKnownHosts(filename, newLine, getUserVerification) + if err == nil { + break + } + if serr, ok := err.(UserInputCancelError); ok { + return serr + } + } + + // try to write to a file that could not be read (file likely doesn't exist) + // should catch cases where there is no known_hosts file + if err != nil { + for _, filename := range unreadableFiles { + newLine := xknownhosts.Line([]string{xknownhosts.Normalize(hostname)}, key) + getUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key) + err = writeToKnownHosts(filename, newLine, getUserVerification) + if err == nil { + knownHostsFiles = []string{filename} + break + } + if serr, ok := err.(UserInputCancelError); ok { + return serr + } + } + } + if err != nil { + return fmt.Errorf("unable to create new knownhost key: %e", err) + } + } else { + // the key changed + correctKeyFingerprint := base64.StdEncoding.EncodeToString(key.Marshal()) + var bulletListKnownHosts []string + for _, knownHostName := range knownHostsFiles { + withBulletPoint := "- " + knownHostName + bulletListKnownHosts = append(bulletListKnownHosts, withBulletPoint) + } + var offendingKeysFmt []string + for _, badKey := range serr.Want { + formattedKey := "- " + base64.StdEncoding.EncodeToString(badKey.Key.Marshal()) + offendingKeysFmt = append(offendingKeysFmt, formattedKey) + } + // todo + _ = fmt.Sprintf("**WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!**\n\n"+ + "If this is not expected, it is possible that someone could be trying to "+ + "eavesdrop on you via a man-in-the-middle attack. "+ + "Alternatively, the host you are connecting to may have changed its key. "+ + "The %s key sent by the remote hist has the fingerprint: \n"+ + "%s\n\n"+ + "If you are sure this is correct, please update your known_hosts files to "+ + "remove the lines with the offending before trying to connect again. \n"+ + "**Known Hosts Files** \n"+ + "%s\n\n"+ + "**Offending Keys** \n"+ + "%s", key.Type(), correctKeyFingerprint, strings.Join(bulletListKnownHosts, " \n"), strings.Join(offendingKeysFmt, " \n")) + //update := scbus.MakeUpdatePacket() + // create update into alert message + + //send update via bus? + return fmt.Errorf("remote host identification has changed") + } + + updatedCallback, err := xknownhosts.New(knownHostsFiles...) + if err != nil { + return err + } + // try one final time + return updatedCallback(hostname, remote, key) + } + + return waveHostKeyCallback, hostKeyAlgorithms, nil +} + +func DialContext(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + d := net.Dialer{Timeout: config.Timeout} + conn, err := d.DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + if err != nil { + return nil, err + } + return ssh.NewClient(c, chans, reqs), nil +} + +func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error) { + sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost) + if err != nil { + return nil, err + } + + sshKeywords, err := combineSshKeywords(opts, sshConfigKeywords) + if err != nil { + return nil, err + } + remoteName := sshKeywords.User + "@" + xknownhosts.Normalize(sshKeywords.HostName+":"+sshKeywords.Port) + + var authSockSigners []ssh.Signer + var agentClient agent.ExtendedAgent + conn, err := net.Dial("unix", sshKeywords.IdentityAgent) + if err != nil { + log.Printf("Failed to open Identity Agent Socket: %v", err) + } else { + agentClient = agent.NewClient(conn) + authSockSigners, _ = agentClient.Signers() + } + + publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient)) + keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName)) + passwordCallback := ssh.PasswordCallback(createInteractivePasswordCallbackPrompt(connCtx, remoteName)) + + // exclude gssapi-with-mic and hostbased until implemented + authMethodMap := map[string]ssh.AuthMethod{ + "publickey": ssh.RetryableAuthMethod(publicKeyCallback, len(sshKeywords.IdentityFile)+len(authSockSigners)), + "keyboard-interactive": ssh.RetryableAuthMethod(keyboardInteractive, 1), + "password": ssh.RetryableAuthMethod(passwordCallback, 1), + } + + // note: batch mode turns off interactive input + authMethodActiveMap := map[string]bool{ + "publickey": sshKeywords.PubkeyAuthentication, + "keyboard-interactive": sshKeywords.KbdInteractiveAuthentication && !sshKeywords.BatchMode, + "password": sshKeywords.PasswordAuthentication && !sshKeywords.BatchMode, + } + + var authMethods []ssh.AuthMethod + for _, authMethodName := range sshKeywords.PreferredAuthentications { + authMethodActive, ok := authMethodActiveMap[authMethodName] + if !ok || !authMethodActive { + continue + } + authMethod, ok := authMethodMap[authMethodName] + if !ok { + continue + } + authMethods = append(authMethods, authMethod) + } + + hostKeyCallback, hostKeyAlgorithms, err := createHostKeyCallback(opts) + if err != nil { + return nil, err + } + + networkAddr := sshKeywords.HostName + ":" + sshKeywords.Port + clientConfig := &ssh.ClientConfig{ + User: sshKeywords.User, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + HostKeyAlgorithms: hostKeyAlgorithms(networkAddr), + } + return DialContext(connCtx, "tcp", networkAddr, clientConfig) +} + +type SshKeywords struct { + User string + HostName string + Port string + IdentityFile []string + BatchMode bool + PubkeyAuthentication bool + PasswordAuthentication bool + KbdInteractiveAuthentication bool + PreferredAuthentications []string + AddKeysToAgent bool + IdentityAgent string +} + +func combineSshKeywords(opts *SSHOpts, configKeywords *SshKeywords) (*SshKeywords, error) { + sshKeywords := &SshKeywords{} + + if opts.SSHUser != "" { + sshKeywords.User = opts.SSHUser + } else if configKeywords.User != "" { + sshKeywords.User = configKeywords.User + } else { + user, err := user.Current() + if err != nil { + return nil, fmt.Errorf("failed to get user for ssh: %+v", err) + } + sshKeywords.User = user.Username + } + + // we have to check the host value because of the weird way + // we store the pattern as the hostname for imported remotes + if configKeywords.HostName != "" { + sshKeywords.HostName = configKeywords.HostName + } else { + sshKeywords.HostName = opts.SSHHost + } + + if opts.SSHPort != 0 && opts.SSHPort != 22 { + sshKeywords.Port = strconv.Itoa(opts.SSHPort) + } else if configKeywords.Port != "" && configKeywords.Port != "22" { + sshKeywords.Port = configKeywords.Port + } else { + sshKeywords.Port = "22" + } + + sshKeywords.IdentityFile = configKeywords.IdentityFile + + // these are not officially supported in the waveterm frontend but can be configured + // in ssh config files + sshKeywords.BatchMode = configKeywords.BatchMode + sshKeywords.PubkeyAuthentication = configKeywords.PubkeyAuthentication + sshKeywords.PasswordAuthentication = configKeywords.PasswordAuthentication + sshKeywords.KbdInteractiveAuthentication = configKeywords.KbdInteractiveAuthentication + sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications + sshKeywords.AddKeysToAgent = configKeywords.AddKeysToAgent + sshKeywords.IdentityAgent = configKeywords.IdentityAgent + + return sshKeywords, nil +} + +// note that a `var == "yes"` will default to false +// but `var != "no"` will default to true +// when given unexpected strings +func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) { + ssh_config.ReloadConfigs() + sshKeywords := &SshKeywords{} + var err error + + userRaw, err := ssh_config.GetStrict(hostPattern, "User") + if err != nil { + return nil, err + } + sshKeywords.User = trimquotes.TryTrimQuotes(userRaw) + + hostNameRaw, err := ssh_config.GetStrict(hostPattern, "HostName") + if err != nil { + return nil, err + } + sshKeywords.HostName = trimquotes.TryTrimQuotes(hostNameRaw) + + portRaw, err := ssh_config.GetStrict(hostPattern, "Port") + if err != nil { + return nil, err + } + sshKeywords.Port = trimquotes.TryTrimQuotes(portRaw) + + identityFileRaw := ssh_config.GetAll(hostPattern, "IdentityFile") + for i := 0; i < len(identityFileRaw); i++ { + identityFileRaw[i] = trimquotes.TryTrimQuotes(identityFileRaw[i]) + } + sshKeywords.IdentityFile = identityFileRaw + + batchModeRaw, err := ssh_config.GetStrict(hostPattern, "BatchMode") + if err != nil { + return nil, err + } + sshKeywords.BatchMode = (strings.ToLower(trimquotes.TryTrimQuotes(batchModeRaw)) == "yes") + + // we currently do not support host-bound or unbound but will use yes when they are selected + pubkeyAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "PubkeyAuthentication") + if err != nil { + return nil, err + } + sshKeywords.PubkeyAuthentication = (strings.ToLower(trimquotes.TryTrimQuotes(pubkeyAuthenticationRaw)) != "no") + + passwordAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "PasswordAuthentication") + if err != nil { + return nil, err + } + sshKeywords.PasswordAuthentication = (strings.ToLower(trimquotes.TryTrimQuotes(passwordAuthenticationRaw)) != "no") + + kbdInteractiveAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "KbdInteractiveAuthentication") + if err != nil { + return nil, err + } + sshKeywords.KbdInteractiveAuthentication = (strings.ToLower(trimquotes.TryTrimQuotes(kbdInteractiveAuthenticationRaw)) != "no") + + // these are parsed as a single string and must be separated + // these are case sensitive in openssh so they are here too + preferredAuthenticationsRaw, err := ssh_config.GetStrict(hostPattern, "PreferredAuthentications") + if err != nil { + return nil, err + } + sshKeywords.PreferredAuthentications = strings.Split(trimquotes.TryTrimQuotes(preferredAuthenticationsRaw), ",") + addKeysToAgentRaw, err := ssh_config.GetStrict(hostPattern, "AddKeysToAgent") + if err != nil { + return nil, err + } + sshKeywords.AddKeysToAgent = (strings.ToLower(trimquotes.TryTrimQuotes(addKeysToAgentRaw)) == "yes") + + identityAgentRaw, err := ssh_config.GetStrict(hostPattern, "IdentityAgent") + if err != nil { + return nil, err + } + if identityAgentRaw == "" { + shellPath := shellutil.DetectLocalShellPath() + authSockCommand := exec.Command(shellPath, "-c", "echo ${SSH_AUTH_SOCK}") + sshAuthSock, err := authSockCommand.Output() + if err == nil { + sshKeywords.IdentityAgent = wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(strings.TrimSpace(string(sshAuthSock)))) + } else { + log.Printf("unable to find SSH_AUTH_SOCK: %v\n", err) + } + } else { + sshKeywords.IdentityAgent = wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(identityAgentRaw)) + } + + return sshKeywords, nil +} + +type SSHOpts struct { + SSHHost string `json:"sshhost"` + SSHUser string `json:"sshuser"` + SSHPort int `json:"sshport,omitempty"` +} + +func (opts SSHOpts) String() string { + stringRepr := "" + if opts.SSHUser != "" { + stringRepr = opts.SSHUser + "@" + } + stringRepr = stringRepr + opts.SSHHost + if opts.SSHPort != 0 { + stringRepr = stringRepr + ":" + fmt.Sprint(opts.SSHPort) + } + return stringRepr +} diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go new file mode 100644 index 000000000..8dc6d942f --- /dev/null +++ b/pkg/service/blockservice/blockservice.go @@ -0,0 +1,82 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package blockservice + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/wavetermdev/waveterm/pkg/blockcontroller" + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +type BlockService struct{} + +const DefaultTimeout = 2 * time.Second + +var BlockServiceInstance = &BlockService{} + +func (bs *BlockService) SendCommand_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "send command to block", + ArgNames: []string{"blockid", "cmd"}, + } +} + +func (bs *BlockService) GetControllerStatus(ctx context.Context, blockId string) (*blockcontroller.BlockControllerRuntimeStatus, error) { + bc := blockcontroller.GetBlockController(blockId) + if bc == nil { + return nil, nil + } + return bc.GetRuntimeStatus(), nil +} + +func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, state string, stateType string, ptyOffset int64) error { + _, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return err + } + if stateType != "full" && stateType != "preview" { + return fmt.Errorf("invalid state type: %q", stateType) + } + // ignore MakeFile error (already exists is ok) + filestore.WFS.MakeFile(ctx, blockId, "cache:term:"+stateType, nil, filestore.FileOptsType{}) + err = filestore.WFS.WriteFile(ctx, blockId, "cache:term:"+stateType, []byte(state)) + if err != nil { + return fmt.Errorf("cannot save terminal state: %w", err) + } + err = filestore.WFS.WriteMeta(ctx, blockId, "cache:term:"+stateType, filestore.FileMeta{"ptyoffset": ptyOffset}, true) + if err != nil { + return fmt.Errorf("cannot save terminal state meta: %w", err) + } + return nil +} + +func (bs *BlockService) SaveWaveAiData(ctx context.Context, blockId string, history []wshrpc.OpenAIPromptMessageType) error { + block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return err + } + viewName := block.Meta.GetString(waveobj.MetaKey_View, "") + if viewName != "waveai" { + return fmt.Errorf("invalid view type: %s", viewName) + } + historyBytes, err := json.Marshal(history) + if err != nil { + return fmt.Errorf("unable to serialize ai history: %v", err) + } + // ignore MakeFile error (already exists is ok) + filestore.WFS.MakeFile(ctx, blockId, "aidata", nil, filestore.FileOptsType{}) + err = filestore.WFS.WriteFile(ctx, blockId, "aidata", historyBytes) + if err != nil { + return fmt.Errorf("cannot save terminal state: %w", err) + } + return nil +} diff --git a/pkg/service/clientservice/clientservice.go b/pkg/service/clientservice/clientservice.go new file mode 100644 index 000000000..757c0d00a --- /dev/null +++ b/pkg/service/clientservice/clientservice.go @@ -0,0 +1,108 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package clientservice + +import ( + "context" + "fmt" + "time" + + "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wcore" + "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +type ClientService struct{} + +const DefaultTimeout = 2 * time.Second + +func (cs *ClientService) GetClientData() (*waveobj.Client, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + clientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return nil, fmt.Errorf("error getting client data: %w", err) + } + return clientData, nil +} + +func (cs *ClientService) GetWorkspace(workspaceId string) (*waveobj.Workspace, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ws, err := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) + if err != nil { + return nil, fmt.Errorf("error getting workspace: %w", err) + } + return ws, nil +} + +func (cs *ClientService) GetTab(tabId string) (*waveobj.Tab, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error getting tab: %w", err) + } + return tab, nil +} + +func (cs *ClientService) GetWindow(windowId string) (*waveobj.Window, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + window, err := wstore.DBGet[*waveobj.Window](ctx, windowId) + if err != nil { + return nil, fmt.Errorf("error getting window: %w", err) + } + return window, nil +} + +func (cs *ClientService) MakeWindow(ctx context.Context) (*waveobj.Window, error) { + window, err := wcore.CreateWindow(ctx, nil) + if err != nil { + return nil, err + } + err = wlayout.BootstrapNewWindowLayout(ctx, window) + if err != nil { + return window, err + } + return window, nil +} + +func (cs *ClientService) GetAllConnStatus(ctx context.Context) ([]wshrpc.ConnStatus, error) { + return conncontroller.GetAllConnStatus(), nil +} + +// moves the window to the front of the windowId stack +func (cs *ClientService) FocusWindow(ctx context.Context, windowId string) error { + client, err := cs.GetClientData() + if err != nil { + return err + } + winIdx := utilfn.SliceIdx(client.WindowIds, windowId) + if winIdx == -1 { + return nil + } + client.WindowIds = utilfn.MoveSliceIdxToFront(client.WindowIds, winIdx) + return wstore.DBUpdate(ctx, client) +} + +func (cs *ClientService) AgreeTos(ctx context.Context) (waveobj.UpdatesRtnType, error) { + ctx = waveobj.ContextWithUpdates(ctx) + clientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return nil, fmt.Errorf("error getting client data: %w", err) + } + timestamp := time.Now().UnixMilli() + clientData.TosAgreed = timestamp + err = wstore.DBUpdate(ctx, clientData) + if err != nil { + return nil, fmt.Errorf("error updating client data: %w", err) + } + wlayout.BootstrapStarterLayout(ctx) + return waveobj.ContextGetUpdatesRtn(ctx), nil +} diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go new file mode 100644 index 000000000..b9c1cf662 --- /dev/null +++ b/pkg/service/fileservice/fileservice.go @@ -0,0 +1,159 @@ +package fileservice + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const MaxFileSize = 10 * 1024 * 1024 // 10M +const DefaultTimeout = 2 * time.Second + +type FileService struct{} + +type FullFile struct { + Info *wshrpc.FileInfo `json:"info"` + Data64 string `json:"data64"` // base64 encoded +} + +func (fs *FileService) SaveFile_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "save file", + ArgNames: []string{"connection", "path", "data64"}, + } +} + +func (fs *FileService) SaveFile(connection string, path string, data64 string) error { + if connection == "" { + connection = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(connection) + client := wshserver.GetMainRpcClient() + writeData := wshrpc.CommandRemoteWriteFileData{Path: path, Data64: data64} + return wshclient.RemoteWriteFileCommand(client, writeData, &wshrpc.RpcOpts{Route: connRoute}) +} + +func (fs *FileService) StatFile_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "get file info", + ArgNames: []string{"connection", "path"}, + } +} + +func (fs *FileService) StatFile(connection string, path string) (*wshrpc.FileInfo, error) { + if connection == "" { + connection = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(connection) + client := wshserver.GetMainRpcClient() + return wshclient.RemoteFileInfoCommand(client, path, &wshrpc.RpcOpts{Route: connRoute}) +} + +func (fs *FileService) ReadFile_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "read file", + ArgNames: []string{"connection", "path"}, + } +} + +func (fs *FileService) ReadFile(connection string, path string) (*FullFile, error) { + if connection == "" { + connection = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(connection) + client := wshserver.GetMainRpcClient() + streamFileData := wshrpc.CommandRemoteStreamFileData{Path: path} + rtnCh := wshclient.RemoteStreamFileCommand(client, streamFileData, &wshrpc.RpcOpts{Route: connRoute}) + fullFile := &FullFile{} + firstPk := true + isDir := false + var fileBuf bytes.Buffer + var fileInfoArr []*wshrpc.FileInfo + for respUnion := range rtnCh { + if respUnion.Error != nil { + return nil, respUnion.Error + } + resp := respUnion.Response + if firstPk { + firstPk = false + // first packet has the fileinfo + if len(resp.FileInfo) != 1 { + return nil, fmt.Errorf("stream file protocol error, first pk fileinfo len=%d", len(resp.FileInfo)) + } + fullFile.Info = resp.FileInfo[0] + if fullFile.Info.IsDir { + isDir = true + } + continue + } + if isDir { + if len(resp.FileInfo) == 0 { + continue + } + fileInfoArr = append(fileInfoArr, resp.FileInfo...) + } else { + if resp.Data64 == "" { + continue + } + decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader([]byte(resp.Data64))) + _, err := io.Copy(&fileBuf, decoder) + if err != nil { + return nil, fmt.Errorf("stream file, failed to decode base64 data %q: %w", resp.Data64, err) + } + } + } + if isDir { + fiBytes, err := json.Marshal(fileInfoArr) + if err != nil { + return nil, fmt.Errorf("unable to serialize files %s", path) + } + fullFile.Data64 = base64.StdEncoding.EncodeToString(fiBytes) + } else { + // we can avoid this re-encoding if we ensure the remote side always encodes chunks of 3 bytes so we don't get padding chars + fullFile.Data64 = base64.StdEncoding.EncodeToString(fileBuf.Bytes()) + } + return fullFile, nil +} + +func (fs *FileService) GetWaveFile(id string, path string) (any, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + file, err := filestore.WFS.Stat(ctx, id, path) + if err != nil { + return nil, fmt.Errorf("error getting file: %w", err) + } + return file, nil +} + +func (fs *FileService) DeleteFile_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "delete file", + ArgNames: []string{"connection", "path"}, + } +} + +func (fs *FileService) DeleteFile(connection string, path string) error { + if connection == "" { + connection = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(connection) + client := wshserver.GetMainRpcClient() + return wshclient.RemoteFileDeleteCommand(client, path, &wshrpc.RpcOpts{Route: connRoute}) +} + +func (fs *FileService) GetFullConfig() wconfig.FullConfigType { + watcher := wconfig.GetWatcher() + return watcher.GetFullConfig() +} diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go new file mode 100644 index 000000000..7a6e4b298 --- /dev/null +++ b/pkg/service/objectservice/objectservice.go @@ -0,0 +1,252 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package objectservice + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wcore" + "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +type ObjectService struct{} + +const DefaultTimeout = 2 * time.Second +const ConnContextTimeout = 60 * time.Second + +func parseORef(oref string) (*waveobj.ORef, error) { + fields := strings.Split(oref, ":") + if len(fields) != 2 { + return nil, fmt.Errorf("invalid object reference: %q", oref) + } + return &waveobj.ORef{OType: fields[0], OID: fields[1]}, nil +} + +func (svc *ObjectService) GetObject_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "get wave object by oref", + ArgNames: []string{"oref"}, + } +} + +func (svc *ObjectService) GetObject(orefStr string) (waveobj.WaveObj, error) { + oref, err := parseORef(orefStr) + if err != nil { + return nil, err + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + obj, err := wstore.DBGetORef(ctx, *oref) + if err != nil { + return nil, fmt.Errorf("error getting object: %w", err) + } + return obj, nil +} + +func (svc *ObjectService) GetObjects_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"orefs"}, + ReturnDesc: "objects", + } +} + +func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + + var orefArr []waveobj.ORef + for _, orefStr := range orefStrArr { + orefObj, err := parseORef(orefStr) + if err != nil { + return nil, err + } + orefArr = append(orefArr, *orefObj) + } + return wstore.DBSelectORefs(ctx, orefArr) +} + +func (svc *ObjectService) AddTabToWorkspace_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "tabName", "activateTab"}, + ReturnDesc: "tabId", + } +} + +func (svc *ObjectService) AddTabToWorkspace(uiContext waveobj.UIContext, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + tabId, err := wcore.CreateTab(ctx, uiContext.WindowId, tabName, activateTab) + if err != nil { + return "", nil, fmt.Errorf("error creating tab: %w", err) + } + + err = wlayout.ApplyPortableLayout(ctx, tabId, wlayout.GetNewTabLayout()) + if err != nil { + return "", nil, fmt.Errorf("error applying new tab layout: %w", err) + } + return tabId, waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *ObjectService) UpdateWorkspaceTabIds_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "workspaceId", "tabIds"}, + } +} + +func (svc *ObjectService) UpdateWorkspaceTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + err := wstore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds) + if err != nil { + return nil, fmt.Errorf("error updating workspace tab ids: %w", err) + } + return waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *ObjectService) SetActiveTab_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "tabId"}, + } +} + +func (svc *ObjectService) SetActiveTab(uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + err := wstore.SetActiveTab(ctx, uiContext.WindowId, tabId) + if err != nil { + return nil, fmt.Errorf("error setting active tab: %w", err) + } + // check all blocks in tab and start controllers (if necessary) + tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error getting tab: %w", err) + } + blockORefs := tab.GetBlockORefs() + blocks, err := wstore.DBSelectORefs(ctx, blockORefs) + if err != nil { + return nil, fmt.Errorf("error getting tab blocks: %w", err) + } + updates := waveobj.ContextGetUpdatesRtn(ctx) + updates = append(updates, waveobj.MakeUpdate(tab)) + updates = append(updates, waveobj.MakeUpdates(blocks)...) + return updates, nil +} + +func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "tabId", "name"}, + } +} + +func (svc *ObjectService) UpdateTabName(uiContext waveobj.UIContext, tabId, name string) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + err := wstore.UpdateTabName(ctx, tabId, name) + if err != nil { + return nil, fmt.Errorf("error updating tab name: %w", err) + } + return waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "blockDef", "rtOpts"}, + ReturnDesc: "blockId", + } +} + +func (svc *ObjectService) CreateBlock(uiContext waveobj.UIContext, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (string, waveobj.UpdatesRtnType, error) { + if uiContext.ActiveTabId == "" { + return "", nil, fmt.Errorf("no active tab") + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + + blockData, err := wcore.CreateBlock(ctx, uiContext.ActiveTabId, blockDef, rtOpts) + if err != nil { + return "", nil, err + } + + return blockData.OID, waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *ObjectService) DeleteBlock_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "blockId"}, + } +} + +func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId string) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + err := wcore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId) + if err != nil { + return nil, fmt.Errorf("error deleting block: %w", err) + } + return waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *ObjectService) UpdateObjectMeta_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "oref", "meta"}, + } +} + +func (svc *ObjectService) UpdateObjectMeta(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + oref, err := parseORef(orefStr) + if err != nil { + return nil, fmt.Errorf("error parsing object reference: %w", err) + } + err = wstore.UpdateObjectMeta(ctx, *oref, meta) + if err != nil { + return nil, fmt.Errorf("error updateing %q meta: %w", orefStr, err) + } + return waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *ObjectService) UpdateObject_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "waveObj", "returnUpdates"}, + } +} + +func (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj waveobj.WaveObj, returnUpdates bool) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + if waveObj == nil { + return nil, fmt.Errorf("update wavobj is nil") + } + oref := waveobj.ORefFromWaveObj(waveObj) + found, err := wstore.DBExistsORef(ctx, *oref) + if err != nil { + return nil, fmt.Errorf("error getting object: %w", err) + } + if !found { + return nil, fmt.Errorf("object not found: %s", oref) + } + err = wstore.DBUpdate(ctx, waveObj) + if err != nil { + return nil, fmt.Errorf("error updating object: %w", err) + } + if returnUpdates { + return waveobj.ContextGetUpdatesRtn(ctx), nil + } + return nil, nil +} diff --git a/pkg/service/service.go b/pkg/service/service.go new file mode 100644 index 000000000..ddbf21494 --- /dev/null +++ b/pkg/service/service.go @@ -0,0 +1,458 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/wavetermdev/waveterm/pkg/service/blockservice" + "github.com/wavetermdev/waveterm/pkg/service/clientservice" + "github.com/wavetermdev/waveterm/pkg/service/fileservice" + "github.com/wavetermdev/waveterm/pkg/service/objectservice" + "github.com/wavetermdev/waveterm/pkg/service/userinputservice" + "github.com/wavetermdev/waveterm/pkg/service/windowservice" + "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/web/webcmd" +) + +var ServiceMap = map[string]any{ + "block": blockservice.BlockServiceInstance, + "object": &objectservice.ObjectService{}, + "file": &fileservice.FileService{}, + "client": &clientservice.ClientService{}, + "window": &windowservice.WindowService{}, + "userinput": &userinputservice.UserInputService{}, +} + +var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem() +var errorRType = reflect.TypeOf((*error)(nil)).Elem() +var updatesRType = reflect.TypeOf(([]waveobj.WaveObjUpdate{})) +var waveObjRType = reflect.TypeOf((*waveobj.WaveObj)(nil)).Elem() +var waveObjSliceRType = reflect.TypeOf([]waveobj.WaveObj{}) +var waveObjMapRType = reflect.TypeOf(map[string]waveobj.WaveObj{}) +var methodMetaRType = reflect.TypeOf(tsgenmeta.MethodMeta{}) +var waveObjUpdateRType = reflect.TypeOf(waveobj.WaveObjUpdate{}) +var uiContextRType = reflect.TypeOf((*waveobj.UIContext)(nil)).Elem() +var wsCommandRType = reflect.TypeOf((*webcmd.WSCommandType)(nil)).Elem() +var orefRType = reflect.TypeOf((*waveobj.ORef)(nil)).Elem() + +type WebCallType struct { + Service string `json:"service"` + Method string `json:"method"` + UIContext *waveobj.UIContext `json:"uicontext,omitempty"` + Args []any `json:"args"` +} + +type WebReturnType struct { + Success bool `json:"success,omitempty"` + Error string `json:"error,omitempty"` + Data any `json:"data,omitempty"` + Updates []waveobj.WaveObjUpdate `json:"updates,omitempty"` +} + +func convertNumber(argType reflect.Type, jsonArg float64) (any, error) { + switch argType.Kind() { + case reflect.Int: + return int(jsonArg), nil + case reflect.Int8: + return int8(jsonArg), nil + case reflect.Int16: + return int16(jsonArg), nil + case reflect.Int32: + return int32(jsonArg), nil + case reflect.Int64: + return int64(jsonArg), nil + case reflect.Uint: + return uint(jsonArg), nil + case reflect.Uint8: + return uint8(jsonArg), nil + case reflect.Uint16: + return uint16(jsonArg), nil + case reflect.Uint32: + return uint32(jsonArg), nil + case reflect.Uint64: + return uint64(jsonArg), nil + case reflect.Float32: + return float32(jsonArg), nil + case reflect.Float64: + return jsonArg, nil + } + return nil, fmt.Errorf("invalid number type %s", argType) +} + +func convertComplex(argType reflect.Type, jsonArg any) (any, error) { + nativeArgVal := reflect.New(argType) + err := utilfn.DoMapStructure(nativeArgVal.Interface(), jsonArg) + if err != nil { + return nil, err + } + return nativeArgVal.Elem().Interface(), nil +} + +func isSpecialWaveArgType(argType reflect.Type) bool { + return argType == waveObjRType || argType == waveObjSliceRType || argType == waveObjMapRType || argType == wsCommandRType +} + +func convertWSCommand(argType reflect.Type, jsonArg any) (any, error) { + if _, ok := jsonArg.(map[string]any); !ok { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + cmd, err := webcmd.ParseWSCommandMap(jsonArg.(map[string]any)) + if err != nil { + return nil, fmt.Errorf("error parsing command map: %w", err) + } + return cmd, nil +} + +func convertSpecial(argType reflect.Type, jsonArg any) (any, error) { + jsonType := reflect.TypeOf(jsonArg) + if argType == orefRType { + if jsonType.Kind() != reflect.String { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + oref, err := waveobj.ParseORef(jsonArg.(string)) + if err != nil { + return nil, fmt.Errorf("invalid oref string: %v", err) + } + return oref, nil + } else if argType == wsCommandRType { + return convertWSCommand(argType, jsonArg) + } else if argType == waveObjRType { + if jsonType.Kind() != reflect.Map { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + return waveobj.FromJsonMap(jsonArg.(map[string]any)) + } else if argType == waveObjSliceRType { + if jsonType.Kind() != reflect.Slice { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + sliceArg := jsonArg.([]any) + nativeSlice := make([]waveobj.WaveObj, len(sliceArg)) + for idx, elem := range sliceArg { + elemMap, ok := elem.(map[string]any) + if !ok { + return nil, fmt.Errorf("cannot convert %T to %s (idx %d is not a map, is %T)", jsonArg, waveObjSliceRType, idx, elem) + } + nativeObj, err := waveobj.FromJsonMap(elemMap) + if err != nil { + return nil, fmt.Errorf("cannot convert %T to %s (idx %d) error: %v", jsonArg, waveObjSliceRType, idx, err) + } + nativeSlice[idx] = nativeObj + } + return nativeSlice, nil + } else if argType == waveObjMapRType { + if jsonType.Kind() != reflect.Map { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + mapArg := jsonArg.(map[string]any) + nativeMap := make(map[string]waveobj.WaveObj) + for key, elem := range mapArg { + elemMap, ok := elem.(map[string]any) + if !ok { + return nil, fmt.Errorf("cannot convert %T to %s (key %s is not a map, is %T)", jsonArg, waveObjMapRType, key, elem) + } + nativeObj, err := waveobj.FromJsonMap(elemMap) + if err != nil { + return nil, fmt.Errorf("cannot convert %T to %s (key %s) error: %v", jsonArg, waveObjMapRType, key, err) + } + nativeMap[key] = nativeObj + } + return nativeMap, nil + } else { + return nil, fmt.Errorf("invalid special wave argument type %s", argType) + } +} + +func convertSpecialForReturn(argType reflect.Type, nativeArg any) (any, error) { + if argType == waveObjRType { + return waveobj.ToJsonMap(nativeArg.(waveobj.WaveObj)) + } else if argType == waveObjSliceRType { + nativeSlice := nativeArg.([]waveobj.WaveObj) + jsonSlice := make([]map[string]any, len(nativeSlice)) + for idx, elem := range nativeSlice { + elemMap, err := waveobj.ToJsonMap(elem) + if err != nil { + return nil, err + } + jsonSlice[idx] = elemMap + } + return jsonSlice, nil + } else if argType == waveObjMapRType { + nativeMap := nativeArg.(map[string]waveobj.WaveObj) + jsonMap := make(map[string]map[string]any) + for key, elem := range nativeMap { + elemMap, err := waveobj.ToJsonMap(elem) + if err != nil { + return nil, err + } + jsonMap[key] = elemMap + } + return jsonMap, nil + } else { + return nil, fmt.Errorf("invalid special wave argument type %s", argType) + } +} + +func convertArgument(argType reflect.Type, jsonArg any) (any, error) { + if jsonArg == nil { + return reflect.Zero(argType).Interface(), nil + } + if isSpecialWaveArgType(argType) { + return convertSpecial(argType, jsonArg) + } + jsonType := reflect.TypeOf(jsonArg) + switch argType.Kind() { + case reflect.String: + if jsonType.Kind() == reflect.String { + return jsonArg, nil + } + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + + case reflect.Bool: + if jsonType.Kind() == reflect.Bool { + return jsonArg, nil + } + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + if jsonType.Kind() == reflect.Float64 { + return convertNumber(argType, jsonArg.(float64)) + } + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + + case reflect.Map: + if argType.Key().Kind() != reflect.String { + return nil, fmt.Errorf("invalid map key type %s", argType.Key()) + } + if jsonType.Kind() != reflect.Map { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + return convertComplex(argType, jsonArg) + + case reflect.Slice: + if jsonType.Kind() != reflect.Slice { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + return convertComplex(argType, jsonArg) + + case reflect.Struct: + if jsonType.Kind() != reflect.Map { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + return convertComplex(argType, jsonArg) + + case reflect.Ptr: + if argType.Elem().Kind() != reflect.Struct { + return nil, fmt.Errorf("invalid pointer type %s", argType) + } + if jsonType.Kind() != reflect.Map { + return nil, fmt.Errorf("cannot convert %T to %s", jsonArg, argType) + } + return convertComplex(argType, jsonArg) + + default: + return nil, fmt.Errorf("invalid argument type %s", argType) + } +} + +func isNilable(val reflect.Value) bool { + switch val.Kind() { + case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Interface, reflect.Chan, reflect.Func: + return true + } + return false + +} + +func convertReturnValues(rtnVals []reflect.Value) *WebReturnType { + rtn := &WebReturnType{} + if len(rtnVals) == 0 { + return rtn + } + for _, val := range rtnVals { + if isNilable(val) && val.IsNil() { + continue + } + valType := val.Type() + if valType == errorRType { + rtn.Error = val.Interface().(error).Error() + continue + } + if valType == updatesRType { + // has a special MarshalJSON method + rtn.Updates = val.Interface().([]waveobj.WaveObjUpdate) + continue + } + if isSpecialWaveArgType(valType) { + jsonVal, err := convertSpecialForReturn(valType, val.Interface()) + if err != nil { + rtn.Error = fmt.Errorf("cannot convert special return value: %v", err).Error() + continue + } + rtn.Data = jsonVal + continue + } + rtn.Data = val.Interface() + } + if rtn.Error == "" { + rtn.Success = true + } + return rtn +} + +func webErrorRtn(err error) *WebReturnType { + return &WebReturnType{ + Error: err.Error(), + } +} + +func CallService(ctx context.Context, webCall WebCallType) *WebReturnType { + svcObj := ServiceMap[webCall.Service] + if svcObj == nil { + return webErrorRtn(fmt.Errorf("invalid service: %q", webCall.Service)) + } + method := reflect.ValueOf(svcObj).MethodByName(webCall.Method) + if !method.IsValid() { + return webErrorRtn(fmt.Errorf("invalid method: %s.%s", webCall.Service, webCall.Method)) + } + var valueArgs []reflect.Value + argIdx := 0 + for idx := 0; idx < method.Type().NumIn(); idx++ { + argType := method.Type().In(idx) + if idx == 0 && argType == contextRType { + valueArgs = append(valueArgs, reflect.ValueOf(ctx)) + continue + } + if argType == uiContextRType { + if webCall.UIContext == nil { + return webErrorRtn(fmt.Errorf("missing UIContext for %s.%s", webCall.Service, webCall.Method)) + } + valueArgs = append(valueArgs, reflect.ValueOf(*webCall.UIContext)) + continue + } + if argIdx >= len(webCall.Args) { + return webErrorRtn(fmt.Errorf("not enough arguments passed %s.%s idx:%d (type %T)", webCall.Service, webCall.Method, idx, argType)) + } + nativeArg, err := convertArgument(argType, webCall.Args[argIdx]) + if err != nil { + return webErrorRtn(fmt.Errorf("cannot convert argument %s.%s type:%T idx:%d error:%v", webCall.Service, webCall.Method, argType, idx, err)) + } + valueArgs = append(valueArgs, reflect.ValueOf(nativeArg)) + argIdx++ + } + retValArr := method.Call(valueArgs) + return convertReturnValues(retValArr) +} + +// ValidateServiceArg validates the argument type for a service method +// does not allow interfaces (and the obvious invalid types) +// arguments + return values have special handling for wave objects +func baseValidateServiceArg(argType reflect.Type) error { + if argType == waveObjUpdateRType { + // has special MarshalJSON method, so it is safe + return nil + } + switch argType.Kind() { + case reflect.Ptr, reflect.Slice, reflect.Array: + return baseValidateServiceArg(argType.Elem()) + case reflect.Map: + if argType.Key().Kind() != reflect.String { + return fmt.Errorf("invalid map key type %s", argType.Key()) + } + return baseValidateServiceArg(argType.Elem()) + case reflect.Struct: + for idx := 0; idx < argType.NumField(); idx++ { + if err := baseValidateServiceArg(argType.Field(idx).Type); err != nil { + return err + } + } + case reflect.Interface: + return fmt.Errorf("invalid argument type %s: contains interface", argType) + + case reflect.Chan, reflect.Func, reflect.Complex128, reflect.Complex64, reflect.Invalid, reflect.Uintptr, reflect.UnsafePointer: + return fmt.Errorf("invalid argument type %s", argType) + } + return nil +} + +func validateMethodReturnArg(retType reflect.Type) error { + // specifically allow waveobj.WaveObj, []waveobj.WaveObj, map[string]waveobj.WaveObj, and error + if isSpecialWaveArgType(retType) || retType == errorRType { + return nil + } + return baseValidateServiceArg(retType) +} + +func validateMethodArg(argType reflect.Type) error { + // specifically allow waveobj.WaveObj, []waveobj.WaveObj, map[string]waveobj.WaveObj, and context.Context + if isSpecialWaveArgType(argType) || argType == contextRType { + return nil + } + return baseValidateServiceArg(argType) +} + +func validateServiceMethod(service string, method reflect.Method) error { + for idx := 0; idx < method.Type.NumOut(); idx++ { + if err := validateMethodReturnArg(method.Type.Out(idx)); err != nil { + return fmt.Errorf("invalid return type %s.%s %s: %v", service, method.Name, method.Type.Out(idx), err) + } + } + for idx := 1; idx < method.Type.NumIn(); idx++ { + // skip the first argument which is the receiver + if err := validateMethodArg(method.Type.In(idx)); err != nil { + return fmt.Errorf("invalid argument type %s.%s %s: %v", service, method.Name, method.Type.In(idx), err) + } + } + return nil +} + +func validateServiceMetaMethod(service string, method reflect.Method) error { + if method.Type.NumIn() != 1 { + return fmt.Errorf("invalid number of arguments %s.%s: got:%d, expected just the receiver", service, method.Name, method.Type.NumIn()) + } + if method.Type.NumOut() != 1 && method.Type.Out(0) != methodMetaRType { + return fmt.Errorf("invalid return type %s.%s: got:%s, expected servicemeta.MethodMeta", service, method.Name, method.Type.Out(0)) + } + return nil +} + +func ValidateService(serviceName string, svcObj any) error { + svcType := reflect.TypeOf(svcObj) + if svcType.Kind() != reflect.Ptr { + return fmt.Errorf("service object %q must be a pointer", serviceName) + } + svcType = svcType.Elem() + if svcType.Kind() != reflect.Struct { + return fmt.Errorf("service object %q must be a ptr to struct", serviceName) + } + for idx := 0; idx < svcType.NumMethod(); idx++ { + method := svcType.Method(idx) + if strings.HasSuffix(method.Name, "_Meta") { + err := validateServiceMetaMethod(serviceName, method) + if err != nil { + return err + } + } + if err := validateServiceMethod(serviceName, method); err != nil { + return err + } + } + return nil +} + +func ValidateServiceMap() error { + for svcName, svcObj := range ServiceMap { + if err := ValidateService(svcName, svcObj); err != nil { + return err + } + } + return nil +} diff --git a/pkg/service/userinputservice/userinputservice.go b/pkg/service/userinputservice/userinputservice.go new file mode 100644 index 000000000..1de7e4e4a --- /dev/null +++ b/pkg/service/userinputservice/userinputservice.go @@ -0,0 +1,18 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package userinputservice + +import ( + "github.com/wavetermdev/waveterm/pkg/userinput" +) + +type UserInputService struct { +} + +func (uis *UserInputService) SendUserInputResponse(response *userinput.UserInputResponse) { + select { + case userinput.MainUserInputHandler.Channels[response.RequestId] <- response: + default: + } +} diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go new file mode 100644 index 000000000..883a5b670 --- /dev/null +++ b/pkg/service/windowservice/windowservice.go @@ -0,0 +1,184 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package windowservice + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/wavetermdev/waveterm/pkg/blockcontroller" + "github.com/wavetermdev/waveterm/pkg/eventbus" + "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wcore" + "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +const DefaultTimeout = 2 * time.Second + +type WindowService struct{} + +func (ws *WindowService) SetWindowPosAndSize(ctx context.Context, windowId string, pos *waveobj.Point, size *waveobj.WinSize) (waveobj.UpdatesRtnType, error) { + if pos == nil && size == nil { + return nil, nil + } + ctx = waveobj.ContextWithUpdates(ctx) + win, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) + if err != nil { + return nil, err + } + if pos != nil { + win.Pos = *pos + } + if size != nil { + win.WinSize = *size + } + err = wstore.DBUpdate(ctx, win) + if err != nil { + return nil, err + } + return waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *WindowService) CloseTab(ctx context.Context, uiContext waveobj.UIContext, tabId string) (waveobj.UpdatesRtnType, error) { + ctx = waveobj.ContextWithUpdates(ctx) + window, err := wstore.DBMustGet[*waveobj.Window](ctx, uiContext.WindowId) + if err != nil { + return nil, fmt.Errorf("error getting window: %w", err) + } + tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error getting tab: %w", err) + } + ws, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId) + if err != nil { + return nil, fmt.Errorf("error getting workspace: %w", err) + } + tabIndex := -1 + for i, id := range ws.TabIds { + if id == tabId { + tabIndex = i + break + } + } + go func() { + for _, blockId := range tab.BlockIds { + blockcontroller.StopBlockController(blockId) + } + }() + if err := wcore.DeleteTab(ctx, window.WorkspaceId, tabId); err != nil { + return nil, fmt.Errorf("error closing tab: %w", err) + } + if window.ActiveTabId == tabId && tabIndex != -1 { + if len(ws.TabIds) == 1 { + eventbus.SendEventToElectron(eventbus.WSEventType{ + EventType: eventbus.WSEvent_ElectronCloseWindow, + Data: uiContext.WindowId, + }) + } else { + if tabIndex < len(ws.TabIds)-1 { + newActiveTabId := ws.TabIds[tabIndex+1] + wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) + } else { + newActiveTabId := ws.TabIds[tabIndex-1] + wstore.SetActiveTab(ctx, uiContext.WindowId, newActiveTabId) + } + } + } + return waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *WindowService) MoveBlockToNewWindow_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + Desc: "move block to new window", + ArgNames: []string{"ctx", "currentTabId", "blockId"}, + } +} + +func (svc *WindowService) MoveBlockToNewWindow(ctx context.Context, currentTabId string, blockId string) (waveobj.UpdatesRtnType, error) { + log.Printf("MoveBlockToNewWindow(%s, %s)", currentTabId, blockId) + ctx = waveobj.ContextWithUpdates(ctx) + tab, err := wstore.DBMustGet[*waveobj.Tab](ctx, currentTabId) + if err != nil { + return nil, fmt.Errorf("error getting tab: %w", err) + } + log.Printf("tab.BlockIds[%s]: %v", tab.OID, tab.BlockIds) + var foundBlock bool + for _, tabBlockId := range tab.BlockIds { + if tabBlockId == blockId { + foundBlock = true + break + } + } + if !foundBlock { + return nil, fmt.Errorf("block not found in current tab") + } + newWindow, err := wcore.CreateWindow(ctx, nil) + if err != nil { + return nil, fmt.Errorf("error creating window: %w", err) + } + err = wstore.MoveBlockToTab(ctx, currentTabId, newWindow.ActiveTabId, blockId) + if err != nil { + return nil, fmt.Errorf("error moving block to tab: %w", err) + } + eventbus.SendEventToElectron(eventbus.WSEventType{ + EventType: eventbus.WSEvent_ElectronNewWindow, + Data: newWindow.OID, + }) + windowCreated := eventbus.BusyWaitForWindowId(newWindow.OID, 2*time.Second) + if !windowCreated { + return nil, fmt.Errorf("new window not created") + } + wlayout.QueueLayoutActionForTab(ctx, currentTabId, waveobj.LayoutActionData{ + ActionType: wlayout.LayoutActionDataType_Remove, + BlockId: blockId, + }) + wlayout.QueueLayoutActionForTab(ctx, newWindow.ActiveTabId, waveobj.LayoutActionData{ + ActionType: wlayout.LayoutActionDataType_Insert, + BlockId: blockId, + Focused: true, + }) + return waveobj.ContextGetUpdatesRtn(ctx), nil +} + +func (svc *WindowService) CloseWindow(ctx context.Context, windowId string) error { + ctx = waveobj.ContextWithUpdates(ctx) + window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) + if err != nil { + return fmt.Errorf("error getting window: %w", err) + } + workspace, err := wstore.DBMustGet[*waveobj.Workspace](ctx, window.WorkspaceId) + if err != nil { + return fmt.Errorf("error getting workspace: %w", err) + } + for _, tabId := range workspace.TabIds { + uiContext := waveobj.UIContext{WindowId: windowId} + _, err := svc.CloseTab(ctx, uiContext, tabId) + if err != nil { + return fmt.Errorf("error closing tab: %w", err) + } + } + err = wstore.DBDelete(ctx, waveobj.OType_Workspace, window.WorkspaceId) + if err != nil { + return fmt.Errorf("error deleting workspace: %w", err) + } + err = wstore.DBDelete(ctx, waveobj.OType_Window, windowId) + if err != nil { + return fmt.Errorf("error deleting window: %w", err) + } + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return fmt.Errorf("error getting client: %w", err) + } + client.WindowIds = utilfn.RemoveElemFromSlice(client.WindowIds, windowId) + err = wstore.DBUpdate(ctx, client) + if err != nil { + return fmt.Errorf("error updating client: %w", err) + } + return nil +} diff --git a/pkg/shellexec/conninterface.go b/pkg/shellexec/conninterface.go new file mode 100644 index 000000000..e601f8e1d --- /dev/null +++ b/pkg/shellexec/conninterface.go @@ -0,0 +1,131 @@ +package shellexec + +import ( + "io" + "os" + "os/exec" + "time" + + "github.com/creack/pty" + "golang.org/x/crypto/ssh" +) + +type ConnInterface interface { + Kill() + KillGraceful(time.Duration) + Wait() error + Start() error + StdinPipe() (io.WriteCloser, error) + StdoutPipe() (io.ReadCloser, error) + StderrPipe() (io.ReadCloser, error) + SetSize(w int, h int) error + pty.Pty +} + +type CmdWrap struct { + Cmd *exec.Cmd + pty.Pty +} + +func (cw CmdWrap) Kill() { + cw.Cmd.Process.Kill() +} + +func (cw CmdWrap) Wait() error { + return cw.Cmd.Wait() +} + +func (cw CmdWrap) KillGraceful(timeout time.Duration) { + if cw.Cmd.Process == nil { + return + } + if cw.Cmd.ProcessState != nil && cw.Cmd.ProcessState.Exited() { + return + } + cw.Cmd.Process.Signal(os.Interrupt) + go func() { + time.Sleep(timeout) + if cw.Cmd.ProcessState == nil || !cw.Cmd.ProcessState.Exited() { + cw.Cmd.Process.Kill() // force kill if it is already not exited + } + }() +} + +func (cw CmdWrap) Start() error { + defer func() { + for _, extraFile := range cw.Cmd.ExtraFiles { + if extraFile != nil { + extraFile.Close() + } + } + }() + return cw.Cmd.Start() +} + +func (cw CmdWrap) StdinPipe() (io.WriteCloser, error) { + return cw.Cmd.StdinPipe() +} + +func (cw CmdWrap) StdoutPipe() (io.ReadCloser, error) { + return cw.Cmd.StdoutPipe() +} + +func (cw CmdWrap) StderrPipe() (io.ReadCloser, error) { + return cw.Cmd.StderrPipe() +} + +func (cw CmdWrap) SetSize(w int, h int) error { + err := pty.Setsize(cw.Pty, &pty.Winsize{Rows: uint16(w), Cols: uint16(h)}) + if err != nil { + return err + } + return nil +} + +type SessionWrap struct { + Session *ssh.Session + StartCmd string + Tty pty.Tty + pty.Pty +} + +func (sw SessionWrap) Kill() { + sw.Tty.Close() + sw.Session.Close() +} + +func (sw SessionWrap) KillGraceful(timeout time.Duration) { + sw.Kill() +} + +func (sw SessionWrap) Wait() error { + return sw.Session.Wait() +} + +func (sw SessionWrap) Start() error { + return sw.Session.Start(sw.StartCmd) +} + +func (sw SessionWrap) StdinPipe() (io.WriteCloser, error) { + return sw.Session.StdinPipe() +} + +func (sw SessionWrap) StdoutPipe() (io.ReadCloser, error) { + stdoutReader, err := sw.Session.StdoutPipe() + if err != nil { + return nil, err + } + return io.NopCloser(stdoutReader), nil +} + +func (sw SessionWrap) StderrPipe() (io.ReadCloser, error) { + stderrReader, err := sw.Session.StderrPipe() + if err != nil { + return nil, err + } + return io.NopCloser(stderrReader), nil +} + +func (sw SessionWrap) SetSize(h int, w int) error { + return sw.Session.WindowChange(h, w) +} diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go new file mode 100644 index 000000000..e5d483469 --- /dev/null +++ b/pkg/shellexec/shellexec.go @@ -0,0 +1,362 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package shellexec + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "time" + + "github.com/creack/pty" + "github.com/wavetermdev/waveterm/pkg/remote" + "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const DefaultGracefulKillWait = 400 * time.Millisecond + +type CommandOptsType struct { + Interactive bool `json:"interactive,omitempty"` + Login bool `json:"login,omitempty"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +type ShellProc struct { + ConnName string + Cmd ConnInterface + CloseOnce *sync.Once + DoneCh chan any // closed after proc.Wait() returns + WaitErr error // WaitErr is synchronized by DoneCh (written before DoneCh is closed) and CloseOnce +} + +func (sp *ShellProc) Close() { + sp.Cmd.KillGraceful(DefaultGracefulKillWait) + go func() { + waitErr := sp.Cmd.Wait() + sp.SetWaitErrorAndSignalDone(waitErr) + + // windows cannot handle the pty being + // closed twice, so we let the pty + // close itself instead + if runtime.GOOS != "windows" { + sp.Cmd.Close() + } + }() +} + +func (sp *ShellProc) SetWaitErrorAndSignalDone(waitErr error) { + sp.CloseOnce.Do(func() { + sp.WaitErr = waitErr + close(sp.DoneCh) + }) +} + +func (sp *ShellProc) Wait() error { + <-sp.DoneCh + return sp.WaitErr +} + +// returns (done, waitError) +func (sp *ShellProc) WaitNB() (bool, error) { + select { + case <-sp.DoneCh: + return true, sp.WaitErr + default: + return false, nil + } +} + +func ExitCodeFromWaitErr(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + return status.ExitStatus() + } + } + return -1 + +} + +func checkCwd(cwd string) error { + if cwd == "" { + return fmt.Errorf("cwd is empty") + } + if _, err := os.Stat(cwd); err != nil { + return fmt.Errorf("error statting cwd %q: %w", cwd, err) + } + return nil +} + +type PipePty struct { + remoteStdinWrite *os.File + remoteStdoutRead *os.File +} + +func (pp *PipePty) Fd() uintptr { + return pp.remoteStdinWrite.Fd() +} + +func (pp *PipePty) Name() string { + return "pipe-pty" +} + +func (pp *PipePty) Read(p []byte) (n int, err error) { + return pp.remoteStdoutRead.Read(p) +} + +func (pp *PipePty) Write(p []byte) (n int, err error) { + return pp.remoteStdinWrite.Write(p) +} + +func (pp *PipePty) Close() error { + err1 := pp.remoteStdinWrite.Close() + err2 := pp.remoteStdoutRead.Close() + + if err1 != nil { + return err1 + } + return err2 +} + +func (pp *PipePty) WriteString(s string) (n int, err error) { + return pp.Write([]byte(s)) +} + +func StartRemoteShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { + client := conn.GetClient() + shellPath, err := remote.DetectShell(client) + if err != nil { + return nil, err + } + var shellOpts []string + var cmdCombined string + log.Printf("detected shell: %s", shellPath) + + err = remote.InstallClientRcFiles(client) + if err != nil { + log.Printf("error installing rc files: %v", err) + return nil, err + } + + homeDir := remote.GetHomeDir(client) + + if cmdStr == "" { + /* transform command in order to inject environment vars */ + if isBashShell(shellPath) { + log.Printf("recognized as bash shell") + // add --rcfile + // cant set -l or -i with --rcfile + shellOpts = append(shellOpts, "--rcfile", fmt.Sprintf(`"%s"/.waveterm/%s/.bashrc`, homeDir, shellutil.BashIntegrationDir)) + } else if remote.IsPowershell(shellPath) { + // powershell is weird about quoted path executables and requires an ampersand first + shellPath = "& " + shellPath + shellOpts = append(shellOpts, "-NoExit", "-File", homeDir+fmt.Sprintf("/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir)) + } else { + if cmdOpts.Login { + shellOpts = append(shellOpts, "-l") + } + if cmdOpts.Interactive { + shellOpts = append(shellOpts, "-i") + } + // zdotdir setting moved to after session is created + } + cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) + log.Printf("combined command is: %s", cmdCombined) + } else { + shellPath = cmdStr + if cmdOpts.Login { + shellOpts = append(shellOpts, "-l") + } + if cmdOpts.Interactive { + shellOpts = append(shellOpts, "-i") + } + shellOpts = append(shellOpts, "-c", cmdStr) + cmdCombined = fmt.Sprintf("%s %s", shellPath, strings.Join(shellOpts, " ")) + log.Printf("combined command is: %s", cmdCombined) + } + + session, err := client.NewSession() + if err != nil { + return nil, err + } + + remoteStdinRead, remoteStdinWriteOurs, err := os.Pipe() + if err != nil { + return nil, err + } + + remoteStdoutReadOurs, remoteStdoutWrite, err := os.Pipe() + if err != nil { + return nil, err + } + + pipePty := &PipePty{ + remoteStdinWrite: remoteStdinWriteOurs, + remoteStdoutRead: remoteStdoutReadOurs, + } + if termSize.Rows == 0 || termSize.Cols == 0 { + termSize.Rows = shellutil.DefaultTermRows + termSize.Cols = shellutil.DefaultTermCols + } + if termSize.Rows <= 0 || termSize.Cols <= 0 { + return nil, fmt.Errorf("invalid term size: %v", termSize) + } + session.Stdin = remoteStdinRead + session.Stdout = remoteStdoutWrite + session.Stderr = remoteStdoutWrite + + for envKey, envVal := range cmdOpts.Env { + // note these might fail depending on server settings, but we still try + session.Setenv(envKey, envVal) + } + + if isZshShell(shellPath) { + cmdCombined = fmt.Sprintf(`ZDOTDIR="%s/.waveterm/%s" %s`, homeDir, shellutil.ZshIntegrationDir, cmdCombined) + } + + jwtToken, ok := cmdOpts.Env[wshutil.WaveJwtTokenVarName] + if !ok { + return nil, fmt.Errorf("no jwt token provided to connection") + } + + if remote.IsPowershell(shellPath) { + cmdCombined = fmt.Sprintf(`$env:%s="%s"; %s`, wshutil.WaveJwtTokenVarName, jwtToken, cmdCombined) + } else { + cmdCombined = fmt.Sprintf(`%s=%s %s`, wshutil.WaveJwtTokenVarName, jwtToken, cmdCombined) + } + + session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) + + sessionWrap := SessionWrap{session, cmdCombined, pipePty, pipePty} + err = sessionWrap.Start() + if err != nil { + pipePty.Close() + return nil, err + } + return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil +} + +func isZshShell(shellPath string) bool { + // get the base path, and then check contains + shellBase := filepath.Base(shellPath) + return strings.Contains(shellBase, "zsh") +} + +func isBashShell(shellPath string) bool { + // get the base path, and then check contains + shellBase := filepath.Base(shellPath) + return strings.Contains(shellBase, "bash") +} + +func StartShellProc(termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType) (*ShellProc, error) { + shellutil.InitCustomShellStartupFiles() + var ecmd *exec.Cmd + var shellOpts []string + + shellPath := shellutil.DetectLocalShellPath() + if cmdStr == "" { + if isBashShell(shellPath) { + // add --rcfile + // cant set -l or -i with --rcfile + shellOpts = append(shellOpts, "--rcfile", shellutil.GetBashRcFileOverride()) + } else if remote.IsPowershell(shellPath) { + shellOpts = append(shellOpts, "-NoExit", "-File", shellutil.GetWavePowershellEnv()) + } else { + if cmdOpts.Login { + shellOpts = append(shellOpts, "-l") + } + if cmdOpts.Interactive { + shellOpts = append(shellOpts, "-i") + } + } + ecmd = exec.Command(shellPath, shellOpts...) + ecmd.Env = os.Environ() + if isZshShell(shellPath) { + shellutil.UpdateCmdEnv(ecmd, map[string]string{"ZDOTDIR": shellutil.GetZshZDotDir()}) + } + } else { + if cmdOpts.Login { + shellOpts = append(shellOpts, "-l") + } + if cmdOpts.Interactive { + shellOpts = append(shellOpts, "-i") + } + shellOpts = append(shellOpts, "-c", cmdStr) + ecmd = exec.Command(shellPath, shellOpts...) + ecmd.Env = os.Environ() + } + if cmdOpts.Cwd != "" { + ecmd.Dir = cmdOpts.Cwd + } + if cwdErr := checkCwd(ecmd.Dir); cwdErr != nil { + ecmd.Dir = wavebase.GetHomeDir() + } + envToAdd := shellutil.WaveshellLocalEnvVars(shellutil.DefaultTermType) + if os.Getenv("LANG") == "" { + envToAdd["LANG"] = wavebase.DetermineLang() + } + shellutil.UpdateCmdEnv(ecmd, envToAdd) + shellutil.UpdateCmdEnv(ecmd, cmdOpts.Env) + if termSize.Rows == 0 || termSize.Cols == 0 { + termSize.Rows = shellutil.DefaultTermRows + termSize.Cols = shellutil.DefaultTermCols + } + if termSize.Rows <= 0 || termSize.Cols <= 0 { + return nil, fmt.Errorf("invalid term size: %v", termSize) + } + cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) + if err != nil { + cmdPty.Close() + return nil, err + } + return &ShellProc{Cmd: CmdWrap{ecmd, cmdPty}, CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil +} + +func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize waveobj.TermSize) ([]byte, error) { + ecmd.Env = os.Environ() + shellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellLocalEnvVars(shellutil.DefaultTermType)) + if termSize.Rows == 0 || termSize.Cols == 0 { + termSize.Rows = shellutil.DefaultTermRows + termSize.Cols = shellutil.DefaultTermCols + } + if termSize.Rows <= 0 || termSize.Cols <= 0 { + return nil, fmt.Errorf("invalid term size: %v", termSize) + } + cmdPty, err := pty.StartWithSize(ecmd, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) + if err != nil { + cmdPty.Close() + return nil, err + } + if runtime.GOOS != "windows" { + defer cmdPty.Close() + } + ioDone := make(chan bool) + var outputBuf bytes.Buffer + go func() { + // ignore error (/dev/ptmx has read error when process is done) + defer close(ioDone) + io.Copy(&outputBuf, cmdPty) + }() + exitErr := ecmd.Wait() + if exitErr != nil { + return nil, exitErr + } + <-ioDone + return outputBuf.Bytes(), nil +} diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go new file mode 100644 index 000000000..fda73313b --- /dev/null +++ b/pkg/telemetry/telemetry.go @@ -0,0 +1,171 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package telemetry + +import ( + "context" + "database/sql/driver" + "log" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/daystr" + "github.com/wavetermdev/waveterm/pkg/util/dbutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +const MaxTzNameLen = 50 + +// "terminal" should not be in this list +var allowedRenderers = map[string]bool{ + "markdown": true, + "code": true, + "openai": true, + "csv": true, + "image": true, + "pdf": true, + "media": true, + "mustache": true, +} + +type ActivityUpdate struct { + FgMinutes int + ActiveMinutes int + OpenMinutes int + NumTabs int + NewTab int + Startup int + Shutdown int + BuildTime string + Renderers map[string]int +} + +type ActivityType struct { + Day string `json:"day"` + Uploaded bool `json:"-"` + TData TelemetryData `json:"tdata"` + TzName string `json:"tzname"` + TzOffset int `json:"tzoffset"` + ClientVersion string `json:"clientversion"` + ClientArch string `json:"clientarch"` + BuildTime string `json:"buildtime"` + OSRelease string `json:"osrelease"` +} + +type TelemetryData struct { + ActiveMinutes int `json:"activeminutes"` + FgMinutes int `json:"fgminutes"` + OpenMinutes int `json:"openminutes"` + NumTabs int `json:"numtabs"` + NewTab int `json:"newtab"` + NumStartup int `json:"numstartup,omitempty"` + NumShutdown int `json:"numshutdown,omitempty"` + Renderers map[string]int `json:"renderers,omitempty"` +} + +func (tdata TelemetryData) Value() (driver.Value, error) { + return dbutil.QuickValueJson(tdata) +} + +func (tdata *TelemetryData) Scan(val interface{}) error { + return dbutil.QuickScanJson(tdata, val) +} + +func IsTelemetryEnabled() bool { + settings := wconfig.GetWatcher().GetFullConfig() + return settings.Settings.TelemetryEnabled +} + +func IsAllowedRenderer(renderer string) bool { + return allowedRenderers[renderer] +} + +// Wraps UpdateCurrentActivity, spawns goroutine, and logs errors +func GoUpdateActivityWrap(update ActivityUpdate, debugStr string) { + go func() { + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + err := UpdateActivity(ctx, update) + if err != nil { + // ignore error, just log, since this is not critical + log.Printf("error updating current activity (%s): %v\n", debugStr, err) + } + }() +} + +func UpdateActivity(ctx context.Context, update ActivityUpdate) error { + now := time.Now() + dayStr := daystr.GetCurDayStr() + txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { + var tdata TelemetryData + query := `SELECT tdata FROM db_activity WHERE day = ?` + found := tx.Get(&tdata, query, dayStr) + if !found { + query = `INSERT INTO db_activity (day, uploaded, tdata, tzname, tzoffset, clientversion, clientarch, buildtime, osrelease) + VALUES ( ?, 0, ?, ?, ?, ?, ?, ?, ?)` + tzName, tzOffset := now.Zone() + if len(tzName) > MaxTzNameLen { + tzName = tzName[0:MaxTzNameLen] + } + tx.Exec(query, dayStr, tdata, tzName, tzOffset, wavebase.WaveVersion, wavebase.ClientArch(), wavebase.BuildTime, wavebase.UnameKernelRelease()) + } + tdata.FgMinutes += update.FgMinutes + tdata.ActiveMinutes += update.ActiveMinutes + tdata.OpenMinutes += update.OpenMinutes + tdata.NewTab += update.NewTab + tdata.NumStartup += update.Startup + tdata.NumShutdown += update.Shutdown + if update.NumTabs > 0 { + tdata.NumTabs = update.NumTabs + } + if len(update.Renderers) > 0 { + if tdata.Renderers == nil { + tdata.Renderers = make(map[string]int) + } + for key, val := range update.Renderers { + tdata.Renderers[key] += val + } + } + query = `UPDATE db_activity + SET tdata = ?, + clientversion = ?, + buildtime = ? + WHERE day = ?` + tx.Exec(query, tdata, wavebase.WaveVersion, wavebase.BuildTime, dayStr) + return nil + }) + if txErr != nil { + return txErr + } + return nil +} + +func GetNonUploadedActivity(ctx context.Context) ([]*ActivityType, error) { + var rtn []*ActivityType + txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { + query := `SELECT * FROM db_activity WHERE uploaded = 0 ORDER BY day DESC LIMIT 30` + tx.Select(&rtn, query) + return nil + }) + if txErr != nil { + return nil, txErr + } + return rtn, nil +} + +func MarkActivityAsUploaded(ctx context.Context, activityArr []*ActivityType) error { + dayStr := daystr.GetCurDayStr() + txErr := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { + query := `UPDATE db_activity SET uploaded = 1 WHERE day = ?` + for _, activity := range activityArr { + if activity.Day == dayStr { + continue + } + tx.Exec(query, activity.Day) + } + return nil + }) + return txErr +} diff --git a/pkg/trimquotes/trimquotes.go b/pkg/trimquotes/trimquotes.go new file mode 100644 index 000000000..9d7421ad7 --- /dev/null +++ b/pkg/trimquotes/trimquotes.go @@ -0,0 +1,28 @@ +package trimquotes + +import ( + "strconv" +) + +func TrimQuotes(s string) (string, bool) { + if len(s) > 2 && s[0] == '"' { + trimmed, err := strconv.Unquote(s) + if err != nil { + return s, false + } + return trimmed, true + } + return s, false +} + +func TryTrimQuotes(s string) string { + trimmed, _ := TrimQuotes(s) + return trimmed +} + +func ReplaceQuotes(s string, shouldReplace bool) string { + if shouldReplace { + return strconv.Quote(s) + } + return s +} diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go new file mode 100644 index 000000000..cbde00941 --- /dev/null +++ b/pkg/tsgen/tsgen.go @@ -0,0 +1,506 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tsgen + +import ( + "bytes" + "context" + "fmt" + "reflect" + "strings" + + "github.com/wavetermdev/waveterm/pkg/eventbus" + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/service" + "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/web/webcmd" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +// add extra types to generate here +var ExtraTypes = []any{ + waveobj.ORef{}, + (*waveobj.WaveObj)(nil), + map[string]any{}, + service.WebCallType{}, + service.WebReturnType{}, + waveobj.UIContext{}, + eventbus.WSEventType{}, + wps.WSFileEventData{}, + waveobj.LayoutActionData{}, + filestore.WaveFile{}, + wconfig.FullConfigType{}, + wconfig.WatcherUpdate{}, + wshutil.RpcMessage{}, + wshrpc.WshServerCommandMeta{}, + userinput.UserInputRequest{}, + vdom.Elem{}, + vdom.VDomFuncType{}, + vdom.VDomRefType{}, + waveobj.MetaTSType{}, +} + +// add extra type unions to generate here +var TypeUnions = []tsgenmeta.TypeUnionMeta{ + webcmd.WSCommandTypeUnionMeta(), +} + +var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem() +var errorRType = reflect.TypeOf((*error)(nil)).Elem() +var anyRType = reflect.TypeOf((*interface{})(nil)).Elem() +var metaRType = reflect.TypeOf((*waveobj.MetaMapType)(nil)).Elem() +var metaSettingsType = reflect.TypeOf((*wconfig.MetaSettingsType)(nil)).Elem() +var uiContextRType = reflect.TypeOf((*waveobj.UIContext)(nil)).Elem() +var waveObjRType = reflect.TypeOf((*waveobj.WaveObj)(nil)).Elem() +var updatesRtnRType = reflect.TypeOf(waveobj.UpdatesRtnType{}) +var orefRType = reflect.TypeOf((*waveobj.ORef)(nil)).Elem() +var wshRpcInterfaceRType = reflect.TypeOf((*wshrpc.WshRpcInterface)(nil)).Elem() + +func generateTSMethodTypes(method reflect.Method, tsTypesMap map[reflect.Type]string, skipFirstArg bool) error { + for idx := 0; idx < method.Type.NumIn(); idx++ { + if skipFirstArg && idx == 0 { + continue + } + inType := method.Type.In(idx) + GenerateTSType(inType, tsTypesMap) + } + for idx := 0; idx < method.Type.NumOut(); idx++ { + outType := method.Type.Out(idx) + GenerateTSType(outType, tsTypesMap) + } + return nil +} + +func getTSFieldName(field reflect.StructField) string { + tsFieldTag := field.Tag.Get("tsfield") + if tsFieldTag != "" { + if tsFieldTag == "-" { + return "" + } + return tsFieldTag + } + jsonTag := utilfn.GetJsonTag(field) + if jsonTag == "-" { + return "" + } + if strings.Contains(jsonTag, ":") { + return "\"" + jsonTag + "\"" + } + if jsonTag != "" { + return jsonTag + } + return field.Name +} + +func isFieldOmitEmpty(field reflect.StructField) bool { + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + parts := strings.Split(jsonTag, ",") + if len(parts) > 1 { + for _, part := range parts[1:] { + if part == "omitempty" { + return true + } + } + } + } + return false +} + +func TypeToTSType(t reflect.Type, tsTypesMap map[reflect.Type]string) (string, []reflect.Type) { + switch t.Kind() { + case reflect.String: + return "string", nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return "number", nil + case reflect.Bool: + return "boolean", nil + case reflect.Slice, reflect.Array: + elemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap) + if elemType == "" { + return "", nil + } + return fmt.Sprintf("%s[]", elemType), subTypes + case reflect.Map: + if t.Key().Kind() != reflect.String { + return "", nil + } + if t == metaRType { + return "MetaType", nil + } + elemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap) + if elemType == "" { + return "", nil + } + return fmt.Sprintf("{[key: string]: %s}", elemType), subTypes + case reflect.Struct: + name := t.Name() + if tsRename := tsRenameMap[name]; tsRename != "" { + name = tsRename + } + return name, []reflect.Type{t} + case reflect.Ptr: + return TypeToTSType(t.Elem(), tsTypesMap) + case reflect.Interface: + if _, ok := tsTypesMap[t]; ok { + return t.Name(), nil + } + return "any", nil + default: + return "", nil + } +} + +var tsRenameMap = map[string]string{ + "Window": "WaveWindow", + "Elem": "VDomElem", + "MetaTSType": "MetaType", + "MetaSettingsType": "SettingsType", +} + +func generateTSTypeInternal(rtype reflect.Type, tsTypesMap map[reflect.Type]string) (string, []reflect.Type) { + var buf bytes.Buffer + tsTypeName := rtype.Name() + if tsRename, ok := tsRenameMap[tsTypeName]; ok { + tsTypeName = tsRename + } + var isWaveObj bool + buf.WriteString(fmt.Sprintf("// %s\n", rtype.String())) + if rtype.Implements(waveObjRType) || reflect.PointerTo(rtype).Implements(waveObjRType) { + isWaveObj = true + buf.WriteString(fmt.Sprintf("type %s = WaveObj & {\n", tsTypeName)) + } else { + buf.WriteString(fmt.Sprintf("type %s = {\n", tsTypeName)) + } + var subTypes []reflect.Type + for i := 0; i < rtype.NumField(); i++ { + field := rtype.Field(i) + if field.PkgPath != "" { + continue + } + fieldName := getTSFieldName(field) + if fieldName == "" { + continue + } + if isWaveObj && (fieldName == waveobj.OTypeKeyName || fieldName == waveobj.OIDKeyName || fieldName == waveobj.VersionKeyName || fieldName == waveobj.MetaKeyName) { + continue + } + optMarker := "" + if isFieldOmitEmpty(field) { + optMarker = "?" + } + tsTypeTag := field.Tag.Get("tstype") + if tsTypeTag != "" { + if tsTypeTag == "-" { + continue + } + buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", fieldName, optMarker, tsTypeTag)) + continue + } + tsType, fieldSubTypes := TypeToTSType(field.Type, tsTypesMap) + if tsType == "" { + continue + } + subTypes = append(subTypes, fieldSubTypes...) + if tsType == "UIContext" { + optMarker = "?" + } + buf.WriteString(fmt.Sprintf(" %s%s: %s;\n", fieldName, optMarker, tsType)) + } + buf.WriteString("};\n") + return buf.String(), subTypes +} + +func GenerateWaveObjTSType() string { + var buf bytes.Buffer + buf.WriteString("// waveobj.WaveObj\n") + buf.WriteString("type WaveObj = {\n") + buf.WriteString(" otype: string;\n") + buf.WriteString(" oid: string;\n") + buf.WriteString(" version: number;\n") + buf.WriteString(" meta: MetaType;\n") + buf.WriteString("};\n") + return buf.String() +} + +func GenerateTSTypeUnion(unionMeta tsgenmeta.TypeUnionMeta, tsTypeMap map[reflect.Type]string) { + rtn := generateTSTypeUnionInternal(unionMeta) + tsTypeMap[unionMeta.BaseType] = rtn + for _, rtype := range unionMeta.Types { + GenerateTSType(rtype, tsTypeMap) + } +} + +func generateTSTypeUnionInternal(unionMeta tsgenmeta.TypeUnionMeta) string { + var buf bytes.Buffer + if unionMeta.Desc != "" { + buf.WriteString(fmt.Sprintf("// %s\n", unionMeta.Desc)) + } + buf.WriteString(fmt.Sprintf("type %s = {\n", unionMeta.BaseType.Name())) + buf.WriteString(fmt.Sprintf(" %s: string;\n", unionMeta.TypeFieldName)) + buf.WriteString("} & ( ") + for idx, rtype := range unionMeta.Types { + if idx > 0 { + buf.WriteString(" | ") + } + buf.WriteString(rtype.Name()) + } + buf.WriteString(" );\n") + return buf.String() +} + +func GenerateTSType(rtype reflect.Type, tsTypesMap map[reflect.Type]string) { + if rtype == nil { + return + } + if rtype.Kind() == reflect.Chan { + rtype = rtype.Elem() + } + if rtype == contextRType || rtype == errorRType || rtype == anyRType { + return + } + if rtype.Kind() == reflect.Slice { + rtype = rtype.Elem() + } + if rtype.Kind() == reflect.Map { + rtype = rtype.Elem() + } + if rtype.Kind() == reflect.Ptr { + rtype = rtype.Elem() + } + if _, ok := tsTypesMap[rtype]; ok { + return + } + if rtype == orefRType { + tsTypesMap[orefRType] = "// waveobj.ORef\ntype ORef = string;\n" + return + } + if rtype == waveObjRType { + tsTypesMap[rtype] = GenerateWaveObjTSType() + return + } + if rtype == metaSettingsType { + return + } + if rtype.Kind() != reflect.Struct { + return + } + tsType, subTypes := generateTSTypeInternal(rtype, tsTypesMap) + tsTypesMap[rtype] = tsType + for _, subType := range subTypes { + GenerateTSType(subType, tsTypesMap) + } +} + +func hasUpdatesReturn(method reflect.Method) bool { + for idx := 0; idx < method.Type.NumOut(); idx++ { + outType := method.Type.Out(idx) + if outType == updatesRtnRType { + return true + } + } + return false +} + +func GenerateMethodSignature(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta, isFirst bool, tsTypesMap map[reflect.Type]string) string { + var sb strings.Builder + mayReturnUpdates := hasUpdatesReturn(method) + if (meta.Desc != "" || meta.ReturnDesc != "" || mayReturnUpdates) && !isFirst { + sb.WriteString("\n") + } + if meta.Desc != "" { + sb.WriteString(fmt.Sprintf(" // %s\n", meta.Desc)) + } + if mayReturnUpdates || meta.ReturnDesc != "" { + if mayReturnUpdates && meta.ReturnDesc != "" { + sb.WriteString(fmt.Sprintf(" // @returns %s (and object updates)\n", meta.ReturnDesc)) + } else if mayReturnUpdates { + sb.WriteString(" // @returns object updates\n") + } else { + sb.WriteString(fmt.Sprintf(" // @returns %s\n", meta.ReturnDesc)) + } + } + sb.WriteString(" ") + sb.WriteString(method.Name) + sb.WriteString("(") + wroteArg := false + // skip first arg, which is the receiver + for idx := 1; idx < method.Type.NumIn(); idx++ { + if wroteArg { + sb.WriteString(", ") + } + inType := method.Type.In(idx) + if inType == contextRType || inType == uiContextRType { + continue + } + tsTypeName, _ := TypeToTSType(inType, tsTypesMap) + var argName string + if idx-1 < len(meta.ArgNames) { + argName = meta.ArgNames[idx-1] // subtract 1 for receiver + } else { + argName = fmt.Sprintf("arg%d", idx) + } + sb.WriteString(fmt.Sprintf("%s: %s", argName, tsTypeName)) + wroteArg = true + } + sb.WriteString("): ") + wroteRtn := false + for idx := 0; idx < method.Type.NumOut(); idx++ { + outType := method.Type.Out(idx) + if outType == errorRType { + continue + } + if outType == updatesRtnRType { + continue + } + tsTypeName, _ := TypeToTSType(outType, tsTypesMap) + sb.WriteString(fmt.Sprintf("Promise<%s>", tsTypeName)) + wroteRtn = true + } + if !wroteRtn { + sb.WriteString("Promise") + } + sb.WriteString(" {\n") + return sb.String() +} + +func GenerateMethodBody(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta) string { + return fmt.Sprintf(" return WOS.callBackendService(%q, %q, Array.from(arguments))\n", serviceName, method.Name) +} + +func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[reflect.Type]string) string { + serviceType := reflect.TypeOf(serviceObj) + var sb strings.Builder + tsServiceName := serviceType.Elem().Name() + sb.WriteString(fmt.Sprintf("// %s (%s)\n", serviceType.Elem().String(), serviceName)) + sb.WriteString("class ") + sb.WriteString(tsServiceName + "Type") + sb.WriteString(" {\n") + isFirst := true + for midx := 0; midx < serviceType.NumMethod(); midx++ { + method := serviceType.Method(midx) + if strings.HasSuffix(method.Name, "_Meta") { + continue + } + var meta tsgenmeta.MethodMeta + metaMethod, found := serviceType.MethodByName(method.Name + "_Meta") + if found { + serviceObjVal := reflect.ValueOf(serviceObj) + metaVal := metaMethod.Func.Call([]reflect.Value{serviceObjVal}) + meta = metaVal[0].Interface().(tsgenmeta.MethodMeta) + } + sb.WriteString(GenerateMethodSignature(serviceName, method, meta, isFirst, tsTypesMap)) + sb.WriteString(GenerateMethodBody(serviceName, method, meta)) + sb.WriteString(" }\n") + isFirst = false + } + sb.WriteString("}\n\n") + sb.WriteString(fmt.Sprintf("export const %s = new %sType();\n", tsServiceName, tsServiceName)) + return sb.String() +} + +func GenerateWshClientApiMethod(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string { + if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { + return generateWshClientApiMethod_ResponseStream(methodDecl, tsTypesMap) + } else if methodDecl.CommandType == wshrpc.RpcType_Call { + return generateWshClientApiMethod_Call(methodDecl, tsTypesMap) + } else { + panic(fmt.Sprintf("cannot generate wshserver commandtype %q", methodDecl.CommandType)) + } +} + +func generateWshClientApiMethod_ResponseStream(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf(" // command %q [%s]\n", methodDecl.Command, methodDecl.CommandType)) + respType := "any" + if methodDecl.DefaultResponseDataType != nil { + respType, _ = TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap) + } + dataName := "null" + if methodDecl.CommandDataType != nil { + dataName = "data" + } + genRespType := fmt.Sprintf("AsyncGenerator<%s, void, boolean>", respType) + if methodDecl.CommandDataType != nil { + cmdDataTsName, _ := TypeToTSType(methodDecl.CommandDataType, tsTypesMap) + sb.WriteString(fmt.Sprintf(" %s(client: WshClient, data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, cmdDataTsName, genRespType)) + } else { + sb.WriteString(fmt.Sprintf(" %s(client: WshClient, opts?: RpcOpts): %s {\n", methodDecl.MethodName, genRespType)) + } + sb.WriteString(fmt.Sprintf(" return client.wshRpcStream(%q, %s, opts);\n", methodDecl.Command, dataName)) + sb.WriteString(" }\n") + return sb.String() +} + +func generateWshClientApiMethod_Call(methodDecl *wshrpc.WshRpcMethodDecl, tsTypesMap map[reflect.Type]string) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf(" // command %q [%s]\n", methodDecl.Command, methodDecl.CommandType)) + rtnType := "Promise" + if methodDecl.DefaultResponseDataType != nil { + rtnTypeName, _ := TypeToTSType(methodDecl.DefaultResponseDataType, tsTypesMap) + rtnType = fmt.Sprintf("Promise<%s>", rtnTypeName) + } + dataName := "null" + if methodDecl.CommandDataType != nil { + dataName = "data" + } + if methodDecl.CommandDataType != nil { + cmdDataTsName, _ := TypeToTSType(methodDecl.CommandDataType, tsTypesMap) + sb.WriteString(fmt.Sprintf(" %s(client: WshClient, data: %s, opts?: RpcOpts): %s {\n", methodDecl.MethodName, cmdDataTsName, rtnType)) + } else { + sb.WriteString(fmt.Sprintf(" %s(client: WshClient, opts?: RpcOpts): %s {\n", methodDecl.MethodName, rtnType)) + } + methodBody := fmt.Sprintf(" return client.wshRpcCall(%q, %s, opts);\n", methodDecl.Command, dataName) + sb.WriteString(methodBody) + sb.WriteString(" }\n") + return sb.String() +} + +func GenerateWaveObjTypes(tsTypesMap map[reflect.Type]string) { + for _, typeUnion := range TypeUnions { + GenerateTSTypeUnion(typeUnion, tsTypesMap) + } + for _, extraType := range ExtraTypes { + GenerateTSType(reflect.TypeOf(extraType), tsTypesMap) + } + for _, rtype := range waveobj.AllWaveObjTypes() { + GenerateTSType(rtype, tsTypesMap) + } +} + +func GenerateServiceTypes(tsTypesMap map[reflect.Type]string) error { + for _, serviceObj := range service.ServiceMap { + serviceType := reflect.TypeOf(serviceObj) + for midx := 0; midx < serviceType.NumMethod(); midx++ { + method := serviceType.Method(midx) + err := generateTSMethodTypes(method, tsTypesMap, true) + if err != nil { + return fmt.Errorf("error generating TS method types for %s.%s: %v", serviceType, method.Name, err) + } + } + } + return nil +} + +func GenerateWshServerTypes(tsTypesMap map[reflect.Type]string) error { + GenerateTSType(reflect.TypeOf(wshrpc.RpcOpts{}), tsTypesMap) + rtype := wshRpcInterfaceRType + for midx := 0; midx < rtype.NumMethod(); midx++ { + method := rtype.Method(midx) + err := generateTSMethodTypes(method, tsTypesMap, false) + if err != nil { + return fmt.Errorf("error generating TS method types for %s.%s: %v", rtype, method.Name, err) + } + } + return nil +} diff --git a/pkg/tsgen/tsgenmeta/tsgenmeta.go b/pkg/tsgen/tsgenmeta/tsgenmeta.go new file mode 100644 index 000000000..8e17164a6 --- /dev/null +++ b/pkg/tsgen/tsgenmeta/tsgenmeta.go @@ -0,0 +1,19 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tsgenmeta + +import "reflect" + +type MethodMeta struct { + Desc string + ArgNames []string + ReturnDesc string +} + +type TypeUnionMeta struct { + BaseType reflect.Type + Desc string + TypeFieldName string + Types []reflect.Type +} diff --git a/pkg/userinput/userinput.go b/pkg/userinput/userinput.go new file mode 100644 index 000000000..0ebf9dd0b --- /dev/null +++ b/pkg/userinput/userinput.go @@ -0,0 +1,92 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package userinput + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wps" +) + +var MainUserInputHandler = UserInputHandler{Channels: make(map[string](chan *UserInputResponse), 1)} + +type UserInputRequest struct { + RequestId string `json:"requestid"` + QueryText string `json:"querytext"` + ResponseType string `json:"responsetype"` + Title string `json:"title"` + Markdown bool `json:"markdown"` + TimeoutMs int `json:"timeoutms"` + CheckBoxMsg string `json:"checkboxmsg"` + PublicText bool `json:"publictext"` +} + +type UserInputResponse struct { + Type string `json:"type"` + RequestId string `json:"requestid"` + Text string `json:"text,omitempty"` + Confirm bool `json:"confirm,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + CheckboxStat bool `json:"checkboxstat,omitempty"` +} + +type UserInputHandler struct { + Lock sync.Mutex + Channels map[string](chan *UserInputResponse) +} + +func (ui *UserInputHandler) registerChannel() (string, chan *UserInputResponse) { + ui.Lock.Lock() + defer ui.Lock.Unlock() + + id := uuid.New().String() + uich := make(chan *UserInputResponse, 1) + + ui.Channels[id] = uich + return id, uich +} + +func (ui *UserInputHandler) unregisterChannel(id string) { + ui.Lock.Lock() + defer ui.Lock.Unlock() + + delete(ui.Channels, id) +} + +func (ui *UserInputHandler) sendRequestToFrontend(request *UserInputRequest) { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_UserInput, + Data: request, + }) +} + +func GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) { + id, uiCh := MainUserInputHandler.registerChannel() + defer MainUserInputHandler.unregisterChannel(id) + request.RequestId = id + deadline, _ := ctx.Deadline() + request.TimeoutMs = int(time.Until(deadline).Milliseconds()) - 500 + MainUserInputHandler.sendRequestToFrontend(request) + + var response *UserInputResponse + var err error + select { + case resp := <-uiCh: + log.Printf("checking received: %v", resp.RequestId) + response = resp + case <-ctx.Done(): + return nil, fmt.Errorf("timed out waiting for user input") + } + + if response.ErrorMsg != "" { + err = fmt.Errorf(response.ErrorMsg) + } + + return response, err +} diff --git a/pkg/util/daystr/daystr.go b/pkg/util/daystr/daystr.go new file mode 100644 index 000000000..cda6d9690 --- /dev/null +++ b/pkg/util/daystr/daystr.go @@ -0,0 +1,104 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package daystr + +import ( + "fmt" + "regexp" + "strconv" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" +) + +var customDayStrRe = regexp.MustCompile(`^((?:\d{4}-\d{2}-\d{2})|today|yesterday|bom|bow)?((?:[+-]\d+[dwm])*)$`) +var daystrRe = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})$`) + +func GetCurDayStr() string { + now := time.Now() + dayStr := now.Format("2006-01-02") + return dayStr +} + +func GetRelDayStr(relDays int) string { + now := time.Now() + dayStr := now.AddDate(0, 0, relDays).Format("2006-01-02") + return dayStr +} + +// accepts a custom format string to return a daystr +// can be either a prefix, a delta, or a prefix w/ a delta +// if no prefix is given, "today" is assumed +// examples: today-2d, bow, bom+1m-1d (that's end of the month), 2024-04-01+1w +// +// prefixes: +// +// yyyy-mm-dd +// today +// yesterday +// bom (beginning of month) +// bow (beginning of week -- sunday) +// +// deltas: +// +// +[n]d, -[n]d (e.g. +1d, -5d) +// +[n]w, -[n]w (e.g. +2w) +// +[n]m, -[n]m (e.g. -1m) +// deltas can be combined e.g. +1w-2d +func GetCustomDayStr(format string) (string, error) { + m := customDayStrRe.FindStringSubmatch(format) + if m == nil { + return "", fmt.Errorf("invalid daystr format") + } + prefix, deltas := m[1], m[2] + if prefix == "" { + prefix = "today" + } + var rtnTime time.Time + now := time.Now() + switch prefix { + case "today": + rtnTime = now + case "yesterday": + rtnTime = now.AddDate(0, 0, -1) + case "bom": + rtnTime = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + case "bow": + weekday := now.Weekday() + if weekday == time.Sunday { + rtnTime = now + } else { + rtnTime = now.AddDate(0, 0, -int(weekday)) + } + default: + m = daystrRe.FindStringSubmatch(prefix) + if m == nil { + return "", fmt.Errorf("invalid prefix format") + } + year, month, day := m[1], m[2], m[3] + yearInt, monthInt, dayInt := utilfn.AtoiNoErr(year), utilfn.AtoiNoErr(month), utilfn.AtoiNoErr(day) + if yearInt == 0 || monthInt == 0 || dayInt == 0 { + return "", fmt.Errorf("invalid prefix format") + } + rtnTime = time.Date(yearInt, time.Month(monthInt), dayInt, 0, 0, 0, 0, now.Location()) + } + for _, delta := range regexp.MustCompile(`[+-]\d+[dwm]`).FindAllString(deltas, -1) { + deltaVal, err := strconv.Atoi(delta[1 : len(delta)-1]) + if err != nil { + return "", fmt.Errorf("invalid delta format") + } + if delta[0] == '-' { + deltaVal = -deltaVal + } + switch delta[len(delta)-1] { + case 'd': + rtnTime = rtnTime.AddDate(0, 0, deltaVal) + case 'w': + rtnTime = rtnTime.AddDate(0, 0, deltaVal*7) + case 'm': + rtnTime = rtnTime.AddDate(0, deltaVal, 0) + } + } + return rtnTime.Format("2006-01-02"), nil +} diff --git a/pkg/util/dbutil/dbmappable.go b/pkg/util/dbutil/dbmappable.go new file mode 100644 index 000000000..7a23ca136 --- /dev/null +++ b/pkg/util/dbutil/dbmappable.go @@ -0,0 +1,237 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package dbutil + +import ( + "fmt" + "reflect" + "strings" + + "github.com/sawka/txwrap" +) + +type DBMappable interface { + UseDBMap() +} + +type MapEntry[T any] struct { + Key string + Val T +} + +type MapConverter interface { + ToMap() map[string]interface{} + FromMap(map[string]interface{}) bool +} + +type HasSimpleKey interface { + GetSimpleKey() string +} + +type HasSimpleInt64Key interface { + GetSimpleKey() int64 +} + +type MapConverterPtr[T any] interface { + MapConverter + *T +} + +type DBMappablePtr[T any] interface { + DBMappable + *T +} + +func FromMap[PT MapConverterPtr[T], T any](m map[string]any) PT { + if len(m) == 0 { + return nil + } + rtn := PT(new(T)) + ok := rtn.FromMap(m) + if !ok { + return nil + } + return rtn +} + +func GetMapGen[PT MapConverterPtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) PT { + m := tx.GetMap(query, args...) + return FromMap[PT](m) +} + +func GetMappable[PT DBMappablePtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) PT { + m := tx.GetMap(query, args...) + if len(m) == 0 { + return nil + } + rtn := PT(new(T)) + FromDBMap(rtn, m) + return rtn +} + +func SelectMappable[PT DBMappablePtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) []PT { + var rtn []PT + marr := tx.SelectMaps(query, args...) + for _, m := range marr { + if len(m) == 0 { + continue + } + val := PT(new(T)) + FromDBMap(val, m) + rtn = append(rtn, val) + } + return rtn +} + +func SelectMapsGen[PT MapConverterPtr[T], T any](tx *txwrap.TxWrap, query string, args ...interface{}) []PT { + var rtn []PT + marr := tx.SelectMaps(query, args...) + for _, m := range marr { + val := FromMap[PT](m) + if val != nil { + rtn = append(rtn, val) + } + } + return rtn +} + +func SelectSimpleMap[T any](tx *txwrap.TxWrap, query string, args ...interface{}) map[string]T { + var rtn []MapEntry[T] + tx.Select(&rtn, query, args...) + if len(rtn) == 0 { + return nil + } + rtnMap := make(map[string]T) + for _, entry := range rtn { + rtnMap[entry.Key] = entry.Val + } + return rtnMap +} + +func MakeGenMap[T HasSimpleKey](arr []T) map[string]T { + rtn := make(map[string]T) + for _, val := range arr { + rtn[val.GetSimpleKey()] = val + } + return rtn +} + +func MakeGenMapInt64[T HasSimpleInt64Key](arr []T) map[int64]T { + rtn := make(map[int64]T) + for _, val := range arr { + rtn[val.GetSimpleKey()] = val + } + return rtn +} + +func isStructType(rt reflect.Type) bool { + if rt.Kind() == reflect.Struct { + return true + } + if rt.Kind() == reflect.Pointer && rt.Elem().Kind() == reflect.Struct { + return true + } + return false +} + +func isByteArrayType(t reflect.Type) bool { + return t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 +} + +func isStringMapType(t reflect.Type) bool { + return t.Kind() == reflect.Map && t.Key().Kind() == reflect.String +} + +func ToDBMap(v DBMappable, useBytes bool) map[string]interface{} { + if CheckNil(v) { + return nil + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + panic(fmt.Sprintf("invalid type %T (non-struct) passed to StructToDBMap", v)) + } + rt := rv.Type() + m := make(map[string]interface{}) + numFields := rt.NumField() + for i := 0; i < numFields; i++ { + field := rt.Field(i) + fieldVal := rv.FieldByIndex(field.Index) + dbName := field.Tag.Get("dbmap") + if dbName == "" { + dbName = strings.ToLower(field.Name) + } + if dbName == "-" { + continue + } + if isByteArrayType(field.Type) { + m[dbName] = fieldVal.Interface() + } else if field.Type.Kind() == reflect.Slice { + if useBytes { + m[dbName] = QuickJsonArrBytes(fieldVal.Interface()) + } else { + m[dbName] = QuickJsonArr(fieldVal.Interface()) + } + } else if isStructType(field.Type) || isStringMapType(field.Type) { + if useBytes { + m[dbName] = QuickJsonBytes(fieldVal.Interface()) + } else { + m[dbName] = QuickJson(fieldVal.Interface()) + } + } else { + m[dbName] = fieldVal.Interface() + } + } + return m +} + +func FromDBMap(v DBMappable, m map[string]interface{}) { + if CheckNil(v) { + panic("StructFromDBMap, v cannot be nil") + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + panic(fmt.Sprintf("invalid type %T (non-struct) passed to StructFromDBMap", v)) + } + rt := rv.Type() + numFields := rt.NumField() + for i := 0; i < numFields; i++ { + field := rt.Field(i) + fieldVal := rv.FieldByIndex(field.Index) + dbName := field.Tag.Get("dbmap") + if dbName == "" { + dbName = strings.ToLower(field.Name) + } + if dbName == "-" { + continue + } + if isByteArrayType(field.Type) { + barrVal := fieldVal.Addr().Interface() + QuickSetBytes(barrVal.(*[]byte), m, dbName) + } else if field.Type.Kind() == reflect.Slice { + QuickSetJsonArr(fieldVal.Addr().Interface(), m, dbName) + } else if isStructType(field.Type) || isStringMapType(field.Type) { + QuickSetJson(fieldVal.Addr().Interface(), m, dbName) + } else if field.Type.Kind() == reflect.String { + strVal := fieldVal.Addr().Interface() + QuickSetStr(strVal.(*string), m, dbName) + } else if field.Type.Kind() == reflect.Int64 { + intVal := fieldVal.Addr().Interface() + QuickSetInt64(intVal.(*int64), m, dbName) + } else if field.Type.Kind() == reflect.Int { + intVal := fieldVal.Addr().Interface() + QuickSetInt(intVal.(*int), m, dbName) + } else if field.Type.Kind() == reflect.Bool { + boolVal := fieldVal.Addr().Interface() + QuickSetBool(boolVal.(*bool), m, dbName) + } else { + panic(fmt.Sprintf("StructFromDBMap invalid field type %v in %T", fieldVal.Type(), v)) + } + } +} diff --git a/pkg/util/dbutil/dbutil.go b/pkg/util/dbutil/dbutil.go new file mode 100644 index 000000000..42dd9bc0f --- /dev/null +++ b/pkg/util/dbutil/dbutil.go @@ -0,0 +1,235 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package dbutil + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "reflect" + "strconv" +) + +func QuickSetStr(strVal *string, m map[string]interface{}, name string) { + v, ok := m[name] + if !ok { + return + } + ival, ok := v.(int64) + if ok { + *strVal = strconv.FormatInt(ival, 10) + return + } + str, ok := v.(string) + if !ok { + return + } + *strVal = str +} + +func QuickSetInt(ival *int, m map[string]interface{}, name string) { + v, ok := m[name] + if !ok { + return + } + sqlInt, ok := v.(int) + if ok { + *ival = sqlInt + return + } + sqlInt64, ok := v.(int64) + if ok { + *ival = int(sqlInt64) + return + } +} + +func QuickSetNullableInt64(ival **int64, m map[string]any, name string) { + v, ok := m[name] + if !ok { + // set to nil + return + } + sqlInt64, ok := v.(int64) + if ok { + *ival = &sqlInt64 + return + } + sqlInt, ok := v.(int) + if ok { + sqlInt64 = int64(sqlInt) + *ival = &sqlInt64 + return + } +} + +func QuickSetInt64(ival *int64, m map[string]interface{}, name string) { + v, ok := m[name] + if !ok { + // leave as zero + return + } + sqlInt64, ok := v.(int64) + if ok { + *ival = sqlInt64 + return + } + sqlInt, ok := v.(int) + if ok { + *ival = int64(sqlInt) + return + } +} + +func QuickSetBool(bval *bool, m map[string]interface{}, name string) { + v, ok := m[name] + if !ok { + return + } + sqlInt, ok := v.(int64) + if ok { + if sqlInt > 0 { + *bval = true + } + return + } + sqlBool, ok := v.(bool) + if ok { + *bval = sqlBool + } +} + +func QuickSetBytes(bval *[]byte, m map[string]interface{}, name string) { + v, ok := m[name] + if !ok { + return + } + sqlBytes, ok := v.([]byte) + if ok { + *bval = sqlBytes + } +} + +func getByteArr(m map[string]any, name string, def string) ([]byte, bool) { + v, ok := m[name] + if !ok { + return nil, false + } + barr, ok := v.([]byte) + if !ok { + str, ok := v.(string) + if !ok { + return nil, false + } + barr = []byte(str) + } + if len(barr) == 0 { + barr = []byte(def) + } + return barr, true +} + +func QuickSetJson(ptr interface{}, m map[string]interface{}, name string) { + barr, ok := getByteArr(m, name, "{}") + if !ok { + return + } + json.Unmarshal(barr, ptr) +} + +func QuickSetNullableJson(ptr interface{}, m map[string]interface{}, name string) { + barr, ok := getByteArr(m, name, "null") + if !ok { + return + } + json.Unmarshal(barr, ptr) +} + +func QuickSetJsonArr(ptr interface{}, m map[string]interface{}, name string) { + barr, ok := getByteArr(m, name, "[]") + if !ok { + return + } + json.Unmarshal(barr, ptr) +} + +func CheckNil(v interface{}) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + switch rv.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return rv.IsNil() + + default: + return false + } +} + +func QuickNullableJson(v interface{}) string { + if CheckNil(v) { + return "null" + } + barr, _ := json.Marshal(v) + return string(barr) +} + +func QuickJson(v interface{}) string { + if CheckNil(v) { + return "{}" + } + barr, _ := json.Marshal(v) + return string(barr) +} + +func QuickJsonBytes(v interface{}) []byte { + if CheckNil(v) { + return []byte("{}") + } + barr, _ := json.Marshal(v) + return barr +} + +func QuickJsonArr(v interface{}) string { + if CheckNil(v) { + return "[]" + } + barr, _ := json.Marshal(v) + return string(barr) +} + +func QuickJsonArrBytes(v interface{}) []byte { + if CheckNil(v) { + return []byte("[]") + } + barr, _ := json.Marshal(v) + return barr +} + +func QuickScanJson(ptr interface{}, val interface{}) error { + barrVal, ok := val.([]byte) + if !ok { + strVal, ok := val.(string) + if !ok { + return fmt.Errorf("cannot scan '%T' into '%T'", val, ptr) + } + barrVal = []byte(strVal) + } + if len(barrVal) == 0 { + barrVal = []byte("{}") + } + return json.Unmarshal(barrVal, ptr) +} + +func QuickValueJson(v interface{}) (driver.Value, error) { + if CheckNil(v) { + return "{}", nil + } + barr, err := json.Marshal(v) + if err != nil { + return nil, err + } + return string(barr), nil +} diff --git a/pkg/util/ds/syncmap.go b/pkg/util/ds/syncmap.go new file mode 100644 index 000000000..02d0f80d8 --- /dev/null +++ b/pkg/util/ds/syncmap.go @@ -0,0 +1,43 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ds + +import "sync" + +type SyncMap[T any] struct { + lock *sync.Mutex + m map[string]T +} + +func NewSyncMap[T any]() *SyncMap[T] { + return &SyncMap[T]{ + lock: &sync.Mutex{}, + m: make(map[string]T), + } +} + +func (sm *SyncMap[T]) Set(key string, value T) { + sm.lock.Lock() + defer sm.lock.Unlock() + sm.m[key] = value +} + +func (sm *SyncMap[T]) Get(key string) T { + sm.lock.Lock() + defer sm.lock.Unlock() + return sm.m[key] +} + +func (sm *SyncMap[T]) GetEx(key string) (T, bool) { + sm.lock.Lock() + defer sm.lock.Unlock() + v, ok := sm.m[key] + return v, ok +} + +func (sm *SyncMap[T]) Delete(key string) { + sm.lock.Lock() + defer sm.lock.Unlock() + delete(sm.m, key) +} diff --git a/pkg/util/migrateutil/migrateutil.go b/pkg/util/migrateutil/migrateutil.go new file mode 100644 index 000000000..c27f5a329 --- /dev/null +++ b/pkg/util/migrateutil/migrateutil.go @@ -0,0 +1,67 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package migrateutil + +import ( + "database/sql" + "fmt" + "io/fs" + "log" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/source/iofs" + + sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3" +) + +func GetMigrateVersion(m *migrate.Migrate) (uint, bool, error) { + curVersion, dirty, err := m.Version() + if err == migrate.ErrNilVersion { + return 0, false, nil + } + return curVersion, dirty, err +} + +func MakeMigrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) (*migrate.Migrate, error) { + fsVar, err := iofs.New(migrationFS, migrationsName) + if err != nil { + return nil, fmt.Errorf("opening fs: %w", err) + } + mdriver, err := sqlite3migrate.WithInstance(db, &sqlite3migrate.Config{}) + if err != nil { + return nil, fmt.Errorf("making %s migration driver: %w", storeName, err) + } + m, err := migrate.NewWithInstance("iofs", fsVar, "sqlite3", mdriver) + if err != nil { + return nil, fmt.Errorf("making %s migration: %w", storeName, err) + } + return m, nil +} + +func Migrate(storeName string, db *sql.DB, migrationFS fs.FS, migrationsName string) error { + log.Printf("migrate %s\n", storeName) + m, err := MakeMigrate(storeName, db, migrationFS, migrationsName) + if err != nil { + return err + } + curVersion, dirty, err := GetMigrateVersion(m) + if dirty { + return fmt.Errorf("%s, migrate up, database is dirty", storeName) + } + if err != nil { + return fmt.Errorf("%s, cannot get current migration version: %v", storeName, err) + } + err = m.Up() + if err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrating %s: %w", storeName, err) + } + newVersion, _, err := GetMigrateVersion(m) + if err != nil { + return fmt.Errorf("%s, cannot get new migration version: %v", storeName, err) + } + if newVersion != curVersion { + log.Printf("[db] %s migration done, version %d -> %d\n", storeName, curVersion, newVersion) + } + return nil +} diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go new file mode 100644 index 000000000..691684f99 --- /dev/null +++ b/pkg/util/shellutil/shellutil.go @@ -0,0 +1,328 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package shellutil + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "os/user" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +const DefaultTermType = "xterm-256color" +const DefaultTermRows = 24 +const DefaultTermCols = 80 + +var cachedMacUserShell string +var macUserShellOnce = &sync.Once{} +var userShellRegexp = regexp.MustCompile(`^UserShell: (.*)$`) + +const DefaultShellPath = "/bin/bash" + +const WaveAppPathVarName = "WAVETERM_APP_PATH" +const AppPathBinDir = "bin" + +const ( + ZshIntegrationDir = "shell/zsh" + BashIntegrationDir = "shell/bash" + PwshIntegrationDir = "shell/pwsh" + WaveHomeBinDir = "bin" + + ZshStartup_Zprofile = ` +# Source the original zprofile +[ -f ~/.zprofile ] && source ~/.zprofile +` + + ZshStartup_Zshrc = ` +# Source the original zshrc +[ -f ~/.zshrc ] && source ~/.zshrc + +export PATH={{.WSHBINDIR}}:$PATH +if [[ -n ${_comps+x} ]]; then + source <(wsh completion zsh) +fi +` + + ZshStartup_Zlogin = ` +# Source the original zlogin +[ -f ~/.zlogin ] && source ~/.zlogin +` + + ZshStartup_Zshenv = ` +[ -f ~/.zshenv ] && source ~/.zshenv +` + + BashStartup_Bashrc = ` +# Source /etc/profile if it exists +if [ -f /etc/profile ]; then + . /etc/profile +fi + +# Source the first of ~/.bash_profile, ~/.bash_login, or ~/.profile that exists +if [ -f ~/.bash_profile ]; then + . ~/.bash_profile +elif [ -f ~/.bash_login ]; then + . ~/.bash_login +elif [ -f ~/.profile ]; then + . ~/.profile +fi + +export PATH={{.WSHBINDIR}}:$PATH +if type _init_completion &>/dev/null; then + source <(wsh completion bash) +fi + +` + PwshStartup_wavepwsh = ` +# no need to source regular profiles since we cannot +# overwrite those with powershell. Instead we will source +# this file with -NoExit +$env:PATH = "{{.WSHBINDIR}}" + "{{.PATHSEP}}" + $env:PATH +` +) + +func DetectLocalShellPath() string { + if runtime.GOOS == "windows" { + return "powershell.exe" + } + shellPath := GetMacUserShell() + if shellPath == "" { + shellPath = os.Getenv("SHELL") + } + if shellPath == "" { + return DefaultShellPath + } + return shellPath +} + +func GetMacUserShell() string { + if runtime.GOOS != "darwin" { + return "" + } + macUserShellOnce.Do(func() { + cachedMacUserShell = internalMacUserShell() + }) + return cachedMacUserShell +} + +// dscl . -read /Users/[username] UserShell +// defaults to /bin/bash +func internalMacUserShell() string { + osUser, err := user.Current() + if err != nil { + return DefaultShellPath + } + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + userStr := "/Users/" + osUser.Username + out, err := exec.CommandContext(ctx, "dscl", ".", "-read", userStr, "UserShell").CombinedOutput() + if err != nil { + return DefaultShellPath + } + outStr := strings.TrimSpace(string(out)) + m := userShellRegexp.FindStringSubmatch(outStr) + if m == nil { + return DefaultShellPath + } + return m[1] +} + +func DefaultTermSize() waveobj.TermSize { + return waveobj.TermSize{Rows: DefaultTermRows, Cols: DefaultTermCols} +} + +func WaveshellLocalEnvVars(termType string) map[string]string { + rtn := make(map[string]string) + if termType != "" { + rtn["TERM"] = termType + } + rtn["TERM_PROGRAM"] = "waveterm" + rtn["WAVETERM"], _ = os.Executable() + rtn["WAVETERM_VERSION"] = wavebase.WaveVersion + rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveHomeDir(), WaveHomeBinDir) + return rtn +} + +func UpdateCmdEnv(cmd *exec.Cmd, envVars map[string]string) { + if len(envVars) == 0 { + return + } + found := make(map[string]bool) + var newEnv []string + for _, envStr := range cmd.Env { + envKey := GetEnvStrKey(envStr) + newEnvVal, ok := envVars[envKey] + if ok { + if newEnvVal == "" { + continue + } + newEnv = append(newEnv, envKey+"="+newEnvVal) + found[envKey] = true + } else { + newEnv = append(newEnv, envStr) + } + } + for envKey, envVal := range envVars { + if found[envKey] { + continue + } + newEnv = append(newEnv, envKey+"="+envVal) + } + cmd.Env = newEnv +} + +func GetEnvStrKey(envStr string) string { + eqIdx := strings.Index(envStr, "=") + if eqIdx == -1 { + return envStr + } + return envStr[0:eqIdx] +} + +var initStartupFilesOnce = &sync.Once{} + +// in a Once block so it can be called multiple times +// we run it at startup, but also before launching local shells so we know everything is initialized before starting the shell +func InitCustomShellStartupFiles() error { + var err error + initStartupFilesOnce.Do(func() { + err = initCustomShellStartupFilesInternal() + }) + return err +} + +func GetBashRcFileOverride() string { + return filepath.Join(wavebase.GetWaveHomeDir(), BashIntegrationDir, ".bashrc") +} + +func GetWavePowershellEnv() string { + return filepath.Join(wavebase.GetWaveHomeDir(), PwshIntegrationDir, "wavepwsh.ps1") +} + +func GetZshZDotDir() string { + return filepath.Join(wavebase.GetWaveHomeDir(), ZshIntegrationDir) +} + +func GetWshBaseName(version string, goos string, goarch string) string { + ext := "" + if goarch == "amd64" { + goarch = "x64" + } + if goarch == "aarch64" { + goarch = "arm64" + } + if goos == "windows" { + ext = ".exe" + } + return fmt.Sprintf("wsh-%s-%s.%s%s", version, goos, goarch, ext) +} + +func GetWshBinaryPath(version string, goos string, goarch string) string { + return filepath.Join(os.Getenv(WaveAppPathVarName), AppPathBinDir, GetWshBaseName(version, goos, goarch)) +} + +func InitRcFiles(waveHome string, wshBinDir string) error { + // ensure directiries exist + zshDir := filepath.Join(waveHome, ZshIntegrationDir) + err := wavebase.CacheEnsureDir(zshDir, ZshIntegrationDir, 0755, ZshIntegrationDir) + if err != nil { + return err + } + bashDir := filepath.Join(waveHome, BashIntegrationDir) + err = wavebase.CacheEnsureDir(bashDir, BashIntegrationDir, 0755, BashIntegrationDir) + if err != nil { + return err + } + pwshDir := filepath.Join(waveHome, PwshIntegrationDir) + err = wavebase.CacheEnsureDir(pwshDir, PwshIntegrationDir, 0755, PwshIntegrationDir) + if err != nil { + return err + } + + // write files to directory + zprofilePath := filepath.Join(zshDir, ".zprofile") + err = os.WriteFile(zprofilePath, []byte(ZshStartup_Zprofile), 0644) + if err != nil { + return fmt.Errorf("error writing zsh-integration .zprofile: %v", err) + } + err = utilfn.WriteTemplateToFile(filepath.Join(zshDir, ".zshrc"), ZshStartup_Zshrc, map[string]string{"WSHBINDIR": fmt.Sprintf(`"%s"`, wshBinDir)}) + if err != nil { + return fmt.Errorf("error writing zsh-integration .zshrc: %v", err) + } + zloginPath := filepath.Join(zshDir, ".zlogin") + err = os.WriteFile(zloginPath, []byte(ZshStartup_Zlogin), 0644) + if err != nil { + return fmt.Errorf("error writing zsh-integration .zlogin: %v", err) + } + zshenvPath := filepath.Join(zshDir, ".zshenv") + err = os.WriteFile(zshenvPath, []byte(ZshStartup_Zshenv), 0644) + if err != nil { + return fmt.Errorf("error writing zsh-integration .zshenv: %v", err) + } + err = utilfn.WriteTemplateToFile(filepath.Join(bashDir, ".bashrc"), BashStartup_Bashrc, map[string]string{"WSHBINDIR": fmt.Sprintf(`"%s"`, wshBinDir)}) + if err != nil { + return fmt.Errorf("error writing bash-integration .bashrc: %v", err) + } + var pathSep string + if runtime.GOOS == "windows" { + pathSep = ";" + } else { + pathSep = ":" + } + err = utilfn.WriteTemplateToFile(filepath.Join(pwshDir, "wavepwsh.ps1"), PwshStartup_wavepwsh, map[string]string{"WSHBINDIR": toPwshEnvVarRef(wshBinDir), "PATHSEP": pathSep}) + if err != nil { + return fmt.Errorf("error writing pwsh-integration wavepwsh.ps1: %v", err) + } + + return nil +} + +func initCustomShellStartupFilesInternal() error { + log.Printf("initializing wsh and shell startup files\n") + waveHome := wavebase.GetWaveHomeDir() + binDir := filepath.Join(waveHome, WaveHomeBinDir) + err := InitRcFiles(waveHome, `$WAVETERM_WSHBINDIR`) + if err != nil { + return err + } + + err = wavebase.CacheEnsureDir(binDir, WaveHomeBinDir, 0755, WaveHomeBinDir) + if err != nil { + return err + } + + // copy the correct binary to bin + wshBaseName := GetWshBaseName(wavebase.WaveVersion, runtime.GOOS, runtime.GOARCH) + wshFullPath := GetWshBinaryPath(wavebase.WaveVersion, runtime.GOOS, runtime.GOARCH) + if _, err := os.Stat(wshFullPath); err != nil { + log.Printf("error (non-fatal), could not resolve wsh binary %q: %v\n", wshFullPath, err) + return nil + } + wshDstPath := filepath.Join(binDir, "wsh") + if runtime.GOOS == "windows" { + wshDstPath = wshDstPath + ".exe" + } + err = utilfn.AtomicRenameCopy(wshDstPath, wshFullPath, 0755) + if err != nil { + return fmt.Errorf("error copying wsh binary to bin: %v", err) + } + log.Printf("wsh binary successfully %q copied to %q\n", wshBaseName, wshDstPath) + return nil +} + +func toPwshEnvVarRef(input string) string { + return strings.Replace(input, "$", "$env:", -1) +} diff --git a/pkg/util/utilfn/mimetypes.go b/pkg/util/utilfn/mimetypes.go new file mode 100644 index 000000000..1e464ac29 --- /dev/null +++ b/pkg/util/utilfn/mimetypes.go @@ -0,0 +1,1261 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +var StaticMimeTypeMap = map[string]string{ + ".a2l": "application/A2L", + ".aml": "application/AML", + ".ez": "application/andrew-inset", + ".anx": "application/annodex", + ".atf": "application/ATF", + ".atfx": "application/ATFX", + ".atom": "application/atom+xml", + ".atomcat": "application/atomcat+xml", + ".atomdeleted": "application/atomdeleted+xml", + ".atomsrv": "application/atomserv+xml", + ".atomsvc": "application/atomsvc+xml", + ".dwd": "application/atsc-dwd+xml", + ".held": "application/atsc-held+xml", + ".rsat": "application/atsc-rsat+xml", + ".atxml": "application/ATXML", + ".apxml": "application/auth-policy+xml", + ".amlx": "application/automationml-amlx+zip", + ".xdd": "application/bacnet-xdd+zip", + ".lin": "application/bbolin", + ".xcs": "application/calendar+xml", + ".cbor": "application/cbor", + ".c3ex": "application/cccex", + ".ccmp": "application/ccmp+xml", + ".ccxml": "application/ccxml+xml", + ".cdfx": "application/CDFX+XML", + ".cdmia": "application/cdmi-capability", + ".cdmic": "application/cdmi-container", + ".cdmid": "application/cdmi-domain", + ".cdmio": "application/cdmi-object", + ".cdmiq": "application/cdmi-queue", + ".cea": "application/CEA", + ".cellml": "application/cellml+xml", + ".1clr": "application/clr", + ".clue": "application/clue_info+xml", + ".cmsc": "application/cms", + ".cpl": "application/cpl+xml", + ".csrattrs": "application/csrattrs", + ".cu": "application/cu-seeme", + ".cwl": "application/cwl", + ".cwl.json": "application/cwl+json", + ".mpd": "application/dash+xml", + ".mpdd": "application/dashdelta", + ".davmount": "application/davmount+xml", + ".dcd": "application/DCD", + ".dcm": "application/dicom", + ".dii": "application/DII", + ".dit": "application/DIT", + ".xmls": "application/dskpp+xml", + ".tsp": "application/dsptype", + ".dssc": "application/dssc+der", + ".xdssc": "application/dssc+xml", + ".dvc": "application/dvcs", + ".efi": "application/efi", + ".emma": "application/emma+xml", + ".emotionml": "application/emotionml+xml", + ".epub": "application/epub+zip", + ".exi": "application/exi", + ".exp": "application/express", + ".finf": "application/fastinfoset", + ".fdf": "application/fdf", + ".fdt": "application/fdt+xml", + ".pfr": "application/font-tdpfr", + ".spl": "application/futuresplash", + ".geojson": "application/geo+json", + ".gpkg": "application/geopackage+sqlite3", + ".glbin": "application/gltf-buffer", + ".gml": "application/gml+xml", + ".gql": "application/graphql", + ".graphql": "application/graphql", + ".gz": "application/gzip", + ".hta": "application/hta", + ".stk": "application/hyperstudio", + ".ink": "application/inkml+xml", + ".ipfix": "application/ipfix", + ".its": "application/its+xml", + ".jar": "application/java-archive", + ".ser": "application/java-serialized-object", + ".class": "application/java-vm", + ".jrd": "application/jrd+json", + ".json": "application/json", + ".json-patch": "application/json-patch+json", + ".jsonld": "application/ld+json", + ".lgr": "application/lgr+xml", + ".wlnk": "application/link-format", + ".liquid": "application/liquid", + ".lostxml": "application/lost+xml", + ".lostsyncxml": "application/lostsync+xml", + ".lpf": "application/lpf+zip", + ".lxf": "application/LXF", + ".m3g": "application/m3g", + ".hqx": "application/mac-binhex40", + ".cpt": "application/mac-compactpro", + ".mads": "application/mads+xml", + ".webmanifest": "application/manifest+json", + ".mrc": "application/marc", + ".mrcx": "application/marcxml+xml", + ".ma": "application/mathematica", + ".mml": "application/mathml+xml", + ".mbox": "application/mbox", + ".meta4": "application/metalink4+xml", + ".mets": "application/mets+xml", + ".mf4": "application/MF4", + ".maei": "application/mmt-aei+xml", + ".musd": "application/mmt-usd+xml", + ".mods": "application/mods+xml", + ".m21": "application/mp21", + ".mdb": "application/msaccess", + ".doc": "application/msword", + ".mxf": "application/mxf", + ".nq": "application/n-quads", + ".nt": "application/n-triples", + ".orq": "application/ocsp-request", + ".ors": "application/ocsp-response", + ".bin": "application/octet-stream", + ".oda": "application/ODA", + ".odx": "application/ODX", + ".opf": "application/oebps-package+xml", + ".ogx": "application/ogg", + ".one": "application/onenote", + ".oxps": "application/oxps", + ".p21": "application/p21", + ".relo": "application/p2p-overlay+xml", + ".pdf": "application/pdf", + ".pdx": "application/PDX", + ".pem": "application/pem-certificate-chain", + ".pgp": "application/pgp-encrypted", + ".asc": "application/pgp-keys", + ".sig": "application/pgp-signature", + ".prf": "application/pics-rules", + ".p10": "application/pkcs10", + ".p12": "application/pkcs12", + ".p7m": "application/pkcs7-mime", + ".p7s": "application/pkcs7-signature", + ".p8": "application/pkcs8", + ".p8e": "application/pkcs8-encrypted", + ".ac": "application/pkix-attr-cert", + ".cer": "application/pkix-cert", + ".crl": "application/pkix-crl", + ".pkipath": "application/pkix-pkipath", + ".pki": "application/pkixcmp", + ".ps": "application/postscript", + ".provx": "application/provenance+xml", + ".cw": "application/prs.cww", + ".hpub": "application/prs.hpub+zip", + ".rnd": "application/prs.nprend", + ".rdf-crypt": "application/prs.rdf-xml-crypt", + ".xsf": "application/prs.xsf+xml", + ".pskcxml": "application/pskc+xml", + ".rdf": "application/rdf+xml", + ".rif": "application/reginfo+xml", + ".rnc": "application/relax-ng-compact-syntax", + ".rl": "application/resource-lists+xml", + ".rld": "application/resource-lists-diff+xml", + ".rfcxml": "application/rfc+xml", + ".rapd": "application/route-apd+xml", + ".sls": "application/route-s-tsid+xml", + ".rusd": "application/route-usd+xml", + ".gbr": "application/rpki-ghostbusters", + ".mft": "application/rpki-manifest", + ".roa": "application/rpki-roa", + ".rtf": "application/rtf", + ".sarif": "application/sarif+json", + ".sarif-external-properties": "application/sarif-external-properties+json", + ".scim": "application/scim+json", + ".scq": "application/scvp-cv-request", + ".scs": "application/scvp-cv-response", + ".spq": "application/scvp-vp-request", + ".spp": "application/scvp-vp-response", + ".sdp": "application/sdp", + ".senmlc": "application/senml+cbor", + ".senml": "application/senml+json", + ".senmlx": "application/senml+xml", + ".senml-etchc": "application/senml-etch+cbor", + ".senml-etchj": "application/senml-etch+json", + ".senmle": "application/senml-exi", + ".sensmlc": "application/sensml+cbor", + ".sensml": "application/sensml+json", + ".sensmlx": "application/sensml+xml", + ".sensmle": "application/sensml-exi", + ".soc": "application/sgml-open-catalog", + ".shf": "application/shf+xml", + ".siv": "application/sieve", + ".smil": "application/smil+xml", + ".rq": "application/sparql-query", + ".srx": "application/sparql-results+xml", + ".spdx.json": "application/spdx+json", + ".sql": "application/sql", + ".gram": "application/srgs", + ".grxml": "application/srgs+xml", + ".sru": "application/sru+xml", + ".ssml": "application/ssml+xml", + ".stix": "application/stix+json", + ".coswid": "application/swid+cbor", + ".swidtag": "application/swid+xml", + ".tau": "application/tamp-apex-update", + ".auc": "application/tamp-apex-update-confirm", + ".tcu": "application/tamp-community-update", + ".cuc": "application/tamp-community-update-confirm", + ".ter": "application/tamp-error", + ".tsa": "application/tamp-sequence-adjust", + ".sac": "application/tamp-sequence-adjust-confirm", + ".tur": "application/tamp-update", + ".tuc": "application/tamp-update-confirm", + ".jsontd": "application/td+json", + ".tei": "application/tei+xml", + ".tfi": "application/thraud+xml", + ".tsq": "application/timestamp-query", + ".tsr": "application/timestamp-reply", + ".tsd": "application/timestamped-data", + ".tm.jsonld": "application/tm+json", + ".toml": "application/toml", + ".trig": "application/trig", + ".ttml": "application/ttml+xml", + ".gsheet": "application/urc-grpsheet+xml", + ".rsheet": "application/urc-ressheet+xml", + ".td": "application/urc-targetdesc+xml", + ".uis": "application/urc-uisocketdesc+xml", + ".1km": "application/vnd.1000minds.decision-model+xml", + ".ob": "application/vnd.1ob", + ".plb": "application/vnd.3gpp.pic-bw-large", + ".psb": "application/vnd.3gpp.pic-bw-small", + ".pvb": "application/vnd.3gpp.pic-bw-var", + ".sms": "application/vnd.3gpp2.sms", + ".tcap": "application/vnd.3gpp2.tcap", + ".imgcal": "application/vnd.3lightssoftware.imagescal", + ".pwn": "application/vnd.3M.Post-it-Notes", + ".aso": "application/vnd.accpac.simply.aso", + ".imp": "application/vnd.accpac.simply.imp", + ".acu": "application/vnd.acucobol", + ".atc": "application/vnd.acucorp", + ".swf": "application/vnd.adobe.flash.movie", + ".fcdt": "application/vnd.adobe.formscentral.fcdt", + ".fxp": "application/vnd.adobe.fxp", + ".xdp": "application/vnd.adobe.xdp+xml", + ".list3820": "application/vnd.afpc.modca", + ".ovl": "application/vnd.afpc.modca-overlay", + ".psg": "application/vnd.afpc.modca-pagesegment", + ".age": "application/vnd.age", + ".ahead": "application/vnd.ahead.space", + ".azf": "application/vnd.airzip.filesecure.azf", + ".azs": "application/vnd.airzip.filesecure.azs", + ".azw3": "application/vnd.amazon.mobi8-ebook", + ".acc": "application/vnd.americandynamics.acc", + ".ami": "application/vnd.amiga.ami", + ".ota": "application/vnd.android.ota", + ".apk": "application/vnd.android.package-archive", + ".apkg": "application/vnd.anki", + ".cii": "application/vnd.anser-web-certificate-issue-initiation", + ".fti": "application/vnd.anser-web-funds-transfer-initiation", + ".arrow": "application/vnd.apache.arrow.file", + ".arrows": "application/vnd.apache.arrow.stream", + ".apexlang": "application/vnd.apexlang", + ".dist": "application/vnd.apple.installer+xml", + ".keynote": "application/vnd.apple.keynote", + ".m3u8": "application/vnd.apple.mpegurl", + ".numbers": "application/vnd.apple.numbers", + ".pages": "application/vnd.apple.pages", + ".swi": "application/vnd.aristanetworks.swi", + ".artisan": "application/vnd.artisan+json", + ".iota": "application/vnd.astraea-software.iota", + ".aep": "application/vnd.audiograph", + ".package": "application/vnd.autopackage", + ".bmml": "application/vnd.balsamiq.bmml+xml", + ".bmpr": "application/vnd.balsamiq.bmpr", + ".ac2": "application/vnd.banana-accounting", + ".lhzd": "application/vnd.belightsoft.lhzd+zip", + ".lhzl": "application/vnd.belightsoft.lhzl+zip", + ".mpm": "application/vnd.blueice.multipass", + ".ep": "application/vnd.bluetooth.ep.oob", + ".le": "application/vnd.bluetooth.le.oob", + ".bmi": "application/vnd.bmi", + ".rep": "application/vnd.businessobjects", + ".tlclient": "application/vnd.cendio.thinlinc.clientconf", + ".cdxml": "application/vnd.chemdraw+xml", + ".pgn": "application/vnd.chess-pgn", + ".mmd": "application/vnd.chipnuts.karaoke-mmd", + ".cdy": "application/vnd.cinderella", + ".csl": "application/vnd.citationstyles.style+xml", + ".cla": "application/vnd.claymore", + ".rp9": "application/vnd.cloanto.rp9", + ".c4g": "application/vnd.clonk.c4group", + ".c11amc": "application/vnd.cluetrust.cartomobile-config", + ".c11amz": "application/vnd.cluetrust.cartomobile-config-pkg", + ".coffee": "application/vnd.coffeescript", + ".xodt": "application/vnd.collabio.xodocuments.document", + ".xott": "application/vnd.collabio.xodocuments.document-template", + ".xodp": "application/vnd.collabio.xodocuments.presentation", + ".xotp": "application/vnd.collabio.xodocuments.presentation-template", + ".xods": "application/vnd.collabio.xodocuments.spreadsheet", + ".xots": "application/vnd.collabio.xodocuments.spreadsheet-template", + ".cbz": "application/vnd.comicbook+zip", + ".cbr": "application/vnd.comicbook-rar", + ".icf": "application/vnd.commerce-battelle", + ".csp": "application/vnd.commonspace", + ".cdbcmsg": "application/vnd.contact.cmsg", + ".ign": "application/vnd.coreos.ignition+json", + ".cmc": "application/vnd.cosmocaller", + ".clkx": "application/vnd.crick.clicker", + ".clkk": "application/vnd.crick.clicker.keyboard", + ".clkp": "application/vnd.crick.clicker.palette", + ".clkt": "application/vnd.crick.clicker.template", + ".clkw": "application/vnd.crick.clicker.wordbank", + ".wbs": "application/vnd.criticaltools.wbs+xml", + ".ssvc": "application/vnd.crypto-shade-file", + ".c9r": "application/vnd.cryptomator.encrypted", + ".cryptomator": "application/vnd.cryptomator.vault", + ".pml": "application/vnd.ctc-posml", + ".ppd": "application/vnd.cups-ppd", + ".rdz": "application/vnd.data-vision.rdz", + ".dl": "application/vnd.datalog", + ".dbf": "application/vnd.dbf", + ".deb": "application/vnd.debian.binary-package", + ".uvf": "application/vnd.dece.data", + ".uvt": "application/vnd.dece.ttml+xml", + ".uvx": "application/vnd.dece.unspecified", + ".uvz": "application/vnd.dece.zip", + ".fe_launch": "application/vnd.denovo.fcselayout-link", + ".dsm": "application/vnd.desmume.movie", + ".dna": "application/vnd.dna", + ".docjson": "application/vnd.document+json", + ".scld": "application/vnd.doremir.scorecloud-binary-document", + ".dpg": "application/vnd.dpgraph", + ".dfac": "application/vnd.dreamfactory", + ".fla": "application/vnd.dtg.local.flash", + ".ait": "application/vnd.dvb.ait", + ".svc": "application/vnd.dvb.service", + ".geo": "application/vnd.dynageo", + ".dzr": "application/vnd.dzr", + ".mag": "application/vnd.ecowin.chart", + ".ELN": "application/vnd.eln+zip", + ".nml": "application/vnd.enliven", + ".esf": "application/vnd.epson.esf", + ".msf": "application/vnd.epson.msf", + ".qam": "application/vnd.epson.quickanime", + ".slt": "application/vnd.epson.salt", + ".ssf": "application/vnd.epson.ssf", + ".qcall": "application/vnd.ericsson.quickcall", + ".espass": "application/vnd.espass-espass+zip", + ".es3": "application/vnd.eszigno3+xml", + ".asice": "application/vnd.etsi.asic-e+zip", + ".asics": "application/vnd.etsi.asic-s+zip", + ".tst": "application/vnd.etsi.timestamp-token", + ".carjson": "application/vnd.eu.kasparian.car+json", + ".ecigprofile": "application/vnd.evolv.ecig.profile", + ".ecig": "application/vnd.evolv.ecig.settings", + ".ecigtheme": "application/vnd.evolv.ecig.theme", + ".mpw": "application/vnd.exstream-empower+zip", + ".pub": "application/vnd.exstream-package", + ".ez2": "application/vnd.ezpix-album", + ".ez3": "application/vnd.ezpix-package", + ".gdz": "application/vnd.familysearch.gedcom+zip", + ".dim": "application/vnd.fastcopy-disk-image", + ".msd": "application/vnd.fdsn.mseed", + ".seed": "application/vnd.fdsn.seed", + ".flb": "application/vnd.ficlab.flb+zip", + ".zfc": "application/vnd.filmit.zfc", + ".gph": "application/vnd.FloGraphIt", + ".ftc": "application/vnd.fluxtime.clip", + ".sfd": "application/vnd.font-fontforge-sfd", + ".fm": "application/vnd.framemaker", + ".fsc": "application/vnd.fsc.weblaunch", + ".oas": "application/vnd.fujitsu.oasys", + ".oa2": "application/vnd.fujitsu.oasys2", + ".oa3": "application/vnd.fujitsu.oasys3", + ".fg5": "application/vnd.fujitsu.oasysgp", + ".bh2": "application/vnd.fujitsu.oasysprs", + ".ddd": "application/vnd.fujixerox.ddd", + ".xdw": "application/vnd.fujixerox.docuworks", + ".xbd": "application/vnd.fujixerox.docuworks.binder", + ".xct": "application/vnd.fujixerox.docuworks.container", + ".fzs": "application/vnd.fuzzysheet", + ".txd": "application/vnd.genomatix.tuxedo", + ".genozip": "application/vnd.genozip", + ".grd": "application/vnd.gentics.grd+json", + ".ebuild": "application/vnd.gentoo.ebuild", + ".eclass": "application/vnd.gentoo.eclass", + ".gpkg.tar": "application/vnd.gentoo.gpkg", + ".xpak": "application/vnd.gentoo.xpak", + ".ggb": "application/vnd.geogebra.file", + ".ggs": "application/vnd.geogebra.slides", + ".ggt": "application/vnd.geogebra.tool", + ".gex": "application/vnd.geometry-explorer", + ".gxt": "application/vnd.geonext", + ".g2w": "application/vnd.geoplan", + ".g3w": "application/vnd.geospace", + ".kml": "application/vnd.google-earth.kml+xml", + ".kmz": "application/vnd.google-earth.kmz", + ".gqf": "application/vnd.grafeq", + ".gac": "application/vnd.groove-account", + ".ghf": "application/vnd.groove-help", + ".gim": "application/vnd.groove-identity-message", + ".grv": "application/vnd.groove-injector", + ".gtm": "application/vnd.groove-tool-message", + ".tpl": "application/vnd.groove-tool-template", + ".vcg": "application/vnd.groove-vcard", + ".hal": "application/vnd.hal+xml", + ".zmm": "application/vnd.HandHeld-Entertainment+xml", + ".hbci": "application/vnd.hbci", + ".hdt": "application/vnd.hdt", + ".les": "application/vnd.hhe.lesson-player", + ".hpgl": "application/vnd.hp-HPGL", + ".hpi": "application/vnd.hp-hpid", + ".hps": "application/vnd.hp-hps", + ".jlt": "application/vnd.hp-jlyt", + ".pcl": "application/vnd.hp-PCL", + ".hsl": "application/vnd.hsl", + ".sfd-hdstx": "application/vnd.hydrostatix.sof-data", + ".emm": "application/vnd.ibm.electronic-media", + ".mpy": "application/vnd.ibm.MiniPay", + ".irm": "application/vnd.ibm.rights-management", + ".icc": "application/vnd.iccprofile", + ".1905.1": "application/vnd.ieee.1905", + ".igl": "application/vnd.igloader", + ".imf": "application/vnd.imagemeter.folder+zip", + ".imi": "application/vnd.imagemeter.image+zip", + ".ivp": "application/vnd.immervision-ivp", + ".ivu": "application/vnd.immervision-ivu", + ".imscc": "application/vnd.ims.imsccv1p1", + ".igm": "application/vnd.insors.igm", + ".xpw": "application/vnd.intercon.formnet", + ".i2g": "application/vnd.intergeo", + ".qbo": "application/vnd.intu.qbo", + ".qfx": "application/vnd.intu.qfx", + ".ipns-record": "application/vnd.ipfs.ipns-record", + ".car": "application/vnd.ipld.car", + ".rcprofile": "application/vnd.ipunplugged.rcprofile", + ".irp": "application/vnd.irepository.package+xml", + ".xpr": "application/vnd.is-xpr", + ".fcs": "application/vnd.isac.fcs", + ".jam": "application/vnd.jam", + ".rms": "application/vnd.jcp.javame.midlet-rms", + ".jisp": "application/vnd.jisp", + ".joda": "application/vnd.joost.joda-archive", + ".ktz": "application/vnd.kahootz", + ".karbon": "application/vnd.kde.karbon", + ".chrt": "application/vnd.kde.kchart", + ".kfo": "application/vnd.kde.kformula", + ".flw": "application/vnd.kde.kivio", + ".kon": "application/vnd.kde.kontour", + ".kpr": "application/vnd.kde.kpresenter", + ".ksp": "application/vnd.kde.kspread", + ".kwd": "application/vnd.kde.kword", + ".htke": "application/vnd.kenameaapp", + ".kia": "application/vnd.kidspiration", + ".kne": "application/vnd.Kinar", + ".skp": "application/vnd.koan", + ".sse": "application/vnd.kodak-descriptor", + ".las": "application/vnd.las", + ".lasjson": "application/vnd.las.las+json", + ".lasxml": "application/vnd.las.las+xml", + ".lbd": "application/vnd.llamagraphics.life-balance.desktop", + ".lbe": "application/vnd.llamagraphics.life-balance.exchange+xml", + ".lcs": "application/vnd.logipipe.circuit+zip", + ".loom": "application/vnd.loom", + ".123": "application/vnd.lotus-1-2-3", + ".apr": "application/vnd.lotus-approach", + ".prz": "application/vnd.lotus-freelance", + ".nsf": "application/vnd.lotus-notes", + ".or3": "application/vnd.lotus-organizer", + ".lwp": "application/vnd.lotus-wordpro", + ".portpkg": "application/vnd.macports.portpkg", + ".mvt": "application/vnd.mapbox-vector-tile", + ".mdc": "application/vnd.marlin.drm.mdcf", + ".3tz": "application/vnd.maxar.archive.3tz+zip", + ".mmdb": "application/vnd.maxmind.maxmind-db", + ".mcd": "application/vnd.mcd", + ".mdl": "application/vnd.mdl", + ".mbsdf": "application/vnd.mdl-mbsdf", + ".mc1": "application/vnd.medcalcdata", + ".cdkey": "application/vnd.mediastation.cdkey", + ".rxt": "application/vnd.medicalholodeck.recordxr", + ".mwf": "application/vnd.MFER", + ".mfm": "application/vnd.mfmp", + ".flo": "application/vnd.micrografx.flo", + ".igx": "application/vnd.micrografx.igx", + ".mif": "application/vnd.mif", + ".daf": "application/vnd.Mobius.DAF", + ".dis": "application/vnd.Mobius.DIS", + ".mbk": "application/vnd.Mobius.MBK", + ".mqy": "application/vnd.Mobius.MQY", + ".msl": "application/vnd.Mobius.MSL", + ".plc": "application/vnd.Mobius.PLC", + ".txf": "application/vnd.Mobius.TXF", + ".modl": "application/vnd.modl", + ".mpn": "application/vnd.mophun.application", + ".mpc": "application/vnd.mophun.certificate", + ".xul": "application/vnd.mozilla.xul+xml", + ".3mf": "application/vnd.ms-3mfdocument", + ".cil": "application/vnd.ms-artgalry", + ".asf": "application/vnd.ms-asf", + ".cab": "application/vnd.ms-cab-compressed", + ".xls": "application/vnd.ms-excel", + ".xlam": "application/vnd.ms-excel.addin.macroEnabled.12", + ".xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + ".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12", + ".xltm": "application/vnd.ms-excel.template.macroEnabled.12", + ".eot": "application/vnd.ms-fontobject", + ".chm": "application/vnd.ms-htmlhelp", + ".ims": "application/vnd.ms-ims", + ".lrm": "application/vnd.ms-lrm", + ".thmx": "application/vnd.ms-officetheme", + ".cat": "application/vnd.ms-pki.seccat", + ".ppt": "application/vnd.ms-powerpoint", + ".ppam": "application/vnd.ms-powerpoint.addin.macroEnabled.12", + ".pptm": "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ".sldm": "application/vnd.ms-powerpoint.slide.macroEnabled.12", + ".ppsm": "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + ".potm": "application/vnd.ms-powerpoint.template.macroEnabled.12", + ".mpp": "application/vnd.ms-project", + ".tnef": "application/vnd.ms-tnef", + ".docm": "application/vnd.ms-word.document.macroEnabled.12", + ".dotm": "application/vnd.ms-word.template.macroEnabled.12", + ".wcm": "application/vnd.ms-works", + ".wpl": "application/vnd.ms-wpl", + ".xps": "application/vnd.ms-xpsdocument", + ".msa": "application/vnd.msa-disk-image", + ".mseq": "application/vnd.mseq", + ".crtr": "application/vnd.multiad.creator", + ".cif": "application/vnd.multiad.creator.cif", + ".mus": "application/vnd.musician", + ".msty": "application/vnd.muvee.style", + ".taglet": "application/vnd.mynfc", + ".nebul": "application/vnd.nebumind.line", + ".entity": "application/vnd.nervana", + ".nlu": "application/vnd.neurolanguage.nlu", + ".nimn": "application/vnd.nimn", + ".nds": "application/vnd.nintendo.nitro.rom", + ".sfc": "application/vnd.nintendo.snes.rom", + ".nitf": "application/vnd.nitf", + ".nnd": "application/vnd.noblenet-directory", + ".nns": "application/vnd.noblenet-sealer", + ".nnw": "application/vnd.noblenet-web", + ".ngdat": "application/vnd.nokia.n-gage.data", + ".rpst": "application/vnd.nokia.radio-preset", + ".rpss": "application/vnd.nokia.radio-presets", + ".edm": "application/vnd.novadigm.EDM", + ".edx": "application/vnd.novadigm.EDX", + ".ext": "application/vnd.novadigm.EXT", + ".odb": "application/vnd.oasis.opendocument.base", + ".odc": "application/vnd.oasis.opendocument.chart", + ".otc": "application/vnd.oasis.opendocument.chart-template", + ".odf": "application/vnd.oasis.opendocument.formula", + ".odg": "application/vnd.oasis.opendocument.graphics", + ".otg": "application/vnd.oasis.opendocument.graphics-template", + ".odi": "application/vnd.oasis.opendocument.image", + ".oti": "application/vnd.oasis.opendocument.image-template", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".otp": "application/vnd.oasis.opendocument.presentation-template", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".ots": "application/vnd.oasis.opendocument.spreadsheet-template", + ".odt": "application/vnd.oasis.opendocument.text", + ".odm": "application/vnd.oasis.opendocument.text-master", + ".otm": "application/vnd.oasis.opendocument.text-master-template", + ".ott": "application/vnd.oasis.opendocument.text-template", + ".oth": "application/vnd.oasis.opendocument.text-web", + ".xo": "application/vnd.olpc-sugar", + ".dd2": "application/vnd.oma.dd2+xml", + ".tam": "application/vnd.onepager", + ".tamp": "application/vnd.onepagertamp", + ".tamx": "application/vnd.onepagertamx", + ".tat": "application/vnd.onepagertat", + ".tatp": "application/vnd.onepagertatp", + ".tatx": "application/vnd.onepagertatx", + ".obgx": "application/vnd.openblox.game+xml", + ".obg": "application/vnd.openblox.game-binary", + ".oeb": "application/vnd.openeye.oeb", + ".oxt": "application/vnd.openofficeorg.extension", + ".osm": "application/vnd.openstreetmap.data+xml", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".sldx": "application/vnd.openxmlformats-officedocument.presentationml.slide", + ".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + ".potx": "application/vnd.openxmlformats-officedocument.presentationml.template", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ".ndc": "application/vnd.osa.netdeploy", + ".mgp": "application/vnd.osgeo.mapguide.package", + ".dp": "application/vnd.osgi.dp", + ".esa": "application/vnd.osgi.subsystem", + ".oxlicg": "application/vnd.oxli.countgraph", + ".pdb": "application/vnd.palm", + ".plp": "application/vnd.panoply", + ".dive": "application/vnd.patentdive", + ".paw": "application/vnd.pawaafile", + ".str": "application/vnd.pg.format", + ".ei6": "application/vnd.pg.osasli", + ".pil": "application/vnd.piaccess.application-licence", + ".efif": "application/vnd.picsel", + ".wg": "application/vnd.pmi.widget", + ".plf": "application/vnd.pocketlearn", + ".pbd": "application/vnd.powerbuilder6", + ".preminet": "application/vnd.preminet", + ".box": "application/vnd.previewsystems.box", + ".mgz": "application/vnd.proteus.magazine", + ".psfs": "application/vnd.psfs", + ".qps": "application/vnd.publishare-delta-tree", + ".ptid": "application/vnd.pvi.ptid1", + ".bar": "application/vnd.qualcomm.brew-app-res", + ".qxd": "application/vnd.Quark.QuarkXPress", + ".quox": "application/vnd.quobject-quoxdocument", + ".tree": "application/vnd.rainstor.data", + ".rar": "application/vnd.rar", + ".bed": "application/vnd.realvnc.bed", + ".mxl": "application/vnd.recordare.musicxml", + ".rlm": "application/vnd.resilient.logic", + ".cryptonote": "application/vnd.rig.cryptonote", + ".cod": "application/vnd.rim.cod", + ".link66": "application/vnd.route66.link66+xml", + ".st": "application/vnd.sailingtracker.track", + ".SAR": "application/vnd.sar", + ".scd": "application/vnd.scribus", + ".s3df": "application/vnd.sealed.3df", + ".scsf": "application/vnd.sealed.csf", + ".sdoc": "application/vnd.sealed.doc", + ".seml": "application/vnd.sealed.eml", + ".smht": "application/vnd.sealed.mht", + ".sppt": "application/vnd.sealed.ppt", + ".stif": "application/vnd.sealed.tiff", + ".sxls": "application/vnd.sealed.xls", + ".stml": "application/vnd.sealedmedia.softseal.html", + ".spdf": "application/vnd.sealedmedia.softseal.pdf", + ".see": "application/vnd.seemail", + ".sema": "application/vnd.sema", + ".semd": "application/vnd.semd", + ".semf": "application/vnd.semf", + ".ssv": "application/vnd.shade-save-file", + ".ifm": "application/vnd.shana.informed.formdata", + ".itp": "application/vnd.shana.informed.formtemplate", + ".iif": "application/vnd.shana.informed.interchange", + ".ipk": "application/vnd.shana.informed.package", + ".shp": "application/vnd.shp", + ".shx": "application/vnd.shx", + ".sr": "application/vnd.sigrok.session", + ".twd": "application/vnd.SimTech-MindMapper", + ".mmf": "application/vnd.smaf", + ".notebook": "application/vnd.smart.notebook", + ".teacher": "application/vnd.smart.teacher", + ".sipa": "application/vnd.smintio.portals.archive", + ".ptrom": "application/vnd.snesdev-page-table", + ".fo": "application/vnd.software602.filler.form+xml", + ".zfo": "application/vnd.software602.filler.form-xml-zip", + ".sdkm": "application/vnd.solent.sdkm+xml", + ".dxp": "application/vnd.spotfire.dxp", + ".sfs": "application/vnd.spotfire.sfs", + ".sqlite": "application/vnd.sqlite3", + ".sdc": "application/vnd.stardivision.calc", + ".sds": "application/vnd.stardivision.chart", + ".sda": "application/vnd.stardivision.draw", + ".sdd": "application/vnd.stardivision.impress", + ".smf": "application/vnd.stardivision.math", + ".sdw": "application/vnd.stardivision.writer", + ".sgl": "application/vnd.stardivision.writer-global", + ".smzip": "application/vnd.stepmania.package", + ".sm": "application/vnd.stepmania.stepchart", + ".wadl": "application/vnd.sun.wadl+xml", + ".sxc": "application/vnd.sun.xml.calc", + ".stc": "application/vnd.sun.xml.calc.template", + ".sxd": "application/vnd.sun.xml.draw", + ".std": "application/vnd.sun.xml.draw.template", + ".sxi": "application/vnd.sun.xml.impress", + ".sti": "application/vnd.sun.xml.impress.template", + ".sxm": "application/vnd.sun.xml.math", + ".sxw": "application/vnd.sun.xml.writer", + ".sxg": "application/vnd.sun.xml.writer.global", + ".stw": "application/vnd.sun.xml.writer.template", + ".sus": "application/vnd.sus-calendar", + ".ml2": "application/vnd.sybyl.mol2", + ".scl": "application/vnd.sycle+xml", + ".syft.json": "application/vnd.syft+json", + ".sis": "application/vnd.symbian.install", + ".xsm": "application/vnd.syncml+xml", + ".bdm": "application/vnd.syncml.dm+wbxml", + ".xdm": "application/vnd.syncml.dm+xml", + ".ddf": "application/vnd.syncml.dmddf+xml", + ".tao": "application/vnd.tao.intent-module-archive", + ".pcap": "application/vnd.tcpdump.pcap", + ".qvd": "application/vnd.theqvd", + ".ppttc": "application/vnd.think-cell.ppttc+json", + ".vfr": "application/vnd.tml", + ".tmo": "application/vnd.tmobile-livetv", + ".tpt": "application/vnd.trid.tpt", + ".mxs": "application/vnd.triscape.mxs", + ".tra": "application/vnd.trueapp", + ".ufdl": "application/vnd.ufdl", + ".utz": "application/vnd.uiq.theme", + ".umj": "application/vnd.umajin", + ".unityweb": "application/vnd.unity", + ".uoml": "application/vnd.uoml+xml", + ".urim": "application/vnd.uri-map", + ".vmt": "application/vnd.valve.source.material", + ".vcx": "application/vnd.vcx", + ".mxi": "application/vnd.vd-study", + ".vwx": "application/vnd.vectorworks", + ".aion": "application/vnd.veritone.aion+json", + ".istc": "application/vnd.veryant.thin", + ".VES": "application/vnd.ves.encrypted", + ".vsc": "application/vnd.vidsoft.vidconference", + ".vsd": "application/vnd.visio", + ".vis": "application/vnd.visionary", + ".vsf": "application/vnd.vsf", + ".sic": "application/vnd.wap.sic", + ".slc": "application/vnd.wap.slc", + ".wbxml": "application/vnd.wap.wbxml", + ".wmlc": "application/vnd.wap.wmlc", + ".wmlsc": "application/vnd.wap.wmlscriptc", + ".wafl": "application/vnd.wasmflow.wafl", + ".wtb": "application/vnd.webturbo", + ".p2p": "application/vnd.wfa.p2p", + ".wsc": "application/vnd.wfa.wsc", + ".wmc": "application/vnd.wmc", + ".nb": "application/vnd.wolfram.mathematica", + ".nbp": "application/vnd.wolfram.player", + ".wpd": "application/vnd.wordperfect", + ".wqd": "application/vnd.wqd", + ".stf": "application/vnd.wt.stf", + ".wv": "application/vnd.wv.csp+wbxml", + ".xar": "application/vnd.xara", + ".xfdl": "application/vnd.xfdl", + ".cpkg": "application/vnd.xmpie.cpkg", + ".dpkg": "application/vnd.xmpie.dpkg", + ".ppkg": "application/vnd.xmpie.ppkg", + ".xlim": "application/vnd.xmpie.xlim", + ".hvd": "application/vnd.yamaha.hv-dic", + ".hvs": "application/vnd.yamaha.hv-script", + ".hvp": "application/vnd.yamaha.hv-voice", + ".osf": "application/vnd.yamaha.openscoreformat", + ".saf": "application/vnd.yamaha.smaf-audio", + ".spf": "application/vnd.yamaha.smaf-phrase", + ".yme": "application/vnd.yaoweme", + ".cmp": "application/vnd.yellowriver-custom-menu", + ".zir": "application/vnd.zul", + ".zaz": "application/vnd.zzazz.deck+xml", + ".vxml": "application/voicexml+xml", + ".vcj": "application/voucher-cms+json", + ".wasm": "application/wasm", + ".wif": "application/watcherinfo+xml", + ".wgt": "application/widget", + ".wsdl": "application/wsdl+xml", + ".wspolicy": "application/wspolicy+xml", + ".wk": "application/x-123", + ".7z": "application/x-7z-compressed", + ".abw": "application/x-abiword", + ".dmg": "application/x-apple-diskimage", + ".bcpio": "application/x-bcpio", + ".torrent": "application/x-bittorrent", + ".cdf": "application/x-cdf", + ".vcd": "application/x-cdlink", + ".mph": "application/x-comsol", + ".cpio": "application/x-cpio", + ".dcr": "application/x-director", + ".wad": "application/x-doom", + ".dvi": "application/x-dvi", + ".pfa": "application/x-font", + ".pcf": "application/x-font-pcf", + ".mm": "application/x-freemind", + ".gan": "application/x-ganttproject", + ".gnumeric": "application/x-gnumeric", + ".sgf": "application/x-go-sgf", + ".gcf": "application/x-graphing-calculator", + ".gtar": "application/x-gtar", + ".tgz": "application/x-gtar-compressed", + ".hdf": "application/x-hdf", + ".php": "application/x-php", + ".hwp": "application/x-hwp", + ".ica": "application/x-ica", + ".info": "application/x-info", + ".ins": "application/x-internet-signup", + ".iii": "application/x-iphone", + ".iso": "application/x-iso9660-image", + ".jnlp": "application/x-java-jnlp-file", + ".jmz": "application/x-jmol", + ".kil": "application/x-killustrator", + ".latex": "application/x-latex", + ".lha": "application/x-lha", + ".lyx": "application/x-lyx", + ".lzh": "application/x-lzh", + ".lzx": "application/x-lzx", + ".frm": "application/x-maker", + ".wmd": "application/x-ms-wmd", + ".wmz": "application/x-ms-wmz", + ".com": "application/x-msdos-program", + ".msi": "application/x-msi", + ".nc": "application/x-netcdf", + ".pac": "application/x-ns-proxy-autoconfig", + ".nwc": "application/x-nwc", + ".o": "application/x-object", + ".oza": "application/x-oz-application", + ".p7r": "application/x-pkcs7-certreqresp", + ".pyc": "application/x-python-code", + ".qgs": "application/x-qgis", + ".qtl": "application/x-quicktimeplayer", + ".rdp": "application/x-rdp", + ".rpm": "application/x-redhat-package-manager", + ".rss": "application/x-rss+xml", + ".rb": "application/x-ruby", + ".erb": "application/x-ruby", + ".sci": "application/x-scilab", + ".xcos": "application/x-scilab-xcos", + ".sh": "application/x-sh", + ".shar": "application/x-shar", + ".scr": "application/x-silverlight", + ".sit": "application/x-stuffit", + ".sv4cpio": "application/x-sv4cpio", + ".sv4crc": "application/x-sv4crc", + ".tar": "application/x-tar", + ".gf": "application/x-tex-gf", + ".pk": "application/x-tex-pk", + ".texinfo": "application/x-texinfo", + ".~": "application/x-trash", + ".man": "application/x-troff-man", + ".me": "application/x-troff-me", + ".ms": "application/x-troff-ms", + ".ustar": "application/x-ustar", + ".src": "application/x-wais-source", + ".wz": "application/x-wingz", + ".crt": "application/x-x509-ca-cert", + ".fig": "application/x-xfig", + ".xpi": "application/x-xpinstall", + ".xz": "application/x-xz", + ".xav": "application/xcap-att+xml", + ".xca": "application/xcap-caps+xml", + ".xdf": "application/xcap-diff+xml", + ".xel": "application/xcap-el+xml", + ".xer": "application/xcap-error+xml", + ".xns": "application/xcap-ns+xml", + ".xfdf": "application/xfdf", + ".xhtml": "application/xhtml+xml", + ".xlf": "application/xliff+xml", + ".xml": "application/xml", + ".dtd": "application/xml-dtd", + ".ent": "application/xml-external-parsed-entity", + ".xop": "application/xop+xml", + ".xsl": "application/xslt+xml", + ".xspf": "application/xspf+xml", + ".mxml": "application/xv+xml", + ".yaml": "application/x-yaml", + ".yang": "application/yang", + ".yin": "application/yin+xml", + ".zip": "application/zip", + ".zst": "application/zstd", + ".726": "audio/32kadpcm", + ".adts": "audio/aac", + ".ac3": "audio/ac3", + ".amr": "audio/AMR", + ".awb": "audio/AMR-WB", + ".axa": "audio/annodex", + ".acn": "audio/asc", + ".aal": "audio/ATRAC-ADVANCED-LOSSLESS", + ".atx": "audio/ATRAC-X", + ".at3": "audio/ATRAC3", + ".au": "audio/basic", + ".csd": "audio/csound", + ".dls": "audio/dls", + ".evc": "audio/EVRC", + ".qcp": "audio/EVRC-QCP", + ".evb": "audio/EVRCB", + ".enw": "audio/EVRCNW", + ".evw": "audio/EVRCWB", + ".flac": "audio/flac", + ".lbc": "audio/iLBC", + ".l16": "audio/L16", + ".mhas": "audio/mhas", + ".mxmf": "audio/mobile-xmf", + ".m4a": "audio/mp4", + ".mpga": "audio/mpeg", + ".m3u": "audio/mpegurl", + ".oga": "audio/ogg", + ".sid": "audio/prs.sid", + ".smv": "audio/SMV", + ".sofa": "audio/sofa", + ".mid": "audio/sp-midi", + ".loas": "audio/usac", + ".koz": "audio/vnd.audiokoz", + ".uva": "audio/vnd.dece.audio", + ".eol": "audio/vnd.digital-winds", + ".mlp": "audio/vnd.dolby.mlp", + ".dts": "audio/vnd.dts", + ".dtshd": "audio/vnd.dts.hd", + ".plj": "audio/vnd.everad.plj", + ".lvp": "audio/vnd.lucent.voice", + ".pya": "audio/vnd.ms-playready.media.pya", + ".vbk": "audio/vnd.nortel.vbk", + ".ecelp4800": "audio/vnd.nuera.ecelp4800", + ".ecelp7470": "audio/vnd.nuera.ecelp7470", + ".ecelp9600": "audio/vnd.nuera.ecelp9600", + ".multitrack": "audio/vnd.presonus.multitrack", + ".rip": "audio/vnd.rip", + ".smp3": "audio/vnd.sealedmedia.softseal.mpeg", + ".aif": "audio/x-aiff", + ".gsm": "audio/x-gsm", + ".wax": "audio/x-ms-wax", + ".wma": "audio/x-ms-wma", + ".ra": "audio/x-pn-realaudio", + ".pls": "audio/x-scpls", + ".sd2": "audio/x-sd2", + ".wav": "audio/x-wav", + ".alc": "chemical/x-alchemy", + ".cac": "chemical/x-cache", + ".csf": "chemical/x-cache-csf", + ".cbin": "chemical/x-cactvs-binary", + ".cdx": "chemical/x-cdx", + ".c3d": "chemical/x-chem3d", + ".cmdf": "chemical/x-cmdf", + ".cml": "chemical/x-cml", + ".cpa": "chemical/x-compass", + ".bsd": "chemical/x-crossfire", + ".csml": "chemical/x-csml", + ".ctx": "chemical/x-ctx", + ".cxf": "chemical/x-cxf", + ".smi": "#chemical/x-daylight-smiles", + ".emb": "chemical/x-embl-dl-nucleotide", + ".spc": "chemical/x-galactic-spc", + ".inp": "chemical/x-gamess-input", + ".fch": "chemical/x-gaussian-checkpoint", + ".cub": "chemical/x-gaussian-cube", + ".gau": "chemical/x-gaussian-input", + ".gal": "chemical/x-gaussian-log", + ".gcg": "chemical/x-gcg8-sequence", + ".gen": "chemical/x-genbank", + ".hin": "chemical/x-hin", + ".istr": "chemical/x-isostar", + ".jdx": "chemical/x-jcamp-dx", + ".kin": "chemical/x-kinemage", + ".mcm": "chemical/x-macmolecule", + ".mmod": "chemical/x-macromodel-input", + ".mol": "chemical/x-mdl-molfile", + ".rd": "chemical/x-mdl-rdfile", + ".rxn": "chemical/x-mdl-rxnfile", + ".sd": "chemical/x-mdl-sdfile", + ".tgf": "chemical/x-mdl-tgf", + ".mcif": "chemical/x-mmcif", + ".b": "chemical/x-molconn-Z", + ".gpt": "chemical/x-mopac-graph", + ".mop": "chemical/x-mopac-input", + ".moo": "chemical/x-mopac-out", + ".mvb": "chemical/x-mopac-vib", + ".asn": "chemical/x-ncbi-asn1", + ".prt": "chemical/x-ncbi-asn1-ascii", + ".val": "chemical/x-ncbi-asn1-binary", + ".ros": "chemical/x-rosdal", + ".sw": "chemical/x-swissprot", + ".vms": "chemical/x-vamas-iso14976", + ".vmd": "chemical/x-vmd", + ".xtel": "chemical/x-xtel", + ".xyz": "chemical/x-xyz", + ".ttc": "font/collection", + ".otf": "font/otf", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".exr": "image/aces", + ".apng": "image/apng", + ".avci": "image/avci", + ".avcs": "image/avcs", + ".avif": "image/avif", + ".bmp": "image/bmp", + ".cgm": "image/cgm", + ".drle": "image/dicom-rle", + ".dpx": "image/dpx", + ".emf": "image/emf", + ".fits": "image/fits", + ".gif": "image/gif", + ".heic": "image/heic", + ".heics": "image/heic-sequence", + ".heif": "image/heif", + ".heifs": "image/heif-sequence", + ".hej2": "image/hej2k", + ".hsj2": "image/hsj2", + ".ief": "image/ief", + ".j2c": "image/j2c", + ".jls": "image/jls", + ".jp2": "image/jp2", + ".jpeg": "image/jpeg", + ".jph": "image/jph", + ".jhc": "image/jphc", + ".jpm": "image/jpm", + ".jpx": "image/jpx", + ".jxl": "image/jxl", + ".jxr": "image/jxr", + ".jxra": "image/jxrA", + ".jxrs": "image/jxrS", + ".jxs": "image/jxs", + ".jxsc": "image/jxsc", + ".jxsi": "image/jxsi", + ".jxss": "image/jxss", + ".ktx": "image/ktx", + ".ktx2": "image/ktx2", + ".png": "image/png", + ".btif": "image/prs.btif", + ".pti": "image/prs.pti", + ".svg": "image/svg+xml", + ".tiff": "image/tiff", + ".tfx": "image/tiff-fx", + ".psd": "image/vnd.adobe.photoshop", + ".azv": "image/vnd.airzip.accelerator.azv", + ".uvi": "image/vnd.dece.graphic", + ".djvu": "image/vnd.djvu", + ".dwg": "image/vnd.dwg", + ".dxf": "image/vnd.dxf", + ".fbs": "image/vnd.fastbidsheet", + ".fpx": "image/vnd.fpx", + ".fst": "image/vnd.fst", + ".mmr": "image/vnd.fujixerox.edmics-mmr", + ".rlc": "image/vnd.fujixerox.edmics-rlc", + ".PGB": "image/vnd.globalgraphics.pgb", + ".ico": "image/vnd.microsoft.icon", + ".mdi": "image/vnd.ms-modi", + ".b16": "image/vnd.pco.b16", + ".hdr": "image/vnd.radiance", + ".spng": "image/vnd.sealed.png", + ".sgif": "image/vnd.sealedmedia.softseal.gif", + ".sjpg": "image/vnd.sealedmedia.softseal.jpg", + ".tap": "image/vnd.tencent.tap", + ".vtf": "image/vnd.valve.source.texture", + ".wbmp": "image/vnd.wap.wbmp", + ".xif": "image/vnd.xiff", + ".pcx": "image/vnd.zbrush.pcx", + ".webp": "image/webp", + ".wmf": "image/wmf", + ".cr2": "image/x-canon-cr2", + ".crw": "image/x-canon-crw", + ".ras": "image/x-cmu-raster", + ".cdr": "image/x-coreldraw", + ".pat": "image/x-coreldrawpattern", + ".cdt": "image/x-coreldrawtemplate", + ".erf": "image/x-epson-erf", + ".art": "image/x-jg", + ".jng": "image/x-jng", + ".nef": "image/x-nikon-nef", + ".orf": "image/x-olympus-orf", + ".pnm": "image/x-portable-anymap", + ".pbm": "image/x-portable-bitmap", + ".pgm": "image/x-portable-graymap", + ".ppm": "image/x-portable-pixmap", + ".rgb": "image/x-rgb", + ".xbm": "image/x-xbitmap", + ".xcf": "image/x-xcf", + ".xpm": "image/x-xpixmap", + ".xwd": "image/x-xwindowdump", + ".u8msg": "message/global", + ".u8dsn": "message/global-delivery-status", + ".u8mdn": "message/global-disposition-notification", + ".u8hdr": "message/global-headers", + ".eml": "message/rfc822", + ".gltf": "model/gltf+json", + ".glb": "model/gltf-binary", + ".igs": "model/iges", + ".jt": "model/JT", + ".msh": "model/mesh", + ".mtl": "model/mtl", + ".obj": "model/obj", + ".prc": "model/prc", + ".stp": "model/step", + ".stpx": "model/step+xml", + ".stpz": "model/step+zip", + ".stpxz": "model/step-xml+zip", + ".stl": "model/stl", + ".u3d": "model/u3d", + ".bary": "model/vnd.bary", + ".cld": "model/vnd.cld", + ".dae": "model/vnd.collada+xml", + ".dwf": "model/vnd.dwf", + ".gdl": "model/vnd.gdl", + ".gtw": "model/vnd.gtw", + ".moml": "model/vnd.moml+xml", + ".mts": "model/vnd.mts", + ".ogex": "model/vnd.opengex", + ".x_b": "model/vnd.parasolid.transmit.binary", + ".x_t": "model/vnd.parasolid.transmit.text", + ".pyox": "model/vnd.pytha.pyox", + ".vds": "model/vnd.sap.vds", + ".usda": "model/vnd.usda", + ".usdz": "model/vnd.usdz+zip", + ".bsp": "model/vnd.valve.source.compiled-map", + ".vtu": "model/vnd.vtu", + ".wrl": "model/vrml", + ".x3db": "model/x3d+fastinfoset", + ".x3d": "model/x3d+xml", + ".x3dv": "model/x3d-vrml", + ".bmed": "multipart/vnd.bint.med-plus", + ".vpm": "multipart/voice-message", + "ahk": "text/autohotkey", + "au3": "text/autohotkey", + ".appcache": "text/cache-manifest", + ".ics": "text/calendar", + "cof": "text/coffeescript", + "coffee": "text/coffeescript", + "coffeescript": "text/coffeescript", + ".CQL": "text/cql", + ".css": "text/css", + ".csv": "text/csv", + ".csvs": "text/csv-schema", + ".soa": "text/dns", + ".gff3": "text/gff3", + ".htm": "text/html", + ".html": "text/html", + ".cjs": "text/javascript", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".cnd": "text/jcr-cnd", + ".jsx": "text/jsx", + ".less": "text/less", + ".md": "text/markdown", + ".m": "text/mips", + ".miz": "text/mizar", + ".n3": "text/n3", + ".txt": "text/plain", + ".provn": "text/provenance-notation", + ".rst": "text/prs.fallenstein.rst", + ".tag": "text/prs.lines.tag", + ".rs": "text/x-rust", + ".sass": "text/scss", + ".scss": "text/scss", + ".sgml": "text/SGML", + ".shaclc": "text/shaclc", + ".shex": "text/shex", + ".spdx": "text/spdx", + ".tsv": "text/tab-separated-values", + ".tm": "text/texmacs", + ".t": "text/troff", + ".tsx": "text/typescript-jsx", + ".ttl": "text/turtle", + ".ts": "text/typescript", + ".uris": "text/uri-list", + ".vcf": "text/vcard", + ".a": "text/vnd.a", + ".abc": "text/vnd.abc", + ".ascii": "text/vnd.ascii-art", + ".curl": "text/vnd.curl", + ".copyright": "text/vnd.debian.copyright", + ".dms": "text/vnd.DMClientScript", + ".jtd": "text/vnd.esmertec.theme-descriptor", + ".VFK": "text/vnd.exchangeable", + ".ged": "text/vnd.familysearch.gedcom", + ".flt": "text/vnd.ficlab.flt", + ".fly": "text/vnd.fly", + ".flx": "text/vnd.fmi.flexstor", + ".gv": "text/vnd.graphviz", + ".hans": "text/vnd.hans", + ".hgl": "text/vnd.hgl", + ".3dml": "text/vnd.in3d.3dml", + ".spot": "text/vnd.in3d.spot", + ".mpf": "text/vnd.ms-mediapackage", + ".ccc": "text/vnd.net2phone.commcenter.command", + ".mc2": "text/vnd.senx.warpscript", + ".sos": "text/vnd.sosi", + ".jad": "text/vnd.sun.j2me.app-descriptor", + ".si": "text/vnd.wap.si", + ".sl": "text/vnd.wap.sl", + ".wml": "text/vnd.wap.wml", + ".wmls": "text/vnd.wap.wmlscript", + ".vtt": "text/vtt", + ".wgsl": "text/wgsl", + ".cls": "text/x-apex", + ".asp": "text/x-aspx", + ".aspx": "text/x-aspx", + ".bib": "text/x-bibtex", + ".boo": "text/x-boo", + ".h++": "text/x-c++hdr", + ".cc": "text/x-c++src", + ".cpp": "text/x-c++src", + ".c++": "text/x-c++src", + ".h": "text/x-chdr", + ".clojure": "text/x-clojure", + ".htc": "text/x-component", + ".csh": "text/x-csh", + ".cshtml": "text/x-cshtml", + ".c": "text/x-csrc", + ".dart": "text/x-dart", + ".diff": "text/x-diff", + ".d": "text/x-dsrc", + ".ex": "text/x-elixir", + ".elm": "text/x-elm", + ".erl": "text/x-erlang", + ".go": "text/x-go", + ".handlebars": "text/x-handlebars-template", + ".hbs": "text/x-handlebars-template", + ".hs": "text/x-haskell", + ".java": "text/x-java", + ".jl": "text/x-julia", + ".kt": "text/x-kotlin", + ".kts": "text/x-kotlin", + ".ly": "text/x-lilypond", + ".cl": "text/x-common-lisp", + ".cs": "text/x-c#src", + ".l": "text/x-common-lisp", + ".lisp": "text/x-common-lisp", + ".lsp": "text/x-common-lisp", + ".lua": "text/x-lua", + ".lhs": "text/x-literate-haskell", + ".moc": "text/x-moc", + ".p": "text/x-pascal", + ".pas": "text/x-pascal", + ".pp": "text/x-pascal", + ".gcd": "text/x-pcs-gcd", + ".pl": "text/x-perl", + ".py": "text/x-python", + ".r": "text/x-r", + ".sbt": "text/x-scala", + ".sc": "text/x-scala", + ".scala": "text/x-scala", + ".scm": "text/x-scheme", + ".etx": "text/x-setext", + ".sfv": "text/x-sfv", + ".swift": "text/swift", + ".tcl": "text/x-tcl", + ".tex": "text/x-tex", + ".twig": "text/x-twig", + ".vcs": "text/x-vcalendar", + ".axv": "video/annodex", + ".dif": "video/dv", + ".fli": "video/fli", + ".gl": "video/gl", + ".m4s": "video/iso.segment", + ".mj2": "video/mj2", + ".m4v": "video/mp4", + ".mkv": "video/mp4", + ".mov": "video/mp4", + ".mp4": "video/mp4", + ".mpeg": "video/mpeg", + ".mpg": "video/mpeg", + ".ogv": "video/ogg", + ".qt": "video/quicktime", + ".uvh": "video/vnd.dece.hd", + ".uvm": "video/vnd.dece.mobile", + ".uvu": "video/vnd.dece.mp4", + ".uvp": "video/vnd.dece.pd", + ".uvs": "video/vnd.dece.sd", + ".uvv": "video/vnd.dece.video", + ".dvb": "video/vnd.dvb.file", + ".fvt": "video/vnd.fvt", + ".mxu": "video/vnd.mpegurl", + ".pyv": "video/vnd.ms-playready.media.pyv", + ".nim": "video/vnd.nokia.interleaved-multimedia", + ".bik": "video/vnd.radgamettools.bink", + ".smk": "video/vnd.radgamettools.smacker", + ".smpg": "video/vnd.sealed.mpeg1", + ".s14": "video/vnd.sealed.mpeg4", + ".sswf": "video/vnd.sealed.swf", + ".smov": "video/vnd.sealedmedia.softseal.mov", + ".viv": "video/vnd.vivo", + ".yt": "video/vnd.youtube.yt", + ".webm": "video/webm", + ".flv": "video/x-flv", + ".lsf": "video/x-la-asf", + ".mpv": "video/x-matroska", + ".mng": "video/x-mng", + ".wm": "video/x-ms-wm", + ".wmv": "video/x-ms-wmv", + ".wmx": "video/x-ms-wmx", + ".wvx": "video/x-ms-wvx", + ".avi": "video/x-msvideo", + ".movie": "video/x-sgi-movie", +} diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go new file mode 100644 index 000000000..23540d168 --- /dev/null +++ b/pkg/util/utilfn/utilfn.go @@ -0,0 +1,919 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +import ( + "bytes" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "math" + mathrand "math/rand" + "mime" + "net/http" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "syscall" + "text/template" + "unicode/utf8" + + "github.com/mitchellh/mapstructure" +) + +var HexDigits = []byte{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'} + +func GetStrArr(v interface{}, field string) []string { + if v == nil { + return nil + } + m, ok := v.(map[string]interface{}) + if !ok { + return nil + } + fieldVal := m[field] + if fieldVal == nil { + return nil + } + iarr, ok := fieldVal.([]interface{}) + if !ok { + return nil + } + var sarr []string + for _, iv := range iarr { + if sv, ok := iv.(string); ok { + sarr = append(sarr, sv) + } + } + return sarr +} + +func GetBool(v interface{}, field string) bool { + if v == nil { + return false + } + m, ok := v.(map[string]interface{}) + if !ok { + return false + } + fieldVal := m[field] + if fieldVal == nil { + return false + } + bval, ok := fieldVal.(bool) + if !ok { + return false + } + return bval +} + +var needsQuoteRe = regexp.MustCompile(`[^\w@%:,./=+-]`) + +// minimum maxlen=6 +func ShellQuote(val string, forceQuote bool, maxLen int) string { + if maxLen < 6 { + maxLen = 6 + } + rtn := val + if needsQuoteRe.MatchString(val) { + rtn = "'" + strings.ReplaceAll(val, "'", `'"'"'`) + "'" + } + if strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") { + if len(rtn) > maxLen { + return rtn[0:maxLen-4] + "..." + rtn[0:1] + } + return rtn + } + if forceQuote { + if len(rtn) > maxLen-2 { + return "\"" + rtn[0:maxLen-5] + "...\"" + } + return "\"" + rtn + "\"" + } else { + if len(rtn) > maxLen { + return rtn[0:maxLen-3] + "..." + } + return rtn + } +} + +func EllipsisStr(s string, maxLen int) string { + if maxLen < 4 { + maxLen = 4 + } + if len(s) > maxLen { + return s[0:maxLen-3] + "..." + } + return s +} + +func LongestPrefix(root string, strs []string) string { + if len(strs) == 0 { + return root + } + if len(strs) == 1 { + comp := strs[0] + if len(comp) >= len(root) && strings.HasPrefix(comp, root) { + if strings.HasSuffix(comp, "/") { + return strs[0] + } + return strs[0] + } + } + lcp := strs[0] + for i := 1; i < len(strs); i++ { + s := strs[i] + for j := 0; j < len(lcp); j++ { + if j >= len(s) || lcp[j] != s[j] { + lcp = lcp[0:j] + break + } + } + } + if len(lcp) < len(root) || !strings.HasPrefix(lcp, root) { + return root + } + return lcp +} + +func ContainsStr(strs []string, test string) bool { + for _, s := range strs { + if s == test { + return true + } + } + return false +} + +func IsPrefix(strs []string, test string) bool { + for _, s := range strs { + if len(s) > len(test) && strings.HasPrefix(s, test) { + return true + } + } + return false +} + +// sentinel value for StrWithPos.Pos to indicate no position +const NoStrPos = -1 + +type StrWithPos struct { + Str string `json:"str"` + Pos int `json:"pos"` // this is a 'rune' position (not a byte position) +} + +func (sp StrWithPos) String() string { + return strWithCursor(sp.Str, sp.Pos) +} + +func ParseToSP(s string) StrWithPos { + idx := strings.Index(s, "[*]") + if idx == -1 { + return StrWithPos{Str: s, Pos: NoStrPos} + } + return StrWithPos{Str: s[0:idx] + s[idx+3:], Pos: utf8.RuneCountInString(s[0:idx])} +} + +func strWithCursor(str string, pos int) string { + if pos == NoStrPos { + return str + } + if pos < 0 { + // invalid position + return "[*]_" + str + } + if pos > len(str) { + // invalid position + return str + "_[*]" + } + if pos == len(str) { + return str + "[*]" + } + var rtn []rune + for _, ch := range str { + if len(rtn) == pos { + rtn = append(rtn, '[', '*', ']') + } + rtn = append(rtn, ch) + } + return string(rtn) +} + +func (sp StrWithPos) Prepend(str string) StrWithPos { + return StrWithPos{Str: str + sp.Str, Pos: utf8.RuneCountInString(str) + sp.Pos} +} + +func (sp StrWithPos) Append(str string) StrWithPos { + return StrWithPos{Str: sp.Str + str, Pos: sp.Pos} +} + +// returns base64 hash of data +func Sha1Hash(data []byte) string { + hvalRaw := sha1.Sum(data) + hval := base64.StdEncoding.EncodeToString(hvalRaw[:]) + return hval +} + +func ChunkSlice[T any](s []T, chunkSize int) [][]T { + var rtn [][]T + for len(rtn) > 0 { + if len(s) <= chunkSize { + rtn = append(rtn, s) + break + } + rtn = append(rtn, s[:chunkSize]) + s = s[chunkSize:] + } + return rtn +} + +var ErrOverflow = errors.New("integer overflow") + +// Add two int values, returning an error if the result overflows. +func AddInt(left, right int) (int, error) { + if right > 0 { + if left > math.MaxInt-right { + return 0, ErrOverflow + } + } else { + if left < math.MinInt-right { + return 0, ErrOverflow + } + } + return left + right, nil +} + +// Add a slice of ints, returning an error if the result overflows. +func AddIntSlice(vals ...int) (int, error) { + var rtn int + for _, v := range vals { + var err error + rtn, err = AddInt(rtn, v) + if err != nil { + return 0, err + } + } + return rtn, nil +} + +func StrsEqual(s1arr []string, s2arr []string) bool { + if len(s1arr) != len(s2arr) { + return false + } + for i, s1 := range s1arr { + s2 := s2arr[i] + if s1 != s2 { + return false + } + } + return true +} + +func StrMapsEqual(m1 map[string]string, m2 map[string]string) bool { + if len(m1) != len(m2) { + return false + } + for key, val1 := range m1 { + val2, found := m2[key] + if !found || val1 != val2 { + return false + } + } + for key := range m2 { + _, found := m1[key] + if !found { + return false + } + } + return true +} + +func ByteMapsEqual(m1 map[string][]byte, m2 map[string][]byte) bool { + if len(m1) != len(m2) { + return false + } + for key, val1 := range m1 { + val2, found := m2[key] + if !found || !bytes.Equal(val1, val2) { + return false + } + } + for key := range m2 { + _, found := m1[key] + if !found { + return false + } + } + return true +} + +func GetOrderedStringerMapKeys[K interface { + comparable + fmt.Stringer +}, V any](m map[K]V) []K { + keyStrMap := make(map[K]string) + keys := make([]K, 0, len(m)) + for key := range m { + keys = append(keys, key) + keyStrMap[key] = key.String() + } + sort.Slice(keys, func(i, j int) bool { + return keyStrMap[keys[i]] < keyStrMap[keys[j]] + }) + return keys +} + +func GetOrderedMapKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +const ( + nullEncodeEscByte = '\\' + nullEncodeSepByte = '|' + nullEncodeEqByte = '=' + nullEncodeZeroByteEsc = '0' + nullEncodeEscByteEsc = '\\' + nullEncodeSepByteEsc = 's' + nullEncodeEqByteEsc = 'e' +) + +func EncodeStringMap(m map[string]string) []byte { + var buf bytes.Buffer + for idx, key := range GetOrderedMapKeys(m) { + val := m[key] + buf.Write(NullEncodeStr(key)) + buf.WriteByte(nullEncodeEqByte) + buf.Write(NullEncodeStr(val)) + if idx < len(m)-1 { + buf.WriteByte(nullEncodeSepByte) + } + } + return buf.Bytes() +} + +func DecodeStringMap(barr []byte) (map[string]string, error) { + if len(barr) == 0 { + return nil, nil + } + var rtn = make(map[string]string) + for _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) { + keyVal := bytes.SplitN(b, []byte{nullEncodeEqByte}, 2) + if len(keyVal) != 2 { + return nil, fmt.Errorf("invalid null encoding: %s", string(b)) + } + key, err := NullDecodeStr(keyVal[0]) + if err != nil { + return nil, err + } + val, err := NullDecodeStr(keyVal[1]) + if err != nil { + return nil, err + } + rtn[key] = val + } + return rtn, nil +} + +func EncodeStringArray(arr []string) []byte { + var buf bytes.Buffer + for idx, s := range arr { + buf.Write(NullEncodeStr(s)) + if idx < len(arr)-1 { + buf.WriteByte(nullEncodeSepByte) + } + } + return buf.Bytes() +} + +func DecodeStringArray(barr []byte) ([]string, error) { + if len(barr) == 0 { + return nil, nil + } + var rtn []string + for _, b := range bytes.Split(barr, []byte{nullEncodeSepByte}) { + s, err := NullDecodeStr(b) + if err != nil { + return nil, err + } + rtn = append(rtn, s) + } + return rtn, nil +} + +func EncodedStringArrayHasFirstVal(encoded []byte, firstKey string) bool { + firstKeyBytes := NullEncodeStr(firstKey) + if !bytes.HasPrefix(encoded, firstKeyBytes) { + return false + } + if len(encoded) == len(firstKeyBytes) || encoded[len(firstKeyBytes)] == nullEncodeSepByte { + return true + } + return false +} + +// on encoding error returns "" +// this is used to perform logic on first value without decoding the entire array +func EncodedStringArrayGetFirstVal(encoded []byte) string { + sepIdx := bytes.IndexByte(encoded, nullEncodeSepByte) + if sepIdx == -1 { + str, _ := NullDecodeStr(encoded) + return str + } + str, _ := NullDecodeStr(encoded[0:sepIdx]) + return str +} + +// encodes a string, removing null/zero bytes (and separators '|') +// a zero byte is encoded as "\0", a '\' is encoded as "\\", sep is encoded as "\s" +// allows for easy double splitting (first on \x00, and next on "|") +func NullEncodeStr(s string) []byte { + strBytes := []byte(s) + if bytes.IndexByte(strBytes, 0) == -1 && + bytes.IndexByte(strBytes, nullEncodeEscByte) == -1 && + bytes.IndexByte(strBytes, nullEncodeSepByte) == -1 && + bytes.IndexByte(strBytes, nullEncodeEqByte) == -1 { + return strBytes + } + var rtn []byte + for _, b := range strBytes { + if b == 0 { + rtn = append(rtn, nullEncodeEscByte, nullEncodeZeroByteEsc) + } else if b == nullEncodeEscByte { + rtn = append(rtn, nullEncodeEscByte, nullEncodeEscByteEsc) + } else if b == nullEncodeSepByte { + rtn = append(rtn, nullEncodeEscByte, nullEncodeSepByteEsc) + } else if b == nullEncodeEqByte { + rtn = append(rtn, nullEncodeEscByte, nullEncodeEqByteEsc) + } else { + rtn = append(rtn, b) + } + } + return rtn +} + +func NullDecodeStr(barr []byte) (string, error) { + if bytes.IndexByte(barr, nullEncodeEscByte) == -1 { + return string(barr), nil + } + var rtn []byte + for i := 0; i < len(barr); i++ { + curByte := barr[i] + if curByte == nullEncodeEscByte { + i++ + nextByte := barr[i] + if nextByte == nullEncodeZeroByteEsc { + rtn = append(rtn, 0) + } else if nextByte == nullEncodeEscByteEsc { + rtn = append(rtn, nullEncodeEscByte) + } else if nextByte == nullEncodeSepByteEsc { + rtn = append(rtn, nullEncodeSepByte) + } else if nextByte == nullEncodeEqByteEsc { + rtn = append(rtn, nullEncodeEqByte) + } else { + // invalid encoding + return "", fmt.Errorf("invalid null encoding: %d", nextByte) + } + } else { + rtn = append(rtn, curByte) + } + } + return string(rtn), nil +} + +func SortStringRunes(s string) string { + runes := []rune(s) + sort.Slice(runes, func(i, j int) bool { + return runes[i] < runes[j] + }) + return string(runes) +} + +// will overwrite m1 with m2's values +func CombineMaps[V any](m1 map[string]V, m2 map[string]V) { + for key, val := range m2 { + m1[key] = val + } +} + +// returns hex escaped string (\xNN for each byte) +func ShellHexEscape(s string) string { + var rtn []byte + for _, ch := range []byte(s) { + rtn = append(rtn, []byte(fmt.Sprintf("\\x%02x", ch))...) + } + return string(rtn) +} + +func GetMapKeys[K comparable, V any](m map[K]V) []K { + var rtn []K + for key := range m { + rtn = append(rtn, key) + } + return rtn +} + +// combines string arrays and removes duplicates (returns a new array) +func CombineStrArrays(sarr1 []string, sarr2 []string) []string { + var rtn []string + m := make(map[string]struct{}) + for _, s := range sarr1 { + if _, found := m[s]; found { + continue + } + m[s] = struct{}{} + rtn = append(rtn, s) + } + for _, s := range sarr2 { + if _, found := m[s]; found { + continue + } + m[s] = struct{}{} + rtn = append(rtn, s) + } + return rtn +} + +func QuickJson(v interface{}) string { + barr, _ := json.Marshal(v) + return string(barr) +} + +func QuickParseJson[T any](s string) T { + var v T + _ = json.Unmarshal([]byte(s), &v) + return v +} + +func StrArrayToMap(sarr []string) map[string]bool { + m := make(map[string]bool) + for _, s := range sarr { + m[s] = true + } + return m +} + +func AppendNonZeroRandomBytes(b []byte, randLen int) []byte { + if randLen <= 0 { + return b + } + numAdded := 0 + for numAdded < randLen { + rn := mathrand.Intn(256) + if rn > 0 && rn < 256 { // exclude 0, also helps to suppress security warning to have a guard here + b = append(b, byte(rn)) + numAdded++ + } + } + return b +} + +// returns (isEOF, error) +func CopyWithEndBytes(outputBuf *bytes.Buffer, reader io.Reader, endBytes []byte) (bool, error) { + buf := make([]byte, 4096) + for { + n, err := reader.Read(buf) + if n > 0 { + outputBuf.Write(buf[:n]) + obytes := outputBuf.Bytes() + if bytes.HasSuffix(obytes, endBytes) { + outputBuf.Truncate(len(obytes) - len(endBytes)) + return (err == io.EOF), nil + } + } + if err == io.EOF { + return true, nil + } + if err != nil { + return false, err + } + } +} + +// does *not* close outputCh on EOF or error +func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error { + buf := make([]byte, 4096) + for { + n, err := reader.Read(buf) + if n > 0 { + // copy so client can use []byte without it being overwritten + bufCopy := make([]byte, n) + copy(bufCopy, buf[:n]) + outputCh <- bufCopy + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } +} + +// on error just returns "" +// does not return "application/octet-stream" as this is considered a detection failure +// can pass an existing fileInfo to avoid re-statting the file +func DetectMimeType(path string, fileInfo fs.FileInfo) string { + if fileInfo == nil { + statRtn, err := os.Stat(path) + if err != nil { + return "" + } + fileInfo = statRtn + } + if fileInfo.IsDir() { + return "directory" + } + if fileInfo.Mode()&os.ModeNamedPipe == os.ModeNamedPipe { + return "pipe" + } + charDevice := os.ModeDevice | os.ModeCharDevice + if fileInfo.Mode()&charDevice == charDevice { + return "character-special" + } + if fileInfo.Mode()&os.ModeDevice == os.ModeDevice { + return "block-special" + } + ext := filepath.Ext(path) + if mimeType, ok := StaticMimeTypeMap[ext]; ok { + return mimeType + } + if mimeType := mime.TypeByExtension(ext); mimeType != "" { + return mimeType + } + fd, err := os.Open(path) + if err != nil { + return "" + } + defer fd.Close() + buf := make([]byte, 512) + // ignore the error (EOF / UnexpectedEOF is fine, just process how much we got back) + n, _ := io.ReadAtLeast(fd, buf, 512) + if n == 0 { + return "" + } + buf = buf[:n] + rtn := http.DetectContentType(buf) + if rtn == "application/octet-stream" { + return "" + } + return rtn +} + +func GetCmdExitCode(cmd *exec.Cmd, err error) int { + if cmd == nil || cmd.ProcessState == nil { + return GetExitCode(err) + } + status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus) + if !ok { + return cmd.ProcessState.ExitCode() + } + signaled := status.Signaled() + if signaled { + signal := status.Signal() + return 128 + int(signal) + } + exitStatus := status.ExitStatus() + return exitStatus +} + +func GetExitCode(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } else { + return -1 + } +} + +func GetFirstLine(s string) string { + idx := strings.Index(s, "\n") + if idx == -1 { + return s + } + return s[0:idx] +} + +func JsonMapToStruct(m map[string]any, v interface{}) error { + barr, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(barr, v) +} + +func StructToJsonMap(v interface{}) (map[string]any, error) { + barr, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]any + err = json.Unmarshal(barr, &m) + if err != nil { + return nil, err + } + return m, nil +} + +func IndentString(indent string, str string) string { + splitArr := strings.Split(str, "\n") + var rtn strings.Builder + for _, line := range splitArr { + if line == "" { + rtn.WriteByte('\n') + continue + } + rtn.WriteString(indent) + rtn.WriteString(line) + rtn.WriteByte('\n') + } + return rtn.String() +} + +func ReUnmarshal(out any, in any) error { + barr, err := json.Marshal(in) + if err != nil { + return err + } + return json.Unmarshal(barr, out) +} + +// does a mapstructure using "json" tags +func DoMapStructure(out any, input any) error { + dconfig := &mapstructure.DecoderConfig{ + Result: out, + TagName: "json", + } + decoder, err := mapstructure.NewDecoder(dconfig) + if err != nil { + return err + } + return decoder.Decode(input) +} + +func SliceIdx[T comparable](arr []T, elem T) int { + for idx, e := range arr { + if e == elem { + return idx + } + } + return -1 +} + +// removes an element from a slice and modifies the original slice (the backing elements) +// if it removes the last element from the slice, it will return nil so we free the original slice's backing memory +func RemoveElemFromSlice[T comparable](arr []T, elem T) []T { + idx := SliceIdx(arr, elem) + if idx == -1 { + return arr + } + if len(arr) == 1 { + return nil + } + return append(arr[:idx], arr[idx+1:]...) +} + +func AddElemToSliceUniq[T comparable](arr []T, elem T) []T { + if SliceIdx(arr, elem) != -1 { + return arr + } + return append(arr, elem) +} + +func MoveSliceIdxToFront[T any](arr []T, idx int) []T { + // create and return a new slice with idx moved to the front + if idx == 0 || idx >= len(arr) { + // make a copy still + return append([]T(nil), arr...) + } + rtn := make([]T, 0, len(arr)) + rtn = append(rtn, arr[idx]) + rtn = append(rtn, arr[0:idx]...) + rtn = append(rtn, arr[idx+1:]...) + return rtn +} + +// matches a delimited string with a pattern string +// the pattern string can contain "*" to match a single part, or "**" to match the rest of the string +// note that "**" may only appear at the end of the string +func StarMatchString(pattern string, s string, delimiter string) bool { + patternParts := strings.Split(pattern, delimiter) + stringParts := strings.Split(s, delimiter) + pLen, sLen := len(patternParts), len(stringParts) + + for i := 0; i < pLen; i++ { + if patternParts[i] == "**" { + // '**' must be at the end to be valid + return i == pLen-1 + } + if i >= sLen { + // If string is exhausted but pattern is not + return false + } + if patternParts[i] != "*" && patternParts[i] != stringParts[i] { + // If current parts don't match and pattern part is not '*' + return false + } + } + // Check if both pattern and string are fully matched + return pLen == sLen +} + +func MergeStrMaps[T any](m1 map[string]T, m2 map[string]T) map[string]T { + rtn := make(map[string]T) + for key, val := range m1 { + rtn[key] = val + } + for key, val := range m2 { + rtn[key] = val + } + return rtn +} + +func AtomicRenameCopy(dstPath string, srcPath string, perms os.FileMode) error { + // first copy the file to dstPath.new, then rename into place + srcFd, err := os.Open(srcPath) + if err != nil { + return err + } + defer srcFd.Close() + tempName := dstPath + ".new" + dstFd, err := os.Create(tempName) + if err != nil { + return err + } + defer dstFd.Close() + _, err = io.Copy(dstFd, srcFd) + if err != nil { + return err + } + err = dstFd.Close() + if err != nil { + return err + } + err = os.Chmod(tempName, perms) + if err != nil { + return err + } + err = os.Rename(tempName, dstPath) + if err != nil { + return err + } + return nil +} + +func AtoiNoErr(str string) int { + val, err := strconv.Atoi(str) + if err != nil { + return 0 + } + return val +} + +func WriteTemplateToFile(fileName string, templateText string, vars map[string]string) error { + outBuffer := &bytes.Buffer{} + template.Must(template.New("").Parse(templateText)).Execute(outBuffer, vars) + return os.WriteFile(fileName, outBuffer.Bytes(), 0644) +} + +// every byte is 4-bits of randomness +func RandomHexString(numHexDigits int) (string, error) { + numBytes := (numHexDigits + 1) / 2 // Calculate the number of bytes needed + bytes := make([]byte, numBytes) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + hexStr := hex.EncodeToString(bytes) + return hexStr[:numHexDigits], nil // Return the exact number of hex digits +} + +func GetJsonTag(field reflect.StructField) string { + jsonTag := field.Tag.Get("json") + if jsonTag == "" { + return "" + } + commaIdx := strings.Index(jsonTag, ",") + if commaIdx != -1 { + jsonTag = jsonTag[:commaIdx] + } + return jsonTag +} diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go new file mode 100644 index 000000000..4cab277b7 --- /dev/null +++ b/pkg/vdom/vdom.go @@ -0,0 +1,270 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "unicode" +) + +// ReactNode types = nil | string | Elem + +const TextTag = "#text" +const FragmentTag = "#fragment" + +const ChildrenPropKey = "children" +const KeyPropKey = "key" + +// doubles as VDOM structure +type Elem struct { + Id string `json:"id,omitempty"` // used for vdom + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []Elem `json:"children,omitempty"` + Text string `json:"text,omitempty"` +} + +type VDomRefType struct { + RefId string `json:"#ref"` + Current any `json:"current"` +} + +// can be used to set preventDefault/stopPropagation +type VDomFuncType struct { + Fn any `json:"-"` // the actual function to call (called via reflection) + FuncType string `json:"#func"` + StopPropagation bool `json:"#stopPropagation,omitempty"` + PreventDefault bool `json:"#preventDefault,omitempty"` + Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture" +} + +// generic hook structure +type Hook struct { + Init bool // is initialized + Idx int // index in the hook array + Fn func() func() // for useEffect + UnmountFn func() // for useEffect + Val any // for useState, useMemo, useRef + Deps []any +} + +type CFunc = func(ctx context.Context, props map[string]any) any + +func (e *Elem) Key() string { + keyVal, ok := e.Props[KeyPropKey] + if !ok { + return "" + } + keyStr, ok := keyVal.(string) + if ok { + return keyStr + } + return "" +} + +func TextElem(text string) Elem { + return Elem{Tag: TextTag, Text: text} +} + +func mergeProps(props *map[string]any, newProps map[string]any) { + if *props == nil { + *props = make(map[string]any) + } + for k, v := range newProps { + if v == nil { + delete(*props, k) + continue + } + (*props)[k] = v + } +} + +func E(tag string, parts ...any) *Elem { + rtn := &Elem{Tag: tag} + for _, part := range parts { + if part == nil { + continue + } + props, ok := part.(map[string]any) + if ok { + mergeProps(&rtn.Props, props) + continue + } + elems := partToElems(part) + rtn.Children = append(rtn.Children, elems...) + } + return rtn +} + +func P(propName string, propVal any) map[string]any { + return map[string]any{propName: propVal} +} + +func getHookFromCtx(ctx context.Context) (*VDomContextVal, *Hook) { + vc := getRenderContext(ctx) + if vc == nil { + panic("UseState must be called within a component (no context)") + } + if vc.Comp == nil { + panic("UseState must be called within a component (vc.Comp is nil)") + } + for len(vc.Comp.Hooks) <= vc.HookIdx { + vc.Comp.Hooks = append(vc.Comp.Hooks, &Hook{Idx: len(vc.Comp.Hooks)}) + } + hookVal := vc.Comp.Hooks[vc.HookIdx] + vc.HookIdx++ + return vc, hookVal +} + +func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + hookVal.Val = initialVal + } + var rtnVal T + rtnVal, ok := hookVal.Val.(T) + if !ok { + panic("UseState hook value is not a state (possible out of order or conditional hooks)") + } + setVal := func(newVal T) { + hookVal.Val = newVal + vc.Root.AddRenderWork(vc.Comp.Id) + } + return rtnVal, setVal +} + +func UseRef(ctx context.Context, initialVal any) *VDomRefType { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx) + hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal} + } + refVal, ok := hookVal.Val.(*VDomRefType) + if !ok { + panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") + } + return refVal +} + +func UseId(ctx context.Context) string { + vc := getRenderContext(ctx) + if vc == nil { + panic("UseId must be called within a component (no context)") + } + return vc.Comp.Id +} + +func depsEqual(deps1 []any, deps2 []any) bool { + if len(deps1) != len(deps2) { + return false + } + for i := range deps1 { + if deps1[i] != deps2[i] { + return false + } + } + return true +} + +func UseEffect(ctx context.Context, fn func() func(), deps []any) { + // note UseEffect never actually runs anything, it just queues the effect to run later + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + hookVal.Fn = fn + hookVal.Deps = deps + vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + return + } + if depsEqual(hookVal.Deps, deps) { + return + } + hookVal.Fn = fn + hookVal.Deps = deps + vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) +} + +func numToString[T any](value T) (string, bool) { + switch v := any(value).(type) { + case int, int8, int16, int32, int64: + return strconv.FormatInt(v.(int64), 10), true + case uint, uint8, uint16, uint32, uint64: + return strconv.FormatUint(v.(uint64), 10), true + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32), true + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), true + default: + return "", false + } +} + +func partToElems(part any) []Elem { + if part == nil { + return nil + } + switch part := part.(type) { + case string: + return []Elem{TextElem(part)} + case *Elem: + if part == nil { + return nil + } + return []Elem{*part} + case Elem: + return []Elem{part} + case []Elem: + return part + case []*Elem: + var rtn []Elem + for _, e := range part { + if e == nil { + continue + } + rtn = append(rtn, *e) + } + return rtn + } + sval, ok := numToString(part) + if ok { + return []Elem{TextElem(sval)} + } + partVal := reflect.ValueOf(part) + if partVal.Kind() == reflect.Slice { + var rtn []Elem + for i := 0; i < partVal.Len(); i++ { + subPart := partVal.Index(i).Interface() + rtn = append(rtn, partToElems(subPart)...) + } + return rtn + } + stringer, ok := part.(fmt.Stringer) + if ok { + return []Elem{TextElem(stringer.String())} + } + jsonStr, jsonErr := json.Marshal(part) + if jsonErr == nil { + return []Elem{TextElem(string(jsonStr))} + } + typeText := "invalid:" + reflect.TypeOf(part).String() + return []Elem{TextElem(typeText)} +} + +func isWaveTag(tag string) bool { + return strings.HasPrefix(tag, "wave:") || strings.HasPrefix(tag, "w:") +} + +func isBaseTag(tag string) bool { + if len(tag) == 0 { + return false + } + return tag[0] == '#' || unicode.IsLower(rune(tag[0])) || isWaveTag(tag) +} diff --git a/pkg/vdom/vdom_comp.go b/pkg/vdom/vdom_comp.go new file mode 100644 index 000000000..3b51701a5 --- /dev/null +++ b/pkg/vdom/vdom_comp.go @@ -0,0 +1,40 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +// so components either render to another component (or fragment) +// or to a base element (text or vdom). base elements can then render children + +type ChildKey struct { + Tag string + Idx int + Key string +} + +type Component struct { + Id string + Tag string + Key string + Elem *Elem + Mounted bool + + // hooks + Hooks []*Hook + + // #text component + Text string + + // base component -- vdom, wave elem, or #fragment + Children []*Component + + // component -> component + Comp *Component +} + +func (c *Component) compMatch(tag string, key string) bool { + if c == nil { + return false + } + return c.Tag == tag && c.Key == key +} diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go new file mode 100644 index 000000000..6e8586d2e --- /dev/null +++ b/pkg/vdom/vdom_html.go @@ -0,0 +1,253 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/wavetermdev/htmltoken" +) + +// can tokenize and bind HTML to Elems + +func appendChildToStack(stack []*Elem, child *Elem) { + if child == nil { + return + } + if len(stack) == 0 { + return + } + parent := stack[len(stack)-1] + parent.Children = append(parent.Children, *child) +} + +func pushElemStack(stack []*Elem, elem *Elem) []*Elem { + if elem == nil { + return stack + } + return append(stack, elem) +} + +func popElemStack(stack []*Elem) []*Elem { + if len(stack) <= 1 { + return stack + } + curElem := stack[len(stack)-1] + appendChildToStack(stack[:len(stack)-1], curElem) + return stack[:len(stack)-1] +} + +func curElemTag(stack []*Elem) string { + if len(stack) == 0 { + return "" + } + return stack[len(stack)-1].Tag +} + +func finalizeStack(stack []*Elem) *Elem { + if len(stack) == 0 { + return nil + } + for len(stack) > 1 { + stack = popElemStack(stack) + } + rtnElem := stack[0] + if len(rtnElem.Children) == 0 { + return nil + } + if len(rtnElem.Children) == 1 { + return &rtnElem.Children[0] + } + return rtnElem +} + +func getAttr(token htmltoken.Token, key string) string { + for _, attr := range token.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { + elem := &Elem{Tag: token.Data} + if len(token.Attr) > 0 { + elem.Props = make(map[string]any) + } + for _, attr := range token.Attr { + if attr.Key == "" || attr.Val == "" { + continue + } + if strings.HasPrefix(attr.Val, "#bind:") { + bindKey := attr.Val[6:] + bindVal, ok := data[bindKey] + if !ok { + continue + } + elem.Props[attr.Key] = bindVal + continue + } + elem.Props[attr.Key] = attr.Val + } + return elem +} + +func isWsChar(char rune) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' +} + +func isWsByte(char byte) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' +} + +func isFirstCharLt(s string) bool { + for _, char := range s { + if isWsChar(char) { + continue + } + return char == '<' + } + return false +} + +func isLastCharGt(s string) bool { + for i := len(s) - 1; i >= 0; i-- { + char := s[i] + if isWsByte(char) { + continue + } + return char == '>' + } + return false +} + +func isAllWhitespace(s string) bool { + for _, char := range s { + if !isWsChar(char) { + return false + } + } + return true +} + +func trimWhitespaceConditionally(s string) string { + // Trim leading whitespace if the first non-whitespace character is '<' + if isAllWhitespace(s) { + return "" + } + if isFirstCharLt(s) { + s = strings.TrimLeftFunc(s, func(r rune) bool { + return isWsChar(r) + }) + } + // Trim trailing whitespace if the last non-whitespace character is '>' + if isLastCharGt(s) { + s = strings.TrimRightFunc(s, func(r rune) bool { + return isWsChar(r) + }) + } + return s +} + +func processWhitespace(htmlStr string) string { + lines := strings.Split(htmlStr, "\n") + var newLines []string + for _, line := range lines { + trimmedLine := trimWhitespaceConditionally(line + "\n") + if trimmedLine == "" { + continue + } + newLines = append(newLines, trimmedLine) + } + return strings.Join(newLines, "") +} + +func processTextStr(s string) string { + if s == "" { + return "" + } + if isAllWhitespace(s) { + return " " + } + return strings.TrimSpace(s) +} + +func Bind(htmlStr string, data map[string]any) *Elem { + htmlStr = processWhitespace(htmlStr) + r := strings.NewReader(htmlStr) + iter := htmltoken.NewTokenizer(r) + var elemStack []*Elem + elemStack = append(elemStack, &Elem{Tag: FragmentTag}) + var tokenErr error +outer: + for { + tokenType := iter.Next() + token := iter.Token() + switch tokenType { + case htmltoken.StartTagToken: + if token.Data == "bind" { + tokenErr = errors.New("bind tag must be self closing") + break outer + } + elem := tokenToElem(token, data) + elemStack = pushElemStack(elemStack, elem) + case htmltoken.EndTagToken: + if token.Data == "bind" { + tokenErr = errors.New("bind tag must be self closing") + break outer + } + if len(elemStack) <= 1 { + tokenErr = fmt.Errorf("end tag %q without start tag", token.Data) + break outer + } + if curElemTag(elemStack) != token.Data { + tokenErr = fmt.Errorf("end tag %q does not match start tag %q", token.Data, curElemTag(elemStack)) + break outer + } + elemStack = popElemStack(elemStack) + case htmltoken.SelfClosingTagToken: + if token.Data == "bind" { + keyAttr := getAttr(token, "key") + dataVal := data[keyAttr] + elemList := partToElems(dataVal) + for _, elem := range elemList { + appendChildToStack(elemStack, &elem) + } + continue + } + elem := tokenToElem(token, data) + appendChildToStack(elemStack, elem) + case htmltoken.TextToken: + if token.Data == "" { + continue + } + textStr := processTextStr(token.Data) + if textStr == "" { + continue + } + elem := TextElem(textStr) + appendChildToStack(elemStack, &elem) + case htmltoken.CommentToken: + continue + case htmltoken.DoctypeToken: + tokenErr = errors.New("doctype not supported") + break outer + case htmltoken.ErrorToken: + if iter.Err() == io.EOF { + break outer + } + tokenErr = iter.Err() + break outer + } + } + if tokenErr != nil { + errTextElem := TextElem(tokenErr.Error()) + appendChildToStack(elemStack, &errTextElem) + } + return finalizeStack(elemStack) +} diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go new file mode 100644 index 000000000..898ab61f8 --- /dev/null +++ b/pkg/vdom/vdom_root.go @@ -0,0 +1,328 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" + "fmt" + "log" + "reflect" + + "github.com/google/uuid" +) + +type vdomContextKeyType struct{} + +var vdomContextKey = vdomContextKeyType{} + +type VDomContextVal struct { + Root *RootElem + Comp *Component + HookIdx int +} + +type RootElem struct { + OuterCtx context.Context + Root *Component + CFuncs map[string]CFunc + CompMap map[string]*Component // component id -> component + EffectWorkQueue []*EffectWorkElem + NeedsRenderMap map[string]bool +} + +const ( + WorkType_Render = "render" + WorkType_Effect = "effect" +) + +type EffectWorkElem struct { + Id string + EffectIndex int +} + +func (r *RootElem) AddRenderWork(id string) { + if r.NeedsRenderMap == nil { + r.NeedsRenderMap = make(map[string]bool) + } + r.NeedsRenderMap[id] = true +} + +func (r *RootElem) AddEffectWork(id string, effectIndex int) { + r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{Id: id, EffectIndex: effectIndex}) +} + +func MakeRoot() *RootElem { + return &RootElem{ + Root: nil, + CFuncs: make(map[string]CFunc), + CompMap: make(map[string]*Component), + } +} + +func (r *RootElem) SetOuterCtx(ctx context.Context) { + r.OuterCtx = ctx +} + +func (r *RootElem) RegisterComponent(name string, cfunc CFunc) { + r.CFuncs[name] = cfunc +} + +func (r *RootElem) Render(elem *Elem) { + log.Printf("Render %s\n", elem.Tag) + r.render(elem, &r.Root) +} + +func (r *RootElem) Event(id string, propName string) { + comp := r.CompMap[id] + if comp == nil || comp.Elem == nil { + return + } + fnVal := comp.Elem.Props[propName] + if fnVal == nil { + return + } + fn, ok := fnVal.(func()) + if !ok { + return + } + fn() +} + +// this will be called by the frontend to say the DOM has been mounted +// it will eventually send any updated "refs" to the backend as well +func (r *RootElem) runWork() { + workQueue := r.EffectWorkQueue + r.EffectWorkQueue = nil + // first, run effect cleanups + for _, work := range workQueue { + comp := r.CompMap[work.Id] + if comp == nil { + continue + } + hook := comp.Hooks[work.EffectIndex] + if hook.UnmountFn != nil { + hook.UnmountFn() + } + } + // now run, new effects + for _, work := range workQueue { + comp := r.CompMap[work.Id] + if comp == nil { + continue + } + hook := comp.Hooks[work.EffectIndex] + if hook.Fn != nil { + hook.UnmountFn = hook.Fn() + } + } + // now check if we need a render + if len(r.NeedsRenderMap) > 0 { + r.NeedsRenderMap = nil + r.render(r.Root.Elem, &r.Root) + } +} + +func (r *RootElem) render(elem *Elem, comp **Component) { + if elem == nil || elem.Tag == "" { + r.unmount(comp) + return + } + elemKey := elem.Key() + if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) { + r.unmount(comp) + r.createComp(elem.Tag, elemKey, comp) + } + (*comp).Elem = elem + if elem.Tag == TextTag { + r.renderText(elem.Text, comp) + return + } + if isBaseTag(elem.Tag) { + // simple vdom, fragment, wave element + r.renderSimple(elem, comp) + return + } + cfunc := r.CFuncs[elem.Tag] + if cfunc == nil { + text := fmt.Sprintf("<%s>", elem.Tag) + r.renderText(text, comp) + return + } + r.renderComponent(cfunc, elem, comp) +} + +func (r *RootElem) unmount(comp **Component) { + if *comp == nil { + return + } + // parent clean up happens first + for _, hook := range (*comp).Hooks { + if hook.UnmountFn != nil { + hook.UnmountFn() + } + } + // clean up any children + if (*comp).Comp != nil { + r.unmount(&(*comp).Comp) + } + if (*comp).Children != nil { + for _, child := range (*comp).Children { + r.unmount(&child) + } + } + delete(r.CompMap, (*comp).Id) + *comp = nil +} + +func (r *RootElem) createComp(tag string, key string, comp **Component) { + *comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key} + r.CompMap[(*comp).Id] = *comp +} + +func (r *RootElem) renderText(text string, comp **Component) { + if (*comp).Text != text { + (*comp).Text = text + } +} + +func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component { + newChildren := make([]*Component, len(elems)) + curCM := make(map[ChildKey]*Component) + usedMap := make(map[*Component]bool) + for idx, child := range curChildren { + if child.Key != "" { + curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child + } else { + curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child + } + } + for idx, elem := range elems { + elemKey := elem.Key() + var curChild *Component + if elemKey != "" { + curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}] + } else { + curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}] + } + usedMap[curChild] = true + newChildren[idx] = curChild + r.render(&elem, &newChildren[idx]) + } + for _, child := range curChildren { + if !usedMap[child] { + r.unmount(&child) + } + } + return newChildren +} + +func (r *RootElem) renderSimple(elem *Elem, comp **Component) { + if (*comp).Comp != nil { + r.unmount(&(*comp).Comp) + } + (*comp).Children = r.renderChildren(elem.Children, (*comp).Children) +} + +func (r *RootElem) makeRenderContext(comp *Component) context.Context { + var ctx context.Context + if r.OuterCtx != nil { + ctx = r.OuterCtx + } else { + ctx = context.Background() + } + ctx = context.WithValue(ctx, vdomContextKey, &VDomContextVal{Root: r, Comp: comp, HookIdx: 0}) + return ctx +} + +func getRenderContext(ctx context.Context) *VDomContextVal { + v := ctx.Value(vdomContextKey) + if v == nil { + return nil + } + return v.(*VDomContextVal) +} + +func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { + if (*comp).Children != nil { + for _, child := range (*comp).Children { + r.unmount(&child) + } + (*comp).Children = nil + } + props := make(map[string]any) + for k, v := range elem.Props { + props[k] = v + } + props[ChildrenPropKey] = elem.Children + ctx := r.makeRenderContext(*comp) + renderedElem := cfunc(ctx, props) + rtnElemArr := partToElems(renderedElem) + if len(rtnElemArr) == 0 { + r.unmount(&(*comp).Comp) + return + } + var rtnElem *Elem + if len(rtnElemArr) == 1 { + rtnElem = &rtnElemArr[0] + } else { + rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr} + } + r.render(rtnElem, &(*comp).Comp) +} + +func convertPropsToVDom(props map[string]any) map[string]any { + if len(props) == 0 { + return nil + } + vdomProps := make(map[string]any) + for k, v := range props { + if v == nil { + continue + } + val := reflect.ValueOf(v) + if val.Kind() == reflect.Func { + vdomProps[k] = VDomFuncType{FuncType: "server"} + continue + } + vdomProps[k] = v + } + return vdomProps +} + +func convertBaseToVDom(c *Component) *Elem { + elem := &Elem{Id: c.Id, Tag: c.Tag} + if c.Elem != nil { + elem.Props = convertPropsToVDom(c.Elem.Props) + } + for _, child := range c.Children { + childVDom := convertToVDom(child) + if childVDom != nil { + elem.Children = append(elem.Children, *childVDom) + } + } + return elem +} + +func convertToVDom(c *Component) *Elem { + if c == nil { + return nil + } + if c.Tag == TextTag { + return &Elem{Tag: TextTag, Text: c.Text} + } + if isBaseTag(c.Tag) { + return convertBaseToVDom(c) + } else { + return convertToVDom(c.Comp) + } +} + +func (r *RootElem) makeVDom(comp *Component) *Elem { + vdomElem := convertToVDom(comp) + return vdomElem +} + +func (r *RootElem) MakeVDom() *Elem { + return r.makeVDom(r.Root) +} diff --git a/pkg/vdom/vdom_test.go b/pkg/vdom/vdom_test.go new file mode 100644 index 000000000..430e07ff3 --- /dev/null +++ b/pkg/vdom/vdom_test.go @@ -0,0 +1,120 @@ +package vdom + +import ( + "context" + "encoding/json" + "fmt" + "log" + "testing" +) + +type renderContextKeyType struct{} + +var renderContextKey = renderContextKeyType{} + +type TestContext struct { + ButtonId string +} + +func Page(ctx context.Context, props map[string]any) any { + clicked, setClicked := UseState(ctx, false) + var clickedDiv *Elem + if clicked { + clickedDiv = Bind(`
clicked
`, nil) + } + clickFn := func() { + log.Printf("run clickFn\n") + setClicked(true) + } + return Bind( + ` +
+

hello world

+ + +
+`, + map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, + ) +} + +func Button(ctx context.Context, props map[string]any) any { + ref := UseRef(ctx, nil) + clName, setClName := UseState(ctx, "button") + UseEffect(ctx, func() func() { + fmt.Printf("Button useEffect\n") + setClName("button mounted") + return nil + }, nil) + compId := UseId(ctx) + testContext := getTestContext(ctx) + if testContext != nil { + testContext.ButtonId = compId + } + return Bind(` +
+ +
+ `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) +} + +func printVDom(root *RootElem) { + vd := root.MakeVDom() + jsonBytes, _ := json.MarshalIndent(vd, "", " ") + fmt.Printf("%s\n", string(jsonBytes)) +} + +func getTestContext(ctx context.Context) *TestContext { + val := ctx.Value(renderContextKey) + if val == nil { + return nil + } + return val.(*TestContext) +} + +func Test1(t *testing.T) { + log.Printf("hello!\n") + testContext := &TestContext{ButtonId: ""} + ctx := context.WithValue(context.Background(), renderContextKey, testContext) + root := MakeRoot() + root.SetOuterCtx(ctx) + root.RegisterComponent("Page", Page) + root.RegisterComponent("Button", Button) + root.Render(E("Page")) + if root.Root == nil { + t.Fatalf("root.Root is nil") + } + printVDom(root) + root.runWork() + printVDom(root) + root.Event(testContext.ButtonId, "onClick") + root.runWork() + printVDom(root) +} + +func TestBind(t *testing.T) { + elem := Bind(`
clicked
`, nil) + jsonBytes, _ := json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) + + elem = Bind(` +
+ clicked +
`, nil) + jsonBytes, _ = json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) + + elem = Bind(``, nil) + jsonBytes, _ = json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) + + elem = Bind(` +
+

hello world

+ + +
+`, nil) + jsonBytes, _ = json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) +} diff --git a/pkg/waveai/waveai.go b/pkg/waveai/waveai.go new file mode 100644 index 000000000..6a9e39e3d --- /dev/null +++ b/pkg/waveai/waveai.go @@ -0,0 +1,283 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveai + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "time" + + openaiapi "github.com/sashabaranov/go-openai" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wcloud" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + + "github.com/gorilla/websocket" +) + +const OpenAIPacketStr = "openai" +const OpenAICloudReqStr = "openai-cloudreq" +const PacketEOFStr = "EOF" + +type OpenAICmdInfoPacketOutputType struct { + Model string `json:"model,omitempty"` + Created int64 `json:"created,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +func MakeOpenAIPacket() *wshrpc.OpenAIPacketType { + return &wshrpc.OpenAIPacketType{Type: OpenAIPacketStr} +} + +type OpenAICmdInfoChatMessage struct { + MessageID int `json:"messageid"` + IsAssistantResponse bool `json:"isassistantresponse,omitempty"` + AssistantResponse *OpenAICmdInfoPacketOutputType `json:"assistantresponse,omitempty"` + UserQuery string `json:"userquery,omitempty"` + UserEngineeredQuery string `json:"userengineeredquery,omitempty"` +} + +type OpenAICloudReqPacketType struct { + Type string `json:"type"` + ClientId string `json:"clientid"` + Prompt []wshrpc.OpenAIPromptMessageType `json:"prompt"` + MaxTokens int `json:"maxtokens,omitempty"` + MaxChoices int `json:"maxchoices,omitempty"` +} + +type OpenAIOptsType struct { + Model string `json:"model"` + APIToken string `json:"apitoken"` + BaseURL string `json:"baseurl,omitempty"` + MaxTokens int `json:"maxtokens,omitempty"` + MaxChoices int `json:"maxchoices,omitempty"` + Timeout int `json:"timeout,omitempty"` + BlockId string `json:"blockid"` +} + +func MakeOpenAICloudReqPacket() *OpenAICloudReqPacketType { + return &OpenAICloudReqPacketType{ + Type: OpenAICloudReqStr, + } +} + +func GetWSEndpoint() string { + return PCloudWSEndpoint + if !wavebase.IsDevMode() { + return PCloudWSEndpoint + } else { + endpoint := os.Getenv(PCloudWSEndpointVarName) + if endpoint == "" { + panic("Invalid PCloud ws dev endpoint, PCLOUD_WS_ENDPOINT not set or invalid") + } + return endpoint + } +} + +const DefaultMaxTokens = 1000 +const DefaultModel = "gpt-4o-mini" +const DefaultStreamChanSize = 10 +const PCloudWSEndpoint = "wss://wsapi.waveterm.dev/" +const PCloudWSEndpointVarName = "PCLOUD_WS_ENDPOINT" + +const CloudWebsocketConnectTimeout = 1 * time.Minute + +func convertUsage(resp openaiapi.ChatCompletionResponse) *wshrpc.OpenAIUsageType { + if resp.Usage.TotalTokens == 0 { + return nil + } + return &wshrpc.OpenAIUsageType{ + PromptTokens: resp.Usage.PromptTokens, + CompletionTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + } +} + +func ConvertPrompt(prompt []wshrpc.OpenAIPromptMessageType) []openaiapi.ChatCompletionMessage { + var rtn []openaiapi.ChatCompletionMessage + for _, p := range prompt { + msg := openaiapi.ChatCompletionMessage{Role: p.Role, Content: p.Content, Name: p.Name} + rtn = append(rtn, msg) + } + return rtn +} + +func makeAIError(err error) wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType] { + return wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Error: err} +} + +func RunCloudCompletionStream(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType] { + rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]) + wsEndpoint := wcloud.GetWSEndpoint() + go func() { + defer close(rtn) + if wsEndpoint == "" { + rtn <- makeAIError(fmt.Errorf("no cloud ws endpoint found")) + return + } + if request.Opts == nil { + rtn <- makeAIError(fmt.Errorf("no openai opts found")) + return + } + websocketContext, dialCancelFn := context.WithTimeout(context.Background(), CloudWebsocketConnectTimeout) + defer dialCancelFn() + conn, _, err := websocket.DefaultDialer.DialContext(websocketContext, wsEndpoint, nil) + if err == context.DeadlineExceeded { + rtn <- makeAIError(fmt.Errorf("OpenAI request, timed out connecting to cloud server: %v", err)) + return + } else if err != nil { + rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket connect error: %v", err)) + return + } + defer func() { + err = conn.Close() + if err != nil { + rtn <- makeAIError(fmt.Errorf("unable to close openai channel: %v", err)) + } + }() + reqPk := MakeOpenAICloudReqPacket() + reqPk.ClientId = request.ClientId + reqPk.Prompt = request.Prompt + reqPk.MaxTokens = request.Opts.MaxTokens + reqPk.MaxChoices = request.Opts.MaxChoices + configMessageBuf, err := json.Marshal(reqPk) + if err != nil { + rtn <- makeAIError(fmt.Errorf("OpenAI request, packet marshal error: %v", err)) + return + } + err = conn.WriteMessage(websocket.TextMessage, configMessageBuf) + if err != nil { + rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket write config error: %v", err)) + return + } + for { + _, socketMessage, err := conn.ReadMessage() + if err == io.EOF { + break + } + if err != nil { + log.Printf("err received: %v", err) + rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket error reading message: %v", err)) + break + } + var streamResp *wshrpc.OpenAIPacketType + err = json.Unmarshal(socketMessage, &streamResp) + if err != nil { + rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket response json decode error: %v", err)) + break + } + if streamResp.Error == PacketEOFStr { + // got eof packet from socket + break + } else if streamResp.Error != "" { + // use error from server directly + rtn <- makeAIError(fmt.Errorf("%v", streamResp.Error)) + break + } + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Response: *streamResp} + } + }() + return rtn +} + +func RunLocalCompletionStream(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType] { + rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]) + go func() { + defer close(rtn) + if request.Opts == nil { + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Error: fmt.Errorf("no openai opts found")} + return + } + if request.Opts.Model == "" { + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Error: fmt.Errorf("no openai model specified")} + return + } + if request.Opts.BaseURL == "" && request.Opts.APIToken == "" { + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Error: fmt.Errorf("no api token")} + return + } + clientConfig := openaiapi.DefaultConfig(request.Opts.APIToken) + if request.Opts.BaseURL != "" { + clientConfig.BaseURL = request.Opts.BaseURL + } + client := openaiapi.NewClientWithConfig(clientConfig) + req := openaiapi.ChatCompletionRequest{ + Model: request.Opts.Model, + Messages: ConvertPrompt(request.Prompt), + MaxTokens: request.Opts.MaxTokens, + Stream: true, + } + if request.Opts.MaxChoices > 1 { + req.N = request.Opts.MaxChoices + } + apiResp, err := client.CreateChatCompletionStream(ctx, req) + if err != nil { + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Error: fmt.Errorf("error calling openai API: %v", err)} + return + } + sentHeader := false + for { + streamResp, err := apiResp.Recv() + if err == io.EOF { + break + } + if err != nil { + log.Printf("err received2: %v", err) + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Error: fmt.Errorf("OpenAI request, websocket error reading message: %v", err)} + break + } + if streamResp.Model != "" && !sentHeader { + pk := MakeOpenAIPacket() + pk.Model = streamResp.Model + pk.Created = streamResp.Created + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Response: *pk} + sentHeader = true + } + for _, choice := range streamResp.Choices { + pk := MakeOpenAIPacket() + pk.Index = choice.Index + pk.Text = choice.Delta.Content + pk.FinishReason = string(choice.FinishReason) + rtn <- wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType]{Response: *pk} + } + } + }() + return rtn +} + +func marshalResponse(resp openaiapi.ChatCompletionResponse) []*wshrpc.OpenAIPacketType { + var rtn []*wshrpc.OpenAIPacketType + headerPk := MakeOpenAIPacket() + headerPk.Model = resp.Model + headerPk.Created = resp.Created + headerPk.Usage = convertUsage(resp) + rtn = append(rtn, headerPk) + for _, choice := range resp.Choices { + choicePk := MakeOpenAIPacket() + choicePk.Index = choice.Index + choicePk.Text = choice.Message.Content + choicePk.FinishReason = string(choice.FinishReason) + rtn = append(rtn, choicePk) + } + return rtn +} + +func CreateErrorPacket(errStr string) *wshrpc.OpenAIPacketType { + errPk := MakeOpenAIPacket() + errPk.FinishReason = "error" + errPk.Error = errStr + return errPk +} + +func CreateTextPacket(text string) *wshrpc.OpenAIPacketType { + pk := MakeOpenAIPacket() + pk.Text = text + return pk +} diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go new file mode 100644 index 000000000..2ae6f1be6 --- /dev/null +++ b/pkg/wavebase/wavebase.go @@ -0,0 +1,225 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wavebase + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/alexflint/go-filemutex" +) + +// set by main-server.go +var WaveVersion = "0.0.0" +var BuildTime = "0" + +const DefaultWaveHome = "~/.waveterm" +const DevWaveHome = "~/.waveterm-dev" +const WaveHomeVarName = "WAVETERM_HOME" +const WaveDevVarName = "WAVETERM_DEV" +const WaveLockFile = "wave.lock" +const DomainSocketBaseName = "wave.sock" +const WaveDBDir = "db" +const JwtSecret = "waveterm" // TODO generate and store this +const ConfigDir = "config" + +var baseLock = &sync.Mutex{} +var ensureDirCache = map[string]bool{} + +func IsDevMode() bool { + pdev := os.Getenv(WaveDevVarName) + return pdev != "" +} + +func GetHomeDir() string { + homeVar, err := os.UserHomeDir() + if err != nil { + return "/" + } + return homeVar +} + +func ExpandHomeDir(pathStr string) string { + if pathStr != "~" && !strings.HasPrefix(pathStr, "~/") { + return pathStr + } + homeDir := GetHomeDir() + if pathStr == "~" { + return homeDir + } + return filepath.Join(homeDir, pathStr[2:]) +} + +func ReplaceHomeDir(pathStr string) string { + homeDir := GetHomeDir() + if pathStr == homeDir { + return "~" + } + if strings.HasPrefix(pathStr, homeDir+"/") { + return "~" + pathStr[len(homeDir):] + } + return pathStr +} + +func GetDomainSocketName() string { + return filepath.Join(GetWaveHomeDir(), DomainSocketBaseName) +} + +func GetWaveHomeDir() string { + homeVar := os.Getenv(WaveHomeVarName) + if homeVar != "" { + return ExpandHomeDir(homeVar) + } + if IsDevMode() { + return ExpandHomeDir(DevWaveHome) + } + return ExpandHomeDir(DefaultWaveHome) +} + +func EnsureWaveHomeDir() error { + return CacheEnsureDir(GetWaveHomeDir(), "wavehome", 0700, "wave home directory") +} + +func EnsureWaveDBDir() error { + return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), WaveDBDir), "wavedb", 0700, "wave db directory") +} + +func EnsureWaveConfigDir() error { + return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), ConfigDir), "waveconfig", 0700, "wave config directory") +} + +func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { + baseLock.Lock() + ok := ensureDirCache[cacheKey] + baseLock.Unlock() + if ok { + return nil + } + err := TryMkdirs(dirName, perm, dirDesc) + if err != nil { + return err + } + baseLock.Lock() + ensureDirCache[cacheKey] = true + baseLock.Unlock() + return nil +} + +func TryMkdirs(dirName string, perm os.FileMode, dirDesc string) error { + info, err := os.Stat(dirName) + if errors.Is(err, fs.ErrNotExist) { + err = os.MkdirAll(dirName, perm) + if err != nil { + return fmt.Errorf("cannot make %s %q: %w", dirDesc, dirName, err) + } + info, err = os.Stat(dirName) + } + if err != nil { + return fmt.Errorf("error trying to stat %s: %w", dirDesc, err) + } + if !info.IsDir() { + return fmt.Errorf("%s %q must be a directory", dirDesc, dirName) + } + return nil +} + +var osLangOnce = &sync.Once{} +var osLang string + +func determineLang() string { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + if runtime.GOOS == "darwin" { + out, err := exec.CommandContext(ctx, "defaults", "read", "-g", "AppleLocale").CombinedOutput() + if err != nil { + log.Printf("error executing 'defaults read -g AppleLocale': %v\n", err) + return "" + } + strOut := string(out) + truncOut := strings.Split(strOut, "@")[0] + return strings.TrimSpace(truncOut) + ".UTF-8" + } else if runtime.GOOS == "win32" { + out, err := exec.CommandContext(ctx, "Get-Culture", "|", "select", "-exp", "Name").CombinedOutput() + if err != nil { + log.Printf("error executing 'Get-Culture | select -exp Name': %v\n", err) + return "" + } + return strings.TrimSpace(string(out)) + ".UTF-8" + } else { + // this is specifically to get the wavesrv LANG so waveshell + // on a remote uses the same LANG + return os.Getenv("LANG") + } +} + +func DetermineLang() string { + osLangOnce.Do(func() { + osLang = determineLang() + }) + return osLang +} + +func DetermineLocale() string { + truncated := strings.Split(DetermineLang(), ".")[0] + if truncated == "" { + return "C" + } + return strings.Replace(truncated, "_", "-", -1) +} + +func AcquireWaveLock() (*filemutex.FileMutex, error) { + homeDir := GetWaveHomeDir() + lockFileName := filepath.Join(homeDir, WaveLockFile) + log.Printf("[base] acquiring lock on %s\n", lockFileName) + m, err := filemutex.New(lockFileName) + if err != nil { + return nil, err + } + + err = m.TryLock() + return m, err +} + +func ClientArch() string { + return fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) +} + +var releaseRegex = regexp.MustCompile(`^(\d+\.\d+\.\d+)`) +var osReleaseOnce = &sync.Once{} +var osRelease string + +func unameKernelRelease() string { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + out, err := exec.CommandContext(ctx, "uname", "-r").CombinedOutput() + if err != nil { + log.Printf("error executing uname -r: %v\n", err) + return "-" + } + releaseStr := strings.TrimSpace(string(out)) + m := releaseRegex.FindStringSubmatch(releaseStr) + if m == nil || len(m) < 2 { + log.Printf("invalid uname -r output: [%s]\n", releaseStr) + return "-" + } + return m[1] +} + +func UnameKernelRelease() string { + osReleaseOnce.Do(func() { + osRelease = unameKernelRelease() + }) + return osRelease +} diff --git a/pkg/waveobj/ctxupdate.go b/pkg/waveobj/ctxupdate.go new file mode 100644 index 000000000..5ee01fdab --- /dev/null +++ b/pkg/waveobj/ctxupdate.go @@ -0,0 +1,153 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveobj + +import ( + "bytes" + "context" + "fmt" + "log" +) + +var waveObjUpdateKey = struct{}{} + +type contextUpdatesType struct { + UpdatesStack []map[ORef]WaveObjUpdate +} + +func dumpUpdateStack(updates *contextUpdatesType) { + log.Printf("dumpUpdateStack len:%d\n", len(updates.UpdatesStack)) + for idx, update := range updates.UpdatesStack { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf(" [%d]:", idx)) + for k := range update { + buf.WriteString(fmt.Sprintf(" %s:%s", k.OType, k.OID)) + } + buf.WriteString("\n") + log.Print(buf.String()) + } +} + +func ContextWithUpdates(ctx context.Context) context.Context { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal != nil { + return ctx + } + return context.WithValue(ctx, waveObjUpdateKey, &contextUpdatesType{ + UpdatesStack: []map[ORef]WaveObjUpdate{make(map[ORef]WaveObjUpdate)}, + }) +} + +func ContextGetUpdates(ctx context.Context) map[ORef]WaveObjUpdate { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal == nil { + return nil + } + updates := updatesVal.(*contextUpdatesType) + if len(updates.UpdatesStack) == 1 { + return updates.UpdatesStack[0] + } + rtn := make(map[ORef]WaveObjUpdate) + for _, update := range updates.UpdatesStack { + for k, v := range update { + rtn[k] = v + } + } + return rtn +} + +func ContextGetUpdate(ctx context.Context, oref ORef) *WaveObjUpdate { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal == nil { + return nil + } + updates := updatesVal.(*contextUpdatesType) + for idx := len(updates.UpdatesStack) - 1; idx >= 0; idx-- { + if obj, ok := updates.UpdatesStack[idx][oref]; ok { + return &obj + } + } + return nil +} + +func ContextAddUpdate(ctx context.Context, update WaveObjUpdate) { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal == nil { + return + } + updates := updatesVal.(*contextUpdatesType) + oref := ORef{ + OType: update.OType, + OID: update.OID, + } + updates.UpdatesStack[len(updates.UpdatesStack)-1][oref] = update +} + +func ContextUpdatesBeginTx(ctx context.Context) context.Context { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal == nil { + return ctx + } + updates := updatesVal.(*contextUpdatesType) + updates.UpdatesStack = append(updates.UpdatesStack, make(map[ORef]WaveObjUpdate)) + return ctx +} + +func ContextUpdatesCommitTx(ctx context.Context) { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal == nil { + return + } + updates := updatesVal.(*contextUpdatesType) + if len(updates.UpdatesStack) <= 1 { + panic(fmt.Errorf("no updates transaction to commit")) + } + // merge the last two updates + curUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-1] + prevUpdateMap := updates.UpdatesStack[len(updates.UpdatesStack)-2] + for k, v := range curUpdateMap { + prevUpdateMap[k] = v + } + updates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1] +} + +func ContextUpdatesRollbackTx(ctx context.Context) { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal == nil { + return + } + updates := updatesVal.(*contextUpdatesType) + if len(updates.UpdatesStack) <= 1 { + panic(fmt.Errorf("no updates transaction to rollback")) + } + updates.UpdatesStack = updates.UpdatesStack[:len(updates.UpdatesStack)-1] +} + +func ContextGetUpdatesRtn(ctx context.Context) UpdatesRtnType { + updatesMap := ContextGetUpdates(ctx) + if updatesMap == nil { + return nil + } + rtn := make(UpdatesRtnType, 0, len(updatesMap)) + for _, v := range updatesMap { + rtn = append(rtn, v) + } + return rtn +} + +func ContextPrintUpdates(ctx context.Context) { + updatesVal := ctx.Value(waveObjUpdateKey) + if updatesVal == nil { + log.Print("no updates\n") + return + } + updates := updatesVal.(*contextUpdatesType) + log.Printf("updates len:%d\n", len(updates.UpdatesStack)) + for idx, update := range updates.UpdatesStack { + log.Printf(" update[%d]:\n", idx) + for k, v := range update { + log.Printf(" %s:%s %s\n", k.OType, k.OID, v.UpdateType) + } + } +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go new file mode 100644 index 000000000..d6d18f468 --- /dev/null +++ b/pkg/waveobj/metaconsts.go @@ -0,0 +1,65 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Generated Code. DO NOT EDIT. + +package waveobj + +const ( + MetaKey_View = "view" + + MetaKey_Controller = "controller" + + MetaKey_Title = "title" + + MetaKey_File = "file" + + MetaKey_Url = "url" + + MetaKey_Connection = "connection" + + MetaKey_Edit = "edit" + + MetaKey_History = "history" + MetaKey_HistoryForward = "history:forward" + + MetaKey_DisplayName = "display:name" + MetaKey_DisplayOrder = "display:order" + + MetaKey_Icon = "icon" + MetaKey_IconColor = "icon:color" + + MetaKey_Frame = "frame" + MetaKey_FrameClear = "frame:*" + MetaKey_FrameBorderColor = "frame:bordercolor" + MetaKey_FrameBorderColor_Focused = "frame:bordercolor:focused" + + MetaKey_Cmd = "cmd" + MetaKey_CmdClear = "cmd:*" + MetaKey_CmdInteractive = "cmd:interactive" + MetaKey_CmdLogin = "cmd:login" + MetaKey_CmdRunOnStart = "cmd:runonstart" + MetaKey_CmdClearOnStart = "cmd:clearonstart" + MetaKey_CmdClearOnRestart = "cmd:clearonrestart" + MetaKey_CmdEnv = "cmd:env" + MetaKey_CmdCwd = "cmd:cwd" + MetaKey_CmdNoWsh = "cmd:nowsh" + + MetaKey_GraphClear = "graph:*" + MetaKey_GraphNumPoints = "graph:numpoints" + MetaKey_GraphMetrics = "graph:metrics" + + MetaKey_Bg = "bg" + MetaKey_BgClear = "bg:*" + MetaKey_BgOpacity = "bg:opacity" + MetaKey_BgBlendMode = "bg:blendmode" + + MetaKey_TermClear = "term:*" + MetaKey_TermFontSize = "term:fontsize" + MetaKey_TermFontFamily = "term:fontfamily" + MetaKey_TermMode = "term:mode" + MetaKey_TermTheme = "term:theme" + + MetaKey_Count = "count" +) + diff --git a/pkg/waveobj/metamap.go b/pkg/waveobj/metamap.go new file mode 100644 index 000000000..57a881ea5 --- /dev/null +++ b/pkg/waveobj/metamap.go @@ -0,0 +1,74 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveobj + +type MetaMapType map[string]any + +func (m MetaMapType) GetString(key string, def string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return def +} + +func (m MetaMapType) GetBool(key string, def bool) bool { + if v, ok := m[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return def +} + +func (m MetaMapType) GetInt(key string, def int) int { + if v, ok := m[key]; ok { + if fval, ok := v.(float64); ok { + return int(fval) + } + } + return def +} + +func (m MetaMapType) GetFloat(key string, def float64) float64 { + if v, ok := m[key]; ok { + if fval, ok := v.(float64); ok { + return fval + } + } + return def +} + +func (m MetaMapType) GetMap(key string) MetaMapType { + if v, ok := m[key]; ok { + if mval, ok := v.(map[string]any); ok { + return MetaMapType(mval) + } + } + return nil +} + +func (m MetaMapType) GetArray(key string) []any { + if v, ok := m[key]; ok { + if aval, ok := v.([]any); ok { + return aval + } + } + return nil +} + +func (m MetaMapType) GetStringArray(key string) []string { + arr := m.GetArray(key) + if len(arr) == 0 { + return nil + } + rtn := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok { + rtn = append(rtn, s) + } + } + return rtn +} diff --git a/pkg/waveobj/waveobj.go b/pkg/waveobj/waveobj.go new file mode 100644 index 000000000..ba5931316 --- /dev/null +++ b/pkg/waveobj/waveobj.go @@ -0,0 +1,318 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveobj + +import ( + "encoding/json" + "fmt" + "reflect" + "regexp" + "strings" + "sync" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" +) + +const ( + OTypeKeyName = "otype" + OIDKeyName = "oid" + VersionKeyName = "version" + MetaKeyName = "meta" + + OIDGoFieldName = "OID" + VersionGoFieldName = "Version" + MetaGoFieldName = "Meta" +) + +type ORef struct { + // special JSON marshalling to string + OType string `json:"otype" mapstructure:"otype"` + OID string `json:"oid" mapstructure:"oid"` +} + +func (oref ORef) String() string { + if oref.OType == "" || oref.OID == "" { + return "" + } + return fmt.Sprintf("%s:%s", oref.OType, oref.OID) +} + +func (oref ORef) MarshalJSON() ([]byte, error) { + return json.Marshal(oref.String()) +} + +func (oref ORef) IsEmpty() bool { + // either being empty is not valid + return oref.OType == "" || oref.OID == "" +} + +func (oref *ORef) UnmarshalJSON(data []byte) error { + var orefStr string + err := json.Unmarshal(data, &orefStr) + if err != nil { + return err + } + if len(orefStr) == 0 { + oref.OType = "" + oref.OID = "" + return nil + } + parsed, err := ParseORef(orefStr) + if err != nil { + return err + } + *oref = parsed + return nil +} + +func MakeORef(otype string, oid string) ORef { + return ORef{ + OType: otype, + OID: oid, + } +} + +var otypeRe = regexp.MustCompile(`^[a-z]+$`) + +func ParseORef(orefStr string) (ORef, error) { + fields := strings.Split(orefStr, ":") + if len(fields) != 2 { + return ORef{}, fmt.Errorf("invalid object reference: %q", orefStr) + } + otype := fields[0] + if !otypeRe.MatchString(otype) { + return ORef{}, fmt.Errorf("invalid object type: %q", otype) + } + oid := fields[1] + _, err := uuid.Parse(oid) + if err != nil { + return ORef{}, fmt.Errorf("invalid object id: %q", oid) + } + return ORef{OType: otype, OID: oid}, nil +} + +type WaveObj interface { + GetOType() string // should not depend on object state (should work with nil value) +} + +type waveObjDesc struct { + RType reflect.Type + OIDField reflect.StructField + VersionField reflect.StructField + MetaField reflect.StructField +} + +var waveObjMap = sync.Map{} +var waveObjRType = reflect.TypeOf((*WaveObj)(nil)).Elem() +var metaMapRType = reflect.TypeOf(MetaMapType{}) + +func RegisterType(rtype reflect.Type) { + if rtype.Kind() != reflect.Ptr { + panic(fmt.Sprintf("wave object must be a pointer for %v", rtype)) + } + if !rtype.Implements(waveObjRType) { + panic(fmt.Sprintf("wave object must implement WaveObj for %v", rtype)) + } + waveObj := reflect.Zero(rtype).Interface().(WaveObj) + otype := waveObj.GetOType() + if otype == "" { + panic(fmt.Sprintf("otype is empty for %v", rtype)) + } + oidField, found := rtype.Elem().FieldByName(OIDGoFieldName) + if !found { + panic(fmt.Sprintf("missing OID field for %v", rtype)) + } + if oidField.Type.Kind() != reflect.String { + panic(fmt.Sprintf("OID field must be string for %v", rtype)) + } + oidJsonTag := utilfn.GetJsonTag(oidField) + if oidJsonTag != OIDKeyName { + panic(fmt.Sprintf("OID field json tag must be %q for %v", OIDKeyName, rtype)) + } + versionField, found := rtype.Elem().FieldByName(VersionGoFieldName) + if !found { + panic(fmt.Sprintf("missing Version field for %v", rtype)) + } + if versionField.Type.Kind() != reflect.Int { + panic(fmt.Sprintf("Version field must be int for %v", rtype)) + } + versionJsonTag := utilfn.GetJsonTag(versionField) + if versionJsonTag != VersionKeyName { + panic(fmt.Sprintf("Version field json tag must be %q for %v", VersionKeyName, rtype)) + } + metaField, found := rtype.Elem().FieldByName(MetaGoFieldName) + if !found { + panic(fmt.Sprintf("missing Meta field for %v", rtype)) + } + if metaField.Type != metaMapRType { + panic(fmt.Sprintf("Meta field must be MetaMapType for %v", rtype)) + } + _, found = waveObjMap.Load(otype) + if found { + panic(fmt.Sprintf("otype %q already registered", otype)) + } + waveObjMap.Store(otype, &waveObjDesc{ + RType: rtype, + OIDField: oidField, + VersionField: versionField, + MetaField: metaField, + }) +} + +func getWaveObjDesc(otype string) *waveObjDesc { + desc, _ := waveObjMap.Load(otype) + if desc == nil { + return nil + } + return desc.(*waveObjDesc) +} + +func GetOID(waveObj WaveObj) string { + desc := getWaveObjDesc(waveObj.GetOType()) + if desc == nil { + return "" + } + return reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).String() +} + +func SetOID(waveObj WaveObj, oid string) { + desc := getWaveObjDesc(waveObj.GetOType()) + if desc == nil { + return + } + reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.OIDField.Index).SetString(oid) +} + +func GetVersion(waveObj WaveObj) int { + desc := getWaveObjDesc(waveObj.GetOType()) + if desc == nil { + return 0 + } + return int(reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).Int()) +} + +func SetVersion(waveObj WaveObj, version int) { + desc := getWaveObjDesc(waveObj.GetOType()) + if desc == nil { + return + } + reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.VersionField.Index).SetInt(int64(version)) +} + +func GetMeta(waveObj WaveObj) MetaMapType { + desc := getWaveObjDesc(waveObj.GetOType()) + if desc == nil { + return nil + } + mval := reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.MetaField.Index).Interface() + if mval == nil { + return nil + } + return mval.(MetaMapType) +} + +func SetMeta(waveObj WaveObj, meta map[string]any) { + desc := getWaveObjDesc(waveObj.GetOType()) + if desc == nil { + return + } + reflect.ValueOf(waveObj).Elem().FieldByIndex(desc.MetaField.Index).Set(reflect.ValueOf(meta)) +} + +func ToJsonMap(w WaveObj) (map[string]any, error) { + if w == nil { + return nil, nil + } + m := make(map[string]any) + dconfig := &mapstructure.DecoderConfig{ + Result: &m, + TagName: "json", + } + decoder, err := mapstructure.NewDecoder(dconfig) + if err != nil { + return nil, err + } + err = decoder.Decode(w) + if err != nil { + return nil, err + } + m[OTypeKeyName] = w.GetOType() + m[OIDKeyName] = GetOID(w) + m[VersionKeyName] = GetVersion(w) + return m, nil +} + +func ToJson(w WaveObj) ([]byte, error) { + m, err := ToJsonMap(w) + if err != nil { + return nil, err + } + return json.Marshal(m) +} + +func FromJson(data []byte) (WaveObj, error) { + var m map[string]any + err := json.Unmarshal(data, &m) + if err != nil { + return nil, err + } + return FromJsonMap(m) +} + +func FromJsonMap(m map[string]any) (WaveObj, error) { + otype, ok := m[OTypeKeyName].(string) + if !ok { + return nil, fmt.Errorf("missing otype") + } + desc := getWaveObjDesc(otype) + if desc == nil { + return nil, fmt.Errorf("unknown otype: %s", otype) + } + wobj := reflect.Zero(desc.RType).Interface().(WaveObj) + dconfig := &mapstructure.DecoderConfig{ + Result: &wobj, + TagName: "json", + } + decoder, err := mapstructure.NewDecoder(dconfig) + if err != nil { + return nil, err + } + err = decoder.Decode(m) + if err != nil { + return nil, err + } + return wobj, nil +} + +func ORefFromMap(m map[string]any) (*ORef, error) { + oref := ORef{} + err := mapstructure.Decode(m, &oref) + if err != nil { + return nil, err + } + return &oref, nil +} + +func ORefFromWaveObj(w WaveObj) *ORef { + return &ORef{ + OType: w.GetOType(), + OID: GetOID(w), + } +} + +func FromJsonGen[T WaveObj](data []byte) (T, error) { + obj, err := FromJson(data) + if err != nil { + var zero T + return zero, err + } + rtn, ok := obj.(T) + if !ok { + var zero T + return zero, fmt.Errorf("type mismatch got %T, expected %T", obj, zero) + } + return rtn, nil +} diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go new file mode 100644 index 000000000..9494c995a --- /dev/null +++ b/pkg/waveobj/wtype.go @@ -0,0 +1,279 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveobj + +import ( + "encoding/json" + "fmt" + "reflect" +) + +type UpdatesRtnType = []WaveObjUpdate + +type UIContext struct { + WindowId string `json:"windowid"` + ActiveTabId string `json:"activetabid"` +} + +const ( + UpdateType_Update = "update" + UpdateType_Delete = "delete" +) + +const ( + OType_Client = "client" + OType_Window = "window" + OType_Workspace = "workspace" + OType_Tab = "tab" + OType_LayoutState = "layout" + OType_Block = "block" +) + +type WaveObjUpdate struct { + UpdateType string `json:"updatetype"` + OType string `json:"otype"` + OID string `json:"oid"` + Obj WaveObj `json:"obj,omitempty"` +} + +func (update WaveObjUpdate) MarshalJSON() ([]byte, error) { + rtn := make(map[string]any) + rtn["updatetype"] = update.UpdateType + rtn["otype"] = update.OType + rtn["oid"] = update.OID + if update.Obj != nil { + var err error + rtn["obj"], err = ToJsonMap(update.Obj) + if err != nil { + return nil, err + } + } + return json.Marshal(rtn) +} + +func MakeUpdate(obj WaveObj) WaveObjUpdate { + return WaveObjUpdate{ + UpdateType: UpdateType_Update, + OType: obj.GetOType(), + OID: GetOID(obj), + Obj: obj, + } +} + +func MakeUpdates(objs []WaveObj) []WaveObjUpdate { + rtn := make([]WaveObjUpdate, 0, len(objs)) + for _, obj := range objs { + rtn = append(rtn, MakeUpdate(obj)) + } + return rtn +} + +func (update *WaveObjUpdate) UnmarshalJSON(data []byte) error { + var objMap map[string]any + err := json.Unmarshal(data, &objMap) + if err != nil { + return err + } + var ok1, ok2, ok3 bool + if _, found := objMap["updatetype"]; !found { + return fmt.Errorf("missing updatetype (in WaveObjUpdate)") + } + update.UpdateType, ok1 = objMap["updatetype"].(string) + if !ok1 { + return fmt.Errorf("in WaveObjUpdate bad updatetype type %T", objMap["updatetype"]) + } + if _, found := objMap["otype"]; !found { + return fmt.Errorf("missing otype (in WaveObjUpdate)") + } + update.OType, ok2 = objMap["otype"].(string) + if !ok2 { + return fmt.Errorf("in WaveObjUpdate bad otype type %T", objMap["otype"]) + } + if _, found := objMap["oid"]; !found { + return fmt.Errorf("missing oid (in WaveObjUpdate)") + } + update.OID, ok3 = objMap["oid"].(string) + if !ok3 { + return fmt.Errorf("in WaveObjUpdate bad oid type %T", objMap["oid"]) + } + if _, found := objMap["obj"]; found { + objMap, ok := objMap["obj"].(map[string]any) + if !ok { + return fmt.Errorf("in WaveObjUpdate bad obj type %T", objMap["obj"]) + } + waveObj, err := FromJsonMap(objMap) + if err != nil { + return fmt.Errorf("in WaveObjUpdate error decoding obj: %w", err) + } + update.Obj = waveObj + } + return nil +} + +type Client struct { + OID string `json:"oid"` + Version int `json:"version"` + WindowIds []string `json:"windowids"` + Meta MetaMapType `json:"meta"` + TosAgreed int64 `json:"tosagreed,omitempty"` + HistoryMigrated bool `json:"historymigrated,omitempty"` +} + +func (*Client) GetOType() string { + return OType_Client +} + +// stores the ui-context of the window +// workspaceid, active tab, active block within each tab, window size, etc. +type Window struct { + OID string `json:"oid"` + Version int `json:"version"` + WorkspaceId string `json:"workspaceid"` + ActiveTabId string `json:"activetabid"` + Pos Point `json:"pos"` + WinSize WinSize `json:"winsize"` + LastFocusTs int64 `json:"lastfocusts"` + Meta MetaMapType `json:"meta"` +} + +func (*Window) GetOType() string { + return OType_Window +} + +type Workspace struct { + OID string `json:"oid"` + Version int `json:"version"` + Name string `json:"name"` + TabIds []string `json:"tabids"` + Meta MetaMapType `json:"meta"` +} + +func (*Workspace) GetOType() string { + return OType_Workspace +} + +type Tab struct { + OID string `json:"oid"` + Version int `json:"version"` + Name string `json:"name"` + LayoutState string `json:"layoutstate"` + BlockIds []string `json:"blockids"` + Meta MetaMapType `json:"meta"` +} + +func (*Tab) GetOType() string { + return OType_Tab +} + +func (t *Tab) GetBlockORefs() []ORef { + rtn := make([]ORef, 0, len(t.BlockIds)) + for _, blockId := range t.BlockIds { + rtn = append(rtn, ORef{OType: OType_Block, OID: blockId}) + } + return rtn +} + +type LayoutActionData struct { + ActionType string `json:"actiontype"` + BlockId string `json:"blockid"` + NodeSize *uint `json:"nodesize,omitempty"` + IndexArr *[]int `json:"indexarr,omitempty"` + Focused bool `json:"focused"` + Magnified bool `json:"magnified"` +} + +type LeafOrderEntry struct { + NodeId string `json:"nodeid"` + BlockId string `json:"blockid"` +} + +type LayoutState struct { + OID string `json:"oid"` + Version int `json:"version"` + RootNode any `json:"rootnode,omitempty"` + MagnifiedNodeId string `json:"magnifiednodeid,omitempty"` + FocusedNodeId string `json:"focusednodeid,omitempty"` + LeafOrder *[]LeafOrderEntry `json:"leaforder,omitempty"` + PendingBackendActions *[]LayoutActionData `json:"pendingbackendactions,omitempty"` + Meta MetaMapType `json:"meta,omitempty"` +} + +func (*LayoutState) GetOType() string { + return OType_LayoutState +} + +type FileDef struct { + FileType string `json:"filetype,omitempty"` + Path string `json:"path,omitempty"` + Url string `json:"url,omitempty"` + Content string `json:"content,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +type BlockDef struct { + Files map[string]*FileDef `json:"files,omitempty"` + Meta MetaMapType `json:"meta,omitempty"` +} + +type StickerClickOptsType struct { + SendInput string `json:"sendinput,omitempty"` + CreateBlock *BlockDef `json:"createblock,omitempty"` +} + +type StickerDisplayOptsType struct { + Icon string `json:"icon"` + ImgSrc string `json:"imgsrc"` + SvgBlob string `json:"svgblob,omitempty"` +} + +type StickerType struct { + StickerType string `json:"stickertype"` + Style map[string]any `json:"style"` + ClickOpts *StickerClickOptsType `json:"clickopts,omitempty"` + Display *StickerDisplayOptsType `json:"display"` +} + +type RuntimeOpts struct { + TermSize TermSize `json:"termsize,omitempty"` + WinSize WinSize `json:"winsize,omitempty"` +} + +type Point struct { + X int `json:"x"` + Y int `json:"y"` +} + +type WinSize struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type Block struct { + OID string `json:"oid"` + Version int `json:"version"` + BlockDef *BlockDef `json:"blockdef"` + RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` + Stickers []*StickerType `json:"stickers,omitempty"` + Meta MetaMapType `json:"meta"` +} + +func (*Block) GetOType() string { + return OType_Block +} + +func AllWaveObjTypes() []reflect.Type { + return []reflect.Type{ + reflect.TypeOf(&Client{}), + reflect.TypeOf(&Window{}), + reflect.TypeOf(&Workspace{}), + reflect.TypeOf(&Tab{}), + reflect.TypeOf(&Block{}), + reflect.TypeOf(&LayoutState{}), + } +} + +type TermSize struct { + Rows int `json:"rows"` + Cols int `json:"cols"` +} diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go new file mode 100644 index 000000000..d964c2062 --- /dev/null +++ b/pkg/waveobj/wtypemeta.go @@ -0,0 +1,125 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveobj + +import ( + "strings" +) + +const Entity_Any = "any" + +// for typescript typing +type MetaTSType struct { + // shared + View string `json:"view,omitempty"` + Controller string `json:"controller,omitempty"` + Title string `json:"title,omitempty"` + File string `json:"file,omitempty"` + Url string `json:"url,omitempty"` + Connection string `json:"connection,omitempty"` + Edit bool `json:"edit,omitempty"` + History []string `json:"history,omitempty"` + HistoryForward []string `json:"history:forward,omitempty"` + + DisplayName string `json:"display:name,omitempty"` + DisplayOrder float64 `json:"display:order,omitempty"` + + Icon string `json:"icon,omitempty"` + IconColor string `json:"icon:color,omitempty"` + + Frame bool `json:"frame,omitempty"` + FrameClear bool `json:"frame:*,omitempty"` + FrameBorderColor string `json:"frame:bordercolor,omitempty"` + FrameBorderColor_Focused string `json:"frame:bordercolor:focused,omitempty"` + + Cmd string `json:"cmd,omitempty"` + CmdClear bool `json:"cmd:*,omitempty"` + CmdInteractive bool `json:"cmd:interactive,omitempty"` + CmdLogin bool `json:"cmd:login,omitempty"` + CmdRunOnStart bool `json:"cmd:runonstart,omitempty"` + CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"` + CmdClearOnRestart bool `json:"cmd:clearonrestart,omitempty"` + CmdEnv map[string]string `json:"cmd:env,omitempty"` + CmdCwd string `json:"cmd:cwd,omitempty"` + CmdNoWsh bool `json:"cmd:nowsh,omitempty"` + + GraphClear bool `json:"graph:*,omitempty"` + GraphNumPoints int `json:"graph:numpoints,omitempty"` + GraphMetrics []string `json:"graph:metrics,omitempty"` + + // for tabs + Bg string `json:"bg,omitempty"` + BgClear bool `json:"bg:*,omitempty"` + BgOpacity float64 `json:"bg:opacity,omitempty"` + BgBlendMode string `json:"bg:blendmode,omitempty"` + + TermClear bool `json:"term:*,omitempty"` + TermFontSize int `json:"term:fontsize,omitempty"` + TermFontFamily string `json:"term:fontfamily,omitempty"` + TermMode string `json:"term:mode,omitempty"` + TermTheme string `json:"term:theme,omitempty"` + Count int `json:"count,omitempty"` // temp for cpu plot. will remove later +} + +type MetaDataDecl struct { + Key string `json:"key"` + Desc string `json:"desc,omitempty"` + Type string `json:"type"` // string, int, float, bool, array, object + Default any `json:"default,omitempty"` + StrOptions []string `json:"stroptions,omitempty"` + NumRange []*int `json:"numrange,omitempty"` // inclusive, null means no limit + Entity []string `json:"entity"` // what entities this applies to, e.g. "block", "tab", "any", etc. + Special []string `json:"special,omitempty"` // special handling. things that need to happen if this gets updated +} + +type MetaPresetDecl struct { + Preset string `json:"preset"` + Desc string `json:"desc,omitempty"` + Keys []string `json:"keys"` + Entity []string `json:"entity"` // what entities this applies to, e.g. "block", "tab", etc. +} + +// returns a clean copy of meta with mergeMeta merged in +// if mergeSpecial is false, then special keys will not be merged (like display:*) +func MergeMeta(meta MetaMapType, metaUpdate MetaMapType, mergeSpecial bool) MetaMapType { + rtn := make(MetaMapType) + for k, v := range meta { + rtn[k] = v + } + // deal with "section:*" keys + for k := range metaUpdate { + if !strings.HasSuffix(k, ":*") { + continue + } + if !metaUpdate.GetBool(k, false) { + continue + } + prefix := strings.TrimSuffix(k, ":*") + if prefix == "" { + continue + } + // delete "[prefix]" and all keys that start with "[prefix]:" + prefixColon := prefix + ":" + for k2 := range rtn { + if k2 == prefix || strings.HasPrefix(k2, prefixColon) { + delete(rtn, k2) + } + } + } + // now deal with regular keys + for k, v := range metaUpdate { + if !mergeSpecial && strings.HasPrefix(k, "display:") { + continue + } + if strings.HasSuffix(k, ":*") { + continue + } + if v == nil { + delete(rtn, k) + continue + } + rtn[k] = v + } + return rtn +} diff --git a/pkg/wcloud/wcloud.go b/pkg/wcloud/wcloud.go new file mode 100644 index 000000000..43c60491f --- /dev/null +++ b/pkg/wcloud/wcloud.go @@ -0,0 +1,159 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wcloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/wavetermdev/waveterm/pkg/telemetry" + "github.com/wavetermdev/waveterm/pkg/util/daystr" + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +const WCloudEndpoint = "https://api.waveterm.dev/central" +const WCloudEndpointVarName = "WCLOUD_ENDPOINT" +const WCloudWSEndpoint = "wss://wsapi.waveterm.dev/" +const WCloudWSEndpointVarName = "WCLOUD_WS_ENDPOINT" + +const APIVersion = 1 +const MaxPtyUpdateSize = (128 * 1024) +const MaxUpdatesPerReq = 10 +const MaxUpdatesToDeDup = 1000 +const MaxUpdateWriterErrors = 3 +const WCloudDefaultTimeout = 5 * time.Second +const WCloudWebShareUpdateTimeout = 15 * time.Second + +// setting to 1M to be safe (max is 6M for API-GW + Lambda, but there is base64 encoding and upload time) +// we allow one extra update past this estimated size +const MaxUpdatePayloadSize = 1 * (1024 * 1024) + +const TelemetryUrl = "/telemetry" +const NoTelemetryUrl = "/no-telemetry" +const WebShareUpdateUrl = "/auth/web-share-update" + +func GetEndpoint() string { + if !wavebase.IsDevMode() { + return WCloudEndpoint + } + endpoint := os.Getenv(WCloudEndpointVarName) + if endpoint == "" || !strings.HasPrefix(endpoint, "https://") { + log.Printf("Invalid wcloud dev endpoint, WCLOUD_ENDPOINT not set or invalid\n") + return "" + } + return endpoint +} + +func GetWSEndpoint() string { + if !wavebase.IsDevMode() { + return WCloudWSEndpoint + } + endpoint := os.Getenv(WCloudWSEndpointVarName) + if endpoint == "" || !strings.HasPrefix(endpoint, "wss://") { + log.Printf("Invalid wcloud ws dev endpoint, WCLOUD_WS_ENDPOINT not set or invalid\n") + return "" + } + return endpoint +} + +func makeAnonPostReq(ctx context.Context, apiUrl string, data interface{}) (*http.Request, error) { + endpoint := GetEndpoint() + if endpoint == "" { + return nil, errors.New("wcloud endpoint not set") + } + var dataReader io.Reader + if data != nil { + byteArr, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("error marshaling json for %s request: %v", apiUrl, err) + } + dataReader = bytes.NewReader(byteArr) + } + fullUrl := GetEndpoint() + apiUrl + req, err := http.NewRequestWithContext(ctx, "POST", fullUrl, dataReader) + if err != nil { + return nil, fmt.Errorf("error creating %s request: %v", apiUrl, err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-PromptAPIVersion", strconv.Itoa(APIVersion)) + req.Header.Set("X-PromptAPIUrl", apiUrl) + req.Close = true + return req, nil +} + +func doRequest(req *http.Request, outputObj interface{}) (*http.Response, error) { + apiUrl := req.Header.Get("X-PromptAPIUrl") + log.Printf("[wcloud] sending request %s %v\n", req.Method, req.URL) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error contacting wcloud %q service: %v", apiUrl, err) + } + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return resp, fmt.Errorf("error reading %q response body: %v", apiUrl, err) + } + if resp.StatusCode != http.StatusOK { + return resp, fmt.Errorf("error contacting wcloud %q service: %s", apiUrl, resp.Status) + } + if outputObj != nil && resp.Header.Get("Content-Type") == "application/json" { + err = json.Unmarshal(bodyBytes, outputObj) + if err != nil { + return resp, fmt.Errorf("error decoding json: %v", err) + } + } + return resp, nil +} + +func SendTelemetry(ctx context.Context, clientId string) error { + if !telemetry.IsTelemetryEnabled() { + log.Printf("telemetry disabled, not sending\n") + return nil + } + activity, err := telemetry.GetNonUploadedActivity(ctx) + if err != nil { + return fmt.Errorf("cannot get activity: %v", err) + } + if len(activity) == 0 { + return nil + } + log.Printf("[wcloud] sending telemetry data\n") + dayStr := daystr.GetCurDayStr() + input := TelemetryInputType{ClientId: clientId, UserId: clientId, CurDay: dayStr, Activity: activity} + req, err := makeAnonPostReq(ctx, TelemetryUrl, input) + if err != nil { + return err + } + _, err = doRequest(req, nil) + if err != nil { + return err + } + err = telemetry.MarkActivityAsUploaded(ctx, activity) + if err != nil { + return fmt.Errorf("error marking activity as uploaded: %v", err) + } + return nil +} + +func SendNoTelemetryUpdate(ctx context.Context, clientId string, noTelemetryVal bool) error { + req, err := makeAnonPostReq(ctx, NoTelemetryUrl, NoTelemetryInputType{ClientId: clientId, Value: noTelemetryVal}) + if err != nil { + return err + } + _, err = doRequest(req, nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/wcloud/wclouddata.go b/pkg/wcloud/wclouddata.go new file mode 100644 index 000000000..8068b457f --- /dev/null +++ b/pkg/wcloud/wclouddata.go @@ -0,0 +1,21 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wcloud + +import ( + "github.com/wavetermdev/waveterm/pkg/telemetry" +) + +type NoTelemetryInputType struct { + ClientId string `json:"clientid"` + Value bool `json:"value"` +} + +type TelemetryInputType struct { + UserId string `json:"userid"` + ClientId string `json:"clientid"` + CurDay string `json:"curday"` + DefaultShell string `json:"defaultshell"` + Activity []*telemetry.ActivityType `json:"activity"` +} diff --git a/pkg/wconfig/defaultconfig/defaultconfig.go b/pkg/wconfig/defaultconfig/defaultconfig.go new file mode 100644 index 000000000..bc28a9557 --- /dev/null +++ b/pkg/wconfig/defaultconfig/defaultconfig.go @@ -0,0 +1,9 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package defaultconfig + +import "embed" + +//go:embed *.json +var ConfigFS embed.FS diff --git a/pkg/wconfig/defaultconfig/defaultwidgets.json b/pkg/wconfig/defaultconfig/defaultwidgets.json new file mode 100644 index 000000000..3f6ebc863 --- /dev/null +++ b/pkg/wconfig/defaultconfig/defaultwidgets.json @@ -0,0 +1,55 @@ +{ + "defwidget@terminal": { + "display:order": 1, + "icon": "square-terminal", + "label": "terminal", + "blockdef": { + "meta": { + "view": "term", + "controller": "shell" + } + } + }, + "defwidget@files": { + "display:order": 2, + "icon": "folder", + "label": "files", + "blockdef": { + "meta": { + "view": "preview", + "file": "~" + } + } + }, + "defwidget@web": { + "display:order": 3, + "icon": "globe", + "label": "web", + "blockdef": { + "meta": { + "view": "web", + "url": "https://waveterm.dev/" + } + } + }, + "defwidget@ai": { + "display:order": 4, + "icon": "sparkles", + "label": "ai", + "blockdef": { + "meta": { + "view": "waveai" + } + } + }, + "defwidget@cpuplot": { + "display:order": 5, + "icon": "chart-line", + "label": "cpu", + "blockdef": { + "meta": { + "view": "cpuplot" + } + } + } +} diff --git a/pkg/wconfig/defaultconfig/mimetypes.json b/pkg/wconfig/defaultconfig/mimetypes.json new file mode 100644 index 000000000..f17583143 --- /dev/null +++ b/pkg/wconfig/defaultconfig/mimetypes.json @@ -0,0 +1,72 @@ +{ + "audio": { + "icon": "file-audio" + }, + "application/pdf": { + "icon": "file-pdf" + }, + "application/javascript": { + "icon": "js fa-brands" + }, + "application/typescript": { + "icon": "js fa-brands" + }, + "application/json": { + "icon": "file-lines" + }, + "directory": { + "icon": "folder", + "color": "var(--term-bright-blue)" + }, + "font": { + "icon": "book-font" + }, + "image": { + "icon": "file-image" + }, + "text": { + "icon": "file-lines" + }, + "text/css": { + "icon": "css3-alt fa-brands" + }, + "text/javascript": { + "icon": "js fa-brands" + }, + "text/typescript": { + "icon": "js fa-brands" + }, + "text/golang": { + "icon": "golang fa-brands" + }, + "text/html": { + "icon": "html5 fa-brands" + }, + "text/less": { + "icon": "less fa-brands" + }, + "text/markdown": { + "icon": "markdown fa-brands" + }, + "text/rust": { + "icon": "rust fa-brands" + }, + "text/scss": { + "icon": "sass fa-brands" + }, + "video": { + "icon": "file-video" + }, + "text/csv": { + "icon": "file-csv" + }, + "text/x-dart": { + "icon": "dart-lang fa-brands" + }, + "text/x-go": { + "icon": "golang fa-brands" + }, + "text/x-rust": { + "icon": "rust fa-brands" + } +} diff --git a/pkg/wconfig/defaultconfig/presets.json b/pkg/wconfig/defaultconfig/presets.json new file mode 100644 index 000000000..9e0ee9b92 --- /dev/null +++ b/pkg/wconfig/defaultconfig/presets.json @@ -0,0 +1,32 @@ +{ + "bg@default": { + "display:name": "Default", + "display:order": -1, + "bg:*": true + }, + "bg@rainbow": { + "display:name": "Rainbow", + "display:order": 1, + "bg:*": true, + "bg": "linear-gradient( 226.4deg, rgba(255,26,1,1) 28.9%, rgba(254,155,1,1) 33%, rgba(255,241,0,1) 48.6%, rgba(34,218,1,1) 65.3%, rgba(0,141,254,1) 80.6%, rgba(113,63,254,1) 100.1% )", + "bg:opacity": 0.3 + }, + "bg@green": { + "display:name": "Green", + "bg:*": true, + "bg": "green", + "bg:opacity": 0.3 + }, + "bg@blue": { + "display:name": "Blue", + "bg:*": true, + "bg": "blue", + "bg:opacity": 0.3 + }, + "bg@red": { + "display:name": "Red", + "bg:*": true, + "bg": "red", + "bg:opacity": 0.3 + } +} diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json new file mode 100644 index 000000000..fcb805cb1 --- /dev/null +++ b/pkg/wconfig/defaultconfig/settings.json @@ -0,0 +1,11 @@ +{ + "ai:model": "gpt-4o-mini", + "ai:maxtokens": 1000, + "ai:timeoutms": 10000, + "autoupdate:enabled": true, + "autoupdate:installonquit": true, + "autoupdate:intervalms": 3600000, + "editor:minimapenabled": true, + "window:tilegapsize": 3, + "telemetry:enabled": true +} diff --git a/pkg/wconfig/defaultconfig/termthemes.json b/pkg/wconfig/defaultconfig/termthemes.json new file mode 100644 index 000000000..663564650 --- /dev/null +++ b/pkg/wconfig/defaultconfig/termthemes.json @@ -0,0 +1,106 @@ +{ + "default-dark": { + "display:name": "Default Dark", + "display:order": 1, + "black": "#757575", + "red": "#cc685c", + "green": "#76c266", + "yellow": "#cbca9b", + "blue": "#85aacb", + "magenta": "#cc72ca", + "cyan": "#74a7cb", + "white": "#c1c1c1", + "brightBlack": "#727272", + "brightRed": "#cc9d97", + "brightGreen": "#a3dd97", + "brightYellow": "#cbcaaa", + "brightBlue": "#9ab6cb", + "brightMagenta": "#cc8ecb", + "brightCyan": "#b7b8cb", + "brightWhite": "#f0f0f0", + "gray": "#8b918a", + "cmdtext": "#f0f0f0", + "foreground": "#c1c1c1", + "selectionBackground": "", + "background": "#00000077", + "cursorAccent": "" + }, + "dracula": { + "display:name": "Dracula", + "display:order": 2, + "black": "#21222C", + "red": "#FF5555", + "green": "#50FA7B", + "yellow": "#F1FA8C", + "blue": "#BD93F9", + "magenta": "#FF79C6", + "cyan": "#8BE9FD", + "white": "#F8F8F2", + "brightBlack": "#6272A4", + "brightRed": "#FF6E6E", + "brightGreen": "#69FF94", + "brightYellow": "#FFFFA5", + "brightBlue": "#D6ACFF", + "brightMagenta": "#FF92DF", + "brightCyan": "#A4FFFF", + "brightWhite": "#FFFFFF", + "gray": "#6272A4", + "cmdtext": "#F8F8F2", + "foreground": "#F8F8F2", + "selectionBackground": "#44475a", + "background": "#282a36", + "cursorAccent": "#f8f8f2" + }, + "monokai": { + "display:name": "Monokai", + "display:order": 3, + "black": "#1B1D1E", + "red": "#F92672", + "green": "#A6E22E", + "yellow": "#E6DB74", + "blue": "#66D9EF", + "magenta": "#AE81FF", + "cyan": "#A1EFE4", + "white": "#F8F8F2", + "brightBlack": "#75715E", + "brightRed": "#FD5FF1", + "brightGreen": "#A6E22E", + "brightYellow": "#E6DB74", + "brightBlue": "#66D9EF", + "brightMagenta": "#AE81FF", + "brightCyan": "#A1EFE4", + "brightWhite": "#F9F8F5", + "gray": "#75715E", + "cmdtext": "#F8F8F2", + "foreground": "#F8F8F2", + "selectionBackground": "#49483E", + "background": "#272822", + "cursorAccent": "#F8F8F2" + }, + "campbell": { + "display:name": "Campbell", + "display:order": 4, + "black": "#0C0C0C", + "red": "#C50F1F", + "green": "#13A10E", + "yellow": "#C19C00", + "blue": "#0037DA", + "magenta": "#881798", + "cyan": "#3A96DD", + "white": "#CCCCCC", + "brightBlack": "#767676", + "brightRed": "#E74856", + "brightGreen": "#16C60C", + "brightYellow": "#F9F1A5", + "brightBlue": "#3B78FF", + "brightMagenta": "#B4009E", + "brightCyan": "#61D6D6", + "brightWhite": "#F2F2F2", + "gray": "#767676", + "cmdtext": "#CCCCCC", + "foreground": "#CCCCCC", + "selectionBackground": "#3A96DD", + "background": "#0C0C0C", + "cursorAccent": "#CCCCCC" + } +} diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go new file mode 100644 index 000000000..f8ce9fd93 --- /dev/null +++ b/pkg/wconfig/filewatcher.go @@ -0,0 +1,138 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wconfig + +import ( + "log" + "path/filepath" + "regexp" + "sync" + + "github.com/fsnotify/fsnotify" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wps" +) + +var configDirAbsPath = filepath.Join(wavebase.GetWaveHomeDir(), wavebase.ConfigDir) + +var instance *Watcher +var once sync.Once + +type Watcher struct { + initialized bool + watcher *fsnotify.Watcher + mutex sync.Mutex + fullConfig FullConfigType +} + +type WatcherUpdate struct { + FullConfig FullConfigType `json:"fullconfig"` +} + +// GetWatcher returns the singleton instance of the Watcher +func GetWatcher() *Watcher { + once.Do(func() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("failed to create file watcher: %v", err) + return + } + instance = &Watcher{watcher: watcher} + err = instance.watcher.Add(configDirAbsPath) + if err != nil { + log.Printf("failed to add path %s to watcher: %v", configDirAbsPath, err) + } + }) + return instance +} + +func (w *Watcher) Start() { + w.mutex.Lock() + defer w.mutex.Unlock() + + log.Printf("starting file watcher\n") + w.initialized = true + w.sendInitialValues() + + go func() { + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + w.handleEvent(event) + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + log.Println("watcher error:", err) + } + } + }() +} + +// for initial values, exit on first error +func (w *Watcher) sendInitialValues() error { + w.fullConfig = ReadFullConfig() + message := WatcherUpdate{ + FullConfig: w.fullConfig, + } + w.broadcast(message) + return nil +} + +func (w *Watcher) Close() { + w.mutex.Lock() + defer w.mutex.Unlock() + if w.watcher != nil { + w.watcher.Close() + w.watcher = nil + log.Println("file watcher closed") + } +} + +func (w *Watcher) broadcast(message WatcherUpdate) { + // send to frontend + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_Config, + Data: message, + }) +} + +func (w *Watcher) GetFullConfig() FullConfigType { + w.mutex.Lock() + defer w.mutex.Unlock() + return w.fullConfig +} + +func (w *Watcher) handleEvent(event fsnotify.Event) { + w.mutex.Lock() + defer w.mutex.Unlock() + + fileName := filepath.ToSlash(event.Name) + if event.Op == fsnotify.Chmod { + return + } + if !isValidSubSettingsFileName(fileName) { + return + } + w.handleSettingsFileEvent(event, fileName) +} + +var validFileRe = regexp.MustCompile(`^[a-zA-Z0-9_@.-]+\.json$`) + +func isValidSubSettingsFileName(fileName string) bool { + if filepath.Ext(fileName) != ".json" { + return false + } + baseName := filepath.Base(fileName) + return validFileRe.MatchString(baseName) +} + +func (w *Watcher) handleSettingsFileEvent(event fsnotify.Event, fileName string) { + fullConfig := ReadFullConfig() + w.fullConfig = fullConfig + w.broadcast(WatcherUpdate{FullConfig: w.fullConfig}) +} diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go new file mode 100644 index 000000000..b01dbcd2a --- /dev/null +++ b/pkg/wconfig/metaconsts.go @@ -0,0 +1,51 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Generated Code. DO NOT EDIT. + +package wconfig + +const ( + ConfigKey_AiClear = "ai:*" + ConfigKey_AiBaseURL = "ai:baseurl" + ConfigKey_AiApiToken = "ai:apitoken" + ConfigKey_AiName = "ai:name" + ConfigKey_AiModel = "ai:model" + ConfigKey_AiMaxTokens = "ai:maxtokens" + ConfigKey_AiTimeoutMs = "ai:timeoutms" + + ConfigKey_TermClear = "term:*" + ConfigKey_TermFontSize = "term:fontsize" + ConfigKey_TermFontFamily = "term:fontfamily" + ConfigKey_TermDisableWebGl = "term:disablewebgl" + + ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" + ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" + + ConfigKey_WebClear = "web:*" + ConfigKey_WebOpenLinksInternally = "web:openlinksinternally" + + ConfigKey_BlockHeaderClear = "blockheader:*" + ConfigKey_BlockHeaderShowBlockIds = "blockheader:showblockids" + + ConfigKey_AutoUpdateClear = "autoupdate:*" + ConfigKey_AutoUpdateEnabled = "autoupdate:enabled" + ConfigKey_AutoUpdateIntervalMs = "autoupdate:intervalms" + ConfigKey_AutoUpdateInstallOnQuit = "autoupdate:installonquit" + ConfigKey_AutoUpdateChannel = "autoupdate:channel" + + ConfigKey_WidgetClear = "widget:*" + ConfigKey_WidgetShowHelp = "widget:showhelp" + + ConfigKey_WindowClear = "window:*" + ConfigKey_WindowTransparent = "window:transparent" + ConfigKey_WindowBlur = "window:blur" + ConfigKey_WindowOpacity = "window:opacity" + ConfigKey_WindowBgColor = "window:bgcolor" + ConfigKey_WindowReducedMotion = "window:reducedmotion" + ConfigKey_WindowTileGapSize = "window:tilegapsize" + + ConfigKey_TelemetryClear = "telemetry:*" + ConfigKey_TelemetryEnabled = "telemetry:enabled" +) + diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go new file mode 100644 index 000000000..8a68b591d --- /dev/null +++ b/pkg/wconfig/settingsconfig.go @@ -0,0 +1,351 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wconfig + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" +) + +const SettingsFile = "settings.json" + +type MetaSettingsType struct { + waveobj.MetaMapType +} + +func (m *MetaSettingsType) UnmarshalJSON(data []byte) error { + var metaMap waveobj.MetaMapType + if err := json.Unmarshal(data, &metaMap); err != nil { + return err + } + *m = MetaSettingsType{MetaMapType: metaMap} + return nil +} + +func (m MetaSettingsType) MarshalJSON() ([]byte, error) { + return json.Marshal(m.MetaMapType) +} + +type SettingsType struct { + AiClear bool `json:"ai:*,omitempty"` + AiBaseURL string `json:"ai:baseurl,omitempty"` + AiApiToken string `json:"ai:apitoken,omitempty"` + AiName string `json:"ai:name,omitempty"` + AiModel string `json:"ai:model,omitempty"` + AiMaxTokens float64 `json:"ai:maxtokens,omitempty"` + AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"` + + TermClear bool `json:"term:*,omitempty"` + TermFontSize float64 `json:"term:fontsize,omitempty"` + TermFontFamily string `json:"term:fontfamily,omitempty"` + TermDisableWebGl bool `json:"term:disablewebgl,omitempty"` + + EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` + EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` + + WebClear bool `json:"web:*,omitempty"` + WebOpenLinksInternally bool `json:"web:openlinksinternally,omitempty"` + + BlockHeaderClear bool `json:"blockheader:*,omitempty"` + BlockHeaderShowBlockIds bool `json:"blockheader:showblockids,omitempty"` + + AutoUpdateClear bool `json:"autoupdate:*,omitempty"` + AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` + AutoUpdateIntervalMs float64 `json:"autoupdate:intervalms,omitempty"` + AutoUpdateInstallOnQuit bool `json:"autoupdate:installonquit,omitempty"` + AutoUpdateChannel string `json:"autoupdate:channel,omitempty"` + + WidgetClear bool `json:"widget:*,omitempty"` + WidgetShowHelp bool `json:"widget:showhelp,omitempty"` + + WindowClear bool `json:"window:*,omitempty"` + WindowTransparent bool `json:"window:transparent,omitempty"` + WindowBlur bool `json:"window:blur,omitempty"` + WindowOpacity *float64 `json:"window:opacity,omitempty"` + WindowBgColor string `json:"window:bgcolor,omitempty"` + WindowReducedMotion bool `json:"window:reducedmotion,omitempty"` + WindowTileGapSize *int8 `json:"window:tilegapsize,omitempty"` + + TelemetryClear bool `json:"telemetry:*,omitempty"` + TelemetryEnabled bool `json:"telemetry:enabled,omitempty"` +} + +type ConfigError struct { + File string `json:"file"` + Err string `json:"err"` +} + +type FullConfigType struct { + Settings SettingsType `json:"settings" merge:"meta"` + MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` + DefaultWidgets map[string]WidgetConfigType `json:"defaultwidgets"` + Widgets map[string]WidgetConfigType `json:"widgets"` + Presets map[string]waveobj.MetaMapType `json:"presets"` + TermThemes map[string]TermThemeType `json:"termthemes"` + ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` +} + +var settingsAbsPath = filepath.Join(configDirAbsPath, SettingsFile) + +func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.MetaMapType, []ConfigError) { + var cerrs []ConfigError + if readErr != nil && !os.IsNotExist(readErr) { + cerrs = append(cerrs, ConfigError{File: "defaults:" + fileName, Err: readErr.Error()}) + } + if len(barr) == 0 { + return nil, cerrs + } + var rtn waveobj.MetaMapType + err := json.Unmarshal(barr, &rtn) + if err != nil { + cerrs = append(cerrs, ConfigError{File: "defaults:" + fileName, Err: err.Error()}) + } + return rtn, cerrs +} + +func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { + barr, readErr := defaultconfig.ConfigFS.ReadFile(fileName) + return readConfigHelper("defaults:"+fileName, barr, readErr) +} + +func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { + fullFileName := filepath.Join(configDirAbsPath, fileName) + barr, err := os.ReadFile(fullFileName) + return readConfigHelper(fullFileName, barr, err) +} + +func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { + fullFileName := filepath.Join(configDirAbsPath, fileName) + barr, err := jsonMarshalConfigInOrder(m) + if err != nil { + return err + } + return os.WriteFile(fullFileName, barr, 0644) +} + +// simple merge that overwrites +func mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) waveobj.MetaMapType { + if m == nil { + return toMerge + } + if toMerge == nil { + return m + } + for k, v := range toMerge { + if v == nil { + delete(m, k) + continue + } + m[k] = v + } + if len(m) == 0 { + return nil + } + return m +} + +func ReadConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + defConfig, cerrs1 := ReadDefaultsConfigFile(partName) + userConfig, cerrs2 := ReadWaveHomeConfigFile(partName) + allErrs := append(cerrs1, cerrs2...) + if simpleMerge { + return mergeMetaMapSimple(defConfig, userConfig), allErrs + } else { + return waveobj.MergeMeta(defConfig, userConfig, true), allErrs + } +} + +func ReadFullConfig() FullConfigType { + var fullConfig FullConfigType + configRType := reflect.TypeOf(fullConfig) + configRVal := reflect.ValueOf(&fullConfig).Elem() + for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { + field := configRType.Field(fieldIdx) + if field.PkgPath != "" { + continue + } + configFile := field.Tag.Get("configfile") + if configFile == "-" { + continue + } + jsonTag := utilfn.GetJsonTag(field) + if jsonTag == "-" || jsonTag == "" { + continue + } + simpleMerge := field.Tag.Get("merge") == "" + fileName := jsonTag + ".json" + configPart, cerrs := ReadConfigPart(fileName, simpleMerge) + fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, cerrs...) + if configPart != nil { + fieldPtr := configRVal.Field(fieldIdx).Addr().Interface() + utilfn.ReUnmarshal(fieldPtr, configPart) + } + } + return fullConfig +} + +func getConfigKeyType(configKey string) reflect.Type { + ctype := reflect.TypeOf(SettingsType{}) + for i := 0; i < ctype.NumField(); i++ { + field := ctype.Field(i) + jsonTag := utilfn.GetJsonTag(field) + if jsonTag == configKey { + return field.Type + } + } + return nil +} + +func getConfigKeyNamespace(key string) string { + colonIdx := strings.Index(key, ":") + if colonIdx == -1 { + return "" + } + return key[:colonIdx] +} + +func orderConfigKeys(m waveobj.MetaMapType) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + k1 := keys[i] + k2 := keys[j] + k1ns := getConfigKeyNamespace(k1) + k2ns := getConfigKeyNamespace(k2) + if k1ns != k2ns { + return k1ns < k2ns + } + return k1 < k2 + }) + return keys +} + +func reindentJson(barr []byte, indentStr string) []byte { + if len(barr) < 2 { + return barr + } + if barr[0] != '{' && barr[0] != '[' { + return barr + } + if bytes.Index(barr, []byte("\n")) == -1 { + return barr + } + outputLines := bytes.Split(barr, []byte("\n")) + for i, line := range outputLines { + if i == 0 || i == len(outputLines)-1 { + continue + } + outputLines[i] = append([]byte(indentStr), line...) + } + return bytes.Join(outputLines, []byte("\n")) +} + +func jsonMarshalConfigInOrder(m waveobj.MetaMapType) ([]byte, error) { + if len(m) == 0 { + return []byte("{}"), nil + } + var buf bytes.Buffer + orderedKeys := orderConfigKeys(m) + buf.WriteString("{\n") + for idx, key := range orderedKeys { + val := m[key] + keyBarr, err := json.Marshal(key) + if err != nil { + return nil, err + } + valBarr, err := json.MarshalIndent(val, "", " ") + if err != nil { + return nil, err + } + valBarr = reindentJson(valBarr, " ") + buf.WriteString(" ") + buf.Write(keyBarr) + buf.WriteString(": ") + buf.Write(valBarr) + if idx < len(orderedKeys)-1 { + buf.WriteString(",") + } + buf.WriteString("\n") + } + buf.WriteString("}") + return buf.Bytes(), nil +} + +func SetBaseConfigValue(toMerge waveobj.MetaMapType) error { + m, cerrs := ReadWaveHomeConfigFile(SettingsFile) + if len(cerrs) > 0 { + return fmt.Errorf("error reading config file: %v", cerrs[0]) + } + if m == nil { + m = make(waveobj.MetaMapType) + } + for configKey, val := range toMerge { + ctype := getConfigKeyType(configKey) + if ctype == nil { + return fmt.Errorf("invalid config key: %s", configKey) + } + if val == nil { + delete(m, configKey) + } else { + if reflect.TypeOf(val) != ctype { + return fmt.Errorf("invalid value type for %s: %T", configKey, val) + } + m[configKey] = val + } + } + return WriteWaveHomeConfigFile(SettingsFile, m) +} + +type WidgetConfigType struct { + DisplayOrder float64 `json:"display:order,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` + BlockDef waveobj.BlockDef `json:"blockdef"` +} + +type MimeTypeConfigType struct { + Icon string `json:"icon"` + Color string `json:"color"` +} + +type TermThemeType struct { + DisplayName string `json:"display:name"` + DisplayOrder float64 `json:"display:order"` + Black string `json:"black"` + Red string `json:"red"` + Green string `json:"green"` + Yellow string `json:"yellow"` + Blue string `json:"blue"` + Magenta string `json:"magenta"` + Cyan string `json:"cyan"` + White string `json:"white"` + BrightBlack string `json:"brightBlack"` + BrightRed string `json:"brightRed"` + BrightGreen string `json:"brightGreen"` + BrightYellow string `json:"brightYellow"` + BrightBlue string `json:"brightBlue"` + BrightMagenta string `json:"brightMagenta"` + BrightCyan string `json:"brightCyan"` + BrightWhite string `json:"brightWhite"` + Gray string `json:"gray"` + CmdText string `json:"cmdtext"` + Foreground string `json:"foreground"` + SelectionBackground string `json:"selectionBackground"` + Background string `json:"background"` + CursorAccent string `json:"cursorAccent"` +} diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go new file mode 100644 index 000000000..030724199 --- /dev/null +++ b/pkg/wcore/wcore.go @@ -0,0 +1,180 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// wave core application coordinator +package wcore + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/blockcontroller" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +// the wcore package coordinates actions across the storage layer +// orchestrating the wave object store, the wave pubsub system, and the wave rpc system + +// TODO bring Tx infra into wcore + +const DefaultTimeout = 2 * time.Second +const DefaultActivateBlockTimeout = 60 * time.Second + +func DeleteBlock(ctx context.Context, tabId string, blockId string) error { + err := wstore.DeleteBlock(ctx, tabId, blockId) + if err != nil { + return fmt.Errorf("error deleting block: %w", err) + } + go blockcontroller.StopBlockController(blockId) + sendBlockCloseEvent(tabId, blockId) + return nil +} + +func sendBlockCloseEvent(tabId string, blockId string) { + waveEvent := wps.WaveEvent{ + Event: wps.Event_BlockClose, + Scopes: []string{ + waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), + waveobj.MakeORef(waveobj.OType_Block, blockId).String(), + }, + Data: blockId, + } + wps.Broker.Publish(waveEvent) +} + +func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { + tabData, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) + if err != nil { + return fmt.Errorf("error getting tab: %w", err) + } + if tabData == nil { + return nil + } + // close blocks (sends events + stops block controllers) + for _, blockId := range tabData.BlockIds { + err := DeleteBlock(ctx, tabId, blockId) + if err != nil { + return fmt.Errorf("error deleting block %s: %w", blockId, err) + } + } + // now delete tab (also deletes layout) + err = wstore.DeleteTab(ctx, workspaceId, tabId) + if err != nil { + return fmt.Errorf("error deleting tab: %w", err) + } + + return nil +} + +// returns tabid +func CreateTab(ctx context.Context, windowId string, tabName string, activateTab bool) (string, error) { + windowData, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) + if err != nil { + return "", fmt.Errorf("error getting window: %w", err) + } + tab, err := wstore.CreateTab(ctx, windowData.WorkspaceId, tabName) + if err != nil { + return "", fmt.Errorf("error creating tab: %w", err) + } + if activateTab { + err = wstore.SetActiveTab(ctx, windowId, tab.OID) + if err != nil { + return "", fmt.Errorf("error setting active tab: %w", err) + } + } + return tab.OID, nil +} + +func CreateWindow(ctx context.Context, winSize *waveobj.WinSize) (*waveobj.Window, error) { + windowId := uuid.NewString() + workspaceId := uuid.NewString() + if winSize == nil { + winSize = &waveobj.WinSize{ + Width: 1200, + Height: 850, + } + } + window := &waveobj.Window{ + OID: windowId, + WorkspaceId: workspaceId, + Pos: waveobj.Point{ + X: 100, + Y: 100, + }, + WinSize: *winSize, + } + err := wstore.DBInsert(ctx, window) + if err != nil { + return nil, fmt.Errorf("error inserting window: %w", err) + } + ws := &waveobj.Workspace{ + OID: workspaceId, + Name: "w" + workspaceId[0:8], + } + err = wstore.DBInsert(ctx, ws) + if err != nil { + return nil, fmt.Errorf("error inserting workspace: %w", err) + } + _, err = CreateTab(ctx, windowId, "T1", true) + if err != nil { + return nil, fmt.Errorf("error inserting tab: %w", err) + } + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return nil, fmt.Errorf("error getting client: %w", err) + } + client.WindowIds = append(client.WindowIds, windowId) + err = wstore.DBUpdate(ctx, client) + if err != nil { + return nil, fmt.Errorf("error updating client: %w", err) + } + return wstore.DBMustGet[*waveobj.Window](ctx, windowId) +} + +// returns (new-window, first-time, error) +func EnsureInitialData() (*waveobj.Window, bool, error) { + // does not need to run in a transaction since it is called on startup + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + firstRun := false + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err == wstore.ErrNotFound { + client, err = CreateClient(ctx) + if err != nil { + return nil, false, fmt.Errorf("error creating client: %w", err) + } + firstRun = true + } + if len(client.WindowIds) > 0 { + return nil, false, nil + } + window, err := CreateWindow(ctx, &waveobj.WinSize{Height: 0, Width: 0}) + if err != nil { + return nil, false, fmt.Errorf("error creating window: %w", err) + } + return window, firstRun, nil +} + +func CreateClient(ctx context.Context) (*waveobj.Client, error) { + client := &waveobj.Client{ + OID: uuid.NewString(), + WindowIds: []string{}, + } + err := wstore.DBInsert(ctx, client) + if err != nil { + return nil, fmt.Errorf("error inserting client: %w", err) + } + return client, nil +} + +func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { + blockData, err := wstore.CreateBlock(ctx, tabId, blockDef, rtOpts) + if err != nil { + return nil, fmt.Errorf("error creating block: %w", err) + } + return blockData, nil +} diff --git a/pkg/web/web.go b/pkg/web/web.go new file mode 100644 index 000000000..96b1e77c6 --- /dev/null +++ b/pkg/web/web.go @@ -0,0 +1,455 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package web + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "runtime/debug" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/wavetermdev/waveterm/pkg/authkey" + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/service" + "github.com/wavetermdev/waveterm/pkg/telemetry" + "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" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +type WebFnType = func(http.ResponseWriter, *http.Request) + +const TransparentGif64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + +// Header constants +const ( + CacheControlHeaderKey = "Cache-Control" + CacheControlHeaderNoCache = "no-cache" + + ContentTypeHeaderKey = "Content-Type" + ContentTypeJson = "application/json" + ContentTypeBinary = "application/octet-stream" + + ContentLengthHeaderKey = "Content-Length" + LastModifiedHeaderKey = "Last-Modified" + + WaveZoneFileInfoHeaderKey = "X-ZoneFileInfo" +) + +const HttpReadTimeout = 5 * time.Second +const HttpWriteTimeout = 21 * time.Second +const HttpMaxHeaderBytes = 60000 +const HttpTimeoutDuration = 21 * time.Second + +const WSStateReconnectTime = 30 * time.Second +const WSStatePacketChSize = 20 + +type WebFnOpts struct { + AllowCaching bool + JsonErrors bool +} + +func copyHeaders(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +type notFoundBlockingResponseWriter struct { + w http.ResponseWriter + status int + headers http.Header +} + +func (rw *notFoundBlockingResponseWriter) Header() http.Header { + return rw.headers +} + +func (rw *notFoundBlockingResponseWriter) WriteHeader(status int) { + if status == http.StatusNotFound { + rw.status = status + return + } + rw.status = status + copyHeaders(rw.w.Header(), rw.headers) + rw.w.WriteHeader(status) +} + +func (rw *notFoundBlockingResponseWriter) Write(b []byte) (int, error) { + if rw.status == http.StatusNotFound { + // Block the write if it's a 404 + return len(b), nil + } + if rw.status == 0 { + rw.WriteHeader(http.StatusOK) + } + return rw.w.Write(b) +} + +func handleService(w http.ResponseWriter, r *http.Request) { + bodyData, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + if r.Method != http.MethodPost { + http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) + return + } + var webCall service.WebCallType + err = json.Unmarshal(bodyData, &webCall) + if err != nil { + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) + } + + rtn := service.CallService(r.Context(), webCall) + jsonRtn, err := json.Marshal(rtn) + if err != nil { + http.Error(w, fmt.Sprintf("error serializing response: %v", err), http.StatusInternalServerError) + } + w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) + w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", len(jsonRtn))) + w.WriteHeader(http.StatusOK) + w.Write(jsonRtn) +} + +func marshalReturnValue(data any, err error) []byte { + var mapRtn = make(map[string]any) + if err != nil { + mapRtn["error"] = err.Error() + } else { + mapRtn["success"] = true + mapRtn["data"] = data + } + rtn, err := json.Marshal(mapRtn) + if err != nil { + return marshalReturnValue(nil, fmt.Errorf("error serializing response: %v", err)) + } + return rtn +} + +func handleWaveFile(w http.ResponseWriter, r *http.Request) { + zoneId := r.URL.Query().Get("zoneid") + name := r.URL.Query().Get("name") + offsetStr := r.URL.Query().Get("offset") + var offset int64 = 0 + if offsetStr != "" { + var err error + offset, err = strconv.ParseInt(offsetStr, 10, 64) + if err != nil { + http.Error(w, fmt.Sprintf("invalid offset: %v", err), http.StatusBadRequest) + } + } + if _, err := uuid.Parse(zoneId); err != nil { + http.Error(w, fmt.Sprintf("invalid zoneid: %v", err), http.StatusBadRequest) + return + } + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + + } + file, err := filestore.WFS.Stat(r.Context(), zoneId, name) + if err == fs.ErrNotExist { + w.WriteHeader(http.StatusNoContent) + return + } + if err != nil { + http.Error(w, fmt.Sprintf("error getting file info: %v", err), http.StatusInternalServerError) + return + } + jsonFileBArr, err := json.Marshal(file) + if err != nil { + http.Error(w, fmt.Sprintf("error serializing file info: %v", err), http.StatusInternalServerError) + } + // can make more efficient by checking modtime + If-Modified-Since headers to allow caching + dataStartIdx := file.DataStartIdx() + if offset >= dataStartIdx { + dataStartIdx = offset + } + w.Header().Set(ContentTypeHeaderKey, ContentTypeBinary) + w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", file.Size-dataStartIdx)) + w.Header().Set(WaveZoneFileInfoHeaderKey, base64.StdEncoding.EncodeToString(jsonFileBArr)) + w.Header().Set(LastModifiedHeaderKey, time.UnixMilli(file.ModTs).UTC().Format(http.TimeFormat)) + if dataStartIdx >= file.Size { + w.WriteHeader(http.StatusOK) + return + } + for offset := dataStartIdx; offset < file.Size; offset += filestore.DefaultPartDataSize { + _, data, err := filestore.WFS.ReadAt(r.Context(), zoneId, name, offset, filestore.DefaultPartDataSize) + if err != nil { + if offset == 0 { + http.Error(w, fmt.Sprintf("error reading file: %v", err), http.StatusInternalServerError) + } else { + // nothing to do, the headers have already been sent + log.Printf("error reading file %s/%s @ %d: %v\n", zoneId, name, offset, err) + } + return + } + w.Write(data) + } +} + +func serveTransparentGIF(w http.ResponseWriter) { + gifBytes, _ := base64.StdEncoding.DecodeString(TransparentGif64) + w.Header().Set("Content-Type", "image/gif") + w.WriteHeader(http.StatusOK) + w.Write(gifBytes) +} + +func handleLocalStreamFile(w http.ResponseWriter, r *http.Request, fileName string, no404 bool) { + if no404 { + log.Printf("streaming file w/no404: %q\n", fileName) + // use the custom response writer + rw := ¬FoundBlockingResponseWriter{w: w, headers: http.Header{}} + // Serve the file using http.ServeFile + http.ServeFile(rw, r, fileName) + // if the file was not found, serve the transparent GIF + log.Printf("got streamfile status: %d\n", rw.status) + if rw.status == http.StatusNotFound { + serveTransparentGIF(w) + } + } else { + fileName = wavebase.ExpandHomeDir(fileName) + http.ServeFile(w, r, fileName) + } +} + +func handleRemoteStreamFile(w http.ResponseWriter, r *http.Request, conn string, fileName string, no404 bool) error { + client := wshserver.GetMainRpcClient() + streamFileData := wshrpc.CommandRemoteStreamFileData{Path: fileName} + route := wshutil.MakeConnectionRouteId(conn) + rtnCh := wshclient.RemoteStreamFileCommand(client, streamFileData, &wshrpc.RpcOpts{Route: route}) + firstPk := true + var fileInfo *wshrpc.FileInfo + loopDone := false + defer func() { + if loopDone { + return + } + // if loop didn't finish naturally clear it out + go func() { + for range rtnCh { + } + }() + }() + for respUnion := range rtnCh { + if respUnion.Error != nil { + return respUnion.Error + } + if firstPk { + firstPk = false + if len(respUnion.Response.FileInfo) != 1 { + return fmt.Errorf("stream file protocol error, first pk fileinfo len=%d", len(respUnion.Response.FileInfo)) + } + fileInfo = respUnion.Response.FileInfo[0] + if fileInfo.NotFound { + if no404 { + serveTransparentGIF(w) + return nil + } else { + return fmt.Errorf("file not found: %q", fileName) + } + } + if fileInfo.IsDir { + return fmt.Errorf("cannot stream directory: %q", fileName) + } + w.Header().Set(ContentTypeHeaderKey, fileInfo.MimeType) + w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", fileInfo.Size)) + continue + } + if respUnion.Response.Data64 == "" { + continue + } + decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader([]byte(respUnion.Response.Data64))) + _, err := io.Copy(w, decoder) + if err != nil { + log.Printf("error streaming file %q: %v\n", fileName, err) + // not sure what to do here, the headers have already been sent. + // just return + return nil + } + } + loopDone = true + return nil +} + +func handleStreamFile(w http.ResponseWriter, r *http.Request) { + conn := r.URL.Query().Get("connection") + if conn == "" { + conn = wshrpc.LocalConnName + } + fileName := r.URL.Query().Get("path") + if fileName == "" { + http.Error(w, "path is required", http.StatusBadRequest) + return + } + no404 := r.URL.Query().Get("no404") + if conn == wshrpc.LocalConnName { + handleLocalStreamFile(w, r, fileName, no404 != "") + } else { + err := handleRemoteStreamFile(w, r, conn, fileName, no404 != "") + if err != nil { + log.Printf("error streaming remote file %q %q: %v\n", conn, fileName, err) + http.Error(w, fmt.Sprintf("error streaming file: %v", err), http.StatusInternalServerError) + } + } +} + +func WriteJsonError(w http.ResponseWriter, errVal error) { + w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) + w.WriteHeader(http.StatusOK) + errMap := make(map[string]interface{}) + errMap["error"] = errVal.Error() + barr, _ := json.Marshal(errMap) + w.Write(barr) +} + +func WriteJsonSuccess(w http.ResponseWriter, data interface{}) { + w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) + rtnMap := make(map[string]interface{}) + rtnMap["success"] = true + if data != nil { + rtnMap["data"] = data + } + barr, err := json.Marshal(rtnMap) + if err != nil { + WriteJsonError(w, err) + return + } + w.WriteHeader(http.StatusOK) + w.Write(barr) +} + +type ClientActiveState struct { + Fg bool `json:"fg"` + Active bool `json:"active"` + Open bool `json:"open"` +} + +// params: fg, active, open +func handleLogActiveState(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var activeState ClientActiveState + err := decoder.Decode(&activeState) + if err != nil { + WriteJsonError(w, fmt.Errorf("error decoding json: %v", err)) + return + } + activity := telemetry.ActivityUpdate{} + if activeState.Fg { + activity.FgMinutes = 1 + } + if activeState.Active { + activity.ActiveMinutes = 1 + } + if activeState.Open { + activity.OpenMinutes = 1 + } + activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](r.Context()) + err = telemetry.UpdateActivity(r.Context(), activity) + if err != nil { + WriteJsonError(w, fmt.Errorf("error updating activity: %w", err)) + return + } + WriteJsonSuccess(w, true) +} + +func WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + recErr := recover() + if recErr == nil { + return + } + panicStr := fmt.Sprintf("panic: %v", recErr) + log.Printf("panic: %v\n", recErr) + debug.PrintStack() + if opts.JsonErrors { + jsonRtn := marshalReturnValue(nil, fmt.Errorf(panicStr)) + w.Header().Set(ContentTypeHeaderKey, ContentTypeJson) + w.Header().Set(ContentLengthHeaderKey, fmt.Sprintf("%d", len(jsonRtn))) + w.WriteHeader(http.StatusOK) + w.Write(jsonRtn) + } else { + http.Error(w, panicStr, http.StatusInternalServerError) + } + }() + if !opts.AllowCaching { + w.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache) + } + w.Header().Set("Access-Control-Expose-Headers", "X-ZoneFileInfo") + err := authkey.ValidateIncomingRequest(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) + return + } + fn(w, r) + } +} + +func MakeTCPListener(serviceName string) (net.Listener, error) { + serverAddr := "127.0.0.1:" + rtn, err := net.Listen("tcp", serverAddr) + if err != nil { + return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) + } + log.Printf("Server [%s] listening on %s\n", serviceName, rtn.Addr()) + return rtn, nil +} + +func MakeUnixListener() (net.Listener, error) { + serverAddr := wavebase.GetWaveHomeDir() + "/wave.sock" + os.Remove(serverAddr) // ignore error + rtn, err := net.Listen("unix", serverAddr) + if err != nil { + return nil, fmt.Errorf("error creating listener at %v: %v", serverAddr, err) + } + os.Chmod(serverAddr, 0700) + log.Printf("Server [unix-domain] listening on %s\n", serverAddr) + return rtn, nil +} + +// blocking +func RunWebServer(listener net.Listener) { + gr := mux.NewRouter() + gr.HandleFunc("/wave/stream-file", WebFnWrap(WebFnOpts{AllowCaching: true}, handleStreamFile)) + gr.HandleFunc("/wave/file", WebFnWrap(WebFnOpts{AllowCaching: false}, handleWaveFile)) + gr.HandleFunc("/wave/service", WebFnWrap(WebFnOpts{JsonErrors: true}, handleService)) + gr.HandleFunc("/wave/log-active-state", WebFnWrap(WebFnOpts{JsonErrors: true}, handleLogActiveState)) + handler := http.TimeoutHandler(gr, HttpTimeoutDuration, "Timeout") + if wavebase.IsDevMode() { + handler = handlers.CORS(handlers.AllowedOrigins([]string{"*"}))(handler) + } + server := &http.Server{ + ReadTimeout: HttpReadTimeout, + WriteTimeout: HttpWriteTimeout, + MaxHeaderBytes: HttpMaxHeaderBytes, + Handler: handler, + } + err := server.Serve(listener) + if err != nil { + log.Printf("ERROR: %v\n", err) + } +} diff --git a/pkg/web/webcmd/webcmd.go b/pkg/web/webcmd/webcmd.go new file mode 100644 index 000000000..a025c2626 --- /dev/null +++ b/pkg/web/webcmd/webcmd.go @@ -0,0 +1,98 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package webcmd + +import ( + "fmt" + "reflect" + + "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const ( + WSCommand_SetBlockTermSize = "setblocktermsize" + WSCommand_BlockInput = "blockinput" + WSCommand_Rpc = "rpc" +) + +type WSCommandType interface { + GetWSCommand() string +} + +func WSCommandTypeUnionMeta() tsgenmeta.TypeUnionMeta { + return tsgenmeta.TypeUnionMeta{ + BaseType: reflect.TypeOf((*WSCommandType)(nil)).Elem(), + TypeFieldName: "wscommand", + Types: []reflect.Type{ + reflect.TypeOf(SetBlockTermSizeWSCommand{}), + reflect.TypeOf(BlockInputWSCommand{}), + reflect.TypeOf(WSRpcCommand{}), + }, + } +} + +type WSRpcCommand struct { + WSCommand string `json:"wscommand" tstype:"\"rpc\""` + Message *wshutil.RpcMessage `json:"message"` +} + +func (cmd *WSRpcCommand) GetWSCommand() string { + return cmd.WSCommand +} + +type SetBlockTermSizeWSCommand struct { + WSCommand string `json:"wscommand" tstype:"\"setblocktermsize\""` + BlockId string `json:"blockid"` + TermSize waveobj.TermSize `json:"termsize"` +} + +func (cmd *SetBlockTermSizeWSCommand) GetWSCommand() string { + return cmd.WSCommand +} + +type BlockInputWSCommand struct { + WSCommand string `json:"wscommand" tstype:"\"blockinput\""` + BlockId string `json:"blockid"` + InputData64 string `json:"inputdata64"` +} + +func (cmd *BlockInputWSCommand) GetWSCommand() string { + return cmd.WSCommand +} + +func ParseWSCommandMap(cmdMap map[string]any) (WSCommandType, error) { + cmdType, ok := cmdMap["wscommand"].(string) + if !ok { + return nil, fmt.Errorf("no wscommand field in command map") + } + switch cmdType { + case WSCommand_SetBlockTermSize: + var cmd SetBlockTermSizeWSCommand + err := utilfn.DoMapStructure(&cmd, cmdMap) + if err != nil { + return nil, fmt.Errorf("error decoding SetBlockTermSizeWSCommand: %w", err) + } + return &cmd, nil + case WSCommand_BlockInput: + var cmd BlockInputWSCommand + err := utilfn.DoMapStructure(&cmd, cmdMap) + if err != nil { + return nil, fmt.Errorf("error decoding BlockInputWSCommand: %w", err) + } + return &cmd, nil + case WSCommand_Rpc: + var cmd WSRpcCommand + err := utilfn.DoMapStructure(&cmd, cmdMap) + if err != nil { + return nil, fmt.Errorf("error decoding WSRpcCommand: %w", err) + } + return &cmd, nil + default: + return nil, fmt.Errorf("unknown wscommand type %q", cmdType) + } + +} diff --git a/pkg/web/ws.go b/pkg/web/ws.go new file mode 100644 index 000000000..4c605fa27 --- /dev/null +++ b/pkg/web/ws.go @@ -0,0 +1,306 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package web + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "runtime/debug" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + "github.com/wavetermdev/waveterm/pkg/authkey" + "github.com/wavetermdev/waveterm/pkg/eventbus" + "github.com/wavetermdev/waveterm/pkg/web/webcmd" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const wsReadWaitTimeout = 15 * time.Second +const wsWriteWaitTimeout = 10 * time.Second +const wsPingPeriodTickTime = 10 * time.Second +const wsInitialPingTime = 1 * time.Second + +const DefaultCommandTimeout = 2 * time.Second + +func RunWebSocketServer(listener net.Listener) { + gr := mux.NewRouter() + gr.HandleFunc("/ws", HandleWs) + server := &http.Server{ + ReadTimeout: HttpReadTimeout, + WriteTimeout: HttpWriteTimeout, + MaxHeaderBytes: HttpMaxHeaderBytes, + Handler: gr, + } + server.SetKeepAlivesEnabled(false) + log.Printf("Running websocket server on %s\n", listener.Addr()) + err := server.Serve(listener) + if err != nil { + log.Printf("[error] trying to run websocket server: %v\n", err) + } +} + +var WebSocketUpgrader = websocket.Upgrader{ + ReadBufferSize: 4 * 1024, + WriteBufferSize: 32 * 1024, + HandshakeTimeout: 1 * time.Second, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func HandleWs(w http.ResponseWriter, r *http.Request) { + err := HandleWsInternal(w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func getMessageType(jmsg map[string]any) string { + if str, ok := jmsg["type"].(string); ok { + return str + } + return "" +} + +func getStringFromMap(jmsg map[string]any, key string) string { + if str, ok := jmsg[key].(string); ok { + return str + } + return "" +} + +func processWSCommand(jmsg map[string]any, outputCh chan any, rpcInputCh chan []byte) { + var rtnErr error + defer func() { + r := recover() + if r != nil { + rtnErr = fmt.Errorf("panic: %v", r) + log.Printf("panic in processMessage: %v\n", r) + debug.PrintStack() + } + if rtnErr == nil { + return + } + rtn := map[string]any{"type": "error", "error": rtnErr.Error()} + outputCh <- rtn + }() + wsCommand, err := webcmd.ParseWSCommandMap(jmsg) + if err != nil { + rtnErr = fmt.Errorf("cannot parse wscommand: %v", err) + return + } + switch cmd := wsCommand.(type) { + case *webcmd.SetBlockTermSizeWSCommand: + data := wshrpc.CommandBlockInputData{ + BlockId: cmd.BlockId, + TermSize: &cmd.TermSize, + } + rpcMsg := wshutil.RpcMessage{ + Command: wshrpc.Command_ControllerInput, + Data: data, + } + msgBytes, err := json.Marshal(rpcMsg) + if err != nil { + // this really should never fail since we just unmarshalled this value + log.Printf("error marshalling rpc message: %v\n", err) + return + } + rpcInputCh <- msgBytes + + case *webcmd.BlockInputWSCommand: + data := wshrpc.CommandBlockInputData{ + BlockId: cmd.BlockId, + InputData64: cmd.InputData64, + } + rpcMsg := wshutil.RpcMessage{ + Command: wshrpc.Command_ControllerInput, + Data: data, + } + msgBytes, err := json.Marshal(rpcMsg) + if err != nil { + // this really should never fail since we just unmarshalled this value + log.Printf("error marshalling rpc message: %v\n", err) + return + } + rpcInputCh <- msgBytes + + case *webcmd.WSRpcCommand: + rpcMsg := cmd.Message + if rpcMsg == nil { + return + } + msgBytes, err := json.Marshal(rpcMsg) + if err != nil { + // this really should never fail since we just unmarshalled this value + return + } + rpcInputCh <- msgBytes + } +} + +func processMessage(jmsg map[string]any, outputCh chan any, rpcInputCh chan []byte) { + wsCommand := getStringFromMap(jmsg, "wscommand") + if wsCommand == "" { + return + } + processWSCommand(jmsg, outputCh, rpcInputCh) +} + +func ReadLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any, rpcInputCh chan []byte) { + readWait := wsReadWaitTimeout + conn.SetReadLimit(64 * 1024) + conn.SetReadDeadline(time.Now().Add(readWait)) + defer close(closeCh) + for { + _, message, err := conn.ReadMessage() + if err != nil { + log.Printf("ReadPump error: %v\n", err) + break + } + jmsg := map[string]any{} + err = json.Unmarshal(message, &jmsg) + if err != nil { + log.Printf("Error unmarshalling json: %v\n", err) + break + } + conn.SetReadDeadline(time.Now().Add(readWait)) + msgType := getMessageType(jmsg) + if msgType == "pong" { + // nothing + continue + } + if msgType == "ping" { + now := time.Now() + pongMessage := map[string]interface{}{"type": "pong", "stime": now.UnixMilli()} + outputCh <- pongMessage + continue + } + go processMessage(jmsg, outputCh, rpcInputCh) + } +} + +func WritePing(conn *websocket.Conn) error { + now := time.Now() + pingMessage := map[string]interface{}{"type": "ping", "stime": now.UnixMilli()} + jsonVal, _ := json.Marshal(pingMessage) + _ = conn.SetWriteDeadline(time.Now().Add(wsWriteWaitTimeout)) // no error + err := conn.WriteMessage(websocket.TextMessage, jsonVal) + if err != nil { + return err + } + return nil +} + +func WriteLoop(conn *websocket.Conn, outputCh chan any, closeCh chan any) { + ticker := time.NewTicker(wsInitialPingTime) + defer ticker.Stop() + initialPing := true + for { + select { + case msg := <-outputCh: + var barr []byte + var err error + if _, ok := msg.([]byte); ok { + barr = msg.([]byte) + } else { + barr, err = json.Marshal(msg) + if err != nil { + log.Printf("cannot marshal websocket message: %v\n", err) + // just loop again + break + } + } + err = conn.WriteMessage(websocket.TextMessage, barr) + if err != nil { + conn.Close() + log.Printf("WritePump error: %v\n", err) + return + } + + case <-ticker.C: + err := WritePing(conn) + if err != nil { + log.Printf("WritePump error: %v\n", err) + return + } + if initialPing { + initialPing = false + ticker.Reset(wsPingPeriodTickTime) + } + + case <-closeCh: + return + } + } +} + +func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { + windowId := r.URL.Query().Get("windowid") + if windowId == "" { + return fmt.Errorf("windowid is required") + } + + err := authkey.ValidateIncomingRequest(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) + return err + } + conn, err := WebSocketUpgrader.Upgrade(w, r, nil) + if err != nil { + return fmt.Errorf("WebSocket Upgrade Failed: %v", err) + } + defer conn.Close() + wsConnId := uuid.New().String() + log.Printf("New websocket connection: windowid:%s connid:%s\n", windowId, wsConnId) + outputCh := make(chan any, 100) + closeCh := make(chan any) + eventbus.RegisterWSChannel(wsConnId, windowId, outputCh) + var routeId string + if windowId == wshutil.ElectronRoute { + routeId = wshutil.ElectronRoute + } else { + routeId = wshutil.MakeWindowRouteId(windowId) + } + defer eventbus.UnregisterWSChannel(wsConnId) + // we create a wshproxy to handle rpc messages to/from the window + wproxy := wshutil.MakeRpcProxy() + wshutil.DefaultRouter.RegisterRoute(routeId, wproxy) + defer func() { + wshutil.DefaultRouter.UnregisterRoute(routeId) + close(wproxy.ToRemoteCh) + }() + // WshServerFactoryFn(rpcInputCh, rpcOutputCh, wshrpc.RpcContext{}) + wg := &sync.WaitGroup{} + wg.Add(2) + go func() { + // no waitgroup add here + // move values from rpcOutputCh to outputCh + for msgBytes := range wproxy.ToRemoteCh { + rpcWSMsg := map[string]any{ + "eventtype": "rpc", // TODO don't hard code this (but def is in eventbus) + "data": json.RawMessage(msgBytes), + } + outputCh <- rpcWSMsg + } + }() + go func() { + // read loop + defer wg.Done() + ReadLoop(conn, outputCh, closeCh, wproxy.FromRemoteCh) + }() + go func() { + // write loop + defer wg.Done() + WriteLoop(conn, outputCh, closeCh) + }() + wg.Wait() + close(wproxy.FromRemoteCh) + return nil +} diff --git a/pkg/wlayout/wlayout.go b/pkg/wlayout/wlayout.go new file mode 100644 index 000000000..9effdcc22 --- /dev/null +++ b/pkg/wlayout/wlayout.go @@ -0,0 +1,191 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wlayout + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wcore" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +const ( + LayoutActionDataType_Insert = "insert" + LayoutActionDataType_InsertAtIndex = "insertatindex" + LayoutActionDataType_Remove = "delete" + LayoutActionDataType_ClearTree = "clear" +) + +type PortableLayout []struct { + IndexArr []int `json:"indexarr"` + Size *uint `json:"size,omitempty"` + BlockDef *waveobj.BlockDef `json:"blockdef"` + Focused bool `json:"focused"` +} + +func GetStarterLayout() PortableLayout { + return PortableLayout{ + {IndexArr: []int{0}, BlockDef: &waveobj.BlockDef{ + Meta: waveobj.MetaMapType{ + waveobj.MetaKey_View: "term", + waveobj.MetaKey_Controller: "shell", + }, + }, Focused: true}, + {IndexArr: []int{1}, BlockDef: &waveobj.BlockDef{ + Meta: waveobj.MetaMapType{ + waveobj.MetaKey_View: "cpuplot", + }, + }}, + {IndexArr: []int{1, 1}, BlockDef: &waveobj.BlockDef{ + Meta: waveobj.MetaMapType{ + waveobj.MetaKey_View: "web", + waveobj.MetaKey_Url: "https://github.com/wavetermdev/waveterm", + }, + }}, + {IndexArr: []int{1, 2}, BlockDef: &waveobj.BlockDef{ + Meta: waveobj.MetaMapType{ + waveobj.MetaKey_View: "preview", + waveobj.MetaKey_File: "~", + }, + }}, + {IndexArr: []int{2}, BlockDef: &waveobj.BlockDef{ + Meta: waveobj.MetaMapType{ + waveobj.MetaKey_View: "help", + }, + }}, + {IndexArr: []int{2, 1}, BlockDef: &waveobj.BlockDef{ + Meta: waveobj.MetaMapType{ + waveobj.MetaKey_View: "waveai", + }, + }}, + // {IndexArr: []int{2, 2}, BlockDef: &wstore.BlockDef{ + // Meta: wstore.MetaMapType{ + // waveobj.MetaKey_View: "web", + // waveobj.MetaKey_Url: "https://www.youtube.com/embed/cKqsw_sAsU8", + // }, + // }}, + } +} + +func GetNewTabLayout() PortableLayout { + return PortableLayout{ + {IndexArr: []int{0}, BlockDef: &waveobj.BlockDef{ + Meta: waveobj.MetaMapType{ + waveobj.MetaKey_View: "term", + waveobj.MetaKey_Controller: "shell", + }, + }, Focused: true}, + } +} + +func GetLayoutIdForTab(ctx context.Context, tabId string) (string, error) { + tabObj, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) + if err != nil { + return "", fmt.Errorf("unable to get layout id for given tab id %s: %w", tabId, err) + } + return tabObj.LayoutState, nil +} + +func QueueLayoutAction(ctx context.Context, layoutStateId string, actions ...waveobj.LayoutActionData) error { + layoutStateObj, err := wstore.DBGet[*waveobj.LayoutState](ctx, layoutStateId) + if err != nil { + return fmt.Errorf("unable to get layout state for given id %s: %w", layoutStateId, err) + } + + if layoutStateObj.PendingBackendActions == nil { + layoutStateObj.PendingBackendActions = &actions + } else { + *layoutStateObj.PendingBackendActions = append(*layoutStateObj.PendingBackendActions, actions...) + } + + err = wstore.DBUpdate(ctx, layoutStateObj) + if err != nil { + return fmt.Errorf("unable to update layout state with new actions: %w", err) + } + return nil +} + +func QueueLayoutActionForTab(ctx context.Context, tabId string, actions ...waveobj.LayoutActionData) error { + layoutStateId, err := GetLayoutIdForTab(ctx, tabId) + if err != nil { + return err + } + + return QueueLayoutAction(ctx, layoutStateId, actions...) +} + +func ApplyPortableLayout(ctx context.Context, tabId string, layout PortableLayout) error { + actions := make([]waveobj.LayoutActionData, len(layout)+1) + actions[0] = waveobj.LayoutActionData{ActionType: LayoutActionDataType_ClearTree} + for i := 0; i < len(layout); i++ { + layoutAction := layout[i] + + blockData, err := wcore.CreateBlock(ctx, tabId, layoutAction.BlockDef, &waveobj.RuntimeOpts{}) + if err != nil { + return fmt.Errorf("unable to create block to apply portable layout to tab %s: %w", tabId, err) + } + + actions[i+1] = waveobj.LayoutActionData{ + ActionType: LayoutActionDataType_InsertAtIndex, + BlockId: blockData.OID, + IndexArr: &layoutAction.IndexArr, + NodeSize: layoutAction.Size, + Focused: layoutAction.Focused, + } + } + + err := QueueLayoutActionForTab(ctx, tabId, actions...) + if err != nil { + return fmt.Errorf("unable to queue layout actions for portable layout: %w", err) + } + + return nil +} + +func BootstrapNewWindowLayout(ctx context.Context, window *waveobj.Window) error { + tabId := window.ActiveTabId + newTabLayout := GetNewTabLayout() + + err := ApplyPortableLayout(ctx, tabId, newTabLayout) + if err != nil { + return fmt.Errorf("error applying new window layout: %w", err) + } + return nil +} + +func BootstrapStarterLayout(ctx context.Context) error { + ctx, cancelFn := context.WithTimeout(ctx, 2*time.Second) + defer cancelFn() + client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + log.Printf("unable to find client: %v\n", err) + return fmt.Errorf("unable to find client: %w", err) + } + + if len(client.WindowIds) < 1 { + return fmt.Errorf("error bootstrapping layout, no windows exist") + } + + windowId := client.WindowIds[0] + + window, err := wstore.DBMustGet[*waveobj.Window](ctx, windowId) + if err != nil { + return fmt.Errorf("error getting window: %w", err) + } + + tabId := window.ActiveTabId + + starterLayout := GetStarterLayout() + + err = ApplyPortableLayout(ctx, tabId, starterLayout) + if err != nil { + return fmt.Errorf("error applying starter layout: %w", err) + } + + return nil +} diff --git a/pkg/wps/wps.go b/pkg/wps/wps.go new file mode 100644 index 000000000..87e00faa2 --- /dev/null +++ b/pkg/wps/wps.go @@ -0,0 +1,286 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// wave pubsub system +package wps + +import ( + "log" + "strings" + "sync" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +// this broker interface is mostly generic +// strong typing and event types can be defined elsewhere + +const MaxPersist = 4096 +const ReMakeArrThreshold = 10 * 1024 + +type Client interface { + SendEvent(routeId string, event WaveEvent) +} + +type BrokerSubscription struct { + AllSubs []string // routeids subscribed to "all" events + ScopeSubs map[string][]string // routeids subscribed to specific scopes + StarSubs map[string][]string // routeids subscribed to star scope (scopes with "*" or "**" in them) +} + +type persistKey struct { + Event string + Scope string +} + +type persistEventWrap struct { + ArrTotalAdds int + Events []*WaveEvent +} + +type BrokerType struct { + Lock *sync.Mutex + Client Client + SubMap map[string]*BrokerSubscription + PersistMap map[persistKey]*persistEventWrap +} + +var Broker = &BrokerType{ + Lock: &sync.Mutex{}, + SubMap: make(map[string]*BrokerSubscription), + PersistMap: make(map[persistKey]*persistEventWrap), +} + +func scopeHasStarMatch(scope string) bool { + parts := strings.Split(scope, ":") + for _, part := range parts { + if part == "*" || part == "**" { + return true + } + } + return false +} + +func (b *BrokerType) SetClient(client Client) { + b.Lock.Lock() + defer b.Lock.Unlock() + b.Client = client +} + +func (b *BrokerType) GetClient() Client { + b.Lock.Lock() + defer b.Lock.Unlock() + return b.Client +} + +// if already subscribed, this will *resubscribe* with the new subscription (remove the old one, and replace with this one) +func (b *BrokerType) Subscribe(subRouteId string, sub SubscriptionRequest) { + log.Printf("[wps] sub %s %s\n", subRouteId, sub.Event) + if sub.Event == "" { + return + } + b.Lock.Lock() + defer b.Lock.Unlock() + b.unsubscribe_nolock(subRouteId, sub.Event) + bs := b.SubMap[sub.Event] + if bs == nil { + bs = &BrokerSubscription{ + AllSubs: []string{}, + ScopeSubs: make(map[string][]string), + StarSubs: make(map[string][]string), + } + b.SubMap[sub.Event] = bs + } + if sub.AllScopes { + bs.AllSubs = utilfn.AddElemToSliceUniq(bs.AllSubs, subRouteId) + return + } + for _, scope := range sub.Scopes { + starMatch := scopeHasStarMatch(scope) + if starMatch { + addStrToScopeMap(bs.StarSubs, scope, subRouteId) + } else { + addStrToScopeMap(bs.ScopeSubs, scope, subRouteId) + } + } +} + +func (bs *BrokerSubscription) IsEmpty() bool { + return len(bs.AllSubs) == 0 && len(bs.ScopeSubs) == 0 && len(bs.StarSubs) == 0 +} + +func removeStrFromScopeMap(scopeMap map[string][]string, scope string, routeId string) { + scopeSubs := scopeMap[scope] + scopeSubs = utilfn.RemoveElemFromSlice(scopeSubs, routeId) + if len(scopeSubs) == 0 { + delete(scopeMap, scope) + } else { + scopeMap[scope] = scopeSubs + } +} + +func removeStrFromScopeMapAll(scopeMap map[string][]string, routeId string) { + for scope, scopeSubs := range scopeMap { + scopeSubs = utilfn.RemoveElemFromSlice(scopeSubs, routeId) + if len(scopeSubs) == 0 { + delete(scopeMap, scope) + } else { + scopeMap[scope] = scopeSubs + } + } +} + +func addStrToScopeMap(scopeMap map[string][]string, scope string, routeId string) { + scopeSubs := scopeMap[scope] + scopeSubs = utilfn.AddElemToSliceUniq(scopeSubs, routeId) + scopeMap[scope] = scopeSubs +} + +func (b *BrokerType) Unsubscribe(subRouteId string, eventName string) { + log.Printf("[wps] unsub %s %s\n", subRouteId, eventName) + b.Lock.Lock() + defer b.Lock.Unlock() + b.unsubscribe_nolock(subRouteId, eventName) +} + +func (b *BrokerType) unsubscribe_nolock(subRouteId string, eventName string) { + bs := b.SubMap[eventName] + if bs == nil { + return + } + bs.AllSubs = utilfn.RemoveElemFromSlice(bs.AllSubs, subRouteId) + for scope := range bs.ScopeSubs { + removeStrFromScopeMap(bs.ScopeSubs, scope, subRouteId) + } + for scope := range bs.StarSubs { + removeStrFromScopeMap(bs.StarSubs, scope, subRouteId) + } + if bs.IsEmpty() { + delete(b.SubMap, eventName) + } +} + +func (b *BrokerType) UnsubscribeAll(subRouteId string) { + b.Lock.Lock() + defer b.Lock.Unlock() + for eventType, bs := range b.SubMap { + bs.AllSubs = utilfn.RemoveElemFromSlice(bs.AllSubs, subRouteId) + removeStrFromScopeMapAll(bs.StarSubs, subRouteId) + removeStrFromScopeMapAll(bs.ScopeSubs, subRouteId) + if bs.IsEmpty() { + delete(b.SubMap, eventType) + } + } +} + +// does not take wildcards, use "" for all +func (b *BrokerType) ReadEventHistory(eventType string, scope string, maxItems int) []*WaveEvent { + if maxItems <= 0 { + return nil + } + b.Lock.Lock() + defer b.Lock.Unlock() + key := persistKey{Event: eventType, Scope: scope} + pe := b.PersistMap[key] + if pe == nil || len(pe.Events) == 0 { + return nil + } + if maxItems > len(pe.Events) { + maxItems = len(pe.Events) + } + // return new arr + rtn := make([]*WaveEvent, maxItems) + copy(rtn, pe.Events[len(pe.Events)-maxItems:]) + return rtn +} + +func (b *BrokerType) persistEvent(event WaveEvent) { + if event.Persist <= 0 { + return + } + numPersist := event.Persist + if numPersist > MaxPersist { + numPersist = MaxPersist + } + scopeMap := make(map[string]bool) + for _, scope := range event.Scopes { + scopeMap[scope] = true + } + scopeMap[""] = true + b.Lock.Lock() + defer b.Lock.Unlock() + for scope := range scopeMap { + key := persistKey{Event: event.Event, Scope: scope} + pe := b.PersistMap[key] + if pe == nil { + pe = &persistEventWrap{ + ArrTotalAdds: 0, + Events: make([]*WaveEvent, 0, event.Persist), + } + b.PersistMap[key] = pe + } + pe.Events = append(pe.Events, &event) + pe.ArrTotalAdds++ + if pe.ArrTotalAdds > ReMakeArrThreshold { + pe.Events = append([]*WaveEvent{}, pe.Events...) + pe.ArrTotalAdds = len(pe.Events) + } + } +} + +func (b *BrokerType) Publish(event WaveEvent) { + // log.Printf("BrokerType.Publish: %v\n", event) + if event.Persist > 0 { + b.persistEvent(event) + } + client := b.GetClient() + if client == nil { + return + } + routeIds := b.getMatchingRouteIds(event) + for _, routeId := range routeIds { + client.SendEvent(routeId, event) + } +} + +func (b *BrokerType) SendUpdateEvents(updates waveobj.UpdatesRtnType) { + for _, update := range updates { + b.Publish(WaveEvent{ + Event: Event_WaveObjUpdate, + Scopes: []string{waveobj.MakeORef(update.OType, update.OID).String()}, + Data: update, + }) + } +} + +func (b *BrokerType) getMatchingRouteIds(event WaveEvent) []string { + b.Lock.Lock() + defer b.Lock.Unlock() + bs := b.SubMap[event.Event] + if bs == nil { + return nil + } + routeIds := make(map[string]bool) + for _, routeId := range bs.AllSubs { + routeIds[routeId] = true + } + for _, scope := range event.Scopes { + for _, routeId := range bs.ScopeSubs[scope] { + routeIds[routeId] = true + } + for starScope := range bs.StarSubs { + if utilfn.StarMatchString(starScope, scope, ":") { + for _, routeId := range bs.StarSubs[starScope] { + routeIds[routeId] = true + } + } + } + } + var rtn []string + for routeId := range routeIds { + rtn = append(rtn, routeId) + } + // log.Printf("getMatchingRouteIds %v %v\n", event, rtn) + return rtn +} diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go new file mode 100644 index 000000000..d925f3eeb --- /dev/null +++ b/pkg/wps/wpstypes.go @@ -0,0 +1,45 @@ +package wps + +import "github.com/wavetermdev/waveterm/pkg/util/utilfn" + +const ( + Event_BlockClose = "blockclose" + Event_ConnChange = "connchange" + Event_SysInfo = "sysinfo" + Event_ControllerStatus = "controllerstatus" + Event_WaveObjUpdate = "waveobj:update" + Event_BlockFile = "blockfile" + Event_Config = "config" + Event_UserInput = "userinput" +) + +type WaveEvent struct { + Event string `json:"event"` + Scopes []string `json:"scopes,omitempty"` + Sender string `json:"sender,omitempty"` + Persist int `json:"persist,omitempty"` + Data any `json:"data,omitempty"` +} + +func (e WaveEvent) HasScope(scope string) bool { + return utilfn.ContainsStr(e.Scopes, scope) +} + +type SubscriptionRequest struct { + Event string `json:"event"` + Scopes []string `json:"scopes,omitempty"` + AllScopes bool `json:"allscopes,omitempty"` +} + +const ( + FileOp_Append = "append" + FileOp_Truncate = "truncate" + FileOp_Invalidate = "invalidate" +) + +type WSFileEventData struct { + ZoneId string `json:"zoneid"` + FileName string `json:"filename"` + FileOp string `json:"fileop"` + Data64 string `json:"data64"` +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go new file mode 100644 index 000000000..71b89ff9d --- /dev/null +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -0,0 +1,263 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Generated Code. DO NOT EDIT. + +package wshclient + +import ( + "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" +) + +// command "authenticate", wshserver.AuthenticateCommand +func AuthenticateCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (wshrpc.CommandAuthenticateRtnData, error) { + resp, err := sendRpcRequestCallHelper[wshrpc.CommandAuthenticateRtnData](w, "authenticate", data, opts) + return resp, err +} + +// command "blockinfo", wshserver.BlockInfoCommand +func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockInfoData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.BlockInfoData](w, "blockinfo", data, opts) + return resp, err +} + +// command "connconnect", wshserver.ConnConnectCommand +func ConnConnectCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "connconnect", data, opts) + return err +} + +// command "conndisconnect", wshserver.ConnDisconnectCommand +func ConnDisconnectCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "conndisconnect", data, opts) + return err +} + +// command "connensure", wshserver.ConnEnsureCommand +func ConnEnsureCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "connensure", data, opts) + return err +} + +// command "connlist", wshserver.ConnListCommand +func ConnListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "connlist", nil, opts) + return resp, err +} + +// command "connreinstallwsh", wshserver.ConnReinstallWshCommand +func ConnReinstallWshCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "connreinstallwsh", data, opts) + return err +} + +// command "connstatus", wshserver.ConnStatusCommand +func ConnStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.ConnStatus, error) { + resp, err := sendRpcRequestCallHelper[[]wshrpc.ConnStatus](w, "connstatus", nil, opts) + return resp, err +} + +// command "controllerinput", wshserver.ControllerInputCommand +func ControllerInputCommand(w *wshutil.WshRpc, data wshrpc.CommandBlockInputData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "controllerinput", data, opts) + return err +} + +// command "controllerresync", wshserver.ControllerResyncCommand +func ControllerResyncCommand(w *wshutil.WshRpc, data wshrpc.CommandControllerResyncData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "controllerresync", data, opts) + return err +} + +// command "controllerstop", wshserver.ControllerStopCommand +func ControllerStopCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "controllerstop", data, opts) + return err +} + +// command "createblock", wshserver.CreateBlockCommand +func CreateBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) { + resp, err := sendRpcRequestCallHelper[waveobj.ORef](w, "createblock", data, opts) + return resp, err +} + +// command "deleteblock", wshserver.DeleteBlockCommand +func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deleteblock", data, opts) + return err +} + +// command "eventpublish", wshserver.EventPublishCommand +func EventPublishCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "eventpublish", data, opts) + return err +} + +// command "eventreadhistory", wshserver.EventReadHistoryCommand +func EventReadHistoryCommand(w *wshutil.WshRpc, data wshrpc.CommandEventReadHistoryData, opts *wshrpc.RpcOpts) ([]*wps.WaveEvent, error) { + resp, err := sendRpcRequestCallHelper[[]*wps.WaveEvent](w, "eventreadhistory", data, opts) + return resp, err +} + +// command "eventrecv", wshserver.EventRecvCommand +func EventRecvCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "eventrecv", data, opts) + return err +} + +// command "eventsub", wshserver.EventSubCommand +func EventSubCommand(w *wshutil.WshRpc, data wps.SubscriptionRequest, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "eventsub", data, opts) + return err +} + +// command "eventunsub", wshserver.EventUnsubCommand +func EventUnsubCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "eventunsub", data, opts) + return err +} + +// command "eventunsuball", wshserver.EventUnsubAllCommand +func EventUnsubAllCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "eventunsuball", nil, opts) + return err +} + +// command "fileappend", wshserver.FileAppendCommand +func FileAppendCommand(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "fileappend", data, opts) + return err +} + +// command "fileappendijson", wshserver.FileAppendIJsonCommand +func FileAppendIJsonCommand(w *wshutil.WshRpc, data wshrpc.CommandAppendIJsonData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "fileappendijson", data, opts) + return err +} + +// command "fileread", wshserver.FileReadCommand +func FileReadCommand(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "fileread", data, opts) + return resp, err +} + +// command "filewrite", wshserver.FileWriteCommand +func FileWriteCommand(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "filewrite", data, opts) + return err +} + +// command "getmeta", wshserver.GetMetaCommand +func GetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandGetMetaData, opts *wshrpc.RpcOpts) (waveobj.MetaMapType, error) { + resp, err := sendRpcRequestCallHelper[waveobj.MetaMapType](w, "getmeta", data, opts) + return resp, err +} + +// command "message", wshserver.MessageCommand +func MessageCommand(w *wshutil.WshRpc, data wshrpc.CommandMessageData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "message", data, opts) + return err +} + +// command "remotefiledelete", wshserver.RemoteFileDeleteCommand +func RemoteFileDeleteCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "remotefiledelete", data, opts) + return err +} + +// command "remotefileinfo", wshserver.RemoteFileInfoCommand +func RemoteFileInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "remotefileinfo", data, opts) + return resp, err +} + +// command "remotefilejoin", wshserver.RemoteFileJoinCommand +func RemoteFileJoinCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "remotefilejoin", data, opts) + return resp, err +} + +// command "remotestreamcpudata", wshserver.RemoteStreamCpuDataCommand +func RemoteStreamCpuDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] { + return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "remotestreamcpudata", nil, opts) +} + +// command "remotestreamfile", wshserver.RemoteStreamFileCommand +func RemoteStreamFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteStreamFileData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteStreamFileRtnData] { + return sendRpcRequestResponseStreamHelper[wshrpc.CommandRemoteStreamFileRtnData](w, "remotestreamfile", data, opts) +} + +// command "remotewritefile", wshserver.RemoteWriteFileCommand +func RemoteWriteFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteWriteFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "remotewritefile", data, opts) + return err +} + +// command "resolveids", wshserver.ResolveIdsCommand +func ResolveIdsCommand(w *wshutil.WshRpc, data wshrpc.CommandResolveIdsData, opts *wshrpc.RpcOpts) (wshrpc.CommandResolveIdsRtnData, error) { + resp, err := sendRpcRequestCallHelper[wshrpc.CommandResolveIdsRtnData](w, "resolveids", data, opts) + return resp, err +} + +// command "routeannounce", wshserver.RouteAnnounceCommand +func RouteAnnounceCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "routeannounce", nil, opts) + return err +} + +// command "routeunannounce", wshserver.RouteUnannounceCommand +func RouteUnannounceCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "routeunannounce", nil, opts) + return err +} + +// command "setconfig", wshserver.SetConfigCommand +func SetConfigCommand(w *wshutil.WshRpc, data wconfig.MetaSettingsType, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "setconfig", data, opts) + return err +} + +// command "setmeta", wshserver.SetMetaCommand +func SetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandSetMetaData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "setmeta", data, opts) + return err +} + +// command "setview", wshserver.SetViewCommand +func SetViewCommand(w *wshutil.WshRpc, data wshrpc.CommandBlockSetViewData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "setview", data, opts) + return err +} + +// command "streamcpudata", wshserver.StreamCpuDataCommand +func StreamCpuDataCommand(w *wshutil.WshRpc, data wshrpc.CpuDataRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] { + return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "streamcpudata", data, opts) +} + +// command "streamtest", wshserver.StreamTestCommand +func StreamTestCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[int] { + return sendRpcRequestResponseStreamHelper[int](w, "streamtest", nil, opts) +} + +// command "streamwaveai", wshserver.StreamWaveAiCommand +func StreamWaveAiCommand(w *wshutil.WshRpc, data wshrpc.OpenAiStreamRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType] { + return sendRpcRequestResponseStreamHelper[wshrpc.OpenAIPacketType](w, "streamwaveai", data, opts) +} + +// command "test", wshserver.TestCommand +func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "test", data, opts) + return err +} + +// command "webselector", wshserver.WebSelectorCommand +func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) + return resp, err +} + + diff --git a/pkg/wshrpc/wshclient/wshclientutil.go b/pkg/wshrpc/wshclient/wshclientutil.go new file mode 100644 index 000000000..fcddbd7f8 --- /dev/null +++ b/pkg/wshrpc/wshclient/wshclientutil.go @@ -0,0 +1,86 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshclient + +import ( + "errors" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +func sendRpcRequestCallHelper[T any](w *wshutil.WshRpc, command string, data interface{}, opts *wshrpc.RpcOpts) (T, error) { + if opts == nil { + opts = &wshrpc.RpcOpts{} + } + var respData T + if w == nil { + return respData, errors.New("nil wshrpc passed to wshclient") + } + if opts.NoResponse { + err := w.SendCommand(command, data, opts) + if err != nil { + return respData, err + } + return respData, nil + } + resp, err := w.SendRpcRequest(command, data, opts) + if err != nil { + return respData, err + } + err = utilfn.ReUnmarshal(&respData, resp) + if err != nil { + return respData, err + } + return respData, nil +} + +func rtnErr[T any](ch chan wshrpc.RespOrErrorUnion[T], err error) { + go func() { + ch <- wshrpc.RespOrErrorUnion[T]{Error: err} + close(ch) + }() +} + +func sendRpcRequestResponseStreamHelper[T any](w *wshutil.WshRpc, command string, data interface{}, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[T] { + if opts == nil { + opts = &wshrpc.RpcOpts{} + } + respChan := make(chan wshrpc.RespOrErrorUnion[T]) + if w == nil { + rtnErr(respChan, errors.New("nil wshrpc passed to wshclient")) + return respChan + } + reqHandler, err := w.SendComplexRequest(command, data, opts) + if err != nil { + rtnErr(respChan, err) + return respChan + } + opts.StreamCancelFn = func() { + // TODO coordinate the cancel with the for loop below + reqHandler.SendCancel() + } + go func() { + defer close(respChan) + for { + if reqHandler.ResponseDone() { + break + } + resp, err := reqHandler.NextResponse() + if err != nil { + respChan <- wshrpc.RespOrErrorUnion[T]{Error: err} + break + } + var respData T + err = utilfn.ReUnmarshal(&respData, resp) + if err != nil { + respChan <- wshrpc.RespOrErrorUnion[T]{Error: err} + break + } + respChan <- wshrpc.RespOrErrorUnion[T]{Response: respData} + } + }() + return respChan +} diff --git a/pkg/wshrpc/wshremote/sysinfo.go b/pkg/wshrpc/wshremote/sysinfo.go new file mode 100644 index 000000000..eba8290fb --- /dev/null +++ b/pkg/wshrpc/wshremote/sysinfo.go @@ -0,0 +1,70 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshremote + +import ( + "log" + "strconv" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/mem" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +func getCpuData(values map[string]float64) { + percentArr, err := cpu.Percent(0, false) + if err != nil { + return + } + if len(percentArr) > 0 { + values[wshrpc.TimeSeries_Cpu] = percentArr[0] + } + percentArr, err = cpu.Percent(0, true) + if err != nil { + return + } + for idx, percent := range percentArr { + values[wshrpc.TimeSeries_Cpu+":"+strconv.Itoa(idx)] = percent + } +} + +func getMemData(values map[string]float64) { + memData, err := mem.VirtualMemory() + if err != nil { + return + } + values["mem:total"] = float64(memData.Total) + values["mem:available"] = float64(memData.Available) + values["mem:used"] = float64(memData.Used) + values["mem:free"] = float64(memData.Free) +} + +func generateSingleServerData(client *wshutil.WshRpc, connName string) { + now := time.Now() + values := make(map[string]float64) + getCpuData(values) + getMemData(values) + tsData := wshrpc.TimeSeriesData{Ts: now.UnixMilli(), Values: values} + event := wps.WaveEvent{ + Event: wps.Event_SysInfo, + Scopes: []string{connName}, + Data: tsData, + Persist: 1024, + } + wshclient.EventPublishCommand(client, event, &wshrpc.RpcOpts{NoResponse: true}) +} + +func RunSysInfoLoop(client *wshutil.WshRpc, connName string) { + defer func() { + log.Printf("sysinfo loop ended conn:%s\n", connName) + }() + for { + generateSingleServerData(client, connName) + time.Sleep(1 * time.Second) + } +} diff --git a/pkg/wshrpc/wshremote/wshremote.go b/pkg/wshrpc/wshremote/wshremote.go new file mode 100644 index 000000000..c31513267 --- /dev/null +++ b/pkg/wshrpc/wshremote/wshremote.go @@ -0,0 +1,333 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshremote + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const MaxFileSize = 50 * 1024 * 1024 // 10M +const MaxDirSize = 1024 +const FileChunkSize = 16 * 1024 +const DirChunkSize = 128 + +type ServerImpl struct { + LogWriter io.Writer +} + +func (*ServerImpl) WshServerImpl() {} + +func (impl *ServerImpl) Log(format string, args ...interface{}) { + if impl.LogWriter != nil { + fmt.Fprintf(impl.LogWriter, format, args...) + } else { + log.Printf(format, args...) + } +} + +func (impl *ServerImpl) MessageCommand(ctx context.Context, data wshrpc.CommandMessageData) error { + impl.Log("[message] %q\n", data.Message) + return nil +} + +func respErr(err error) wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteStreamFileRtnData] { + return wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteStreamFileRtnData]{Error: err} +} + +type ByteRangeType struct { + All bool + Start int64 + End int64 +} + +func parseByteRange(rangeStr string) (ByteRangeType, error) { + if rangeStr == "" { + return ByteRangeType{All: true}, nil + } + var start, end int64 + _, err := fmt.Sscanf(rangeStr, "%d-%d", &start, &end) + if err != nil { + return ByteRangeType{}, errors.New("invalid byte range") + } + if start < 0 || end < 0 || start > end { + return ByteRangeType{}, errors.New("invalid byte range") + } + return ByteRangeType{Start: start, End: end}, nil +} + +func (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, byteRange ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte)) error { + innerFilesEntries, err := os.ReadDir(path) + if err != nil { + return fmt.Errorf("cannot open dir %q: %w", path, err) + } + if byteRange.All { + if len(innerFilesEntries) > MaxDirSize { + innerFilesEntries = innerFilesEntries[:MaxDirSize] + } + } else { + if byteRange.Start >= int64(len(innerFilesEntries)) { + return nil + } + realEnd := byteRange.End + if realEnd > int64(len(innerFilesEntries)) { + realEnd = int64(len(innerFilesEntries)) + } + innerFilesEntries = innerFilesEntries[byteRange.Start:realEnd] + } + var fileInfoArr []*wshrpc.FileInfo + parent := filepath.Dir(path) + parentFileInfo, err := impl.fileInfoInternal(parent, false) + if err == nil && parent != path { + parentFileInfo.Name = ".." + parentFileInfo.Size = -1 + fileInfoArr = append(fileInfoArr, parentFileInfo) + } + for _, innerFileEntry := range innerFilesEntries { + if ctx.Err() != nil { + return ctx.Err() + } + innerFileInfoInt, err := innerFileEntry.Info() + if err != nil { + continue + } + innerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt) + fileInfoArr = append(fileInfoArr, innerFileInfo) + if len(fileInfoArr) >= DirChunkSize { + dataCallback(fileInfoArr, nil) + fileInfoArr = nil + } + } + if len(fileInfoArr) > 0 { + dataCallback(fileInfoArr, nil) + } + return nil +} + +// TODO make sure the read is in chunks of 3 bytes (so 4 bytes of base64) in order to make decoding more efficient +func (impl *ServerImpl) remoteStreamFileRegular(ctx context.Context, path string, byteRange ByteRangeType, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte)) error { + fd, err := os.Open(path) + if err != nil { + return fmt.Errorf("cannot open file %q: %w", path, err) + } + defer fd.Close() + var filePos int64 + if !byteRange.All && byteRange.Start > 0 { + _, err := fd.Seek(byteRange.Start, io.SeekStart) + if err != nil { + return fmt.Errorf("seeking file %q: %w", path, err) + } + filePos = byteRange.Start + } + buf := make([]byte, FileChunkSize) + for { + if ctx.Err() != nil { + return ctx.Err() + } + n, err := fd.Read(buf) + if n > 0 { + if !byteRange.All && filePos+int64(n) > byteRange.End { + n = int(byteRange.End - filePos) + } + filePos += int64(n) + dataCallback(nil, buf[:n]) + } + if !byteRange.All && filePos >= byteRange.End { + break + } + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("reading file %q: %w", path, err) + } + } + return nil +} + +func (impl *ServerImpl) remoteStreamFileInternal(ctx context.Context, data wshrpc.CommandRemoteStreamFileData, dataCallback func(fileInfo []*wshrpc.FileInfo, data []byte)) error { + byteRange, err := parseByteRange(data.ByteRange) + if err != nil { + return err + } + path := data.Path + path = wavebase.ExpandHomeDir(path) + finfo, err := impl.fileInfoInternal(path, true) + if err != nil { + return fmt.Errorf("cannot stat file %q: %w", path, err) + } + dataCallback([]*wshrpc.FileInfo{finfo}, nil) + if finfo.NotFound { + return nil + } + if finfo.Size > MaxFileSize { + return fmt.Errorf("file %q is too large to read, use /wave/stream-file", path) + } + if finfo.IsDir { + return impl.remoteStreamFileDir(ctx, path, byteRange, dataCallback) + } else { + return impl.remoteStreamFileRegular(ctx, path, byteRange, dataCallback) + } +} + +func (impl *ServerImpl) RemoteStreamFileCommand(ctx context.Context, data wshrpc.CommandRemoteStreamFileData) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteStreamFileRtnData] { + ch := make(chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteStreamFileRtnData], 16) + defer close(ch) + err := impl.remoteStreamFileInternal(ctx, data, func(fileInfo []*wshrpc.FileInfo, data []byte) { + resp := wshrpc.CommandRemoteStreamFileRtnData{} + resp.FileInfo = fileInfo + if len(data) > 0 { + resp.Data64 = base64.StdEncoding.EncodeToString(data) + } + ch <- wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteStreamFileRtnData]{Response: resp} + }) + if err != nil { + ch <- respErr(err) + } + return ch +} + +func statToFileInfo(fullPath string, finfo fs.FileInfo) *wshrpc.FileInfo { + mimeType := utilfn.DetectMimeType(fullPath, finfo) + rtn := &wshrpc.FileInfo{ + Path: wavebase.ReplaceHomeDir(fullPath), + Dir: computeDirPart(fullPath, finfo.IsDir()), + Name: finfo.Name(), + Size: finfo.Size(), + Mode: finfo.Mode(), + ModeStr: finfo.Mode().String(), + ModTime: finfo.ModTime().UnixMilli(), + IsDir: finfo.IsDir(), + MimeType: mimeType, + } + if finfo.IsDir() { + rtn.Size = -1 + } + return rtn +} + +// fileInfo might be null +func checkIsReadOnly(path string, fileInfo fs.FileInfo, exists bool) bool { + if !exists || fileInfo.Mode().IsDir() { + dirName := filepath.Dir(path) + randHexStr, err := utilfn.RandomHexString(12) + if err != nil { + // we're not sure, just return false + return false + } + tmpFileName := filepath.Join(dirName, "wsh-tmp-"+randHexStr) + _, err = os.Create(tmpFileName) + if err != nil { + return true + } + os.Remove(tmpFileName) + return false + } + // try to open for writing, if this fails then it is read-only + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return true + } + file.Close() + return false +} + +func computeDirPart(path string, isDir bool) string { + path = filepath.Clean(wavebase.ExpandHomeDir(path)) + path = filepath.ToSlash(path) + if path == "/" { + return "/" + } + path = strings.TrimSuffix(path, "/") + if isDir { + return path + } + return filepath.Dir(path) +} + +func (*ServerImpl) fileInfoInternal(path string, extended bool) (*wshrpc.FileInfo, error) { + cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path)) + finfo, err := os.Stat(cleanedPath) + if os.IsNotExist(err) { + return &wshrpc.FileInfo{ + Path: wavebase.ReplaceHomeDir(path), + Dir: computeDirPart(path, false), + NotFound: true, + ReadOnly: checkIsReadOnly(cleanedPath, finfo, false), + }, nil + } + if err != nil { + return nil, fmt.Errorf("cannot stat file %q: %w", path, err) + } + rtn := statToFileInfo(cleanedPath, finfo) + if extended { + rtn.ReadOnly = checkIsReadOnly(cleanedPath, finfo, true) + } + return rtn, nil +} + +func resolvePaths(paths []string) string { + if len(paths) == 0 { + return wavebase.ExpandHomeDir("~") + } + var rtnPath = wavebase.ExpandHomeDir(paths[0]) + for _, path := range paths[1:] { + path = wavebase.ExpandHomeDir(path) + if filepath.IsAbs(path) { + rtnPath = path + continue + } + rtnPath = filepath.Join(rtnPath, path) + } + return rtnPath +} + +func (impl *ServerImpl) RemoteFileJoinCommand(ctx context.Context, paths []string) (*wshrpc.FileInfo, error) { + rtnPath := resolvePaths(paths) + return impl.fileInfoInternal(rtnPath, true) +} + +func (impl *ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wshrpc.FileInfo, error) { + return impl.fileInfoInternal(path, true) +} + +func (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.CommandRemoteWriteFileData) error { + path := wavebase.ExpandHomeDir(data.Path) + createMode := data.CreateMode + if createMode == 0 { + createMode = 0644 + } + dataSize := base64.StdEncoding.DecodedLen(len(data.Data64)) + dataBytes := make([]byte, dataSize) + n, err := base64.StdEncoding.Decode(dataBytes, []byte(data.Data64)) + if err != nil { + return fmt.Errorf("cannot decode base64 data: %w", err) + } + err = os.WriteFile(path, dataBytes[:n], createMode) + if err != nil { + return fmt.Errorf("cannot write file %q: %w", path, err) + } + return nil +} + +func (*ServerImpl) RemoteFileDeleteCommand(ctx context.Context, path string) error { + cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path)) + err := os.Remove(cleanedPath) + if err != nil { + return fmt.Errorf("cannot delete file %q: %w", path, err) + } + return nil +} diff --git a/pkg/wshrpc/wshrpcmeta.go b/pkg/wshrpc/wshrpcmeta.go new file mode 100644 index 000000000..09568ea09 --- /dev/null +++ b/pkg/wshrpc/wshrpcmeta.go @@ -0,0 +1,116 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshrpc + +import ( + "context" + "fmt" + "log" + "reflect" + "strings" +) + +type WshRpcMethodDecl struct { + Command string + CommandType string + MethodName string + CommandDataType reflect.Type + DefaultResponseDataType reflect.Type +} + +var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem() +var wshRpcInterfaceRType = reflect.TypeOf((*WshRpcInterface)(nil)).Elem() + +func getWshCommandType(method reflect.Method) string { + if method.Type.NumOut() == 1 { + outType := method.Type.Out(0) + if outType.Kind() == reflect.Chan { + return RpcType_ResponseStream + } + } + return RpcType_Call +} + +func getWshMethodResponseType(commandType string, method reflect.Method) reflect.Type { + switch commandType { + case RpcType_ResponseStream: + if method.Type.NumOut() != 1 { + panic(fmt.Sprintf("method %q has invalid number of return values for response stream", method.Name)) + } + outType := method.Type.Out(0) + if outType.Kind() != reflect.Chan { + panic(fmt.Sprintf("method %q has invalid return type %s for response stream", method.Name, outType)) + } + elemType := outType.Elem() + if !strings.HasPrefix(elemType.Name(), "RespOrErrorUnion") { + panic(fmt.Sprintf("method %q has invalid return element type %s for response stream (should be RespOrErrorUnion)", method.Name, elemType)) + } + respField, found := elemType.FieldByName("Response") + if !found { + panic(fmt.Sprintf("method %q has invalid return element type %s for response stream (missing Response field)", method.Name, elemType)) + } + return respField.Type + case RpcType_Call: + if method.Type.NumOut() > 1 { + return method.Type.Out(0) + } + return nil + default: + panic(fmt.Sprintf("unsupported command type %q", commandType)) + } +} + +func generateWshCommandDecl(method reflect.Method) *WshRpcMethodDecl { + if method.Type.NumIn() == 0 || method.Type.In(0) != contextRType { + panic(fmt.Sprintf("method %q does not have context as first argument", method.Name)) + } + cmdStr := method.Name + decl := &WshRpcMethodDecl{} + // remove Command suffix + if !strings.HasSuffix(cmdStr, "Command") { + panic(fmt.Sprintf("method %q does not have Command suffix", cmdStr)) + } + cmdStr = cmdStr[:len(cmdStr)-len("Command")] + decl.Command = strings.ToLower(cmdStr) + decl.CommandType = getWshCommandType(method) + decl.MethodName = method.Name + var cdataType reflect.Type + if method.Type.NumIn() > 1 { + cdataType = method.Type.In(1) + } + decl.CommandDataType = cdataType + decl.DefaultResponseDataType = getWshMethodResponseType(decl.CommandType, method) + return decl +} + +func MakeMethodMapForImpl(impl any, declMap map[string]*WshRpcMethodDecl) map[string]reflect.Method { + rtype := reflect.TypeOf(impl) + rtnMap := make(map[string]reflect.Method) + for midx := 0; midx < rtype.NumMethod(); midx++ { + method := rtype.Method(midx) + if !strings.HasSuffix(method.Name, "Command") { + continue + } + commandName := strings.ToLower(method.Name[:len(method.Name)-len("Command")]) + decl := declMap[commandName] + if decl == nil { + log.Printf("WARNING: method %q does not match a command method", method.Name) + continue + } + rtnMap[commandName] = method + } + return rtnMap + +} + +func GenerateWshCommandDeclMap() map[string]*WshRpcMethodDecl { + rtype := wshRpcInterfaceRType + rtnMap := make(map[string]*WshRpcMethodDecl) + for midx := 0; midx < rtype.NumMethod(); midx++ { + method := rtype.Method(midx) + decl := generateWshCommandDecl(method) + rtnMap[decl.Command] = decl + } + return rtnMap +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go new file mode 100644 index 000000000..0e25854ae --- /dev/null +++ b/pkg/wshrpc/wshrpctypes.go @@ -0,0 +1,376 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// types and methods for wsh rpc calls +package wshrpc + +import ( + "context" + "log" + "os" + "reflect" + + "github.com/wavetermdev/waveterm/pkg/ijson" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wps" +) + +const LocalConnName = "local" + +const ( + RpcType_Call = "call" // single response (regular rpc) + RpcType_ResponseStream = "responsestream" // stream of responses (streaming rpc) + RpcType_StreamingRequest = "streamingrequest" // streaming request + RpcType_Complex = "complex" // streaming request/response +) + +const ( + Command_Authenticate = "authenticate" // special + Command_RouteAnnounce = "routeannounce" // special (for routing) + Command_RouteUnannounce = "routeunannounce" // special (for routing) + Command_Message = "message" + Command_GetMeta = "getmeta" + Command_SetMeta = "setmeta" + Command_SetView = "setview" + Command_ControllerInput = "controllerinput" + Command_ControllerRestart = "controllerrestart" + Command_ControllerStop = "controllerstop" + Command_ControllerResync = "controllerresync" + Command_FileAppend = "fileappend" + Command_FileAppendIJson = "fileappendijson" + Command_ResolveIds = "resolveids" + Command_BlockInfo = "blockinfo" + Command_CreateBlock = "createblock" + Command_DeleteBlock = "deleteblock" + Command_FileWrite = "filewrite" + Command_FileRead = "fileread" + Command_EventPublish = "eventpublish" + Command_EventRecv = "eventrecv" + Command_EventSub = "eventsub" + Command_EventUnsub = "eventunsub" + Command_EventUnsubAll = "eventunsuball" + Command_EventReadHistory = "eventreadhistory" + Command_StreamTest = "streamtest" + Command_StreamWaveAi = "streamwaveai" + Command_StreamCpuData = "streamcpudata" + Command_Test = "test" + Command_RemoteStreamFile = "remotestreamfile" + Command_RemoteFileInfo = "remotefileinfo" + Command_RemoteWriteFile = "remotewritefile" + Command_RemoteFileDelete = "remotefiledelete" + Command_RemoteFileJoiin = "remotefilejoin" + + Command_ConnEnsure = "connensure" + Command_ConnReinstallWsh = "connreinstallwsh" + Command_ConnConnect = "connconnect" + Command_ConnDisconnect = "conndisconnect" + Command_ConnList = "connlist" + + Command_WebSelector = "webselector" +) + +type RespOrErrorUnion[T any] struct { + Response T + Error error +} + +type WshRpcInterface interface { + AuthenticateCommand(ctx context.Context, data string) (CommandAuthenticateRtnData, error) + RouteAnnounceCommand(ctx context.Context) error // (special) announces a new route to the main router + RouteUnannounceCommand(ctx context.Context) error // (special) unannounces a route to the main router + + MessageCommand(ctx context.Context, data CommandMessageData) error + GetMetaCommand(ctx context.Context, data CommandGetMetaData) (waveobj.MetaMapType, error) + SetMetaCommand(ctx context.Context, data CommandSetMetaData) error + SetViewCommand(ctx context.Context, data CommandBlockSetViewData) error + ControllerInputCommand(ctx context.Context, data CommandBlockInputData) error + ControllerStopCommand(ctx context.Context, blockId string) error + ControllerResyncCommand(ctx context.Context, data CommandControllerResyncData) error + FileAppendCommand(ctx context.Context, data CommandFileData) error + FileAppendIJsonCommand(ctx context.Context, data CommandAppendIJsonData) error + ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) + CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) + DeleteBlockCommand(ctx context.Context, data CommandDeleteBlockData) error + FileWriteCommand(ctx context.Context, data CommandFileData) error + FileReadCommand(ctx context.Context, data CommandFileData) (string, error) + EventPublishCommand(ctx context.Context, data wps.WaveEvent) error + EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error + EventUnsubCommand(ctx context.Context, data string) error + EventUnsubAllCommand(ctx context.Context) error + EventReadHistoryCommand(ctx context.Context, data CommandEventReadHistoryData) ([]*wps.WaveEvent, error) + StreamTestCommand(ctx context.Context) chan RespOrErrorUnion[int] + StreamWaveAiCommand(ctx context.Context, request OpenAiStreamRequest) chan RespOrErrorUnion[OpenAIPacketType] + StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData] + TestCommand(ctx context.Context, data string) error + SetConfigCommand(ctx context.Context, data wconfig.MetaSettingsType) error + BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error) + + // connection functions + ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) + ConnEnsureCommand(ctx context.Context, connName string) error + ConnReinstallWshCommand(ctx context.Context, connName string) error + ConnConnectCommand(ctx context.Context, connName string) error + ConnDisconnectCommand(ctx context.Context, connName string) error + ConnListCommand(ctx context.Context) ([]string, error) + + // eventrecv is special, it's handled internally by WshRpc with EventListener + EventRecvCommand(ctx context.Context, data wps.WaveEvent) error + + // remotes + RemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[CommandRemoteStreamFileRtnData] + RemoteFileInfoCommand(ctx context.Context, path string) (*FileInfo, error) + RemoteFileDeleteCommand(ctx context.Context, path string) error + RemoteWriteFileCommand(ctx context.Context, data CommandRemoteWriteFileData) error + RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) + RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData] + + WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) +} + +// for frontend +type WshServerCommandMeta struct { + CommandType string `json:"commandtype"` +} + +type RpcOpts struct { + Timeout int `json:"timeout,omitempty"` + NoResponse bool `json:"noresponse,omitempty"` + Route string `json:"route,omitempty"` + + StreamCancelFn func() `json:"-"` // this is an *output* parameter, set by the handler +} + +const ( + ClientType_ConnServer = "connserver" + ClientType_BlockController = "blockcontroller" +) + +type RpcContext struct { + ClientType string `json:"ctype,omitempty"` + BlockId string `json:"blockid,omitempty"` + TabId string `json:"tabid,omitempty"` + Conn string `json:"conn,omitempty"` +} + +func HackRpcContextIntoData(dataPtr any, rpcContext RpcContext) { + dataVal := reflect.ValueOf(dataPtr).Elem() + if dataVal.Kind() != reflect.Struct { + return + } + dataType := dataVal.Type() + for i := 0; i < dataVal.NumField(); i++ { + field := dataVal.Field(i) + if !field.IsZero() { + continue + } + fieldType := dataType.Field(i) + tag := fieldType.Tag.Get("wshcontext") + if tag == "" { + continue + } + switch tag { + case "BlockId": + field.SetString(rpcContext.BlockId) + case "TabId": + field.SetString(rpcContext.TabId) + case "BlockORef": + if rpcContext.BlockId != "" { + field.Set(reflect.ValueOf(waveobj.MakeORef(waveobj.OType_Block, rpcContext.BlockId))) + } + default: + log.Printf("invalid wshcontext tag: %q in type(%T)", tag, dataPtr) + } + } +} + +type CommandAuthenticateRtnData struct { + RouteId string `json:"routeid"` +} + +type CommandMessageData struct { + ORef waveobj.ORef `json:"oref" wshcontext:"BlockORef"` + Message string `json:"message"` +} + +type CommandGetMetaData struct { + ORef waveobj.ORef `json:"oref" wshcontext:"BlockORef"` +} + +type CommandSetMetaData struct { + ORef waveobj.ORef `json:"oref" wshcontext:"BlockORef"` + Meta waveobj.MetaMapType `json:"meta"` +} + +type CommandResolveIdsData struct { + BlockId string `json:"blockid" wshcontext:"BlockId"` + Ids []string `json:"ids"` +} + +type CommandResolveIdsRtnData struct { + ResolvedIds map[string]waveobj.ORef `json:"resolvedids"` +} + +type CommandCreateBlockData struct { + TabId string `json:"tabid" wshcontext:"TabId"` + BlockDef *waveobj.BlockDef `json:"blockdef"` + RtOpts *waveobj.RuntimeOpts `json:"rtopts,omitempty"` + Magnified bool `json:"magnified,omitempty"` +} + +type CommandBlockSetViewData struct { + BlockId string `json:"blockid" wshcontext:"BlockId"` + View string `json:"view"` +} + +type CommandControllerResyncData struct { + ForceRestart bool `json:"forcerestart,omitempty"` + TabId string `json:"tabid" wshcontext:"TabId"` + BlockId string `json:"blockid" wshcontext:"BlockId"` + RtOpts *waveobj.RuntimeOpts `json:"rtopts,omitempty"` +} + +type CommandBlockInputData struct { + BlockId string `json:"blockid" wshcontext:"BlockId"` + InputData64 string `json:"inputdata64,omitempty"` + SigName string `json:"signame,omitempty"` + TermSize *waveobj.TermSize `json:"termsize,omitempty"` +} + +type CommandFileData struct { + ZoneId string `json:"zoneid" wshcontext:"BlockId"` + FileName string `json:"filename"` + Data64 string `json:"data64,omitempty"` +} + +type CommandAppendIJsonData struct { + ZoneId string `json:"zoneid" wshcontext:"BlockId"` + FileName string `json:"filename"` + Data ijson.Command `json:"data"` +} + +type CommandDeleteBlockData struct { + BlockId string `json:"blockid" wshcontext:"BlockId"` +} + +type CommandEventReadHistoryData struct { + Event string `json:"event"` + Scope string `json:"scope"` + MaxItems int `json:"maxitems"` +} + +type OpenAiStreamRequest struct { + ClientId string `json:"clientid,omitempty"` + Opts *OpenAIOptsType `json:"opts"` + Prompt []OpenAIPromptMessageType `json:"prompt"` +} + +type OpenAIPromptMessageType struct { + Role string `json:"role"` + Content string `json:"content"` + Name string `json:"name,omitempty"` +} + +type OpenAIOptsType struct { + Model string `json:"model"` + APIToken string `json:"apitoken"` + BaseURL string `json:"baseurl,omitempty"` + MaxTokens int `json:"maxtokens,omitempty"` + MaxChoices int `json:"maxchoices,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +type OpenAIPacketType struct { + Type string `json:"type"` + Model string `json:"model,omitempty"` + Created int64 `json:"created,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Usage *OpenAIUsageType `json:"usage,omitempty"` + Index int `json:"index,omitempty"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` +} + +type OpenAIUsageType struct { + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` +} + +type CpuDataRequest struct { + Id string `json:"id"` + Count int `json:"count"` +} + +type CpuDataType struct { + Time int64 `json:"time"` + Value float64 `json:"value"` +} + +type FileInfo struct { + Path string `json:"path"` // cleaned path (may have "~") + Dir string `json:"dir"` // returns the directory part of the path (if this is a a directory, it will be equal to Path). "~" will be expanded, and separators will be normalized to "/" + Name string `json:"name"` + NotFound bool `json:"notfound,omitempty"` + Size int64 `json:"size"` + Mode os.FileMode `json:"mode"` + ModeStr string `json:"modestr"` + ModTime int64 `json:"modtime"` + IsDir bool `json:"isdir,omitempty"` + MimeType string `json:"mimetype,omitempty"` + ReadOnly bool `json:"readonly,omitempty"` // this is not set for fileinfo's returned from directory listings +} + +type CommandRemoteStreamFileData struct { + Path string `json:"path"` + ByteRange string `json:"byterange,omitempty"` +} + +type CommandRemoteStreamFileRtnData struct { + FileInfo []*FileInfo `json:"fileinfo,omitempty"` + Data64 string `json:"data64,omitempty"` +} + +type CommandRemoteWriteFileData struct { + Path string `json:"path"` + Data64 string `json:"data64"` + CreateMode os.FileMode `json:"createmode,omitempty"` +} + +const ( + TimeSeries_Cpu = "cpu" +) + +type TimeSeriesData struct { + Ts int64 `json:"ts"` + Values map[string]float64 `json:"values"` +} + +type ConnStatus struct { + Status string `json:"status"` + Connection string `json:"connection"` + Connected bool `json:"connected"` + HasConnected bool `json:"hasconnected"` // true if it has *ever* connected successfully + ActiveConnNum int `json:"activeconnnum"` + Error string `json:"error,omitempty"` +} + +type WebSelectorOpts struct { + All bool `json:"all,omitempty"` + Inner bool `json:"inner,omitempty"` +} + +type CommandWebSelectorData struct { + WindowId string `json:"windowid"` + BlockId string `json:"blockid" wshcontext:"BlockId"` + TabId string `json:"tabid" wshcontext:"TabId"` + Selector string `json:"selector"` + Opts *WebSelectorOpts `json:"opts,omitempty"` +} + +type BlockInfoData struct { + BlockId string `json:"blockid"` + TabId string `json:"tabid"` + WindowId string `json:"windowid"` + Meta waveobj.MetaMapType `json:"meta"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go new file mode 100644 index 000000000..890c81726 --- /dev/null +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -0,0 +1,549 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshserver + +// this file contains the implementation of the wsh server methods + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io/fs" + "log" + "regexp" + "strconv" + "strings" + "time" + + "github.com/wavetermdev/waveterm/pkg/blockcontroller" + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/remote" + "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/waveai" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wcore" + "github.com/wavetermdev/waveterm/pkg/wlayout" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +const SimpleId_This = "this" + +var SimpleId_BlockNum_Regex = regexp.MustCompile(`^\d+$`) + +type WshServer struct{} + +func (*WshServer) WshServerImpl() {} + +var WshServerImpl = WshServer{} + +func (ws *WshServer) TestCommand(ctx context.Context, data string) error { + defer func() { + if r := recover(); r != nil { + log.Printf("panic in TestCommand: %v", r) + } + }() + rpcSource := wshutil.GetRpcSourceFromContext(ctx) + log.Printf("TEST src:%s | %s\n", rpcSource, data) + if rpcSource == "" { + return nil + } + go func() { + mainClient := GetMainRpcClient() + wshclient.MessageCommand(mainClient, wshrpc.CommandMessageData{Message: "test message"}, &wshrpc.RpcOpts{NoResponse: true, Route: rpcSource}) + resp, err := wshclient.RemoteFileInfoCommand(mainClient, "~/work/wails/thenextwave/README.md", &wshrpc.RpcOpts{Route: rpcSource}) + if err != nil { + log.Printf("error getting remote file info: %v", err) + return + } + log.Printf("remote file info: %#v\n", resp) + rch := wshclient.RemoteStreamFileCommand(mainClient, wshrpc.CommandRemoteStreamFileData{Path: "~/work/wails/thenextwave/README.md"}, &wshrpc.RpcOpts{Route: rpcSource}) + for msg := range rch { + if msg.Error != nil { + log.Printf("error in stream: %v", msg.Error) + break + } + if msg.Response.FileInfo != nil { + log.Printf("stream resp (fileinfo): %v\n", msg.Response.FileInfo) + } + if msg.Response.Data64 != "" { + log.Printf("stream resp (data): %v\n", len(msg.Response.Data64)) + } + } + }() + return nil +} + +// for testing +func (ws *WshServer) MessageCommand(ctx context.Context, data wshrpc.CommandMessageData) error { + log.Printf("MESSAGE: %s | %q\n", data.ORef, data.Message) + return nil +} + +// for testing +func (ws *WshServer) StreamTestCommand(ctx context.Context) chan wshrpc.RespOrErrorUnion[int] { + rtn := make(chan wshrpc.RespOrErrorUnion[int]) + go func() { + for i := 1; i <= 5; i++ { + rtn <- wshrpc.RespOrErrorUnion[int]{Response: i} + time.Sleep(1 * time.Second) + } + close(rtn) + }() + return rtn +} + +func (ws *WshServer) StreamWaveAiCommand(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.OpenAIPacketType] { + if request.Opts.BaseURL == "" && request.Opts.APIToken == "" { + return waveai.RunCloudCompletionStream(ctx, request) + } + return waveai.RunLocalCompletionStream(ctx, request) +} + +func MakePlotData(ctx context.Context, blockId string) error { + block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return err + } + viewName := block.Meta.GetString(waveobj.MetaKey_View, "") + if viewName != "cpuplot" { + return fmt.Errorf("invalid view type: %s", viewName) + } + return filestore.WFS.MakeFile(ctx, blockId, "cpuplotdata", nil, filestore.FileOptsType{}) +} + +func SavePlotData(ctx context.Context, blockId string, history string) error { + block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return err + } + viewName := block.Meta.GetString(waveobj.MetaKey_View, "") + if viewName != "cpuplot" { + return fmt.Errorf("invalid view type: %s", viewName) + } + // todo: interpret the data being passed + // for now, this is just to throw an error if the block was closed + historyBytes, err := json.Marshal(history) + if err != nil { + return fmt.Errorf("unable to serialize plot data: %v", err) + } + // ignore MakeFile error (already exists is ok) + return filestore.WFS.WriteFile(ctx, blockId, "cpuplotdata", historyBytes) +} + +func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetMetaData) (waveobj.MetaMapType, error) { + obj, err := wstore.DBGetORef(ctx, data.ORef) + if err != nil { + return nil, fmt.Errorf("error getting object: %w", err) + } + if obj == nil { + return nil, fmt.Errorf("object not found: %s", data.ORef) + } + return waveobj.GetMeta(obj), nil +} + +func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { + log.Printf("SETMETA: %s | %v\n", data.ORef, data.Meta) + oref := data.ORef + err := wstore.UpdateObjectMeta(ctx, oref, data.Meta) + if err != nil { + return fmt.Errorf("error updating object meta: %w", err) + } + sendWaveObjUpdate(oref) + return nil +} + +func sendWaveObjUpdate(oref waveobj.ORef) { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + // send a waveobj:update event + waveObj, err := wstore.DBGetORef(ctx, oref) + if err != nil { + log.Printf("error getting object for update event: %v", err) + return + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WaveObjUpdate, + Scopes: []string{oref.String()}, + Data: waveobj.WaveObjUpdate{ + UpdateType: waveobj.UpdateType_Update, + OType: waveObj.GetOType(), + OID: waveobj.GetOID(waveObj), + Obj: waveObj, + }, + }) +} + +func resolveSimpleId(ctx context.Context, data wshrpc.CommandResolveIdsData, simpleId string) (*waveobj.ORef, error) { + if simpleId == SimpleId_This { + if data.BlockId == "" { + return nil, fmt.Errorf("no blockid in request") + } + return &waveobj.ORef{OType: waveobj.OType_Block, OID: data.BlockId}, nil + } + blockNum, err := strconv.Atoi(simpleId) + if err == nil { + tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) + if err != nil { + return nil, fmt.Errorf("error finding tab for blockid %s: %w", data.BlockId, err) + } + + tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error retrieving tab %s: %w", tabId, err) + } + + layout, err := wstore.DBGet[*waveobj.LayoutState](ctx, tab.LayoutState) + if err != nil { + return nil, fmt.Errorf("error retrieving layout state %s: %w", tab.LayoutState, err) + } + + if layout.LeafOrder == nil { + return nil, fmt.Errorf("could not resolve block num %v, leaf order is empty", blockNum) + } + + leafIndex := blockNum - 1 // block nums are 1-indexed, we need the 0-indexed version + if len(*layout.LeafOrder) <= leafIndex { + return nil, fmt.Errorf("could not find a node in the layout matching blockNum %v", blockNum) + } + leafEntry := (*layout.LeafOrder)[leafIndex] + return &waveobj.ORef{OType: waveobj.OType_Block, OID: leafEntry.BlockId}, nil + } else if strings.Contains(simpleId, ":") { + rtn, err := waveobj.ParseORef(simpleId) + if err != nil { + return nil, fmt.Errorf("error parsing simple id: %w", err) + } + return &rtn, nil + } + return wstore.DBResolveEasyOID(ctx, simpleId) +} + +func (ws *WshServer) ResolveIdsCommand(ctx context.Context, data wshrpc.CommandResolveIdsData) (wshrpc.CommandResolveIdsRtnData, error) { + rtn := wshrpc.CommandResolveIdsRtnData{} + rtn.ResolvedIds = make(map[string]waveobj.ORef) + for _, simpleId := range data.Ids { + oref, err := resolveSimpleId(ctx, data, simpleId) + if err != nil || oref == nil { + continue + } + rtn.ResolvedIds[simpleId] = *oref + } + return rtn, nil +} + +func (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.CommandCreateBlockData) (*waveobj.ORef, error) { + ctx = waveobj.ContextWithUpdates(ctx) + tabId := data.TabId + blockData, err := wcore.CreateBlock(ctx, tabId, data.BlockDef, data.RtOpts) + if err != nil { + return nil, fmt.Errorf("error creating block: %w", err) + } + blockRef := &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID} + windowId, err := wstore.DBFindWindowForTabId(ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error finding window for tab: %w", err) + } + if windowId == "" { + return nil, fmt.Errorf("no window found for tab") + } + err = wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ + ActionType: wlayout.LayoutActionDataType_Insert, + BlockId: blockRef.OID, + Magnified: data.Magnified, + Focused: true, + }) + if err != nil { + return nil, fmt.Errorf("error queuing layout action: %w", err) + } + updates := waveobj.ContextGetUpdatesRtn(ctx) + wps.Broker.SendUpdateEvents(updates) + return &waveobj.ORef{OType: waveobj.OType_Block, OID: blockRef.OID}, nil +} + +func (ws *WshServer) SetViewCommand(ctx context.Context, data wshrpc.CommandBlockSetViewData) error { + log.Printf("SETVIEW: %s | %q\n", data.BlockId, data.View) + ctx = waveobj.ContextWithUpdates(ctx) + block, err := wstore.DBGet[*waveobj.Block](ctx, data.BlockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + block.Meta[waveobj.MetaKey_View] = data.View + err = wstore.DBUpdate(ctx, block) + if err != nil { + return fmt.Errorf("error updating block: %w", err) + } + updates := waveobj.ContextGetUpdatesRtn(ctx) + wps.Broker.SendUpdateEvents(updates) + return nil +} + +func (ws *WshServer) ControllerStopCommand(ctx context.Context, blockId string) error { + bc := blockcontroller.GetBlockController(blockId) + if bc == nil { + return nil + } + bc.StopShellProc(true) + return nil +} + +func (ws *WshServer) ControllerResyncCommand(ctx context.Context, data wshrpc.CommandControllerResyncData) error { + if data.ForceRestart { + blockcontroller.StopBlockController(data.BlockId) + } + return blockcontroller.ResyncController(ctx, data.TabId, data.BlockId, data.RtOpts) +} + +func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.CommandBlockInputData) error { + bc := blockcontroller.GetBlockController(data.BlockId) + if bc == nil { + return fmt.Errorf("block controller not found for block %q", data.BlockId) + } + inputUnion := &blockcontroller.BlockInputUnion{ + SigName: data.SigName, + TermSize: data.TermSize, + } + if len(data.InputData64) > 0 { + inputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.InputData64))) + nw, err := base64.StdEncoding.Decode(inputBuf, []byte(data.InputData64)) + if err != nil { + return fmt.Errorf("error decoding input data: %w", err) + } + inputUnion.InputData = inputBuf[:nw] + } + return bc.SendInput(inputUnion) +} + +func (ws *WshServer) FileWriteCommand(ctx context.Context, data wshrpc.CommandFileData) error { + dataBuf, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return fmt.Errorf("error decoding data64: %w", err) + } + err = filestore.WFS.WriteFile(ctx, data.ZoneId, data.FileName, dataBuf) + if err != nil { + return fmt.Errorf("error writing to blockfile: %w", err) + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockFile, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, data.ZoneId).String()}, + Data: &wps.WSFileEventData{ + ZoneId: data.ZoneId, + FileName: data.FileName, + FileOp: wps.FileOp_Invalidate, + }, + }) + return nil +} + +func (ws *WshServer) FileReadCommand(ctx context.Context, data wshrpc.CommandFileData) (string, error) { + _, dataBuf, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) + if err != nil { + return "", fmt.Errorf("error reading blockfile: %w", err) + } + return base64.StdEncoding.EncodeToString(dataBuf), nil +} + +func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.CommandFileData) error { + dataBuf, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return fmt.Errorf("error decoding data64: %w", err) + } + err = filestore.WFS.AppendData(ctx, data.ZoneId, data.FileName, dataBuf) + if err != nil { + return fmt.Errorf("error appending to blockfile: %w", err) + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockFile, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, data.ZoneId).String()}, + Data: &wps.WSFileEventData{ + ZoneId: data.ZoneId, + FileName: data.FileName, + FileOp: wps.FileOp_Append, + Data64: base64.StdEncoding.EncodeToString(dataBuf), + }, + }) + return nil +} + +func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.CommandAppendIJsonData) error { + tryCreate := true + if data.FileName == blockcontroller.BlockFile_Html && tryCreate { + err := filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, nil, filestore.FileOptsType{MaxSize: blockcontroller.DefaultHtmlMaxFileSize, IJson: true}) + if err != nil && err != fs.ErrExist { + return fmt.Errorf("error creating blockfile[html]: %w", err) + } + } + err := filestore.WFS.AppendIJson(ctx, data.ZoneId, data.FileName, data.Data) + if err != nil { + return fmt.Errorf("error appending to blockfile(ijson): %w", err) + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockFile, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, data.ZoneId).String()}, + Data: &wps.WSFileEventData{ + ZoneId: data.ZoneId, + FileName: data.FileName, + FileOp: wps.FileOp_Append, + Data64: base64.StdEncoding.EncodeToString([]byte("{}")), + }, + }) + return nil +} + +func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { + ctx = waveobj.ContextWithUpdates(ctx) + tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) + if err != nil { + return fmt.Errorf("error finding tab for block: %w", err) + } + if tabId == "" { + return fmt.Errorf("no tab found for block") + } + windowId, err := wstore.DBFindWindowForTabId(ctx, tabId) + if err != nil { + return fmt.Errorf("error finding window for tab: %w", err) + } + if windowId == "" { + return fmt.Errorf("no window found for tab") + } + err = wcore.DeleteBlock(ctx, tabId, data.BlockId) + if err != nil { + return fmt.Errorf("error deleting block: %w", err) + } + wlayout.QueueLayoutActionForTab(ctx, tabId, waveobj.LayoutActionData{ + ActionType: wlayout.LayoutActionDataType_Remove, + BlockId: data.BlockId, + }) + updates := waveobj.ContextGetUpdatesRtn(ctx) + wps.Broker.SendUpdateEvents(updates) + return nil +} + +func (ws *WshServer) EventRecvCommand(ctx context.Context, data wps.WaveEvent) error { + return nil +} + +func (ws *WshServer) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error { + rpcSource := wshutil.GetRpcSourceFromContext(ctx) + if rpcSource == "" { + return fmt.Errorf("no rpc source set") + } + if data.Sender == "" { + data.Sender = rpcSource + } + wps.Broker.Publish(data) + return nil +} + +func (ws *WshServer) EventSubCommand(ctx context.Context, data wps.SubscriptionRequest) error { + log.Printf("EventSubCommand: %v\n", data) + rpcSource := wshutil.GetRpcSourceFromContext(ctx) + if rpcSource == "" { + return fmt.Errorf("no rpc source set") + } + wps.Broker.Subscribe(rpcSource, data) + return nil +} + +func (ws *WshServer) EventUnsubCommand(ctx context.Context, data string) error { + rpcSource := wshutil.GetRpcSourceFromContext(ctx) + if rpcSource == "" { + return fmt.Errorf("no rpc source set") + } + wps.Broker.Unsubscribe(rpcSource, data) + return nil +} + +func (ws *WshServer) EventUnsubAllCommand(ctx context.Context) error { + rpcSource := wshutil.GetRpcSourceFromContext(ctx) + if rpcSource == "" { + return fmt.Errorf("no rpc source set") + } + wps.Broker.UnsubscribeAll(rpcSource) + return nil +} + +func (ws *WshServer) EventReadHistoryCommand(ctx context.Context, data wshrpc.CommandEventReadHistoryData) ([]*wps.WaveEvent, error) { + events := wps.Broker.ReadEventHistory(data.Event, data.Scope, data.MaxItems) + return events, nil +} + +func (ws *WshServer) SetConfigCommand(ctx context.Context, data wconfig.MetaSettingsType) error { + log.Printf("SETCONFIG: %v\n", data) + return wconfig.SetBaseConfigValue(data.MetaMapType) +} + +func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) { + rtn := conncontroller.GetAllConnStatus() + return rtn, nil +} + +func (ws *WshServer) ConnEnsureCommand(ctx context.Context, connName string) error { + return conncontroller.EnsureConnection(ctx, connName) +} + +func (ws *WshServer) ConnDisconnectCommand(ctx context.Context, connName string) error { + connOpts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("error parsing connection name: %w", err) + } + conn := conncontroller.GetConn(ctx, connOpts, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + return conn.Close() +} + +func (ws *WshServer) ConnConnectCommand(ctx context.Context, connName string) error { + connOpts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("error parsing connection name: %w", err) + } + conn := conncontroller.GetConn(ctx, connOpts, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + return conn.Connect(ctx) +} + +func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, connName string) error { + connOpts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("error parsing connection name: %w", err) + } + conn := conncontroller.GetConn(ctx, connOpts, false) + if conn == nil { + return fmt.Errorf("connection not found: %s", connName) + } + return conn.CheckAndInstallWsh(ctx, connName, &conncontroller.WshInstallOpts{Force: true, NoUserPrompt: true}) +} + +func (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) { + return conncontroller.GetConnectionsList() +} + +func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wshrpc.BlockInfoData, error) { + blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return nil, fmt.Errorf("error getting block: %w", err) + } + tabId, err := wstore.DBFindTabForBlockId(ctx, blockId) + if err != nil { + return nil, fmt.Errorf("error finding tab for block: %w", err) + } + windowId, err := wstore.DBFindWindowForTabId(ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error finding window for tab: %w", err) + } + return &wshrpc.BlockInfoData{ + BlockId: blockId, + TabId: tabId, + WindowId: windowId, + Meta: blockData.Meta, + }, nil +} diff --git a/pkg/wshrpc/wshserver/wshserverutil.go b/pkg/wshrpc/wshserver/wshserverutil.go new file mode 100644 index 000000000..c70aa79bf --- /dev/null +++ b/pkg/wshrpc/wshserver/wshserverutil.go @@ -0,0 +1,29 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshserver + +import ( + "sync" + + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const ( + DefaultOutputChSize = 32 + DefaultInputChSize = 32 +) + +var waveSrvClient_Singleton *wshutil.WshRpc +var waveSrvClient_Once = &sync.Once{} + +// returns the wavesrv main rpc client singleton +func GetMainRpcClient() *wshutil.WshRpc { + waveSrvClient_Once.Do(func() { + inputCh := make(chan []byte, DefaultInputChSize) + outputCh := make(chan []byte, DefaultOutputChSize) + waveSrvClient_Singleton = wshutil.MakeWshRpc(inputCh, outputCh, wshrpc.RpcContext{}, &WshServerImpl) + }) + return waveSrvClient_Singleton +} diff --git a/pkg/wshutil/wshadapter.go b/pkg/wshutil/wshadapter.go new file mode 100644 index 000000000..9f21fd300 --- /dev/null +++ b/pkg/wshutil/wshadapter.go @@ -0,0 +1,156 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "fmt" + "reflect" + "strings" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +var WshCommandDeclMap = wshrpc.GenerateWshCommandDeclMap() + +func findCmdMethod(impl any, cmd string) *reflect.Method { + rtype := reflect.TypeOf(impl) + methodName := cmd + "command" + for i := 0; i < rtype.NumMethod(); i++ { + method := rtype.Method(i) + if strings.ToLower(method.Name) == methodName { + return &method + } + } + return nil +} + +func decodeRtnVals(rtnVals []reflect.Value) (any, error) { + switch len(rtnVals) { + case 0: + return nil, nil + case 1: + errIf := rtnVals[0].Interface() + if errIf == nil { + return nil, nil + } + return nil, errIf.(error) + case 2: + errIf := rtnVals[1].Interface() + if errIf == nil { + return rtnVals[0].Interface(), nil + } + return rtnVals[0].Interface(), errIf.(error) + default: + return nil, fmt.Errorf("too many return values: %d", len(rtnVals)) + } +} + +func noImplHandler(handler *RpcResponseHandler) bool { + handler.SendResponseError(fmt.Errorf("command %q not implemented", handler.GetCommand())) + return true +} + +func recodeCommandData(command string, data any, rpcCtx *wshrpc.RpcContext) (any, error) { + // only applies to initial command packet + if command == "" { + return data, nil + } + methodDecl := WshCommandDeclMap[command] + if methodDecl == nil { + return data, fmt.Errorf("command %q not found", command) + } + if methodDecl.CommandDataType == nil { + return data, nil + } + commandDataPtr := reflect.New(methodDecl.CommandDataType).Interface() + if data != nil { + err := utilfn.ReUnmarshal(commandDataPtr, data) + if err != nil { + return data, fmt.Errorf("error re-marshalling command data: %w", err) + } + if rpcCtx != nil { + wshrpc.HackRpcContextIntoData(commandDataPtr, *rpcCtx) + } + } + return reflect.ValueOf(commandDataPtr).Elem().Interface(), nil +} + +func serverImplAdapter(impl any) func(*RpcResponseHandler) bool { + if impl == nil { + return noImplHandler + } + rtype := reflect.TypeOf(impl) + if rtype.Kind() != reflect.Ptr && rtype.Elem().Kind() != reflect.Struct { + panic(fmt.Sprintf("expected struct pointer, got %s", rtype)) + } + // returns isAsync + return func(handler *RpcResponseHandler) bool { + cmd := handler.GetCommand() + methodDecl := WshCommandDeclMap[cmd] + if methodDecl == nil { + handler.SendResponseError(fmt.Errorf("command %q not found", cmd)) + return true + } + rmethod := findCmdMethod(impl, cmd) + if rmethod == nil { + if !handler.NeedsResponse() { + // we also send an out of band message here since this is likely unexpected and will require debugging + handler.SendMessage(fmt.Sprintf("command %q method %q not found", handler.GetCommand(), methodDecl.MethodName)) + } + handler.SendResponseError(fmt.Errorf("command not implemented %q", cmd)) + return true + } + implMethod := reflect.ValueOf(impl).MethodByName(rmethod.Name) + var callParams []reflect.Value + callParams = append(callParams, reflect.ValueOf(handler.Context())) + if methodDecl.CommandDataType != nil { + rpcCtx := handler.GetRpcContext() + cmdData, err := recodeCommandData(cmd, handler.GetCommandRawData(), &rpcCtx) + if err != nil { + handler.SendResponseError(err) + return true + } + callParams = append(callParams, reflect.ValueOf(cmdData)) + } + if methodDecl.CommandType == wshrpc.RpcType_Call { + rtnVals := implMethod.Call(callParams) + rtnData, rtnErr := decodeRtnVals(rtnVals) + if rtnErr != nil { + handler.SendResponseError(rtnErr) + return true + } + handler.SendResponse(rtnData, true) + return true + } else if methodDecl.CommandType == wshrpc.RpcType_ResponseStream { + rtnVals := implMethod.Call(callParams) + rtnChVal := rtnVals[0] + if rtnChVal.IsNil() { + handler.SendResponse(nil, true) + return true + } + go func() { + defer handler.Finalize() + // must use reflection here because we don't know the generic type of RespOrErrorUnion + for { + respVal, ok := rtnChVal.Recv() + if !ok { + break + } + errorVal := respVal.FieldByName("Error") + if !errorVal.IsNil() { + handler.SendResponseError(errorVal.Interface().(error)) + break + } + respData := respVal.FieldByName("Response").Interface() + handler.SendResponse(respData, false) + } + }() + return false + } else { + handler.SendResponseError(fmt.Errorf("unsupported command type %q", methodDecl.CommandType)) + return true + } + } +} diff --git a/pkg/wshutil/wshcmdreader.go b/pkg/wshutil/wshcmdreader.go new file mode 100644 index 000000000..b33dd5027 --- /dev/null +++ b/pkg/wshutil/wshcmdreader.go @@ -0,0 +1,171 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "bytes" + "fmt" + "io" + "sync" +) + +const ( + Mode_Normal = "normal" + Mode_Esc = "esc" + Mode_WaveEsc = "waveesc" +) + +const MaxBufferedDataSize = 256 * 1024 + +type PtyBuffer struct { + CVar *sync.Cond + DataBuf *bytes.Buffer + EscMode string + EscSeqBuf []byte + OSCPrefix string + InputReader io.Reader + MessageCh chan []byte + AtEOF bool + Err error +} + +// closes messageCh when input is closed (or error) +func MakePtyBuffer(oscPrefix string, input io.Reader, messageCh chan []byte) *PtyBuffer { + if len(oscPrefix) != WaveOSCPrefixLen { + panic(fmt.Sprintf("invalid OSC prefix length: %d", len(oscPrefix))) + } + b := &PtyBuffer{ + CVar: sync.NewCond(&sync.Mutex{}), + DataBuf: &bytes.Buffer{}, + OSCPrefix: oscPrefix, + EscMode: Mode_Normal, + InputReader: input, + MessageCh: messageCh, + } + go b.run() + return b +} + +func (b *PtyBuffer) setErr(err error) { + b.CVar.L.Lock() + defer b.CVar.L.Unlock() + if b.Err == nil { + b.Err = err + } + b.CVar.Broadcast() +} + +func (b *PtyBuffer) setEOF() { + b.CVar.L.Lock() + defer b.CVar.L.Unlock() + b.AtEOF = true + b.CVar.Broadcast() +} + +func (b *PtyBuffer) processWaveEscSeq(escSeq []byte) { + b.MessageCh <- escSeq +} + +func (b *PtyBuffer) run() { + defer close(b.MessageCh) + buf := make([]byte, 4096) + for { + n, err := b.InputReader.Read(buf) + b.processData(buf[:n]) + if err == io.EOF { + b.setEOF() + return + } + if err != nil { + b.setErr(fmt.Errorf("error reading input: %w", err)) + return + } + } +} + +func (b *PtyBuffer) processData(data []byte) { + outputBuf := make([]byte, 0, len(data)) + for _, ch := range data { + if b.EscMode == Mode_WaveEsc { + if ch == ESC { + // terminates the escape sequence (and the rest was invalid) + b.EscMode = Mode_Normal + outputBuf = append(outputBuf, b.EscSeqBuf...) + outputBuf = append(outputBuf, ch) + b.EscSeqBuf = nil + } else if ch == BEL || ch == ST { + // terminates the escpae sequence (is a valid Wave OSC command) + b.EscMode = Mode_Normal + waveEscSeq := b.EscSeqBuf[WaveOSCPrefixLen:] + b.EscSeqBuf = nil + b.processWaveEscSeq(waveEscSeq) + } else { + b.EscSeqBuf = append(b.EscSeqBuf, ch) + } + continue + } + if b.EscMode == Mode_Esc { + if ch == ESC || ch == BEL || ch == ST { + // these all terminate the escape sequence (invalid, not a Wave OSC) + b.EscMode = Mode_Normal + outputBuf = append(outputBuf, b.EscSeqBuf...) + outputBuf = append(outputBuf, ch) + b.EscSeqBuf = nil + continue + } + if ch != b.OSCPrefix[len(b.EscSeqBuf)] { + // this is not a Wave OSC sequence, just an escape sequence + b.EscMode = Mode_Normal + outputBuf = append(outputBuf, b.EscSeqBuf...) + outputBuf = append(outputBuf, ch) + b.EscSeqBuf = nil + continue + } + // we're still building what could be a Wave OSC sequence + b.EscSeqBuf = append(b.EscSeqBuf, ch) + // check to see if we have a full Wave OSC prefix + if len(b.EscSeqBuf) == len(b.OSCPrefix) { + b.EscMode = Mode_WaveEsc + } + continue + } + // Mode_Normal + if ch == ESC { + b.EscMode = Mode_Esc + b.EscSeqBuf = []byte{ch} + continue + } + outputBuf = append(outputBuf, ch) + } + if len(outputBuf) > 0 { + b.writeData(outputBuf) + } +} + +func (b *PtyBuffer) writeData(data []byte) { + b.CVar.L.Lock() + defer b.CVar.L.Unlock() + // only wait if buffer is currently over max size, otherwise allow this append to go through + for b.DataBuf.Len() > MaxBufferedDataSize { + b.CVar.Wait() + } + b.DataBuf.Write(data) + b.CVar.Broadcast() +} + +func (b *PtyBuffer) Read(p []byte) (n int, err error) { + b.CVar.L.Lock() + defer b.CVar.L.Unlock() + for b.DataBuf.Len() == 0 { + if b.Err != nil { + return 0, b.Err + } + if b.AtEOF { + return 0, io.EOF + } + b.CVar.Wait() + } + b.CVar.Broadcast() + return b.DataBuf.Read(p) +} diff --git a/pkg/wshutil/wshevent.go b/pkg/wshutil/wshevent.go new file mode 100644 index 000000000..8a55f1c00 --- /dev/null +++ b/pkg/wshutil/wshevent.go @@ -0,0 +1,67 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "sync" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wps" +) + +// event inverter. converts WaveEvents to a listener.On() API + +type singleListener struct { + Id string + Fn func(*wps.WaveEvent) +} + +type EventListener struct { + Lock *sync.Mutex + Listeners map[string][]singleListener +} + +func MakeEventListener() *EventListener { + return &EventListener{ + Lock: &sync.Mutex{}, + Listeners: make(map[string][]singleListener), + } +} + +func (el *EventListener) On(eventName string, fn func(*wps.WaveEvent)) string { + id := uuid.New().String() + el.Lock.Lock() + defer el.Lock.Unlock() + larr := el.Listeners[eventName] + larr = append(larr, singleListener{Id: id, Fn: fn}) + el.Listeners[eventName] = larr + return id +} + +func (el *EventListener) Unregister(eventName string, id string) { + el.Lock.Lock() + defer el.Lock.Unlock() + larr := el.Listeners[eventName] + newArr := make([]singleListener, 0) + for _, sl := range larr { + if sl.Id == id { + continue + } + newArr = append(newArr, sl) + } + el.Listeners[eventName] = newArr +} + +func (el *EventListener) getListeners(eventName string) []singleListener { + el.Lock.Lock() + defer el.Lock.Unlock() + return el.Listeners[eventName] +} + +func (el *EventListener) RecvEvent(e *wps.WaveEvent) { + larr := el.getListeners(e.Event) + for _, sl := range larr { + sl.Fn(e) + } +} diff --git a/pkg/wshutil/wshproxy.go b/pkg/wshutil/wshproxy.go new file mode 100644 index 000000000..c6a1ecf9f --- /dev/null +++ b/pkg/wshutil/wshproxy.go @@ -0,0 +1,160 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "encoding/json" + "fmt" + "log" + "sync" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +type WshRpcProxy struct { + Lock *sync.Mutex + RpcContext *wshrpc.RpcContext + ToRemoteCh chan []byte + FromRemoteCh chan []byte +} + +func MakeRpcProxy() *WshRpcProxy { + return &WshRpcProxy{ + Lock: &sync.Mutex{}, + ToRemoteCh: make(chan []byte, DefaultInputChSize), + FromRemoteCh: make(chan []byte, DefaultOutputChSize), + } +} + +func (p *WshRpcProxy) SetRpcContext(rpcCtx *wshrpc.RpcContext) { + p.Lock.Lock() + defer p.Lock.Unlock() + p.RpcContext = rpcCtx +} + +func (p *WshRpcProxy) GetRpcContext() *wshrpc.RpcContext { + p.Lock.Lock() + defer p.Lock.Unlock() + return p.RpcContext +} + +func (p *WshRpcProxy) sendResponseError(msg RpcMessage, sendErr error) { + if msg.ReqId == "" { + // no response needed + return + } + resp := RpcMessage{ + ResId: msg.ReqId, + Route: msg.Source, + Error: sendErr.Error(), + } + respBytes, _ := json.Marshal(resp) + p.SendRpcMessage(respBytes) +} + +func (p *WshRpcProxy) sendResponse(msg RpcMessage, routeId string) { + if msg.ReqId == "" { + // no response needed + return + } + resp := RpcMessage{ + ResId: msg.ReqId, + Route: msg.Source, + Data: wshrpc.CommandAuthenticateRtnData{RouteId: routeId}, + } + respBytes, _ := json.Marshal(resp) + p.SendRpcMessage(respBytes) +} + +func handleAuthenticationCommand(msg RpcMessage) (*wshrpc.RpcContext, string, error) { + if msg.Data == nil { + return nil, "", fmt.Errorf("no data in authenticate message") + } + strData, ok := msg.Data.(string) + if !ok { + return nil, "", fmt.Errorf("data in authenticate message not a string") + } + newCtx, err := ValidateAndExtractRpcContextFromToken(strData) + if err != nil { + return nil, "", fmt.Errorf("error validating token: %w", err) + } + if newCtx == nil { + return nil, "", fmt.Errorf("no context found in jwt token") + } + if newCtx.BlockId == "" && newCtx.Conn == "" { + return nil, "", fmt.Errorf("no blockid or conn found in jwt token") + } + if newCtx.BlockId != "" { + if _, err := uuid.Parse(newCtx.BlockId); err != nil { + return nil, "", fmt.Errorf("invalid blockId in jwt token") + } + } + routeId, err := MakeRouteIdFromCtx(newCtx) + if err != nil { + return nil, "", fmt.Errorf("error making routeId from context: %w", err) + } + return newCtx, routeId, nil +} + +func (p *WshRpcProxy) HandleAuthentication() (*wshrpc.RpcContext, error) { + for { + msgBytes, ok := <-p.FromRemoteCh + if !ok { + return nil, fmt.Errorf("remote closed, not authenticated") + } + var msg RpcMessage + err := json.Unmarshal(msgBytes, &msg) + if err != nil { + // nothing to do, can't even send a response since we don't have Source or ReqId + continue + } + if msg.Command == "" { + // this message is not allowed (protocol error at this point), ignore + continue + } + // we only allow one command "authenticate", everything else returns an error + if msg.Command != wshrpc.Command_Authenticate { + respErr := fmt.Errorf("connection not authenticated") + p.sendResponseError(msg, respErr) + continue + } + newCtx, routeId, err := handleAuthenticationCommand(msg) + if err != nil { + log.Printf("error handling authentication: %v\n", err) + p.sendResponseError(msg, err) + continue + } + p.sendResponse(msg, routeId) + return newCtx, nil + } +} + +func (p *WshRpcProxy) SendRpcMessage(msg []byte) { + p.ToRemoteCh <- msg +} + +func (p *WshRpcProxy) RecvRpcMessage() ([]byte, bool) { + msgBytes, ok := <-p.FromRemoteCh + if !ok || p.RpcContext == nil { + return msgBytes, ok + } + var msg RpcMessage + err := json.Unmarshal(msgBytes, &msg) + if err != nil { + // nothing to do here -- will error out at another level + return msgBytes, true + } + msg.Data, err = recodeCommandData(msg.Command, msg.Data, p.RpcContext) + if err != nil { + // nothing to do here -- will error out at another level + return msgBytes, true + } + newBytes, err := json.Marshal(msg) + if err != nil { + // nothing to do here + return msgBytes, true + } + return newBytes, true +} diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go new file mode 100644 index 000000000..26fdb8a39 --- /dev/null +++ b/pkg/wshutil/wshrouter.go @@ -0,0 +1,345 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const DefaultRoute = "wavesrv" +const SysRoute = "sys" // this route doesn't exist, just a placeholder for system messages +const ElectronRoute = "electron" + +// this works like a network switch + +// TODO maybe move the wps integration here instead of in wshserver + +type routeInfo struct { + RpcId string + SourceRouteId string + DestRouteId string +} + +type msgAndRoute struct { + msgBytes []byte + fromRouteId string +} + +type WshRouter struct { + Lock *sync.Mutex + RouteMap map[string]AbstractRpcClient // routeid => client + UpstreamClient AbstractRpcClient // upstream client (if we are not the terminal router) + AnnouncedRoutes map[string]string // routeid => local routeid + RpcMap map[string]*routeInfo // rpcid => routeinfo + InputCh chan msgAndRoute +} + +func MakeConnectionRouteId(connId string) string { + return "conn:" + connId +} + +func MakeControllerRouteId(blockId string) string { + return "controller:" + blockId +} + +func MakeWindowRouteId(windowId string) string { + return "window:" + windowId +} + +func MakeProcRouteId(procId string) string { + return "proc:" + procId +} + +var DefaultRouter = NewWshRouter() + +func NewWshRouter() *WshRouter { + rtn := &WshRouter{ + Lock: &sync.Mutex{}, + RouteMap: make(map[string]AbstractRpcClient), + AnnouncedRoutes: make(map[string]string), + RpcMap: make(map[string]*routeInfo), + InputCh: make(chan msgAndRoute, DefaultInputChSize), + } + go rtn.runServer() + return rtn +} + +func noRouteErr(routeId string) error { + if routeId == "" { + return errors.New("no default route") + } + return fmt.Errorf("no route for %q", routeId) +} + +func (router *WshRouter) SendEvent(routeId string, event wps.WaveEvent) { + rpc := router.GetRpc(routeId) + if rpc == nil { + return + } + msg := RpcMessage{ + Command: wshrpc.Command_EventRecv, + Route: routeId, + Data: event, + } + msgBytes, err := json.Marshal(msg) + if err != nil { + // nothing to do + return + } + rpc.SendRpcMessage(msgBytes) +} + +func (router *WshRouter) handleNoRoute(msg RpcMessage) { + nrErr := noRouteErr(msg.Route) + if msg.ReqId == "" { + if msg.Command == wshrpc.Command_Message { + // to prevent infinite loops + return + } + // no response needed, but send message back to source + respMsg := RpcMessage{Command: wshrpc.Command_Message, Route: msg.Source, Data: wshrpc.CommandMessageData{Message: nrErr.Error()}} + respBytes, _ := json.Marshal(respMsg) + router.InputCh <- msgAndRoute{msgBytes: respBytes, fromRouteId: SysRoute} + return + } + // send error response + response := RpcMessage{ + ResId: msg.ReqId, + Error: nrErr.Error(), + } + respBytes, _ := json.Marshal(response) + router.sendRoutedMessage(respBytes, msg.Source) +} + +func (router *WshRouter) registerRouteInfo(rpcId string, sourceRouteId string, destRouteId string) { + if rpcId == "" { + return + } + router.Lock.Lock() + defer router.Lock.Unlock() + router.RpcMap[rpcId] = &routeInfo{RpcId: rpcId, SourceRouteId: sourceRouteId, DestRouteId: destRouteId} +} + +func (router *WshRouter) unregisterRouteInfo(rpcId string) { + router.Lock.Lock() + defer router.Lock.Unlock() + delete(router.RpcMap, rpcId) +} + +func (router *WshRouter) getRouteInfo(rpcId string) *routeInfo { + router.Lock.Lock() + defer router.Lock.Unlock() + return router.RpcMap[rpcId] +} + +func (router *WshRouter) handleAnnounceMessage(msg RpcMessage, input msgAndRoute) { + // if we have an upstream, send it there + // if we don't (we are the terminal router), then add it to our announced route map + upstream := router.GetUpstreamClient() + if upstream != nil { + upstream.SendRpcMessage(input.msgBytes) + return + } + if msg.Source == input.fromRouteId { + // not necessary to save the id mapping + return + } + router.Lock.Lock() + defer router.Lock.Unlock() + router.AnnouncedRoutes[msg.Source] = input.fromRouteId +} + +func (router *WshRouter) handleUnannounceMessage(msg RpcMessage) { + router.Lock.Lock() + defer router.Lock.Unlock() + delete(router.AnnouncedRoutes, msg.Source) +} + +func (router *WshRouter) getAnnouncedRoute(routeId string) string { + router.Lock.Lock() + defer router.Lock.Unlock() + return router.AnnouncedRoutes[routeId] +} + +// returns true if message was sent, false if failed +func (router *WshRouter) sendRoutedMessage(msgBytes []byte, routeId string) bool { + rpc := router.GetRpc(routeId) + if rpc != nil { + rpc.SendRpcMessage(msgBytes) + return true + } + upstream := router.GetUpstreamClient() + if upstream != nil { + upstream.SendRpcMessage(msgBytes) + return true + } else { + // we are the upstream, so consult our announced routes map + localRouteId := router.getAnnouncedRoute(routeId) + rpc := router.GetRpc(localRouteId) + if rpc == nil { + return false + } + rpc.SendRpcMessage(msgBytes) + return true + } +} + +func (router *WshRouter) runServer() { + for input := range router.InputCh { + msgBytes := input.msgBytes + var msg RpcMessage + err := json.Unmarshal(msgBytes, &msg) + if err != nil { + fmt.Println("error unmarshalling message: ", err) + continue + } + routeId := msg.Route + if msg.Command == wshrpc.Command_RouteAnnounce { + router.handleAnnounceMessage(msg, input) + continue + } + if msg.Command == wshrpc.Command_RouteUnannounce { + router.handleUnannounceMessage(msg) + continue + } + if msg.Command != "" { + // new comand, setup new rpc + ok := router.sendRoutedMessage(msgBytes, routeId) + if !ok { + router.handleNoRoute(msg) + continue + } + router.registerRouteInfo(msg.ReqId, msg.Source, routeId) + continue + } + // look at reqid or resid to route correctly + if msg.ReqId != "" { + routeInfo := router.getRouteInfo(msg.ReqId) + if routeInfo == nil { + // no route info, nothing to do + continue + } + // no need to check the return value here (noop if failed) + router.sendRoutedMessage(msgBytes, routeInfo.DestRouteId) + continue + } else if msg.ResId != "" { + routeInfo := router.getRouteInfo(msg.ResId) + if routeInfo == nil { + // no route info, nothing to do + continue + } + router.sendRoutedMessage(msgBytes, routeInfo.SourceRouteId) + if !msg.Cont { + router.unregisterRouteInfo(msg.ResId) + } + continue + } else { + // this is a bad message (no command, reqid, or resid) + continue + } + } +} + +func (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) error { + for { + if router.GetRpc(routeId) != nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(30 * time.Millisecond): + continue + } + } +} + +// this will also consume the output channel of the abstract client +func (router *WshRouter) RegisterRoute(routeId string, rpc AbstractRpcClient) { + if routeId == SysRoute { + // cannot register sys route + log.Printf("error: WshRouter cannot register sys route\n") + return + } + log.Printf("[router] registering wsh route %q\n", routeId) + router.Lock.Lock() + defer router.Lock.Unlock() + router.RouteMap[routeId] = rpc + go func() { + // announce + if router.GetUpstreamClient() != nil { + announceMsg := RpcMessage{Command: wshrpc.Command_RouteAnnounce, Source: routeId} + announceBytes, _ := json.Marshal(announceMsg) + router.GetUpstreamClient().SendRpcMessage(announceBytes) + } + for { + msgBytes, ok := rpc.RecvRpcMessage() + if !ok { + break + } + var rpcMsg RpcMessage + err := json.Unmarshal(msgBytes, &rpcMsg) + if err != nil { + continue + } + if rpcMsg.Command != "" { + if rpcMsg.Source == "" { + rpcMsg.Source = routeId + } + if rpcMsg.Route == "" { + rpcMsg.Route = DefaultRoute + } + msgBytes, err = json.Marshal(rpcMsg) + if err != nil { + continue + } + } + router.InputCh <- msgAndRoute{msgBytes: msgBytes, fromRouteId: routeId} + } + }() +} + +func (router *WshRouter) UnregisterRoute(routeId string) { + log.Printf("[router] unregistering wsh route %q\n", routeId) + router.Lock.Lock() + defer router.Lock.Unlock() + delete(router.RouteMap, routeId) + // clear out announced routes + for routeId, localRouteId := range router.AnnouncedRoutes { + if localRouteId == routeId { + delete(router.AnnouncedRoutes, routeId) + } + } + go func() { + wps.Broker.UnsubscribeAll(routeId) + }() +} + +// this may return nil (returns default only for empty routeId) +func (router *WshRouter) GetRpc(routeId string) AbstractRpcClient { + router.Lock.Lock() + defer router.Lock.Unlock() + return router.RouteMap[routeId] +} + +func (router *WshRouter) SetUpstreamClient(rpc AbstractRpcClient) { + router.Lock.Lock() + defer router.Lock.Unlock() + router.UpstreamClient = rpc +} + +func (router *WshRouter) GetUpstreamClient() AbstractRpcClient { + router.Lock.Lock() + defer router.Lock.Unlock() + return router.UpstreamClient +} diff --git a/pkg/wshutil/wshrpc.go b/pkg/wshutil/wshrpc.go new file mode 100644 index 000000000..cccc353e7 --- /dev/null +++ b/pkg/wshutil/wshrpc.go @@ -0,0 +1,676 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "reflect" + "runtime/debug" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const DefaultTimeoutMs = 5000 +const RespChSize = 32 +const DefaultMessageChSize = 32 + +type ResponseFnType = func(any) error + +// returns true if handler is complete, false for an async handler +type CommandHandlerFnType = func(*RpcResponseHandler) bool + +type ServerImpl interface { + WshServerImpl() +} + +type AbstractRpcClient interface { + SendRpcMessage(msg []byte) + RecvRpcMessage() ([]byte, bool) // blocking +} + +type WshRpc struct { + Lock *sync.Mutex + clientId string + InputCh chan []byte + OutputCh chan []byte + RpcContext *atomic.Pointer[wshrpc.RpcContext] + RpcMap map[string]*rpcData + ServerImpl ServerImpl + EventListener *EventListener + ResponseHandlerMap map[string]*RpcResponseHandler // reqId => handler +} + +type wshRpcContextKey struct{} +type wshRpcRespHandlerContextKey struct{} + +func withWshRpcContext(ctx context.Context, wshRpc *WshRpc) context.Context { + return context.WithValue(ctx, wshRpcContextKey{}, wshRpc) +} + +func withRespHandler(ctx context.Context, handler *RpcResponseHandler) context.Context { + return context.WithValue(ctx, wshRpcRespHandlerContextKey{}, handler) +} + +func GetWshRpcFromContext(ctx context.Context) *WshRpc { + rtn := ctx.Value(wshRpcContextKey{}) + if rtn == nil { + return nil + } + return rtn.(*WshRpc) +} + +func GetRpcSourceFromContext(ctx context.Context) string { + rtn := ctx.Value(wshRpcRespHandlerContextKey{}) + if rtn == nil { + return "" + } + return rtn.(*RpcResponseHandler).GetSource() +} + +func GetIsCanceledFromContext(ctx context.Context) bool { + rtn := ctx.Value(wshRpcRespHandlerContextKey{}) + if rtn == nil { + return false + } + return rtn.(*RpcResponseHandler).IsCanceled() +} + +func GetRpcResponseHandlerFromContext(ctx context.Context) *RpcResponseHandler { + rtn := ctx.Value(wshRpcRespHandlerContextKey{}) + if rtn == nil { + return nil + } + return rtn.(*RpcResponseHandler) +} + +func (w *WshRpc) SendRpcMessage(msg []byte) { + w.InputCh <- msg +} + +func (w *WshRpc) RecvRpcMessage() ([]byte, bool) { + msg, more := <-w.OutputCh + return msg, more +} + +type RpcMessage struct { + Command string `json:"command,omitempty"` + ReqId string `json:"reqid,omitempty"` + ResId string `json:"resid,omitempty"` + Timeout int `json:"timeout,omitempty"` + Route string `json:"route,omitempty"` // to route/forward requests to alternate servers + Source string `json:"source,omitempty"` // source route id + Cont bool `json:"cont,omitempty"` // flag if additional requests/responses are forthcoming + Cancel bool `json:"cancel,omitempty"` // used to cancel a streaming request or response (sent from the side that is not streaming) + Error string `json:"error,omitempty"` + DataType string `json:"datatype,omitempty"` + Data any `json:"data,omitempty"` +} + +func (r *RpcMessage) IsRpcRequest() bool { + return r.Command != "" || r.ReqId != "" +} + +func (r *RpcMessage) Validate() error { + if r.ReqId != "" && r.ResId != "" { + return fmt.Errorf("request packets may not have both reqid and resid set") + } + if r.Cancel { + if r.Command != "" { + return fmt.Errorf("cancel packets may not have command set") + } + if r.ReqId == "" && r.ResId == "" { + return fmt.Errorf("cancel packets must have reqid or resid set") + } + if r.Data != nil { + return fmt.Errorf("cancel packets may not have data set") + } + return nil + } + if r.Command != "" { + if r.ResId != "" { + return fmt.Errorf("command packets may not have resid set") + } + if r.Error != "" { + return fmt.Errorf("command packets may not have error set") + } + if r.DataType != "" { + return fmt.Errorf("command packets may not have datatype set") + } + return nil + } + if r.ReqId != "" { + if r.ResId == "" { + return fmt.Errorf("request packets must have resid set") + } + if r.Timeout != 0 { + return fmt.Errorf("non-command request packets may not have timeout set") + } + return nil + } + if r.ResId != "" { + if r.Command != "" { + return fmt.Errorf("response packets may not have command set") + } + if r.ReqId == "" { + return fmt.Errorf("response packets must have reqid set") + } + if r.Timeout != 0 { + return fmt.Errorf("response packets may not have timeout set") + } + return nil + } + return fmt.Errorf("invalid packet: must have command, reqid, or resid set") +} + +type rpcData struct { + ResCh chan *RpcMessage + Ctx context.Context +} + +func validateServerImpl(serverImpl ServerImpl) { + if serverImpl == nil { + return + } + serverType := reflect.TypeOf(serverImpl) + if serverType.Kind() != reflect.Pointer && serverType.Elem().Kind() != reflect.Struct { + panic(fmt.Sprintf("serverImpl must be a pointer to struct, got %v", serverType)) + } +} + +// closes outputCh when inputCh is closed/done +func MakeWshRpc(inputCh chan []byte, outputCh chan []byte, rpcCtx wshrpc.RpcContext, serverImpl ServerImpl) *WshRpc { + if inputCh == nil { + inputCh = make(chan []byte, DefaultInputChSize) + } + if outputCh == nil { + outputCh = make(chan []byte, DefaultOutputChSize) + } + validateServerImpl(serverImpl) + rtn := &WshRpc{ + Lock: &sync.Mutex{}, + clientId: uuid.New().String(), + InputCh: inputCh, + OutputCh: outputCh, + RpcMap: make(map[string]*rpcData), + RpcContext: &atomic.Pointer[wshrpc.RpcContext]{}, + EventListener: MakeEventListener(), + ServerImpl: serverImpl, + ResponseHandlerMap: make(map[string]*RpcResponseHandler), + } + rtn.RpcContext.Store(&rpcCtx) + go rtn.runServer() + return rtn +} + +func (w *WshRpc) ClientId() string { + return w.clientId +} + +func (w *WshRpc) GetRpcContext() wshrpc.RpcContext { + rtnPtr := w.RpcContext.Load() + return *rtnPtr +} + +func (w *WshRpc) SetRpcContext(ctx wshrpc.RpcContext) { + w.RpcContext.Store(&ctx) +} + +func (w *WshRpc) registerResponseHandler(reqId string, handler *RpcResponseHandler) { + w.Lock.Lock() + defer w.Lock.Unlock() + w.ResponseHandlerMap[reqId] = handler +} + +func (w *WshRpc) unregisterResponseHandler(reqId string) { + w.Lock.Lock() + defer w.Lock.Unlock() + delete(w.ResponseHandlerMap, reqId) +} + +func (w *WshRpc) cancelRequest(reqId string) { + if reqId == "" { + return + } + w.Lock.Lock() + defer w.Lock.Unlock() + handler := w.ResponseHandlerMap[reqId] + if handler != nil { + handler.canceled.Store(true) + } + +} + +func (w *WshRpc) handleRequest(req *RpcMessage) { + // events first + if req.Command == wshrpc.Command_EventRecv { + if req.Data == nil { + // invalid + return + } + var waveEvent wps.WaveEvent + err := utilfn.ReUnmarshal(&waveEvent, req.Data) + if err != nil { + // invalid + return + } + w.EventListener.RecvEvent(&waveEvent) + return + } + + var respHandler *RpcResponseHandler + defer func() { + if r := recover(); r != nil { + log.Printf("panic in handleRequest: %v\n", r) + debug.PrintStack() + if respHandler != nil { + respHandler.SendResponseError(fmt.Errorf("panic: %v", r)) + } + } + }() + timeoutMs := req.Timeout + if timeoutMs <= 0 { + timeoutMs = DefaultTimeoutMs + } + ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) + ctx = withWshRpcContext(ctx, w) + respHandler = &RpcResponseHandler{ + w: w, + ctx: ctx, + reqId: req.ReqId, + command: req.Command, + commandData: req.Data, + source: req.Source, + done: &atomic.Bool{}, + canceled: &atomic.Bool{}, + contextCancelFn: &atomic.Pointer[context.CancelFunc]{}, + rpcCtx: w.GetRpcContext(), + } + respHandler.contextCancelFn.Store(&cancelFn) + respHandler.ctx = withRespHandler(ctx, respHandler) + w.registerResponseHandler(req.ReqId, respHandler) + isAsync := false + defer func() { + if r := recover(); r != nil { + log.Printf("panic in handleRequest: %v\n", r) + debug.PrintStack() + respHandler.SendResponseError(fmt.Errorf("panic: %v", r)) + } + if isAsync { + go func() { + <-ctx.Done() + respHandler.Finalize() + }() + } else { + cancelFn() + respHandler.Finalize() + } + }() + handlerFn := serverImplAdapter(w.ServerImpl) + isAsync = !handlerFn(respHandler) +} + +func (w *WshRpc) runServer() { + defer close(w.OutputCh) + for msgBytes := range w.InputCh { + var msg RpcMessage + err := json.Unmarshal(msgBytes, &msg) + if err != nil { + log.Printf("wshrpc received bad message: %v\n", err) + continue + } + if msg.Cancel { + if msg.ReqId != "" { + w.cancelRequest(msg.ReqId) + } + continue + } + if msg.IsRpcRequest() { + go w.handleRequest(&msg) + } else { + respCh := w.getResponseCh(msg.ResId) + if respCh == nil { + continue + } + respCh <- &msg + if !msg.Cont { + w.unregisterRpc(msg.ResId, nil) + } + } + } +} + +func (w *WshRpc) getResponseCh(resId string) chan *RpcMessage { + if resId == "" { + return nil + } + w.Lock.Lock() + defer w.Lock.Unlock() + rd := w.RpcMap[resId] + if rd == nil { + return nil + } + return rd.ResCh +} + +func (w *WshRpc) SetServerImpl(serverImpl ServerImpl) { + validateServerImpl(serverImpl) + w.Lock.Lock() + defer w.Lock.Unlock() + w.ServerImpl = serverImpl +} + +func (w *WshRpc) registerRpc(ctx context.Context, reqId string) chan *RpcMessage { + w.Lock.Lock() + defer w.Lock.Unlock() + rpcCh := make(chan *RpcMessage, RespChSize) + w.RpcMap[reqId] = &rpcData{ + ResCh: rpcCh, + Ctx: ctx, + } + go func() { + <-ctx.Done() + w.unregisterRpc(reqId, fmt.Errorf("EC-TIME: timeout waiting for response")) + }() + return rpcCh +} + +func (w *WshRpc) unregisterRpc(reqId string, err error) { + w.Lock.Lock() + defer w.Lock.Unlock() + rd := w.RpcMap[reqId] + if rd == nil { + return + } + if err != nil { + errResp := &RpcMessage{ + ResId: reqId, + Error: err.Error(), + } + rd.ResCh <- errResp + } + delete(w.RpcMap, reqId) + close(rd.ResCh) +} + +// no response +func (w *WshRpc) SendCommand(command string, data any, opts *wshrpc.RpcOpts) error { + var optsCopy wshrpc.RpcOpts + if opts != nil { + optsCopy = *opts + } + optsCopy.NoResponse = true + optsCopy.Timeout = 0 + handler, err := w.SendComplexRequest(command, data, &optsCopy) + if err != nil { + return err + } + handler.finalize() + return nil +} + +// single response +func (w *WshRpc) SendRpcRequest(command string, data any, opts *wshrpc.RpcOpts) (any, error) { + var optsCopy wshrpc.RpcOpts + if opts != nil { + optsCopy = *opts + } + optsCopy.NoResponse = false + handler, err := w.SendComplexRequest(command, data, &optsCopy) + if err != nil { + return nil, err + } + defer handler.finalize() + return handler.NextResponse() +} + +type RpcRequestHandler struct { + w *WshRpc + ctx context.Context + ctxCancelFn *atomic.Pointer[context.CancelFunc] + reqId string + respCh chan *RpcMessage + cachedResp *RpcMessage +} + +func (handler *RpcRequestHandler) Context() context.Context { + return handler.ctx +} + +func (handler *RpcRequestHandler) SendCancel() { + defer func() { + if r := recover(); r != nil { + // this is likely a write to closed channel + log.Printf("panic in SendCancel: %v\n", r) + } + }() + msg := &RpcMessage{ + Cancel: true, + ReqId: handler.reqId, + } + barr, _ := json.Marshal(msg) // will never fail + handler.w.OutputCh <- barr + handler.finalize() +} + +func (handler *RpcRequestHandler) ResponseDone() bool { + if handler.cachedResp != nil { + return false + } + select { + case msg, more := <-handler.respCh: + if !more { + return true + } + handler.cachedResp = msg + return false + default: + return false + } +} + +func (handler *RpcRequestHandler) NextResponse() (any, error) { + var resp *RpcMessage + if handler.cachedResp != nil { + resp = handler.cachedResp + handler.cachedResp = nil + } else { + resp = <-handler.respCh + } + if resp == nil { + return nil, errors.New("response channel closed") + } + if resp.Error != "" { + return nil, errors.New(resp.Error) + } + return resp.Data, nil +} + +func (handler *RpcRequestHandler) finalize() { + cancelFnPtr := handler.ctxCancelFn.Load() + if cancelFnPtr != nil && *cancelFnPtr != nil { + (*cancelFnPtr)() + handler.ctxCancelFn.Store(nil) + } + if handler.reqId != "" { + handler.w.unregisterRpc(handler.reqId, nil) + } +} + +type RpcResponseHandler struct { + w *WshRpc + ctx context.Context + contextCancelFn *atomic.Pointer[context.CancelFunc] + reqId string + source string + command string + commandData any + rpcCtx wshrpc.RpcContext + canceled *atomic.Bool // canceled by requestor + done *atomic.Bool +} + +func (handler *RpcResponseHandler) Context() context.Context { + return handler.ctx +} + +func (handler *RpcResponseHandler) GetCommand() string { + return handler.command +} + +func (handler *RpcResponseHandler) GetCommandRawData() any { + return handler.commandData +} + +func (handler *RpcResponseHandler) GetRpcContext() wshrpc.RpcContext { + return handler.rpcCtx +} + +func (handler *RpcResponseHandler) GetSource() string { + return handler.source +} + +func (handler *RpcResponseHandler) NeedsResponse() bool { + return handler.reqId != "" +} + +func (handler *RpcResponseHandler) SendMessage(msg string) { + rpcMsg := &RpcMessage{ + Command: wshrpc.Command_Message, + Data: wshrpc.CommandMessageData{ + Message: msg, + }, + } + msgBytes, _ := json.Marshal(rpcMsg) // will never fail + handler.w.OutputCh <- msgBytes +} + +func (handler *RpcResponseHandler) SendResponse(data any, done bool) error { + defer func() { + if r := recover(); r != nil { + // this is likely a write to closed channel + log.Printf("panic in SendResponse: %v\n", r) + handler.close() + } + }() + if handler.reqId == "" { + return nil // no response expected + } + if handler.done.Load() { + return fmt.Errorf("request already done, cannot send additional response") + } + if done { + defer handler.close() + } + msg := &RpcMessage{ + ResId: handler.reqId, + Data: data, + Cont: !done, + } + barr, err := json.Marshal(msg) + if err != nil { + return err + } + handler.w.OutputCh <- barr + return nil +} + +func (handler *RpcResponseHandler) SendResponseError(err error) { + defer func() { + if r := recover(); r != nil { + // this is likely a write to closed channel + log.Printf("panic in SendResponseError: %v\n", r) + handler.close() + } + }() + if handler.reqId == "" || handler.done.Load() { + return + } + defer handler.close() + msg := &RpcMessage{ + ResId: handler.reqId, + Error: err.Error(), + } + barr, _ := json.Marshal(msg) // will never fail + handler.w.OutputCh <- barr +} + +func (handler *RpcResponseHandler) IsCanceled() bool { + return handler.canceled.Load() +} + +func (handler *RpcResponseHandler) close() { + cancelFn := handler.contextCancelFn.Load() + if cancelFn != nil && *cancelFn != nil { + (*cancelFn)() + handler.contextCancelFn.Store(nil) + } + handler.done.Store(true) +} + +// if async, caller must call finalize +func (handler *RpcResponseHandler) Finalize() { + if handler.reqId == "" || handler.done.Load() { + return + } + handler.SendResponse(nil, true) + handler.close() + handler.w.unregisterResponseHandler(handler.reqId) +} + +func (handler *RpcResponseHandler) IsDone() bool { + return handler.done.Load() +} + +func (w *WshRpc) SendComplexRequest(command string, data any, opts *wshrpc.RpcOpts) (rtnHandler *RpcRequestHandler, rtnErr error) { + if opts == nil { + opts = &wshrpc.RpcOpts{} + } + timeoutMs := opts.Timeout + if timeoutMs <= 0 { + timeoutMs = DefaultTimeoutMs + } + defer func() { + if r := recover(); r != nil { + log.Printf("panic in SendComplexRequest: %v\n", r) + rtnErr = fmt.Errorf("panic: %v", r) + } + }() + if command == "" { + return nil, fmt.Errorf("command cannot be empty") + } + handler := &RpcRequestHandler{ + w: w, + ctxCancelFn: &atomic.Pointer[context.CancelFunc]{}, + } + var cancelFn context.CancelFunc + handler.ctx, cancelFn = context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) + handler.ctxCancelFn.Store(&cancelFn) + if !opts.NoResponse { + handler.reqId = uuid.New().String() + } + req := &RpcMessage{ + Command: command, + ReqId: handler.reqId, + Data: data, + Timeout: timeoutMs, + Route: opts.Route, + } + barr, err := json.Marshal(req) + if err != nil { + return nil, err + } + handler.respCh = w.registerRpc(handler.ctx, handler.reqId) + w.OutputCh <- barr + return handler, nil +} diff --git a/pkg/wshutil/wshrpcio.go b/pkg/wshutil/wshrpcio.go new file mode 100644 index 000000000..00d564d71 --- /dev/null +++ b/pkg/wshutil/wshrpcio.go @@ -0,0 +1,89 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "bytes" + "fmt" + "io" +) + +// special I/O wrappers for wshrpc +// * terminal (wrap with OSC codes) +// * stream (json lines) +// * websocket (json packets) + +type lineBuf struct { + buf []byte + inLongLine bool +} + +const maxLineLength = 128 * 1024 + +func streamToLines_processBuf(lineBuf *lineBuf, readBuf []byte, lineFn func([]byte)) { + for len(readBuf) > 0 { + nlIdx := bytes.IndexByte(readBuf, '\n') + if nlIdx == -1 { + if lineBuf.inLongLine || len(lineBuf.buf)+len(readBuf) > maxLineLength { + lineBuf.buf = nil + lineBuf.inLongLine = true + return + } + lineBuf.buf = append(lineBuf.buf, readBuf...) + return + } + if !lineBuf.inLongLine && len(lineBuf.buf)+nlIdx <= maxLineLength { + line := append(lineBuf.buf, readBuf[:nlIdx]...) + lineFn(line) + } + lineBuf.buf = nil + lineBuf.inLongLine = false + readBuf = readBuf[nlIdx+1:] + } +} + +func StreamToLines(input io.Reader, lineFn func([]byte)) error { + var lineBuf lineBuf + readBuf := make([]byte, 16*1024) + for { + n, err := input.Read(readBuf) + streamToLines_processBuf(&lineBuf, readBuf[:n], lineFn) + if err != nil { + return err + } + } +} + +func AdaptStreamToMsgCh(input io.Reader, output chan []byte) error { + return StreamToLines(input, func(line []byte) { + output <- line + }) +} + +func AdaptOutputChToStream(outputCh chan []byte, output io.Writer) error { + for msg := range outputCh { + if _, err := output.Write(msg); err != nil { + return fmt.Errorf("error writing to output (AdaptOutputChToStream): %w", err) + } + // write trailing newline + if _, err := output.Write([]byte{'\n'}); err != nil { + return fmt.Errorf("error writing trailing newline to output (AdaptOutputChToStream): %w", err) + } + } + return nil +} + +func AdaptMsgChToPty(outputCh chan []byte, oscEsc string, output io.Writer) error { + if len(oscEsc) != 5 { + panic("oscEsc must be 5 characters") + } + for msg := range outputCh { + barr := EncodeWaveOSCBytes(oscEsc, msg) + _, err := output.Write(barr) + if err != nil { + return fmt.Errorf("error writing to output: %w", err) + } + } + return nil +} diff --git a/pkg/wshutil/wshutil.go b/pkg/wshutil/wshutil.go new file mode 100644 index 000000000..926fa8353 --- /dev/null +++ b/pkg/wshutil/wshutil.go @@ -0,0 +1,429 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshutil + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "golang.org/x/term" +) + +// these should both be 5 characters +const WaveOSC = "23198" +const WaveServerOSC = "23199" +const WaveOSCPrefixLen = 5 + 3 // \x1b] + WaveOSC + ; + \x07 + +const WaveOSCPrefix = "\x1b]" + WaveOSC + ";" +const WaveServerOSCPrefix = "\x1b]" + WaveServerOSC + ";" + +const HexChars = "0123456789ABCDEF" +const BEL = 0x07 +const ST = 0x9c +const ESC = 0x1b + +const DefaultOutputChSize = 32 +const DefaultInputChSize = 32 + +const WaveJwtTokenVarName = "WAVETERM_JWT" + +// OSC escape types +// OSC 23198 ; (JSON | base64-JSON) ST +// JSON = must escape all ASCII control characters ([\x00-\x1F\x7F]) +// we can tell the difference between JSON and base64-JSON by the first character: '{' or not + +// for responses (terminal -> program), we'll use OSC 23199 +// same json format + +func copyOscPrefix(dst []byte, oscNum string) { + dst[0] = ESC + dst[1] = ']' + copy(dst[2:], oscNum) + dst[len(oscNum)+2] = ';' +} + +func oscPrefixLen(oscNum string) int { + return 3 + len(oscNum) +} + +func makeOscPrefix(oscNum string) []byte { + output := make([]byte, oscPrefixLen(oscNum)) + copyOscPrefix(output, oscNum) + return output +} + +func EncodeWaveOSCBytes(oscNum string, barr []byte) []byte { + if len(oscNum) != 5 { + panic("oscNum must be 5 characters") + } + hasControlChars := false + for _, b := range barr { + if b < 0x20 || b == 0x7F { + hasControlChars = true + break + } + } + if !hasControlChars { + // If no control characters, directly construct the output + // \x1b] (2) + WaveOSC + ; (1) + message + \x07 (1) + output := make([]byte, oscPrefixLen(oscNum)+len(barr)+1) + copyOscPrefix(output, oscNum) + copy(output[oscPrefixLen(oscNum):], barr) + output[len(output)-1] = BEL + return output + } + + var buf bytes.Buffer + buf.Write(makeOscPrefix(oscNum)) + escSeq := [6]byte{'\\', 'u', '0', '0', '0', '0'} + for _, b := range barr { + if b < 0x20 || b == 0x7f { + escSeq[4] = HexChars[b>>4] + escSeq[5] = HexChars[b&0x0f] + buf.Write(escSeq[:]) + } else { + buf.WriteByte(b) + } + } + buf.WriteByte(BEL) + return buf.Bytes() +} + +func EncodeWaveOSCMessageEx(oscNum string, msg *RpcMessage) ([]byte, error) { + if msg == nil { + return nil, fmt.Errorf("nil message") + } + barr, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("error marshalling message to json: %w", err) + } + return EncodeWaveOSCBytes(oscNum, barr), nil +} + +var termModeLock = sync.Mutex{} +var termIsRaw bool +var origTermState *term.State +var shutdownSignalHandlersInstalled bool +var shutdownOnce sync.Once +var extraShutdownFunc atomic.Pointer[func()] + +func DoShutdown(reason string, exitCode int, quiet bool) { + shutdownOnce.Do(func() { + defer os.Exit(exitCode) + RestoreTermState() + extraFn := extraShutdownFunc.Load() + if extraFn != nil { + (*extraFn)() + } + if !quiet && reason != "" { + log.Printf("shutting down: %s\r\n", reason) + } + }) +} + +func installShutdownSignalHandlers(quiet bool) { + termModeLock.Lock() + defer termModeLock.Unlock() + if shutdownSignalHandlersInstalled { + return + } + 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), 1, quiet) + break + } + }() +} + +func SetTermRawModeAndInstallShutdownHandlers(quietShutdown bool) { + SetTermRawMode() + installShutdownSignalHandlers(quietShutdown) +} + +func SetExtraShutdownFunc(fn func()) { + extraShutdownFunc.Store(&fn) +} + +func SetTermRawMode() { + termModeLock.Lock() + defer termModeLock.Unlock() + if termIsRaw { + return + } + origState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + fmt.Fprintf(os.Stderr, "Error setting raw mode: %v\n", err) + return + } + origTermState = origState + termIsRaw = true +} + +func RestoreTermState() { + termModeLock.Lock() + defer termModeLock.Unlock() + if !termIsRaw || origTermState == nil { + return + } + term.Restore(int(os.Stdin.Fd()), origTermState) + termIsRaw = false +} + +// returns (wshRpc, wrappedStdin) +func SetupTerminalRpcClient(serverImpl ServerImpl) (*WshRpc, io.Reader) { + messageCh := make(chan []byte, DefaultInputChSize) + outputCh := make(chan []byte, DefaultOutputChSize) + ptyBuf := MakePtyBuffer(WaveServerOSCPrefix, os.Stdin, messageCh) + rpcClient := MakeWshRpc(messageCh, outputCh, wshrpc.RpcContext{}, serverImpl) + go func() { + for msg := range outputCh { + barr := EncodeWaveOSCBytes(WaveOSC, msg) + os.Stdout.Write(barr) + } + }() + return rpcClient, ptyBuf +} + +func SetupConnRpcClient(conn net.Conn, serverImpl ServerImpl) (*WshRpc, chan error, error) { + inputCh := make(chan []byte, DefaultInputChSize) + outputCh := make(chan []byte, DefaultOutputChSize) + writeErrCh := make(chan error, 1) + go func() { + writeErr := AdaptOutputChToStream(outputCh, conn) + if writeErr != nil { + writeErrCh <- writeErr + close(writeErrCh) + } + }() + go func() { + // when input is closed, close the connection + defer conn.Close() + AdaptStreamToMsgCh(conn, inputCh) + }() + rtn := MakeWshRpc(inputCh, outputCh, wshrpc.RpcContext{}, serverImpl) + return rtn, writeErrCh, nil +} + +func SetupDomainSocketRpcClient(sockName string, serverImpl ServerImpl) (*WshRpc, error) { + conn, err := net.Dial("unix", sockName) + if err != nil { + return nil, fmt.Errorf("failed to connect to Unix domain socket: %w", err) + } + rtn, errCh, err := SetupConnRpcClient(conn, serverImpl) + go func() { + defer conn.Close() + err := <-errCh + if err != nil && err != io.EOF { + log.Printf("error in domain socket connection: %v\n", err) + } + }() + return rtn, err +} + +func MakeClientJWTToken(rpcCtx wshrpc.RpcContext, sockName string) (string, error) { + claims := jwt.MapClaims{} + claims["iat"] = time.Now().Unix() + claims["iss"] = "waveterm" + claims["sock"] = sockName + claims["exp"] = time.Now().Add(time.Hour * 24 * 365).Unix() + if rpcCtx.BlockId != "" { + claims["blockid"] = rpcCtx.BlockId + } + if rpcCtx.TabId != "" { + claims["tabid"] = rpcCtx.TabId + } + if rpcCtx.Conn != "" { + claims["conn"] = rpcCtx.Conn + } + if rpcCtx.ClientType != "" { + claims["ctype"] = rpcCtx.ClientType + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString([]byte(wavebase.JwtSecret)) + if err != nil { + return "", fmt.Errorf("error signing token: %w", err) + } + return tokenStr, nil +} + +func ValidateAndExtractRpcContextFromToken(tokenStr string) (*wshrpc.RpcContext, error) { + parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + token, err := parser.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + return []byte(wavebase.JwtSecret), nil + }) + if err != nil { + return nil, fmt.Errorf("error parsing token: %w", err) + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("error getting claims from token") + } + // validate "exp" claim + if exp, ok := claims["exp"].(float64); ok { + if int64(exp) < time.Now().Unix() { + return nil, fmt.Errorf("token has expired") + } + } else { + return nil, fmt.Errorf("exp claim is missing or invalid") + } + // validate "iss" claim + if iss, ok := claims["iss"].(string); ok { + if iss != "waveterm" { + return nil, fmt.Errorf("unexpected issuer: %s", iss) + } + } else { + return nil, fmt.Errorf("iss claim is missing or invalid") + } + return mapClaimsToRpcContext(claims), nil +} + +func mapClaimsToRpcContext(claims jwt.MapClaims) *wshrpc.RpcContext { + rpcCtx := &wshrpc.RpcContext{} + if claims["blockid"] != nil { + if blockId, ok := claims["blockid"].(string); ok { + rpcCtx.BlockId = blockId + } + } + if claims["tabid"] != nil { + if tabId, ok := claims["tabid"].(string); ok { + rpcCtx.TabId = tabId + } + } + if claims["conn"] != nil { + if conn, ok := claims["conn"].(string); ok { + rpcCtx.Conn = conn + } + } + if claims["ctype"] != nil { + if ctype, ok := claims["ctype"].(string); ok { + rpcCtx.ClientType = ctype + } + } + return rpcCtx +} + +func RunWshRpcOverListener(listener net.Listener) { + defer log.Printf("domain socket listener shutting down\n") + for { + conn, err := listener.Accept() + if err == io.EOF { + break + } + if err != nil { + log.Printf("error accepting connection: %v\n", err) + break + } + log.Print("got domain socket connection\n") + go handleDomainSocketClient(conn) + } +} + +func MakeRouteIdFromCtx(rpcCtx *wshrpc.RpcContext) (string, error) { + if rpcCtx.ClientType != "" { + if rpcCtx.ClientType == wshrpc.ClientType_ConnServer { + if rpcCtx.Conn != "" { + return MakeConnectionRouteId(rpcCtx.Conn), nil + } + return "", fmt.Errorf("invalid connserver connection, no conn id") + } + if rpcCtx.ClientType == wshrpc.ClientType_BlockController { + if rpcCtx.BlockId != "" { + return MakeControllerRouteId(rpcCtx.BlockId), nil + } + return "", fmt.Errorf("invalid block controller connection, no block id") + } + return "", fmt.Errorf("invalid client type: %q", rpcCtx.ClientType) + } + procId := uuid.New().String() + return MakeProcRouteId(procId), nil +} + +func handleDomainSocketClient(conn net.Conn) { + var routeIdContainer atomic.Pointer[string] + proxy := MakeRpcProxy() + go func() { + writeErr := AdaptOutputChToStream(proxy.ToRemoteCh, conn) + if writeErr != nil { + log.Printf("error writing to domain socket: %v\n", writeErr) + } + }() + go func() { + // when input is closed, close the connection + defer func() { + conn.Close() + routeIdPtr := routeIdContainer.Load() + if routeIdPtr != nil && *routeIdPtr != "" { + DefaultRouter.UnregisterRoute(*routeIdPtr) + } + }() + AdaptStreamToMsgCh(conn, proxy.FromRemoteCh) + }() + rpcCtx, err := proxy.HandleAuthentication() + if err != nil { + conn.Close() + log.Printf("error handling authentication: %v\n", err) + return + } + // now that we're authenticated, set the ctx and attach to the router + log.Printf("domain socket connection authenticated: %#v\n", rpcCtx) + proxy.SetRpcContext(rpcCtx) + routeId, err := MakeRouteIdFromCtx(rpcCtx) + if err != nil { + conn.Close() + log.Printf("error making route id: %v\n", err) + return + } + routeIdContainer.Store(&routeId) + DefaultRouter.RegisterRoute(routeId, proxy) +} + +// only for use on client +func ExtractUnverifiedRpcContext(tokenStr string) (*wshrpc.RpcContext, error) { + // this happens on the client who does not have access to the secret key + // we want to read the claims without validating the signature + token, _, err := new(jwt.Parser).ParseUnverified(tokenStr, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("error parsing token: %w", err) + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("error getting claims from token") + } + return mapClaimsToRpcContext(claims), nil +} + +// only for use on client +func ExtractUnverifiedSocketName(tokenStr string) (string, error) { + // this happens on the client who does not have access to the secret key + // we want to read the claims without validating the signature + token, _, err := new(jwt.Parser).ParseUnverified(tokenStr, jwt.MapClaims{}) + if err != nil { + return "", fmt.Errorf("error parsing token: %w", err) + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("error getting claims from token") + } + sockName, ok := claims["sock"].(string) + if !ok { + return "", fmt.Errorf("sock claim is missing or invalid") + } + return sockName, nil +} diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go new file mode 100644 index 000000000..1c9824350 --- /dev/null +++ b/pkg/wstore/wstore.go @@ -0,0 +1,211 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wstore + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +func init() { + for _, rtype := range waveobj.AllWaveObjTypes() { + waveobj.RegisterType(rtype) + } +} + +func CreateTab(ctx context.Context, workspaceId string, name string) (*waveobj.Tab, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Tab, error) { + ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) + if ws == nil { + return nil, fmt.Errorf("workspace not found: %q", workspaceId) + } + layoutStateId := uuid.NewString() + tab := &waveobj.Tab{ + OID: uuid.NewString(), + Name: name, + BlockIds: []string{}, + LayoutState: layoutStateId, + } + layoutState := &waveobj.LayoutState{ + OID: layoutStateId, + } + ws.TabIds = append(ws.TabIds, tab.OID) + DBInsert(tx.Context(), tab) + DBInsert(tx.Context(), layoutState) + DBUpdate(tx.Context(), ws) + return tab, nil + }) +} + +func CreateWorkspace(ctx context.Context) (*waveobj.Workspace, error) { + ws := &waveobj.Workspace{ + OID: uuid.NewString(), + TabIds: []string{}, + } + DBInsert(ctx, ws) + return ws, nil +} + +func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error { + return WithTx(ctx, func(tx *TxWrap) error { + ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) + if ws == nil { + return fmt.Errorf("workspace not found: %q", workspaceId) + } + ws.TabIds = tabIds + DBUpdate(tx.Context(), ws) + return nil + }) +} + +func SetActiveTab(ctx context.Context, windowId string, tabId string) error { + return WithTx(ctx, func(tx *TxWrap) error { + window, _ := DBGet[*waveobj.Window](tx.Context(), windowId) + if window == nil { + return fmt.Errorf("window not found: %q", windowId) + } + if tabId != "" { + tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) + if tab == nil { + return fmt.Errorf("tab not found: %q", tabId) + } + } + window.ActiveTabId = tabId + DBUpdate(tx.Context(), window) + return nil + }) +} + +func UpdateTabName(ctx context.Context, tabId, name string) error { + return WithTx(ctx, func(tx *TxWrap) error { + tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) + if tab == nil { + return fmt.Errorf("tab not found: %q", tabId) + } + if tabId != "" { + tab.Name = name + DBUpdate(tx.Context(), tab) + } + return nil + }) +} + +func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) { + tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) + if tab == nil { + return nil, fmt.Errorf("tab not found: %q", tabId) + } + blockId := uuid.NewString() + blockData := &waveobj.Block{ + OID: blockId, + BlockDef: blockDef, + RuntimeOpts: rtOpts, + Meta: blockDef.Meta, + } + DBInsert(tx.Context(), blockData) + tab.BlockIds = append(tab.BlockIds, blockId) + DBUpdate(tx.Context(), tab) + return blockData, nil + }) +} + +func findStringInSlice(slice []string, val string) int { + for idx, v := range slice { + if v == val { + return idx + } + } + return -1 +} + +func DeleteBlock(ctx context.Context, tabId string, blockId string) error { + return WithTx(ctx, func(tx *TxWrap) error { + tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) + if tab == nil { + return fmt.Errorf("tab not found: %q", tabId) + } + blockIdx := findStringInSlice(tab.BlockIds, blockId) + if blockIdx == -1 { + return nil + } + tab.BlockIds = append(tab.BlockIds[:blockIdx], tab.BlockIds[blockIdx+1:]...) + DBUpdate(tx.Context(), tab) + DBDelete(tx.Context(), waveobj.OType_Block, blockId) + return nil + }) +} + +// must delete all blocks individually first +// also deletes LayoutState +func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { + return WithTx(ctx, func(tx *TxWrap) error { + ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) + if ws == nil { + return fmt.Errorf("workspace not found: %q", workspaceId) + } + tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) + if tab == nil { + return fmt.Errorf("tab not found: %q", tabId) + } + if len(tab.BlockIds) != 0 { + return fmt.Errorf("tab has blocks, must delete blocks first") + } + tabIdx := findStringInSlice(ws.TabIds, tabId) + if tabIdx == -1 { + return nil + } + ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...) + DBUpdate(tx.Context(), ws) + DBDelete(tx.Context(), waveobj.OType_Tab, tabId) + DBDelete(tx.Context(), waveobj.OType_LayoutState, tab.LayoutState) + return nil + }) +} + +func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType) error { + return WithTx(ctx, func(tx *TxWrap) error { + if oref.IsEmpty() { + return fmt.Errorf("empty object reference") + } + obj, _ := DBGetORef(tx.Context(), oref) + if obj == nil { + return ErrNotFound + } + objMeta := waveobj.GetMeta(obj) + if objMeta == nil { + objMeta = make(map[string]any) + } + newMeta := waveobj.MergeMeta(objMeta, meta, false) + waveobj.SetMeta(obj, newMeta) + DBUpdate(tx.Context(), obj) + return nil + }) +} + +func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, blockId string) error { + return WithTx(ctx, func(tx *TxWrap) error { + currentTab, _ := DBGet[*waveobj.Tab](tx.Context(), currentTabId) + if currentTab == nil { + return fmt.Errorf("current tab not found: %q", currentTabId) + } + newTab, _ := DBGet[*waveobj.Tab](tx.Context(), newTabId) + if newTab == nil { + return fmt.Errorf("new tab not found: %q", newTabId) + } + blockIdx := findStringInSlice(currentTab.BlockIds, blockId) + if blockIdx == -1 { + return fmt.Errorf("block not found in current tab: %q", blockId) + } + currentTab.BlockIds = utilfn.RemoveElemFromSlice(currentTab.BlockIds, blockId) + newTab.BlockIds = append(newTab.BlockIds, blockId) + DBUpdate(tx.Context(), currentTab) + DBUpdate(tx.Context(), newTab) + return nil + }) +} diff --git a/pkg/wstore/wstore_dboldmigration.go b/pkg/wstore/wstore_dboldmigration.go new file mode 100644 index 000000000..00c8550d6 --- /dev/null +++ b/pkg/wstore/wstore_dboldmigration.go @@ -0,0 +1,112 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wstore + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/jmoiron/sqlx" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +const OldDBName = "~/.waveterm/waveterm.db" + +func GetOldDBName() string { + return wavebase.ExpandHomeDir(OldDBName) +} + +func MakeOldDB(ctx context.Context) (*sqlx.DB, error) { + dbName := GetOldDBName() + rtn, err := sqlx.Open("sqlite3", fmt.Sprintf("file:%s?mode=ro&_busy_timeout=5000", dbName)) + if err != nil { + return nil, err + } + rtn.DB.SetMaxOpenConns(1) + return rtn, nil +} + +type OldHistoryType struct { + HistoryId string + Ts int64 + RemoteName string + HadError bool + CmdStr string + ExitCode int + DurationMs int64 +} + +func GetAllOldHistory() ([]*OldHistoryType, error) { + query := ` + SELECT + h.historyid, + h.ts, + COALESCE(r.remotecanonicalname, '') as remotename, + h.haderror, + h.cmdstr, + COALESCE(h.exitcode, 0) as exitcode, + COALESCE(h.durationms, 0) as durationms + FROM history h, remote r + WHERE h.remoteid = r.remoteid + AND NOT h.ismetacmd + ` + db, err := MakeOldDB(context.Background()) + if err != nil { + return nil, err + } + defer db.Close() + var rtn []*OldHistoryType + err = db.Select(&rtn, query) + if err != nil { + return nil, err + } + return rtn, nil +} + +func ReplaceOldHistory(ctx context.Context, hist []*OldHistoryType) error { + return WithTx(ctx, func(tx *TxWrap) error { + query := `DELETE FROM history_migrated` + tx.Exec(query) + query = `INSERT INTO history_migrated (historyid, ts, remotename, haderror, cmdstr, exitcode, durationms) + VALUES (?, ?, ?, ?, ?, ?, ?)` + for _, hobj := range hist { + tx.Exec(query, hobj.HistoryId, hobj.Ts, hobj.RemoteName, hobj.HadError, hobj.CmdStr, hobj.ExitCode, hobj.DurationMs) + } + return nil + }) +} + +func TryMigrateOldHistory() error { + ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelFn() + client, err := DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + return err + } + if client.HistoryMigrated { + return nil + } + log.Printf("trying to migrate old wave history\n") + client.HistoryMigrated = true + err = DBUpdate(ctx, client) + if err != nil { + return err + } + hist, err := GetAllOldHistory() + if err != nil { + return err + } + if len(hist) == 0 { + return nil + } + err = ReplaceOldHistory(ctx, hist) + if err != nil { + return err + } + log.Printf("migrated %d old wave history records\n", len(hist)) + return nil +} diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go new file mode 100644 index 000000000..c4cd17ba2 --- /dev/null +++ b/pkg/wstore/wstore_dbops.go @@ -0,0 +1,279 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wstore + +import ( + "context" + "fmt" + "log" + "reflect" + "time" + + "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/util/dbutil" + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +var ErrNotFound = fmt.Errorf("not found") + +func waveObjTableName(w waveobj.WaveObj) string { + return "db_" + w.GetOType() +} + +func tableNameFromOType(otype string) string { + return "db_" + otype +} + +func tableNameGen[T waveobj.WaveObj]() string { + var zeroObj T + return tableNameFromOType(zeroObj.GetOType()) +} + +func getOTypeGen[T waveobj.WaveObj]() string { + var zeroObj T + return zeroObj.GetOType() +} + +func DBGetCount[T waveobj.WaveObj](ctx context.Context) (int, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (int, error) { + table := tableNameGen[T]() + query := fmt.Sprintf("SELECT count(*) FROM %s", table) + return tx.GetInt(query), nil + }) +} + +type idDataType struct { + OId string + Version int + Data []byte +} + +func genericCastWithErr[T any](v any, err error) (T, error) { + if err != nil { + var zeroVal T + return zeroVal, err + } + if v == nil { + var zeroVal T + return zeroVal, nil + } + return v.(T), err +} + +func DBGetSingleton[T waveobj.WaveObj](ctx context.Context) (T, error) { + rtn, err := DBGetSingletonByType(ctx, getOTypeGen[T]()) + return genericCastWithErr[T](rtn, err) +} + +func DBGetSingletonByType(ctx context.Context, otype string) (waveobj.WaveObj, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) { + table := tableNameFromOType(otype) + query := fmt.Sprintf("SELECT oid, version, data FROM %s LIMIT 1", table) + var row idDataType + found := tx.Get(&row, query) + if !found { + return nil, ErrNotFound + } + rtn, err := waveobj.FromJson(row.Data) + if err != nil { + return rtn, err + } + waveobj.SetVersion(rtn, row.Version) + return rtn, nil + }) +} + +func DBExistsORef(ctx context.Context, oref waveobj.ORef) (bool, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (bool, error) { + table := tableNameFromOType(oref.OType) + query := fmt.Sprintf("SELECT oid FROM %s WHERE oid = ?", table) + return tx.Exists(query, oref.OID), nil + }) +} + +func DBGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) { + rtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id}) + return genericCastWithErr[T](rtn, err) +} + +func DBMustGet[T waveobj.WaveObj](ctx context.Context, id string) (T, error) { + rtn, err := DBGetORef(ctx, waveobj.ORef{OType: getOTypeGen[T](), OID: id}) + if err != nil { + var zeroVal T + return zeroVal, err + } + if rtn == nil { + var zeroVal T + return zeroVal, ErrNotFound + } + return rtn.(T), nil +} + +func DBGetORef(ctx context.Context, oref waveobj.ORef) (waveobj.WaveObj, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (waveobj.WaveObj, error) { + table := tableNameFromOType(oref.OType) + query := fmt.Sprintf("SELECT oid, version, data FROM %s WHERE oid = ?", table) + var row idDataType + found := tx.Get(&row, query, oref.OID) + if !found { + return nil, nil + } + rtn, err := waveobj.FromJson(row.Data) + if err != nil { + return rtn, err + } + waveobj.SetVersion(rtn, row.Version) + return rtn, nil + }) +} + +func dbSelectOIDs(ctx context.Context, otype string, oids []string) ([]waveobj.WaveObj, error) { + return WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) { + table := tableNameFromOType(otype) + query := fmt.Sprintf("SELECT oid, version, data FROM %s WHERE oid IN (SELECT value FROM json_each(?))", table) + var rows []idDataType + tx.Select(&rows, query, dbutil.QuickJson(oids)) + rtn := make([]waveobj.WaveObj, 0, len(rows)) + for _, row := range rows { + waveObj, err := waveobj.FromJson(row.Data) + if err != nil { + return nil, err + } + waveobj.SetVersion(waveObj, row.Version) + rtn = append(rtn, waveObj) + } + return rtn, nil + }) +} + +func DBSelectORefs(ctx context.Context, orefs []waveobj.ORef) ([]waveobj.WaveObj, error) { + oidsByType := make(map[string][]string) + for _, oref := range orefs { + oidsByType[oref.OType] = append(oidsByType[oref.OType], oref.OID) + } + return WithTxRtn(ctx, func(tx *TxWrap) ([]waveobj.WaveObj, error) { + rtn := make([]waveobj.WaveObj, 0, len(orefs)) + for otype, oids := range oidsByType { + rtnArr, err := dbSelectOIDs(tx.Context(), otype, oids) + if err != nil { + return nil, err + } + rtn = append(rtn, rtnArr...) + } + return rtn, nil + }) +} + +func DBResolveEasyOID(ctx context.Context, oid string) (*waveobj.ORef, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.ORef, error) { + for _, rtype := range waveobj.AllWaveObjTypes() { + otype := reflect.Zero(rtype).Interface().(waveobj.WaveObj).GetOType() + table := tableNameFromOType(otype) + var fullOID string + if len(oid) == 8 { + query := fmt.Sprintf("SELECT oid FROM %s WHERE oid LIKE ?", table) + fullOID = tx.GetString(query, oid+"%") + } else { + query := fmt.Sprintf("SELECT oid FROM %s WHERE oid = ?", table) + fullOID = tx.GetString(query, oid) + } + if fullOID != "" { + oref := waveobj.MakeORef(otype, fullOID) + return &oref, nil + } + } + return nil, ErrNotFound + }) +} + +func DBSelectMap[T waveobj.WaveObj](ctx context.Context, ids []string) (map[string]T, error) { + rtnArr, err := dbSelectOIDs(ctx, getOTypeGen[T](), ids) + if err != nil { + return nil, err + } + rtnMap := make(map[string]T) + for _, obj := range rtnArr { + rtnMap[waveobj.GetOID(obj)] = obj.(T) + } + return rtnMap, nil +} + +func DBDelete(ctx context.Context, otype string, id string) error { + err := WithTx(ctx, func(tx *TxWrap) error { + table := tableNameFromOType(otype) + query := fmt.Sprintf("DELETE FROM %s WHERE oid = ?", table) + tx.Exec(query, id) + waveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Delete, OType: otype, OID: id}) + return nil + }) + if err != nil { + return err + } + go func() { + // we spawn a go routine here because we don't want to reuse the DB connection + // since DBDelete is called in a transaction from DeleteTab + deleteCtx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + err := filestore.WFS.DeleteZone(deleteCtx, id) + if err != nil { + log.Printf("error deleting filestore zone (after deleting block): %v", err) + } + }() + return nil +} + +func DBUpdate(ctx context.Context, val waveobj.WaveObj) error { + oid := waveobj.GetOID(val) + if oid == "" { + return fmt.Errorf("cannot update %T value with empty id", val) + } + jsonData, err := waveobj.ToJson(val) + if err != nil { + return err + } + return WithTx(ctx, func(tx *TxWrap) error { + table := waveObjTableName(val) + query := fmt.Sprintf("UPDATE %s SET data = ?, version = version+1 WHERE oid = ? RETURNING version", table) + newVersion := tx.GetInt(query, jsonData, oid) + waveobj.SetVersion(val, newVersion) + waveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val}) + return nil + }) +} + +func DBInsert(ctx context.Context, val waveobj.WaveObj) error { + oid := waveobj.GetOID(val) + if oid == "" { + return fmt.Errorf("cannot insert %T value with empty id", val) + } + jsonData, err := waveobj.ToJson(val) + if err != nil { + return err + } + return WithTx(ctx, func(tx *TxWrap) error { + table := waveObjTableName(val) + waveobj.SetVersion(val, 1) + query := fmt.Sprintf("INSERT INTO %s (oid, version, data) VALUES (?, ?, ?)", table) + tx.Exec(query, oid, 1, jsonData) + waveobj.ContextAddUpdate(ctx, waveobj.WaveObjUpdate{UpdateType: waveobj.UpdateType_Update, OType: val.GetOType(), OID: oid, Obj: val}) + return nil + }) +} + +func DBFindWindowForTabId(ctx context.Context, tabId string) (string, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { + query := "SELECT oid FROM db_window WHERE data->>'activetabid' = ?" + return tx.GetString(query, tabId), nil + }) +} + +func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { + query := ` + SELECT t.oid + FROM db_tab t, json_each(data->'blockids') je + WHERE je.value = ?;` + return tx.GetString(query, blockId), nil + }) +} diff --git a/pkg/wstore/wstore_dbsetup.go b/pkg/wstore/wstore_dbsetup.go new file mode 100644 index 000000000..7df15a021 --- /dev/null +++ b/pkg/wstore/wstore_dbsetup.go @@ -0,0 +1,81 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wstore + +import ( + "context" + "fmt" + "log" + "path/filepath" + "time" + + "github.com/jmoiron/sqlx" + "github.com/sawka/txwrap" + "github.com/wavetermdev/waveterm/pkg/util/migrateutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + + dbfs "github.com/wavetermdev/waveterm/db" +) + +const WStoreDBName = "waveterm.db" + +type TxWrap = txwrap.TxWrap + +var globalDB *sqlx.DB + +func InitWStore() error { + ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelFn() + var err error + globalDB, err = MakeDB(ctx) + if err != nil { + return err + } + err = migrateutil.Migrate("wstore", globalDB.DB, dbfs.WStoreMigrationFS, "migrations-wstore") + if err != nil { + return err + } + log.Printf("wstore initialized\n") + return nil +} + +func GetDBName() string { + waveHome := wavebase.GetWaveHomeDir() + return filepath.Join(waveHome, wavebase.WaveDBDir, WStoreDBName) +} + +func MakeDB(ctx context.Context) (*sqlx.DB, error) { + dbName := GetDBName() + rtn, err := sqlx.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc&_journal_mode=WAL&_busy_timeout=5000", dbName)) + if err != nil { + return nil, err + } + rtn.DB.SetMaxOpenConns(1) + return rtn, nil +} + +func WithTx(ctx context.Context, fn func(tx *TxWrap) error) (rtnErr error) { + waveobj.ContextUpdatesBeginTx(ctx) + defer func() { + if rtnErr != nil { + waveobj.ContextUpdatesRollbackTx(ctx) + } else { + waveobj.ContextUpdatesCommitTx(ctx) + } + }() + return txwrap.WithTx(ctx, globalDB, fn) +} + +func WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (rtnVal RT, rtnErr error) { + waveobj.ContextUpdatesBeginTx(ctx) + defer func() { + if rtnErr != nil { + waveobj.ContextUpdatesRollbackTx(ctx) + } else { + waveobj.ContextUpdatesCommitTx(ctx) + } + }() + return txwrap.WithTxRtn(ctx, globalDB, fn) +} diff --git a/prettier.config.cjs b/prettier.config.cjs new file mode 100644 index 000000000..3cfb47d5f --- /dev/null +++ b/prettier.config.cjs @@ -0,0 +1,11 @@ +/** @type {import("prettier").Config} */ +module.exports = { + plugins: ["prettier-plugin-jsdoc", "prettier-plugin-organize-imports"], + printWidth: 120, + trailingComma: "es5", + useTabs: false, + jsdocVerticalAlignment: true, + jsdocSeparateReturnsFromParam: true, + jsdocSeparateTagGroups: true, + jsdocPreferCodeFences: true, +}; diff --git a/public/fontawesome/css/brands.min.css b/public/fontawesome/css/brands.min.css new file mode 100644 index 000000000..9bba20446 --- /dev/null +++ b/public/fontawesome/css/brands.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Commercial License) + * Copyright 2023 Fonticons, Inc. + */ +:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-pixiv:before{content:"\e640"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-brave:before{content:"\e63c"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-threads:before{content:"\e618"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-opensuse:before{content:"\e62b"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-debian:before{content:"\e60b"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-square-letterboxd:before{content:"\e62e"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-shoelace:before{content:"\e60c"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-square-threads:before{content:"\e619"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-google-scholar:before{content:"\e63b"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-signal-messenger:before{content:"\e663"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-mintbit:before{content:"\e62f"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-brave-reverse:before{content:"\e63d"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-letterboxd:before{content:"\e62d"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-x-twitter:before{content:"\e61b"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-upwork:before{content:"\e641"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-webflow:before{content:"\e65c"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-stubber:before{content:"\e5c7"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-odysee:before{content:"\e5c6"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-square-x-twitter:before{content:"\e61a"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"} \ No newline at end of file diff --git a/public/fontawesome/css/fontawesome.min.css b/public/fontawesome/css/fontawesome.min.css new file mode 100644 index 000000000..3e764c8a9 --- /dev/null +++ b/public/fontawesome/css/fontawesome.min.css @@ -0,0 +1,9 @@ +/*! + * Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Commercial License) + * Copyright 2023 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Pro");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-duotone,.fa-light,.fa-regular,.fa-sharp,.fa-sharp-solid,.fa-solid,.fa-thin,.fab,.fad,.fal,.far,.fas,.fasl,.fasr,.fass,.fast,.fat{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-light,.fa-regular,.fa-solid,.fa-thin,.fal,.far,.fas,.fat{font-family:"Font Awesome 6 Pro"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-classic.fa-duotone,.fa-duotone,.fad{font-family:"Font Awesome 6 Duotone"}.fa-sharp,.fasl,.fasr,.fass,.fast{font-family:"Font Awesome 6 Sharp"}.fa-sharp,.fass{font-weight:900}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-transition-delay:0s;transition-delay:0s;-webkit-transition-duration:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} + +.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-wagon-covered:before{content:"\f8ee"}.fa-line-height:before{content:"\f871"}.fa-bagel:before{content:"\e3d7"}.fa-transporter-7:before{content:"\e2a8"}.fa-at:before{content:"\40"}.fa-rectangles-mixed:before{content:"\e323"}.fa-phone-arrow-up-right:before,.fa-phone-arrow-up:before,.fa-phone-outgoing:before{content:"\e224"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-circle-l:before{content:"\e114"}.fa-head-side-goggles:before,.fa-head-vr:before{content:"\f6ea"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-face-hand-yawn:before{content:"\e379"}.fa-gauge-simple-min:before,.fa-tachometer-slowest:before{content:"\f62d"}.fa-stethoscope:before{content:"\f0f1"}.fa-coffin:before{content:"\f6c6"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-bowl-salad:before,.fa-salad:before{content:"\f81e"}.fa-info:before{content:"\f129"}.fa-robot-astromech:before{content:"\e2d2"}.fa-ring-diamond:before{content:"\e5ab"}.fa-fondue-pot:before{content:"\e40d"}.fa-theta:before{content:"\f69e"}.fa-face-hand-peeking:before{content:"\e481"}.fa-square-user:before{content:"\e283"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-tire-pressure-warning:before{content:"\f633"}.fa-wifi-2:before,.fa-wifi-fair:before{content:"\f6ab"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-mp3-player:before{content:"\f8ce"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-tally-4:before{content:"\e297"}.fa-rectangle-history:before{content:"\e4a2"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-sun-haze:before{content:"\f765"}.fa-text-size:before{content:"\f894"}.fa-ufo:before{content:"\e047"}.fa-fork:before,.fa-utensil-fork:before{content:"\f2e3"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-mobile-signal:before{content:"\e1ef"}.fa-barcode-scan:before{content:"\f465"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-folder-arrow-down:before,.fa-folder-download:before{content:"\e053"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-face-icicles:before{content:"\e37c"}.fa-shovel:before{content:"\f713"}.fa-door-open:before{content:"\f52b"}.fa-films:before{content:"\e17a"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-face-glasses:before{content:"\e377"}.fa-nfc:before{content:"\e1f7"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-closed-captioning-slash:before{content:"\e135"}.fa-calculator-alt:before,.fa-calculator-simple:before{content:"\f64c"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-sliders-up:before,.fa-sliders-v:before{content:"\f3f1"}.fa-location-minus:before,.fa-map-marker-minus:before{content:"\f609"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-ski-boot:before{content:"\e3cc"}.fa-rectangle-sd:before,.fa-standard-definition:before{content:"\e28a"}.fa-h1:before{content:"\f313"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-message-bot:before{content:"\e3b8"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-light-ceiling:before{content:"\e016"}.fa-comment-alt-exclamation:before,.fa-message-exclamation:before{content:"\f4a5"}.fa-bowl-scoop:before,.fa-bowl-shaved-ice:before{content:"\e3de"}.fa-square-x:before{content:"\e286"}.fa-building-memo:before{content:"\e61e"}.fa-utility-pole-double:before{content:"\e2c4"}.fa-flag-checkered:before{content:"\f11e"}.fa-chevron-double-up:before,.fa-chevrons-up:before{content:"\f325"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-user-vneck:before{content:"\e461"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-arrow-square-right:before,.fa-square-arrow-right:before{content:"\f33b"}.fa-location-plus:before,.fa-map-marker-plus:before{content:"\f60a"}.fa-lightbulb-exclamation-on:before{content:"\e1ca"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-crate-empty:before{content:"\e151"}.fa-diagram-predecessor:before{content:"\e477"}.fa-transporter:before{content:"\e042"}.fa-calendar-circle-user:before{content:"\e471"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-person-carry-box:before,.fa-person-carry:before{content:"\f4cf"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-union:before{content:"\f6a2"}.fa-chevron-double-left:before,.fa-chevrons-left:before{content:"\f323"}.fa-circle-heart:before,.fa-heart-circle:before{content:"\f4c7"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-bring-forward:before{content:"\f856"}.fa-square-p:before{content:"\e279"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-sigma:before{content:"\f68b"}.fa-camera-movie:before{content:"\f8a9"}.fa-bong:before{content:"\f55c"}.fa-clarinet:before{content:"\f8ad"}.fa-truck-flatbed:before{content:"\e2b6"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-arrows-rotate-reverse:before{content:"\e630"}.fa-leaf-heart:before{content:"\f4cb"}.fa-house-building:before{content:"\e1b1"}.fa-cheese-swiss:before{content:"\f7f0"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-bow-arrow:before{content:"\f6b9"}.fa-cart-xmark:before{content:"\e0dd"}.fa-hexagon-xmark:before,.fa-times-hexagon:before,.fa-xmark-hexagon:before{content:"\f2ee"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-merge:before{content:"\e526"}.fa-pager:before{content:"\f815"}.fa-cart-minus:before{content:"\e0db"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-pan-frying:before{content:"\e42c"}.fa-grid-3:before,.fa-grid:before{content:"\e195"}.fa-football-helmet:before{content:"\f44f"}.fa-hand-love:before{content:"\e1a5"}.fa-trees:before{content:"\f724"}.fa-strikethrough:before{content:"\f0cc"}.fa-page:before{content:"\e428"}.fa-k:before{content:"\4b"}.fa-diagram-previous:before{content:"\e478"}.fa-gauge-min:before,.fa-tachometer-alt-slowest:before{content:"\f628"}.fa-folder-grid:before{content:"\e188"}.fa-eggplant:before{content:"\e16c"}.fa-excavator:before{content:"\e656"}.fa-ram:before{content:"\f70a"}.fa-landmark-flag:before{content:"\e51c"}.fa-lips:before{content:"\f600"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-desktop-arrow-down:before{content:"\e155"}.fa-code-pull-request:before{content:"\e13c"}.fa-pumpkin:before{content:"\f707"}.fa-clipboard-list:before{content:"\f46d"}.fa-pen-field:before{content:"\e211"}.fa-blueberries:before{content:"\e2e8"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-note:before{content:"\e1ff"}.fa-arrow-down-to-square:before{content:"\e096"}.fa-user-check:before{content:"\f4fc"}.fa-cloud-xmark:before{content:"\e35f"}.fa-vial-virus:before{content:"\e597"}.fa-book-alt:before,.fa-book-blank:before{content:"\f5d9"}.fa-golf-flag-hole:before{content:"\e3ac"}.fa-comment-alt-arrow-down:before,.fa-message-arrow-down:before{content:"\e1db"}.fa-face-unamused:before{content:"\e39f"}.fa-sheet-plastic:before{content:"\e571"}.fa-circle-9:before{content:"\e0f6"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-pencil-slash:before{content:"\e215"}.fa-bowling-pins:before{content:"\f437"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-down-right:before{content:"\e16b"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-webhook:before{content:"\e5d5"}.fa-blinds-open:before{content:"\f8fc"}.fa-fence:before{content:"\e303"}.fa-arrow-alt-up:before,.fa-up:before{content:"\f357"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-drumstick:before{content:"\f6d6"}.fa-square-v:before{content:"\e284"}.fa-face-awesome:before,.fa-gave-dandy:before{content:"\e409"}.fa-dial-off:before{content:"\e162"}.fa-toggle-off:before{content:"\f204"}.fa-face-smile-horns:before{content:"\e391"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-grapes:before{content:"\e306"}.fa-person-drowning:before{content:"\e545"}.fa-dial-max:before{content:"\e15e"}.fa-circle-m:before{content:"\e115"}.fa-calendar-image:before{content:"\e0d4"}.fa-caret-circle-down:before,.fa-circle-caret-down:before{content:"\f32d"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-shish-kebab:before{content:"\f821"}.fa-spray-can:before{content:"\f5bd"}.fa-alarm-snooze:before{content:"\f845"}.fa-scarecrow:before{content:"\f70d"}.fa-truck-monster:before{content:"\f63b"}.fa-gift-card:before{content:"\f663"}.fa-w:before{content:"\57"}.fa-code-pull-request-draft:before{content:"\e3fa"}.fa-square-b:before{content:"\e264"}.fa-elephant:before{content:"\f6da"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-message-question:before{content:"\e1e3"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-square-3:before{content:"\e258"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-microwave:before{content:"\e01b"}.fa-chf-sign:before{content:"\e602"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-cart-circle-arrow-up:before{content:"\e3f0"}.fa-trash-clock:before{content:"\e2b0"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-sprinkler-ceiling:before{content:"\e44c"}.fa-browsers:before{content:"\e0cb"}.fa-trillium:before{content:"\e588"}.fa-music-slash:before{content:"\f8d1"}.fa-truck-ramp:before{content:"\f4e0"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-circle-c:before{content:"\e101"}.fa-star-christmas:before{content:"\f7d4"}.fa-chart-bullet:before{content:"\e0e1"}.fa-motorcycle:before{content:"\f21c"}.fa-tree-christmas:before{content:"\f7db"}.fa-tire-flat:before{content:"\f632"}.fa-sunglasses:before{content:"\f892"}.fa-badge:before{content:"\f335"}.fa-comment-alt-edit:before,.fa-message-edit:before,.fa-message-pen:before{content:"\f4a4"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-file-mp3:before{content:"\e648"}.fa-arrow-progress:before{content:"\e5df"}.fa-chess-rook-alt:before,.fa-chess-rook-piece:before{content:"\f448"}.fa-square-root:before{content:"\f697"}.fa-album-collection-circle-plus:before{content:"\e48e"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-sign-post:before{content:"\e624"}.fa-face-angry-horns:before{content:"\e368"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-tombstone:before{content:"\f720"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-list-music:before{content:"\f8c9"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-mustache:before{content:"\e5bc"}.fa-hyphen:before{content:"\2d"}.fa-table:before{content:"\f0ce"}.fa-user-chef:before{content:"\e3d2"}.fa-comment-alt-image:before,.fa-message-image:before{content:"\e1e0"}.fa-users-medical:before{content:"\f830"}.fa-sensor-alert:before,.fa-sensor-triangle-exclamation:before{content:"\e029"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-face-mask:before{content:"\e37f"}.fa-pickleball:before{content:"\e435"}.fa-star-sharp-half:before{content:"\e28c"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-meat:before{content:"\f814"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-empty-set:before{content:"\f656"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-bird:before{content:"\e469"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-heart-half-alt:before,.fa-heart-half-stroke:before{content:"\e1ac"}.fa-file-circle-question:before{content:"\e4ef"}.fa-truck-utensils:before{content:"\e628"}.fa-laptop-code:before{content:"\f5fc"}.fa-joystick:before{content:"\f8c5"}.fa-grill-fire:before{content:"\e5a4"}.fa-rectangle-vertical-history:before{content:"\e237"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-keyboard-left:before{content:"\e1c3"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-face-beam-hand-over-mouth:before{content:"\e47c"}.fa-droplet-percent:before,.fa-humidity:before{content:"\f750"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-corn:before{content:"\f6c7"}.fa-roller-coaster:before{content:"\e324"}.fa-photo-film-music:before{content:"\e228"}.fa-radar:before{content:"\e024"}.fa-sickle:before{content:"\f822"}.fa-film:before{content:"\f008"}.fa-coconut:before{content:"\e2f6"}.fa-ruler-horizontal:before{content:"\f547"}.fa-shield-cross:before{content:"\f712"}.fa-cassette-tape:before{content:"\f8ab"}.fa-square-terminal:before{content:"\e32a"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-comment-middle:before{content:"\e149"}.fa-trash-can-list:before{content:"\e2ab"}.fa-block:before{content:"\e46a"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-face-frown-slight:before{content:"\e376"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-sidebar-flip:before{content:"\e24f"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-temperature-list:before{content:"\e299"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-pipe-section:before{content:"\e438"}.fa-space-station-moon-alt:before,.fa-space-station-moon-construction:before{content:"\e034"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-face-sleeping:before{content:"\e38d"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-squirrel:before{content:"\f71a"}.fa-arrow-to-top:before,.fa-arrow-up-to-line:before{content:"\f341"}.fa-flag:before{content:"\f024"}.fa-face-cowboy-hat:before{content:"\e36e"}.fa-hanukiah:before{content:"\f6e6"}.fa-chart-scatter-3d:before{content:"\e0e8"}.fa-display-chart-up:before{content:"\e5e3"}.fa-square-code:before{content:"\e267"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-times-to-slot:before,.fa-vote-nay:before,.fa-xmark-to-slot:before{content:"\f771"}.fa-box-alt:before,.fa-box-taped:before{content:"\f49a"}.fa-comment-slash:before{content:"\f4b3"}.fa-swords:before{content:"\f71d"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-album:before{content:"\f89f"}.fa-circle-n:before{content:"\e118"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-key-skeleton-left-right:before{content:"\e3b4"}.fa-comment-lines:before{content:"\f4b0"}.fa-luchador-mask:before,.fa-luchador:before,.fa-mask-luchador:before{content:"\f455"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-shredder:before{content:"\f68a"}.fa-book-open-alt:before,.fa-book-open-cover:before{content:"\e0c0"}.fa-sandwich:before{content:"\f81f"}.fa-peseta-sign:before{content:"\e221"}.fa-parking-slash:before,.fa-square-parking-slash:before{content:"\f617"}.fa-train-tunnel:before{content:"\e454"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-face-anguished:before{content:"\e369"}.fa-hockey-sticks:before{content:"\f454"}.fa-abacus:before{content:"\f640"}.fa-film-alt:before,.fa-film-simple:before{content:"\f3a0"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-tree-palm:before{content:"\f82b"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-border-center-v:before{content:"\f89d"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-clipboard-medical:before{content:"\e133"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-arrow-alt-to-top:before,.fa-up-to-line:before{content:"\f34d"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-watch-fitness:before{content:"\f63e"}.fa-clock-nine-thirty:before{content:"\e34d"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-jug:before{content:"\f8c6"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-snow-blowing:before{content:"\f761"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-arrow-down-from-line:before,.fa-arrow-from-top:before{content:"\f345"}.fa-gas-pump:before{content:"\f52f"}.fa-signal-alt-slash:before,.fa-signal-bars-slash:before{content:"\f694"}.fa-monkey:before{content:"\f6fb"}.fa-pro:before,.fa-rectangle-pro:before{content:"\e235"}.fa-house-night:before{content:"\e010"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-globe-pointer:before{content:"\e60e"}.fa-blanket:before{content:"\f498"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-comments-question-check:before{content:"\e14f"}.fa-tree:before{content:"\f1bb"}.fa-arrows-cross:before{content:"\e0a2"}.fa-backpack:before{content:"\f5d4"}.fa-square-small:before{content:"\e27e"}.fa-folder-arrow-up:before,.fa-folder-upload:before{content:"\e054"}.fa-bridge-lock:before{content:"\e4cc"}.fa-crosshairs-simple:before{content:"\e59f"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-sliders-h-square:before,.fa-square-sliders:before{content:"\f3f0"}.fa-car-side:before{content:"\f5e4"}.fa-comment-middle-top-alt:before,.fa-message-middle-top:before{content:"\e1e2"}.fa-lightbulb-on:before{content:"\f672"}.fa-knife:before,.fa-utensil-knife:before{content:"\f2e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-display-chart-up-circle-dollar:before{content:"\e5e6"}.fa-wave-sine:before{content:"\f899"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-circle-w:before{content:"\e12c"}.fa-calendar-circle:before,.fa-circle-calendar:before{content:"\e102"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sunset:before{content:"\f767"}.fa-sink:before{content:"\e06d"}.fa-calendar-exclamation:before{content:"\f334"}.fa-truck-container-empty:before{content:"\e2b5"}.fa-hand-heart:before{content:"\f4bc"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-reply-clock:before,.fa-reply-time:before{content:"\e239"}.fa-person-rays:before{content:"\e54d"}.fa-arrow-alt-right:before,.fa-right:before{content:"\f356"}.fa-circle-f:before{content:"\e10e"}.fa-users:before{content:"\f0c0"}.fa-face-pleading:before{content:"\e386"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-police-box:before{content:"\e021"}.fa-cucumber:before{content:"\e401"}.fa-head-side-brain:before{content:"\f808"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-biking-mountain:before,.fa-person-biking-mountain:before{content:"\f84b"}.fa-utensils-slash:before{content:"\e464"}.fa-print-magnifying-glass:before,.fa-print-search:before{content:"\f81a"}.fa-turn-right:before{content:"\e639"}.fa-folder-bookmark:before{content:"\e186"}.fa-arrow-turn-left-down:before{content:"\e633"}.fa-om:before{content:"\f679"}.fa-pi:before{content:"\f67e"}.fa-flask-potion:before,.fa-flask-round-potion:before{content:"\f6e1"}.fa-face-shush:before{content:"\e38c"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-calendar-circle-exclamation:before{content:"\e46e"}.fa-square-i:before{content:"\e272"}.fa-chevron-up:before{content:"\f077"}.fa-face-saluting:before{content:"\e484"}.fa-gauge-simple-low:before,.fa-tachometer-slow:before{content:"\f62c"}.fa-face-persevering:before{content:"\e385"}.fa-camera-circle:before,.fa-circle-camera:before{content:"\e103"}.fa-hand-spock:before{content:"\f259"}.fa-spider-web:before{content:"\f719"}.fa-circle-microphone:before,.fa-microphone-circle:before{content:"\e116"}.fa-book-arrow-up:before{content:"\e0ba"}.fa-popsicle:before{content:"\e43e"}.fa-command:before{content:"\e142"}.fa-blinds:before{content:"\f8fb"}.fa-stopwatch:before{content:"\f2f2"}.fa-saxophone:before{content:"\f8dc"}.fa-square-2:before{content:"\e257"}.fa-field-hockey-stick-ball:before,.fa-field-hockey:before{content:"\f44c"}.fa-arrow-up-square-triangle:before,.fa-sort-shapes-up-alt:before{content:"\f88b"}.fa-face-scream:before{content:"\e38b"}.fa-square-m:before{content:"\e276"}.fa-camera-web:before,.fa-webcam:before{content:"\f832"}.fa-comment-arrow-down:before{content:"\e143"}.fa-lightbulb-cfl:before{content:"\e5a6"}.fa-window-frame-open:before{content:"\e050"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-period:before{content:"\2e"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-up-to-dotted-line:before{content:"\e457"}.fa-thought-bubble:before{content:"\e32e"}.fa-skeleton-ribs:before{content:"\e5cb"}.fa-raygun:before{content:"\e025"}.fa-flute:before{content:"\f8b9"}.fa-acorn:before{content:"\f6ae"}.fa-video-arrow-up-right:before{content:"\e2c9"}.fa-grate-droplet:before{content:"\e194"}.fa-seal-exclamation:before{content:"\e242"}.fa-chess-bishop:before{content:"\f43a"}.fa-message-sms:before{content:"\e1e5"}.fa-coffee-beans:before{content:"\e13f"}.fa-hat-witch:before{content:"\f6e7"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-clock-three-thirty:before{content:"\e357"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-alarm-clock:before{content:"\f34e"}.fa-eclipse:before{content:"\f749"}.fa-face-relieved:before{content:"\e389"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-minus-octagon:before,.fa-octagon-minus:before{content:"\f308"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-face-zany:before{content:"\e3a4"}.fa-tricycle:before{content:"\e5c3"}.fa-land-mine-on:before{content:"\e51b"}.fa-square-arrow-up-left:before{content:"\e263"}.fa-i-cursor:before{content:"\f246"}.fa-chart-mixed-up-circle-dollar:before{content:"\e5d9"}.fa-salt-shaker:before{content:"\e446"}.fa-stamp:before{content:"\f5bf"}.fa-file-plus:before{content:"\f319"}.fa-draw-square:before{content:"\f5ef"}.fa-toilet-paper-reverse-slash:before,.fa-toilet-paper-under-slash:before{content:"\e2a1"}.fa-stairs:before{content:"\e289"}.fa-drone-alt:before,.fa-drone-front:before{content:"\f860"}.fa-glass-empty:before{content:"\e191"}.fa-dial-high:before{content:"\e15c"}.fa-user-construction:before,.fa-user-hard-hat:before,.fa-user-helmet-safety:before{content:"\f82c"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-arrow-down-left-and-arrow-up-right-to-center:before{content:"\e092"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-basketball-hoop:before{content:"\f435"}.fa-objects-align-bottom:before{content:"\e3bb"}.fa-v:before{content:"\56"}.fa-sparkles:before{content:"\f890"}.fa-squid:before{content:"\e450"}.fa-leafy-green:before{content:"\e41d"}.fa-circle-arrow-up-right:before{content:"\e0fc"}.fa-calendars:before{content:"\e0d7"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-hammer-war:before{content:"\f6e4"}.fa-circle-d:before{content:"\e104"}.fa-spider-black-widow:before{content:"\f718"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-pear:before{content:"\e20c"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-file-mov:before{content:"\e647"}.fa-triangle:before{content:"\f2ec"}.fa-apartment:before{content:"\e468"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-pepper:before{content:"\e432"}.fa-piano:before{content:"\f8d4"}.fa-gun-squirt:before{content:"\e19d"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-user-alien:before{content:"\e04a"}.fa-shield-check:before{content:"\f2f7"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-chart-candlestick:before{content:"\e0e2"}.fa-briefcase-blank:before{content:"\e0c8"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-bracket-round:before,.fa-parenthesis:before{content:"\28"}.fa-joint:before{content:"\f595"}.fa-horse-saddle:before{content:"\f8c3"}.fa-mug-marshmallows:before{content:"\f7b7"}.fa-filters:before{content:"\e17e"}.fa-bell-on:before{content:"\f8fa"}.fa-angle-right:before{content:"\f105"}.fa-dial-med:before{content:"\e15f"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-monitor-heart-rate:before,.fa-monitor-waveform:before{content:"\f611"}.fa-link-simple:before{content:"\e1cd"}.fa-whistle:before{content:"\f460"}.fa-g:before{content:"\47"}.fa-fragile:before,.fa-wine-glass-crack:before{content:"\f4bb"}.fa-slot-machine:before{content:"\e3ce"}.fa-notes-medical:before{content:"\f481"}.fa-car-wash:before{content:"\f5e6"}.fa-escalator:before{content:"\e171"}.fa-comment-image:before{content:"\e148"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-donut:before,.fa-doughnut:before{content:"\e406"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-tally-1:before{content:"\e294"}.fa-file-vector:before{content:"\e64c"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-square-dashed:before{content:"\e269"}.fa-bag-shopping-plus:before{content:"\e651"}.fa-square-j:before{content:"\e273"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-arrow-up-big-small:before,.fa-sort-size-up:before{content:"\f88e"}.fa-barcode-read:before{content:"\f464"}.fa-baguette:before{content:"\e3d8"}.fa-bowl-soft-serve:before{content:"\e46b"}.fa-face-holding-back-tears:before{content:"\e482"}.fa-arrow-alt-square-up:before,.fa-square-up:before{content:"\f353"}.fa-subway-tunnel:before,.fa-train-subway-tunnel:before{content:"\e2a3"}.fa-exclamation-square:before,.fa-square-exclamation:before{content:"\f321"}.fa-semicolon:before{content:"\3b"}.fa-bookmark:before{content:"\f02e"}.fa-fan-table:before{content:"\e004"}.fa-align-justify:before{content:"\f039"}.fa-battery-1:before,.fa-battery-low:before{content:"\e0b1"}.fa-credit-card-front:before{content:"\f38a"}.fa-brain-arrow-curved-right:before,.fa-mind-share:before{content:"\f677"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-location-smile:before,.fa-map-marker-smile:before{content:"\f60d"}.fa-arrow-left-to-line:before,.fa-arrow-to-left:before{content:"\f33e"}.fa-bullseye:before{content:"\f140"}.fa-nigiri:before,.fa-sushi:before{content:"\e48a"}.fa-comment-alt-captions:before,.fa-message-captions:before{content:"\e1de"}.fa-trash-list:before{content:"\e2b1"}.fa-bacon:before{content:"\f7e5"}.fa-option:before{content:"\e318"}.fa-raccoon:before{content:"\e613"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-head-side-gear:before{content:"\e611"}.fa-trash-plus:before{content:"\e2b2"}.fa-objects-align-top:before{content:"\e3c0"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-face-anxious-sweat:before{content:"\e36a"}.fa-credit-card-blank:before{content:"\f389"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-microchip-ai:before{content:"\e1ec"}.fa-mug:before{content:"\f874"}.fa-plane-up-slash:before{content:"\e22e"}.fa-radiation:before{content:"\f7b9"}.fa-pen-circle:before{content:"\e20e"}.fa-bag-seedling:before{content:"\e5f2"}.fa-chart-simple:before{content:"\e473"}.fa-crutches:before{content:"\f7f8"}.fa-circle-parking:before,.fa-parking-circle:before{content:"\f615"}.fa-mars-stroke:before{content:"\f229"}.fa-leaf-oak:before{content:"\f6f7"}.fa-square-bolt:before{content:"\e265"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-lambda:before{content:"\f66e"}.fa-e:before{content:"\45"}.fa-pizza:before{content:"\f817"}.fa-bowl-chopsticks-noodles:before{content:"\e2ea"}.fa-h3:before{content:"\f315"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-badge-percent:before{content:"\f646"}.fa-rotate-reverse:before{content:"\e631"}.fa-user:before{content:"\f007"}.fa-sensor:before{content:"\e028"}.fa-comma:before{content:"\2c"}.fa-school-circle-check:before{content:"\e56b"}.fa-toilet-paper-reverse:before,.fa-toilet-paper-under:before{content:"\e2a0"}.fa-light-emergency:before{content:"\e41f"}.fa-arrow-down-to-arc:before{content:"\e4ae"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-light-switch:before{content:"\e017"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-heart-rate:before,.fa-wave-pulse:before{content:"\f5f8"}.fa-key:before{content:"\f084"}.fa-hat-santa:before{content:"\f7a7"}.fa-tamale:before{content:"\e451"}.fa-box-check:before{content:"\f467"}.fa-bullhorn:before{content:"\f0a1"}.fa-steak:before{content:"\f824"}.fa-location-crosshairs-slash:before,.fa-location-slash:before{content:"\f603"}.fa-person-dolly:before{content:"\f4d0"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-file-chart-column:before,.fa-file-chart-line:before{content:"\f659"}.fa-person-half-dress:before{content:"\e548"}.fa-folder-image:before{content:"\e18a"}.fa-calendar-edit:before,.fa-calendar-pen:before{content:"\f333"}.fa-road-bridge:before{content:"\e563"}.fa-face-smile-tear:before{content:"\e393"}.fa-comment-alt-plus:before,.fa-message-plus:before{content:"\f4a8"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-person-dress-fairy:before{content:"\e607"}.fa-rectangle-history-circle-user:before{content:"\e4a4"}.fa-building-lock:before{content:"\e4d6"}.fa-chart-line-up:before{content:"\e0e5"}.fa-mailbox:before{content:"\f813"}.fa-sign-posts:before{content:"\e625"}.fa-truck-bolt:before{content:"\e3d0"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-circle-three-quarters-stroke:before{content:"\e5d4"}.fa-person-circle-minus:before{content:"\e540"}.fa-scalpel:before{content:"\f61d"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-bell-exclamation:before{content:"\f848"}.fa-bookmark-circle:before,.fa-circle-bookmark:before{content:"\e100"}.fa-egg-fried:before{content:"\f7fc"}.fa-face-weary:before{content:"\e3a1"}.fa-uniform-martial-arts:before{content:"\e3d1"}.fa-camera-rotate:before{content:"\e0d8"}.fa-sun-dust:before{content:"\f764"}.fa-comment-text:before{content:"\e14d"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-signal-alt-4:before,.fa-signal-alt:before,.fa-signal-bars-strong:before,.fa-signal-bars:before{content:"\f690"}.fa-diamond-exclamation:before{content:"\e405"}.fa-star:before{content:"\f005"}.fa-dial-min:before{content:"\e161"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-file-caret-down:before,.fa-page-caret-down:before{content:"\e429"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-clock-seven-thirty:before{content:"\e351"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-clock-four-thirty:before{content:"\e34b"}.fa-signal-alt-3:before,.fa-signal-bars-good:before{content:"\f693"}.fa-cactus:before{content:"\f8a7"}.fa-lightbulb-gear:before{content:"\e5fd"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-plane-tail:before{content:"\e22c"}.fa-gauge-simple-max:before,.fa-tachometer-fastest:before{content:"\f62b"}.fa-circle-u:before{content:"\e127"}.fa-shield-slash:before{content:"\e24b"}.fa-phone-square-down:before,.fa-square-phone-hangup:before{content:"\e27a"}.fa-arrow-up-left:before{content:"\e09d"}.fa-transporter-1:before{content:"\e043"}.fa-peanuts:before{content:"\e431"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-bin-bottles-recycle:before{content:"\e5f6"}.fa-arrow-up-from-square:before{content:"\e09c"}.fa-file-dashed-line:before,.fa-page-break:before{content:"\f877"}.fa-bracket-curly-right:before{content:"\7d"}.fa-spider:before{content:"\f717"}.fa-clock-three:before{content:"\e356"}.fa-hands-bound:before{content:"\e4f9"}.fa-scalpel-line-dashed:before,.fa-scalpel-path:before{content:"\f61e"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-pipe-smoking:before{content:"\e3c4"}.fa-face-astonished:before{content:"\e36b"}.fa-window:before{content:"\f40e"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-ear:before{content:"\f5f0"}.fa-file-lock:before{content:"\e3a6"}.fa-diagram-venn:before{content:"\e15a"}.fa-x-ray:before{content:"\f497"}.fa-goal-net:before{content:"\e3ab"}.fa-coffin-cross:before{content:"\e051"}.fa-spell-check:before{content:"\f891"}.fa-location-xmark:before,.fa-map-marker-times:before,.fa-map-marker-xmark:before{content:"\f60e"}.fa-circle-quarter-stroke:before{content:"\e5d3"}.fa-lasso:before{content:"\f8c8"}.fa-slash:before{content:"\f715"}.fa-person-to-portal:before,.fa-portal-enter:before{content:"\e022"}.fa-calendar-star:before{content:"\f736"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-pegasus:before{content:"\f703"}.fa-files-medical:before{content:"\f7fd"}.fa-cannon:before{content:"\e642"}.fa-nfc-lock:before{content:"\e1f8"}.fa-person-ski-lift:before,.fa-ski-lift:before{content:"\f7c8"}.fa-square-6:before{content:"\e25b"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-wind-turbine:before{content:"\f89b"}.fa-sliders-simple:before{content:"\e253"}.fa-grid-round:before{content:"\e5da"}.fa-badge-sheriff:before{content:"\f8a2"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-intersection:before{content:"\f668"}.fa-shop-lock:before{content:"\e4a5"}.fa-family:before{content:"\e300"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-user-hair-buns:before{content:"\e3d3"}.fa-blender-phone:before{content:"\f6b6"}.fa-hourglass-clock:before{content:"\e41b"}.fa-person-seat-reclined:before{content:"\e21f"}.fa-paper-plane-alt:before,.fa-paper-plane-top:before,.fa-send:before{content:"\e20a"}.fa-comment-alt-arrow-up:before,.fa-message-arrow-up:before{content:"\e1dc"}.fa-lightbulb-exclamation:before{content:"\f671"}.fa-layer-group-minus:before,.fa-layer-minus:before{content:"\f5fe"}.fa-chart-pie-simple-circle-currency:before{content:"\e604"}.fa-circle-e:before{content:"\e109"}.fa-building-wheat:before{content:"\e4db"}.fa-gauge-max:before,.fa-tachometer-alt-fastest:before{content:"\f626"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-apostrophe:before{content:"\27"}.fa-file-png:before{content:"\e666"}.fa-fire-hydrant:before{content:"\e17f"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-video-plus:before{content:"\f4e1"}.fa-arrow-alt-square-right:before,.fa-square-right:before{content:"\f352"}.fa-comment-smile:before{content:"\f4b4"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-inbox-arrow-down:before,.fa-inbox-in:before{content:"\f310"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-circle-8:before{content:"\e0f5"}.fa-clouds-moon:before{content:"\f745"}.fa-clock-ten-thirty:before{content:"\e355"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-folder-user:before{content:"\e18e"}.fa-trash-can-xmark:before{content:"\e2ae"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-left-long-to-line:before{content:"\e41e"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-calendar-range:before{content:"\e0d6"}.fa-flower-daffodil:before{content:"\f800"}.fa-hand-back-point-up:before{content:"\e1a2"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-arrow-up-to-arc:before{content:"\e617"}.fa-star-exclamation:before{content:"\f2f3"}.fa-books:before{content:"\f5db"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-layer-group-plus:before,.fa-layer-plus:before{content:"\f5ff"}.fa-play-pause:before{content:"\e22f"}.fa-block-question:before{content:"\e3dd"}.fa-snooze:before,.fa-zzz:before{content:"\f880"}.fa-scanner-image:before{content:"\f8f3"}.fa-tv-retro:before{content:"\f401"}.fa-square-t:before{content:"\e280"}.fa-barn-silo:before,.fa-farm:before{content:"\f864"}.fa-chess-knight:before{content:"\f441"}.fa-bars-sort:before{content:"\e0ae"}.fa-palette-boxes:before,.fa-pallet-alt:before,.fa-pallet-boxes:before{content:"\f483"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-code-simple:before{content:"\e13d"}.fa-bolt-slash:before{content:"\e0b8"}.fa-panel-fire:before{content:"\e42f"}.fa-binary-circle-check:before{content:"\e33c"}.fa-comment-minus:before{content:"\f4b1"}.fa-burrito:before{content:"\f7ed"}.fa-violin:before{content:"\f8ed"}.fa-objects-column:before{content:"\e3c1"}.fa-chevron-square-down:before,.fa-square-chevron-down:before{content:"\f329"}.fa-comment-plus:before{content:"\f4b2"}.fa-triangle-instrument:before,.fa-triangle-music:before{content:"\f8e2"}.fa-wheelchair:before{content:"\f193"}.fa-user-pilot-tie:before{content:"\e2c1"}.fa-piano-keyboard:before{content:"\f8d5"}.fa-bed-empty:before{content:"\f8f9"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-rectangle-portrait:before,.fa-rectangle-vertical:before{content:"\f2fb"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-signal-stream:before{content:"\f8dd"}.fa-down-to-bracket:before{content:"\e4e7"}.fa-circle-z:before{content:"\e130"}.fa-stars:before{content:"\f762"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-house-day:before{content:"\e00e"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-shirt-long-sleeve:before{content:"\e3c7"}.fa-chart-pie-alt:before,.fa-chart-pie-simple:before{content:"\f64e"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-album-collection-circle-user:before{content:"\e48f"}.fa-candy:before{content:"\e3e7"}.fa-bowl-hot:before,.fa-soup:before{content:"\f823"}.fa-flatbread:before{content:"\e40b"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-signal-alt-1:before,.fa-signal-bars-weak:before{content:"\f691"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-clock-twelve:before{content:"\e358"}.fa-pepper-hot:before{content:"\f816"}.fa-citrus-slice:before{content:"\e2f5"}.fa-sheep:before{content:"\f711"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-badger-honey:before{content:"\f6b4"}.fa-h4:before{content:"\f86a"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-signal-slash:before{content:"\f695"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-taco:before{content:"\f826"}.fa-hexagon-plus:before,.fa-plus-hexagon:before{content:"\f300"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-comments-alt:before,.fa-messages:before{content:"\f4b6"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-umbrella-alt:before,.fa-umbrella-simple:before{content:"\e2bc"}.fa-rectangle-history-circle-plus:before{content:"\e4a3"}.fa-underline:before{content:"\f0cd"}.fa-prescription-bottle-pill:before{content:"\e5c0"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-binary-slash:before{content:"\e33e"}.fa-square-o:before{content:"\e278"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-transporter-3:before{content:"\e045"}.fa-engine-exclamation:before,.fa-engine-warning:before{content:"\f5f2"}.fa-circle-down-right:before{content:"\e108"}.fa-square-k:before{content:"\e274"}.fa-manat-sign:before{content:"\e1d5"}.fa-money-check-edit:before,.fa-money-check-pen:before{content:"\f872"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-tilde:before{content:"\7e"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-arrow-down-square-triangle:before,.fa-sort-shapes-down-alt:before{content:"\f889"}.fa-mug-hot:before{content:"\f7b6"}.fa-dog-leashed:before{content:"\f6d4"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-face-downcast-sweat:before{content:"\e371"}.fa-mailbox-flag-up:before{content:"\e5bb"}.fa-memo-circle-info:before{content:"\e49a"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-volume-medium:before,.fa-volume:before{content:"\f6a8"}.fa-transporter-5:before{content:"\e2a6"}.fa-gauge-circle-bolt:before{content:"\e496"}.fa-coin-front:before{content:"\e3fc"}.fa-file-slash:before{content:"\e3a7"}.fa-message-arrow-up-right:before{content:"\e1dd"}.fa-treasure-chest:before{content:"\f723"}.fa-chess-queen:before{content:"\f445"}.fa-paint-brush-alt:before,.fa-paint-brush-fine:before,.fa-paintbrush-alt:before,.fa-paintbrush-fine:before{content:"\f5a9"}.fa-glasses:before{content:"\f530"}.fa-hood-cloak:before{content:"\f6ef"}.fa-square-quote:before{content:"\e329"}.fa-up-left:before{content:"\e2bd"}.fa-bring-front:before{content:"\f857"}.fa-chess-board:before{content:"\f43c"}.fa-burger-cheese:before,.fa-cheeseburger:before{content:"\f7f1"}.fa-building-circle-check:before{content:"\e4d2"}.fa-repeat-1:before{content:"\f365"}.fa-arrow-down-to-line:before,.fa-arrow-to-bottom:before{content:"\f33d"}.fa-grid-5:before{content:"\e199"}.fa-swap-arrows:before{content:"\e60a"}.fa-right-long-to-line:before{content:"\e444"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-grid-round-5:before{content:"\e5de"}.fa-tally-5:before,.fa-tally:before{content:"\f69c"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-up-from-dotted-line:before{content:"\e456"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-traffic-light-go:before{content:"\f638"}.fa-face-exhaling:before{content:"\e480"}.fa-sensor-fire:before{content:"\e02a"}.fa-user-unlock:before{content:"\e058"}.fa-hexagon-divide:before{content:"\e1ad"}.fa-00:before{content:"\e467"}.fa-crow:before{content:"\f520"}.fa-betamax:before,.fa-cassette-betamax:before{content:"\f8a4"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-nfc-magnifying-glass:before{content:"\e1f9"}.fa-file-binary:before{content:"\e175"}.fa-circle-v:before{content:"\e12a"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-bowl-scoops:before{content:"\e3df"}.fa-mistletoe:before{content:"\f7b4"}.fa-custard:before{content:"\e403"}.fa-lacrosse-stick:before{content:"\e3b5"}.fa-hockey-mask:before{content:"\f6ee"}.fa-sunrise:before{content:"\f766"}.fa-subtitles:before{content:"\e60f"}.fa-panel-ews:before{content:"\e42e"}.fa-torii-gate:before{content:"\f6a1"}.fa-cloud-exclamation:before{content:"\e491"}.fa-comment-alt-lines:before,.fa-message-lines:before{content:"\f4a6"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-floppy-disk-pen:before{content:"\e182"}.fa-image:before{content:"\f03e"}.fa-window-frame:before{content:"\e04f"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-file-zip:before{content:"\e5ee"}.fa-square-ring:before{content:"\e44f"}.fa-arrow-alt-from-top:before,.fa-down-from-line:before{content:"\f349"}.fa-caret-up:before{content:"\f0d8"}.fa-shield-times:before,.fa-shield-xmark:before{content:"\e24c"}.fa-screwdriver:before{content:"\f54a"}.fa-circle-sort-down:before,.fa-sort-circle-down:before{content:"\e031"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-forklift:before{content:"\f47a"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-bracket-square-right:before{content:"\5d"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-square-a:before{content:"\e25f"}.fa-tick:before{content:"\e32f"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-refrigerator:before{content:"\e026"}.fa-monument:before{content:"\f5a6"}.fa-octagon-xmark:before,.fa-times-octagon:before,.fa-xmark-octagon:before{content:"\f2f0"}.fa-align-slash:before{content:"\f846"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-truck-couch:before,.fa-truck-ramp-couch:before{content:"\f4dd"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-arrow-up-right-and-arrow-down-left-from-center:before{content:"\e0a0"}.fa-location-arrow-up:before{content:"\e63a"}.fa-tablets:before{content:"\f490"}.fa-360-degrees:before{content:"\e2dc"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-square-dashed-circle-plus:before{content:"\e5c2"}.fa-hand-holding-circle-dollar:before{content:"\e621"}.fa-money-simple-from-bracket:before{content:"\e313"}.fa-bat:before{content:"\f6b5"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-head-side-headphones:before{content:"\f8c2"}.fa-phone-rotary:before{content:"\f8d3"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-calendar-circle-minus:before{content:"\e46f"}.fa-chopsticks:before{content:"\e3f7"}.fa-car-mechanic:before,.fa-car-wrench:before{content:"\f5e3"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-kazoo:before{content:"\f8c7"}.fa-marker:before{content:"\f5a1"}.fa-bin-bottles:before{content:"\e5f5"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-square-arrow-down-left:before{content:"\e261"}.fa-battery-bolt:before{content:"\f376"}.fa-tree-large:before{content:"\f7dd"}.fa-helicopter-symbol:before{content:"\e502"}.fa-aperture:before{content:"\e2df"}.fa-universal-access:before{content:"\f29a"}.fa-gear-complex:before{content:"\e5e9"}.fa-file-magnifying-glass:before,.fa-file-search:before{content:"\f865"}.fa-up-right:before{content:"\e2be"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-user-police:before{content:"\e333"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-teddy-bear:before{content:"\e3cf"}.fa-stocking:before{content:"\f7d5"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-image-slash:before{content:"\e1b7"}.fa-mask-snorkel:before{content:"\e3b7"}.fa-smoke:before{content:"\f760"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-battery-exclamation:before{content:"\e0b0"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-crystal-ball:before{content:"\e362"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-star-shooting:before{content:"\e036"}.fa-binary-lock:before{content:"\e33d"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-comment-edit:before,.fa-comment-pen:before{content:"\f4ae"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-banjo:before{content:"\f8a3"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-light-emergency-on:before{content:"\e420"}.fa-kerning:before{content:"\f86f"}.fa-box-open:before{content:"\f49e"}.fa-square-f:before{content:"\e270"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-arrow-from-right:before,.fa-arrow-left-from-line:before{content:"\f344"}.fa-strawberry:before{content:"\e32b"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-clock-eight-thirty:before{content:"\e346"}.fa-plane-alt:before,.fa-plane-engines:before{content:"\f3de"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-gauge-low:before,.fa-tachometer-alt-slow:before{content:"\f627"}.fa-registered:before{content:"\f25d"}.fa-trash-can-plus:before{content:"\e2ac"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-globe-snow:before{content:"\f7a3"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-integral:before{content:"\f667"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-blinds-raised:before{content:"\f8fd"}.fa-smog:before{content:"\f75f"}.fa-ufo-beam:before{content:"\e048"}.fa-caret-circle-up:before,.fa-circle-caret-up:before{content:"\f331"}.fa-user-vneck-hair-long:before{content:"\e463"}.fa-square-a-lock:before{content:"\e44d"}.fa-crutch:before{content:"\f7f7"}.fa-gas-pump-slash:before{content:"\f5f4"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-transporter-4:before{content:"\e2a5"}.fa-chart-mixed-up-circle-currency:before{content:"\e5d8"}.fa-objects-align-right:before{content:"\e3bf"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-pig:before{content:"\f706"}.fa-inbox-full:before{content:"\e1ba"}.fa-circle-envelope:before,.fa-envelope-circle:before{content:"\e10c"}.fa-construction:before,.fa-triangle-person-digging:before{content:"\f85d"}.fa-ferry:before{content:"\e4ea"}.fa-bullseye-arrow:before{content:"\f648"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-clock-seven:before{content:"\e350"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-flashlight:before{content:"\f8b8"}.fa-file-jpg:before{content:"\e646"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-square-9:before{content:"\e25e"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-dollar-square:before,.fa-square-dollar:before,.fa-usd-square:before{content:"\f2e9"}.fa-phone-arrow-right:before{content:"\e5be"}.fa-hand-holding-seedling:before{content:"\f4bf"}.fa-comment-alt-check:before,.fa-message-check:before{content:"\f4a2"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-chart-line-up-down:before{content:"\e5d7"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-watch:before{content:"\f2e1"}.fa-circle-down-left:before{content:"\e107"}.fa-text:before{content:"\f893"}.fa-projector:before{content:"\f8d6"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-tombstone-alt:before,.fa-tombstone-blank:before{content:"\f721"}.fa-chess-king-alt:before,.fa-chess-king-piece:before{content:"\f440"}.fa-circle-6:before{content:"\e0f3"}.fa-waves-sine:before{content:"\e65d"}.fa-arrow-alt-left:before,.fa-left:before{content:"\f355"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrow-alt-square-down:before,.fa-square-down:before{content:"\f350"}.fa-objects-align-center-vertical:before{content:"\e3bd"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-wreath:before{content:"\f7e2"}.fa-children:before{content:"\e4e1"}.fa-meter-droplet:before{content:"\e1ea"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-signal-4:before,.fa-signal-strong:before{content:"\f68f"}.fa-lollipop:before,.fa-lollypop:before{content:"\e424"}.fa-list-tree:before{content:"\e1d2"}.fa-envelope-open:before{content:"\f2b6"}.fa-draw-circle:before{content:"\f5ed"}.fa-cat-space:before{content:"\e001"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-rabbit-fast:before,.fa-rabbit-running:before{content:"\f709"}.fa-memo-pad:before{content:"\e1da"}.fa-mattress-pillow:before{content:"\e525"}.fa-alarm-plus:before{content:"\f844"}.fa-alicorn:before{content:"\f6b0"}.fa-comment-question:before{content:"\e14b"}.fa-gingerbread-man:before{content:"\f79d"}.fa-guarani-sign:before{content:"\e19a"}.fa-burger-fries:before{content:"\e0cd"}.fa-mug-tea:before{content:"\f875"}.fa-border-top:before{content:"\f855"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-book-circle:before,.fa-circle-book-open:before{content:"\e0ff"}.fa-arrows-to-dotted-line:before{content:"\e0a6"}.fa-fire-extinguisher:before{content:"\f134"}.fa-magnifying-glass-arrows-rotate:before{content:"\e65e"}.fa-garage-open:before{content:"\e00b"}.fa-shelves-empty:before{content:"\e246"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-watch-apple:before{content:"\e2cb"}.fa-watch-calculator:before{content:"\f8f0"}.fa-list-dropdown:before{content:"\e1cf"}.fa-cabinet-filing:before{content:"\f64b"}.fa-burger-soda:before{content:"\f858"}.fa-arrow-square-up:before,.fa-square-arrow-up:before{content:"\f33c"}.fa-greater-than-equal:before{content:"\f532"}.fa-pallet-box:before{content:"\e208"}.fa-face-confounded:before{content:"\e36c"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-truck-plow:before{content:"\f7de"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-grid-round-2:before{content:"\e5db"}.fa-comment-middle-top:before{content:"\e14a"}.fa-wave:before{content:"\e65b"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-restroom-simple:before{content:"\e23a"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-border-outer:before{content:"\f851"}.fa-hashtag-lock:before{content:"\e415"}.fa-clock-two-thirty:before{content:"\e35b"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-ticket-perforated:before{content:"\e63e"}.fa-heart-half:before{content:"\e1ab"}.fa-comment-check:before{content:"\f4ac"}.fa-square:before{content:"\f0c8"}.fa-memo:before{content:"\e1d8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-glass-citrus:before{content:"\f869"}.fa-calendar-lines-pen:before{content:"\e472"}.fa-church:before{content:"\f51d"}.fa-person-snowmobiling:before,.fa-snowmobile:before{content:"\f7d1"}.fa-face-hushed:before{content:"\e37b"}.fa-comments-dollar:before{content:"\f653"}.fa-tickets-simple:before{content:"\e659"}.fa-pickaxe:before{content:"\e5bf"}.fa-link-simple-slash:before{content:"\e1ce"}.fa-democrat:before{content:"\f747"}.fa-face-confused:before{content:"\e36d"}.fa-pinball:before{content:"\e229"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-deer:before{content:"\f78e"}.fa-input-pipe:before{content:"\e1be"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-bookmark-slash:before{content:"\e0c2"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-mace:before{content:"\f6f8"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-sausage:before{content:"\f820"}.fa-trash-can-clock:before{content:"\e2aa"}.fa-p:before{content:"\50"}.fa-broom-wide:before{content:"\e5d1"}.fa-snowflake:before{content:"\f2dc"}.fa-stomach:before{content:"\f623"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-guitar-electric:before{content:"\f8be"}.fa-arrow-turn-down-right:before{content:"\e3d6"}.fa-moon-cloud:before{content:"\f754"}.fa-bread-slice-butter:before{content:"\e3e1"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-user-group-crown:before,.fa-users-crown:before{content:"\f6a5"}.fa-circle-i:before{content:"\e111"}.fa-toilet-paper-check:before{content:"\e5b2"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-chart-waterfall:before{content:"\e0eb"}.fa-sparkle:before{content:"\e5d6"}.fa-face-party:before{content:"\e383"}.fa-kidneys:before{content:"\f5fb"}.fa-wifi-exclamation:before{content:"\e2cf"}.fa-chart-network:before{content:"\f78a"}.fa-person-dress-burst:before{content:"\e544"}.fa-dice-d4:before{content:"\f6d0"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-wheat-awn-slash:before{content:"\e338"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-dagger:before{content:"\f6cb"}.fa-podium:before{content:"\f680"}.fa-memo-circle-check:before{content:"\e1d9"}.fa-route-highway:before{content:"\f61a"}.fa-arrow-alt-to-bottom:before,.fa-down-to-line:before{content:"\f34a"}.fa-filter:before{content:"\f0b0"}.fa-square-g:before{content:"\e271"}.fa-circle-phone:before,.fa-phone-circle:before{content:"\e11b"}.fa-clipboard-prescription:before{content:"\f5e8"}.fa-user-nurse-hair:before{content:"\e45d"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-toggle-large-on:before{content:"\e5b1"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-dryer-alt:before,.fa-dryer-heat:before{content:"\f862"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-arrow-up-small-big:before,.fa-sort-size-up-alt:before{content:"\f88f"}.fa-train-track:before{content:"\e453"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-file-exclamation:before{content:"\f31a"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-moon-stars:before{content:"\f755"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-clothes-hanger:before{content:"\e136"}.fa-mobile-iphone:before,.fa-mobile-notch:before{content:"\e1ee"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-images-user:before{content:"\e1b9"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-image-polaroid-user:before{content:"\e1b6"}.fa-virus-covid:before{content:"\e4a8"}.fa-square-ellipsis:before{content:"\e26e"}.fa-pie:before{content:"\f705"}.fa-chess-knight-alt:before,.fa-chess-knight-piece:before{content:"\f442"}.fa-austral-sign:before{content:"\e0a9"}.fa-cloud-plus:before{content:"\e35e"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-bed-bunk:before{content:"\f8f8"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-clock-eight:before{content:"\e345"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-xls:before{content:"\e64d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-circle-q:before{content:"\e11e"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-face-disguise:before{content:"\e370"}.fa-circle-arrow-down-right:before{content:"\e0fa"}.fa-alien-8bit:before,.fa-alien-monster:before{content:"\f8f6"}.fa-hand-point-ribbon:before{content:"\e1a6"}.fa-poop:before{content:"\f619"}.fa-object-exclude:before{content:"\e49c"}.fa-telescope:before{content:"\e03e"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-square-list:before{content:"\e489"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-comment-code:before{content:"\e147"}.fa-sim-cards:before{content:"\e251"}.fa-starship:before{content:"\e039"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-seal:before{content:"\e241"}.fa-user-cowboy:before{content:"\f8ea"}.fa-hexagon-vertical-nft:before{content:"\e505"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-bread-loaf:before{content:"\f7eb"}.fa-rings-wedding:before{content:"\f81b"}.fa-object-group:before{content:"\f247"}.fa-french-fries:before{content:"\f803"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-calendar-arrow-down:before,.fa-calendar-download:before{content:"\e0d0"}.fa-send-back:before{content:"\f87e"}.fa-mask-ventilator:before{content:"\e524"}.fa-tickets:before{content:"\e658"}.fa-signature-lock:before{content:"\e3ca"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-octagon-plus:before,.fa-plus-octagon:before{content:"\f301"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-melon-slice:before{content:"\e311"}.fa-space-station-moon:before{content:"\e033"}.fa-comment-alt-smile:before,.fa-message-smile:before{content:"\f4aa"}.fa-cup-straw:before{content:"\e363"}.fa-arrow-alt-from-right:before,.fa-left-from-line:before{content:"\f348"}.fa-h:before{content:"\48"}.fa-basket-shopping-simple:before,.fa-shopping-basket-alt:before{content:"\e0af"}.fa-hands-heart:before,.fa-hands-holding-heart:before{content:"\f4c3"}.fa-clock-nine:before{content:"\e34c"}.fa-hammer-brush:before{content:"\e620"}.fa-tarp:before{content:"\e57b"}.fa-face-sleepy:before{content:"\e38e"}.fa-hand-horns:before{content:"\e1a9"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-circle-three-quarters:before{content:"\e125"}.fa-trophy-alt:before,.fa-trophy-star:before{content:"\f2eb"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-face-thermometer:before{content:"\e39a"}.fa-grid-round-4:before{content:"\e5dd"}.fa-sign-posts-wrench:before{content:"\e626"}.fa-shirt-running:before{content:"\e3c8"}.fa-book-circle-arrow-up:before{content:"\e0bd"}.fa-face-nauseated:before{content:"\e381"}.fa-heart:before{content:"\f004"}.fa-file-chart-pie:before{content:"\f65a"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-circle-arrow-down-left:before{content:"\e0f9"}.fa-dumpster-fire:before{content:"\f794"}.fa-hexagon-minus:before,.fa-minus-hexagon:before{content:"\f307"}.fa-arrow-alt-to-left:before,.fa-left-to-line:before{content:"\f34b"}.fa-house-crack:before{content:"\e3b1"}.fa-paw-alt:before,.fa-paw-simple:before{content:"\f701"}.fa-arrow-left-long-to-line:before{content:"\e3d4"}.fa-brackets-round:before,.fa-parentheses:before{content:"\e0c5"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-user-shakespeare:before{content:"\e2c2"}.fa-arrow-right-to-arc:before{content:"\e4b2"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-gauge-circle-plus:before{content:"\e498"}.fa-folders:before{content:"\f660"}.fa-angel:before{content:"\f779"}.fa-value-absolute:before{content:"\f6a6"}.fa-rabbit:before{content:"\f708"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-circle-euro:before{content:"\e5ce"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-diamond-half:before{content:"\e5b7"}.fa-lock-alt:before,.fa-lock-keyhole:before{content:"\f30d"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-info-square:before,.fa-square-info:before{content:"\f30f"}.fa-wifi-slash:before{content:"\f6ac"}.fa-toilet-paper-xmark:before{content:"\e5b3"}.fa-hands-holding-dollar:before,.fa-hands-usd:before{content:"\f4c5"}.fa-cube:before{content:"\f1b2"}.fa-arrow-down-triangle-square:before,.fa-sort-shapes-down:before{content:"\f888"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shutters:before{content:"\e449"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-table-tree:before{content:"\e293"}.fa-house-chimney-heart:before{content:"\e1b2"}.fa-tally-3:before{content:"\e296"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-cart-circle-exclamation:before{content:"\e3f2"}.fa-sword:before{content:"\f71c"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-interrobang:before{content:"\e5ba"}.fa-plane-slash:before{content:"\e069"}.fa-circle-dashed:before{content:"\e105"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-fork-knife:before,.fa-utensils-alt:before{content:"\f2e6"}.fa-satellite-dish:before{content:"\f7c0"}.fa-badge-check:before{content:"\f336"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-slider:before{content:"\e252"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-clock-one-thirty:before{content:"\e34f"}.fa-inbox-arrow-up:before,.fa-inbox-out:before{content:"\f311"}.fa-cloud-slash:before{content:"\e137"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-octagon-check:before{content:"\e426"}.fa-flatbread-stuffed:before{content:"\e40c"}.fa-clipboard-check:before{content:"\f46c"}.fa-cart-circle-plus:before{content:"\e3f3"}.fa-shipping-timed:before,.fa-truck-clock:before{content:"\f48c"}.fa-pool-8-ball:before{content:"\e3c5"}.fa-file-audio:before{content:"\f1c7"}.fa-turn-down-left:before{content:"\e331"}.fa-lock-hashtag:before{content:"\e423"}.fa-chart-radar:before{content:"\e0e7"}.fa-staff:before{content:"\f71b"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-utility-pole:before{content:"\e2c3"}.fa-transporter-6:before{content:"\e2a7"}.fa-arrow-turn-left:before{content:"\e632"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-vector-polygon:before{content:"\e2c7"}.fa-diagram-nested:before{content:"\e157"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-tickets-perforated:before{content:"\e63f"}.fa-image-user:before{content:"\e1b8"}.fa-buoy:before{content:"\e5b5"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-border-center-h:before{content:"\f89c"}.fa-can-food:before{content:"\e3e6"}.fa-typewriter:before{content:"\f8e7"}.fa-arrow-right-from-arc:before{content:"\e4b1"}.fa-circle-k:before{content:"\e113"}.fa-face-hand-over-mouth:before{content:"\e378"}.fa-popcorn:before{content:"\f819"}.fa-house-flood:before,.fa-house-water:before{content:"\f74f"}.fa-object-subtract:before{content:"\e49e"}.fa-code-branch:before{content:"\f126"}.fa-warehouse-alt:before,.fa-warehouse-full:before{content:"\f495"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-arrow-down-from-dotted-line:before{content:"\e090"}.fa-file-doc:before{content:"\e5ed"}.fa-square-quarters:before{content:"\e44e"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-trash-xmark:before{content:"\e2b4"}.fa-caret-circle-left:before,.fa-circle-caret-left:before{content:"\f32e"}.fa-files:before{content:"\e178"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-face-clouds:before{content:"\e47d"}.fa-user-crown:before{content:"\f6a4"}.fa-basket-shopping-plus:before{content:"\e653"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-cart-circle-check:before{content:"\e3f1"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-user-tie-hair-long:before{content:"\e460"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-head-side-medical:before{content:"\f809"}.fa-arrow-turn-right:before{content:"\e635"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-user-robot:before{content:"\e04b"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-card-diamond:before{content:"\e3ea"}.fa-face-zipper:before{content:"\e3a5"}.fa-face-raised-eyebrow:before{content:"\e388"}.fa-house-signal:before{content:"\e012"}.fa-chevron-square-up:before,.fa-square-chevron-up:before{content:"\f32c"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-arrows-to-line:before{content:"\e0a7"}.fa-dolphin:before{content:"\e168"}.fa-arrow-up-right:before{content:"\e09f"}.fa-circle-r:before{content:"\e120"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-circle-sort-up:before,.fa-sort-circle-up:before{content:"\e032"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-chestnut:before{content:"\e3f6"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-heat:before{content:"\e00c"}.fa-ticket-airline:before,.fa-ticket-perforated-plane:before,.fa-ticket-plane:before{content:"\e29a"}.fa-boot-heeled:before{content:"\e33f"}.fa-arrows-minimize:before,.fa-compress-arrows:before{content:"\e0a5"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-hexagon:before{content:"\f312"}.fa-manhole:before{content:"\e1d6"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-floppy-disks:before{content:"\e183"}.fa-toilet-paper-blank-under:before,.fa-toilet-paper-reverse-alt:before{content:"\e29f"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-pump:before{content:"\e442"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-arrow-up-left-from-circle:before{content:"\e09e"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-dryer:before{content:"\f861"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-chess-bishop-alt:before,.fa-chess-bishop-piece:before{content:"\f43b"}.fa-shirt-tank-top:before{content:"\e3c9"}.fa-diploma:before,.fa-scroll-ribbon:before{content:"\f5ea"}.fa-screencast:before{content:"\e23e"}.fa-walker:before{content:"\f831"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-face-kiss-closed-eyes:before{content:"\e37d"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-file-user:before{content:"\f65c"}.fa-user-police-tie:before{content:"\e334"}.fa-face-tongue-money:before{content:"\e39d"}.fa-tennis-ball:before{content:"\f45e"}.fa-square-l:before{content:"\e275"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-calendar-arrow-up:before,.fa-calendar-upload:before{content:"\e0d1"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-scarf:before{content:"\f7c1"}.fa-album-circle-plus:before{content:"\e48c"}.fa-user-nurse-hair-long:before{content:"\e45e"}.fa-diamond:before{content:"\f219"}.fa-arrow-alt-square-left:before,.fa-square-left:before{content:"\f351"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-circle-ellipsis-vertical:before{content:"\e10b"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-grid-dividers:before{content:"\e3ad"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-face-pensive:before{content:"\e384"}.fa-user-music:before{content:"\f8eb"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-comments-alt-dollar:before,.fa-messages-dollar:before{content:"\f652"}.fa-sensor-on:before{content:"\e02b"}.fa-balloon:before{content:"\e2e3"}.fa-biohazard:before{content:"\f780"}.fa-chess-queen-alt:before,.fa-chess-queen-piece:before{content:"\f446"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-house-leave:before,.fa-house-person-depart:before,.fa-house-person-leave:before{content:"\e00f"}.fa-ruler-triangle:before{content:"\f61c"}.fa-card-club:before{content:"\e3e9"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-spinner-third:before{content:"\f3f4"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-computer-mouse-scrollwheel:before,.fa-mouse-alt:before{content:"\f8cd"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-table-layout:before{content:"\e290"}.fa-narwhal:before{content:"\f6fe"}.fa-ramp-loading:before{content:"\f4d4"}.fa-calendar-circle-plus:before{content:"\e470"}.fa-toothbrush:before{content:"\f635"}.fa-border-inner:before{content:"\f84e"}.fa-paw-claws:before{content:"\f702"}.fa-kiwi-fruit:before{content:"\e30c"}.fa-traffic-light-slow:before{content:"\f639"}.fa-rectangle-code:before{content:"\e322"}.fa-head-side-virus:before{content:"\e064"}.fa-keyboard-brightness:before{content:"\e1c0"}.fa-books-medical:before{content:"\f7e8"}.fa-lightbulb-slash:before{content:"\f673"}.fa-home-blank:before,.fa-house-blank:before{content:"\e487"}.fa-square-5:before{content:"\e25a"}.fa-heart-square:before,.fa-square-heart:before{content:"\f4c8"}.fa-puzzle:before{content:"\e443"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-pipe-circle-check:before{content:"\e436"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-octagon-exclamation:before{content:"\e204"}.fa-dial-low:before{content:"\e15d"}.fa-door-closed:before{content:"\f52a"}.fa-laptop-mobile:before,.fa-phone-laptop:before{content:"\f87a"}.fa-conveyor-belt-alt:before,.fa-conveyor-belt-boxes:before{content:"\f46f"}.fa-shield-virus:before{content:"\e06c"}.fa-starfighter-alt-advanced:before,.fa-starfighter-twin-ion-engine-advanced:before{content:"\e28e"}.fa-dice-six:before{content:"\f526"}.fa-starfighter-alt:before,.fa-starfighter-twin-ion-engine:before{content:"\e038"}.fa-rocket-launch:before{content:"\e027"}.fa-mosquito-net:before{content:"\e52c"}.fa-vent-damper:before{content:"\e465"}.fa-bridge-water:before{content:"\e4ce"}.fa-ban-bug:before,.fa-debug:before{content:"\f7f9"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-garage-car:before{content:"\e00a"}.fa-square-kanban:before{content:"\e488"}.fa-hat-wizard:before{content:"\f6e8"}.fa-chart-kanban:before{content:"\e64f"}.fa-pen-fancy:before{content:"\f5ac"}.fa-coffee-pot:before{content:"\e002"}.fa-mouse-field:before{content:"\e5a8"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-shower-alt:before,.fa-shower-down:before{content:"\e24d"}.fa-box-circle-check:before{content:"\e0c4"}.fa-brightness:before{content:"\e0c9"}.fa-car-side-bolt:before{content:"\e344"}.fa-file-xml:before{content:"\e654"}.fa-ornament:before{content:"\f7b8"}.fa-phone-arrow-down-left:before,.fa-phone-arrow-down:before,.fa-phone-incoming:before{content:"\e223"}.fa-cloud-word:before{content:"\e138"}.fa-hand-fingers-crossed:before{content:"\e1a3"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-arrow-down-small-big:before,.fa-sort-size-down-alt:before{content:"\f88d"}.fa-book-medical:before{content:"\f7e6"}.fa-face-melting:before{content:"\e483"}.fa-poo:before{content:"\f2fe"}.fa-pen-alt-slash:before,.fa-pen-clip-slash:before{content:"\e20f"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-scroll-old:before{content:"\f70f"}.fa-guitars:before{content:"\f8bf"}.fa-phone-xmark:before{content:"\e227"}.fa-hose:before{content:"\e419"}.fa-clock-six:before{content:"\e352"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-billboard:before{content:"\e5cd"}.fa-square-r:before{content:"\e27c"}.fa-cubes:before{content:"\f1b3"}.fa-envelope-open-dollar:before{content:"\f657"}.fa-divide:before{content:"\f529"}.fa-sun-cloud:before{content:"\f763"}.fa-lamp-floor:before{content:"\e015"}.fa-square-7:before{content:"\e25c"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-campfire:before{content:"\f6ba"}.fa-circle-ampersand:before{content:"\e0f8"}.fa-snowflakes:before{content:"\f7cf"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-leaf-maple:before{content:"\f6f6"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-cup-straw-swoosh:before{content:"\e364"}.fa-temperature-hot:before,.fa-temperature-sun:before{content:"\f76a"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-high-definition:before,.fa-rectangle-hd:before{content:"\e1ae"}.fa-j:before{content:"\4a"}.fa-galaxy:before{content:"\e008"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-cherries:before{content:"\e0ec"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-circle-sort:before,.fa-sort-circle:before{content:"\e030"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-bag-shopping-minus:before{content:"\e650"}.fa-file-pdf:before{content:"\f1c1"}.fa-siren:before{content:"\e02d"}.fa-arrow-up-to-dotted-line:before{content:"\e0a1"}.fa-image-landscape:before,.fa-landscape:before{content:"\e1b5"}.fa-tank-water:before{content:"\e452"}.fa-curling-stone:before,.fa-curling:before{content:"\f44a"}.fa-gamepad-alt:before,.fa-gamepad-modern:before{content:"\e5a2"}.fa-messages-question:before{content:"\e1e7"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-briefcase-arrow-right:before{content:"\e2f2"}.fa-expand-wide:before{content:"\f320"}.fa-clock-eleven-thirty:before{content:"\e348"}.fa-rv:before{content:"\f7be"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-dreidel:before{content:"\f792"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-flower-tulip:before{content:"\f801"}.fa-people-pants-simple:before{content:"\e21a"}.fa-cloud-drizzle:before{content:"\f738"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-solar-system:before{content:"\e02f"}.fa-seal-question:before{content:"\e243"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-disc-drive:before{content:"\f8b5"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-rows:before,.fa-table-rows:before{content:"\e292"}.fa-location-exclamation:before,.fa-map-marker-exclamation:before{content:"\f608"}.fa-face-fearful:before{content:"\e375"}.fa-clipboard-user:before{content:"\f7f3"}.fa-bus-school:before{content:"\f5dd"}.fa-film-slash:before{content:"\e179"}.fa-square-arrow-down-right:before{content:"\e262"}.fa-book-sparkles:before,.fa-book-spells:before{content:"\f6b8"}.fa-washer:before,.fa-washing-machine:before{content:"\f898"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-user-visor:before{content:"\e04c"}.fa-file-plus-minus:before{content:"\e177"}.fa-chess-clock-alt:before,.fa-chess-clock-flip:before{content:"\f43e"}.fa-satellite:before{content:"\f7bf"}.fa-truck-fire:before{content:"\e65a"}.fa-plane-lock:before{content:"\e558"}.fa-steering-wheel:before{content:"\f622"}.fa-tag:before{content:"\f02b"}.fa-stretcher:before{content:"\f825"}.fa-book-law:before,.fa-book-section:before{content:"\e0c1"}.fa-inboxes:before{content:"\e1bb"}.fa-coffee-bean:before{content:"\e13e"}.fa-circle-yen:before{content:"\e5d0"}.fa-brackets-curly:before{content:"\f7ea"}.fa-ellipsis-stroke-vertical:before,.fa-ellipsis-v-alt:before{content:"\f39c"}.fa-comment:before{content:"\f075"}.fa-square-1:before{content:"\e256"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-head-side:before{content:"\f6e9"}.fa-truck-ladder:before{content:"\e657"}.fa-envelope:before{content:"\f0e0"}.fa-dolly-empty:before{content:"\f473"}.fa-face-tissue:before{content:"\e39c"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-bin-recycle:before{content:"\e5f7"}.fa-paperclip:before{content:"\f0c6"}.fa-chart-line-down:before{content:"\f64d"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-lock-a:before{content:"\e422"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-person-pinball:before{content:"\e21d"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-apple-core:before{content:"\e08f"}.fa-circle-y:before{content:"\e12f"}.fa-h6:before{content:"\e413"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-circle-small:before{content:"\e122"}.fa-border-none:before{content:"\f850"}.fa-arrow-turn-down-left:before{content:"\e2e1"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-reflect-horizontal:before{content:"\e664"}.fa-comment-alt-medical:before,.fa-message-medical:before{content:"\f7f4"}.fa-rugby-ball:before{content:"\e3c6"}.fa-comment-music:before{content:"\f8b0"}.fa-indent:before{content:"\f03c"}.fa-tree-alt:before,.fa-tree-deciduous:before{content:"\f400"}.fa-puzzle-piece-alt:before,.fa-puzzle-piece-simple:before{content:"\e231"}.fa-truck-field-un:before{content:"\e58e"}.fa-nfc-trash:before{content:"\e1fd"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-file-times:before,.fa-file-xmark:before{content:"\f317"}.fa-home-heart:before,.fa-house-heart:before{content:"\f4c9"}.fa-house-chimney-blank:before{content:"\e3b0"}.fa-meter-bolt:before{content:"\e1e9"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-slash-back:before{content:"\5c"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-fishing-rod:before{content:"\e3a8"}.fa-hammer-crash:before{content:"\e414"}.fa-message-heart:before{content:"\e5c9"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-polaroid:before{content:"\f8aa"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-cart-arrow-up:before{content:"\e3ee"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-buoy-mooring:before{content:"\e5b6"}.fa-square-4:before{content:"\e259"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-file-eps:before{content:"\e644"}.fa-tricycle-adult:before{content:"\e5c4"}.fa-waveform:before{content:"\f8f1"}.fa-water:before{content:"\f773"}.fa-star-sharp-half-alt:before,.fa-star-sharp-half-stroke:before{content:"\e28d"}.fa-nfc-signal:before{content:"\e1fb"}.fa-plane-prop:before{content:"\e22b"}.fa-calendar-check:before{content:"\f274"}.fa-clock-desk:before{content:"\e134"}.fa-calendar-clock:before,.fa-calendar-time:before{content:"\e0d2"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-plate-utensils:before{content:"\e43b"}.fa-family-pants:before{content:"\e302"}.fa-hose-reel:before{content:"\e41a"}.fa-house-window:before{content:"\e3b3"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-music-magnifying-glass:before{content:"\e662"}.fa-crosshairs:before{content:"\f05b"}.fa-cloud-rainbow:before{content:"\f73e"}.fa-person-cane:before{content:"\e53c"}.fa-alien:before{content:"\f8f5"}.fa-tent:before{content:"\e57d"}.fa-laptop-binary:before{content:"\e5e7"}.fa-vest-patches:before{content:"\e086"}.fa-people-dress-simple:before{content:"\e218"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-bowling-ball-pin:before{content:"\e0c3"}.fa-bell-school-slash:before{content:"\f5d6"}.fa-plus-large:before{content:"\e59e"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-camera-viewfinder:before,.fa-screenshot:before{content:"\e0da"}.fa-comment-alt-music:before,.fa-message-music:before{content:"\f8af"}.fa-car-building:before{content:"\f859"}.fa-border-bottom-right:before,.fa-border-style-alt:before{content:"\f854"}.fa-octagon:before{content:"\f306"}.fa-comment-arrow-up-right:before{content:"\e145"}.fa-octagon-divide:before{content:"\e203"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-tv-music:before{content:"\f8e6"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-reel:before{content:"\e238"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-alarm-exclamation:before{content:"\f843"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-traffic-cone:before{content:"\f636"}.fa-grate:before{content:"\e193"}.fa-arrow-down-right:before{content:"\e093"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-person-from-portal:before,.fa-portal-exit:before{content:"\e023"}.fa-plane-arrival:before{content:"\f5af"}.fa-cowbell-circle-plus:before,.fa-cowbell-more:before{content:"\f8b4"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-distribute-spacing-vertical:before{content:"\e366"}.fa-signal-alt-2:before,.fa-signal-bars-fair:before{content:"\f692"}.fa-sportsball:before{content:"\e44b"}.fa-game-console-handheld-crank:before{content:"\e5b9"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-face-smile-upside-down:before{content:"\e395"}.fa-ball-pile:before{content:"\f77e"}.fa-badge-dollar:before{content:"\f645"}.fa-money-bills-alt:before,.fa-money-bills-simple:before{content:"\e1f4"}.fa-list-timeline:before{content:"\e1d1"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-keyboard-down:before{content:"\e1c2"}.fa-circle-up-right:before{content:"\e129"}.fa-cloud-bolt-moon:before,.fa-thunderstorm-moon:before{content:"\f76d"}.fa-turn-left-up:before{content:"\e638"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-bracket-round-right:before{content:"\29"}.fa-circle-sterling:before{content:"\e5cf"}.fa-circle-5:before{content:"\e0f2"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-fire-flame:before,.fa-flame:before{content:"\f6df"}.fa-arrow-alt-to-right:before,.fa-right-to-line:before{content:"\f34c"}.fa-gif:before{content:"\e190"}.fa-chess:before{content:"\f439"}.fa-trash-slash:before{content:"\e2b3"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-font-case:before{content:"\f866"}.fa-street-view:before{content:"\f21d"}.fa-arrow-down-left:before{content:"\e091"}.fa-franc-sign:before{content:"\e18f"}.fa-flask-poison:before,.fa-flask-round-poison:before{content:"\f6e0"}.fa-volume-off:before{content:"\f026"}.fa-book-circle-arrow-right:before{content:"\e0bc"}.fa-chart-user:before,.fa-user-chart:before{content:"\f6a3"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-presentation-screen:before,.fa-presentation:before{content:"\f685"}.fa-circle-bolt:before{content:"\e0fe"}.fa-face-smile-halo:before{content:"\e38f"}.fa-cart-circle-arrow-down:before{content:"\e3ef"}.fa-house-person-arrive:before,.fa-house-person-return:before,.fa-house-return:before{content:"\e011"}.fa-comment-alt-times:before,.fa-message-times:before,.fa-message-xmark:before{content:"\f4ab"}.fa-file-award:before,.fa-file-certificate:before{content:"\f5f3"}.fa-user-doctor-hair-long:before{content:"\e459"}.fa-camera-home:before,.fa-camera-security:before{content:"\f8fe"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-book-heart:before{content:"\f499"}.fa-mosque:before{content:"\f678"}.fa-duck:before{content:"\f6d8"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-flag-alt:before,.fa-flag-swallowtail:before{content:"\f74c"}.fa-person-military-rifle:before{content:"\e54b"}.fa-car-garage:before{content:"\f5e2"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-book-font:before{content:"\e0bf"}.fa-shield-plus:before{content:"\e24a"}.fa-vials:before{content:"\f493"}.fa-eye-dropper-full:before{content:"\e172"}.fa-distribute-spacing-horizontal:before{content:"\e365"}.fa-tablet-rugged:before{content:"\f48f"}.fa-temperature-frigid:before,.fa-temperature-snow:before{content:"\f768"}.fa-moped:before{content:"\e3b9"}.fa-face-smile-plus:before,.fa-smile-plus:before{content:"\f5b9"}.fa-radio-alt:before,.fa-radio-tuner:before{content:"\f8d8"}.fa-face-swear:before{content:"\e399"}.fa-water-arrow-down:before,.fa-water-lower:before{content:"\f774"}.fa-scanner-touchscreen:before{content:"\f48a"}.fa-circle-7:before{content:"\e0f4"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-person-ski-jumping:before,.fa-ski-jump:before{content:"\f7c7"}.fa-place-of-worship:before{content:"\f67f"}.fa-water-arrow-up:before,.fa-water-rise:before{content:"\f775"}.fa-waveform-lines:before,.fa-waveform-path:before{content:"\f8f2"}.fa-split:before{content:"\e254"}.fa-film-canister:before,.fa-film-cannister:before{content:"\f8b7"}.fa-folder-times:before,.fa-folder-xmark:before{content:"\f65f"}.fa-toilet-paper-alt:before,.fa-toilet-paper-blank:before{content:"\f71f"}.fa-tablet-android-alt:before,.fa-tablet-screen:before{content:"\f3fc"}.fa-hexagon-vertical-nft-slanted:before{content:"\e506"}.fa-folder-music:before{content:"\e18d"}.fa-desktop-medical:before,.fa-display-medical:before{content:"\e166"}.fa-share-all:before{content:"\f367"}.fa-peapod:before{content:"\e31c"}.fa-chess-clock:before{content:"\f43d"}.fa-axe:before{content:"\f6b2"}.fa-square-d:before{content:"\e268"}.fa-grip-vertical:before{content:"\f58e"}.fa-mobile-signal-out:before{content:"\e1f0"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-arrow-up-from-dotted-line:before{content:"\e09b"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-light-switch-on:before{content:"\e019"}.fa-arrow-down-arrow-up:before,.fa-sort-alt:before{content:"\f883"}.fa-raindrops:before{content:"\f75c"}.fa-dash:before,.fa-minus-large:before{content:"\e404"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-input-numeric:before{content:"\e1bd"}.fa-truck-tow:before{content:"\e2b8"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-car-bolt:before{content:"\e341"}.fa-arrows-maximize:before,.fa-expand-arrows:before{content:"\f31d"}.fa-faucet:before{content:"\e005"}.fa-cloud-sleet:before{content:"\f741"}.fa-lamp-street:before{content:"\e1c5"}.fa-list-radio:before{content:"\e1d0"}.fa-pen-nib-slash:before{content:"\e4a1"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-square-up-left:before{content:"\e282"}.fa-overline:before{content:"\f876"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-arrows-from-dotted-line:before{content:"\e0a3"}.fa-usb-drive:before{content:"\f8e9"}.fa-ballot:before{content:"\f732"}.fa-caret-down:before{content:"\f0d7"}.fa-location-dot-slash:before,.fa-map-marker-alt-slash:before{content:"\f605"}.fa-cards:before{content:"\e3ed"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-boxing-glove:before,.fa-glove-boxing:before{content:"\f438"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-bell-school:before{content:"\f5d5"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-folder-heart:before{content:"\e189"}.fa-circle-location-arrow:before,.fa-location-circle:before{content:"\f602"}.fa-face-head-bandage:before{content:"\e37a"}.fa-maki-roll:before,.fa-makizushi:before,.fa-sushi-roll:before{content:"\e48b"}.fa-car-bump:before{content:"\f5e0"}.fa-piggy-bank:before{content:"\f4d3"}.fa-racquet:before{content:"\f45a"}.fa-car-mirrors:before{content:"\e343"}.fa-industry-alt:before,.fa-industry-windows:before{content:"\f3b3"}.fa-bolt-auto:before{content:"\e0b6"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-flux-capacitor:before{content:"\f8ba"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-honey-pot:before{content:"\e418"}.fa-olive:before{content:"\e316"}.fa-khanda:before{content:"\f66d"}.fa-filter-list:before{content:"\e17c"}.fa-outlet:before{content:"\e01c"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-cauldron:before{content:"\f6bf"}.fa-people:before{content:"\e216"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-croissant:before{content:"\f7f6"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-swords-laser:before{content:"\e03d"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-square-u:before{content:"\e281"}.fa-wand-sparkles:before{content:"\f72b"}.fa-router:before{content:"\f8da"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-sword-laser-alt:before{content:"\e03c"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-coin:before{content:"\f85c"}.fa-laptop-slash:before{content:"\e1c7"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-circle-b:before{content:"\e0fd"}.fa-person-dress-simple:before{content:"\e21c"}.fa-pipe-collar:before{content:"\e437"}.fa-lights-holiday:before{content:"\f7b2"}.fa-citrus:before{content:"\e2f4"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-chart-tree-map:before{content:"\e0ea"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-clock-five-thirty:before{content:"\e34a"}.fa-pipe-valve:before{content:"\e439"}.fa-arrow-up-from-arc:before{content:"\e4b4"}.fa-face-spiral-eyes:before{content:"\e485"}.fa-compress-wide:before{content:"\f326"}.fa-circle-phone-hangup:before,.fa-phone-circle-down:before{content:"\e11d"}.fa-gear-complex-code:before{content:"\e5eb"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-badminton:before{content:"\e33a"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-arrow-alt-from-left:before,.fa-right-from-line:before{content:"\f347"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-shuttlecock:before{content:"\f45b"}.fa-user-hair:before{content:"\e45a"}.fa-eye-evil:before{content:"\f6db"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-swap:before{content:"\e609"}.fa-garage:before{content:"\e009"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-shovel-snow:before{content:"\f7c3"}.fa-cloud-rain:before{content:"\f73d"}.fa-face-lying:before{content:"\e37e"}.fa-sprinkler:before{content:"\e035"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-person-sledding:before,.fa-sledding:before{content:"\f7cb"}.fa-game-console-handheld:before{content:"\f8bb"}.fa-ship:before{content:"\f21a"}.fa-clock-six-thirty:before{content:"\e353"}.fa-battery-slash:before{content:"\f377"}.fa-tugrik-sign:before{content:"\e2ba"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-angles-up-down:before{content:"\e60d"}.fa-inventory:before,.fa-shelves:before{content:"\f480"}.fa-cloud-snow:before{content:"\f742"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-oven:before{content:"\e01d"}.fa-cloud-binary:before{content:"\e601"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-comment-captions:before{content:"\e146"}.fa-comments-question:before{content:"\e14e"}.fa-scribble:before{content:"\e23f"}.fa-rotate-exclamation:before{content:"\e23c"}.fa-file-circle-check:before{content:"\e5a0"}.fa-glass:before{content:"\f804"}.fa-loader:before{content:"\e1d4"}.fa-forward:before{content:"\f04e"}.fa-user-pilot:before{content:"\e2c0"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-code-pull-request-closed:before{content:"\e3f9"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-face-dotted:before{content:"\e47f"}.fa-face-worried:before{content:"\e3a3"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-court-sport:before{content:"\e643"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-vector-circle:before{content:"\e2c6"}.fa-car-circle-bolt:before{content:"\e342"}.fa-calendar-week:before{content:"\f784"}.fa-flying-disc:before{content:"\e3a9"}.fa-laptop-medical:before{content:"\f812"}.fa-square-down-right:before{content:"\e26c"}.fa-b:before{content:"\42"}.fa-seat-airline:before{content:"\e244"}.fa-eclipse-alt:before,.fa-moon-over-sun:before{content:"\f74a"}.fa-pipe:before{content:"\7c"}.fa-file-medical:before{content:"\f477"}.fa-potato:before{content:"\e440"}.fa-dice-one:before{content:"\f525"}.fa-circle-a:before{content:"\e0f7"}.fa-helmet-battle:before{content:"\f6eb"}.fa-butter:before{content:"\e3e4"}.fa-blanket-fire:before{content:"\e3da"}.fa-kiwi-bird:before{content:"\f535"}.fa-castle:before{content:"\e0de"}.fa-golf-club:before{content:"\f451"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-chart-pie-simple-circle-dollar:before{content:"\e605"}.fa-balloons:before{content:"\e2e4"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-timeline-arrow:before{content:"\e29d"}.fa-skull:before{content:"\f54c"}.fa-game-board-alt:before,.fa-game-board-simple:before{content:"\f868"}.fa-circle-video:before,.fa-video-circle:before{content:"\e12b"}.fa-chart-scatter-bubble:before{content:"\e0e9"}.fa-house-turret:before{content:"\e1b4"}.fa-banana:before{content:"\e2e5"}.fa-hand-holding-skull:before{content:"\e1a4"}.fa-people-dress:before{content:"\e217"}.fa-couch-small:before,.fa-loveseat:before{content:"\f4cc"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-block-quote:before{content:"\e0b5"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-money-check-dollar-pen:before,.fa-money-check-edit-alt:before{content:"\f873"}.fa-arrow-alt-from-bottom:before,.fa-up-from-line:before{content:"\f346"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-grid-round-2-plus:before{content:"\e5dc"}.fa-people-pants:before{content:"\e219"}.fa-mound:before{content:"\e52d"}.fa-windsock:before{content:"\f777"}.fa-circle-half:before{content:"\e110"}.fa-brake-warning:before{content:"\e0c7"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-sax-hot:before,.fa-saxophone-fire:before{content:"\f8db"}.fa-camera-web-slash:before,.fa-webcam-slash:before{content:"\f833"}.fa-folder-medical:before{content:"\e18c"}.fa-folder-cog:before,.fa-folder-gear:before{content:"\e187"}.fa-hand-wave:before{content:"\e1a7"}.fa-arrow-up-arrow-down:before,.fa-sort-up-down:before{content:"\e099"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-comment-alt-slash:before,.fa-message-slash:before{content:"\f4a9"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-trash-can-check:before{content:"\e2a9"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-person-simple:before{content:"\e220"}.fa-arrow-turn-left-up:before{content:"\e634"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-gear-code:before{content:"\e5e8"}.fa-notes:before{content:"\e202"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-trash-arrow-turn-left:before,.fa-trash-undo:before{content:"\f895"}.fa-champagne-glass:before,.fa-glass-champagne:before{content:"\f79e"}.fa-objects-align-center-horizontal:before{content:"\e3bc"}.fa-sun:before{content:"\f185"}.fa-trash-alt-slash:before,.fa-trash-can-slash:before{content:"\e2ad"}.fa-screen-users:before,.fa-users-class:before{content:"\f63d"}.fa-guitar:before{content:"\f7a6"}.fa-arrow-square-left:before,.fa-square-arrow-left:before{content:"\f33a"}.fa-square-8:before{content:"\e25d"}.fa-face-smile-hearts:before{content:"\e390"}.fa-brackets-square:before,.fa-brackets:before{content:"\f7e9"}.fa-laptop-arrow-down:before{content:"\e1c6"}.fa-hockey-stick-puck:before{content:"\e3ae"}.fa-house-tree:before{content:"\e1b3"}.fa-signal-2:before,.fa-signal-fair:before{content:"\f68d"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-circle-dollar:before,.fa-dollar-circle:before,.fa-usd-circle:before{content:"\f2e8"}.fa-horse-head:before{content:"\f7ab"}.fa-arrows-repeat:before,.fa-repeat-alt:before{content:"\f364"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-image-polaroid:before{content:"\f8c4"}.fa-wave-triangle:before{content:"\f89a"}.fa-turn-left-down:before{content:"\e637"}.fa-person-running-fast:before{content:"\e5ff"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-grill:before{content:"\e5a3"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-analytics:before,.fa-chart-mixed:before{content:"\f643"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-desktop-code:before,.fa-display-code:before{content:"\e165"}.fa-face-drooling:before{content:"\e372"}.fa-oil-temp:before,.fa-oil-temperature:before{content:"\f614"}.fa-question-square:before,.fa-square-question:before{content:"\f2fd"}.fa-air-conditioner:before{content:"\f8f4"}.fa-angle-down:before{content:"\f107"}.fa-mountains:before{content:"\f6fd"}.fa-omega:before{content:"\f67a"}.fa-car-tunnel:before{content:"\e4de"}.fa-person-dolly-empty:before{content:"\f4d1"}.fa-pan-food:before{content:"\e42b"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-tickets-airline:before,.fa-tickets-perforated-plane:before,.fa-tickets-plane:before{content:"\e29b"}.fa-tent-double-peak:before{content:"\e627"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-nfc-slash:before{content:"\e1fc"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-grid-2-plus:before{content:"\e197"}.fa-bells:before{content:"\f77f"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-envelope-badge:before,.fa-envelope-dot:before{content:"\e16f"}.fa-magnifying-glass-waveform:before{content:"\e661"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-bowl-chopsticks:before{content:"\e2e9"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-circle-s:before{content:"\e121"}.fa-box-ballot:before{content:"\f735"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-user-tie-hair:before{content:"\e45f"}.fa-podium-star:before{content:"\f758"}.fa-business-front:before,.fa-party-back:before,.fa-trian-balbot:before,.fa-user-hair-mullet:before{content:"\e45c"}.fa-microphone-stand:before{content:"\f8cb"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-family-dress:before{content:"\e301"}.fa-circle-x:before{content:"\e12e"}.fa-cabin:before{content:"\e46d"}.fa-mountain-sun:before{content:"\e52f"}.fa-chart-simple-horizontal:before{content:"\e474"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-hand-back-point-left:before{content:"\e19f"}.fa-comment-alt-dots:before,.fa-message-dots:before,.fa-messaging:before{content:"\f4a3"}.fa-file-heart:before{content:"\e176"}.fa-beer-foam:before,.fa-beer-mug:before{content:"\e0b3"}.fa-dice-d20:before{content:"\f6cf"}.fa-drone:before{content:"\f85f"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-person-fairy:before{content:"\e608"}.fa-bed:before{content:"\f236"}.fa-book-copy:before{content:"\e0be"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-square-c:before{content:"\e266"}.fa-clock-two:before{content:"\e35a"}.fa-square-ellipsis-vertical:before{content:"\e26f"}.fa-calendar-users:before{content:"\e5e2"}.fa-podcast:before{content:"\f2ce"}.fa-bee:before{content:"\e0b2"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-candy-bar:before,.fa-chocolate-bar:before{content:"\e3e8"}.fa-xmark-large:before{content:"\e59b"}.fa-pinata:before{content:"\e3c3"}.fa-file-ppt:before{content:"\e64a"}.fa-arrows-from-line:before{content:"\e0a4"}.fa-superscript:before{content:"\f12b"}.fa-bowl-spoon:before{content:"\e3e0"}.fa-hexagon-check:before{content:"\e416"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-traffic-light-stop:before{content:"\f63a"}.fa-paint-roller:before{content:"\f5aa"}.fa-accent-grave:before{content:"\60"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-circle-0:before{content:"\e0ed"}.fa-dial-med-low:before{content:"\e160"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-crab:before{content:"\e3ff"}.fa-box-full:before,.fa-box-open-full:before{content:"\f49c"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-quotes:before{content:"\e234"}.fa-pretzel:before{content:"\e441"}.fa-t-rex:before{content:"\e629"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-user-robot-xmarks:before{content:"\e4a7"}.fa-comment-alt-quote:before,.fa-message-quote:before{content:"\e1e4"}.fa-candy-corn:before{content:"\f6bd"}.fa-folder-magnifying-glass:before,.fa-folder-search:before{content:"\e18b"}.fa-notebook:before{content:"\e201"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-bullseye-pointer:before{content:"\f649"}.fa-eraser:before{content:"\f12d"}.fa-hexagon-image:before{content:"\e504"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-file-svg:before{content:"\e64b"}.fa-crate-apple:before{content:"\f6b1"}.fa-apple-crate:before{content:"\f6b1"}.fa-person-burst:before{content:"\e53b"}.fa-game-board:before{content:"\f867"}.fa-hat-chef:before{content:"\f86b"}.fa-hand-back-point-right:before{content:"\e1a1"}.fa-dove:before{content:"\f4ba"}.fa-snowflake-droplets:before{content:"\e5c1"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-grid-4:before{content:"\e198"}.fa-socks:before{content:"\f696"}.fa-face-sunglasses:before{content:"\e398"}.fa-inbox:before{content:"\f01c"}.fa-square-0:before{content:"\e255"}.fa-section:before{content:"\e447"}.fa-box-up:before,.fa-square-this-way-up:before{content:"\f49f"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-square-ampersand:before{content:"\e260"}.fa-envelope-open-text:before{content:"\f658"}.fa-lamp-desk:before{content:"\e014"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-poll-people:before{content:"\f759"}.fa-glass-whiskey-rocks:before,.fa-whiskey-glass-ice:before{content:"\f7a1"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-user-bounty-hunter:before{content:"\e2bf"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-diagram-sankey:before{content:"\e158"}.fa-cloud-hail-mixed:before{content:"\f73a"}.fa-circle-up-left:before{content:"\e128"}.fa-dharmachakra:before{content:"\f655"}.fa-objects-align-left:before{content:"\e3be"}.fa-oil-can-drip:before{content:"\e205"}.fa-face-smiling-hands:before{content:"\e396"}.fa-broccoli:before{content:"\e3e2"}.fa-route-interstate:before{content:"\f61b"}.fa-ear-muffs:before{content:"\f795"}.fa-hotdog:before{content:"\f80f"}.fa-transporter-empty:before{content:"\e046"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-angle-90:before{content:"\e08d"}.fa-rectangle-terminal:before{content:"\e236"}.fa-kite:before{content:"\f6f4"}.fa-drum:before{content:"\f569"}.fa-scrubber:before{content:"\f2f8"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fish-bones:before{content:"\e304"}.fa-deer-rudolph:before{content:"\f78f"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-head-side-heart:before{content:"\e1aa"}.fa-square-e:before{content:"\e26d"}.fa-meter-fire:before{content:"\e1eb"}.fa-cloud-hail:before{content:"\f739"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-money-from-bracket:before{content:"\e312"}.fa-star-half:before{content:"\f089"}.fa-car-bus:before{content:"\f85a"}.fa-speaker:before{content:"\f8df"}.fa-timer:before{content:"\e29e"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-landmark-magnifying-glass:before{content:"\e622"}.fa-grill-hot:before{content:"\e5a5"}.fa-ballot-check:before{content:"\f733"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-file-minus:before{content:"\f318"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-coffee-togo:before,.fa-cup-togo:before{content:"\f6c5"}.fa-square-down-left:before{content:"\e26b"}.fa-burger-lettuce:before{content:"\e3e3"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-chevron-double-down:before,.fa-chevrons-down:before{content:"\f322"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-signal-3:before,.fa-signal-good:before{content:"\f68e"}.fa-location-question:before,.fa-map-marker-question:before{content:"\f60b"}.fa-floppy-disk-circle-xmark:before,.fa-floppy-disk-times:before,.fa-save-circle-xmark:before,.fa-save-times:before{content:"\e181"}.fa-naira-sign:before{content:"\e1f6"}.fa-peach:before{content:"\e20b"}.fa-taxi-bus:before{content:"\e298"}.fa-bracket-curly-left:before,.fa-bracket-curly:before{content:"\7b"}.fa-lobster:before{content:"\e421"}.fa-cart-flatbed-empty:before,.fa-dolly-flatbed-empty:before{content:"\f476"}.fa-colon:before{content:"\3a"}.fa-cart-arrow-down:before{content:"\f218"}.fa-wand:before{content:"\f72a"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-table-picnic:before{content:"\e32d"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-circle-microphone-lines:before,.fa-microphone-circle-alt:before{content:"\e117"}.fa-desktop-slash:before,.fa-display-slash:before{content:"\e2fa"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-transporter-2:before{content:"\e044"}.fa-hand-receiving:before,.fa-hands-holding-diamond:before{content:"\f47c"}.fa-money-bill-simple-wave:before{content:"\e1f2"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-bell-plus:before{content:"\f849"}.fa-book-arrow-right:before{content:"\e0b9"}.fa-hospitals:before{content:"\f80e"}.fa-club:before{content:"\f327"}.fa-skull-crossbones:before{content:"\f714"}.fa-dewpoint:before,.fa-droplet-degree:before{content:"\f748"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-hand-holding-magic:before{content:"\f6e5"}.fa-watermelon-slice:before{content:"\e337"}.fa-circle-ellipsis:before{content:"\e10a"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-sd-cards:before{content:"\e240"}.fa-jug-bottle:before{content:"\e5fb"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-envelopes:before{content:"\e170"}.fa-phone-office:before{content:"\f67d"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-nfc-pen:before{content:"\e1fa"}.fa-person-harassing:before{content:"\e549"}.fa-magnifying-glass-play:before{content:"\e660"}.fa-hat-winter:before{content:"\f7a8"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-bone-break:before{content:"\f5d8"}.fa-arrow-up:before{content:"\f062"}.fa-down-from-dotted-line:before{content:"\e407"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-border-left:before{content:"\f84f"}.fa-circle-divide:before{content:"\e106"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-diagram-subtask:before{content:"\e479"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-square-y:before{content:"\e287"}.fa-user-doctor-hair:before{content:"\e458"}.fa-planet-ringed:before{content:"\e020"}.fa-mushroom:before{content:"\e425"}.fa-user-shield:before{content:"\f505"}.fa-megaphone:before{content:"\f675"}.fa-wreath-laurel:before{content:"\e5d2"}.fa-circle-exclamation-check:before{content:"\e10d"}.fa-wind:before{content:"\f72e"}.fa-box-dollar:before,.fa-box-usd:before{content:"\f4a0"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-user-headset:before{content:"\f82d"}.fa-arrows-retweet:before,.fa-retweet-alt:before{content:"\f361"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-chevron-square-right:before,.fa-square-chevron-right:before{content:"\f32b"}.fa-lacrosse-stick-ball:before{content:"\e3b6"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-user-magnifying-glass:before{content:"\e5c5"}.fa-star-sharp:before{content:"\e28b"}.fa-comment-heart:before{content:"\e5c8"}.fa-circle-1:before{content:"\e0ee"}.fa-circle-star:before,.fa-star-circle:before{content:"\e123"}.fa-fish:before{content:"\f578"}.fa-cloud-fog:before,.fa-fog:before{content:"\f74e"}.fa-waffle:before{content:"\e466"}.fa-music-alt:before,.fa-music-note:before{content:"\f8cf"}.fa-hexagon-exclamation:before{content:"\e417"}.fa-cart-shopping-fast:before{content:"\e0dc"}.fa-object-union:before{content:"\e49f"}.fa-user-graduate:before{content:"\f501"}.fa-starfighter:before{content:"\e037"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-arrow-right-long-to-line:before{content:"\e3d5"}.fa-arrow-square-down:before,.fa-square-arrow-down:before{content:"\f339"}.fa-diamond-half-stroke:before{content:"\e5b8"}.fa-clapperboard:before{content:"\e131"}.fa-chevron-square-left:before,.fa-square-chevron-left:before{content:"\f32a"}.fa-phone-intercom:before{content:"\e434"}.fa-chain-horizontal:before,.fa-link-horizontal:before{content:"\e1cb"}.fa-mango:before{content:"\e30f"}.fa-music-alt-slash:before,.fa-music-note-slash:before{content:"\f8d0"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-face-tongue-sweat:before{content:"\e39e"}.fa-globe-stand:before{content:"\f5f6"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-circle-p:before{content:"\e11a"}.fa-award-simple:before{content:"\e0ab"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-pedestal:before{content:"\e20d"}.fa-chart-pyramid:before{content:"\e0e6"}.fa-sidebar:before{content:"\e24e"}.fa-frosty-head:before,.fa-snowman-head:before{content:"\f79b"}.fa-copy:before{content:"\f0c5"}.fa-burger-glass:before{content:"\e0ce"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-bars-filter:before{content:"\e0ad"}.fa-paintbrush-pencil:before{content:"\e206"}.fa-party-bell:before{content:"\e31a"}.fa-user-vneck-hair:before{content:"\e462"}.fa-jack-o-lantern:before{content:"\f30e"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-keynote:before{content:"\f66c"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-hat-beach:before{content:"\e606"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-fort:before{content:"\e486"}.fa-cloud-check:before{content:"\e35c"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-face-smirking:before{content:"\e397"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-rhombus:before{content:"\e23b"}.fa-claw-marks:before{content:"\f6c2"}.fa-peso-sign:before{content:"\e222"}.fa-face-smile-tongue:before{content:"\e394"}.fa-cart-circle-xmark:before{content:"\e3f4"}.fa-building-shield:before{content:"\e4d8"}.fa-circle-phone-flip:before,.fa-phone-circle-alt:before{content:"\e11c"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-key-skeleton:before{content:"\f6f3"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-arrow-down-to-bracket:before{content:"\e094"}.fa-lines-leaning:before{content:"\e51e"}.fa-square-q:before{content:"\e27b"}.fa-ruler-combined:before{content:"\f546"}.fa-icons-alt:before,.fa-symbols:before{content:"\f86e"}.fa-copyright:before{content:"\f1f9"}.fa-flask-gear:before{content:"\e5f1"}.fa-highlighter-line:before{content:"\e1af"}.fa-bracket-left:before,.fa-bracket-square:before,.fa-bracket:before{content:"\5b"}.fa-island-tree-palm:before,.fa-island-tropical:before{content:"\f811"}.fa-arrow-from-left:before,.fa-arrow-right-from-line:before{content:"\f343"}.fa-h2:before{content:"\f314"}.fa-equals:before{content:"\3d"}.fa-cake-slice:before,.fa-shortcake:before{content:"\e3e5"}.fa-building-magnifying-glass:before{content:"\e61c"}.fa-peanut:before{content:"\e430"}.fa-wrench-simple:before{content:"\e2d1"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-tally-2:before{content:"\e295"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-cars:before{content:"\f85b"}.fa-axe-battle:before{content:"\f6b3"}.fa-user-hair-long:before{content:"\e45b"}.fa-map:before{content:"\f279"}.fa-arrow-left-from-arc:before{content:"\e615"}.fa-file-circle-info:before{content:"\e493"}.fa-face-disappointed:before{content:"\e36f"}.fa-lasso-sparkles:before{content:"\e1c9"}.fa-clock-eleven:before{content:"\e347"}.fa-rocket:before{content:"\f135"}.fa-siren-on:before{content:"\e02e"}.fa-clock-ten:before{content:"\e354"}.fa-candle-holder:before{content:"\f6bc"}.fa-video-arrow-down-left:before{content:"\e2c8"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-floppy-disk-circle-arrow-right:before,.fa-save-circle-arrow-right:before{content:"\e180"}.fa-folder-minus:before{content:"\f65d"}.fa-planet-moon:before{content:"\e01f"}.fa-face-eyes-xmarks:before{content:"\e374"}.fa-chart-scatter:before{content:"\f7ee"}.fa-display-arrow-down:before{content:"\e164"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-olive-branch:before{content:"\e317"}.fa-angle:before{content:"\e08c"}.fa-vacuum-robot:before{content:"\e04e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-square-divide:before{content:"\e26a"}.fa-folder-check:before{content:"\e64e"}.fa-signal-stream-slash:before{content:"\e250"}.fa-bezier-curve:before{content:"\f55b"}.fa-eye-dropper-half:before{content:"\e173"}.fa-store-lock:before{content:"\e4a6"}.fa-bell-slash:before{content:"\f1f6"}.fa-cloud-bolt-sun:before,.fa-thunderstorm-sun:before{content:"\f76e"}.fa-camera-slash:before{content:"\e0d9"}.fa-comment-quote:before{content:"\e14c"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-message-code:before{content:"\e1df"}.fa-glass-half-empty:before,.fa-glass-half-full:before,.fa-glass-half:before{content:"\e192"}.fa-fill:before{content:"\f575"}.fa-comment-alt-minus:before,.fa-message-minus:before{content:"\f4a7"}.fa-angle-up:before{content:"\f106"}.fa-dinosaur:before{content:"\e5fe"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-chain-horizontal-slash:before,.fa-link-horizontal-slash:before{content:"\e1cc"}.fa-holly-berry:before{content:"\f7aa"}.fa-nose:before{content:"\e5bd"}.fa-arrow-left-to-arc:before{content:"\e616"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-clouds:before{content:"\f744"}.fa-money-bill-simple:before{content:"\e1f1"}.fa-hand-lizard:before{content:"\f258"}.fa-table-pivot:before{content:"\e291"}.fa-filter-slash:before{content:"\e17d"}.fa-trash-can-arrow-turn-left:before,.fa-trash-can-undo:before,.fa-trash-undo-alt:before{content:"\f896"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-person-to-door:before{content:"\e433"}.fa-turntable:before{content:"\f8e4"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-signal-1:before,.fa-signal-weak:before{content:"\f68c"}.fa-clock-five:before{content:"\e349"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-computer-classic:before{content:"\f8b1"}.fa-frame:before{content:"\e495"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-ellipsis-h-alt:before,.fa-ellipsis-stroke:before{content:"\f39b"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-face-expressionless:before{content:"\e373"}.fa-down-to-dotted-line:before{content:"\e408"}.fa-cloud-music:before{content:"\f8ae"}.fa-traffic-light:before{content:"\f637"}.fa-cloud-minus:before{content:"\e35d"}.fa-thermometer:before{content:"\f491"}.fa-shield-minus:before{content:"\e249"}.fa-vr-cardboard:before{content:"\f729"}.fa-car-tilt:before{content:"\f5e5"}.fa-gauge-circle-minus:before{content:"\e497"}.fa-brightness-low:before{content:"\e0ca"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-conveyor-belt:before{content:"\f46e"}.fa-location-check:before,.fa-map-marker-check:before{content:"\f606"}.fa-coin-vertical:before{content:"\e3fd"}.fa-display:before{content:"\e163"}.fa-person-sign:before{content:"\f757"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-phone-hangup:before{content:"\e225"}.fa-signature-slash:before{content:"\e3cb"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-wheat-slash:before{content:"\e339"}.fa-trophy:before{content:"\f091"}.fa-clouds-sun:before{content:"\f746"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-face-vomit:before{content:"\e3a0"}.fa-speakers:before{content:"\f8e0"}.fa-teletype-answer:before,.fa-tty-answer:before{content:"\e2b9"}.fa-mug-tea-saucer:before{content:"\e1f5"}.fa-diagram-lean-canvas:before{content:"\e156"}.fa-alt:before{content:"\e08a"}.fa-dial-med-high:before,.fa-dial:before{content:"\e15b"}.fa-hand-peace:before{content:"\f25b"}.fa-circle-trash:before,.fa-trash-circle:before{content:"\e126"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-circle-quarters:before{content:"\e3f8"}.fa-spinner:before{content:"\f110"}.fa-tower-control:before{content:"\e2a2"}.fa-arrow-up-triangle-square:before,.fa-sort-shapes-up:before{content:"\f88a"}.fa-whale:before{content:"\f72c"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-party-horn:before{content:"\e31b"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-sun-alt:before,.fa-sun-bright:before{content:"\e28f"}.fa-warehouse:before{content:"\f494"}.fa-conveyor-belt-arm:before{content:"\e5f8"}.fa-lock-keyhole-open:before,.fa-lock-open-alt:before{content:"\f3c2"}.fa-box-fragile:before,.fa-square-fragile:before,.fa-square-wine-glass-crack:before{content:"\f49b"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-square-n:before{content:"\e277"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-meter:before{content:"\e1e8"}.fa-mandolin:before{content:"\f6f9"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-up-from-bracket:before{content:"\e590"}.fa-knife-kitchen:before{content:"\f6f5"}.fa-border-right:before{content:"\f852"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-spade:before{content:"\f2f4"}.fa-card-spade:before{content:"\e3ec"}.fa-line-columns:before{content:"\f870"}.fa-arrow-right-to-line:before,.fa-arrow-to-right:before{content:"\f340"}.fa-person-falling-burst:before{content:"\e547"}.fa-flag-pennant:before,.fa-pennant:before{content:"\f456"}.fa-conveyor-belt-empty:before{content:"\e150"}.fa-user-group-simple:before{content:"\e603"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-camcorder:before,.fa-video-handheld:before{content:"\f8a8"}.fa-pancakes:before{content:"\e42d"}.fa-album-circle-user:before{content:"\e48d"}.fa-subtitles-slash:before{content:"\e610"}.fa-qrcode:before{content:"\f029"}.fa-dice-d10:before{content:"\f6cd"}.fa-fireplace:before{content:"\f79a"}.fa-browser:before{content:"\f37e"}.fa-pen-paintbrush:before,.fa-pencil-paintbrush:before{content:"\f618"}.fa-fish-cooked:before{content:"\f7fe"}.fa-chair-office:before{content:"\f6c1"}.fa-magnifying-glass-music:before{content:"\e65f"}.fa-nesting-dolls:before{content:"\e3ba"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-trumpet:before{content:"\f8e3"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-fire-smoke:before{content:"\f74b"}.fa-phone-missed:before{content:"\e226"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-arrows-repeat-1:before,.fa-repeat-1-alt:before{content:"\f366"}.fa-gun-slash:before{content:"\e19c"}.fa-avocado:before{content:"\e0aa"}.fa-binary:before{content:"\e33b"}.fa-glasses-alt:before,.fa-glasses-round:before{content:"\f5f5"}.fa-phone-plus:before{content:"\f4d2"}.fa-ditto:before{content:"\22"}.fa-person-seat:before{content:"\e21e"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-scythe:before{content:"\f710"}.fa-pen-nib:before{content:"\f5ad"}.fa-ban-parking:before,.fa-parking-circle-slash:before{content:"\f616"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-face-diagonal-mouth:before{content:"\e47e"}.fa-diagram-cells:before{content:"\e475"}.fa-cricket-bat-ball:before,.fa-cricket:before{content:"\f449"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-pen-line:before{content:"\e212"}.fa-atom-alt:before,.fa-atom-simple:before{content:"\f5d3"}.fa-ampersand:before{content:"\26"}.fa-carrot:before{content:"\f787"}.fa-arrow-from-bottom:before,.fa-arrow-up-from-line:before{content:"\f342"}.fa-moon:before{content:"\f186"}.fa-pen-slash:before{content:"\e213"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-square-star:before{content:"\e27f"}.fa-cheese:before{content:"\f7ef"}.fa-send-backward:before{content:"\f87f"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-compass-slash:before{content:"\f5e9"}.fa-clock-one:before{content:"\e34e"}.fa-file-music:before{content:"\f8b6"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-display-chart-up-circle-currency:before{content:"\e5e5"}.fa-skeleton:before{content:"\f620"}.fa-circle-g:before{content:"\e10f"}.fa-circle-arrow-up-left:before{content:"\e0fb"}.fa-coin-blank:before{content:"\e3fb"}.fa-broom:before{content:"\f51a"}.fa-vacuum:before{content:"\e04d"}.fa-shield-heart:before{content:"\e574"}.fa-card-heart:before{content:"\e3eb"}.fa-lightbulb-cfl-on:before{content:"\e5a7"}.fa-melon:before{content:"\e310"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-container-storage:before{content:"\f4b7"}.fa-face-pouting:before{content:"\e387"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-exploding-head:before,.fa-face-explode:before{content:"\e2fe"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-transformer-bolt:before{content:"\e2a4"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-cassette-vhs:before,.fa-vhs:before{content:"\f8ec"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-chimney:before{content:"\f78b"}.fa-object-intersect:before{content:"\e49d"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-block-brick:before,.fa-wall-brick:before{content:"\e3db"}.fa-fan:before{content:"\f863"}.fa-bags-shopping:before{content:"\f847"}.fa-paragraph-left:before,.fa-paragraph-rtl:before{content:"\f878"}.fa-person-walking-luggage:before{content:"\e554"}.fa-caravan-alt:before,.fa-caravan-simple:before{content:"\e000"}.fa-turtle:before{content:"\f726"}.fa-pencil-mechanical:before{content:"\e5ca"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-booth-curtain:before{content:"\f734"}.fa-calendar:before{content:"\f133"}.fa-box-heart:before{content:"\f49d"}.fa-trailer:before{content:"\e041"}.fa-user-doctor-message:before,.fa-user-md-chat:before{content:"\f82e"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-lighthouse:before{content:"\e612"}.fa-amp-guitar:before{content:"\f8a1"}.fa-sd-card:before{content:"\f7c2"}.fa-volume-slash:before{content:"\f2e2"}.fa-border-bottom:before{content:"\f84d"}.fa-wifi-1:before,.fa-wifi-weak:before{content:"\f6aa"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-delete-right:before{content:"\e154"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-circle-quarter:before{content:"\e11f"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-function:before{content:"\f661"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-people-simple:before{content:"\e21b"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-face-monocle:before{content:"\e380"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-calendar-lines:before,.fa-calendar-note:before{content:"\e0d5"}.fa-arrow-down-big-small:before,.fa-sort-size-down:before{content:"\f88c"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-do-not-enter:before{content:"\f5ec"}.fa-shower:before{content:"\f2cc"}.fa-dice-d8:before{content:"\f6d2"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-spinner-scale:before{content:"\e62a"}.fa-grip-dots-vertical:before{content:"\e411"}.fa-face-viewfinder:before{content:"\e2ff"}.fa-creemee:before,.fa-soft-serve:before{content:"\e400"}.fa-h5:before{content:"\e412"}.fa-hand-back-point-down:before{content:"\e19e"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-basket-shopping-minus:before{content:"\e652"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-rectangle-landscape:before,.fa-rectangle:before{content:"\f2fa"}.fa-clipboard-list-check:before{content:"\f737"}.fa-turkey:before{content:"\f725"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-ice-skate:before{content:"\f7ac"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-tomato:before{content:"\e330"}.fa-sword-laser:before{content:"\e03b"}.fa-house-circle-check:before{content:"\e509"}.fa-buildings:before{content:"\e0cc"}.fa-angle-left:before{content:"\f104"}.fa-cart-flatbed-boxes:before,.fa-dolly-flatbed-alt:before{content:"\f475"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-square-w:before{content:"\e285"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-lamp:before{content:"\f4ca"}.fa-airplay:before{content:"\e089"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-shield-quartered:before{content:"\e575"}.fa-slash-forward:before{content:"\2f"}.fa-location-pen:before,.fa-map-marker-edit:before{content:"\f607"}.fa-cloud-moon:before{content:"\f6c3"}.fa-pot-food:before{content:"\e43f"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-print-slash:before{content:"\f686"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-hand-back-point-ribbon:before{content:"\e1a0"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-tire-rugged:before{content:"\f634"}.fa-lightbulb-dollar:before{content:"\f670"}.fa-cowbell:before{content:"\f8b3"}.fa-baht-sign:before{content:"\e0ac"}.fa-corner:before{content:"\e3fe"}.fa-chevron-double-right:before,.fa-chevrons-right:before{content:"\f324"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-inhaler:before{content:"\f5f9"}.fa-handcuffs:before{content:"\e4f8"}.fa-snake:before{content:"\f716"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-note-medical:before{content:"\e200"}.fa-database:before{content:"\f1c0"}.fa-down-left:before{content:"\e16a"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-face-thinking:before{content:"\e39b"}.fa-turn-down-right:before{content:"\e455"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-scanner-keyboard:before{content:"\f489"}.fa-circle-o:before{content:"\e119"}.fa-grid-horizontal:before{content:"\e307"}.fa-comment-alt-dollar:before,.fa-message-dollar:before{content:"\f650"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-columns-3:before{content:"\e361"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-hand-holding-box:before{content:"\f47b"}.fa-input-text:before{content:"\e1bf"}.fa-window-alt:before,.fa-window-flip:before{content:"\f40f"}.fa-align-right:before{content:"\f038"}.fa-scanner-gun:before,.fa-scanner:before{content:"\f488"}.fa-tire:before{content:"\f631"}.fa-engine:before{content:"\e16e"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-caret-circle-right:before,.fa-circle-caret-right:before{content:"\f330"}.fa-turn-left:before{content:"\e636"}.fa-wheat:before{content:"\f72d"}.fa-file-spreadsheet:before{content:"\f65b"}.fa-audio-description-slash:before{content:"\e0a8"}.fa-bell-ring:before{content:"\e62c"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-chess-pawn-alt:before,.fa-chess-pawn-piece:before{content:"\f444"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-square-s:before{content:"\e27d"}.fa-barcode-alt:before,.fa-rectangle-barcode:before{content:"\f463"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-unicorn:before{content:"\f727"}.fa-bowling-ball:before{content:"\f436"}.fa-pompebled:before{content:"\e43d"}.fa-brain:before{content:"\f5dc"}.fa-watch-smart:before{content:"\e2cc"}.fa-book-user:before{content:"\f7e7"}.fa-sensor-cloud:before,.fa-sensor-smoke:before{content:"\e02c"}.fa-clapperboard-play:before{content:"\e132"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-circle-4:before{content:"\e0f1"}.fa-gifts:before{content:"\f79c"}.fa-album-collection:before{content:"\f8a0"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-cloud-showers:before{content:"\f73f"}.fa-user-clock:before{content:"\f4fd"}.fa-onion:before{content:"\e427"}.fa-clock-twelve-thirty:before{content:"\e359"}.fa-arrow-down-to-dotted-line:before{content:"\e095"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-rectangle-wide:before{content:"\f2fc"}.fa-comment-arrow-up:before{content:"\e144"}.fa-garlic:before{content:"\e40e"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-tree-decorated:before{content:"\f7dc"}.fa-mask:before{content:"\f6fa"}.fa-calendar-heart:before{content:"\e0d3"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-flower:before{content:"\f7ff"}.fa-arrow-down-from-arc:before{content:"\e614"}.fa-right-left-large:before{content:"\e5e1"}.fa-ruler-vertical:before{content:"\f548"}.fa-circles-overlap:before{content:"\e600"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-starship-freighter:before{content:"\e03a"}.fa-train-tram:before{content:"\e5b4"}.fa-bridge-suspension:before{content:"\e4cd"}.fa-trash-check:before{content:"\e2af"}.fa-user-nurse:before{content:"\f82f"}.fa-boombox:before{content:"\f8a5"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-shield-exclamation:before{content:"\e247"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-grip-dots:before{content:"\e410"}.fa-comment-exclamation:before{content:"\f4af"}.fa-pen-swirl:before{content:"\e214"}.fa-falafel:before{content:"\e40a"}.fa-circle-2:before{content:"\e0ef"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-gramophone:before{content:"\f8bd"}.fa-dice-d12:before{content:"\f6ce"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-arrow-alt-down:before,.fa-down:before{content:"\f354"}.fa-100:before,.fa-hundred-points:before{content:"\e41c"}.fa-paperclip-vertical:before{content:"\e3c2"}.fa-wind-circle-exclamation:before,.fa-wind-warning:before{content:"\f776"}.fa-location-pin-slash:before,.fa-map-marker-slash:before{content:"\f60c"}.fa-face-sad-sweat:before{content:"\e38a"}.fa-bug-slash:before{content:"\e490"}.fa-cupcake:before{content:"\e402"}.fa-light-switch-off:before{content:"\e018"}.fa-toggle-large-off:before{content:"\e5b0"}.fa-pen-fancy-slash:before{content:"\e210"}.fa-truck-container:before{content:"\f4dc"}.fa-boot:before{content:"\f782"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-file-check:before{content:"\f316"}.fa-bone:before{content:"\f5d7"}.fa-cards-blank:before{content:"\e4df"}.fa-circle-3:before{content:"\e0f0"}.fa-bench-tree:before{content:"\e2e7"}.fa-keyboard-brightness-low:before{content:"\e1c1"}.fa-ski-boot-ski:before{content:"\e3cd"}.fa-brain-circuit:before{content:"\e0c6"}.fa-user-injured:before{content:"\f728"}.fa-block-brick-fire:before,.fa-firewall:before{content:"\e3dc"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-face-smile-relaxed:before{content:"\e392"}.fa-comment-times:before,.fa-comment-xmark:before{content:"\f4b5"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-face-nose-steam:before{content:"\e382"}.fa-circle-waveform-lines:before,.fa-waveform-circle:before{content:"\e12d"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-ferris-wheel:before{content:"\e174"}.fa-computer-speaker:before{content:"\f8b2"}.fa-skull-cow:before{content:"\f8de"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-circle-t:before{content:"\e124"}.fa-sack:before{content:"\f81c"}.fa-grid-2:before{content:"\e196"}.fa-camera-cctv:before,.fa-cctv:before{content:"\f8ac"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-horizontal-rule:before{content:"\f86c"}.fa-bed-alt:before,.fa-bed-front:before{content:"\f8f7"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-file-gif:before{content:"\e645"}.fa-kip-sign:before{content:"\e1c4"}.fa-face-woozy:before{content:"\e3a2"}.fa-cloud-question:before{content:"\e492"}.fa-pineapple:before{content:"\e31f"}.fa-hand-point-left:before{content:"\f0a5"}.fa-gallery-thumbnails:before{content:"\e3aa"}.fa-circle-j:before{content:"\e112"}.fa-eyes:before{content:"\e367"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-file-caret-up:before,.fa-page-caret-up:before{content:"\e42a"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-comet:before{content:"\e003"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-reflect-vertical:before{content:"\e665"}.fa-shield-keyhole:before{content:"\e248"}.fa-file-mp4:before{content:"\e649"}.fa-barcode:before{content:"\f02a"}.fa-bulldozer:before{content:"\e655"}.fa-plus-minus:before{content:"\e43c"}.fa-sliders-v-square:before,.fa-square-sliders-vertical:before{content:"\f3f2"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-comment-middle-alt:before,.fa-message-middle:before{content:"\e1e1"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-square-z:before{content:"\e288"}.fa-comment-alt-text:before,.fa-message-text:before{content:"\e1e6"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"} +.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0} \ No newline at end of file diff --git a/public/fontawesome/css/sharp-regular.min.css b/public/fontawesome/css/sharp-regular.min.css new file mode 100644 index 000000000..5c1ae6b13 --- /dev/null +++ b/public/fontawesome/css/sharp-regular.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Commercial License) + * Copyright 2023 Fonticons, Inc. + */ +:host,:root{--fa-style-family-sharp:"Font Awesome 6 Sharp";--fa-font-sharp-regular:normal 400 1em/1 "Font Awesome 6 Sharp"}@font-face{font-family:"Font Awesome 6 Sharp";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-sharp-regular-400.woff2) format("woff2"),url(../webfonts/fa-sharp-regular-400.ttf) format("truetype")}.fa-regular,.fasr{font-weight:400} \ No newline at end of file diff --git a/public/fontawesome/css/sharp-solid.min.css b/public/fontawesome/css/sharp-solid.min.css new file mode 100644 index 000000000..f2a049e77 --- /dev/null +++ b/public/fontawesome/css/sharp-solid.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Commercial License) + * Copyright 2023 Fonticons, Inc. + */ +:host,:root{--fa-style-family-sharp:"Font Awesome 6 Sharp";--fa-font-sharp-solid:normal 900 1em/1 "Font Awesome 6 Sharp"}@font-face{font-family:"Font Awesome 6 Sharp";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-sharp-solid-900.woff2) format("woff2"),url(../webfonts/fa-sharp-solid-900.ttf) format("truetype")}.fa-solid,.fass{font-weight:900} \ No newline at end of file diff --git a/public/fontawesome/css/solid.min.css b/public/fontawesome/css/solid.min.css new file mode 100644 index 000000000..76e011d3e --- /dev/null +++ b/public/fontawesome/css/solid.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Pro 6.5.1 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Commercial License) + * Copyright 2023 Fonticons, Inc. + */ +:host,:root{--fa-style-family-classic:"Font Awesome 6 Pro";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Pro"}@font-face{font-family:"Font Awesome 6 Pro";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900} \ No newline at end of file diff --git a/public/fontawesome/webfonts/fa-brands-400.woff2 b/public/fontawesome/webfonts/fa-brands-400.woff2 new file mode 100644 index 000000000..5de3afeb8 Binary files /dev/null and b/public/fontawesome/webfonts/fa-brands-400.woff2 differ diff --git a/public/fontawesome/webfonts/fa-sharp-regular-400.woff2 b/public/fontawesome/webfonts/fa-sharp-regular-400.woff2 new file mode 100644 index 000000000..f549e5ca5 Binary files /dev/null and b/public/fontawesome/webfonts/fa-sharp-regular-400.woff2 differ diff --git a/public/fontawesome/webfonts/fa-sharp-solid-900.woff2 b/public/fontawesome/webfonts/fa-sharp-solid-900.woff2 new file mode 100644 index 000000000..61ac9f3ec Binary files /dev/null and b/public/fontawesome/webfonts/fa-sharp-solid-900.woff2 differ diff --git a/public/fontawesome/webfonts/fa-solid-900.woff2 b/public/fontawesome/webfonts/fa-solid-900.woff2 new file mode 100644 index 000000000..6e4c5f18b Binary files /dev/null and b/public/fontawesome/webfonts/fa-solid-900.woff2 differ diff --git a/public/fonts/MartianMono-VariableFont_wdth,wght.ttf b/public/fonts/MartianMono-VariableFont_wdth,wght.ttf new file mode 100644 index 000000000..843b2903d Binary files /dev/null and b/public/fonts/MartianMono-VariableFont_wdth,wght.ttf differ diff --git a/public/fonts/firacode-bold.woff2 b/public/fonts/firacode-bold.woff2 new file mode 100644 index 000000000..349dc36a5 Binary files /dev/null and b/public/fonts/firacode-bold.woff2 differ diff --git a/public/fonts/firacode-regular.woff2 b/public/fonts/firacode-regular.woff2 new file mode 100644 index 000000000..f8b63fb01 Binary files /dev/null and b/public/fonts/firacode-regular.woff2 differ diff --git a/public/fonts/hack-bold.woff2 b/public/fonts/hack-bold.woff2 new file mode 100644 index 000000000..1155477e9 Binary files /dev/null and b/public/fonts/hack-bold.woff2 differ diff --git a/public/fonts/hack-bolditalic.woff2 b/public/fonts/hack-bolditalic.woff2 new file mode 100644 index 000000000..46ff1c425 Binary files /dev/null and b/public/fonts/hack-bolditalic.woff2 differ diff --git a/public/fonts/hack-italic.woff2 b/public/fonts/hack-italic.woff2 new file mode 100644 index 000000000..1e7630cef Binary files /dev/null and b/public/fonts/hack-italic.woff2 differ diff --git a/public/fonts/hack-regular.woff2 b/public/fonts/hack-regular.woff2 new file mode 100644 index 000000000..524465cf5 Binary files /dev/null and b/public/fonts/hack-regular.woff2 differ diff --git a/public/fonts/hacknerdmono-bold.ttf b/public/fonts/hacknerdmono-bold.ttf new file mode 100644 index 000000000..5cb890bbd Binary files /dev/null and b/public/fonts/hacknerdmono-bold.ttf differ diff --git a/public/fonts/hacknerdmono-bolditalic.ttf b/public/fonts/hacknerdmono-bolditalic.ttf new file mode 100644 index 000000000..58961fa25 Binary files /dev/null and b/public/fonts/hacknerdmono-bolditalic.ttf differ diff --git a/public/fonts/hacknerdmono-italic.ttf b/public/fonts/hacknerdmono-italic.ttf new file mode 100644 index 000000000..30094a32a Binary files /dev/null and b/public/fonts/hacknerdmono-italic.ttf differ diff --git a/public/fonts/hacknerdmono-regular.ttf b/public/fonts/hacknerdmono-regular.ttf new file mode 100644 index 000000000..6c95cc07d Binary files /dev/null and b/public/fonts/hacknerdmono-regular.ttf differ diff --git a/public/fonts/inter-variable.woff2 b/public/fonts/inter-variable.woff2 new file mode 100644 index 000000000..22a12b04e Binary files /dev/null and b/public/fonts/inter-variable.woff2 differ diff --git a/public/fonts/jetbrains-mono-v13-latin-200.woff2 b/public/fonts/jetbrains-mono-v13-latin-200.woff2 new file mode 100644 index 000000000..d23f9b05b Binary files /dev/null and b/public/fonts/jetbrains-mono-v13-latin-200.woff2 differ diff --git a/public/fonts/jetbrains-mono-v13-latin-700.woff2 b/public/fonts/jetbrains-mono-v13-latin-700.woff2 new file mode 100644 index 000000000..5737b146e Binary files /dev/null and b/public/fonts/jetbrains-mono-v13-latin-700.woff2 differ diff --git a/public/fonts/jetbrains-mono-v13-latin-regular.woff2 b/public/fonts/jetbrains-mono-v13-latin-regular.woff2 new file mode 100644 index 000000000..de8b746b8 Binary files /dev/null and b/public/fonts/jetbrains-mono-v13-latin-regular.woff2 differ diff --git a/public/fonts/lato-bold.woff b/public/fonts/lato-bold.woff new file mode 100644 index 000000000..84c1cf303 Binary files /dev/null and b/public/fonts/lato-bold.woff differ diff --git a/public/fonts/lato-regular.woff b/public/fonts/lato-regular.woff new file mode 100644 index 000000000..f8b6d3ecb Binary files /dev/null and b/public/fonts/lato-regular.woff differ diff --git a/public/logos/wave-dark.png b/public/logos/wave-dark.png new file mode 100644 index 000000000..552cf00b1 Binary files /dev/null and b/public/logos/wave-dark.png differ diff --git a/public/logos/wave-logo-dark.png b/public/logos/wave-logo-dark.png new file mode 100644 index 000000000..c40e57904 Binary files /dev/null and b/public/logos/wave-logo-dark.png differ diff --git a/public/logos/wave-logo.png b/public/logos/wave-logo.png new file mode 100644 index 000000000..a10809fb0 Binary files /dev/null and b/public/logos/wave-logo.png differ diff --git a/public/plotdata/congress.csv b/public/plotdata/congress.csv new file mode 100644 index 000000000..566be7461 --- /dev/null +++ b/public/plotdata/congress.csv @@ -0,0 +1,542 @@ +full_name,birthday,gender,type,state,party +Sherrod Brown,1952-11-09,M,sen,OH,Democrat +Maria Cantwell,1958-10-13,F,sen,WA,Democrat +Benjamin L. Cardin,1943-10-05,M,sen,MD,Democrat +Thomas R. Carper,1947-01-23,M,sen,DE,Democrat +"Robert P. Casey, Jr.",1960-04-13,M,sen,PA,Democrat +Dianne Feinstein,1933-06-22,F,sen,CA,Democrat +Amy Klobuchar,1960-05-25,F,sen,MN,Democrat +Robert Menendez,1954-01-01,M,sen,NJ,Democrat +Bernard Sanders,1941-09-08,M,sen,VT,Independent +Debbie Stabenow,1950-04-29,F,sen,MI,Democrat +Jon Tester,1956-08-21,M,sen,MT,Democrat +Sheldon Whitehouse,1955-10-20,M,sen,RI,Democrat +John Barrasso,1952-07-21,M,sen,WY,Republican +Roger F. Wicker,1951-07-05,M,sen,MS,Republican +Susan M. Collins,1952-12-07,F,sen,ME,Republican +John Cornyn,1952-02-02,M,sen,TX,Republican +Richard J. Durbin,1944-11-21,M,sen,IL,Democrat +Lindsey Graham,1955-07-09,M,sen,SC,Republican +Mitch McConnell,1942-02-20,M,sen,KY,Republican +Jeff Merkley,1956-10-24,M,sen,OR,Democrat +Jack Reed,1949-11-12,M,sen,RI,Democrat +James E. Risch,1943-05-03,M,sen,ID,Republican +Jeanne Shaheen,1947-01-28,F,sen,NH,Democrat +Mark R. Warner,1954-12-15,M,sen,VA,Democrat +Kirsten E. Gillibrand,1966-12-09,F,sen,NY,Democrat +Christopher A. Coons,1963-09-09,M,sen,DE,Democrat +"Joe Manchin, III",1947-08-24,M,sen,WV,Democrat +Robert B. Aderholt,1965-07-22,M,rep,AL,Republican +Tammy Baldwin,1962-02-11,F,sen,WI,Democrat +Michael F. Bennet,1964-11-28,M,sen,CO,Democrat +Gus M. Bilirakis,1963-02-08,M,rep,FL,Republican +"Sanford D. Bishop, Jr.",1947-02-04,M,rep,GA,Democrat +Marsha Blackburn,1952-06-06,F,sen,TN,Republican +Earl Blumenauer,1948-08-16,M,rep,OR,Democrat +Richard Blumenthal,1946-02-13,M,sen,CT,Democrat +John Boozman,1950-12-10,M,sen,AR,Republican +Vern Buchanan,1951-05-08,M,rep,FL,Republican +Larry Bucshon,1962-05-31,M,rep,IN,Republican +Michael C. Burgess,1950-12-23,M,rep,TX,Republican +Ken Calvert,1953-06-08,M,rep,CA,Republican +Shelley Moore Capito,1953-11-26,F,sen,WV,Republican +André Carson,1974-10-16,M,rep,IN,Democrat +John R. Carter,1941-11-06,M,rep,TX,Republican +Bill Cassidy,1957-09-28,M,sen,LA,Republican +Kathy Castor,1966-08-20,F,rep,FL,Democrat +Judy Chu,1953-07-07,F,rep,CA,Democrat +David N. Cicilline,1961-07-15,M,rep,RI,Democrat +Yvette D. Clarke,1964-11-21,F,rep,NY,Democrat +Emanuel Cleaver,1944-10-26,M,rep,MO,Democrat +James E. Clyburn,1940-07-21,M,rep,SC,Democrat +Steve Cohen,1949-05-24,M,rep,TN,Democrat +Tom Cole,1949-04-28,M,rep,OK,Republican +Gerald E. Connolly,1950-03-30,M,rep,VA,Democrat +Jim Costa,1952-04-13,M,rep,CA,Democrat +Joe Courtney,1953-04-06,M,rep,CT,Democrat +Mike Crapo,1951-05-20,M,sen,ID,Republican +"Eric A. ""Rick"" Crawford",1966-01-22,M,rep,AR,Republican +Henry Cuellar,1955-09-19,M,rep,TX,Democrat +Danny K. Davis,1941-09-06,M,rep,IL,Democrat +Diana DeGette,1957-07-29,F,rep,CO,Democrat +Rosa L. DeLauro,1943-03-02,F,rep,CT,Democrat +Scott DesJarlais,1964-02-21,M,rep,TN,Republican +Mario Diaz-Balart,1961-09-25,M,rep,FL,Republican +Lloyd Doggett,1946-10-06,M,rep,TX,Democrat +Jeff Duncan,1966-01-07,M,rep,SC,Republican +Anna G. Eshoo,1942-12-13,F,rep,CA,Democrat +"Charles J. ""Chuck"" Fleischmann",1962-10-11,M,rep,TN,Republican +Virginia Foxx,1943-06-29,F,rep,NC,Republican +John Garamendi,1945-01-24,M,rep,CA,Democrat +Paul A. Gosar,1958-11-22,M,rep,AZ,Republican +Kay Granger,1943-01-18,F,rep,TX,Republican +Chuck Grassley,1933-09-17,M,sen,IA,Republican +Sam Graves,1963-11-07,M,rep,MO,Republican +Al Green,1947-09-01,M,rep,TX,Democrat +H. Morgan Griffith,1958-03-15,M,rep,VA,Republican +Raúl M. Grijalva,1948-02-19,M,rep,AZ,Democrat +Brett Guthrie,1964-02-18,M,rep,KY,Republican +Andy Harris,1957-01-25,M,rep,MD,Republican +Martin Heinrich,1971-10-17,M,sen,NM,Democrat +Brian Higgins,1959-10-06,M,rep,NY,Democrat +James A. Himes,1966-07-05,M,rep,CT,Democrat +Mazie K. Hirono,1947-11-03,F,sen,HI,Democrat +John Hoeven,1957-03-13,M,sen,ND,Republican +Steny H. Hoyer,1939-06-14,M,rep,MD,Democrat +Bill Huizenga,1969-01-31,M,rep,MI,Republican +Sheila Jackson Lee,1950-01-12,F,rep,TX,Democrat +Bill Johnson,1954-11-10,M,rep,OH,Republican +"Henry C. ""Hank"" Johnson, Jr.",1954-10-02,M,rep,GA,Democrat +Ron Johnson,1955-04-08,M,sen,WI,Republican +Jim Jordan,1964-02-17,M,rep,OH,Republican +Marcy Kaptur,1946-06-17,F,rep,OH,Democrat +William R. Keating,1952-09-06,M,rep,MA,Democrat +Mike Kelly,1948-05-10,M,rep,PA,Republican +Doug Lamborn,1954-05-24,M,rep,CO,Republican +James Lankford,1968-03-04,M,sen,OK,Republican +Rick Larsen,1965-06-15,M,rep,WA,Democrat +John B. Larson,1948-07-22,M,rep,CT,Democrat +Robert E. Latta,1956-04-18,M,rep,OH,Republican +Barbara Lee,1946-07-16,F,rep,CA,Democrat +Mike Lee,1971-06-04,M,sen,UT,Republican +Zoe Lofgren,1947-12-21,F,rep,CA,Democrat +Frank D. Lucas,1960-01-06,M,rep,OK,Republican +Blaine Luetkemeyer,1952-05-07,M,rep,MO,Republican +Ben Ray Luján,1972-06-07,M,sen,NM,Democrat +Stephen F. Lynch,1955-03-31,M,rep,MA,Democrat +Edward J. Markey,1946-07-11,M,sen,MA,Democrat +Doris O. Matsui,1944-09-25,F,rep,CA,Democrat +Kevin McCarthy,1965-01-26,M,rep,CA,Republican +Michael T. McCaul,1962-01-14,M,rep,TX,Republican +Tom McClintock,1956-07-10,M,rep,CA,Republican +Betty McCollum,1954-07-12,F,rep,MN,Democrat +James P. McGovern,1959-11-20,M,rep,MA,Democrat +Patrick T. McHenry,1975-10-22,M,rep,NC,Republican +Cathy McMorris Rodgers,1969-05-22,F,rep,WA,Republican +Gregory W. Meeks,1953-09-25,M,rep,NY,Democrat +Gwen Moore,1951-04-18,F,rep,WI,Democrat +Jerry Moran,1954-05-29,M,sen,KS,Republican +Lisa Murkowski,1957-05-22,F,sen,AK,Republican +Christopher Murphy,1973-08-03,M,sen,CT,Democrat +Patty Murray,1950-10-11,F,sen,WA,Democrat +Jerrold Nadler,1947-06-13,M,rep,NY,Democrat +Grace F. Napolitano,1936-12-04,F,rep,CA,Democrat +Richard E. Neal,1949-02-14,M,rep,MA,Democrat +Eleanor Holmes Norton,1937-06-13,F,rep,DC,Democrat +"Frank Pallone, Jr.",1951-10-30,M,rep,NJ,Democrat +"Bill Pascrell, Jr.",1937-01-25,M,rep,NJ,Democrat +Rand Paul,1963-01-07,M,sen,KY,Republican +Nancy Pelosi,1940-03-26,F,rep,CA,Democrat +Gary C. Peters,1958-12-01,M,sen,MI,Democrat +Chellie Pingree,1955-04-02,F,rep,ME,Democrat +Bill Posey,1947-12-18,M,rep,FL,Republican +Mike Quigley,1958-10-17,M,rep,IL,Democrat +Harold Rogers,1937-12-31,M,rep,KY,Republican +Mike Rogers,1958-07-16,M,rep,AL,Republican +Marco Rubio,1971-05-28,M,sen,FL,Republican +C. A. Dutch Ruppersberger,1946-01-31,M,rep,MD,Democrat +Gregorio Kilili Camacho Sablan,1955-01-19,M,rep,MP,Democrat +John P. Sarbanes,1962-05-22,M,rep,MD,Democrat +Steve Scalise,1965-10-06,M,rep,LA,Republican +Janice D. Schakowsky,1944-05-26,F,rep,IL,Democrat +Adam B. Schiff,1960-06-22,M,rep,CA,Democrat +Charles E. Schumer,1950-11-23,M,sen,NY,Democrat +David Schweikert,1962-03-03,M,rep,AZ,Republican +Austin Scott,1969-12-10,M,rep,GA,Republican +David Scott,1945-06-27,M,rep,GA,Democrat +"Robert C. ""Bobby"" Scott",1947-04-30,M,rep,VA,Democrat +Tim Scott,1965-09-19,M,sen,SC,Republican +Terri A. Sewell,1965-01-01,F,rep,AL,Democrat +Brad Sherman,1954-10-24,M,rep,CA,Democrat +Michael K. Simpson,1950-09-08,M,rep,ID,Republican +Adam Smith,1965-06-15,M,rep,WA,Democrat +Adrian Smith,1970-12-19,M,rep,NE,Republican +Christopher H. Smith,1953-03-04,M,rep,NJ,Republican +Linda T. Sánchez,1969-01-28,F,rep,CA,Democrat +Bennie G. Thompson,1948-01-28,M,rep,MS,Democrat +Mike Thompson,1951-01-24,M,rep,CA,Democrat +Glenn Thompson,1959-07-27,M,rep,PA,Republican +John Thune,1961-01-07,M,sen,SD,Republican +Paul Tonko,1949-06-18,M,rep,NY,Democrat +Michael R. Turner,1960-01-11,M,rep,OH,Republican +Chris Van Hollen,1959-01-10,M,sen,MD,Democrat +Nydia M. Velázquez,1953-03-28,F,rep,NY,Democrat +Tim Walberg,1951-04-12,M,rep,MI,Republican +Debbie Wasserman Schultz,1966-09-27,F,rep,FL,Democrat +Maxine Waters,1938-08-15,F,rep,CA,Democrat +Daniel Webster,1949-04-27,M,rep,FL,Republican +Peter Welch,1947-05-02,M,sen,VT,Democrat +Joe Wilson,1947-07-31,M,rep,SC,Republican +Frederica S. Wilson,1942-11-05,F,rep,FL,Democrat +Robert J. Wittman,1959-02-03,M,rep,VA,Republican +Steve Womack,1957-02-18,M,rep,AR,Republican +Ron Wyden,1949-05-03,M,sen,OR,Democrat +Todd Young,1972-08-24,M,sen,IN,Republican +Mark E. Amodei,1958-06-12,M,rep,NV,Republican +Suzanne Bonamici,1954-10-14,F,rep,OR,Democrat +Suzan K. DelBene,1962-02-17,F,rep,WA,Democrat +Thomas Massie,1971-01-13,M,rep,KY,Republican +"Donald M. Payne, Jr.",1958-12-17,M,rep,NJ,Democrat +Brian Schatz,1972-10-20,M,sen,HI,Democrat +Bill Foster,1955-10-07,M,rep,IL,Democrat +Dina Titus,1950-05-23,F,rep,NV,Democrat +Tom Cotton,1977-05-13,M,sen,AR,Republican +Kyrsten Sinema,1976-07-12,F,sen,AZ,Independent +Doug LaMalfa,1960-07-02,M,rep,CA,Republican +Jared Huffman,1964-02-18,M,rep,CA,Democrat +Ami Bera,1965-03-02,M,rep,CA,Democrat +Eric Swalwell,1980-11-16,M,rep,CA,Democrat +Julia Brownley,1952-08-28,F,rep,CA,Democrat +Tony Cárdenas,1963-03-31,M,rep,CA,Democrat +Raul Ruiz,1972-08-25,M,rep,CA,Democrat +Mark Takano,1960-12-10,M,rep,CA,Democrat +Juan Vargas,1961-03-07,M,rep,CA,Democrat +Scott H. Peters,1958-06-17,M,rep,CA,Democrat +Lois Frankel,1948-05-16,F,rep,FL,Democrat +Tammy Duckworth,1968-03-12,F,sen,IL,Democrat +Andy Barr,1973-07-24,M,rep,KY,Republican +Elizabeth Warren,1949-06-22,F,sen,MA,Democrat +"Angus S. King, Jr.",1944-03-31,M,sen,ME,Independent +Daniel T. Kildee,1958-08-11,M,rep,MI,Democrat +Ann Wagner,1962-09-13,F,rep,MO,Republican +Steve Daines,1962-08-20,M,sen,MT,Republican +Richard Hudson,1971-11-04,M,rep,NC,Republican +Kevin Cramer,1961-01-21,M,sen,ND,Republican +Deb Fischer,1951-03-01,F,sen,NE,Republican +Ann M. Kuster,1956-09-05,F,rep,NH,Democrat +Grace Meng,1975-10-01,F,rep,NY,Democrat +Hakeem S. Jeffries,1970-08-04,M,rep,NY,Democrat +Brad R. Wenstrup,1958-06-17,M,rep,OH,Republican +Joyce Beatty,1950-03-12,F,rep,OH,Democrat +David P. Joyce,1957-03-17,M,rep,OH,Republican +Markwayne Mullin,1977-07-26,M,sen,OK,Republican +Scott Perry,1962-05-27,M,rep,PA,Republican +Matt Cartwright,1961-05-01,M,rep,PA,Democrat +Ted Cruz,1970-12-22,M,sen,TX,Republican +"Randy K. Weber, Sr.",1953-07-02,M,rep,TX,Republican +Joaquin Castro,1974-09-16,M,rep,TX,Democrat +Roger Williams,1949-09-13,M,rep,TX,Republican +Marc A. Veasey,1971-01-03,M,rep,TX,Democrat +Chris Stewart,1960-07-15,M,rep,UT,Republican +Tim Kaine,1958-02-26,M,sen,VA,Democrat +Derek Kilmer,1974-01-01,M,rep,WA,Democrat +Mark Pocan,1964-08-14,M,rep,WI,Democrat +Robin L. Kelly,1956-04-30,F,rep,IL,Democrat +Jason Smith,1980-06-16,M,rep,MO,Republican +Cory A. Booker,1969-04-27,M,sen,NJ,Democrat +Katherine M. Clark,1963-07-17,F,rep,MA,Democrat +Donald Norcross,1958-12-13,M,rep,NJ,Democrat +Alma S. Adams,1946-05-27,F,rep,NC,Democrat +Gary J. Palmer,1954-05-14,M,rep,AL,Republican +J. French Hill,1956-12-05,M,rep,AR,Republican +Bruce Westerman,1967-11-18,M,rep,AR,Republican +Ruben Gallego,1979-11-20,M,rep,AZ,Democrat +Mark DeSaulnier,1952-03-31,M,rep,CA,Democrat +Pete Aguilar,1979-06-19,M,rep,CA,Democrat +Ted Lieu,1969-03-29,M,rep,CA,Democrat +Norma J. Torres,1965-04-04,F,rep,CA,Democrat +Ken Buck,1959-02-16,M,rep,CO,Republican +"Earl L. ""Buddy"" Carter",1957-09-06,M,rep,GA,Republican +Barry Loudermilk,1963-12-22,M,rep,GA,Republican +Rick W. Allen,1951-11-07,M,rep,GA,Republican +Mike Bost,1960-12-30,M,rep,IL,Republican +Garret Graves,1972-01-31,M,rep,LA,Republican +Seth Moulton,1978-10-24,M,rep,MA,Democrat +John R. Moolenaar,1961-05-08,M,rep,MI,Republican +Debbie Dingell,1953-11-23,F,rep,MI,Democrat +Tom Emmer,1961-03-03,M,rep,MN,Republican +David Rouzer,1972-02-16,M,rep,NC,Republican +Bonnie Watson Coleman,1945-02-06,F,rep,NJ,Democrat +Elise M. Stefanik,1984-07-02,F,rep,NY,Republican +Brendan F. Boyle,1977-02-06,M,rep,PA,Democrat +Brian Babin,1948-03-23,M,rep,TX,Republican +"Donald S. Beyer, Jr.",1950-06-20,M,rep,VA,Democrat +Stacey E. Plaskett,1966-05-13,F,rep,VI,Democrat +Dan Newhouse,1955-07-10,M,rep,WA,Republican +Glenn Grothman,1955-07-03,M,rep,WI,Republican +Alexander X. Mooney,1971-06-05,M,rep,WV,Republican +Aumua Amata Coleman Radewagen,1947-12-29,F,rep,AS,Republican +Dan Sullivan,1964-11-13,M,sen,AK,Republican +Joni Ernst,1970-07-01,F,sen,IA,Republican +Thom Tillis,1960-08-30,M,sen,NC,Republican +Mike Rounds,1954-10-24,M,sen,SD,Republican +Trent Kelly,1966-03-01,M,rep,MS,Republican +Darin LaHood,1968-07-05,M,rep,IL,Republican +Warren Davidson,1970-03-01,M,rep,OH,Republican +James Comer,1972-08-19,M,rep,KY,Republican +Dwight Evans,1954-05-16,M,rep,PA,Democrat +John Kennedy,1951-11-21,M,sen,LA,Republican +Margaret Wood Hassan,1958-02-27,F,sen,NH,Democrat +Catherine Cortez Masto,1964-03-29,F,sen,NV,Democrat +Bradley Scott Schneider,1961-08-20,M,rep,IL,Democrat +Andy Biggs,1958-11-07,M,rep,AZ,Republican +Ro Khanna,1976-09-13,M,rep,CA,Democrat +Jimmy Panetta,1969-10-01,M,rep,CA,Democrat +Salud O. Carbajal,1964-11-18,M,rep,CA,Democrat +Nanette Diaz Barragán,1976-09-15,F,rep,CA,Democrat +J. Luis Correa,1958-01-24,M,rep,CA,Democrat +Lisa Blunt Rochester,1962-02-10,F,rep,DE,Democrat +Matt Gaetz,1982-05-07,M,rep,FL,Republican +Neal P. Dunn,1953-02-16,M,rep,FL,Republican +John H. Rutherford,1952-09-02,M,rep,FL,Republican +Darren Soto,1978-02-25,M,rep,FL,Democrat +Brian J. Mast,1980-07-10,M,rep,FL,Republican +A. Drew Ferguson IV,1966-11-15,M,rep,GA,Republican +Raja Krishnamoorthi,1973-07-19,M,rep,IL,Democrat +Jim Banks,1979-07-16,M,rep,IN,Republican +Roger Marshall,1960-08-09,M,sen,KS,Republican +Clay Higgins,1961-08-24,M,rep,LA,Republican +Mike Johnson,1972-01-30,M,rep,LA,Republican +Jamie Raskin,1962-12-13,M,rep,MD,Democrat +Jack Bergman,1947-02-02,M,rep,MI,Republican +Ted Budd,1971-10-21,M,sen,NC,Republican +Don Bacon,1963-08-16,M,rep,NE,Republican +Josh Gottheimer,1975-03-08,M,rep,NJ,Democrat +Jacky Rosen,1957-08-02,F,sen,NV,Democrat +Adriano Espaillat,1954-09-27,M,rep,NY,Democrat +Brian K. Fitzpatrick,1973-12-17,M,rep,PA,Republican +Lloyd Smucker,1964-01-23,M,rep,PA,Republican +Jenniffer González-Colón,1976-08-05,F,rep,PR,Republican +David Kustoff,1966-10-08,M,rep,TN,Republican +Vicente Gonzalez,1967-09-04,M,rep,TX,Democrat +Jodey C. Arrington,1972-03-09,M,rep,TX,Republican +Pramila Jayapal,1965-09-21,F,rep,WA,Democrat +Mike Gallagher,1984-03-03,M,rep,WI,Republican +Ron Estes,1956-07-19,M,rep,KS,Republican +Ralph Norman,1953-06-20,M,rep,SC,Republican +Jimmy Gomez,1974-11-25,M,rep,CA,Democrat +John R. Curtis,1960-05-10,M,rep,UT,Republican +Tina Smith,1958-03-04,F,sen,MN,Democrat +Cindy Hyde-Smith,1959-05-10,F,sen,MS,Republican +Debbie Lesko,1958-11-14,F,rep,AZ,Republican +Michael Cloud,1975-05-13,M,rep,TX,Republican +Troy Balderson,1962-01-16,M,rep,OH,Republican +Kevin Hern,1961-12-04,M,rep,OK,Republican +Joseph D. Morelle,1957-04-29,M,rep,NY,Democrat +Mary Gay Scanlon,1959-08-30,F,rep,PA,Democrat +Susan Wild,1957-06-07,F,rep,PA,Democrat +Ed Case,1952-09-27,M,rep,HI,Democrat +Steven Horsford,1973-04-29,M,rep,NV,Democrat +Greg Stanton,1970-03-08,M,rep,AZ,Democrat +Josh Harder,1986-08-01,M,rep,CA,Democrat +Katie Porter,1974-01-03,F,rep,CA,Democrat +Mike Levin,1978-10-28,M,rep,CA,Democrat +Joe Neguse,1984-05-13,M,rep,CO,Democrat +Jason Crow,1979-03-15,M,rep,CO,Democrat +Jahana Hayes,1973-03-08,F,rep,CT,Democrat +Michael Waltz,1974-01-31,M,rep,FL,Republican +W. Gregory Steube,1978-05-19,M,rep,FL,Republican +Lucy McBath,1960-06-01,F,rep,GA,Democrat +Russ Fulcher,1973-07-19,M,rep,ID,Republican +"Jesús G. ""Chuy"" García",1956-04-12,M,rep,IL,Democrat +Sean Casten,1971-11-23,M,rep,IL,Democrat +Lauren Underwood,1986-10-04,F,rep,IL,Democrat +James R. Baird,1945-06-04,M,rep,IN,Republican +Greg Pence,1956-11-14,M,rep,IN,Republican +Sharice Davids,1980-05-22,F,rep,KS,Democrat +Lori Trahan,1973-10-27,F,rep,MA,Democrat +Ayanna Pressley,1974-02-03,F,rep,MA,Democrat +David J. Trone,1955-09-21,M,rep,MD,Democrat +Elissa Slotkin,1976-07-10,F,rep,MI,Democrat +Haley M. Stevens,1983-06-24,F,rep,MI,Democrat +Rashida Tlaib,1976-07-24,F,rep,MI,Democrat +Angie Craig,1972-02-14,F,rep,MN,Democrat +Dean Phillips,1969-01-20,M,rep,MN,Democrat +Ilhan Omar,1981-10-04,F,rep,MN,Democrat +Pete Stauber,1966-05-10,M,rep,MN,Republican +Michael Guest,1970-02-04,M,rep,MS,Republican +Kelly Armstrong,1976-10-08,M,rep,ND,Republican +Chris Pappas,1980-06-04,M,rep,NH,Democrat +Jefferson Van Drew,1953-02-23,M,rep,NJ,Republican +Andy Kim,1982-07-12,M,rep,NJ,Democrat +Mikie Sherrill,1972-01-19,F,rep,NJ,Democrat +Susie Lee,1966-11-07,F,rep,NV,Democrat +Alexandria Ocasio-Cortez,1989-10-13,F,rep,NY,Democrat +Madeleine Dean,1959-06-06,F,rep,PA,Democrat +Chrissy Houlahan,1967-06-05,F,rep,PA,Democrat +Daniel Meuser,1964-02-10,M,rep,PA,Republican +John Joyce,1957-02-08,M,rep,PA,Republican +Guy Reschenthaler,1983-04-17,M,rep,PA,Republican +William R. Timmons IV,1984-04-30,M,rep,SC,Republican +Dusty Johnson,1976-09-30,M,rep,SD,Republican +Tim Burchett,1964-08-25,M,rep,TN,Republican +John W. Rose,1965-02-23,M,rep,TN,Republican +Mark E. Green,1964-11-08,M,rep,TN,Republican +Dan Crenshaw,1984-03-14,M,rep,TX,Republican +Lance Gooden,1982-12-01,M,rep,TX,Republican +Lizzie Fletcher,1975-02-13,F,rep,TX,Democrat +Veronica Escobar,1969-09-15,F,rep,TX,Democrat +Chip Roy,1972-08-07,M,rep,TX,Republican +Sylvia R. Garcia,1950-09-06,F,rep,TX,Democrat +Colin Z. Allred,1983-04-15,M,rep,TX,Democrat +Ben Cline,1972-02-29,M,rep,VA,Republican +Abigail Davis Spanberger,1979-08-07,F,rep,VA,Democrat +Jennifer Wexton,1968-05-27,F,rep,VA,Democrat +Kim Schrier,1968-08-23,F,rep,WA,Democrat +Bryan Steil,1981-03-30,M,rep,WI,Republican +Carol D. Miller,1950-11-04,F,rep,WV,Republican +Rick Scott,1952-12-01,M,sen,FL,Republican +Mike Braun,1954-03-24,M,sen,IN,Republican +Josh Hawley,1979-12-31,M,sen,MO,Republican +Mitt Romney,1947-03-12,M,sen,UT,Republican +Jared F. Golden,1982-07-25,M,rep,ME,Democrat +Dan Bishop,1964-07-01,M,rep,NC,Republican +Gregory F. Murphy,1963-03-05,M,rep,NC,Republican +Kweisi Mfume,1948-10-24,M,rep,MD,Democrat +Thomas P. Tiffany,1957-12-30,M,rep,WI,Republican +Mike Garcia,1976-04-24,M,rep,CA,Republican +Mark Kelly,1964-02-21,M,sen,AZ,Democrat +Cynthia M. Lummis,1954-09-10,F,sen,WY,Republican +Darrell Issa,1953-11-01,M,rep,CA,Republican +Pete Sessions,1955-03-22,M,rep,TX,Republican +David G. Valadao,1977-04-14,M,rep,CA,Republican +Tommy Tuberville,1954-09-18,M,sen,AL,Republican +John W. Hickenlooper,1952-02-07,M,sen,CO,Democrat +Bill Hagerty,1959-08-14,M,sen,TN,Republican +Jerry L. Carl,1958-06-17,M,rep,AL,Republican +Barry Moore,1966-09-26,M,rep,AL,Republican +Jay Obernolte,1970-08-18,M,rep,CA,Republican +Young Kim,1962-10-18,F,rep,CA,Republican +Michelle Steel,1955-06-21,F,rep,CA,Republican +Sara Jacobs,1989-02-01,F,rep,CA,Democrat +Lauren Boebert,1986-12-15,F,rep,CO,Republican +Kat Cammack,1988-02-16,F,rep,FL,Republican +C. Scott Franklin,1964-08-23,M,rep,FL,Republican +Byron Donalds,1978-10-28,M,rep,FL,Republican +Carlos A. Gimenez,1954-01-17,M,rep,FL,Republican +Maria Elvira Salazar,1961-11-01,F,rep,FL,Republican +Nikema Williams,1978-07-30,F,rep,GA,Democrat +Andrew S. Clyde,1963-11-22,M,rep,GA,Republican +Marjorie Taylor Greene,1974-05-27,F,rep,GA,Republican +Ashley Hinson,1983-06-27,F,rep,IA,Republican +Mariannette Miller-Meeks,1955-09-06,F,rep,IA,Republican +Randy Feenstra,1969-01-14,M,rep,IA,Republican +Mary E. Miller,1959-08-27,F,rep,IL,Republican +Frank J. Mrvan,1969-04-16,M,rep,IN,Democrat +Victoria Spartz,1978-10-06,F,rep,IN,Republican +Tracey Mann,1976-12-17,M,rep,KS,Republican +Jake LaTurner,1988-02-17,M,rep,KS,Republican +Jake Auchincloss,1988-01-29,M,rep,MA,Democrat +Lisa C. McClain,1966-04-07,F,rep,MI,Republican +Michelle Fischbach,1965-11-03,F,rep,MN,Republican +Cori Bush,1976-07-21,F,rep,MO,Democrat +"Matthew M. Rosendale, Sr.",1960-07-07,M,rep,MT,Republican +Deborah K. Ross,1963-06-20,F,rep,NC,Democrat +Kathy E. Manning,1956-12-03,F,rep,NC,Democrat +Teresa Leger Fernandez,1959-07-01,F,rep,NM,Democrat +Andrew R. Garbarino,1984-09-27,M,rep,NY,Republican +Nicole Malliotakis,1980-11-11,F,rep,NY,Republican +Ritchie Torres,1988-03-12,M,rep,NY,Democrat +Jamaal Bowman,1976-04-01,M,rep,NY,Democrat +Stephanie I. Bice,1973-11-11,F,rep,OK,Republican +Cliff Bentz,1952-01-12,M,rep,OR,Republican +Nancy Mace,1977-12-04,F,rep,SC,Republican +Diana Harshbarger,1960-01-01,F,rep,TN,Republican +Pat Fallon,1967-12-19,M,rep,TX,Republican +August Pfluger,1978-12-28,M,rep,TX,Republican +Ronny Jackson,1967-05-04,M,rep,TX,Republican +Troy E. Nehls,1968-04-07,M,rep,TX,Republican +Tony Gonzales,1980-10-10,M,rep,TX,Republican +Beth Van Duyne,1970-11-16,F,rep,TX,Republican +Blake D. Moore,1980-06-22,M,rep,UT,Republican +Burgess Owens,1951-08-02,M,rep,UT,Republican +Bob Good,1965-09-11,M,rep,VA,Republican +Marilyn Strickland,1962-09-25,F,rep,WA,Democrat +Scott Fitzgerald,1963-11-16,M,rep,WI,Republican +Alex Padilla,1973-03-22,M,sen,CA,Democrat +Jon Ossoff,1987-02-16,M,sen,GA,Democrat +Raphael G. Warnock,1969-07-23,M,sen,GA,Democrat +Claudia Tenney,1961-02-04,F,rep,NY,Republican +Julia Letlow,1981-03-16,F,rep,LA,Republican +Troy A. Carter,1963-10-26,M,rep,LA,Democrat +Melanie A. Stansbury,1979-01-31,F,rep,NM,Democrat +Jake Ellzey,1970-01-24,M,rep,TX,Republican +Shontel M. Brown,1975-06-24,F,rep,OH,Democrat +Mike Carey,1971-03-13,M,rep,OH,Republican +Sheila Cherfilus-McCormick,1979-01-25,F,rep,FL,Democrat +Mike Flood,1975-02-23,M,rep,NE,Republican +Brad Finstad,1976-05-30,M,rep,MN,Republican +Mary Sattler Peltola,1973-08-31,F,rep,AK,Democrat +Patrick Ryan,1982-03-28,M,rep,NY,Democrat +Rudy Yakym III,1984-02-24,M,rep,IN,Republican +Ryan K. Zinke,1961-11-01,M,rep,MT,Republican +Katie Boyd Britt,1982-02-02,F,sen,AL,Republican +Eric Schmitt,1975-06-20,M,sen,MO,Republican +J.D. Vance,1984-08-02,M,sen,OH,Republican +John Fetterman,1969-08-15,M,sen,PA,Democrat +Dale W. Strong,1970-05-08,M,rep,AL,Republican +Elijah Crane,1980-01-03,M,rep,AZ,Republican +Juan Ciscomani,1982-08-30,M,rep,AZ,Republican +Kevin Kiley,1985-01-30,M,rep,CA,Republican +John S. Duarte,1966-09-06,M,rep,CA,Republican +Kevin Mullin,1970-06-15,M,rep,CA,Democrat +Sydney Kamlager-Dove,1972-07-20,F,rep,CA,Democrat +Robert Garcia,1977-12-02,M,rep,CA,Democrat +Brittany Pettersen,1981-12-06,F,rep,CO,Democrat +Yadira Caraveo,1980-12-23,F,rep,CO,Democrat +Aaron Bean,1967-01-25,M,rep,FL,Republican +Cory Mills,1980-07-30,M,rep,FL,Republican +Maxwell Frost,1997-01-17,M,rep,FL,Democrat +Anna Paulina Luna,1989-05-06,F,rep,FL,Republican +Laurel M. Lee,1974-03-26,F,rep,FL,Republican +Jared Moskowitz,1980-12-18,M,rep,FL,Democrat +Richard McCormick,1968-10-07,M,rep,GA,Republican +Mike Collins,1967-07-02,M,rep,GA,Republican +James C. Moylan,1962-07-18,M,rep,GU,Republican +Jill N. Tokuda,1976-03-28,F,rep,HI,Democrat +Zachary Nunn,1979-05-04,M,rep,IA,Republican +Jonathan L. Jackson,1966-01-07,M,rep,IL,Democrat +Delia C. Ramirez,1983-06-02,F,rep,IL,Democrat +Nikki Budzinski,1977-03-11,F,rep,IL,Democrat +Eric Sorensen,1976-03-18,M,rep,IL,Democrat +Erin Houchin,1976-09-24,F,rep,IN,Republican +Morgan McGarvey,1979-12-23,M,rep,KY,Democrat +Glenn Ivey,1961-02-27,M,rep,MD,Democrat +Hillary J. Scholten,1982-02-22,F,rep,MI,Democrat +John James,1981-06-08,M,rep,MI,Republican +Shri Thanedar,1955-02-22,M,rep,MI,Democrat +Mark Alford,1963-10-04,M,rep,MO,Republican +Eric Burlison,1976-10-02,M,rep,MO,Republican +Mike Ezell,1959-04-06,M,rep,MS,Republican +Donald G. Davis,1971-08-29,M,rep,NC,Democrat +Valerie P. Foushee,1956-05-07,F,rep,NC,Democrat +Chuck Edwards,1960-09-13,M,rep,NC,Republican +Wiley Nickel,1975-11-23,M,rep,NC,Democrat +Jeff Jackson,1982-09-12,M,rep,NC,Democrat +"Thomas H. Kean, Jr.",1968-09-05,M,rep,NJ,Republican +Robert Menendez,1985-07-12,M,rep,NJ,Democrat +Gabe Vasquez,1984-08-03,M,rep,NM,Democrat +Nick LaLota,1978-06-23,M,rep,NY,Republican +George Santos,1988-07-22,M,rep,NY,Republican +Anthony D’Esposito,1982-02-22,M,rep,NY,Republican +Daniel S. Goldman,1976-02-26,M,rep,NY,Democrat +Michael Lawler,1986-09-09,M,rep,NY,Republican +Marcus J. Molinaro,1975-10-08,M,rep,NY,Republican +Brandon Williams,1967-05-22,M,rep,NY,Republican +Nicholas A. Langworthy,1981-02-27,M,rep,NY,Republican +Greg Landsman,1976-12-04,M,rep,OH,Democrat +Max L. Miller,1988-11-13,M,rep,OH,Republican +Emilia Strong Sykes,1986-01-04,F,rep,OH,Democrat +Josh Brecheen,1979-06-19,M,rep,OK,Republican +Val T. Hoyle,1964-02-14,F,rep,OR,Democrat +Lori Chavez-DeRemer,1968-04-07,F,rep,OR,Republican +Andrea Salinas,1969-12-06,F,rep,OR,Democrat +Summer L. Lee,1987-11-26,F,rep,PA,Democrat +Christopher R. Deluzio,1984-07-13,M,rep,PA,Democrat +Seth Magaziner,1983-07-22,M,rep,RI,Democrat +Russell Fry,1985-01-31,M,rep,SC,Republican +Andrew Ogles,1971-06-18,M,rep,TN,Republican +Nathaniel Moran,1974-07-22,M,rep,TX,Republican +Keith Self,1953-03-20,M,rep,TX,Republican +Morgan Luttrell,1975-11-07,M,rep,TX,Republican +Monica De La Cruz,1974-11-11,F,rep,TX,Republican +Jasmine Crockett,1981-03-29,F,rep,TX,Democrat +Greg Casar,1989-05-04,M,rep,TX,Democrat +Wesley Hunt,1981-11-13,M,rep,TX,Republican +Jennifer Kiggans,1971-06-18,F,rep,VA,Republican +Becca Balint,1968-05-04,F,rep,VT,Democrat +Marie Gluesenkamp Perez,1988-06-06,F,rep,WA,Democrat +Derrick Van Orden,1969-09-15,M,rep,WI,Republican +Harriet M. Hageman,1962-10-18,F,rep,WY,Republican +Pete Ricketts,1964-08-19,M,sen,NE,Republican +Jennifer L. McClellan,1972-12-28,F,rep,VA,Democrat \ No newline at end of file diff --git a/public/style.less b/public/style.less new file mode 100644 index 000000000..b196f3d31 --- /dev/null +++ b/public/style.less @@ -0,0 +1,61 @@ +// 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; + background-color: var(--main-bg-color); + color: var(--main-text-color); + font: var(--base-font); + overflow: hidden; +} + +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +*::-webkit-scrollbar-track { + background-color: var(--scrollbar-background-color) !important; +} + +*::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb-color) !important; + border-radius: 4px; + margin: 0 1px 0 1px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover-color) !important; +} + +.flex-spacer { + flex-grow: 1; +} + +.text-fixed { + font: var(--fixed-font); +} + +#main, +.mainapp { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.titlebar { + height: 35px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.error-boundary { + color: var(--error-color); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..d782c63c1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "include": ["frontend/**/*", "emain/**/*"], + "compilerOptions": { + "target": "es6", + "module": "es2020", + "jsx": "preserve", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "experimentalDecorators": true, + "downlevelIteration": true, + "baseUrl": "./", + "paths": { + "@/app/*": ["frontend/app/*"], + "@/util/*": ["frontend/util/*"], + "@/layout/*": ["frontend/layout/*"], + "@/store/*": ["frontend/app/store/*"], + "@/view/*": ["frontend/app/view/*"], + "@/element/*": ["frontend/app/element/*"], + "@/bindings/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/service/*"], + "@/gopkg/*": ["frontend/bindings/github.com/wavetermdev/thenextwave/pkg/*"] + }, + "types": ["vite/client", "vite-plugin-svgr/client"] + } +} diff --git a/version.cjs b/version.cjs new file mode 100644 index 000000000..fca8c93e0 --- /dev/null +++ b/version.cjs @@ -0,0 +1,57 @@ +/** + * Script to get the current package version and bump the version, if specified. + * + * If no arguments are present, the current version will 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. + */ + +const path = require("path"); +const packageJsonPath = path.resolve(__dirname, "package.json"); +const packageJson = require(packageJsonPath); + +const VERSION = `${packageJson.version}`; +module.exports = VERSION; + +if (typeof require !== "undefined" && require.main === module) { + if (process.argv.length > 2) { + const fs = require("fs"); + const semver = require("semver"); + + const action = process.argv[2]; + const isPrerelease = + process.argv.length > 3 + ? process.argv[3] === "true" || process.argv[3] === "1" + : action === "true" || action === "1"; + let newVersion = packageJson.version; + switch (action) { + case "major": + case "minor": + case "patch": + newVersion = semver.inc( + VERSION, + `${isPrerelease ? "pre" : ""}${action}`, + null, + isPrerelease ? "beta" : null + ); + break; + case "none": + case "true": + case "1": + if (isPrerelease) newVersion = semver.inc(VERSION, "prerelease", null, "beta"); + break; + default: + throw new Error(`Unknown action ${action}`); + } + packageJson.version = newVersion; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4) + "\n"); + console.log(newVersion); + } else { + console.log(VERSION); + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..c2f9911ad --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { UserConfig, defineConfig, mergeConfig } from "vitest/config"; +import electronViteConfig from "./electron.vite.config"; + +export default mergeConfig( + electronViteConfig.renderer as UserConfig, + defineConfig({ + test: { + reporters: ["verbose", "junit"], + outputFile: { + junit: "test-results.xml", + }, + coverage: { + provider: "istanbul", + reporter: ["lcov"], + reportsDirectory: "./coverage", + }, + typecheck: { + tsconfig: "tsconfig.json", + }, + }, + }) +); diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..9bc8a1fbe --- /dev/null +++ b/yarn.lock @@ -0,0 +1,11326 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"7zip-bin@npm:~5.2.0": + version: 5.2.0 + resolution: "7zip-bin@npm:5.2.0" + checksum: 10c0/7f6c69b4cb10c4060fb8fda258ae2ab88d30516b5a52941efa0e2cbd9ce362ab7d8d568549cd85e9f125c1c68f95c7bb076cc314c2f3c0cb306d3b638080c2ce + languageName: node + linkType: hard + +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.0 + resolution: "@adobe/css-tools@npm:4.4.0" + checksum: 10c0/d65ddc719389bf469097df80fb16a8af48a973dea4b57565789d70ac8e7ab4987e6dc0095da3ed5dc16c1b6f8960214a7590312eeda8abd543d91fd0f59e6c94 + languageName: node + linkType: hard + +"@ampproject/remapping@npm:^2.2.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/code-frame@npm:7.24.7" + dependencies: + "@babel/highlight": "npm:^7.24.7" + picocolors: "npm:^1.0.0" + checksum: 10c0/ab0af539473a9f5aeaac7047e377cb4f4edd255a81d84a76058595f8540784cc3fbe8acf73f1e073981104562490aabfb23008cd66dc677a456a4ed5390fdde6 + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.25.2": + version: 7.25.4 + resolution: "@babel/compat-data@npm:7.25.4" + checksum: 10c0/50d79734d584a28c69d6f5b99adfaa064d0f41609a378aef04eb06accc5b44f8520e68549eba3a082478180957b7d5783f1bfb1672e4ae8574e797ce8bae79fa + languageName: node + linkType: hard + +"@babel/core@npm:^7.18.9, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.7": + version: 7.25.2 + resolution: "@babel/core@npm:7.25.2" + dependencies: + "@ampproject/remapping": "npm:^2.2.0" + "@babel/code-frame": "npm:^7.24.7" + "@babel/generator": "npm:^7.25.0" + "@babel/helper-compilation-targets": "npm:^7.25.2" + "@babel/helper-module-transforms": "npm:^7.25.2" + "@babel/helpers": "npm:^7.25.0" + "@babel/parser": "npm:^7.25.0" + "@babel/template": "npm:^7.25.0" + "@babel/traverse": "npm:^7.25.2" + "@babel/types": "npm:^7.25.2" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/a425fa40e73cb72b6464063a57c478bc2de9dbcc19c280f1b55a3d88b35d572e87e8594e7d7b4880331addb6faef641bbeb701b91b41b8806cd4deae5d74f401 + languageName: node + linkType: hard + +"@babel/generator@npm:^7.25.0, @babel/generator@npm:^7.25.4": + version: 7.25.5 + resolution: "@babel/generator@npm:7.25.5" + dependencies: + "@babel/types": "npm:^7.25.4" + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + jsesc: "npm:^2.5.1" + checksum: 10c0/eb8af30c39476e4f4d6b953f355fcf092258291f78d65fb759b7d5e5e6fd521b5bfee64a4e2e4290279f0dcd25ccf8c49a61807828b99b5830d2b734506da1fd + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.25.2": + version: 7.25.2 + resolution: "@babel/helper-compilation-targets@npm:7.25.2" + dependencies: + "@babel/compat-data": "npm:^7.25.2" + "@babel/helper-validator-option": "npm:^7.24.8" + browserslist: "npm:^4.23.1" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/de10e986b5322c9f807350467dc845ec59df9e596a5926a3b5edbb4710d8e3b8009d4396690e70b88c3844fe8ec4042d61436dd4b92d1f5f75655cf43ab07e99 + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/helper-module-imports@npm:7.24.7" + dependencies: + "@babel/traverse": "npm:^7.24.7" + "@babel/types": "npm:^7.24.7" + checksum: 10c0/97c57db6c3eeaea31564286e328a9fb52b0313c5cfcc7eee4bc226aebcf0418ea5b6fe78673c0e4a774512ec6c86e309d0f326e99d2b37bfc16a25a032498af0 + languageName: node + linkType: hard + +"@babel/helper-module-transforms@npm:^7.25.2": + version: 7.25.2 + resolution: "@babel/helper-module-transforms@npm:7.25.2" + dependencies: + "@babel/helper-module-imports": "npm:^7.24.7" + "@babel/helper-simple-access": "npm:^7.24.7" + "@babel/helper-validator-identifier": "npm:^7.24.7" + "@babel/traverse": "npm:^7.25.2" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/adaa15970ace0aee5934b5a633789b5795b6229c6a9cf3e09a7e80aa33e478675eee807006a862aa9aa517935d81f88a6db8a9f5936e3a2a40ec75f8062bc329 + languageName: node + linkType: hard + +"@babel/helper-plugin-utils@npm:^7.24.7": + version: 7.24.8 + resolution: "@babel/helper-plugin-utils@npm:7.24.8" + checksum: 10c0/0376037f94a3bfe6b820a39f81220ac04f243eaee7193774b983e956c1750883ff236b30785795abbcda43fac3ece74750566830c2daa4d6e3870bb0dff34c2d + languageName: node + linkType: hard + +"@babel/helper-simple-access@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/helper-simple-access@npm:7.24.7" + dependencies: + "@babel/traverse": "npm:^7.24.7" + "@babel/types": "npm:^7.24.7" + checksum: 10c0/7230e419d59a85f93153415100a5faff23c133d7442c19e0cd070da1784d13cd29096ee6c5a5761065c44e8164f9f80e3a518c41a0256df39e38f7ad6744fed7 + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.24.8": + version: 7.24.8 + resolution: "@babel/helper-string-parser@npm:7.24.8" + checksum: 10c0/6361f72076c17fabf305e252bf6d580106429014b3ab3c1f5c4eb3e6d465536ea6b670cc0e9a637a77a9ad40454d3e41361a2909e70e305116a23d68ce094c08 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/helper-validator-identifier@npm:7.24.7" + checksum: 10c0/87ad608694c9477814093ed5b5c080c2e06d44cb1924ae8320474a74415241223cc2a725eea2640dd783ff1e3390e5f95eede978bc540e870053152e58f1d651 + languageName: node + linkType: hard + +"@babel/helper-validator-option@npm:^7.24.8": + version: 7.24.8 + resolution: "@babel/helper-validator-option@npm:7.24.8" + checksum: 10c0/73db93a34ae89201351288bee7623eed81a54000779462a986105b54ffe82069e764afd15171a428b82e7c7a9b5fec10b5d5603b216317a414062edf5c67a21f + languageName: node + linkType: hard + +"@babel/helpers@npm:^7.25.0": + version: 7.25.0 + resolution: "@babel/helpers@npm:7.25.0" + dependencies: + "@babel/template": "npm:^7.25.0" + "@babel/types": "npm:^7.25.0" + checksum: 10c0/b7fe007fc4194268abf70aa3810365085e290e6528dcb9fbbf7a765d43c74b6369ce0f99c5ccd2d44c413853099daa449c9a0123f0b212ac8d18643f2e8174b8 + languageName: node + linkType: hard + +"@babel/highlight@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/highlight@npm:7.24.7" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.24.7" + chalk: "npm:^2.4.2" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.0.0" + checksum: 10c0/674334c571d2bb9d1c89bdd87566383f59231e16bcdcf5bb7835babdf03c9ae585ca0887a7b25bdf78f303984af028df52831c7989fecebb5101cc132da9393a + languageName: node + linkType: hard + +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.0, @babel/parser@npm:^7.25.4": + version: 7.25.4 + resolution: "@babel/parser@npm:7.25.4" + dependencies: + "@babel/types": "npm:^7.25.4" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/bdada5662f15d1df11a7266ec3bc9bb769bf3637ecf3d051eafcfc8f576dcf5a3ac1007c5e059db4a1e1387db9ae9caad239fc4f79e4c2200930ed610e779993 + languageName: node + linkType: hard + +"@babel/plugin-transform-arrow-functions@npm:^7.24.7": + version: 7.24.7 + resolution: "@babel/plugin-transform-arrow-functions@npm:7.24.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.24.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/6ac05a54e5582f34ac6d5dc26499e227227ec1c7fa6fc8de1f3d40c275f140d3907f79bbbd49304da2d7008a5ecafb219d0b71d78ee3290ca22020d878041245 + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.9.2": + version: 7.25.4 + resolution: "@babel/runtime@npm:7.25.4" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10c0/33e937e685f0bfc2d40c219261e2e50d0df7381a6e7cbf56b770e0c5d77cb0c21bf4d97da566cf0164317ed7508e992082c7b6cce7aaa3b17da5794f93fbfb46 + languageName: node + linkType: hard + +"@babel/template@npm:^7.25.0": + version: 7.25.0 + resolution: "@babel/template@npm:7.25.0" + dependencies: + "@babel/code-frame": "npm:^7.24.7" + "@babel/parser": "npm:^7.25.0" + "@babel/types": "npm:^7.25.0" + checksum: 10c0/4e31afd873215744c016e02b04f43b9fa23205d6d0766fb2e93eb4091c60c1b88897936adb895fb04e3c23de98dfdcbe31bc98daaa1a4e0133f78bb948e1209b + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.24.7, @babel/traverse@npm:^7.25.2": + version: 7.25.4 + resolution: "@babel/traverse@npm:7.25.4" + dependencies: + "@babel/code-frame": "npm:^7.24.7" + "@babel/generator": "npm:^7.25.4" + "@babel/parser": "npm:^7.25.4" + "@babel/template": "npm:^7.25.0" + "@babel/types": "npm:^7.25.4" + debug: "npm:^4.3.1" + globals: "npm:^11.1.0" + checksum: 10c0/37c9b49b277e051fe499ef5f6f217370c4f648d6370564d70b5e6beb2da75bfda6d7dab1d39504d89e9245448f8959bc1a5880d2238840cdc3979b35338ed0f5 + languageName: node + linkType: hard + +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.24.0, @babel/types@npm:^7.24.7, @babel/types@npm:^7.25.0, @babel/types@npm:^7.25.2, @babel/types@npm:^7.25.4": + version: 7.25.4 + resolution: "@babel/types@npm:7.25.4" + dependencies: + "@babel/helper-string-parser": "npm:^7.24.8" + "@babel/helper-validator-identifier": "npm:^7.24.7" + to-fast-properties: "npm:^2.0.0" + checksum: 10c0/9aa25dfcd89cc4e4dde3188091c34398a005a49e2c2b069d0367b41e1122c91e80fd92998c52a90f2fb500f7e897b6090ec8be263d9cb53d0d75c756f44419f2 + languageName: node + linkType: hard + +"@base2/pretty-print-object@npm:1.0.1": + version: 1.0.1 + resolution: "@base2/pretty-print-object@npm:1.0.1" + checksum: 10c0/98f77ea185a30c854897feb2a68fe51be8451a1a0b531bac61a5dd67033926a0ba0c9be6e0f819b8cb72ca349b3e7648bf81c12fd21df0b45219c75a3a75784b + languageName: node + linkType: hard + +"@chromatic-com/storybook@npm:^2.0.2": + version: 2.0.2 + resolution: "@chromatic-com/storybook@npm:2.0.2" + dependencies: + chromatic: "npm:^11.4.0" + filesize: "npm:^10.0.12" + jsonfile: "npm:^6.1.0" + react-confetti: "npm:^6.1.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/a997e8247168d9c30030966877836839951b6acd65899a4bb683d78d37e549a3285ca14a721893d75bef5b89e075d7090d084023c142680efaef60c9db64e7fa + languageName: node + linkType: hard + +"@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0": + version: 1.6.0 + resolution: "@colors/colors@npm:1.6.0" + checksum: 10c0/9328a0778a5b0db243af54455b79a69e3fb21122d6c15ef9e9fcc94881d8d17352d8b2b2590f9bdd46fac5c2d6c1636dcfc14358a20c70e22daf89e1a759b629 + languageName: node + linkType: hard + +"@cspotcode/source-map-support@npm:^0.8.0": + version: 0.8.1 + resolution: "@cspotcode/source-map-support@npm:0.8.1" + dependencies: + "@jridgewell/trace-mapping": "npm:0.3.9" + checksum: 10c0/05c5368c13b662ee4c122c7bfbe5dc0b613416672a829f3e78bc49a357a197e0218d6e74e7c66cfcd04e15a179acab080bd3c69658c9fbefd0e1ccd950a07fc6 + languageName: node + linkType: hard + +"@dabh/diagnostics@npm:^2.0.2": + version: 2.0.3 + resolution: "@dabh/diagnostics@npm:2.0.3" + dependencies: + colorspace: "npm:1.1.x" + enabled: "npm:2.0.x" + kuler: "npm:^2.0.0" + checksum: 10c0/a5133df8492802465ed01f2f0a5784585241a1030c362d54a602ed1839816d6c93d71dde05cf2ddb4fd0796238c19774406bd62fa2564b637907b495f52425fe + languageName: node + linkType: hard + +"@develar/schema-utils@npm:~2.6.5": + version: 2.6.5 + resolution: "@develar/schema-utils@npm:2.6.5" + dependencies: + ajv: "npm:^6.12.0" + ajv-keywords: "npm:^3.4.1" + checksum: 10c0/7c6075ce6742dd5c89b3cebf81351ec1d73dafc7c3409748860e4f8262fb26ffe6d998c5baab4eca579cd436e7c6c12c615fe89819c19484a22d25b3e6825cb5 + languageName: node + linkType: hard + +"@electron/asar@npm:^3.2.7": + version: 3.2.10 + resolution: "@electron/asar@npm:3.2.10" + dependencies: + commander: "npm:^5.0.0" + glob: "npm:^7.1.6" + minimatch: "npm:^3.0.4" + bin: + asar: bin/asar.js + checksum: 10c0/5b334162ce40fbc2ad5c5f7649452f53c5dab3600bf5029ab4643e13dd288b1247378e9b4062a0fa9970609b1a2036fee9e9b59dbca781ee49e0e5dba7b1b402 + languageName: node + linkType: hard + +"@electron/get@npm:^2.0.0": + version: 2.0.3 + resolution: "@electron/get@npm:2.0.3" + dependencies: + debug: "npm:^4.1.1" + env-paths: "npm:^2.2.0" + fs-extra: "npm:^8.1.0" + global-agent: "npm:^3.0.0" + got: "npm:^11.8.5" + progress: "npm:^2.0.3" + semver: "npm:^6.2.0" + sumchecker: "npm:^3.0.1" + dependenciesMeta: + global-agent: + optional: true + checksum: 10c0/148957d531bac50c29541515f2483c3e5c9c6ba9f0269a5d536540d2b8d849188a89588f18901f3a84c2b4fd376d1e0c5ea2159eb2d17bda68558f57df19015e + languageName: node + linkType: hard + +"@electron/notarize@npm:2.3.2": + version: 2.3.2 + resolution: "@electron/notarize@npm:2.3.2" + dependencies: + debug: "npm:^4.1.1" + fs-extra: "npm:^9.0.1" + promise-retry: "npm:^2.0.1" + checksum: 10c0/539ed5cd264c3885fd3ca9c0b243144e3e2856d767de3999da1e3f94f0d79db57cbb08862b640270dfad0292bc5345cd7177db096da2061e28e15a6b85946b32 + languageName: node + linkType: hard + +"@electron/osx-sign@npm:1.3.1": + version: 1.3.1 + resolution: "@electron/osx-sign@npm:1.3.1" + dependencies: + compare-version: "npm:^0.1.2" + debug: "npm:^4.3.4" + fs-extra: "npm:^10.0.0" + isbinaryfile: "npm:^4.0.8" + minimist: "npm:^1.2.6" + plist: "npm:^3.0.5" + bin: + electron-osx-flat: bin/electron-osx-flat.js + electron-osx-sign: bin/electron-osx-sign.js + checksum: 10c0/207be0df4ad4d76b0041de97d12b8d8793f3a5ddaff28e73c34a9b1939c83b3224191c7aae3c95d62eeb4a9146204c1db24577f43f91f6fab26783784856e49b + languageName: node + linkType: hard + +"@electron/rebuild@npm:3.6.0": + version: 3.6.0 + resolution: "@electron/rebuild@npm:3.6.0" + dependencies: + "@malept/cross-spawn-promise": "npm:^2.0.0" + chalk: "npm:^4.0.0" + debug: "npm:^4.1.1" + detect-libc: "npm:^2.0.1" + fs-extra: "npm:^10.0.0" + got: "npm:^11.7.0" + node-abi: "npm:^3.45.0" + node-api-version: "npm:^0.2.0" + node-gyp: "npm:^9.0.0" + ora: "npm:^5.1.0" + read-binary-file-arch: "npm:^1.0.6" + semver: "npm:^7.3.5" + tar: "npm:^6.0.5" + yargs: "npm:^17.0.1" + bin: + electron-rebuild: lib/cli.js + checksum: 10c0/3af22cc2e048f27d7055b85b50d5c5de4312f9cd5d72bc7ba96de67f735035acf813f9f39faf3781404759b82c5e5a43e9d1d2695d99c0ade9e0f339c32feff2 + languageName: node + linkType: hard + +"@electron/universal@npm:2.0.1": + version: 2.0.1 + resolution: "@electron/universal@npm:2.0.1" + dependencies: + "@electron/asar": "npm:^3.2.7" + "@malept/cross-spawn-promise": "npm:^2.0.0" + debug: "npm:^4.3.1" + dir-compare: "npm:^4.2.0" + fs-extra: "npm:^11.1.1" + minimatch: "npm:^9.0.3" + plist: "npm:^3.1.0" + checksum: 10c0/d3cd87184ee561e4fa4bddbd8a2f9f8db4b3f7c92fe108bcd3e06eef2dd6bdfc378eaf0758b85e8066b3f88f9dd9775d83b3ac9281b491017747786c5cce50a4 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/aix-ppc64@npm:0.21.5" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/aix-ppc64@npm:0.23.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm64@npm:0.21.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-arm64@npm:0.23.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-arm@npm:0.21.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-arm@npm:0.23.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/android-x64@npm:0.21.5" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-x64@npm:0.23.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-arm64@npm:0.21.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/darwin-arm64@npm:0.23.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/darwin-x64@npm:0.21.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/darwin-x64@npm:0.23.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-arm64@npm:0.21.5" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/freebsd-arm64@npm:0.23.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/freebsd-x64@npm:0.21.5" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/freebsd-x64@npm:0.23.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm64@npm:0.21.5" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-arm64@npm:0.23.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-arm@npm:0.21.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-arm@npm:0.23.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ia32@npm:0.21.5" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-ia32@npm:0.23.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-loong64@npm:0.21.5" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-loong64@npm:0.23.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-mips64el@npm:0.21.5" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-mips64el@npm:0.23.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-ppc64@npm:0.21.5" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-ppc64@npm:0.23.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-riscv64@npm:0.21.5" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-riscv64@npm:0.23.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-s390x@npm:0.21.5" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-s390x@npm:0.23.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/linux-x64@npm:0.21.5" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-x64@npm:0.23.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/netbsd-x64@npm:0.21.5" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/netbsd-x64@npm:0.23.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/openbsd-arm64@npm:0.23.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/openbsd-x64@npm:0.21.5" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/openbsd-x64@npm:0.23.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/sunos-x64@npm:0.21.5" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/sunos-x64@npm:0.23.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-arm64@npm:0.21.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-arm64@npm:0.23.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-ia32@npm:0.21.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-ia32@npm:0.23.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.21.5": + version: 0.21.5 + resolution: "@esbuild/win32-x64@npm:0.21.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-x64@npm:0.23.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": + version: 4.4.0 + resolution: "@eslint-community/eslint-utils@npm:4.4.0" + dependencies: + eslint-visitor-keys: "npm:^3.3.0" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/7e559c4ce59cd3a06b1b5a517b593912e680a7f981ae7affab0d01d709e99cd5647019be8fafa38c350305bc32f1f7d42c7073edde2ab536c745e365f37b607e + languageName: node + linkType: hard + +"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.11.0": + version: 4.11.0 + resolution: "@eslint-community/regexpp@npm:4.11.0" + checksum: 10c0/0f6328869b2741e2794da4ad80beac55cba7de2d3b44f796a60955b0586212ec75e6b0253291fd4aad2100ad471d1480d8895f2b54f1605439ba4c875e05e523 + languageName: node + linkType: hard + +"@eslint/config-array@npm:^0.18.0": + version: 0.18.0 + resolution: "@eslint/config-array@npm:0.18.0" + dependencies: + "@eslint/object-schema": "npm:^2.1.4" + debug: "npm:^4.3.1" + minimatch: "npm:^3.1.2" + checksum: 10c0/0234aeb3e6b052ad2402a647d0b4f8a6aa71524bafe1adad0b8db1dfe94d7f5f26d67c80f79bb37ac61361a1d4b14bb8fb475efe501de37263cf55eabb79868f + languageName: node + linkType: hard + +"@eslint/eslintrc@npm:^3.1.0": + version: 3.1.0 + resolution: "@eslint/eslintrc@npm:3.1.0" + dependencies: + ajv: "npm:^6.12.4" + debug: "npm:^4.3.2" + espree: "npm:^10.0.1" + globals: "npm:^14.0.0" + ignore: "npm:^5.2.0" + import-fresh: "npm:^3.2.1" + js-yaml: "npm:^4.1.0" + minimatch: "npm:^3.1.2" + strip-json-comments: "npm:^3.1.1" + checksum: 10c0/5b7332ed781edcfc98caa8dedbbb843abfb9bda2e86538529c843473f580e40c69eb894410eddc6702f487e9ee8f8cfa8df83213d43a8fdb549f23ce06699167 + languageName: node + linkType: hard + +"@eslint/js@npm:9.10.0, @eslint/js@npm:^9.10.0": + version: 9.10.0 + resolution: "@eslint/js@npm:9.10.0" + checksum: 10c0/2ac45a002dc1ccf25be46ea61001ada8d77248d1313ab4e53f3735e5ae00738a757874e41f62ad6fbd49df7dffeece66e5f53ff0d7b78a99ce4c68e8fea66753 + languageName: node + linkType: hard + +"@eslint/object-schema@npm:^2.1.4": + version: 2.1.4 + resolution: "@eslint/object-schema@npm:2.1.4" + checksum: 10c0/e9885532ea70e483fb007bf1275968b05bb15ebaa506d98560c41a41220d33d342e19023d5f2939fed6eb59676c1bda5c847c284b4b55fce521d282004da4dda + languageName: node + linkType: hard + +"@eslint/plugin-kit@npm:^0.1.0": + version: 0.1.0 + resolution: "@eslint/plugin-kit@npm:0.1.0" + dependencies: + levn: "npm:^0.4.1" + checksum: 10c0/fae97cd4efc1c32501c286abba1b5409848ce8c989e1ca6a5bb057a304a2cd721e6e957f6bc35ce95cfd0871e822ed42df3c759fecdad72c30e70802e26f83c7 + languageName: node + linkType: hard + +"@gar/promisify@npm:^1.1.3": + version: 1.1.3 + resolution: "@gar/promisify@npm:1.1.3" + checksum: 10c0/0b3c9958d3cd17f4add3574975e3115ae05dc7f1298a60810414b16f6f558c137b5fb3cd3905df380bacfd955ec13f67c1e6710cbb5c246a7e8d65a8289b2bff + languageName: node + linkType: hard + +"@humanwhocodes/module-importer@npm:^1.0.1": + version: 1.0.1 + resolution: "@humanwhocodes/module-importer@npm:1.0.1" + checksum: 10c0/909b69c3b86d482c26b3359db16e46a32e0fb30bd306a3c176b8313b9e7313dba0f37f519de6aa8b0a1921349e505f259d19475e123182416a506d7f87e7f529 + languageName: node + linkType: hard + +"@humanwhocodes/retry@npm:^0.3.0": + version: 0.3.0 + resolution: "@humanwhocodes/retry@npm:0.3.0" + checksum: 10c0/7111ec4e098b1a428459b4e3be5a5d2a13b02905f805a2468f4fa628d072f0de2da26a27d04f65ea2846f73ba51f4204661709f05bfccff645e3cedef8781bb6 + languageName: node + linkType: hard + +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" + dependencies: + string-width: "npm:^5.1.2" + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: "npm:^7.0.1" + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: "npm:^8.1.0" + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: 10c0/b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e + languageName: node + linkType: hard + +"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a + languageName: node + linkType: hard + +"@joshwooding/vite-plugin-react-docgen-typescript@npm:0.3.1": + version: 0.3.1 + resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.3.1" + dependencies: + glob: "npm:^7.2.0" + glob-promise: "npm:^4.2.0" + magic-string: "npm:^0.27.0" + react-docgen-typescript: "npm:^2.2.2" + peerDependencies: + typescript: ">= 4.3.x" + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/a9c7a03d7d1daf5bd64949255516ba64c88d5600366c8c74dcdb6f37c2a6099daaec02860b7587d2220e61afa47a0b2de17ef70d723c2db02f24e0890edfd9f3 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.5 + resolution: "@jridgewell/gen-mapping@npm:0.3.5" + dependencies: + "@jridgewell/set-array": "npm:^1.2.1" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/set-array@npm:^1.2.1": + version: 1.2.1 + resolution: "@jridgewell/set-array@npm:1.2.1" + checksum: 10c0/2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.13, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" + checksum: 10c0/2eb864f276eb1096c3c11da3e9bb518f6d9fc0023c78344cdc037abadc725172c70314bdb360f2d4b7bffec7f5d657ce006816bc5d4ecb35e61b66132db00c18 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:0.3.9": + version: 0.3.9 + resolution: "@jridgewell/trace-mapping@npm:0.3.9" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.0.3" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + checksum: 10c0/fa425b606d7c7ee5bfa6a31a7b050dd5814b4082f318e0e4190f991902181b4330f43f4805db1dd4f2433fd0ed9cc7a7b9c2683f1deeab1df1b0a98b1e24055b + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": + version: 0.3.25 + resolution: "@jridgewell/trace-mapping@npm:0.3.25" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/3d1ce6ebc69df9682a5a8896b414c6537e428a1d68b02fcc8363b04284a8ca0df04d0ee3013132252ab14f2527bc13bea6526a912ecb5658f0e39fd2860b4df4 + languageName: node + linkType: hard + +"@malept/cross-spawn-promise@npm:^2.0.0": + version: 2.0.0 + resolution: "@malept/cross-spawn-promise@npm:2.0.0" + dependencies: + cross-spawn: "npm:^7.0.1" + checksum: 10c0/84d60b8d467f4252114849f0a33c3763f07898335269eec5c94978ccac9d5680e1e268d993dd1a6d25a91476f9e0992759d7e1f385f9f3a090d862f9bb949603 + languageName: node + linkType: hard + +"@malept/flatpak-bundler@npm:^0.4.0": + version: 0.4.0 + resolution: "@malept/flatpak-bundler@npm:0.4.0" + dependencies: + debug: "npm:^4.1.1" + fs-extra: "npm:^9.0.0" + lodash: "npm:^4.17.15" + tmp-promise: "npm:^3.0.2" + checksum: 10c0/b3c87f6482b1956411af1118c771afb39cd9a0568fbb5e86015547ff6d68d2e73a7f0d74b75a57f0a156391c347c8d0adc1037e75172b92da72b96e0a05a2f4f + languageName: node + linkType: hard + +"@mdx-js/react@npm:^3.0.0": + version: 3.0.1 + resolution: "@mdx-js/react@npm:3.0.1" + dependencies: + "@types/mdx": "npm:^2.0.0" + peerDependencies: + "@types/react": ">=16" + react: ">=16" + checksum: 10c0/d210d926ef488d39ad65f04d821936b668eadcdde3b6421e94ec4200ca7ad17f17d24c5cbc543882586af9f08b10e2eea715c728ce6277487945e05c5199f532 + languageName: node + linkType: hard + +"@monaco-editor/loader@npm:^1.4.0": + version: 1.4.0 + resolution: "@monaco-editor/loader@npm:1.4.0" + dependencies: + state-local: "npm:^1.0.6" + peerDependencies: + monaco-editor: ">= 0.21.0 < 1" + checksum: 10c0/68938350adf2f42363a801d87f5d00c87d397d4cba7041141af10a9216bd35c85209b4723a26d56cb32e68eef61471deda2a450f8892891118fbdce7fa1d987d + languageName: node + linkType: hard + +"@monaco-editor/react@npm:^4.6.0": + version: 4.6.0 + resolution: "@monaco-editor/react@npm:4.6.0" + dependencies: + "@monaco-editor/loader": "npm:^1.4.0" + peerDependencies: + monaco-editor: ">= 0.25.0 < 1" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/231e9a9b66a530db326f6732de0ebffcce6b79dcfaf4948923d78b9a3d5e2a04b7a06e1f85bbbca45a5ae15c107a124e4c5c46cabadc20a498fb5f2d05f7f379 + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": "npm:2.0.5" + run-parallel: "npm:^1.1.9" + checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3, @nodelib/fs.walk@npm:^1.2.8": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": "npm:2.1.5" + fastq: "npm:^1.6.0" + checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^2.0.0": + version: 2.2.2 + resolution: "@npmcli/agent@npm:2.2.2" + dependencies: + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^10.0.1" + socks-proxy-agent: "npm:^8.0.3" + checksum: 10c0/325e0db7b287d4154ecd164c0815c08007abfb07653cc57bceded17bb7fd240998a3cbdbe87d700e30bef494885eccc725ab73b668020811d56623d145b524ae + languageName: node + linkType: hard + +"@npmcli/fs@npm:^2.1.0": + version: 2.1.2 + resolution: "@npmcli/fs@npm:2.1.2" + dependencies: + "@gar/promisify": "npm:^1.1.3" + semver: "npm:^7.3.5" + checksum: 10c0/c50d087733d0d8df23be24f700f104b19922a28677aa66fdbe06ff6af6431cc4a5bb1e27683cbc661a5dafa9bafdc603e6a0378121506dfcd394b2b6dd76a187 + languageName: node + linkType: hard + +"@npmcli/fs@npm:^3.1.0": + version: 3.1.1 + resolution: "@npmcli/fs@npm:3.1.1" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/c37a5b4842bfdece3d14dfdb054f73fe15ed2d3da61b34ff76629fb5b1731647c49166fd2a8bf8b56fcfa51200382385ea8909a3cbecdad612310c114d3f6c99 + languageName: node + linkType: hard + +"@npmcli/move-file@npm:^2.0.0": + version: 2.0.1 + resolution: "@npmcli/move-file@npm:2.0.1" + dependencies: + mkdirp: "npm:^1.0.4" + rimraf: "npm:^3.0.2" + checksum: 10c0/11b2151e6d1de6f6eb23128de5aa8a429fd9097d839a5190cb77aa47a6b627022c42d50fa7c47a00f1c9f8f0c1560092b09b061855d293fa0741a2a94cfb174d + languageName: node + linkType: hard + +"@observablehq/plot@npm:^0.6.16": + version: 0.6.16 + resolution: "@observablehq/plot@npm:0.6.16" + dependencies: + d3: "npm:^7.9.0" + interval-tree-1d: "npm:^1.0.0" + isoformat: "npm:^0.2.0" + checksum: 10c0/e4a1f6b9b16fe0b17abc592e50de80ec1edbf3b8447ce3951068dc7e321fd2f5b8079e2fa169628bcbf605b2ca2ac5222991f3ff477b269bdae530ef6d700108 + languageName: node + linkType: hard + +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd + languageName: node + linkType: hard + +"@react-dnd/asap@npm:^5.0.1": + version: 5.0.2 + resolution: "@react-dnd/asap@npm:5.0.2" + checksum: 10c0/0063db616db9801c9be18f11a912c3e214f87e714b1e4bf9462952af7ead65cba0b43e1f7c34bc8748811b6926e74d929e5e126f85ebb91b870faf809ceb5177 + languageName: node + linkType: hard + +"@react-dnd/invariant@npm:^4.0.1": + version: 4.0.2 + resolution: "@react-dnd/invariant@npm:4.0.2" + checksum: 10c0/b303cc53fc5074cefb2a76b45b9c73ebb5d35630b18f5dc282ed9a9ac9b0287b9da1f6ac63acfdea2341b8f8187f615afc12d5eb14ec6015964f5c1b167332e2 + languageName: node + linkType: hard + +"@react-dnd/shallowequal@npm:^4.0.1": + version: 4.0.2 + resolution: "@react-dnd/shallowequal@npm:4.0.2" + checksum: 10c0/9a352fd176752e5d9c2797d598aca034b7829111ae0c992d80f40d5f068fcd6a039b1841c741dfa1ab67a36a00664310aec4f0ce216e4112f80875c9fe6fd8dc + languageName: node + linkType: hard + +"@react-hook/latest@npm:^1.0.2": + version: 1.0.3 + resolution: "@react-hook/latest@npm:1.0.3" + peerDependencies: + react: ">=16.8" + checksum: 10c0/d6a166c21121da519a516e8089ba28a2779d37b6017732ab55476c0d354754ad215394135765254f8752a7c6661c3fb868d088769a644848602f00f8821248ed + languageName: node + linkType: hard + +"@react-hook/passive-layout-effect@npm:^1.2.0": + version: 1.2.1 + resolution: "@react-hook/passive-layout-effect@npm:1.2.1" + peerDependencies: + react: ">=16.8" + checksum: 10c0/5c9e6b3df1c91fc2b1d4f711ca96b5f8cb3f6a13a2e97dac7cce623e58d7ee57999c45db3778d0af0b2522b3a5b7463232ef21cb3ee9900437172d48f766d933 + languageName: node + linkType: hard + +"@react-hook/resize-observer@npm:^2.0.2": + version: 2.0.2 + resolution: "@react-hook/resize-observer@npm:2.0.2" + dependencies: + "@react-hook/latest": "npm:^1.0.2" + "@react-hook/passive-layout-effect": "npm:^1.2.0" + peerDependencies: + react: ">=18" + checksum: 10c0/a88f088bd5b87fea80daca391bdea9823dc38651bd112f0c607b057d9c8381f37395cf5a01a79ba7ab24369c4b48acf912cfef0244e6610d72ea3074415e9c32 + languageName: node + linkType: hard + +"@rollup/plugin-node-resolve@npm:^15.2.3": + version: 15.2.3 + resolution: "@rollup/plugin-node-resolve@npm:15.2.3" + dependencies: + "@rollup/pluginutils": "npm:^5.0.1" + "@types/resolve": "npm:1.20.2" + deepmerge: "npm:^4.2.2" + is-builtin-module: "npm:^3.2.1" + is-module: "npm:^1.0.0" + resolve: "npm:^1.22.1" + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/598c15615086f26e28c4b3dbf966682af7fb0e5bc277cc4e57f559668a3be675a63ab261eb34729ce9569c3a51342c48863e50b5efe02e0fc1571828f0113f9d + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.0.5": + version: 5.1.0 + resolution: "@rollup/pluginutils@npm:5.1.0" + dependencies: + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^2.3.1" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/c7bed15711f942d6fdd3470fef4105b73991f99a478605e13d41888963330a6f9e32be37e6ddb13f012bc7673ff5e54f06f59fd47109436c1c513986a8a7612d + languageName: node + linkType: hard + +"@rollup/rollup-android-arm-eabi@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.21.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-android-arm64@npm:4.21.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.21.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.21.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.21.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.21.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.21.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.21.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-powerpc64le-gnu@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.21.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.21.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.21.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.21.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.21.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.21.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.21.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.21.0": + version: 4.21.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.21.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@sindresorhus/is@npm:^4.0.0": + version: 4.6.0 + resolution: "@sindresorhus/is@npm:4.6.0" + checksum: 10c0/33b6fb1d0834ec8dd7689ddc0e2781c2bfd8b9c4e4bacbcb14111e0ae00621f2c264b8a7d36541799d74888b5dccdf422a891a5cb5a709ace26325eedc81e22e + languageName: node + linkType: hard + +"@storybook/addon-actions@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-actions@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + "@types/uuid": "npm:^9.0.1" + dequal: "npm:^2.0.2" + polished: "npm:^4.2.2" + uuid: "npm:^9.0.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/4ce7215dba09b1fb1597fcdc08772eb54a50c6d5e1f2aa1d3b0a20d7252861bfe22265b1481c75638f7a471ef4d6e6b028d5456fb62d8e1ab1be8cba91256ac9 + languageName: node + linkType: hard + +"@storybook/addon-backgrounds@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-backgrounds@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + memoizerific: "npm:^1.11.3" + ts-dedent: "npm:^2.0.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/0b61985eef0293e5f416a574a496e412223d4562bb61069bc964a0cdb82d841619957117b94df20ab4f9c8bc137c6b469f01047a32390656f9481d351590732a + languageName: node + linkType: hard + +"@storybook/addon-controls@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-controls@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + ts-dedent: "npm:^2.0.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/6f11bfdce68a275a5b74db3c46f17fec270f7a27a6cd66337cbc03eb3922628d093564d4cf6c8dea2b5ee3864dfb2dc8a115cdbdb56c1d747145b8edead22893 + languageName: node + linkType: hard + +"@storybook/addon-docs@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-docs@npm:8.3.0" + dependencies: + "@mdx-js/react": "npm:^3.0.0" + "@storybook/blocks": "npm:8.3.0" + "@storybook/csf-plugin": "npm:8.3.0" + "@storybook/global": "npm:^5.0.0" + "@storybook/react-dom-shim": "npm:8.3.0" + "@types/react": "npm:^16.8.0 || ^17.0.0 || ^18.0.0" + fs-extra: "npm:^11.1.0" + react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0" + react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0" + rehype-external-links: "npm:^3.0.0" + rehype-slug: "npm:^6.0.0" + ts-dedent: "npm:^2.0.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/ae5fb917b3244847b0cfbeda447bb21125ee49dbc704aa95ade9834bcfb6d90f9855ca2b2daca68c02b60c2070b17c3f22a5f355ef6392ec462d28ffd8f43e97 + languageName: node + linkType: hard + +"@storybook/addon-essentials@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-essentials@npm:8.3.0" + dependencies: + "@storybook/addon-actions": "npm:8.3.0" + "@storybook/addon-backgrounds": "npm:8.3.0" + "@storybook/addon-controls": "npm:8.3.0" + "@storybook/addon-docs": "npm:8.3.0" + "@storybook/addon-highlight": "npm:8.3.0" + "@storybook/addon-measure": "npm:8.3.0" + "@storybook/addon-outline": "npm:8.3.0" + "@storybook/addon-toolbars": "npm:8.3.0" + "@storybook/addon-viewport": "npm:8.3.0" + ts-dedent: "npm:^2.0.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/5f3d193f1a337a7672870b45aedbf2452de221b1c04714323a64c40b8cc2dfdcb94f482252ad105e9170c5fbc88601d6e8413b4add0b1073b794aced913985e1 + languageName: node + linkType: hard + +"@storybook/addon-highlight@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-highlight@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/3596e58939525bfbed86efffc211f857ff1114a46010e9145e716ee6077519b86a821f158964cb090461e655768b07fb467f0a65a0809c44cfa346245583f085 + languageName: node + linkType: hard + +"@storybook/addon-interactions@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-interactions@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + "@storybook/instrumenter": "npm:8.3.0" + "@storybook/test": "npm:8.3.0" + polished: "npm:^4.2.2" + ts-dedent: "npm:^2.2.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/d59faaf8f16e3fb73f3a5ad1f7528d791d31915ccb3d911dcc5dc0cbdf8d02cfb5d7c0e3208919b1fcf28e26ac968c7103181a8b9c9ce0095ff9fca93379cb3e + languageName: node + linkType: hard + +"@storybook/addon-links@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-links@npm:8.3.0" + dependencies: + "@storybook/csf": "npm:^0.1.11" + "@storybook/global": "npm:^5.0.0" + ts-dedent: "npm:^2.0.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.0 + peerDependenciesMeta: + react: + optional: true + checksum: 10c0/65478fd38bb136813c6b75427312eaa0f06cca4be2796f750ae961695d9ecdebefbe200ee534f3c26fd8fa9b8c40292339b22591815cbae922c7476a734aa2ca + languageName: node + linkType: hard + +"@storybook/addon-measure@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-measure@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + tiny-invariant: "npm:^1.3.1" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/adb3256e147dc9dad5990b13cacd5c42ab7ac165b9644c1196b32b6501869131c8a1e0326d1194c1e3ba8f2df781410589566337e6ffbec9b86a1642b5c43428 + languageName: node + linkType: hard + +"@storybook/addon-outline@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-outline@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + ts-dedent: "npm:^2.0.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/a9a3812306ef8c79e84854b33cd1685d3a18e470f165ce3ebacd7fa3898a062f1f234ae6a0f6ef5616992a198e5b1fa1aec96cd60e11a9cb8909977352ce46c9 + languageName: node + linkType: hard + +"@storybook/addon-toolbars@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-toolbars@npm:8.3.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/76ba61d0db98062c3a82cf74db735844971454857c0f518214ffbeae42c995ebbbf616987185a1dec13bf20fbc723e5adaf2a79510cea403657dd483eace83a5 + languageName: node + linkType: hard + +"@storybook/addon-viewport@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/addon-viewport@npm:8.3.0" + dependencies: + memoizerific: "npm:^1.11.3" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/6af74ffa83e707cf19db201467c0170109c7ff87c59b5431bdcb22ae4a49bfd28284f6fc81aad810f87cde6dc33da8a3dbe8155b268b9a19e735e2fc0d6db245 + languageName: node + linkType: hard + +"@storybook/blocks@npm:8.3.0, @storybook/blocks@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/blocks@npm:8.3.0" + dependencies: + "@storybook/csf": "npm:^0.1.11" + "@storybook/global": "npm:^5.0.0" + "@storybook/icons": "npm:^1.2.10" + "@types/lodash": "npm:^4.14.167" + color-convert: "npm:^2.0.1" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + markdown-to-jsx: "npm:^7.4.5" + memoizerific: "npm:^1.11.3" + polished: "npm:^4.2.2" + react-colorful: "npm:^5.1.2" + telejson: "npm:^7.2.0" + ts-dedent: "npm:^2.0.0" + util-deprecate: "npm:^1.0.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + checksum: 10c0/ed624593ff2313bbe3e6e3b0018d1305b79e87d35aaa89173f7cba655a7917317b3a722e18677d31c5c7b47caea3cbb423d6cc65ff3f84deac941bddb476939f + languageName: node + linkType: hard + +"@storybook/builder-vite@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/builder-vite@npm:8.3.0" + dependencies: + "@storybook/csf-plugin": "npm:8.3.0" + "@types/find-cache-dir": "npm:^3.2.1" + browser-assert: "npm:^1.2.1" + es-module-lexer: "npm:^1.5.0" + express: "npm:^4.19.2" + find-cache-dir: "npm:^3.0.0" + fs-extra: "npm:^11.1.0" + magic-string: "npm:^0.30.0" + ts-dedent: "npm:^2.0.0" + peerDependencies: + "@preact/preset-vite": "*" + storybook: ^8.3.0 + typescript: ">= 4.3.x" + vite: ^4.0.0 || ^5.0.0 + vite-plugin-glimmerx: "*" + peerDependenciesMeta: + "@preact/preset-vite": + optional: true + typescript: + optional: true + vite-plugin-glimmerx: + optional: true + checksum: 10c0/0307065f883bae8d62834308b2dec9d5cfa0fc48c0e3b84630514894751a5ed14834d676c67d509cc5bbcba089935939105850ff73f47045ef275b102fb4e508 + languageName: node + linkType: hard + +"@storybook/components@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/components@npm:8.3.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/2ee1e18f0f24bf9c22c66f2db51bb1076ad3ad20c404c01ca288a49f6dd4b2209fc22ea48b6e9ed45edf4ce7373150197d91e7acc98eed813ce0d52ba30d3d58 + languageName: node + linkType: hard + +"@storybook/core@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/core@npm:8.3.0" + dependencies: + "@storybook/csf": "npm:^0.1.11" + "@types/express": "npm:^4.17.21" + browser-assert: "npm:^1.2.1" + esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0" + esbuild-register: "npm:^3.5.0" + express: "npm:^4.19.2" + process: "npm:^0.11.10" + recast: "npm:^0.23.5" + semver: "npm:^7.6.2" + util: "npm:^0.12.5" + ws: "npm:^8.2.3" + checksum: 10c0/514efe6393c06b927839072367749633c3317b19294a76e2046dbbc116e5aadffc505a19e10aac1e91450ceee3a58066a9d6205ad192aa47d719916289437561 + languageName: node + linkType: hard + +"@storybook/csf-plugin@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/csf-plugin@npm:8.3.0" + dependencies: + unplugin: "npm:^1.3.1" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/7ba0bffd08bfc999538435628c144caab2007259b8db8e3ba9dd4f080f8b9800a7a30cea3bc4cdee56926d2dac98be2f9970f01368a3d19ce2968ebc7f748fd4 + languageName: node + linkType: hard + +"@storybook/csf@npm:^0.1.11": + version: 0.1.11 + resolution: "@storybook/csf@npm:0.1.11" + dependencies: + type-fest: "npm:^2.19.0" + checksum: 10c0/c5329fc13e7d762049b5c91df1bc1c0e510a1a898c401b72b68f1ff64139a85ab64a92f8e681d2fcb226c0a4a55d0f23b569b2bdb517e0f067bd05ea46228356 + languageName: node + linkType: hard + +"@storybook/global@npm:^5.0.0": + version: 5.0.0 + resolution: "@storybook/global@npm:5.0.0" + checksum: 10c0/8f1b61dcdd3a89584540896e659af2ecc700bc740c16909a7be24ac19127ea213324de144a141f7caf8affaed017d064fea0618d453afbe027cf60f54b4a6d0b + languageName: node + linkType: hard + +"@storybook/icons@npm:^1.2.10": + version: 1.2.10 + resolution: "@storybook/icons@npm:1.2.10" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/aadde2efd5c471b78096f29a6393db111ee95174cab94ade0d2859d476262f080aa8ffb414f82932afd81d5c57bed813193a04e92086962bde2224774dac9060 + languageName: node + linkType: hard + +"@storybook/instrumenter@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/instrumenter@npm:8.3.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + "@vitest/utils": "npm:^2.0.5" + util: "npm:^0.12.4" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/a6cebf5c5e367c646e687930aba92b0a4425f58c7cec9fb59dec7f1d8262e929beeeaf3d08261d4c94d515732ae0e4f88db5f856d34ed33398911b7e84ca407c + languageName: node + linkType: hard + +"@storybook/manager-api@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/manager-api@npm:8.3.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/d08b9f4428f3e909bd6c002fd734d189aacc3bc04525e050251ca3f8935ba7012f66ab226c7099b959f159c1f08b98103a21152371c0c2b34f330f67cf2c25b8 + languageName: node + linkType: hard + +"@storybook/preview-api@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/preview-api@npm:8.3.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/930fe08555d4226d95ac600e2439e2213ee3feb9c036e0a9aebfe80b2814e3c3634d24211515ae4416daf6413c2fd9f19e69bcf0e114dc7bc62b710fdf7686ae + languageName: node + linkType: hard + +"@storybook/react-dom-shim@npm:8.3.0": + version: 8.3.0 + resolution: "@storybook/react-dom-shim@npm:8.3.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.0 + checksum: 10c0/102ef1b9133f42e94b2161208fd035ad80ac2a66728fb90c758adb6ba77cd281a002e06c60ee009b751d1007c763fd45bb71f4cc88ac6b5809615fe9ca928dc0 + languageName: node + linkType: hard + +"@storybook/react-vite@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/react-vite@npm:8.3.0" + dependencies: + "@joshwooding/vite-plugin-react-docgen-typescript": "npm:0.3.1" + "@rollup/pluginutils": "npm:^5.0.2" + "@storybook/builder-vite": "npm:8.3.0" + "@storybook/react": "npm:8.3.0" + find-up: "npm:^5.0.0" + magic-string: "npm:^0.30.0" + react-docgen: "npm:^7.0.0" + resolve: "npm:^1.22.8" + tsconfig-paths: "npm:^4.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.0 + vite: ^4.0.0 || ^5.0.0 + checksum: 10c0/1b2f5d7e47f06c296a7273b1325f125f29bb7fe549f074c5ddc74fca155af302419f6d18854f2ba2a817d62f3d60c8af43b269007103f4880afbe058c2d0bdf9 + languageName: node + linkType: hard + +"@storybook/react@npm:8.3.0, @storybook/react@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/react@npm:8.3.0" + dependencies: + "@storybook/components": "npm:^8.3.0" + "@storybook/global": "npm:^5.0.0" + "@storybook/manager-api": "npm:^8.3.0" + "@storybook/preview-api": "npm:^8.3.0" + "@storybook/react-dom-shim": "npm:8.3.0" + "@storybook/theming": "npm:^8.3.0" + "@types/escodegen": "npm:^0.0.6" + "@types/estree": "npm:^0.0.51" + "@types/node": "npm:^22.0.0" + acorn: "npm:^7.4.1" + acorn-jsx: "npm:^5.3.1" + acorn-walk: "npm:^7.2.0" + escodegen: "npm:^2.1.0" + html-tags: "npm:^3.1.0" + prop-types: "npm:^15.7.2" + react-element-to-jsx-string: "npm:^15.0.0" + semver: "npm:^7.3.7" + ts-dedent: "npm:^2.0.0" + type-fest: "npm:~2.19" + util-deprecate: "npm:^1.0.2" + peerDependencies: + "@storybook/test": 8.3.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.0 + typescript: ">= 4.2.x" + peerDependenciesMeta: + "@storybook/test": + optional: true + typescript: + optional: true + checksum: 10c0/816348bd9fc826e42bfa35062adb3031919994a5bf644d57d1e695388add7ee1db97933bbd07beaaf9dfdac85461d434c41a83fad26ee2c59dbd75474beaa006 + languageName: node + linkType: hard + +"@storybook/test@npm:8.3.0, @storybook/test@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/test@npm:8.3.0" + dependencies: + "@storybook/csf": "npm:^0.1.11" + "@storybook/global": "npm:^5.0.0" + "@storybook/instrumenter": "npm:8.3.0" + "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-dom": "npm:6.5.0" + "@testing-library/user-event": "npm:14.5.2" + "@vitest/expect": "npm:2.0.5" + "@vitest/spy": "npm:2.0.5" + util: "npm:^0.12.4" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/e5bab0575d96a8fef6d140b922f74da9a5828f336362a1f2975e6f2f3f254e498bcfbd526acb522eb0b7f9517c9a6bc545b4a8daec23f016756fc3c6de3709f7 + languageName: node + linkType: hard + +"@storybook/theming@npm:^8.3.0": + version: 8.3.0 + resolution: "@storybook/theming@npm:8.3.0" + peerDependencies: + storybook: ^8.3.0 + checksum: 10c0/6b78fcda2599025e958f8299b21578a8bad27dab3297056b631ed047cb4bd6c99510239ef10779b546e5e371ef9afc482b105b7203c063c98c79e867e631048c + languageName: node + linkType: hard + +"@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/a50bd0baa34faf16bcba712091f94c7f0e230431fe99a9dfc3401fa92823ad3f68495b86ab9bf9044b53839e8c416cfbb37eb3f246ff33f261e0fa9ee1779c5b + languageName: node + linkType: hard + +"@svgr/babel-plugin-remove-jsx-attribute@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-remove-jsx-attribute@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/8a98e59bd9971e066815b4129409932f7a4db4866834fe75677ea6d517972fb40b380a69a4413189f20e7947411f9ab1b0f029dd5e8068686a5a0188d3ccd4c7 + languageName: node + linkType: hard + +"@svgr/babel-plugin-remove-jsx-empty-expression@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-remove-jsx-empty-expression@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/517dcca75223bd05d3f056a8514dbba3031278bea4eadf0842c576d84f4651e7a4e0e7082d3ee4ef42456de0f9c4531d8a1917c04876ca64b014b859ca8f1bde + languageName: node + linkType: hard + +"@svgr/babel-plugin-replace-jsx-attribute-value@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-replace-jsx-attribute-value@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/004bd1892053b7e9c1b0bb14acc44e77634ec393722b87b1e4fae53e2c35122a2dd0d5c15e9070dbeec274e22e7693a2b8b48506733a8009ee92b12946fcb10a + languageName: node + linkType: hard + +"@svgr/babel-plugin-svg-dynamic-title@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-svg-dynamic-title@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/80e0a7fcf902f984c705051ca5c82ea6050ccbb70b651a8fea6d0eb5809e4dac274b49ea6be2d87f1eb9dfc0e2d6cdfffe1669ec2117f44b67a60a07d4c0b8b8 + languageName: node + linkType: hard + +"@svgr/babel-plugin-svg-em-dimensions@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-svg-em-dimensions@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/73e92c8277a89279745c0c500f59f083279a8dc30cd552b22981fade2a77628fb2bd2819ee505725fcd2e93f923e3790b52efcff409a159e657b46604a0b9a21 + languageName: node + linkType: hard + +"@svgr/babel-plugin-transform-react-native-svg@npm:8.1.0": + version: 8.1.0 + resolution: "@svgr/babel-plugin-transform-react-native-svg@npm:8.1.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/655ed6bc7a208ceaa4ecff0a54ccc36008c3cb31efa90d11e171cab325ebbb21aa78f09c7b65f9b3ddeda3a85f348c0c862902c48be13c14b4de165c847974e3 + languageName: node + linkType: hard + +"@svgr/babel-plugin-transform-svg-component@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/babel-plugin-transform-svg-component@npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/4ac00bb99a3db4ef05e4362f116a3c608ee365a2d26cf7318d8d41a4a5b30a02c80455cce0e62c65b60ed815b5d632bedabac2ccd4b56f998fadef5286e3ded4 + languageName: node + linkType: hard + +"@svgr/babel-preset@npm:8.1.0": + version: 8.1.0 + resolution: "@svgr/babel-preset@npm:8.1.0" + dependencies: + "@svgr/babel-plugin-add-jsx-attribute": "npm:8.0.0" + "@svgr/babel-plugin-remove-jsx-attribute": "npm:8.0.0" + "@svgr/babel-plugin-remove-jsx-empty-expression": "npm:8.0.0" + "@svgr/babel-plugin-replace-jsx-attribute-value": "npm:8.0.0" + "@svgr/babel-plugin-svg-dynamic-title": "npm:8.0.0" + "@svgr/babel-plugin-svg-em-dimensions": "npm:8.0.0" + "@svgr/babel-plugin-transform-react-native-svg": "npm:8.1.0" + "@svgr/babel-plugin-transform-svg-component": "npm:8.0.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/49367d3ad0831f79b1056871b91766246f449d4d1168623af5e283fbaefce4a01d77ab00de6b045b55e956f9aae27895823198493cd232d88d3435ea4517ffc5 + languageName: node + linkType: hard + +"@svgr/core@npm:^8.1.0": + version: 8.1.0 + resolution: "@svgr/core@npm:8.1.0" + dependencies: + "@babel/core": "npm:^7.21.3" + "@svgr/babel-preset": "npm:8.1.0" + camelcase: "npm:^6.2.0" + cosmiconfig: "npm:^8.1.3" + snake-case: "npm:^3.0.4" + checksum: 10c0/6a2f6b1bc79bce39f66f088d468985d518005fc5147ebf4f108570a933818b5951c2cb7da230ddff4b7c8028b5a672b2d33aa2acce012b8b9770073aa5a2d041 + languageName: node + linkType: hard + +"@svgr/hast-util-to-babel-ast@npm:8.0.0": + version: 8.0.0 + resolution: "@svgr/hast-util-to-babel-ast@npm:8.0.0" + dependencies: + "@babel/types": "npm:^7.21.3" + entities: "npm:^4.4.0" + checksum: 10c0/f4165b583ba9eaf6719e598977a7b3ed182f177983e55f9eb55a6a73982d81277510e9eb7ab41f255151fb9ed4edd11ac4bef95dd872f04ed64966d8c85e0f79 + languageName: node + linkType: hard + +"@svgr/plugin-jsx@npm:^8.1.0": + version: 8.1.0 + resolution: "@svgr/plugin-jsx@npm:8.1.0" + dependencies: + "@babel/core": "npm:^7.21.3" + "@svgr/babel-preset": "npm:8.1.0" + "@svgr/hast-util-to-babel-ast": "npm:8.0.0" + svg-parser: "npm:^2.0.4" + peerDependencies: + "@svgr/core": "*" + checksum: 10c0/07b4d9e00de795540bf70556fa2cc258774d01e97a12a26234c6fdf42b309beb7c10f31ee24d1a71137239347b1547b8bb5587d3a6de10669f95dcfe99cddc56 + languageName: node + linkType: hard + +"@swc/core-darwin-arm64@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-darwin-arm64@npm:1.7.18" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@swc/core-darwin-x64@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-darwin-x64@npm:1.7.18" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@swc/core-linux-arm-gnueabihf@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.7.18" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@swc/core-linux-arm64-gnu@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-linux-arm64-gnu@npm:1.7.18" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@swc/core-linux-arm64-musl@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-linux-arm64-musl@npm:1.7.18" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@swc/core-linux-x64-gnu@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-linux-x64-gnu@npm:1.7.18" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@swc/core-linux-x64-musl@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-linux-x64-musl@npm:1.7.18" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@swc/core-win32-arm64-msvc@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-win32-arm64-msvc@npm:1.7.18" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@swc/core-win32-ia32-msvc@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-win32-ia32-msvc@npm:1.7.18" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@swc/core-win32-x64-msvc@npm:1.7.18": + version: 1.7.18 + resolution: "@swc/core-win32-x64-msvc@npm:1.7.18" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@swc/core@npm:^1.5.7": + version: 1.7.18 + resolution: "@swc/core@npm:1.7.18" + dependencies: + "@swc/core-darwin-arm64": "npm:1.7.18" + "@swc/core-darwin-x64": "npm:1.7.18" + "@swc/core-linux-arm-gnueabihf": "npm:1.7.18" + "@swc/core-linux-arm64-gnu": "npm:1.7.18" + "@swc/core-linux-arm64-musl": "npm:1.7.18" + "@swc/core-linux-x64-gnu": "npm:1.7.18" + "@swc/core-linux-x64-musl": "npm:1.7.18" + "@swc/core-win32-arm64-msvc": "npm:1.7.18" + "@swc/core-win32-ia32-msvc": "npm:1.7.18" + "@swc/core-win32-x64-msvc": "npm:1.7.18" + "@swc/counter": "npm:^0.1.3" + "@swc/types": "npm:^0.1.12" + peerDependencies: + "@swc/helpers": "*" + dependenciesMeta: + "@swc/core-darwin-arm64": + optional: true + "@swc/core-darwin-x64": + optional: true + "@swc/core-linux-arm-gnueabihf": + optional: true + "@swc/core-linux-arm64-gnu": + optional: true + "@swc/core-linux-arm64-musl": + optional: true + "@swc/core-linux-x64-gnu": + optional: true + "@swc/core-linux-x64-musl": + optional: true + "@swc/core-win32-arm64-msvc": + optional: true + "@swc/core-win32-ia32-msvc": + optional: true + "@swc/core-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc/helpers": + optional: true + checksum: 10c0/4b81421353d2405eacb08e383e57e15cb8852b90f0b1acd799263d9073fb0e595a2d4878547ef82d95986ea39e49a7d48fee34d5b70e9b3fc7c99e64df9bc922 + languageName: node + linkType: hard + +"@swc/counter@npm:^0.1.3": + version: 0.1.3 + resolution: "@swc/counter@npm:0.1.3" + checksum: 10c0/8424f60f6bf8694cfd2a9bca45845bce29f26105cda8cf19cdb9fd3e78dc6338699e4db77a89ae449260bafa1cc6bec307e81e7fb96dbf7dcfce0eea55151356 + languageName: node + linkType: hard + +"@swc/types@npm:^0.1.12": + version: 0.1.12 + resolution: "@swc/types@npm:0.1.12" + dependencies: + "@swc/counter": "npm:^0.1.3" + checksum: 10c0/f95fea7dee8fc07f8389afbb9578f3d0cd84b429b1d0dbff7fd99b2ef06ed88e96bc33631f36c3bc0505d5a783bee1374acd84b8fc2593001219b6c2caba241b + languageName: node + linkType: hard + +"@szmarczak/http-timer@npm:^4.0.5": + version: 4.0.6 + resolution: "@szmarczak/http-timer@npm:4.0.6" + dependencies: + defer-to-connect: "npm:^2.0.0" + checksum: 10c0/73946918c025339db68b09abd91fa3001e87fc749c619d2e9c2003a663039d4c3cb89836c98a96598b3d47dec2481284ba85355392644911f5ecd2336536697f + languageName: node + linkType: hard + +"@table-nav/core@npm:^0.0.7": + version: 0.0.7 + resolution: "@table-nav/core@npm:0.0.7" + checksum: 10c0/75955f8ed2c0beef56bfe9dcf9ee0f24d593a49eaa364edbf0d32023242cb39172855d390838c8e818dbed5aa3bf0891151d973bc203fd2f18b3e5072daf97aa + languageName: node + linkType: hard + +"@table-nav/react@npm:^0.0.7": + version: 0.0.7 + resolution: "@table-nav/react@npm:0.0.7" + peerDependencies: + "@table-nav/core": ^0.0.7 + checksum: 10c0/a03baf6fb38bd92260823f15f8309f31fdffec72d2ef43e4d8c808c0aa2081e9e4147f675e961270fa676bcc8361388c08d9dfbaad14458d5cd2b22e76de39f0 + languageName: node + linkType: hard + +"@tanstack/react-table@npm:^8.20.5": + version: 8.20.5 + resolution: "@tanstack/react-table@npm:8.20.5" + dependencies: + "@tanstack/table-core": "npm:8.20.5" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10c0/574fa62fc6868a3b1113dbd043323f8b73aeb60555609caa164d5137a14636d4502784a961191afde2ec46f33f8c2bbfc4561d27a701c3d084e899a632dda3c8 + languageName: node + linkType: hard + +"@tanstack/table-core@npm:8.20.5": + version: 8.20.5 + resolution: "@tanstack/table-core@npm:8.20.5" + checksum: 10c0/3c27b5debd61b6bd9bfbb40bfc7c5d5af90873ae1a566b20e3bf2d2f4f2e9a78061c081aacc5259a00e256f8df506ec250eb5472f5c01ff04baf9918b554982b + languageName: node + linkType: hard + +"@testing-library/dom@npm:10.4.0": + version: 10.4.0 + resolution: "@testing-library/dom@npm:10.4.0" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 10c0/0352487720ecd433400671e773df0b84b8268fb3fe8e527cdfd7c11b1365b398b4e0eddba6e7e0c85e8d615f48257753283fccec41f6b986fd6c85f15eb5f84f + languageName: node + linkType: hard + +"@testing-library/jest-dom@npm:6.5.0": + version: 6.5.0 + resolution: "@testing-library/jest-dom@npm:6.5.0" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.21" + redent: "npm:^3.0.0" + checksum: 10c0/fd5936a547f04608d8de15a7de3ae26516f21023f8f45169b10c8c8847015fd20ec259b7309f08aa1031bcbc37c6e5e6f532d1bb85ef8f91bad654193ec66a4c + languageName: node + linkType: hard + +"@testing-library/user-event@npm:14.5.2": + version: 14.5.2 + resolution: "@testing-library/user-event@npm:14.5.2" + peerDependencies: + "@testing-library/dom": ">=7.21.4" + checksum: 10c0/68a0c2aa28a3c8e6eb05cafee29705438d7d8a9427423ce5064d44f19c29e89b5636de46dd2f28620fb10abba75c67130185bbc3aa23ac1163a227a5f36641e1 + languageName: node + linkType: hard + +"@tootallnate/once@npm:2": + version: 2.0.0 + resolution: "@tootallnate/once@npm:2.0.0" + checksum: 10c0/073bfa548026b1ebaf1659eb8961e526be22fa77139b10d60e712f46d2f0f05f4e6c8bec62a087d41088ee9e29faa7f54838568e475ab2f776171003c3920858 + languageName: node + linkType: hard + +"@tsconfig/node10@npm:^1.0.7": + version: 1.0.11 + resolution: "@tsconfig/node10@npm:1.0.11" + checksum: 10c0/28a0710e5d039e0de484bdf85fee883bfd3f6a8980601f4d44066b0a6bcd821d31c4e231d1117731c4e24268bd4cf2a788a6787c12fc7f8d11014c07d582783c + languageName: node + linkType: hard + +"@tsconfig/node12@npm:^1.0.7": + version: 1.0.11 + resolution: "@tsconfig/node12@npm:1.0.11" + checksum: 10c0/dddca2b553e2bee1308a056705103fc8304e42bb2d2cbd797b84403a223b25c78f2c683ec3e24a095e82cd435387c877239bffcb15a590ba817cd3f6b9a99fd9 + languageName: node + linkType: hard + +"@tsconfig/node14@npm:^1.0.0": + version: 1.0.3 + resolution: "@tsconfig/node14@npm:1.0.3" + checksum: 10c0/67c1316d065fdaa32525bc9449ff82c197c4c19092b9663b23213c8cbbf8d88b6ed6a17898e0cbc2711950fbfaf40388938c1c748a2ee89f7234fc9e7fe2bf44 + languageName: node + linkType: hard + +"@tsconfig/node16@npm:^1.0.2": + version: 1.0.4 + resolution: "@tsconfig/node16@npm:1.0.4" + checksum: 10c0/05f8f2734e266fb1839eb1d57290df1664fe2aa3b0fdd685a9035806daa635f7519bf6d5d9b33f6e69dd545b8c46bd6e2b5c79acb2b1f146e885f7f11a42a5bb + languageName: node + linkType: hard + +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 + languageName: node + linkType: hard + +"@types/babel__core@npm:^7.18.0": + version: 7.20.5 + resolution: "@types/babel__core@npm:7.20.5" + dependencies: + "@babel/parser": "npm:^7.20.7" + "@babel/types": "npm:^7.20.7" + "@types/babel__generator": "npm:*" + "@types/babel__template": "npm:*" + "@types/babel__traverse": "npm:*" + checksum: 10c0/bdee3bb69951e833a4b811b8ee9356b69a61ed5b7a23e1a081ec9249769117fa83aaaf023bb06562a038eb5845155ff663e2d5c75dd95c1d5ccc91db012868ff + languageName: node + linkType: hard + +"@types/babel__generator@npm:*": + version: 7.6.8 + resolution: "@types/babel__generator@npm:7.6.8" + dependencies: + "@babel/types": "npm:^7.0.0" + checksum: 10c0/f0ba105e7d2296bf367d6e055bb22996886c114261e2cb70bf9359556d0076c7a57239d019dee42bb063f565bade5ccb46009bce2044b2952d964bf9a454d6d2 + languageName: node + linkType: hard + +"@types/babel__template@npm:*": + version: 7.4.4 + resolution: "@types/babel__template@npm:7.4.4" + dependencies: + "@babel/parser": "npm:^7.1.0" + "@babel/types": "npm:^7.0.0" + checksum: 10c0/cc84f6c6ab1eab1427e90dd2b76ccee65ce940b778a9a67be2c8c39e1994e6f5bbc8efa309f6cea8dc6754994524cd4d2896558df76d92e7a1f46ecffee7112b + languageName: node + linkType: hard + +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.18.0": + version: 7.20.6 + resolution: "@types/babel__traverse@npm:7.20.6" + dependencies: + "@babel/types": "npm:^7.20.7" + checksum: 10c0/7ba7db61a53e28cac955aa99af280d2600f15a8c056619c05b6fc911cbe02c61aa4f2823299221b23ce0cce00b294c0e5f618ec772aa3f247523c2e48cf7b888 + languageName: node + linkType: hard + +"@types/body-parser@npm:*": + version: 1.19.5 + resolution: "@types/body-parser@npm:1.19.5" + dependencies: + "@types/connect": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/aebeb200f25e8818d8cf39cd0209026750d77c9b85381cdd8deeb50913e4d18a1ebe4b74ca9b0b4d21952511eeaba5e9fbbf739b52731a2061e206ec60d568df + languageName: node + linkType: hard + +"@types/cacheable-request@npm:^6.0.1": + version: 6.0.3 + resolution: "@types/cacheable-request@npm:6.0.3" + dependencies: + "@types/http-cache-semantics": "npm:*" + "@types/keyv": "npm:^3.1.4" + "@types/node": "npm:*" + "@types/responselike": "npm:^1.0.0" + checksum: 10c0/10816a88e4e5b144d43c1d15a81003f86d649776c7f410c9b5e6579d0ad9d4ca71c541962fb403077388b446e41af7ae38d313e46692144985f006ac5e11fa03 + languageName: node + linkType: hard + +"@types/color-convert@npm:*": + version: 2.0.3 + resolution: "@types/color-convert@npm:2.0.3" + dependencies: + "@types/color-name": "npm:*" + checksum: 10c0/a5870547660f426cddd76b54e942703e29c3b43fc26b1ba567e10b9707d144b7d8863e0af7affd9c3391815c06582571f43835c71ede270a6c58949155d18b77 + languageName: node + linkType: hard + +"@types/color-name@npm:*": + version: 1.1.4 + resolution: "@types/color-name@npm:1.1.4" + checksum: 10c0/11a5b67408a53a972fa98e4bbe2b0ff4cb74a3b3abb5f250cb5ec7b055a45aa8e00ddaf39b8327ef683ede9b2ff9b3ee9d25cd708d12b1b6a9aee5e8e6002920 + languageName: node + linkType: hard + +"@types/color@npm:^3.0.6": + version: 3.0.6 + resolution: "@types/color@npm:3.0.6" + dependencies: + "@types/color-convert": "npm:*" + checksum: 10c0/79267eeb67f9d11761aecee36bb1503fb8daa699b9ae7e036fc23a74380e5b130c5c0f6d7adafabba89256e46f36ee4d3e28e0ac7e107e8258550eae7d091acf + languageName: node + linkType: hard + +"@types/connect@npm:*": + version: 3.4.38 + resolution: "@types/connect@npm:3.4.38" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/2e1cdba2c410f25649e77856505cd60223250fa12dff7a503e492208dbfdd25f62859918f28aba95315251fd1f5e1ffbfca1e25e73037189ab85dd3f8d0a148c + languageName: node + linkType: hard + +"@types/css-tree@npm:^2": + version: 2.3.8 + resolution: "@types/css-tree@npm:2.3.8" + checksum: 10c0/fafa7ad516b64481a031aceb3c30762074e1e0bfd67e0f0655e46b8c1b7b3c39660f8285811ca6aac11229ef477c65ca61ee118d2f9264145d5db8fe26f1a721 + languageName: node + linkType: hard + +"@types/debug@npm:^4, @types/debug@npm:^4.0.0, @types/debug@npm:^4.1.6": + version: 4.1.12 + resolution: "@types/debug@npm:4.1.12" + dependencies: + "@types/ms": "npm:*" + checksum: 10c0/5dcd465edbb5a7f226e9a5efd1f399c6172407ef5840686b73e3608ce135eeca54ae8037dcd9f16bdb2768ac74925b820a8b9ecc588a58ca09eca6acabe33e2f + languageName: node + linkType: hard + +"@types/doctrine@npm:^0.0.9": + version: 0.0.9 + resolution: "@types/doctrine@npm:0.0.9" + checksum: 10c0/cdaca493f13c321cf0cacd1973efc0ae74569633145d9e6fc1128f32217a6968c33bea1f858275239fe90c98f3be57ec8f452b416a9ff48b8e8c1098b20fa51c + languageName: node + linkType: hard + +"@types/electron@npm:^1.6.10": + version: 1.6.10 + resolution: "@types/electron@npm:1.6.10" + dependencies: + electron: "npm:*" + checksum: 10c0/d9d7facf29280dbfcecca287c453c5dc51f3d10cbfd63ea7e78670d37acf51aabc6e5e2ef1fe5f48d67e7862fa9f590bb6ef703901eb62599837a14b4278b0e1 + languageName: node + linkType: hard + +"@types/escodegen@npm:^0.0.6": + version: 0.0.6 + resolution: "@types/escodegen@npm:0.0.6" + checksum: 10c0/bbef189319c7b0386486bc7224369f118c7aedf35cc13e40ae5879b9ab4f848936f31e8eea50e71d4de72d4b7a77d9e6e9e5ceec4406c648fbc0077ede634ed5 + languageName: node + linkType: hard + +"@types/estree-jsx@npm:^1.0.0": + version: 1.0.5 + resolution: "@types/estree-jsx@npm:1.0.5" + dependencies: + "@types/estree": "npm:*" + checksum: 10c0/07b354331516428b27a3ab99ee397547d47eb223c34053b48f84872fafb841770834b90cc1a0068398e7c7ccb15ec51ab00ec64b31dc5e3dbefd624638a35c6d + languageName: node + linkType: hard + +"@types/estree@npm:*, @types/estree@npm:1.0.5, @types/estree@npm:^1.0.0": + version: 1.0.5 + resolution: "@types/estree@npm:1.0.5" + checksum: 10c0/b3b0e334288ddb407c7b3357ca67dbee75ee22db242ca7c56fe27db4e1a31989cb8af48a84dd401deb787fe10cc6b2ab1ee82dc4783be87ededbe3d53c79c70d + languageName: node + linkType: hard + +"@types/estree@npm:^0.0.51": + version: 0.0.51 + resolution: "@types/estree@npm:0.0.51" + checksum: 10c0/a70c60d5e634e752fcd45b58c9c046ef22ad59ede4bc93ad5193c7e3b736ebd6bcd788ade59d9c3b7da6eeb0939235f011d4c59bb4fc04d8c346b76035099dd1 + languageName: node + linkType: hard + +"@types/express-serve-static-core@npm:^4.17.33": + version: 4.19.5 + resolution: "@types/express-serve-static-core@npm:4.19.5" + dependencies: + "@types/node": "npm:*" + "@types/qs": "npm:*" + "@types/range-parser": "npm:*" + "@types/send": "npm:*" + checksum: 10c0/ba8d8d976ab797b2602c60e728802ff0c98a00f13d420d82770f3661b67fa36ea9d3be0b94f2ddd632afe1fbc6e41620008b01db7e4fabdd71a2beb5539b0725 + languageName: node + linkType: hard + +"@types/express@npm:^4.17.21": + version: 4.17.21 + resolution: "@types/express@npm:4.17.21" + dependencies: + "@types/body-parser": "npm:*" + "@types/express-serve-static-core": "npm:^4.17.33" + "@types/qs": "npm:*" + "@types/serve-static": "npm:*" + checksum: 10c0/12e562c4571da50c7d239e117e688dc434db1bac8be55613294762f84fd77fbd0658ccd553c7d3ab02408f385bc93980992369dd30e2ecd2c68c358e6af8fabf + languageName: node + linkType: hard + +"@types/find-cache-dir@npm:^3.2.1": + version: 3.2.1 + resolution: "@types/find-cache-dir@npm:3.2.1" + checksum: 10c0/68059aec88ef776a689c1711a881fd91a9ce1b03dd5898ea1d2ac5d77d7b0235f21fdf210f380c13deca8b45e4499841a63aaf31fd2123af687f2c6b472f41ce + languageName: node + linkType: hard + +"@types/fs-extra@npm:9.0.13, @types/fs-extra@npm:^9.0.11": + version: 9.0.13 + resolution: "@types/fs-extra@npm:9.0.13" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/576d4e9d382393316ed815c593f7f5c157408ec5e184521d077fcb15d514b5a985245f153ef52142b9b976cb9bd8f801850d51238153ebd0dc9e96b7a7548588 + languageName: node + linkType: hard + +"@types/glob@npm:^7.1.3": + version: 7.2.0 + resolution: "@types/glob@npm:7.2.0" + dependencies: + "@types/minimatch": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/a8eb5d5cb5c48fc58c7ca3ff1e1ddf771ee07ca5043da6e4871e6757b4472e2e73b4cfef2644c38983174a4bc728c73f8da02845c28a1212f98cabd293ecae98 + languageName: node + linkType: hard + +"@types/hast@npm:^3.0.0": + version: 3.0.4 + resolution: "@types/hast@npm:3.0.4" + dependencies: + "@types/unist": "npm:*" + checksum: 10c0/3249781a511b38f1d330fd1e3344eed3c4e7ea8eff82e835d35da78e637480d36fad37a78be5a7aed8465d237ad0446abc1150859d0fde395354ea634decf9f7 + languageName: node + linkType: hard + +"@types/http-cache-semantics@npm:*": + version: 4.0.4 + resolution: "@types/http-cache-semantics@npm:4.0.4" + checksum: 10c0/51b72568b4b2863e0fe8d6ce8aad72a784b7510d72dc866215642da51d84945a9459fa89f49ec48f1e9a1752e6a78e85a4cda0ded06b1c73e727610c925f9ce6 + languageName: node + linkType: hard + +"@types/http-errors@npm:*": + version: 2.0.4 + resolution: "@types/http-errors@npm:2.0.4" + checksum: 10c0/494670a57ad4062fee6c575047ad5782506dd35a6b9ed3894cea65830a94367bd84ba302eb3dde331871f6d70ca287bfedb1b2cf658e6132cd2cbd427ab56836 + languageName: node + linkType: hard + +"@types/keyv@npm:^3.1.4": + version: 3.1.4 + resolution: "@types/keyv@npm:3.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/ff8f54fc49621210291f815fe5b15d809fd7d032941b3180743440bd507ecdf08b9e844625fa346af568c84bf34114eb378dcdc3e921a08ba1e2a08d7e3c809c + languageName: node + linkType: hard + +"@types/lodash@npm:^4.14.167": + version: 4.17.7 + resolution: "@types/lodash@npm:4.17.7" + checksum: 10c0/40c965b5ffdcf7ff5c9105307ee08b782da228c01b5c0529122c554c64f6b7168fc8f11dc79aa7bae4e67e17efafaba685dc3a47e294dbf52a65ed2b67100561 + languageName: node + linkType: hard + +"@types/mdast@npm:^4.0.0, @types/mdast@npm:^4.0.4": + version: 4.0.4 + resolution: "@types/mdast@npm:4.0.4" + dependencies: + "@types/unist": "npm:*" + checksum: 10c0/84f403dbe582ee508fd9c7643ac781ad8597fcbfc9ccb8d4715a2c92e4545e5772cbd0dbdf18eda65789386d81b009967fdef01b24faf6640f817287f54d9c82 + languageName: node + linkType: hard + +"@types/mdx@npm:^2.0.0": + version: 2.0.13 + resolution: "@types/mdx@npm:2.0.13" + checksum: 10c0/5edf1099505ac568da55f9ae8a93e7e314e8cbc13d3445d0be61b75941226b005e1390d9b95caecf5dcb00c9d1bab2f1f60f6ff9876dc091a48b547495007720 + languageName: node + linkType: hard + +"@types/mime@npm:^1": + version: 1.3.5 + resolution: "@types/mime@npm:1.3.5" + checksum: 10c0/c2ee31cd9b993804df33a694d5aa3fa536511a49f2e06eeab0b484fef59b4483777dbb9e42a4198a0809ffbf698081fdbca1e5c2218b82b91603dfab10a10fbc + languageName: node + linkType: hard + +"@types/minimatch@npm:*": + version: 5.1.2 + resolution: "@types/minimatch@npm:5.1.2" + checksum: 10c0/83cf1c11748891b714e129de0585af4c55dd4c2cafb1f1d5233d79246e5e1e19d1b5ad9e8db449667b3ffa2b6c80125c429dbee1054e9efb45758dbc4e118562 + languageName: node + linkType: hard + +"@types/ms@npm:*": + version: 0.7.34 + resolution: "@types/ms@npm:0.7.34" + checksum: 10c0/ac80bd90012116ceb2d188fde62d96830ca847823e8ca71255616bc73991aa7d9f057b8bfab79e8ee44ffefb031ddd1bcce63ea82f9e66f7c31ec02d2d823ccc + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 22.5.0 + resolution: "@types/node@npm:22.5.0" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/45aa75c5e71645fac42dced4eff7f197c3fdfff6e8a9fdacd0eb2e748ff21ee70ffb73982f068a58e8d73b2c088a63613142c125236cdcf3c072ea97eada1559 + languageName: node + linkType: hard + +"@types/node@npm:^20.9.0": + version: 20.16.1 + resolution: "@types/node@npm:20.16.1" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/cac13c0f42467df3254805a671ca9e74a6eb7c41568de972e26b10dcc448a45743aaf00e9e5fce4a9214da5bc8444fe902918e105dac5a224e24e83fd9989a97 + languageName: node + linkType: hard + +"@types/node@npm:^22.0.0": + version: 22.5.5 + resolution: "@types/node@npm:22.5.5" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/ead9495cfc6b1da5e7025856dcce2591e9bae635357410c0d2dd619fce797d2a1d402887580ca4b336cb78168b195224869967de370a23f61663cf1e4836121c + languageName: node + linkType: hard + +"@types/node@npm:^22.5.4": + version: 22.5.4 + resolution: "@types/node@npm:22.5.4" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/b445daa7eecd761ad4d778b882d6ff7bcc3b4baad2086ea9804db7c5d4a4ab0298b00d7f5315fc640a73b5a1d52bbf9628e09c9fec0cf44dbf9b4df674a8717d + languageName: node + linkType: hard + +"@types/papaparse@npm:^5": + version: 5.3.14 + resolution: "@types/papaparse@npm:5.3.14" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/feb4d215903b67442feaa9836a6a5771e78dc6a9da24781e399c6f891622fa82245cd783ab2613c5be43e4a2d6a94da52325538e4485af258166864576ecd0d8 + languageName: node + linkType: hard + +"@types/plist@npm:^3.0.1": + version: 3.0.5 + resolution: "@types/plist@npm:3.0.5" + dependencies: + "@types/node": "npm:*" + xmlbuilder: "npm:>=11.0.1" + checksum: 10c0/2a929f4482e3bea8c3288a46ae589a2ae2d01df5b7841ead7032d7baa79d79af6c875a5798c90705eea9306c2fb1544d7ed12ab3c905c5626d5dd5dc9f464b94 + languageName: node + linkType: hard + +"@types/pngjs@npm:^6.0.5": + version: 6.0.5 + resolution: "@types/pngjs@npm:6.0.5" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/11979d0690e774046d3a1e8cfc613a6b53a613909d81c93831cf253800e93ba1a2d47950979b523aebd4fc023351b644d7f4aa2dfcc027c70816a2968420e7da + languageName: node + linkType: hard + +"@types/prop-types@npm:*": + version: 15.7.12 + resolution: "@types/prop-types@npm:15.7.12" + checksum: 10c0/1babcc7db6a1177779f8fde0ccc78d64d459906e6ef69a4ed4dd6339c920c2e05b074ee5a92120fe4e9d9f1a01c952f843ebd550bee2332fc2ef81d1706878f8 + languageName: node + linkType: hard + +"@types/qs@npm:*": + version: 6.9.15 + resolution: "@types/qs@npm:6.9.15" + checksum: 10c0/49c5ff75ca3adb18a1939310042d273c9fc55920861bd8e5100c8a923b3cda90d759e1a95e18334092da1c8f7b820084687770c83a1ccef04fb2c6908117c823 + languageName: node + linkType: hard + +"@types/range-parser@npm:*": + version: 1.2.7 + resolution: "@types/range-parser@npm:1.2.7" + checksum: 10c0/361bb3e964ec5133fa40644a0b942279ed5df1949f21321d77de79f48b728d39253e5ce0408c9c17e4e0fd95ca7899da36841686393b9f7a1e209916e9381a3c + languageName: node + linkType: hard + +"@types/react-dom@npm:^18.3.0": + version: 18.3.0 + resolution: "@types/react-dom@npm:18.3.0" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/6c90d2ed72c5a0e440d2c75d99287e4b5df3e7b011838cdc03ae5cd518ab52164d86990e73246b9d812eaf02ec351d74e3b4f5bd325bf341e13bf980392fd53b + languageName: node + linkType: hard + +"@types/react@npm:*, @types/react@npm:^18.3.5": + version: 18.3.5 + resolution: "@types/react@npm:18.3.5" + dependencies: + "@types/prop-types": "npm:*" + csstype: "npm:^3.0.2" + checksum: 10c0/548b1d3d7c2f0242fbfdbbd658731b4ce69a134be072fa83e6ab516f2840402a3f20e3e7f72e95133b23d4880ef24a6d864050dc8e1f7c68f39fa87ca8445917 + languageName: node + linkType: hard + +"@types/react@npm:^16.8.0 || ^17.0.0 || ^18.0.0": + version: 18.3.4 + resolution: "@types/react@npm:18.3.4" + dependencies: + "@types/prop-types": "npm:*" + csstype: "npm:^3.0.2" + checksum: 10c0/5c52e1e6f540cff21e3c2a5212066d02e005f6fb21e4a536a29097fae878db9f407cd7a4b43778f51359349c5f692e08bc77ddb5f5cecbfca9ca4d4e3c91a48e + languageName: node + linkType: hard + +"@types/resolve@npm:1.20.2": + version: 1.20.2 + resolution: "@types/resolve@npm:1.20.2" + checksum: 10c0/c5b7e1770feb5ccfb6802f6ad82a7b0d50874c99331e0c9b259e415e55a38d7a86ad0901c57665d93f75938be2a6a0bc9aa06c9749192cadb2e4512800bbc6e6 + languageName: node + linkType: hard + +"@types/resolve@npm:^1.20.2": + version: 1.20.6 + resolution: "@types/resolve@npm:1.20.6" + checksum: 10c0/a9b0549d816ff2c353077365d865a33655a141d066d0f5a3ba6fd4b28bc2f4188a510079f7c1f715b3e7af505a27374adce2a5140a3ece2a059aab3d6e1a4244 + languageName: node + linkType: hard + +"@types/responselike@npm:^1.0.0": + version: 1.0.3 + resolution: "@types/responselike@npm:1.0.3" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/a58ba341cb9e7d74f71810a88862da7b2a6fa42e2a1fc0ce40498f6ea1d44382f0640117057da779f74c47039f7166bf48fad02dc876f94e005c7afa50f5e129 + languageName: node + linkType: hard + +"@types/semver@npm:^7": + version: 7.5.8 + resolution: "@types/semver@npm:7.5.8" + checksum: 10c0/8663ff927234d1c5fcc04b33062cb2b9fcfbe0f5f351ed26c4d1e1581657deebd506b41ff7fdf89e787e3d33ce05854bc01686379b89e9c49b564c4cfa988efa + languageName: node + linkType: hard + +"@types/send@npm:*": + version: 0.17.4 + resolution: "@types/send@npm:0.17.4" + dependencies: + "@types/mime": "npm:^1" + "@types/node": "npm:*" + checksum: 10c0/7f17fa696cb83be0a104b04b424fdedc7eaba1c9a34b06027239aba513b398a0e2b7279778af521f516a397ced417c96960e5f50fcfce40c4bc4509fb1a5883c + languageName: node + linkType: hard + +"@types/serve-static@npm:*": + version: 1.15.7 + resolution: "@types/serve-static@npm:1.15.7" + dependencies: + "@types/http-errors": "npm:*" + "@types/node": "npm:*" + "@types/send": "npm:*" + checksum: 10c0/26ec864d3a626ea627f8b09c122b623499d2221bbf2f470127f4c9ebfe92bd8a6bb5157001372d4c4bd0dd37a1691620217d9dc4df5aa8f779f3fd996b1c60ae + languageName: node + linkType: hard + +"@types/shell-quote@npm:^1": + version: 1.7.5 + resolution: "@types/shell-quote@npm:1.7.5" + checksum: 10c0/ddcf225e85e5520e3f44411d7d79eee0e56477fab705d0d93e293b61b9f8de2a57db6e859d492a24bc9e0d071c0490271efeae832756e2ac0d4d255922ac281d + languageName: node + linkType: hard + +"@types/sprintf-js@npm:^1": + version: 1.1.4 + resolution: "@types/sprintf-js@npm:1.1.4" + checksum: 10c0/b56aa88876b8c2b00df7f931615f33371231c265328875024071e3dca151021bbce6143833fc2172640a75680dec260a80cca451374976f6fd29d305b108cfe1 + languageName: node + linkType: hard + +"@types/throttle-debounce@npm:^5": + version: 5.0.2 + resolution: "@types/throttle-debounce@npm:5.0.2" + checksum: 10c0/526084a7b83edd5c008f193a80c1f6851421d44921e874032d63a598047e17c1a5c97fe99a9dbf806e3d0b49a40d8c4171541a32ea1911d45b5216ddf129d51a + languageName: node + linkType: hard + +"@types/tinycolor2@npm:^1": + version: 1.4.6 + resolution: "@types/tinycolor2@npm:1.4.6" + checksum: 10c0/922020c3326460e9d8502c8a98f80db69f06fd14e07fe5a48e8ffe66175762298a9bd51263f2a0c9a40632886a74975a3ff79396defcdbeac0dc176e3e5056e8 + languageName: node + linkType: hard + +"@types/triple-beam@npm:^1.3.2": + version: 1.3.5 + resolution: "@types/triple-beam@npm:1.3.5" + checksum: 10c0/d5d7f25da612f6d79266f4f1bb9c1ef8f1684e9f60abab251e1261170631062b656ba26ff22631f2760caeafd372abc41e64867cde27fba54fafb73a35b9056a + languageName: node + linkType: hard + +"@types/unist@npm:*, @types/unist@npm:^3.0.0": + version: 3.0.3 + resolution: "@types/unist@npm:3.0.3" + checksum: 10c0/2b1e4adcab78388e088fcc3c0ae8700f76619dbcb4741d7d201f87e2cb346bfc29a89003cfea2d76c996e1061452e14fcd737e8b25aacf949c1f2d6b2bc3dd60 + languageName: node + linkType: hard + +"@types/unist@npm:^2.0.0": + version: 2.0.11 + resolution: "@types/unist@npm:2.0.11" + checksum: 10c0/24dcdf25a168f453bb70298145eb043cfdbb82472db0bc0b56d6d51cd2e484b9ed8271d4ac93000a80da568f2402e9339723db262d0869e2bf13bc58e081768d + languageName: node + linkType: hard + +"@types/uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "@types/uuid@npm:10.0.0" + checksum: 10c0/9a1404bf287164481cb9b97f6bb638f78f955be57c40c6513b7655160beb29df6f84c915aaf4089a1559c216557dc4d2f79b48d978742d3ae10b937420ddac60 + languageName: node + linkType: hard + +"@types/uuid@npm:^9.0.1": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: 10c0/b411b93054cb1d4361919579ef3508a1f12bf15b5fdd97337d3d351bece6c921b52b6daeef89b62340fd73fd60da407878432a1af777f40648cbe53a01723489 + languageName: node + linkType: hard + +"@types/verror@npm:^1.10.3": + version: 1.10.10 + resolution: "@types/verror@npm:1.10.10" + checksum: 10c0/413c0c0370ed6a796d630fbcdae20049ab3e26558c62bc5f53327830ddb0965aaadedb92f4933b28ee8fc8089e1293b742a0efbf6b264d15ce3930c6b83b0984 + languageName: node + linkType: hard + +"@types/ws@npm:^8": + version: 8.5.12 + resolution: "@types/ws@npm:8.5.12" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/3fd77c9e4e05c24ce42bfc7647f7506b08c40a40fe2aea236ef6d4e96fc7cb4006a81ed1b28ec9c457e177a74a72924f4768b7b4652680b42dfd52bc380e15f9 + languageName: node + linkType: hard + +"@types/yauzl@npm:^2.9.1": + version: 2.10.3 + resolution: "@types/yauzl@npm:2.10.3" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/f1b7c1b99fef9f2fe7f1985ef7426d0cebe48cd031f1780fcdc7451eec7e31ac97028f16f50121a59bcf53086a1fc8c856fd5b7d3e00970e43d92ae27d6b43dc + languageName: node + linkType: hard + +"@typescript-eslint/eslint-plugin@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.5.0" + dependencies: + "@eslint-community/regexpp": "npm:^4.10.0" + "@typescript-eslint/scope-manager": "npm:8.5.0" + "@typescript-eslint/type-utils": "npm:8.5.0" + "@typescript-eslint/utils": "npm:8.5.0" + "@typescript-eslint/visitor-keys": "npm:8.5.0" + graphemer: "npm:^1.4.0" + ignore: "npm:^5.3.1" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^1.3.0" + peerDependencies: + "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/69ae7067e03d2d8d442e69d668235bdafd63b07229d0be27025eaad8aa468b5af8ac54627021e0e3a060df04ed1c39d1327a0b11469ac72405b52b74a79f402b + languageName: node + linkType: hard + +"@typescript-eslint/parser@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/parser@npm:8.5.0" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.5.0" + "@typescript-eslint/types": "npm:8.5.0" + "@typescript-eslint/typescript-estree": "npm:8.5.0" + "@typescript-eslint/visitor-keys": "npm:8.5.0" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/509fdd605b86c7d025928f20e1035712c2fc268c34b1af84248ed0b53d699034f19caf98e085c5c758d3025e29939dd12eea427c72cae9e5ea79274364851f0a + languageName: node + linkType: hard + +"@typescript-eslint/scope-manager@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/scope-manager@npm:8.5.0" + dependencies: + "@typescript-eslint/types": "npm:8.5.0" + "@typescript-eslint/visitor-keys": "npm:8.5.0" + checksum: 10c0/868602f9324a6e15fcae017acd3b0832e9f2c8c8cd315667df37c2e7c765cda5fba7c4bede931f32cc04819ba97cf74a5fddb085c6f1c7993f1fb085ba126422 + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/type-utils@npm:8.5.0" + dependencies: + "@typescript-eslint/typescript-estree": "npm:8.5.0" + "@typescript-eslint/utils": "npm:8.5.0" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^1.3.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/675d3e41f938d16e9268fd33764a4e16b12a4a9817e61d5e2508a07fe6783c69ce9d05facc61822b5647c71d767929618ed37b8b93f423f7c2ccb62cfeb4343b + languageName: node + linkType: hard + +"@typescript-eslint/types@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/types@npm:8.5.0" + checksum: 10c0/f0b666b5c001b9779bfd9e4c7d031843d07264429d5bcf5d636f26f96cd5d949a33f5d6a645b8d74b93daf565a468476a6a4935dd7135a200250fb03acbe4988 + languageName: node + linkType: hard + +"@typescript-eslint/typescript-estree@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.5.0" + dependencies: + "@typescript-eslint/types": "npm:8.5.0" + "@typescript-eslint/visitor-keys": "npm:8.5.0" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^1.3.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/f62f03d0c5dc57b2b54dbe1cbd027966f774f241279655f46c64145abb54b765176a0cd40447583ba56ada306181da9a82e39b777c78128e105e4ea98c609350 + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/utils@npm:8.5.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.4.0" + "@typescript-eslint/scope-manager": "npm:8.5.0" + "@typescript-eslint/types": "npm:8.5.0" + "@typescript-eslint/typescript-estree": "npm:8.5.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + checksum: 10c0/0cb0bfdaf0da79d13c0d0379478eb14b5825d235873bc7181e70c4f6297fa1c74431ef730cbc2912fe1814dd8d46c6515ce22b39c57e8f03c337aa152fd49a4e + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.5.0": + version: 8.5.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.5.0" + dependencies: + "@typescript-eslint/types": "npm:8.5.0" + eslint-visitor-keys: "npm:^3.4.3" + checksum: 10c0/8b9e81968ad36e8af18ac17b63c4e0764612451ca085676c939b723549052243f63577d2706bc2da48174f11bf47587ab47e6e0b7c5b28d9f3c1ef7b9aad322d + languageName: node + linkType: hard + +"@ungap/structured-clone@npm:^1.0.0, @ungap/structured-clone@npm:^1.2.0": + version: 1.2.0 + resolution: "@ungap/structured-clone@npm:1.2.0" + checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d + languageName: node + linkType: hard + +"@vitejs/plugin-react-swc@npm:^3.7.0": + version: 3.7.0 + resolution: "@vitejs/plugin-react-swc@npm:3.7.0" + dependencies: + "@swc/core": "npm:^1.5.7" + peerDependencies: + vite: ^4 || ^5 + checksum: 10c0/f9f562c87f0fd384d160c5d499056841f8a38050fc01f5295d3394a77c288eca1f78f6df3aa08c01f3f5cb3e4937c6490607ac87b700d87bab425b7c4dc15e91 + languageName: node + linkType: hard + +"@vitest/coverage-istanbul@npm:^2.1.1": + version: 2.1.1 + resolution: "@vitest/coverage-istanbul@npm:2.1.1" + dependencies: + "@istanbuljs/schema": "npm:^0.1.3" + debug: "npm:^4.3.6" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-instrument: "npm:^6.0.3" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magicast: "npm:^0.3.4" + test-exclude: "npm:^7.0.1" + tinyrainbow: "npm:^1.2.0" + peerDependencies: + vitest: 2.1.1 + checksum: 10c0/4dd4109294c2cc51306cb1cbabf22e0815664b07f3d668df3ebb0968fbf564213e85eca2e369c8dca838baf3e5ed4fbb243e7a98f20899a8040779fa7e6ee381 + languageName: node + linkType: hard + +"@vitest/expect@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/expect@npm:2.0.5" + dependencies: + "@vitest/spy": "npm:2.0.5" + "@vitest/utils": "npm:2.0.5" + chai: "npm:^5.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/08cb1b0f106d16a5b60db733e3d436fa5eefc68571488eb570dfe4f599f214ab52e4342273b03dbe12331cc6c0cdc325ac6c94f651ad254cd62f3aa0e3d185aa + languageName: node + linkType: hard + +"@vitest/expect@npm:2.1.1": + version: 2.1.1 + resolution: "@vitest/expect@npm:2.1.1" + dependencies: + "@vitest/spy": "npm:2.1.1" + "@vitest/utils": "npm:2.1.1" + chai: "npm:^5.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/2a467bcd37378b653040cca062a665f382087eb9f69cff670848a0c207a8458f27211c408c75b7e563e069a2e6d533c78f24e1a317c259646b948813342dbf3d + languageName: node + linkType: hard + +"@vitest/mocker@npm:2.1.1": + version: 2.1.1 + resolution: "@vitest/mocker@npm:2.1.1" + dependencies: + "@vitest/spy": "npm:^2.1.0-beta.1" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.11" + peerDependencies: + "@vitest/spy": 2.1.1 + msw: ^2.3.5 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/e0681bb75bf7255ce49f720d193c9c795a64d42fef13c7af5c157514ebce88a5b89dbf702aa0929d4cefaed3db73351bd3ade3ccabecc09a23a872d9c55be50d + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/pretty-format@npm:2.0.5" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/236c0798c5170a0b5ad5d4bd06118533738e820b4dd30079d8fbcb15baee949d41c60f42a9f769906c4a5ce366d7ef11279546070646c0efc03128c220c31f37 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:2.1.1, @vitest/pretty-format@npm:^2.1.1": + version: 2.1.1 + resolution: "@vitest/pretty-format@npm:2.1.1" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/21057465a794a037a7af2c48397531eadf9b2d8a7b4d1ee5af9081cf64216cd0039b9e06317319df79aa2240fed1dbb6767b530deae2bd4b42d6fb974297e97d + languageName: node + linkType: hard + +"@vitest/runner@npm:2.1.1": + version: 2.1.1 + resolution: "@vitest/runner@npm:2.1.1" + dependencies: + "@vitest/utils": "npm:2.1.1" + pathe: "npm:^1.1.2" + checksum: 10c0/a6d1424d6224d8a60ed0bbf7cdacb165ef36bc71cc957ad2c11ed1989fa5106636173369f0d8e1fa3f319a965091e52c8ce21203fce4bafe772632ccc2bd65a6 + languageName: node + linkType: hard + +"@vitest/snapshot@npm:2.1.1": + version: 2.1.1 + resolution: "@vitest/snapshot@npm:2.1.1" + dependencies: + "@vitest/pretty-format": "npm:2.1.1" + magic-string: "npm:^0.30.11" + pathe: "npm:^1.1.2" + checksum: 10c0/e9dadee87a2f489883dec0360b55b2776d2a07e460bf2430b34867cd4e9f34b09b3e219a23bc8c3e1359faefdd166072d3305b66a0bea475c7d616470b7d841c + languageName: node + linkType: hard + +"@vitest/spy@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/spy@npm:2.0.5" + dependencies: + tinyspy: "npm:^3.0.0" + checksum: 10c0/70634c21921eb271b54d2986c21d7ab6896a31c0f4f1d266940c9bafb8ac36237846d6736638cbf18b958bd98e5261b158a6944352742accfde50b7818ff655e + languageName: node + linkType: hard + +"@vitest/spy@npm:2.1.1, @vitest/spy@npm:^2.1.0-beta.1": + version: 2.1.1 + resolution: "@vitest/spy@npm:2.1.1" + dependencies: + tinyspy: "npm:^3.0.0" + checksum: 10c0/b251be1390c105b68aa95270159c4583c3e1a0f7a2e1f82db8b7fadedc3cb459c5ef9286033a1ae764810e00715552fc80afe4507cd8b0065934fb1a64926e06 + languageName: node + linkType: hard + +"@vitest/utils@npm:2.0.5": + version: 2.0.5 + resolution: "@vitest/utils@npm:2.0.5" + dependencies: + "@vitest/pretty-format": "npm:2.0.5" + estree-walker: "npm:^3.0.3" + loupe: "npm:^3.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/0d1de748298f07a50281e1ba058b05dcd58da3280c14e6f016265e950bd79adab6b97822de8f0ea82d3070f585654801a9b1bcf26db4372e51cf7746bf86d73b + languageName: node + linkType: hard + +"@vitest/utils@npm:2.1.1, @vitest/utils@npm:^2.0.5": + version: 2.1.1 + resolution: "@vitest/utils@npm:2.1.1" + dependencies: + "@vitest/pretty-format": "npm:2.1.1" + loupe: "npm:^3.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/b724c7f23591860bd24cd8e6d0cd803405f4fbff746db160a948290742144463287566a05ca400deb56817603b5185c4429707947869c3d453805860b5e3a3e5 + languageName: node + linkType: hard + +"@xmldom/xmldom@npm:^0.8.8": + version: 0.8.10 + resolution: "@xmldom/xmldom@npm:0.8.10" + checksum: 10c0/c7647c442502720182b0d65b17d45d2d95317c1c8c497626fe524bda79b4fb768a9aa4fae2da919f308e7abcff7d67c058b102a9d641097e9a57f0b80187851f + languageName: node + linkType: hard + +"@xterm/addon-fit@npm:^0.10.0": + version: 0.10.0 + resolution: "@xterm/addon-fit@npm:0.10.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/76926120fc940376afef2cb68b15aec2a99fc628b6e3cc84f2bcb1682ca9b87f982b3c10ff206faf4ebc5b410467b81a7b5e83be37b4ac386586f472e4fa1c61 + languageName: node + linkType: hard + +"@xterm/addon-serialize@npm:^0.13.0": + version: 0.13.0 + resolution: "@xterm/addon-serialize@npm:0.13.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/090f502867250cdceca9abdbe37aebe0c01eb5d708a09c51d3e0e9bb5d4d3b0d4d67af1c4c8bde9b78f108a5e4150420180de69e6228aac76596fdbf6c4c59dc + languageName: node + linkType: hard + +"@xterm/addon-web-links@npm:^0.11.0": + version: 0.11.0 + resolution: "@xterm/addon-web-links@npm:0.11.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/9426bed80afa954b0ea97771d041eb44e77a64e560ce8b8ef507a5d3a763979af18ae9f74ed54007bb7e235d0daf035be2a33f90d8edfecb431caf8ba0b0664e + languageName: node + linkType: hard + +"@xterm/addon-webgl@npm:^0.18.0": + version: 0.18.0 + resolution: "@xterm/addon-webgl@npm:0.18.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/682a3f5f128ee09a0cf1b41cbb7b2f925a5e43056e12ba0c523b93a1f5f188045caef9e31f32db933b8a7a1b12d8f9babaddfa11e6f11df0c7b265009103476c + languageName: node + linkType: hard + +"@xterm/xterm@npm:^5.5.0": + version: 5.5.0 + resolution: "@xterm/xterm@npm:5.5.0" + checksum: 10c0/358801feece58617d777b2783bec68dac1f52f736da3b0317f71a34f4e25431fb0b1920244f678b8d673f797145b4858c2a5ccb463a4a6df7c10c9093f1c9267 + languageName: node + linkType: hard + +"abbrev@npm:^1.0.0": + version: 1.1.1 + resolution: "abbrev@npm:1.1.1" + checksum: 10c0/3f762677702acb24f65e813070e306c61fafe25d4b2583f9dfc935131f774863f3addd5741572ed576bd69cabe473c5af18e1e108b829cb7b6b4747884f726e6 + languageName: node + linkType: hard + +"abbrev@npm:^2.0.0": + version: 2.0.0 + resolution: "abbrev@npm:2.0.0" + checksum: 10c0/f742a5a107473946f426c691c08daba61a1d15942616f300b5d32fd735be88fef5cba24201757b6c407fd564555fb48c751cfa33519b2605c8a7aadd22baf372 + languageName: node + linkType: hard + +"accepts@npm:~1.3.8": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: "npm:~2.1.34" + negotiator: "npm:0.6.3" + checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 + languageName: node + linkType: hard + +"acorn-jsx@npm:^5.3.1, acorn-jsx@npm:^5.3.2": + version: 5.3.2 + resolution: "acorn-jsx@npm:5.3.2" + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/4c54868fbef3b8d58927d5e33f0a4de35f59012fe7b12cf9dfbb345fb8f46607709e1c4431be869a23fb63c151033d84c4198fa9f79385cec34fcb1dd53974c1 + languageName: node + linkType: hard + +"acorn-walk@npm:^7.2.0": + version: 7.2.0 + resolution: "acorn-walk@npm:7.2.0" + checksum: 10c0/ff99f3406ed8826f7d6ef6ac76b7608f099d45a1ff53229fa267125da1924188dbacf02e7903dfcfd2ae4af46f7be8847dc7d564c73c4e230dfb69c8ea8e6b4c + languageName: node + linkType: hard + +"acorn-walk@npm:^8.1.1": + version: 8.3.3 + resolution: "acorn-walk@npm:8.3.3" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10c0/4a9e24313e6a0a7b389e712ba69b66b455b4cb25988903506a8d247e7b126f02060b05a8a5b738a9284214e4ca95f383dd93443a4ba84f1af9b528305c7f243b + languageName: node + linkType: hard + +"acorn@npm:^7.4.1": + version: 7.4.1 + resolution: "acorn@npm:7.4.1" + bin: + acorn: bin/acorn + checksum: 10c0/bd0b2c2b0f334bbee48828ff897c12bd2eb5898d03bf556dcc8942022cec795ac5bb5b6b585e2de687db6231faf07e096b59a361231dd8c9344d5df5f7f0e526 + languageName: node + linkType: hard + +"acorn@npm:^8.11.0, acorn@npm:^8.12.0, acorn@npm:^8.12.1, acorn@npm:^8.4.1": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" + bin: + acorn: bin/acorn + checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 + languageName: node + linkType: hard + +"agent-base@npm:6, agent-base@npm:^6.0.2": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + +"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": + version: 7.1.1 + resolution: "agent-base@npm:7.1.1" + dependencies: + debug: "npm:^4.3.4" + checksum: 10c0/e59ce7bed9c63bf071a30cc471f2933862044c97fd9958967bfe22521d7a0f601ce4ed5a8c011799d0c726ca70312142ae193bbebb60f576b52be19d4a363b50 + languageName: node + linkType: hard + +"agentkeepalive@npm:^4.2.1": + version: 4.5.0 + resolution: "agentkeepalive@npm:4.5.0" + dependencies: + humanize-ms: "npm:^1.2.1" + checksum: 10c0/394ea19f9710f230722996e156607f48fdf3a345133b0b1823244b7989426c16019a428b56c82d3eabef616e938812981d9009f4792ecc66bd6a59e991c62612 + languageName: node + linkType: hard + +"aggregate-error@npm:^3.0.0": + version: 3.1.0 + resolution: "aggregate-error@npm:3.1.0" + dependencies: + clean-stack: "npm:^2.0.0" + indent-string: "npm:^4.0.0" + checksum: 10c0/a42f67faa79e3e6687a4923050e7c9807db3848a037076f791d10e092677d65c1d2d863b7848560699f40fc0502c19f40963fb1cd1fb3d338a7423df8e45e039 + languageName: node + linkType: hard + +"ajv-keywords@npm:^3.4.1": + version: 3.5.2 + resolution: "ajv-keywords@npm:3.5.2" + peerDependencies: + ajv: ^6.9.1 + checksum: 10c0/0c57a47cbd656e8cdfd99d7c2264de5868918ffa207c8d7a72a7f63379d4333254b2ba03d69e3c035e996a3fd3eb6d5725d7a1597cca10694296e32510546360 + languageName: node + linkType: hard + +"ajv@npm:^6.10.0, ajv@npm:^6.12.0, ajv@npm:^6.12.4": + version: 6.12.6 + resolution: "ajv@npm:6.12.6" + dependencies: + fast-deep-equal: "npm:^3.1.1" + fast-json-stable-stringify: "npm:^2.0.0" + json-schema-traverse: "npm:^0.4.1" + uri-js: "npm:^4.2.2" + checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + languageName: node + linkType: hard + +"ansi-colors@npm:^4.1.3": + version: 4.1.3 + resolution: "ansi-colors@npm:4.1.3" + checksum: 10c0/ec87a2f59902f74e61eada7f6e6fe20094a628dab765cfdbd03c3477599368768cffccdb5d3bb19a1b6c99126783a143b1fee31aab729b31ffe5836c7e5e28b9 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 + languageName: node + linkType: hard + +"ansi-regex@npm:^6.0.1": + version: 6.0.1 + resolution: "ansi-regex@npm:6.0.1" + checksum: 10c0/cbe16dbd2c6b2735d1df7976a7070dd277326434f0212f43abf6d87674095d247968209babdaad31bb00882fa68807256ba9be340eec2f1004de14ca75f52a08 + languageName: node + linkType: hard + +"ansi-styles@npm:^3.2.1": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10c0/ece5a8ef069fcc5298f67e3f4771a663129abd174ea2dfa87923a2be2abf6cd367ef72ac87942da00ce85bd1d651d4cd8595aebdb1b385889b89b205860e977b + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 + languageName: node + linkType: hard + +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df + languageName: node + linkType: hard + +"ansi-styles@npm:^6.1.0": + version: 6.2.1 + resolution: "ansi-styles@npm:6.2.1" + checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c + languageName: node + linkType: hard + +"anymatch@npm:~3.1.2": + version: 3.1.3 + resolution: "anymatch@npm:3.1.3" + dependencies: + normalize-path: "npm:^3.0.0" + picomatch: "npm:^2.0.4" + checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac + languageName: node + linkType: hard + +"app-builder-bin@npm:5.0.0-alpha.7": + version: 5.0.0-alpha.7 + resolution: "app-builder-bin@npm:5.0.0-alpha.7" + checksum: 10c0/0cd587d797613bc2f496533c89711f4314dcb99778ff3159f1156d18d2d929f5358de8e455354d6a16b33cc83853dd884abdc488d6e78ecb5e61b640bf48c34d + languageName: node + linkType: hard + +"app-builder-lib@npm:25.0.5": + version: 25.0.5 + resolution: "app-builder-lib@npm:25.0.5" + dependencies: + "@develar/schema-utils": "npm:~2.6.5" + "@electron/notarize": "npm:2.3.2" + "@electron/osx-sign": "npm:1.3.1" + "@electron/rebuild": "npm:3.6.0" + "@electron/universal": "npm:2.0.1" + "@malept/flatpak-bundler": "npm:^0.4.0" + "@types/fs-extra": "npm:9.0.13" + async-exit-hook: "npm:^2.0.1" + bluebird-lst: "npm:^1.0.9" + builder-util: "npm:25.0.3" + builder-util-runtime: "npm:9.2.5" + chromium-pickle-js: "npm:^0.2.0" + debug: "npm:^4.3.4" + ejs: "npm:^3.1.8" + electron-publish: "npm:25.0.3" + form-data: "npm:^4.0.0" + fs-extra: "npm:^10.1.0" + hosted-git-info: "npm:^4.1.0" + is-ci: "npm:^3.0.0" + isbinaryfile: "npm:^5.0.0" + js-yaml: "npm:^4.1.0" + lazy-val: "npm:^1.0.5" + minimatch: "npm:^10.0.0" + read-config-file: "npm:6.4.0" + resedit: "npm:^1.7.0" + sanitize-filename: "npm:^1.6.3" + semver: "npm:^7.3.8" + tar: "npm:^6.1.12" + temp-file: "npm:^3.4.0" + peerDependencies: + dmg-builder: 25.0.5 + electron-builder-squirrel-windows: 25.0.5 + checksum: 10c0/bd74b5c8694f4957980409e64725166a642a1e2f9ca94fbc67ba55264539087bf869bb090fc2ca22d990d286b27f07154d2e6b1a10740ffbf1d6ed48b9b2bfb8 + languageName: node + linkType: hard + +"aproba@npm:^1.0.3 || ^2.0.0": + version: 2.0.0 + resolution: "aproba@npm:2.0.0" + checksum: 10c0/d06e26384a8f6245d8c8896e138c0388824e259a329e0c9f196b4fa533c82502a6fd449586e3604950a0c42921832a458bb3aa0aa9f0ba449cfd4f50fd0d09b5 + languageName: node + linkType: hard + +"are-we-there-yet@npm:^3.0.0": + version: 3.0.1 + resolution: "are-we-there-yet@npm:3.0.1" + dependencies: + delegates: "npm:^1.0.0" + readable-stream: "npm:^3.6.0" + checksum: 10c0/8373f289ba42e4b5ec713bb585acdac14b5702c75f2a458dc985b9e4fa5762bc5b46b40a21b72418a3ed0cfb5e35bdc317ef1ae132f3035f633d581dd03168c3 + languageName: node + linkType: hard + +"arg@npm:^4.1.0": + version: 4.1.3 + resolution: "arg@npm:4.1.3" + checksum: 10c0/070ff801a9d236a6caa647507bdcc7034530604844d64408149a26b9e87c2f97650055c0f049abd1efc024b334635c01f29e0b632b371ac3f26130f4cf65997a + languageName: node + linkType: hard + +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e + languageName: node + linkType: hard + +"aria-query@npm:5.3.0, aria-query@npm:^5.0.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: "npm:^2.0.3" + checksum: 10c0/2bff0d4eba5852a9dd578ecf47eaef0e82cc52569b48469b0aac2db5145db0b17b7a58d9e01237706d1e14b7a1b0ac9b78e9c97027ad97679dd8f91b85da1469 + languageName: node + linkType: hard + +"array-flatten@npm:1.1.1": + version: 1.1.1 + resolution: "array-flatten@npm:1.1.1" + checksum: 10c0/806966c8abb2f858b08f5324d9d18d7737480610f3bd5d3498aaae6eb5efdc501a884ba019c9b4a8f02ff67002058749d05548fd42fa8643f02c9c7f22198b91 + languageName: node + linkType: hard + +"assert-plus@npm:^1.0.0": + version: 1.0.0 + resolution: "assert-plus@npm:1.0.0" + checksum: 10c0/b194b9d50c3a8f872ee85ab110784911e696a4d49f7ee6fc5fb63216dedbefd2c55999c70cb2eaeb4cf4a0e0338b44e9ace3627117b5bf0d42460e9132f21b91 + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"ast-types@npm:^0.16.1": + version: 0.16.1 + resolution: "ast-types@npm:0.16.1" + dependencies: + tslib: "npm:^2.0.1" + checksum: 10c0/abcc49e42eb921a7ebc013d5bec1154651fb6dbc3f497541d488859e681256901b2990b954d530ba0da4d0851271d484f7057d5eff5e07cb73e8b10909f711bf + languageName: node + linkType: hard + +"astral-regex@npm:^2.0.0": + version: 2.0.0 + resolution: "astral-regex@npm:2.0.0" + checksum: 10c0/f63d439cc383db1b9c5c6080d1e240bd14dae745f15d11ec5da863e182bbeca70df6c8191cffef5deba0b566ef98834610a68be79ac6379c95eeb26e1b310e25 + languageName: node + linkType: hard + +"async-exit-hook@npm:^2.0.1": + version: 2.0.1 + resolution: "async-exit-hook@npm:2.0.1" + checksum: 10c0/81407a440ef0aab328df2369f1a9d957ee53e9a5a43e3b3dcb2be05151a68de0e4ff5e927f4718c88abf85800731f5b3f69a47a6642ce135f5e7d43ca0fce41d + languageName: node + linkType: hard + +"async@npm:^3.2.3": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"at-least-node@npm:^1.0.0": + version: 1.0.0 + resolution: "at-least-node@npm:1.0.0" + checksum: 10c0/4c058baf6df1bc5a1697cf182e2029c58cd99975288a13f9e70068ef5d6f4e1f1fd7c4d2c3c4912eae44797d1725be9700995736deca441b39f3e66d8dee97ef + languageName: node + linkType: hard + +"available-typed-arrays@npm:^1.0.7": + version: 1.0.7 + resolution: "available-typed-arrays@npm:1.0.7" + dependencies: + possible-typed-array-names: "npm:^1.0.0" + checksum: 10c0/d07226ef4f87daa01bd0fe80f8f310982e345f372926da2e5296aecc25c41cab440916bbaa4c5e1034b453af3392f67df5961124e4b586df1e99793a1374bdb2 + languageName: node + linkType: hard + +"babylon@npm:^6.15.0": + version: 6.18.0 + resolution: "babylon@npm:6.18.0" + bin: + babylon: ./bin/babylon.js + checksum: 10c0/9b1bf946e16782deadb1f5414c1269efa6044eb1e97a3de2051f09a3f2a54e97be3542d4242b28d23de0ef67816f519d38ce1ec3ddb7be306131c39a60e5a667 + languageName: node + linkType: hard + +"bail@npm:^2.0.0": + version: 2.0.2 + resolution: "bail@npm:2.0.2" + checksum: 10c0/25cbea309ef6a1f56214187004e8f34014eb015713ea01fa5b9b7e9e776ca88d0fdffd64143ac42dc91966c915a4b7b683411b56e14929fad16153fc026ffb8b + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + +"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + +"binary-extensions@npm:^2.0.0": + version: 2.3.0 + resolution: "binary-extensions@npm:2.3.0" + checksum: 10c0/75a59cafc10fb12a11d510e77110c6c7ae3f4ca22463d52487709ca7f18f69d886aa387557cc9864fbdb10153d0bdb4caacabf11541f55e89ed6e18d12ece2b5 + languageName: node + linkType: hard + +"binary-search-bounds@npm:^2.0.0": + version: 2.0.5 + resolution: "binary-search-bounds@npm:2.0.5" + checksum: 10c0/d433db5fba086d1585e268c0b0b7ae6ec359df60b9135112d1fd4dcf2f8a8820caab7e2523bbbca0a9cae0725486f67a6663a87b5637f0e58f4201985e6e5b0b + languageName: node + linkType: hard + +"binary-searching@npm:^2.0.5": + version: 2.0.5 + resolution: "binary-searching@npm:2.0.5" + checksum: 10c0/914ccf15d4c989a8900e5617e2b6ec77a016f894b3833eaa5720a310214420dbd5d8eb577c158f99d25769968225c522cc37580c8d2ed46cc469f9d0365b7f15 + languageName: node + linkType: hard + +"bl@npm:^4.1.0": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + +"bluebird-lst@npm:^1.0.9": + version: 1.0.9 + resolution: "bluebird-lst@npm:1.0.9" + dependencies: + bluebird: "npm:^3.5.5" + checksum: 10c0/701eef18f37a53277adeacb21281a70fc4536e521fe0deb665a284f4d8480056c6932988c3dfa6a0c46b4d55f4599f716a15873f30ed5fc2470928093438f87e + languageName: node + linkType: hard + +"bluebird@npm:^3.5.5": + version: 3.7.2 + resolution: "bluebird@npm:3.7.2" + checksum: 10c0/680de03adc54ff925eaa6c7bb9a47a0690e8b5de60f4792604aae8ed618c65e6b63a7893b57ca924beaf53eee69c5af4f8314148c08124c550fe1df1add897d2 + languageName: node + linkType: hard + +"body-parser@npm:1.20.3": + version: 1.20.3 + resolution: "body-parser@npm:1.20.3" + dependencies: + bytes: "npm:3.1.2" + content-type: "npm:~1.0.5" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.4.24" + on-finished: "npm:2.4.1" + qs: "npm:6.13.0" + raw-body: "npm:2.5.2" + type-is: "npm:~1.6.18" + unpipe: "npm:1.0.0" + checksum: 10c0/0a9a93b7518f222885498dcecaad528cf010dd109b071bf471c93def4bfe30958b83e03496eb9c1ad4896db543d999bb62be1a3087294162a88cfa1b42c16310 + languageName: node + linkType: hard + +"boolean@npm:^3.0.1": + version: 3.2.0 + resolution: "boolean@npm:3.2.0" + checksum: 10c0/6a0dc9668f6f3dda42a53c181fcbdad223169c8d87b6c4011b87a8b14a21770efb2934a778f063d7ece17280f8c06d313c87f7b834bb1dd526a867ffcd00febf + languageName: node + linkType: hard + +"brace-expansion@npm:^1.1.7": + version: 1.1.11 + resolution: "brace-expansion@npm:1.1.11" + dependencies: + balanced-match: "npm:^1.0.0" + concat-map: "npm:0.0.1" + checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668 + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.1": + version: 2.0.1 + resolution: "brace-expansion@npm:2.0.1" + dependencies: + balanced-match: "npm:^1.0.0" + checksum: 10c0/b358f2fe060e2d7a87aa015979ecea07f3c37d4018f8d6deb5bd4c229ad3a0384fe6029bb76cd8be63c81e516ee52d1a0673edbe2023d53a5191732ae3c3e49f + languageName: node + linkType: hard + +"braces@npm:^3.0.3, braces@npm:~3.0.2": + version: 3.0.3 + resolution: "braces@npm:3.0.3" + dependencies: + fill-range: "npm:^7.1.1" + checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 + languageName: node + linkType: hard + +"browser-assert@npm:^1.2.1": + version: 1.2.1 + resolution: "browser-assert@npm:1.2.1" + checksum: 10c0/902abf999f92c9c951fdb6d7352c09eea9a84706258699655f7e7906e42daa06a1ae286398a755872740e05a6a71c43c5d1a0c0431d67a8cdb66e5d859a3fc0c + languageName: node + linkType: hard + +"browserslist@npm:^4.23.1": + version: 4.23.3 + resolution: "browserslist@npm:4.23.3" + dependencies: + caniuse-lite: "npm:^1.0.30001646" + electron-to-chromium: "npm:^1.5.4" + node-releases: "npm:^2.0.18" + update-browserslist-db: "npm:^1.1.0" + bin: + browserslist: cli.js + checksum: 10c0/3063bfdf812815346447f4796c8f04601bf5d62003374305fd323c2a463e42776475bcc5309264e39bcf9a8605851e53560695991a623be988138b3ff8c66642 + languageName: node + linkType: hard + +"buffer-crc32@npm:~0.2.3": + version: 0.2.13 + resolution: "buffer-crc32@npm:0.2.13" + checksum: 10c0/cb0a8ddf5cf4f766466db63279e47761eb825693eeba6a5a95ee4ec8cb8f81ede70aa7f9d8aeec083e781d47154290eb5d4d26b3f7a465ec57fb9e7d59c47150 + languageName: node + linkType: hard + +"buffer-from@npm:^1.0.0": + version: 1.1.2 + resolution: "buffer-from@npm:1.1.2" + checksum: 10c0/124fff9d66d691a86d3b062eff4663fe437a9d9ee4b47b1b9e97f5a5d14f6d5399345db80f796827be7c95e70a8e765dd404b7c3ff3b3324f98e9b0c8826cc34 + languageName: node + linkType: hard + +"buffer@npm:^5.1.0, buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + +"builder-util-runtime@npm:9.2.5": + version: 9.2.5 + resolution: "builder-util-runtime@npm:9.2.5" + dependencies: + debug: "npm:^4.3.4" + sax: "npm:^1.2.4" + checksum: 10c0/1791a2543355bf9656b3321d20e2a23b1e61c4a7172a824767379da6321f8e6e8a33347238c64c9ac81168b41ebf5c66ce7a69c3c35b1f5e230072d9fe36c605 + languageName: node + linkType: hard + +"builder-util@npm:25.0.3": + version: 25.0.3 + resolution: "builder-util@npm:25.0.3" + dependencies: + 7zip-bin: "npm:~5.2.0" + "@types/debug": "npm:^4.1.6" + app-builder-bin: "npm:5.0.0-alpha.7" + bluebird-lst: "npm:^1.0.9" + builder-util-runtime: "npm:9.2.5" + chalk: "npm:^4.1.2" + cross-spawn: "npm:^7.0.3" + debug: "npm:^4.3.4" + fs-extra: "npm:^10.1.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" + is-ci: "npm:^3.0.0" + js-yaml: "npm:^4.1.0" + source-map-support: "npm:^0.5.19" + stat-mode: "npm:^1.0.0" + temp-file: "npm:^3.4.0" + checksum: 10c0/cce0aad966d275c38eaf556152140f213d141553afe6feb193a23d20b4064cba18a6dc98f62348e32c3727d87ce8d1a3e573b5d636fe194e0765a63e585511ad + languageName: node + linkType: hard + +"builtin-modules@npm:^3.3.0": + version: 3.3.0 + resolution: "builtin-modules@npm:3.3.0" + checksum: 10c0/2cb3448b4f7306dc853632a4fcddc95e8d4e4b9868c139400027b71938fc6806d4ff44007deffb362ac85724bd40c2c6452fb6a0aa4531650eeddb98d8e5ee8a + languageName: node + linkType: hard + +"bytes@npm:3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e + languageName: node + linkType: hard + +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 + languageName: node + linkType: hard + +"cacache@npm:^16.1.0": + version: 16.1.3 + resolution: "cacache@npm:16.1.3" + dependencies: + "@npmcli/fs": "npm:^2.1.0" + "@npmcli/move-file": "npm:^2.0.0" + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.1.0" + glob: "npm:^8.0.1" + infer-owner: "npm:^1.0.4" + lru-cache: "npm:^7.7.1" + minipass: "npm:^3.1.6" + minipass-collect: "npm:^1.0.2" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + mkdirp: "npm:^1.0.4" + p-map: "npm:^4.0.0" + promise-inflight: "npm:^1.0.1" + rimraf: "npm:^3.0.2" + ssri: "npm:^9.0.0" + tar: "npm:^6.1.11" + unique-filename: "npm:^2.0.0" + checksum: 10c0/cdf6836e1c457d2a5616abcaf5d8240c0346b1f5bd6fdb8866b9d84b6dff0b54e973226dc11e0d099f35394213d24860d1989c8358d2a41b39eb912b3000e749 + languageName: node + linkType: hard + +"cacache@npm:^18.0.0": + version: 18.0.4 + resolution: "cacache@npm:18.0.4" + dependencies: + "@npmcli/fs": "npm:^3.1.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^10.2.2" + lru-cache: "npm:^10.0.1" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^4.0.0" + ssri: "npm:^10.0.0" + tar: "npm:^6.1.11" + unique-filename: "npm:^3.0.0" + checksum: 10c0/6c055bafed9de4f3dcc64ac3dc7dd24e863210902b7c470eb9ce55a806309b3efff78033e3d8b4f7dcc5d467f2db43c6a2857aaaf26f0094b8a351d44c42179f + languageName: node + linkType: hard + +"cacheable-lookup@npm:^5.0.3": + version: 5.0.4 + resolution: "cacheable-lookup@npm:5.0.4" + checksum: 10c0/a6547fb4954b318aa831cbdd2f7b376824bc784fb1fa67610e4147099e3074726072d9af89f12efb69121415a0e1f2918a8ddd4aafcbcf4e91fbeef4a59cd42c + languageName: node + linkType: hard + +"cacheable-request@npm:^7.0.2": + version: 7.0.4 + resolution: "cacheable-request@npm:7.0.4" + dependencies: + clone-response: "npm:^1.0.2" + get-stream: "npm:^5.1.0" + http-cache-semantics: "npm:^4.0.0" + keyv: "npm:^4.0.0" + lowercase-keys: "npm:^2.0.0" + normalize-url: "npm:^6.0.1" + responselike: "npm:^2.0.0" + checksum: 10c0/0834a7d17ae71a177bc34eab06de112a43f9b5ad05ebe929bec983d890a7d9f2bc5f1aa8bb67ea2b65e07a3bc74bea35fa62dd36dbac52876afe36fdcf83da41 + languageName: node + linkType: hard + +"call-bind@npm:^1.0.2, call-bind@npm:^1.0.7": + version: 1.0.7 + resolution: "call-bind@npm:1.0.7" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + set-function-length: "npm:^1.2.1" + checksum: 10c0/a3ded2e423b8e2a265983dba81c27e125b48eefb2655e7dfab6be597088da3d47c47976c24bc51b8fd9af1061f8f87b4ab78a314f3c77784b2ae2ba535ad8b8d + languageName: node + linkType: hard + +"callsites@npm:^3.0.0": + version: 3.1.0 + resolution: "callsites@npm:3.1.0" + checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 + languageName: node + linkType: hard + +"camelcase@npm:^6.2.0": + version: 6.3.0 + resolution: "camelcase@npm:6.3.0" + checksum: 10c0/0d701658219bd3116d12da3eab31acddb3f9440790c0792e0d398f0a520a6a4058018e546862b6fba89d7ae990efaeb97da71e1913e9ebf5a8b5621a3d55c710 + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001646": + version: 1.0.30001651 + resolution: "caniuse-lite@npm:1.0.30001651" + checksum: 10c0/7821278952a6dbd17358e5d08083d258f092e2a530f5bc1840657cb140fbbc5ec44293bc888258c44a18a9570cde149ed05819ac8320b9710cf22f699891e6ad + languageName: node + linkType: hard + +"ccount@npm:^2.0.0": + version: 2.0.1 + resolution: "ccount@npm:2.0.1" + checksum: 10c0/3939b1664390174484322bc3f45b798462e6c07ee6384cb3d645e0aa2f318502d174845198c1561930e1d431087f74cf1fe291ae9a4722821a9f4ba67e574350 + languageName: node + linkType: hard + +"chai@npm:^5.1.1": + version: 5.1.1 + resolution: "chai@npm:5.1.1" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/e7f00e5881e3d5224f08fe63966ed6566bd9fdde175863c7c16dd5240416de9b34c4a0dd925f4fd64ad56256ca6507d32cf6131c49e1db65c62578eb31d4566c + languageName: node + linkType: hard + +"chalk@npm:^2.4.2": + version: 2.4.2 + resolution: "chalk@npm:2.4.2" + dependencies: + ansi-styles: "npm:^3.2.1" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^5.3.0" + checksum: 10c0/e6543f02ec877732e3a2d1c3c3323ddb4d39fbab687c23f526e25bd4c6a9bf3b83a696e8c769d078e04e5754921648f7821b2a2acfd16c550435fd630026e073 + languageName: node + linkType: hard + +"chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/ee650b0a065b3d7a6fda258e75d3a86fc8e4effa55871da730a9e42ccb035bf5fd203525e5a1ef45ec2582ecc4f65b47eb11357c526b84dd29a14fb162c414d2 + languageName: node + linkType: hard + +"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 + languageName: node + linkType: hard + +"character-entities-html4@npm:^2.0.0": + version: 2.1.0 + resolution: "character-entities-html4@npm:2.1.0" + checksum: 10c0/fe61b553f083400c20c0b0fd65095df30a0b445d960f3bbf271536ae6c3ba676f39cb7af0b4bf2755812f08ab9b88f2feed68f9aebb73bb153f7a115fe5c6e40 + languageName: node + linkType: hard + +"character-entities-legacy@npm:^3.0.0": + version: 3.0.0 + resolution: "character-entities-legacy@npm:3.0.0" + checksum: 10c0/ec4b430af873661aa754a896a2b55af089b4e938d3d010fad5219299a6b6d32ab175142699ee250640678cd64bdecd6db3c9af0b8759ab7b155d970d84c4c7d1 + languageName: node + linkType: hard + +"character-entities@npm:^2.0.0": + version: 2.0.2 + resolution: "character-entities@npm:2.0.2" + checksum: 10c0/b0c645a45bcc90ff24f0e0140f4875a8436b8ef13b6bcd31ec02cfb2ca502b680362aa95386f7815bdc04b6464d48cf191210b3840d7c04241a149ede591a308 + languageName: node + linkType: hard + +"character-reference-invalid@npm:^2.0.0": + version: 2.0.1 + resolution: "character-reference-invalid@npm:2.0.1" + checksum: 10c0/2ae0dec770cd8659d7e8b0ce24392d83b4c2f0eb4a3395c955dce5528edd4cc030a794cfa06600fcdd700b3f2de2f9b8e40e309c0011c4180e3be64a0b42e6a1 + languageName: node + linkType: hard + +"check-error@npm:^2.1.1": + version: 2.1.1 + resolution: "check-error@npm:2.1.1" + checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e + languageName: node + linkType: hard + +"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": + version: 3.6.0 + resolution: "chokidar@npm:3.6.0" + dependencies: + anymatch: "npm:~3.1.2" + braces: "npm:~3.0.2" + fsevents: "npm:~2.3.2" + glob-parent: "npm:~5.1.2" + is-binary-path: "npm:~2.1.0" + is-glob: "npm:~4.0.1" + normalize-path: "npm:~3.0.0" + readdirp: "npm:~3.6.0" + dependenciesMeta: + fsevents: + optional: true + checksum: 10c0/8361dcd013f2ddbe260eacb1f3cb2f2c6f2b0ad118708a343a5ed8158941a39cb8fb1d272e0f389712e74ee90ce8ba864eece9e0e62b9705cb468a2f6d917462 + languageName: node + linkType: hard + +"chownr@npm:^2.0.0": + version: 2.0.0 + resolution: "chownr@npm:2.0.0" + checksum: 10c0/594754e1303672171cc04e50f6c398ae16128eb134a88f801bf5354fd96f205320f23536a045d9abd8b51024a149696e51231565891d4efdab8846021ecf88e6 + languageName: node + linkType: hard + +"chromatic@npm:^11.4.0": + version: 11.7.1 + resolution: "chromatic@npm:11.7.1" + peerDependencies: + "@chromatic-com/cypress": ^0.*.* || ^1.0.0 + "@chromatic-com/playwright": ^0.*.* || ^1.0.0 + peerDependenciesMeta: + "@chromatic-com/cypress": + optional: true + "@chromatic-com/playwright": + optional: true + bin: + chroma: dist/bin.js + chromatic: dist/bin.js + chromatic-cli: dist/bin.js + checksum: 10c0/367dd36994062a82114859a746b84607fa3b3f5783dca28c015d0b490c4bff58f58ef3247be6b4c3f9be57ac7f91a6ead3261bac75befa1707c083415f36dc55 + languageName: node + linkType: hard + +"chromium-pickle-js@npm:^0.2.0": + version: 0.2.0 + resolution: "chromium-pickle-js@npm:0.2.0" + checksum: 10c0/0a95bd280acdf05b0e08fa1a0e1db58c815dd24e92d639add8f494d23a8a49c26e4829721224d68f2f0e57a69047714db29bcff6deb5d029332321223416cb29 + languageName: node + linkType: hard + +"ci-info@npm:^3.2.0": + version: 3.9.0 + resolution: "ci-info@npm:3.9.0" + checksum: 10c0/6f0109e36e111684291d46123d491bc4e7b7a1934c3a20dea28cba89f1d4a03acd892f5f6a81ed3855c38647e285a150e3c9ba062e38943bef57fee6c1554c3a + languageName: node + linkType: hard + +"clean-stack@npm:^2.0.0": + version: 2.2.0 + resolution: "clean-stack@npm:2.2.0" + checksum: 10c0/1f90262d5f6230a17e27d0c190b09d47ebe7efdd76a03b5a1127863f7b3c9aec4c3e6c8bb3a7bbf81d553d56a1fd35728f5a8ef4c63f867ac8d690109742a8c1 + languageName: node + linkType: hard + +"cli-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "cli-cursor@npm:3.1.0" + dependencies: + restore-cursor: "npm:^3.1.0" + checksum: 10c0/92a2f98ff9037d09be3dfe1f0d749664797fb674bf388375a2207a1203b69d41847abf16434203e0089212479e47a358b13a0222ab9fccfe8e2644a7ccebd111 + languageName: node + linkType: hard + +"cli-spinners@npm:^2.5.0": + version: 2.9.2 + resolution: "cli-spinners@npm:2.9.2" + checksum: 10c0/907a1c227ddf0d7a101e7ab8b300affc742ead4b4ebe920a5bf1bc6d45dce2958fcd195eb28fa25275062fe6fa9b109b93b63bc8033396ed3bcb50297008b3a3 + languageName: node + linkType: hard + +"cli-truncate@npm:^2.1.0": + version: 2.1.0 + resolution: "cli-truncate@npm:2.1.0" + dependencies: + slice-ansi: "npm:^3.0.0" + string-width: "npm:^4.2.0" + checksum: 10c0/dfaa3df675bcef7a3254773de768712b590250420345a4c7ac151f041a4bacb4c25864b1377bee54a39b5925a030c00eabf014e312e3a4ac130952ed3b3879e9 + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 + languageName: node + linkType: hard + +"clone-response@npm:^1.0.2": + version: 1.0.3 + resolution: "clone-response@npm:1.0.3" + dependencies: + mimic-response: "npm:^1.0.0" + checksum: 10c0/06a2b611824efb128810708baee3bd169ec9a1bf5976a5258cd7eb3f7db25f00166c6eee5961f075c7e38e194f373d4fdf86b8166ad5b9c7e82bbd2e333a6087 + languageName: node + linkType: hard + +"clone@npm:^1.0.2": + version: 1.0.4 + resolution: "clone@npm:1.0.4" + checksum: 10c0/2176952b3649293473999a95d7bebfc9dc96410f6cbd3d2595cf12fd401f63a4bf41a7adbfd3ab2ff09ed60cb9870c58c6acdd18b87767366fabfc163700f13b + languageName: node + linkType: hard + +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0, color-convert@npm:^1.9.3": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 + languageName: node + linkType: hard + +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 + languageName: node + linkType: hard + +"color-name@npm:^1.0.0, color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 + languageName: node + linkType: hard + +"color-string@npm:^1.6.0, color-string@npm:^1.9.0": + version: 1.9.1 + resolution: "color-string@npm:1.9.1" + dependencies: + color-name: "npm:^1.0.0" + simple-swizzle: "npm:^0.2.2" + checksum: 10c0/b0bfd74c03b1f837f543898b512f5ea353f71630ccdd0d66f83028d1f0924a7d4272deb278b9aef376cacf1289b522ac3fb175e99895283645a2dc3a33af2404 + languageName: node + linkType: hard + +"color-support@npm:^1.1.3": + version: 1.1.3 + resolution: "color-support@npm:1.1.3" + bin: + color-support: bin.js + checksum: 10c0/8ffeaa270a784dc382f62d9be0a98581db43e11eee301af14734a6d089bd456478b1a8b3e7db7ca7dc5b18a75f828f775c44074020b51c05fc00e6d0992b1cc6 + languageName: node + linkType: hard + +"color@npm:^3.1.3": + version: 3.2.1 + resolution: "color@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.3" + color-string: "npm:^1.6.0" + checksum: 10c0/39345d55825884c32a88b95127d417a2c24681d8b57069413596d9fcbb721459ef9d9ec24ce3e65527b5373ce171b73e38dbcd9c830a52a6487e7f37bf00e83c + languageName: node + linkType: hard + +"color@npm:^4.2.3": + version: 4.2.3 + resolution: "color@npm:4.2.3" + dependencies: + color-convert: "npm:^2.0.1" + color-string: "npm:^1.9.0" + checksum: 10c0/7fbe7cfb811054c808349de19fb380252e5e34e61d7d168ec3353e9e9aacb1802674bddc657682e4e9730c2786592a4de6f8283e7e0d3870b829bb0b7b2f6118 + languageName: node + linkType: hard + +"colorspace@npm:1.1.x": + version: 1.1.4 + resolution: "colorspace@npm:1.1.4" + dependencies: + color: "npm:^3.1.3" + text-hex: "npm:1.0.x" + checksum: 10c0/af5f91ff7f8e146b96e439ac20ed79b197210193bde721b47380a75b21751d90fa56390c773bb67c0aedd34ff85091883a437ab56861c779bd507d639ba7e123 + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"comma-separated-tokens@npm:^2.0.0": + version: 2.0.3 + resolution: "comma-separated-tokens@npm:2.0.3" + checksum: 10c0/91f90f1aae320f1755d6957ef0b864fe4f54737f3313bd95e0802686ee2ca38bff1dd381964d00ae5db42912dd1f4ae5c2709644e82706ffc6f6842a813cdd67 + languageName: node + linkType: hard + +"commander@npm:7": + version: 7.2.0 + resolution: "commander@npm:7.2.0" + checksum: 10c0/8d690ff13b0356df7e0ebbe6c59b4712f754f4b724d4f473d3cc5b3fdcf978e3a5dc3078717858a2ceb50b0f84d0660a7f22a96cdc50fb877d0c9bb31593d23a + languageName: node + linkType: hard + +"commander@npm:^5.0.0": + version: 5.1.0 + resolution: "commander@npm:5.1.0" + checksum: 10c0/da9d71dbe4ce039faf1fe9eac3771dca8c11d66963341f62602f7b66e36d2a3f8883407af4f9a37b1db1a55c59c0c1325f186425764c2e963dc1d67aec2a4b6d + languageName: node + linkType: hard + +"comment-parser@npm:^1.4.0": + version: 1.4.1 + resolution: "comment-parser@npm:1.4.1" + checksum: 10c0/d6c4be3f5be058f98b24f2d557f745d8fe1cc9eb75bebbdccabd404a0e1ed41563171b16285f593011f8b6a5ec81f564fb1f2121418ac5cbf0f49255bf0840dd + languageName: node + linkType: hard + +"commondir@npm:^1.0.1": + version: 1.0.1 + resolution: "commondir@npm:1.0.1" + checksum: 10c0/33a124960e471c25ee19280c9ce31ccc19574b566dc514fe4f4ca4c34fa8b0b57cf437671f5de380e11353ea9426213fca17687dd2ef03134fea2dbc53809fd6 + languageName: node + linkType: hard + +"compare-version@npm:^0.1.2": + version: 0.1.2 + resolution: "compare-version@npm:0.1.2" + checksum: 10c0/f38b853cf0d244c0af5f444409abde3d2198cd97312efa1dbc4ab41b520009327c2a63db59bbaf2d69288eff6167ef22be9788dc5476157d073ecdff1a8eeb2d + languageName: node + linkType: hard + +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f + languageName: node + linkType: hard + +"config-file-ts@npm:0.2.8-rc1": + version: 0.2.8-rc1 + resolution: "config-file-ts@npm:0.2.8-rc1" + dependencies: + glob: "npm:^10.3.12" + typescript: "npm:^5.4.3" + checksum: 10c0/9839a8e33111156665c45c4e5dd6bfa81ee80596f9dc0a078465769b951e28c0fa4bd75bb9bc56f747da285b993fb7998c4c07c0f368ab6bdb019d203764cdc8 + languageName: node + linkType: hard + +"console-control-strings@npm:^1.1.0": + version: 1.1.0 + resolution: "console-control-strings@npm:1.1.0" + checksum: 10c0/7ab51d30b52d461412cd467721bb82afe695da78fff8f29fe6f6b9cbaac9a2328e27a22a966014df9532100f6dd85370460be8130b9c677891ba36d96a343f50 + languageName: node + linkType: hard + +"content-disposition@npm:0.5.4": + version: 0.5.4 + resolution: "content-disposition@npm:0.5.4" + dependencies: + safe-buffer: "npm:5.2.1" + checksum: 10c0/bac0316ebfeacb8f381b38285dc691c9939bf0a78b0b7c2d5758acadad242d04783cee5337ba7d12a565a19075af1b3c11c728e1e4946de73c6ff7ce45f3f1bb + languageName: node + linkType: hard + +"content-type@npm:~1.0.4, content-type@npm:~1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af + languageName: node + linkType: hard + +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + +"cookie-signature@npm:1.0.6": + version: 1.0.6 + resolution: "cookie-signature@npm:1.0.6" + checksum: 10c0/b36fd0d4e3fef8456915fcf7742e58fbfcc12a17a018e0eb9501c9d5ef6893b596466f03b0564b81af29ff2538fd0aa4b9d54fe5ccbfb4c90ea50ad29fe2d221 + languageName: node + linkType: hard + +"cookie@npm:0.6.0": + version: 0.6.0 + resolution: "cookie@npm:0.6.0" + checksum: 10c0/f2318b31af7a31b4ddb4a678d024514df5e705f9be5909a192d7f116cfb6d45cbacf96a473fa733faa95050e7cff26e7832bb3ef94751592f1387b71c8956686 + languageName: node + linkType: hard + +"copy-anything@npm:^2.0.1": + version: 2.0.6 + resolution: "copy-anything@npm:2.0.6" + dependencies: + is-what: "npm:^3.14.1" + checksum: 10c0/2702998a8cc015f9917385b7f16b0d85f1f6e5e2fd34d99f14df584838f492f49aa0c390d973684c687e895c5c58d08b308a0400ac3e1e3d6fa1e5884a5402ad + languageName: node + linkType: hard + +"core-util-is@npm:1.0.2": + version: 1.0.2 + resolution: "core-util-is@npm:1.0.2" + checksum: 10c0/980a37a93956d0de8a828ce508f9b9e3317039d68922ca79995421944146700e4aaf490a6dbfebcb1c5292a7184600c7710b957d724be1e37b8254c6bc0fe246 + languageName: node + linkType: hard + +"cosmiconfig@npm:^8.1.3": + version: 8.3.6 + resolution: "cosmiconfig@npm:8.3.6" + dependencies: + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + path-type: "npm:^4.0.0" + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/0382a9ed13208f8bfc22ca2f62b364855207dffdb73dc26e150ade78c3093f1cf56172df2dd460c8caf2afa91c0ed4ec8a88c62f8f9cd1cf423d26506aa8797a + languageName: node + linkType: hard + +"crc@npm:^3.8.0": + version: 3.8.0 + resolution: "crc@npm:3.8.0" + dependencies: + buffer: "npm:^5.1.0" + checksum: 10c0/1a0da36e5f95b19cd2a7b2eab5306a08f1c47bdd22da6f761ab764e2222e8e90a877398907cea94108bd5e41a6d311ea84d7914eaca67da2baa4050bd6384b3d + languageName: node + linkType: hard + +"create-require@npm:^1.1.0": + version: 1.1.1 + resolution: "create-require@npm:1.1.1" + checksum: 10c0/157cbc59b2430ae9a90034a5f3a1b398b6738bf510f713edc4d4e45e169bc514d3d99dd34d8d01ca7ae7830b5b8b537e46ae8f3c8f932371b0875c0151d7ec91 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 + languageName: node + linkType: hard + +"css-tree@npm:^3.0.0": + version: 3.0.0 + resolution: "css-tree@npm:3.0.0" + dependencies: + mdn-data: "npm:2.10.0" + source-map-js: "npm:^1.0.1" + checksum: 10c0/43d44fdf7004ae91d73d486f17894fef77efa33747a6752b9241cf0f5fb47fabc16ec34a96a993651d9014dfdeee803d7c5fcd3548214252ee19f4e5c98999b2 + languageName: node + linkType: hard + +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: 10c0/5e09035e5bf6c2c422b40c6df2eb1529657a17df37fda5d0433d722609527ab98090baf25b13970ca754079a0f3161dd3dfc0e743563ded8cfa0749d861c1525 + languageName: node + linkType: hard + +"csstype@npm:^3.0.2": + version: 3.1.3 + resolution: "csstype@npm:3.1.3" + checksum: 10c0/80c089d6f7e0c5b2bd83cf0539ab41474198579584fa10d86d0cafe0642202343cbc119e076a0b1aece191989477081415d66c9fefbf3c957fc2fc4b7009f248 + languageName: node + linkType: hard + +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.2.0": + version: 3.2.4 + resolution: "d3-array@npm:3.2.4" + dependencies: + internmap: "npm:1 - 2" + checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50 + languageName: node + linkType: hard + +"d3-axis@npm:3": + version: 3.0.0 + resolution: "d3-axis@npm:3.0.0" + checksum: 10c0/a271e70ba1966daa5aaf6a7f959ceca3e12997b43297e757c7b945db2e1ead3c6ee226f2abcfa22abbd4e2e28bd2b71a0911794c4e5b911bbba271328a582c78 + languageName: node + linkType: hard + +"d3-brush@npm:3": + version: 3.0.0 + resolution: "d3-brush@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-drag: "npm:2 - 3" + d3-interpolate: "npm:1 - 3" + d3-selection: "npm:3" + d3-transition: "npm:3" + checksum: 10c0/07baf00334c576da2f68a91fc0da5732c3a5fa19bd3d7aed7fd24d1d674a773f71a93e9687c154176f7246946194d77c48c2d8fed757f5dcb1a4740067ec50a8 + languageName: node + linkType: hard + +"d3-chord@npm:3": + version: 3.0.1 + resolution: "d3-chord@npm:3.0.1" + dependencies: + d3-path: "npm:1 - 3" + checksum: 10c0/baa6013914af3f4fe1521f0d16de31a38eb8a71d08ff1dec4741f6f45a828661e5cd3935e39bd14e3032bdc78206c283ca37411da21d46ec3cfc520be6e7a7ce + languageName: node + linkType: hard + +"d3-color@npm:1 - 3, d3-color@npm:3": + version: 3.1.0 + resolution: "d3-color@npm:3.1.0" + checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c + languageName: node + linkType: hard + +"d3-contour@npm:4": + version: 4.0.2 + resolution: "d3-contour@npm:4.0.2" + dependencies: + d3-array: "npm:^3.2.0" + checksum: 10c0/98bc5fbed6009e08707434a952076f39f1cd6ed8b9288253cc3e6a3286e4e80c63c62d84954b20e64bf6e4ededcc69add54d3db25e990784a59c04edd3449032 + languageName: node + linkType: hard + +"d3-delaunay@npm:6": + version: 6.0.4 + resolution: "d3-delaunay@npm:6.0.4" + dependencies: + delaunator: "npm:5" + checksum: 10c0/57c3aecd2525664b07c4c292aa11cf49b2752c0cf3f5257f752999399fe3c592de2d418644d79df1f255471eec8057a9cc0c3062ed7128cb3348c45f69597754 + languageName: node + linkType: hard + +"d3-dispatch@npm:1 - 3, d3-dispatch@npm:3": + version: 3.0.1 + resolution: "d3-dispatch@npm:3.0.1" + checksum: 10c0/6eca77008ce2dc33380e45d4410c67d150941df7ab45b91d116dbe6d0a3092c0f6ac184dd4602c796dc9e790222bad3ff7142025f5fd22694efe088d1d941753 + languageName: node + linkType: hard + +"d3-drag@npm:2 - 3, d3-drag@npm:3": + version: 3.0.0 + resolution: "d3-drag@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-selection: "npm:3" + checksum: 10c0/d2556e8dc720741a443b595a30af403dd60642dfd938d44d6e9bfc4c71a962142f9a028c56b61f8b4790b65a34acad177d1263d66f103c3c527767b0926ef5aa + languageName: node + linkType: hard + +"d3-dsv@npm:1 - 3, d3-dsv@npm:3": + version: 3.0.1 + resolution: "d3-dsv@npm:3.0.1" + dependencies: + commander: "npm:7" + iconv-lite: "npm:0.6" + rw: "npm:1" + bin: + csv2json: bin/dsv2json.js + csv2tsv: bin/dsv2dsv.js + dsv2dsv: bin/dsv2dsv.js + dsv2json: bin/dsv2json.js + json2csv: bin/json2dsv.js + json2dsv: bin/json2dsv.js + json2tsv: bin/json2dsv.js + tsv2csv: bin/dsv2dsv.js + tsv2json: bin/dsv2json.js + checksum: 10c0/10e6af9e331950ed258f34ab49ac1b7060128ef81dcf32afc790bd1f7e8c3cc2aac7f5f875250a83f21f39bb5925fbd0872bb209f8aca32b3b77d32bab8a65ab + languageName: node + linkType: hard + +"d3-ease@npm:1 - 3, d3-ease@npm:3": + version: 3.0.1 + resolution: "d3-ease@npm:3.0.1" + checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0 + languageName: node + linkType: hard + +"d3-fetch@npm:3": + version: 3.0.1 + resolution: "d3-fetch@npm:3.0.1" + dependencies: + d3-dsv: "npm:1 - 3" + checksum: 10c0/4f467a79bf290395ac0cbb5f7562483f6a18668adc4c8eb84c9d3eff048b6f6d3b6f55079ba1ebf1908dabe000c941d46be447f8d78453b2dad5fb59fb6aa93b + languageName: node + linkType: hard + +"d3-force@npm:3": + version: 3.0.0 + resolution: "d3-force@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-quadtree: "npm:1 - 3" + d3-timer: "npm:1 - 3" + checksum: 10c0/220a16a1a1ac62ba56df61028896e4b52be89c81040d20229c876efc8852191482c233f8a52bb5a4e0875c321b8e5cb6413ef3dfa4d8fe79eeb7d52c587f52cf + languageName: node + linkType: hard + +"d3-format@npm:1 - 3, d3-format@npm:3": + version: 3.1.0 + resolution: "d3-format@npm:3.1.0" + checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75 + languageName: node + linkType: hard + +"d3-geo@npm:3": + version: 3.1.1 + resolution: "d3-geo@npm:3.1.1" + dependencies: + d3-array: "npm:2.5.0 - 3" + checksum: 10c0/d32270dd2dc8ac3ea63e8805d63239c4c8ec6c0d339d73b5e5a30a87f8f54db22a78fb434369799465eae169503b25f9a107c642c8a16c32a3285bc0e6d8e8c1 + languageName: node + linkType: hard + +"d3-hierarchy@npm:3": + version: 3.1.2 + resolution: "d3-hierarchy@npm:3.1.2" + checksum: 10c0/6dcdb480539644aa7fc0d72dfc7b03f99dfbcdf02714044e8c708577e0d5981deb9d3e99bbbb2d26422b55bcc342ac89a0fa2ea6c9d7302e2fc0951dd96f89cf + languageName: node + linkType: hard + +"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3": + version: 3.0.1 + resolution: "d3-interpolate@npm:3.0.1" + dependencies: + d3-color: "npm:1 - 3" + checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a + languageName: node + linkType: hard + +"d3-path@npm:1 - 3, d3-path@npm:3, d3-path@npm:^3.1.0": + version: 3.1.0 + resolution: "d3-path@npm:3.1.0" + checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da + languageName: node + linkType: hard + +"d3-polygon@npm:3": + version: 3.0.1 + resolution: "d3-polygon@npm:3.0.1" + checksum: 10c0/e236aa7f33efa9a4072907af7dc119f85b150a0716759d4fe5f12f62573018264a6cbde8617fbfa6944a7ae48c1c0c8d3f39ae72e11f66dd471e9b5e668385df + languageName: node + linkType: hard + +"d3-quadtree@npm:1 - 3, d3-quadtree@npm:3": + version: 3.0.1 + resolution: "d3-quadtree@npm:3.0.1" + checksum: 10c0/18302d2548bfecaef788152397edec95a76400fd97d9d7f42a089ceb68d910f685c96579d74e3712d57477ed042b056881b47cd836a521de683c66f47ce89090 + languageName: node + linkType: hard + +"d3-random@npm:3": + version: 3.0.1 + resolution: "d3-random@npm:3.0.1" + checksum: 10c0/987a1a1bcbf26e6cf01fd89d5a265b463b2cea93560fc17d9b1c45e8ed6ff2db5924601bcceb808de24c94133f000039eb7fa1c469a7a844ccbf1170cbb25b41 + languageName: node + linkType: hard + +"d3-scale-chromatic@npm:3": + version: 3.1.0 + resolution: "d3-scale-chromatic@npm:3.1.0" + dependencies: + d3-color: "npm:1 - 3" + d3-interpolate: "npm:1 - 3" + checksum: 10c0/9a3f4671ab0b971f4a411b42180d7cf92bfe8e8584e637ce7e698d705e18d6d38efbd20ec64f60cc0dfe966c20d40fc172565bc28aaa2990c0a006360eed91af + languageName: node + linkType: hard + +"d3-scale@npm:4": + version: 4.0.2 + resolution: "d3-scale@npm:4.0.2" + dependencies: + d3-array: "npm:2.10.0 - 3" + d3-format: "npm:1 - 3" + d3-interpolate: "npm:1.2.0 - 3" + d3-time: "npm:2.1.1 - 3" + d3-time-format: "npm:2 - 4" + checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1 + languageName: node + linkType: hard + +"d3-selection@npm:2 - 3, d3-selection@npm:3": + version: 3.0.0 + resolution: "d3-selection@npm:3.0.0" + checksum: 10c0/e59096bbe8f0cb0daa1001d9bdd6dbc93a688019abc97d1d8b37f85cd3c286a6875b22adea0931b0c88410d025563e1643019161a883c516acf50c190a11b56b + languageName: node + linkType: hard + +"d3-shape@npm:3": + version: 3.2.0 + resolution: "d3-shape@npm:3.2.0" + dependencies: + d3-path: "npm:^3.1.0" + checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132 + languageName: node + linkType: hard + +"d3-time-format@npm:2 - 4, d3-time-format@npm:4": + version: 4.1.0 + resolution: "d3-time-format@npm:4.1.0" + dependencies: + d3-time: "npm:1 - 3" + checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206 + languageName: node + linkType: hard + +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3": + version: 3.1.0 + resolution: "d3-time@npm:3.1.0" + dependencies: + d3-array: "npm:2 - 3" + checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1 + languageName: node + linkType: hard + +"d3-timer@npm:1 - 3, d3-timer@npm:3": + version: 3.0.1 + resolution: "d3-timer@npm:3.0.1" + checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a + languageName: node + linkType: hard + +"d3-transition@npm:2 - 3, d3-transition@npm:3": + version: 3.0.1 + resolution: "d3-transition@npm:3.0.1" + dependencies: + d3-color: "npm:1 - 3" + d3-dispatch: "npm:1 - 3" + d3-ease: "npm:1 - 3" + d3-interpolate: "npm:1 - 3" + d3-timer: "npm:1 - 3" + peerDependencies: + d3-selection: 2 - 3 + checksum: 10c0/4e74535dda7024aa43e141635b7522bb70cf9d3dfefed975eb643b36b864762eca67f88fafc2ca798174f83ca7c8a65e892624f824b3f65b8145c6a1a88dbbad + languageName: node + linkType: hard + +"d3-zoom@npm:3": + version: 3.0.0 + resolution: "d3-zoom@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-drag: "npm:2 - 3" + d3-interpolate: "npm:1 - 3" + d3-selection: "npm:2 - 3" + d3-transition: "npm:2 - 3" + checksum: 10c0/ee2036479049e70d8c783d594c444fe00e398246048e3f11a59755cd0e21de62ece3126181b0d7a31bf37bcf32fd726f83ae7dea4495ff86ec7736ce5ad36fd3 + languageName: node + linkType: hard + +"d3@npm:^7.6.1, d3@npm:^7.9.0": + version: 7.9.0 + resolution: "d3@npm:7.9.0" + dependencies: + d3-array: "npm:3" + d3-axis: "npm:3" + d3-brush: "npm:3" + d3-chord: "npm:3" + d3-color: "npm:3" + d3-contour: "npm:4" + d3-delaunay: "npm:6" + d3-dispatch: "npm:3" + d3-drag: "npm:3" + d3-dsv: "npm:3" + d3-ease: "npm:3" + d3-fetch: "npm:3" + d3-force: "npm:3" + d3-format: "npm:3" + d3-geo: "npm:3" + d3-hierarchy: "npm:3" + d3-interpolate: "npm:3" + d3-path: "npm:3" + d3-polygon: "npm:3" + d3-quadtree: "npm:3" + d3-random: "npm:3" + d3-scale: "npm:4" + d3-scale-chromatic: "npm:3" + d3-selection: "npm:3" + d3-shape: "npm:3" + d3-time: "npm:3" + d3-time-format: "npm:4" + d3-timer: "npm:3" + d3-transition: "npm:3" + d3-zoom: "npm:3" + checksum: 10c0/3dd9c08c73cfaa69c70c49e603c85e049c3904664d9c79a1a52a0f52795828a1ff23592dc9a7b2257e711d68a615472a13103c212032f38e016d609796e087e8 + languageName: node + linkType: hard + +"dayjs@npm:^1.11.13": + version: 1.11.13 + resolution: "dayjs@npm:1.11.13" + checksum: 10c0/a3caf6ac8363c7dade9d1ee797848ddcf25c1ace68d9fe8678ecf8ba0675825430de5d793672ec87b24a69bf04a1544b176547b2539982275d5542a7955f35b7 + languageName: node + linkType: hard + +"debug@npm:2.6.9": + version: 2.6.9 + resolution: "debug@npm:2.6.9" + dependencies: + ms: "npm:2.0.0" + checksum: 10c0/121908fb839f7801180b69a7e218a40b5a0b718813b886b7d6bdb82001b931c938e2941d1e4450f33a1b1df1da653f5f7a0440c197f29fbf8a6e9d45ff6ef589 + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": + version: 4.3.6 + resolution: "debug@npm:4.3.6" + dependencies: + ms: "npm:2.1.2" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/3293416bff072389c101697d4611c402a6bacd1900ac20c0492f61a9cdd6b3b29750fc7f5e299f8058469ef60ff8fb79b86395a30374fbd2490113c1c7112285 + languageName: node + linkType: hard + +"debug@npm:^4.3.3, debug@npm:^4.3.6, debug@npm:^4.3.7": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b + languageName: node + linkType: hard + +"decode-named-character-reference@npm:^1.0.0": + version: 1.0.2 + resolution: "decode-named-character-reference@npm:1.0.2" + dependencies: + character-entities: "npm:^2.0.0" + checksum: 10c0/66a9fc5d9b5385a2b3675c69ba0d8e893393d64057f7dbbb585265bb4fc05ec513d76943b8e5aac7d8016d20eea4499322cbf4cd6d54b466976b78f3a7587a4c + languageName: node + linkType: hard + +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: "npm:^3.1.0" + checksum: 10c0/bd89d23141b96d80577e70c54fb226b2f40e74a6817652b80a116d7befb8758261ad073a8895648a29cc0a5947021ab66705cb542fa9c143c82022b27c5b175e + languageName: node + linkType: hard + +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + +"deep-is@npm:^0.1.3": + version: 0.1.4 + resolution: "deep-is@npm:0.1.4" + checksum: 10c0/7f0ee496e0dff14a573dc6127f14c95061b448b87b995fc96c017ce0a1e66af1675e73f1d6064407975bc4ea6ab679497a29fff7b5b9c4e99cb10797c1ad0b4c + languageName: node + linkType: hard + +"deepmerge@npm:^4.2.2": + version: 4.3.1 + resolution: "deepmerge@npm:4.3.1" + checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 + languageName: node + linkType: hard + +"defaults@npm:^1.0.3": + version: 1.0.4 + resolution: "defaults@npm:1.0.4" + dependencies: + clone: "npm:^1.0.2" + checksum: 10c0/9cfbe498f5c8ed733775db62dfd585780387d93c17477949e1670bfcfb9346e0281ce8c4bf9f4ac1fc0f9b851113bd6dc9e41182ea1644ccd97de639fa13c35a + languageName: node + linkType: hard + +"defer-to-connect@npm:^2.0.0": + version: 2.0.1 + resolution: "defer-to-connect@npm:2.0.1" + checksum: 10c0/625ce28e1b5ad10cf77057b9a6a727bf84780c17660f6644dab61dd34c23de3001f03cedc401f7d30a4ed9965c2e8a7336e220a329146f2cf85d4eddea429782 + languageName: node + linkType: hard + +"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.4": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.0.1" + checksum: 10c0/dea0606d1483eb9db8d930d4eac62ca0fa16738b0b3e07046cddfacf7d8c868bbe13fa0cb263eb91c7d0d527960dc3f2f2471a69ed7816210307f6744fe62e37 + languageName: node + linkType: hard + +"define-properties@npm:^1.2.1": + version: 1.2.1 + resolution: "define-properties@npm:1.2.1" + dependencies: + define-data-property: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.0" + object-keys: "npm:^1.1.1" + checksum: 10c0/88a152319ffe1396ccc6ded510a3896e77efac7a1bfbaa174a7b00414a1747377e0bb525d303794a47cf30e805c2ec84e575758512c6e44a993076d29fd4e6c3 + languageName: node + linkType: hard + +"delaunator@npm:5": + version: 5.0.1 + resolution: "delaunator@npm:5.0.1" + dependencies: + robust-predicates: "npm:^3.0.2" + checksum: 10c0/3d7ea4d964731c5849af33fec0a271bc6753487b331fd7d43ccb17d77834706e1c383e6ab8fda0032da955e7576d1083b9603cdaf9cbdfd6b3ebd1fb8bb675a5 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"delegates@npm:^1.0.0": + version: 1.0.0 + resolution: "delegates@npm:1.0.0" + checksum: 10c0/ba05874b91148e1db4bf254750c042bf2215febd23a6d3cda2e64896aef79745fbd4b9996488bd3cafb39ce19dbce0fd6e3b6665275638befffe1c9b312b91b5 + languageName: node + linkType: hard + +"depd@npm:2.0.0": + version: 2.0.0 + resolution: "depd@npm:2.0.0" + checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c + languageName: node + linkType: hard + +"dequal@npm:^2.0.0, dequal@npm:^2.0.2, dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 + languageName: node + linkType: hard + +"destroy@npm:1.2.0": + version: 1.2.0 + resolution: "destroy@npm:1.2.0" + checksum: 10c0/bd7633942f57418f5a3b80d5cb53898127bcf53e24cdf5d5f4396be471417671f0fee48a4ebe9a1e9defbde2a31280011af58a57e090ff822f589b443ed4e643 + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.1": + version: 2.0.3 + resolution: "detect-libc@npm:2.0.3" + checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7 + languageName: node + linkType: hard + +"detect-node@npm:^2.0.4": + version: 2.1.0 + resolution: "detect-node@npm:2.1.0" + checksum: 10c0/f039f601790f2e9d4654e499913259a798b1f5246ae24f86ab5e8bd4aaf3bce50484234c494f11fb00aecb0c6e2733aa7b1cf3f530865640b65fbbd65b2c4e09 + languageName: node + linkType: hard + +"devlop@npm:^1.0.0, devlop@npm:^1.1.0": + version: 1.1.0 + resolution: "devlop@npm:1.1.0" + dependencies: + dequal: "npm:^2.0.0" + checksum: 10c0/e0928ab8f94c59417a2b8389c45c55ce0a02d9ac7fd74ef62d01ba48060129e1d594501b77de01f3eeafc7cb00773819b0df74d96251cf20b31c5b3071f45c0e + languageName: node + linkType: hard + +"diff@npm:^4.0.1": + version: 4.0.2 + resolution: "diff@npm:4.0.2" + checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1 + languageName: node + linkType: hard + +"dir-compare@npm:^4.2.0": + version: 4.2.0 + resolution: "dir-compare@npm:4.2.0" + dependencies: + minimatch: "npm:^3.0.5" + p-limit: "npm:^3.1.0 " + checksum: 10c0/615c6f6804095f912d98d49f9b56798ceebbc83612d660b7faa6bdb4894d978c02cfa1a30853a7319a269141e4f2a2034d4988a1985b58382614a3942f94e5b2 + languageName: node + linkType: hard + +"dmg-builder@npm:25.0.5": + version: 25.0.5 + resolution: "dmg-builder@npm:25.0.5" + dependencies: + app-builder-lib: "npm:25.0.5" + builder-util: "npm:25.0.3" + builder-util-runtime: "npm:9.2.5" + dmg-license: "npm:^1.0.11" + fs-extra: "npm:^10.1.0" + iconv-lite: "npm:^0.6.2" + js-yaml: "npm:^4.1.0" + dependenciesMeta: + dmg-license: + optional: true + checksum: 10c0/0bb1caec36e0bc9027c46fe828ced69e519b8e57f33ac3a0d73a782d1c64a086168201053cc687771952c296976ef937f1e123e9cf0c4abecb29140d6b94f4f0 + languageName: node + linkType: hard + +"dmg-license@npm:^1.0.11": + version: 1.0.11 + resolution: "dmg-license@npm:1.0.11" + dependencies: + "@types/plist": "npm:^3.0.1" + "@types/verror": "npm:^1.10.3" + ajv: "npm:^6.10.0" + crc: "npm:^3.8.0" + iconv-corefoundation: "npm:^1.1.7" + plist: "npm:^3.0.4" + smart-buffer: "npm:^4.0.2" + verror: "npm:^1.10.0" + bin: + dmg-license: bin/dmg-license.js + conditions: os=darwin + languageName: node + linkType: hard + +"dnd-core@npm:^16.0.1": + version: 16.0.1 + resolution: "dnd-core@npm:16.0.1" + dependencies: + "@react-dnd/asap": "npm:^5.0.1" + "@react-dnd/invariant": "npm:^4.0.1" + redux: "npm:^4.2.0" + checksum: 10c0/6b852c576c88b0a42e618efb37e046334f5e9914b8d38ad139933dd9595b6caf2a484953a6301094d23119c17479549553d71e92fd77fa37318122ea1e579f65 + languageName: node + linkType: hard + +"doctrine@npm:^3.0.0": + version: 3.0.0 + resolution: "doctrine@npm:3.0.0" + dependencies: + esutils: "npm:^2.0.2" + checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360 + languageName: node + linkType: hard + +"dot-case@npm:^3.0.4": + version: 3.0.4 + resolution: "dot-case@npm:3.0.4" + dependencies: + no-case: "npm:^3.0.4" + tslib: "npm:^2.0.3" + checksum: 10c0/5b859ea65097a7ea870e2c91b5768b72ddf7fa947223fd29e167bcdff58fe731d941c48e47a38ec8aa8e43044c8fbd15cd8fa21689a526bc34b6548197cd5b05 + languageName: node + linkType: hard + +"dotenv-expand@npm:^11.0.6": + version: 11.0.6 + resolution: "dotenv-expand@npm:11.0.6" + dependencies: + dotenv: "npm:^16.4.4" + checksum: 10c0/e22891ec72cb926d46d9a26290ef77f9cc9ddcba92d2f83d5e6f3a803d1590887be68e25b559415d080053000441b6f63f5b36093a565bb8c5c994b992ae49f2 + languageName: node + linkType: hard + +"dotenv@npm:^16.4.4, dotenv@npm:^16.4.5": + version: 16.4.5 + resolution: "dotenv@npm:16.4.5" + checksum: 10c0/48d92870076832af0418b13acd6e5a5a3e83bb00df690d9812e94b24aff62b88ade955ac99a05501305b8dc8f1b0ee7638b18493deb6fe93d680e5220936292f + languageName: node + linkType: hard + +"eastasianwidth@npm:^0.2.0": + version: 0.2.0 + resolution: "eastasianwidth@npm:0.2.0" + checksum: 10c0/26f364ebcdb6395f95124fda411f63137a4bfb5d3a06453f7f23dfe52502905bd84e0488172e0f9ec295fdc45f05c23d5d91baf16bd26f0fe9acd777a188dc39 + languageName: node + linkType: hard + +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 10c0/b5bb125ee93161bc16bfe6e56c6b04de5ad2aa44234d8f644813cc95d861a6910903132b05093706de2b706599367c4130eb6d170f6b46895686b95f87d017b7 + languageName: node + linkType: hard + +"ejs@npm:^3.1.8": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10c0/52eade9e68416ed04f7f92c492183340582a36482836b11eab97b159fcdcfdedc62233a1bf0bf5e5e1851c501f2dca0e2e9afd111db2599e4e7f53ee29429ae1 + languageName: node + linkType: hard + +"electron-builder@npm:^25.0.5": + version: 25.0.5 + resolution: "electron-builder@npm:25.0.5" + dependencies: + app-builder-lib: "npm:25.0.5" + builder-util: "npm:25.0.3" + builder-util-runtime: "npm:9.2.5" + chalk: "npm:^4.1.2" + dmg-builder: "npm:25.0.5" + fs-extra: "npm:^10.1.0" + is-ci: "npm:^3.0.0" + lazy-val: "npm:^1.0.5" + read-config-file: "npm:6.4.0" + simple-update-notifier: "npm:2.0.0" + yargs: "npm:^17.6.2" + bin: + electron-builder: cli.js + install-app-deps: install-app-deps.js + checksum: 10c0/4aa8f314e0837ed00069a12f3bbd70528d63cb5cbd06d59df6379953e4e02633f62a7e1ecba36382070a637d6fd241822a84ce95b911e60a34d86e3787f4aee3 + languageName: node + linkType: hard + +"electron-publish@npm:25.0.3": + version: 25.0.3 + resolution: "electron-publish@npm:25.0.3" + dependencies: + "@types/fs-extra": "npm:^9.0.11" + builder-util: "npm:25.0.3" + builder-util-runtime: "npm:9.2.5" + chalk: "npm:^4.1.2" + fs-extra: "npm:^10.1.0" + lazy-val: "npm:^1.0.5" + mime: "npm:^2.5.2" + checksum: 10c0/1764b19507445bf0b6e7b9e4e1ecaaf3f27ac50e65c424e6790c7b01a8949cdc104fba97210d984f64256f8b4ccd01d627e7d400a12ebad84692623c768da6a4 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.5.4": + version: 1.5.13 + resolution: "electron-to-chromium@npm:1.5.13" + checksum: 10c0/1d88ac39447e1d718c4296f92fe89836df4688daf2d362d6c49108136795f05a56dd9c950f1c6715e0395fa037c3b5f5ea686c543fdc90e6d74a005877c45022 + languageName: node + linkType: hard + +"electron-updater@npm:6.3.4": + version: 6.3.4 + resolution: "electron-updater@npm:6.3.4" + dependencies: + builder-util-runtime: "npm:9.2.5" + fs-extra: "npm:^10.1.0" + js-yaml: "npm:^4.1.0" + lazy-val: "npm:^1.0.5" + lodash.escaperegexp: "npm:^4.1.2" + lodash.isequal: "npm:^4.5.0" + semver: "npm:^7.6.3" + tiny-typed-emitter: "npm:^2.1.0" + checksum: 10c0/ca67160d251c16f896672e15b81dcfbf759a6a8350dc919666d426207e60038d20b75c5351705af3153ae686621d2856df2ce68ed538e0aad397ef90723d0da1 + languageName: node + linkType: hard + +"electron-vite@npm:^2.3.0": + version: 2.3.0 + resolution: "electron-vite@npm:2.3.0" + dependencies: + "@babel/core": "npm:^7.24.7" + "@babel/plugin-transform-arrow-functions": "npm:^7.24.7" + cac: "npm:^6.7.14" + esbuild: "npm:^0.21.5" + magic-string: "npm:^0.30.10" + picocolors: "npm:^1.0.1" + peerDependencies: + "@swc/core": ^1.0.0 + vite: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + "@swc/core": + optional: true + bin: + electron-vite: bin/electron-vite.js + checksum: 10c0/7a8e4358a9b2053bd90c530f001b28837044ced7b8579bedb6002eb2be94d206d986d7f177da9ff93d805facf60e78d1e487ed88b097e4a6afab06f33afef6ac + languageName: node + linkType: hard + +"electron@npm:*": + version: 32.0.1 + resolution: "electron@npm:32.0.1" + dependencies: + "@electron/get": "npm:^2.0.0" + "@types/node": "npm:^20.9.0" + extract-zip: "npm:^2.0.1" + bin: + electron: cli.js + checksum: 10c0/274412b2da617c2489f4cb0bcbd2316a8429adbc07ef8a469e5f312ca3113558b7e9b25fdc78faea95270ee11ed155c5cf4a5b11083342577defcb9b298012d7 + languageName: node + linkType: hard + +"electron@npm:^32.1.0": + version: 32.1.0 + resolution: "electron@npm:32.1.0" + dependencies: + "@electron/get": "npm:^2.0.0" + "@types/node": "npm:^20.9.0" + extract-zip: "npm:^2.0.1" + bin: + electron: cli.js + checksum: 10c0/7eaae7a239bceb1ccc2b89dcaf4f6800829a669973a97edfba87b8e0c4e0303b316a449a2dd5518c8e140849d0a0be50309f3f95215644099f91f97d90b2ce70 + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 + languageName: node + linkType: hard + +"emoji-regex@npm:^9.2.2": + version: 9.2.2 + resolution: "emoji-regex@npm:9.2.2" + checksum: 10c0/af014e759a72064cf66e6e694a7fc6b0ed3d8db680427b021a89727689671cefe9d04151b2cad51dbaf85d5ba790d061cd167f1cf32eb7b281f6368b3c181639 + languageName: node + linkType: hard + +"enabled@npm:2.0.x": + version: 2.0.0 + resolution: "enabled@npm:2.0.0" + checksum: 10c0/3b2c2af9bc7f8b9e291610f2dde4a75cf6ee52a68f4dd585482fbdf9a55d65388940e024e56d40bb03e05ef6671f5f53021fa8b72a20e954d7066ec28166713f + languageName: node + linkType: hard + +"encodeurl@npm:~1.0.2": + version: 1.0.2 + resolution: "encodeurl@npm:1.0.2" + checksum: 10c0/f6c2387379a9e7c1156c1c3d4f9cb7bb11cf16dd4c1682e1f6746512564b053df5781029b6061296832b59fb22f459dbe250386d217c2f6e203601abb2ee0bec + languageName: node + linkType: hard + +"encodeurl@npm:~2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb + languageName: node + linkType: hard + +"encoding@npm:^0.1.13": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: "npm:^0.6.2" + checksum: 10c0/36d938712ff00fe1f4bac88b43bcffb5930c1efa57bbcdca9d67e1d9d6c57cfb1200fb01efe0f3109b2ce99b231f90779532814a81370a1bd3274a0f58585039 + languageName: node + linkType: hard + +"end-of-stream@npm:^1.1.0": + version: 1.4.4 + resolution: "end-of-stream@npm:1.4.4" + dependencies: + once: "npm:^1.4.0" + checksum: 10c0/870b423afb2d54bb8d243c63e07c170409d41e20b47eeef0727547aea5740bd6717aca45597a9f2745525667a6b804c1e7bede41f856818faee5806dd9ff3975 + languageName: node + linkType: hard + +"entities@npm:^4.4.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"err-code@npm:^2.0.2": + version: 2.0.3 + resolution: "err-code@npm:2.0.3" + checksum: 10c0/b642f7b4dd4a376e954947550a3065a9ece6733ab8e51ad80db727aaae0817c2e99b02a97a3d6cecc648a97848305e728289cf312d09af395403a90c9d4d8a66 + languageName: node + linkType: hard + +"errno@npm:^0.1.1": + version: 0.1.8 + resolution: "errno@npm:0.1.8" + dependencies: + prr: "npm:~1.0.1" + bin: + errno: cli.js + checksum: 10c0/83758951967ec57bf00b5f5b7dc797e6d65a6171e57ea57adcf1bd1a0b477fd9b5b35fae5be1ff18f4090ed156bce1db749fe7e317aac19d485a5d150f6a4936 + languageName: node + linkType: hard + +"error-ex@npm:^1.3.1": + version: 1.3.2 + resolution: "error-ex@npm:1.3.2" + dependencies: + is-arrayish: "npm:^0.2.1" + checksum: 10c0/ba827f89369b4c93382cfca5a264d059dfefdaa56ecc5e338ffa58a6471f5ed93b71a20add1d52290a4873d92381174382658c885ac1a2305f7baca363ce9cce + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.0": + version: 1.0.0 + resolution: "es-define-property@npm:1.0.0" + dependencies: + get-intrinsic: "npm:^1.2.4" + checksum: 10c0/6bf3191feb7ea2ebda48b577f69bdfac7a2b3c9bcf97307f55fd6ef1bbca0b49f0c219a935aca506c993d8c5d8bddd937766cb760cd5e5a1071351f2df9f9aa4 + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-module-lexer@npm:^1.5.0": + version: 1.5.4 + resolution: "es-module-lexer@npm:1.5.4" + checksum: 10c0/300a469488c2f22081df1e4c8398c78db92358496e639b0df7f89ac6455462aaf5d8893939087c1a1cbcbf20eed4610c70e0bcb8f3e4b0d80a5d2611c539408c + languageName: node + linkType: hard + +"es6-error@npm:^4.1.1": + version: 4.1.1 + resolution: "es6-error@npm:4.1.1" + checksum: 10c0/357663fb1e845c047d548c3d30f86e005db71e122678f4184ced0693f634688c3f3ef2d7de7d4af732f734de01f528b05954e270f06aa7d133679fb9fe6600ef + languageName: node + linkType: hard + +"esbuild-register@npm:^3.5.0": + version: 3.6.0 + resolution: "esbuild-register@npm:3.6.0" + dependencies: + debug: "npm:^4.3.4" + peerDependencies: + esbuild: ">=0.12 <1" + checksum: 10c0/77193b7ca32ba9f81b35ddf3d3d0138efb0b1429d71b39480cfee932e1189dd2e492bd32bf04a4d0bc3adfbc7ec7381ceb5ffd06efe35f3e70904f1f686566d5 + languageName: node + linkType: hard + +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0, esbuild@npm:~0.23.0": + version: 0.23.1 + resolution: "esbuild@npm:0.23.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.23.1" + "@esbuild/android-arm": "npm:0.23.1" + "@esbuild/android-arm64": "npm:0.23.1" + "@esbuild/android-x64": "npm:0.23.1" + "@esbuild/darwin-arm64": "npm:0.23.1" + "@esbuild/darwin-x64": "npm:0.23.1" + "@esbuild/freebsd-arm64": "npm:0.23.1" + "@esbuild/freebsd-x64": "npm:0.23.1" + "@esbuild/linux-arm": "npm:0.23.1" + "@esbuild/linux-arm64": "npm:0.23.1" + "@esbuild/linux-ia32": "npm:0.23.1" + "@esbuild/linux-loong64": "npm:0.23.1" + "@esbuild/linux-mips64el": "npm:0.23.1" + "@esbuild/linux-ppc64": "npm:0.23.1" + "@esbuild/linux-riscv64": "npm:0.23.1" + "@esbuild/linux-s390x": "npm:0.23.1" + "@esbuild/linux-x64": "npm:0.23.1" + "@esbuild/netbsd-x64": "npm:0.23.1" + "@esbuild/openbsd-arm64": "npm:0.23.1" + "@esbuild/openbsd-x64": "npm:0.23.1" + "@esbuild/sunos-x64": "npm:0.23.1" + "@esbuild/win32-arm64": "npm:0.23.1" + "@esbuild/win32-ia32": "npm:0.23.1" + "@esbuild/win32-x64": "npm:0.23.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/08c2ed1105cc3c5e3a24a771e35532fe6089dd24a39c10097899072cef4a99f20860e41e9294e000d86380f353b04d8c50af482483d7f69f5208481cce61eec7 + languageName: node + linkType: hard + +"esbuild@npm:^0.21.3, esbuild@npm:^0.21.5": + version: 0.21.5 + resolution: "esbuild@npm:0.21.5" + dependencies: + "@esbuild/aix-ppc64": "npm:0.21.5" + "@esbuild/android-arm": "npm:0.21.5" + "@esbuild/android-arm64": "npm:0.21.5" + "@esbuild/android-x64": "npm:0.21.5" + "@esbuild/darwin-arm64": "npm:0.21.5" + "@esbuild/darwin-x64": "npm:0.21.5" + "@esbuild/freebsd-arm64": "npm:0.21.5" + "@esbuild/freebsd-x64": "npm:0.21.5" + "@esbuild/linux-arm": "npm:0.21.5" + "@esbuild/linux-arm64": "npm:0.21.5" + "@esbuild/linux-ia32": "npm:0.21.5" + "@esbuild/linux-loong64": "npm:0.21.5" + "@esbuild/linux-mips64el": "npm:0.21.5" + "@esbuild/linux-ppc64": "npm:0.21.5" + "@esbuild/linux-riscv64": "npm:0.21.5" + "@esbuild/linux-s390x": "npm:0.21.5" + "@esbuild/linux-x64": "npm:0.21.5" + "@esbuild/netbsd-x64": "npm:0.21.5" + "@esbuild/openbsd-x64": "npm:0.21.5" + "@esbuild/sunos-x64": "npm:0.21.5" + "@esbuild/win32-arm64": "npm:0.21.5" + "@esbuild/win32-ia32": "npm:0.21.5" + "@esbuild/win32-x64": "npm:0.21.5" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/fa08508adf683c3f399e8a014a6382a6b65542213431e26206c0720e536b31c09b50798747c2a105a4bbba1d9767b8d3615a74c2f7bf1ddf6d836cd11eb672de + languageName: node + linkType: hard + +"escalade@npm:^3.1.1, escalade@npm:^3.1.2": + version: 3.1.2 + resolution: "escalade@npm:3.1.2" + checksum: 10c0/6b4adafecd0682f3aa1cd1106b8fff30e492c7015b178bc81b2d2f75106dabea6c6d6e8508fc491bd58e597c74abb0e8e2368f943ecb9393d4162e3c2f3cf287 + languageName: node + linkType: hard + +"escape-html@npm:~1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 10c0/a968ad453dd0c2724e14a4f20e177aaf32bb384ab41b674a8454afe9a41c5e6fe8903323e0a1052f56289d04bd600f81278edf140b0fcc02f5cac98d0f5b5371 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^5.0.0": + version: 5.0.0 + resolution: "escape-string-regexp@npm:5.0.0" + checksum: 10c0/6366f474c6f37a802800a435232395e04e9885919873e382b157ab7e8f0feb8fed71497f84a6f6a81a49aab41815522f5839112bd38026d203aea0c91622df95 + languageName: node + linkType: hard + +"escodegen@npm:^2.1.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 10c0/e1450a1f75f67d35c061bf0d60888b15f62ab63aef9df1901cffc81cffbbb9e8b3de237c5502cf8613a017c1df3a3003881307c78835a1ab54d8c8d2206e01d3 + languageName: node + linkType: hard + +"eslint-config-prettier@npm:^9.1.0": + version: 9.1.0 + resolution: "eslint-config-prettier@npm:9.1.0" + peerDependencies: + eslint: ">=7.0.0" + bin: + eslint-config-prettier: bin/cli.js + checksum: 10c0/6d332694b36bc9ac6fdb18d3ca2f6ac42afa2ad61f0493e89226950a7091e38981b66bac2b47ba39d15b73fff2cd32c78b850a9cf9eed9ca9a96bfb2f3a2f10d + languageName: node + linkType: hard + +"eslint-scope@npm:^8.0.2": + version: 8.0.2 + resolution: "eslint-scope@npm:8.0.2" + dependencies: + esrecurse: "npm:^4.3.0" + estraverse: "npm:^5.2.0" + checksum: 10c0/477f820647c8755229da913025b4567347fd1f0bf7cbdf3a256efff26a7e2e130433df052bd9e3d014025423dc00489bea47eb341002b15553673379c1a7dc36 + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^3.3.0, eslint-visitor-keys@npm:^3.4.3": + version: 3.4.3 + resolution: "eslint-visitor-keys@npm:3.4.3" + checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^4.0.0": + version: 4.0.0 + resolution: "eslint-visitor-keys@npm:4.0.0" + checksum: 10c0/76619f42cf162705a1515a6868e6fc7567e185c7063a05621a8ac4c3b850d022661262c21d9f1fc1d144ecf0d5d64d70a3f43c15c3fc969a61ace0fb25698cf5 + languageName: node + linkType: hard + +"eslint@npm:^9.10.0": + version: 9.10.0 + resolution: "eslint@npm:9.10.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.2.0" + "@eslint-community/regexpp": "npm:^4.11.0" + "@eslint/config-array": "npm:^0.18.0" + "@eslint/eslintrc": "npm:^3.1.0" + "@eslint/js": "npm:9.10.0" + "@eslint/plugin-kit": "npm:^0.1.0" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.3.0" + "@nodelib/fs.walk": "npm:^1.2.8" + ajv: "npm:^6.12.4" + chalk: "npm:^4.0.0" + cross-spawn: "npm:^7.0.2" + debug: "npm:^4.3.2" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^8.0.2" + eslint-visitor-keys: "npm:^4.0.0" + espree: "npm:^10.1.0" + esquery: "npm:^1.5.0" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:^8.0.0" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + is-path-inside: "npm:^3.0.3" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + lodash.merge: "npm:^4.6.2" + minimatch: "npm:^3.1.2" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + strip-ansi: "npm:^6.0.1" + text-table: "npm:^0.2.0" + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + bin: + eslint: bin/eslint.js + checksum: 10c0/7357f3995b15043eea83c8c0ab16c385ce3f28925c1b11cfcd6b2ede8faab3d91ede84a68173dd5f6e3e176e177984e6218de58b7b8388e53e2881f1ec07c836 + languageName: node + linkType: hard + +"espree@npm:^10.0.1, espree@npm:^10.1.0": + version: 10.1.0 + resolution: "espree@npm:10.1.0" + dependencies: + acorn: "npm:^8.12.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^4.0.0" + checksum: 10c0/52e6feaa77a31a6038f0c0e3fce93010a4625701925b0715cd54a2ae190b3275053a0717db698697b32653788ac04845e489d6773b508d6c2e8752f3c57470a0 + languageName: node + linkType: hard + +"esprima@npm:^4.0.1, esprima@npm:~4.0.0": + version: 4.0.1 + resolution: "esprima@npm:4.0.1" + bin: + esparse: ./bin/esparse.js + esvalidate: ./bin/esvalidate.js + checksum: 10c0/ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3 + languageName: node + linkType: hard + +"esquery@npm:^1.5.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + languageName: node + linkType: hard + +"esrecurse@npm:^4.3.0": + version: 4.3.0 + resolution: "esrecurse@npm:4.3.0" + dependencies: + estraverse: "npm:^5.2.0" + checksum: 10c0/81a37116d1408ded88ada45b9fb16dbd26fba3aadc369ce50fcaf82a0bac12772ebd7b24cd7b91fc66786bf2c1ac7b5f196bc990a473efff972f5cb338877cf5 + languageName: node + linkType: hard + +"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": + version: 5.3.0 + resolution: "estraverse@npm:5.3.0" + checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 + languageName: node + linkType: hard + +"estree-util-is-identifier-name@npm:^3.0.0": + version: 3.0.0 + resolution: "estree-util-is-identifier-name@npm:3.0.0" + checksum: 10c0/d1881c6ed14bd588ebd508fc90bf2a541811dbb9ca04dec2f39d27dcaa635f85b5ed9bbbe7fc6fb1ddfca68744a5f7c70456b4b7108b6c4c52780631cc787c5b + languageName: node + linkType: hard + +"estree-walker@npm:^0.2.1": + version: 0.2.1 + resolution: "estree-walker@npm:0.2.1" + checksum: 10c0/5a7285ba6f4fd29d5ac112fdf3950929b2a52805b6683329355cbddf40edf08b52e6c9a4be28b5426e694d54f8b4594478eb66005b9fea641f18fa427e7888c8 + languageName: node + linkType: hard + +"estree-walker@npm:^2.0.2": + version: 2.0.2 + resolution: "estree-walker@npm:2.0.2" + checksum: 10c0/53a6c54e2019b8c914dc395890153ffdc2322781acf4bd7d1a32d7aedc1710807bdcd866ac133903d5629ec601fbb50abe8c2e5553c7f5a0afdd9b6af6c945af + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"esutils@npm:^2.0.2": + version: 2.0.3 + resolution: "esutils@npm:2.0.3" + checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 + languageName: node + linkType: hard + +"etag@npm:~1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 10c0/12be11ef62fb9817314d790089a0a49fae4e1b50594135dcb8076312b7d7e470884b5100d249b28c18581b7fd52f8b485689ffae22a11ed9ec17377a33a08f84 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.1 + resolution: "exponential-backoff@npm:3.1.1" + checksum: 10c0/160456d2d647e6019640bd07111634d8c353038d9fa40176afb7cd49b0548bdae83b56d05e907c2cce2300b81cae35d800ef92fefb9d0208e190fa3b7d6bb579 + languageName: node + linkType: hard + +"express@npm:^4.19.2": + version: 4.20.0 + resolution: "express@npm:4.20.0" + dependencies: + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:1.20.3" + content-disposition: "npm:0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:0.6.0" + cookie-signature: "npm:1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:1.2.0" + fresh: "npm:0.5.2" + http-errors: "npm:2.0.0" + merge-descriptors: "npm:1.0.3" + methods: "npm:~1.1.2" + on-finished: "npm:2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:0.1.10" + proxy-addr: "npm:~2.0.7" + qs: "npm:6.11.0" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:0.19.0" + serve-static: "npm:1.16.0" + setprototypeof: "npm:1.2.0" + statuses: "npm:2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 10c0/626e440e9feffa3f82ebce5e7dc0ad7a74fa96079994f30048cce450f4855a258abbcabf021f691aeb72154867f0d28440a8498c62888805faf667a829fb65aa + languageName: node + linkType: hard + +"extend@npm:^3.0.0": + version: 3.0.2 + resolution: "extend@npm:3.0.2" + checksum: 10c0/73bf6e27406e80aa3e85b0d1c4fd987261e628064e170ca781125c0b635a3dabad5e05adbf07595ea0cf1e6c5396cacb214af933da7cbaf24fe75ff14818e8f9 + languageName: node + linkType: hard + +"extract-zip@npm:^2.0.1": + version: 2.0.1 + resolution: "extract-zip@npm:2.0.1" + dependencies: + "@types/yauzl": "npm:^2.9.1" + debug: "npm:^4.1.1" + get-stream: "npm:^5.1.0" + yauzl: "npm:^2.10.0" + dependenciesMeta: + "@types/yauzl": + optional: true + bin: + extract-zip: cli.js + checksum: 10c0/9afbd46854aa15a857ae0341a63a92743a7b89c8779102c3b4ffc207516b2019337353962309f85c66ee3d9092202a83cdc26dbf449a11981272038443974aee + languageName: node + linkType: hard + +"extsprintf@npm:^1.2.0": + version: 1.4.1 + resolution: "extsprintf@npm:1.4.1" + checksum: 10c0/e10e2769985d0e9b6c7199b053a9957589d02e84de42832c295798cb422a025e6d4a92e0259c1fb4d07090f5bfde6b55fd9f880ac5855bd61d775f8ab75a7ab0 + languageName: node + linkType: hard + +"fast-average-color@npm:^9.4.0": + version: 9.4.0 + resolution: "fast-average-color@npm:9.4.0" + checksum: 10c0/9031181113356abe240c52f78e908607e3b47dc0121cec3077b3735823951e40f8d6e14eca50d9941e30bcea60e0ed52e36410a8ded0972a89253c3dbefc966d + languageName: node + linkType: hard + +"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + languageName: node + linkType: hard + +"fast-glob@npm:^3.2.11, fast-glob@npm:^3.3.2": + version: 3.3.2 + resolution: "fast-glob@npm:3.3.2" + dependencies: + "@nodelib/fs.stat": "npm:^2.0.2" + "@nodelib/fs.walk": "npm:^1.2.3" + glob-parent: "npm:^5.1.2" + merge2: "npm:^1.3.0" + micromatch: "npm:^4.0.4" + checksum: 10c0/42baad7b9cd40b63e42039132bde27ca2cb3a4950d0a0f9abe4639ea1aa9d3e3b40f98b1fe31cbc0cc17b664c9ea7447d911a152fa34ec5b72977b125a6fc845 + languageName: node + linkType: hard + +"fast-json-stable-stringify@npm:^2.0.0": + version: 2.1.0 + resolution: "fast-json-stable-stringify@npm:2.1.0" + checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b + languageName: node + linkType: hard + +"fast-levenshtein@npm:^2.0.6": + version: 2.0.6 + resolution: "fast-levenshtein@npm:2.0.6" + checksum: 10c0/111972b37338bcb88f7d9e2c5907862c280ebf4234433b95bc611e518d192ccb2d38119c4ac86e26b668d75f7f3894f4ff5c4982899afced7ca78633b08287c4 + languageName: node + linkType: hard + +"fastq@npm:^1.6.0": + version: 1.17.1 + resolution: "fastq@npm:1.17.1" + dependencies: + reusify: "npm:^1.0.4" + checksum: 10c0/1095f16cea45fb3beff558bb3afa74ca7a9250f5a670b65db7ed585f92b4b48381445cd328b3d87323da81e43232b5d5978a8201bde84e0cd514310f1ea6da34 + languageName: node + linkType: hard + +"fd-slicer@npm:~1.1.0": + version: 1.1.0 + resolution: "fd-slicer@npm:1.1.0" + dependencies: + pend: "npm:~1.2.0" + checksum: 10c0/304dd70270298e3ffe3bcc05e6f7ade2511acc278bc52d025f8918b48b6aa3b77f10361bddfadfe2a28163f7af7adbdce96f4d22c31b2f648ba2901f0c5fc20e + languageName: node + linkType: hard + +"fecha@npm:^4.2.0": + version: 4.2.3 + resolution: "fecha@npm:4.2.3" + checksum: 10c0/0e895965959cf6a22bb7b00f0bf546f2783836310f510ddf63f463e1518d4c96dec61ab33fdfd8e79a71b4856a7c865478ce2ee8498d560fe125947703c9b1cf + languageName: node + linkType: hard + +"file-entry-cache@npm:^8.0.0": + version: 8.0.0 + resolution: "file-entry-cache@npm:8.0.0" + dependencies: + flat-cache: "npm:^4.0.0" + checksum: 10c0/9e2b5938b1cd9b6d7e3612bdc533afd4ac17b2fc646569e9a8abbf2eb48e5eb8e316bc38815a3ef6a1b456f4107f0d0f055a614ca613e75db6bf9ff4d72c1638 + languageName: node + linkType: hard + +"filelist@npm:^1.0.4": + version: 1.0.4 + resolution: "filelist@npm:1.0.4" + dependencies: + minimatch: "npm:^5.0.1" + checksum: 10c0/426b1de3944a3d153b053f1c0ebfd02dccd0308a4f9e832ad220707a6d1f1b3c9784d6cadf6b2f68f09a57565f63ebc7bcdc913ccf8012d834f472c46e596f41 + languageName: node + linkType: hard + +"filesize@npm:^10.0.12": + version: 10.1.4 + resolution: "filesize@npm:10.1.4" + checksum: 10c0/b02a792da0da66fce5525566691369db6f0fadf5407b3626ca14821998dfaec65cf4a69fc3ca3ae999bf963e4afa19a8a787996f935c508506cccff3cc075faf + languageName: node + linkType: hard + +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" + dependencies: + to-regex-range: "npm:^5.0.1" + checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 + languageName: node + linkType: hard + +"finalhandler@npm:1.2.0": + version: 1.2.0 + resolution: "finalhandler@npm:1.2.0" + dependencies: + debug: "npm:2.6.9" + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + on-finished: "npm:2.4.1" + parseurl: "npm:~1.3.3" + statuses: "npm:2.0.1" + unpipe: "npm:~1.0.0" + checksum: 10c0/64b7e5ff2ad1fcb14931cd012651631b721ce657da24aedb5650ddde9378bf8e95daa451da43398123f5de161a81e79ff5affe4f9f2a6d2df4a813d6d3e254b7 + languageName: node + linkType: hard + +"find-cache-dir@npm:^3.0.0": + version: 3.3.2 + resolution: "find-cache-dir@npm:3.3.2" + dependencies: + commondir: "npm:^1.0.1" + make-dir: "npm:^3.0.2" + pkg-dir: "npm:^4.1.0" + checksum: 10c0/92747cda42bff47a0266b06014610981cfbb71f55d60f2c8216bc3108c83d9745507fb0b14ecf6ab71112bed29cd6fb1a137ee7436179ea36e11287e3159e587 + languageName: node + linkType: hard + +"find-up@npm:^4.0.0": + version: 4.1.0 + resolution: "find-up@npm:4.1.0" + dependencies: + locate-path: "npm:^5.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/0406ee89ebeefa2d507feb07ec366bebd8a6167ae74aa4e34fb4c4abd06cf782a3ce26ae4194d70706f72182841733f00551c209fe575cb00bd92104056e78c1 + languageName: node + linkType: hard + +"find-up@npm:^5.0.0": + version: 5.0.0 + resolution: "find-up@npm:5.0.0" + dependencies: + locate-path: "npm:^6.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a + languageName: node + linkType: hard + +"flat-cache@npm:^4.0.0": + version: 4.0.1 + resolution: "flat-cache@npm:4.0.1" + dependencies: + flatted: "npm:^3.2.9" + keyv: "npm:^4.5.4" + checksum: 10c0/2c59d93e9faa2523e4fda6b4ada749bed432cfa28c8e251f33b25795e426a1c6dbada777afb1f74fcfff33934fdbdea921ee738fcc33e71adc9d6eca984a1cfc + languageName: node + linkType: hard + +"flatted@npm:^3.2.9": + version: 3.3.1 + resolution: "flatted@npm:3.3.1" + checksum: 10c0/324166b125ee07d4ca9bcf3a5f98d915d5db4f39d711fba640a3178b959919aae1f7cfd8aabcfef5826ed8aa8a2aa14cc85b2d7d18ff638ddf4ae3df39573eaf + languageName: node + linkType: hard + +"flow-remove-types@npm:^1.1.0": + version: 1.2.3 + resolution: "flow-remove-types@npm:1.2.3" + dependencies: + babylon: "npm:^6.15.0" + vlq: "npm:^0.2.1" + bin: + flow-node: ./flow-node + flow-remove-types: ./flow-remove-types + checksum: 10c0/df11795c056e80fea684e715448c83f6d23114c67d3a78370a7a97030c17518bff81484b11d5cf765a19134e2fbe05d075188972aa2e7aa30c79167de237d1eb + languageName: node + linkType: hard + +"fn.name@npm:1.x.x": + version: 1.1.0 + resolution: "fn.name@npm:1.1.0" + checksum: 10c0/8ad62aa2d4f0b2a76d09dba36cfec61c540c13a0fd72e5d94164e430f987a7ce6a743112bbeb14877c810ef500d1f73d7f56e76d029d2e3413f20d79e3460a9a + languageName: node + linkType: hard + +"for-each@npm:^0.3.3": + version: 0.3.3 + resolution: "for-each@npm:0.3.3" + dependencies: + is-callable: "npm:^1.1.3" + checksum: 10c0/22330d8a2db728dbf003ec9182c2d421fbcd2969b02b4f97ec288721cda63eb28f2c08585ddccd0f77cb2930af8d958005c9e72f47141dc51816127a118f39aa + languageName: node + linkType: hard + +"foreground-child@npm:^3.1.0": + version: 3.3.0 + resolution: "foreground-child@npm:3.3.0" + dependencies: + cross-spawn: "npm:^7.0.0" + signal-exit: "npm:^4.0.1" + checksum: 10c0/028f1d41000553fcfa6c4bb5c372963bf3d9bf0b1f25a87d1a6253014343fb69dfb1b42d9625d7cf44c8ba429940f3d0ff718b62105d4d4a4f6ef8ca0a53faa2 + languageName: node + linkType: hard + +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e + languageName: node + linkType: hard + +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: 10c0/9b67c3fac86acdbc9ae47ba1ddd5f2f81526fa4c8226863ede5600a3f7c7416ef451f6f1e240a3cc32d0fd79fcfe6beb08fd0da454f360032bde70bf80afbb33 + languageName: node + linkType: hard + +"fresh@npm:0.5.2": + version: 0.5.2 + resolution: "fresh@npm:0.5.2" + checksum: 10c0/c6d27f3ed86cc5b601404822f31c900dd165ba63fff8152a3ef714e2012e7535027063bc67ded4cb5b3a49fa596495d46cacd9f47d6328459cf570f08b7d9e5a + languageName: node + linkType: hard + +"fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0": + version: 10.1.0 + resolution: "fs-extra@npm:10.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/5f579466e7109719d162a9249abbeffe7f426eb133ea486e020b89bc6d67a741134076bf439983f2eb79276ceaf6bd7b7c1e43c3fd67fe889863e69072fb0a5e + languageName: node + linkType: hard + +"fs-extra@npm:^11.1.0, fs-extra@npm:^11.1.1": + version: 11.2.0 + resolution: "fs-extra@npm:11.2.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398 + languageName: node + linkType: hard + +"fs-extra@npm:^8.1.0": + version: 8.1.0 + resolution: "fs-extra@npm:8.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^4.0.0" + universalify: "npm:^0.1.0" + checksum: 10c0/259f7b814d9e50d686899550c4f9ded85c46c643f7fe19be69504888e007fcbc08f306fae8ec495b8b998635e997c9e3e175ff2eeed230524ef1c1684cc96423 + languageName: node + linkType: hard + +"fs-extra@npm:^9.0.0, fs-extra@npm:^9.0.1": + version: 9.1.0 + resolution: "fs-extra@npm:9.1.0" + dependencies: + at-least-node: "npm:^1.0.0" + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/9b808bd884beff5cb940773018179a6b94a966381d005479f00adda6b44e5e3d4abf765135773d849cc27efe68c349e4a7b86acd7d3306d5932c14f3a4b17a92 + languageName: node + linkType: hard + +"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0": + version: 2.1.0 + resolution: "fs-minipass@npm:2.1.0" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/703d16522b8282d7299337539c3ed6edddd1afe82435e4f5b76e34a79cd74e488a8a0e26a636afc2440e1a23b03878e2122e3a2cfe375a5cf63c37d92b86a004 + languageName: node + linkType: hard + +"fs-minipass@npm:^3.0.0": + version: 3.0.3 + resolution: "fs-minipass@npm:3.0.3" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 + languageName: node + linkType: hard + +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"gauge@npm:^4.0.3": + version: 4.0.4 + resolution: "gauge@npm:4.0.4" + dependencies: + aproba: "npm:^1.0.3 || ^2.0.0" + color-support: "npm:^1.1.3" + console-control-strings: "npm:^1.1.0" + has-unicode: "npm:^2.0.1" + signal-exit: "npm:^3.0.7" + string-width: "npm:^4.2.3" + strip-ansi: "npm:^6.0.1" + wide-align: "npm:^1.1.5" + checksum: 10c0/ef10d7981113d69225135f994c9f8c4369d945e64a8fc721d655a3a38421b738c9fe899951721d1b47b73c41fdb5404ac87cc8903b2ecbed95d2800363e7e58c + languageName: node + linkType: hard + +"gensync@npm:^1.0.0-beta.2": + version: 1.0.0-beta.2 + resolution: "gensync@npm:1.0.0-beta.2" + checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + +"get-func-name@npm:^2.0.1": + version: 2.0.2 + resolution: "get-func-name@npm:2.0.2" + checksum: 10c0/89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4": + version: 1.2.4 + resolution: "get-intrinsic@npm:1.2.4" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + has-proto: "npm:^1.0.1" + has-symbols: "npm:^1.0.3" + hasown: "npm:^2.0.0" + checksum: 10c0/0a9b82c16696ed6da5e39b1267104475c47e3a9bdbe8b509dfe1710946e38a87be70d759f4bb3cda042d76a41ef47fe769660f3b7c0d1f68750299344ffb15b7 + languageName: node + linkType: hard + +"get-stream@npm:^5.1.0": + version: 5.2.0 + resolution: "get-stream@npm:5.2.0" + dependencies: + pump: "npm:^3.0.0" + checksum: 10c0/43797ffd815fbb26685bf188c8cfebecb8af87b3925091dd7b9a9c915993293d78e3c9e1bce125928ff92f2d0796f3889b92b5ec6d58d1041b574682132e0a80 + languageName: node + linkType: hard + +"get-tsconfig@npm:^4.7.5": + version: 4.7.6 + resolution: "get-tsconfig@npm:4.7.6" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/2240e1b13e996dfbb947d177f422f83d09d1f93c9ce16959ebb3c2bdf8bdf4f04f98eba043859172da1685f9c7071091f0acfa964ebbe4780394d83b7dc3f58a + languageName: node + linkType: hard + +"github-slugger@npm:^2.0.0": + version: 2.0.0 + resolution: "github-slugger@npm:2.0.0" + checksum: 10c0/21b912b6b1e48f1e5a50b2292b48df0ff6abeeb0691b161b3d93d84f4ae6b1acd6ae23702e914af7ea5d441c096453cf0f621b72d57893946618d21dd1a1c486 + languageName: node + linkType: hard + +"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: "npm:^4.0.1" + checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee + languageName: node + linkType: hard + +"glob-parent@npm:^6.0.2": + version: 6.0.2 + resolution: "glob-parent@npm:6.0.2" + dependencies: + is-glob: "npm:^4.0.3" + checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 + languageName: node + linkType: hard + +"glob-promise@npm:^4.2.0": + version: 4.2.2 + resolution: "glob-promise@npm:4.2.2" + dependencies: + "@types/glob": "npm:^7.1.3" + peerDependencies: + glob: ^7.1.6 + checksum: 10c0/3eb01bed2901539365df6a4d27800afb8788840647d01f9bf3500b3de756597f2ff4b8c823971ace34db228c83159beca459dc42a70968d4e9c8200ed2cc96bd + languageName: node + linkType: hard + +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.12, glob@npm:^10.4.1": + version: 10.4.5 + resolution: "glob@npm:10.4.5" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + languageName: node + linkType: hard + +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.2.0": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe + languageName: node + linkType: hard + +"glob@npm:^8.0.1": + version: 8.1.0 + resolution: "glob@npm:8.1.0" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^5.0.1" + once: "npm:^1.3.0" + checksum: 10c0/cb0b5cab17a59c57299376abe5646c7070f8acb89df5595b492dba3bfb43d301a46c01e5695f01154e6553168207cb60d4eaf07d3be4bc3eb9b0457c5c561d0f + languageName: node + linkType: hard + +"global-agent@npm:^3.0.0": + version: 3.0.0 + resolution: "global-agent@npm:3.0.0" + dependencies: + boolean: "npm:^3.0.1" + es6-error: "npm:^4.1.1" + matcher: "npm:^3.0.0" + roarr: "npm:^2.15.3" + semver: "npm:^7.3.2" + serialize-error: "npm:^7.0.1" + checksum: 10c0/bb8750d026b25da437072762fd739098bad92ff72f66483c3929db4579e072f5523960f7e7fd70ee0d75db48898067b5dc1c9c1d17888128cff008fcc34d1bd3 + languageName: node + linkType: hard + +"globals@npm:^11.1.0": + version: 11.12.0 + resolution: "globals@npm:11.12.0" + checksum: 10c0/758f9f258e7b19226bd8d4af5d3b0dcf7038780fb23d82e6f98932c44e239f884847f1766e8fa9cc5635ccb3204f7fa7314d4408dd4002a5e8ea827b4018f0a1 + languageName: node + linkType: hard + +"globals@npm:^14.0.0": + version: 14.0.0 + resolution: "globals@npm:14.0.0" + checksum: 10c0/b96ff42620c9231ad468d4c58ff42afee7777ee1c963013ff8aabe095a451d0ceeb8dcd8ef4cbd64d2538cef45f787a78ba3a9574f4a634438963e334471302d + languageName: node + linkType: hard + +"globalthis@npm:^1.0.1": + version: 1.0.4 + resolution: "globalthis@npm:1.0.4" + dependencies: + define-properties: "npm:^1.2.1" + gopd: "npm:^1.0.1" + checksum: 10c0/9d156f313af79d80b1566b93e19285f481c591ad6d0d319b4be5e03750d004dde40a39a0f26f7e635f9007a3600802f53ecd85a759b86f109e80a5f705e01846 + languageName: node + linkType: hard + +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: 10c0/a54c029520cf58bda1d8884f72bd49b4cd74e977883268d931fd83bcbd1a9eb96d57c7dbd4ad80148fb9247467ebfb9b215630b2ed7563b2a8de02e1ff7f89d1 + languageName: node + linkType: hard + +"gopd@npm:^1.0.1": + version: 1.0.1 + resolution: "gopd@npm:1.0.1" + dependencies: + get-intrinsic: "npm:^1.1.3" + checksum: 10c0/505c05487f7944c552cee72087bf1567debb470d4355b1335f2c262d218ebbff805cd3715448fe29b4b380bae6912561d0467233e4165830efd28da241418c63 + languageName: node + linkType: hard + +"got@npm:^11.7.0, got@npm:^11.8.5": + version: 11.8.6 + resolution: "got@npm:11.8.6" + dependencies: + "@sindresorhus/is": "npm:^4.0.0" + "@szmarczak/http-timer": "npm:^4.0.5" + "@types/cacheable-request": "npm:^6.0.1" + "@types/responselike": "npm:^1.0.0" + cacheable-lookup: "npm:^5.0.3" + cacheable-request: "npm:^7.0.2" + decompress-response: "npm:^6.0.0" + http2-wrapper: "npm:^1.0.0-beta.5.2" + lowercase-keys: "npm:^2.0.0" + p-cancelable: "npm:^2.0.0" + responselike: "npm:^2.0.0" + checksum: 10c0/754dd44877e5cf6183f1e989ff01c648d9a4719e357457bd4c78943911168881f1cfb7b2cb15d885e2105b3ad313adb8f017a67265dd7ade771afdb261ee8cb1 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"graphemer@npm:^1.4.0": + version: 1.4.0 + resolution: "graphemer@npm:1.4.0" + checksum: 10c0/e951259d8cd2e0d196c72ec711add7115d42eb9a8146c8eeda5b8d3ac91e5dd816b9cd68920726d9fd4490368e7ed86e9c423f40db87e2d8dfafa00fa17c3a31 + languageName: node + linkType: hard + +"has-flag@npm:^3.0.0": + version: 3.0.0 + resolution: "has-flag@npm:3.0.0" + checksum: 10c0/1c6c83b14b8b1b3c25b0727b8ba3e3b647f99e9e6e13eb7322107261de07a4c1be56fc0d45678fc376e09772a3a1642ccdaf8fc69bdf123b6c086598397ce473 + languageName: node + linkType: hard + +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + languageName: node + linkType: hard + +"has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.2": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" + dependencies: + es-define-property: "npm:^1.0.0" + checksum: 10c0/253c1f59e80bb476cf0dde8ff5284505d90c3bdb762983c3514d36414290475fe3fd6f574929d84de2a8eec00d35cf07cb6776205ff32efd7c50719125f00236 + languageName: node + linkType: hard + +"has-proto@npm:^1.0.1": + version: 1.0.3 + resolution: "has-proto@npm:1.0.3" + checksum: 10c0/35a6989f81e9f8022c2f4027f8b48a552de714938765d019dbea6bb547bd49ce5010a3c7c32ec6ddac6e48fc546166a3583b128f5a7add8b058a6d8b4afec205 + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3": + version: 1.0.3 + resolution: "has-symbols@npm:1.0.3" + checksum: 10c0/e6922b4345a3f37069cdfe8600febbca791c94988c01af3394d86ca3360b4b93928bbf395859158f88099cb10b19d98e3bbab7c9ff2c1bd09cf665ee90afa2c3 + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + +"has-unicode@npm:^2.0.1": + version: 2.0.1 + resolution: "has-unicode@npm:2.0.1" + checksum: 10c0/ebdb2f4895c26bb08a8a100b62d362e49b2190bcfd84b76bc4be1a3bd4d254ec52d0dd9f2fbcc093fc5eb878b20c52146f9dfd33e2686ed28982187be593b47c + languageName: node + linkType: hard + +"hasown@npm:^2.0.0, hasown@npm:^2.0.2": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 + languageName: node + linkType: hard + +"hast-util-from-parse5@npm:^8.0.0": + version: 8.0.1 + resolution: "hast-util-from-parse5@npm:8.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + devlop: "npm:^1.0.0" + hastscript: "npm:^8.0.0" + property-information: "npm:^6.0.0" + vfile: "npm:^6.0.0" + vfile-location: "npm:^5.0.0" + web-namespaces: "npm:^2.0.0" + checksum: 10c0/4a30bb885cff1f0e023c429ae3ece73fe4b03386f07234bf23f5555ca087c2573ff4e551035b417ed7615bde559f394cdaf1db2b91c3b7f0575f3563cd238969 + languageName: node + linkType: hard + +"hast-util-heading-rank@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-heading-rank@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/1879c84f629e73f1f13247ab349324355cd801363b44e3d46f763aa5c0ea3b42dcd47b46e5643a0502cf01a6b1fdb9208fd12852e44ca6c671b3e4bccf9369a1 + languageName: node + linkType: hard + +"hast-util-is-element@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-is-element@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/f5361e4c9859c587ca8eb0d8343492f3077ccaa0f58a44cd09f35d5038f94d65152288dcd0c19336ef2c9491ec4d4e45fde2176b05293437021570aa0bc3613b + languageName: node + linkType: hard + +"hast-util-parse-selector@npm:^4.0.0": + version: 4.0.0 + resolution: "hast-util-parse-selector@npm:4.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/5e98168cb44470dc274aabf1a28317e4feb09b1eaf7a48bbaa8c1de1b43a89cd195cb1284e535698e658e3ec26ad91bc5e52c9563c36feb75abbc68aaf68fb9f + languageName: node + linkType: hard + +"hast-util-raw@npm:^9.0.0": + version: 9.0.4 + resolution: "hast-util-raw@npm:9.0.4" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + "@ungap/structured-clone": "npm:^1.0.0" + hast-util-from-parse5: "npm:^8.0.0" + hast-util-to-parse5: "npm:^8.0.0" + html-void-elements: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + parse5: "npm:^7.0.0" + unist-util-position: "npm:^5.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + web-namespaces: "npm:^2.0.0" + zwitch: "npm:^2.0.0" + checksum: 10c0/03d0fe7ba8bd75c9ce81f829650b19b78917bbe31db70d36bf6f136842496c3474e3bb1841f2d30dafe1f6b561a89a524185492b9a93d40b131000743c0d7998 + languageName: node + linkType: hard + +"hast-util-sanitize@npm:^5.0.0": + version: 5.0.1 + resolution: "hast-util-sanitize@npm:5.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + "@ungap/structured-clone": "npm:^1.2.0" + unist-util-position: "npm:^5.0.0" + checksum: 10c0/30b3e69effcd6c3c6b40264a169dca9ab6568d98b9aeaa75ee5e0a6b3c7f9843fe6bc9c2cb11c24172b32c2ab7d88e0f243e72a9526107ba35ca58f410cd7ca5 + languageName: node + linkType: hard + +"hast-util-to-jsx-runtime@npm:^2.0.0": + version: 2.3.0 + resolution: "hast-util-to-jsx-runtime@npm:2.3.0" + dependencies: + "@types/estree": "npm:^1.0.0" + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + devlop: "npm:^1.0.0" + estree-util-is-identifier-name: "npm:^3.0.0" + hast-util-whitespace: "npm:^3.0.0" + mdast-util-mdx-expression: "npm:^2.0.0" + mdast-util-mdx-jsx: "npm:^3.0.0" + mdast-util-mdxjs-esm: "npm:^2.0.0" + property-information: "npm:^6.0.0" + space-separated-tokens: "npm:^2.0.0" + style-to-object: "npm:^1.0.0" + unist-util-position: "npm:^5.0.0" + vfile-message: "npm:^4.0.0" + checksum: 10c0/df7a36dcc792df7667a54438f044b721753d5e09692606d23bf7336bf4651670111fe7728eebbf9f0e4f96ab3346a05bb23037fa1b1d115482b3bc5bde8b6912 + languageName: node + linkType: hard + +"hast-util-to-parse5@npm:^8.0.0": + version: 8.0.0 + resolution: "hast-util-to-parse5@npm:8.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + devlop: "npm:^1.0.0" + property-information: "npm:^6.0.0" + space-separated-tokens: "npm:^2.0.0" + web-namespaces: "npm:^2.0.0" + zwitch: "npm:^2.0.0" + checksum: 10c0/3c0c7fba026e0c4be4675daf7277f9ff22ae6da801435f1b7104f7740de5422576f1c025023c7b3df1d0a161e13a04c6ab8f98ada96eb50adb287b537849a2bd + languageName: node + linkType: hard + +"hast-util-to-string@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-to-string@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/649edd993cf244563ad86d861aa0863759a4fbec49c43b3d92240e42aa4b69f0c3332ddff9e80954bbd8756c86b0fddc20e97d281c6da59d00427f45da8dab68 + languageName: node + linkType: hard + +"hast-util-to-text@npm:^4.0.0": + version: 4.0.2 + resolution: "hast-util-to-text@npm:4.0.2" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + hast-util-is-element: "npm:^3.0.0" + unist-util-find-after: "npm:^5.0.0" + checksum: 10c0/93ecc10e68fe5391c6e634140eb330942e71dea2724c8e0c647c73ed74a8ec930a4b77043b5081284808c96f73f2bee64ee416038ece75a63a467e8d14f09946 + languageName: node + linkType: hard + +"hast-util-whitespace@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-whitespace@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/b898bc9fe27884b272580d15260b6bbdabe239973a147e97fa98c45fa0ffec967a481aaa42291ec34fb56530dc2d484d473d7e2bae79f39c83f3762307edfea8 + languageName: node + linkType: hard + +"hastscript@npm:^8.0.0": + version: 8.0.0 + resolution: "hastscript@npm:8.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-parse-selector: "npm:^4.0.0" + property-information: "npm:^6.0.0" + space-separated-tokens: "npm:^2.0.0" + checksum: 10c0/f0b54bbdd710854b71c0f044612db0fe1b5e4d74fa2001633dc8c535c26033269f04f536f9fd5b03f234de1111808f9e230e9d19493bf919432bb24d541719e0 + languageName: node + linkType: hard + +"highlight.js@npm:~11.9.0": + version: 11.9.0 + resolution: "highlight.js@npm:11.9.0" + checksum: 10c0/27cfa8717dc9d200aecbdb383eb122d5f45ce715d2f468583785d36fbfe5076ce033abb02486dc13b407171721cda6f474ed3f3a5a8e8c3d91367fa5f51ee374 + languageName: node + linkType: hard + +"hoist-non-react-statics@npm:^3.3.2": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: "npm:^16.7.0" + checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74 + languageName: node + linkType: hard + +"hosted-git-info@npm:^4.1.0": + version: 4.1.0 + resolution: "hosted-git-info@npm:4.1.0" + dependencies: + lru-cache: "npm:^6.0.0" + checksum: 10c0/150fbcb001600336d17fdbae803264abed013548eea7946c2264c49ebe2ebd8c4441ba71dd23dd8e18c65de79d637f98b22d4760ba5fb2e0b15d62543d0fff07 + languageName: node + linkType: hard + +"htl@npm:^0.3.1": + version: 0.3.1 + resolution: "htl@npm:0.3.1" + checksum: 10c0/36a22e10e0f11982c4e142c8c7bd389b3e9e7d70379c12f7572140a668a2a0328198f53fcb582281be761db91240ffb60261840256ebb10131739454baf82560 + languageName: node + linkType: hard + +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + +"html-tags@npm:^3.1.0": + version: 3.3.1 + resolution: "html-tags@npm:3.3.1" + checksum: 10c0/680165e12baa51bad7397452d247dbcc5a5c29dac0e6754b1187eee3bf26f514bc1907a431dd2f7eb56207611ae595ee76a0acc8eaa0d931e72c791dd6463d79 + languageName: node + linkType: hard + +"html-to-image@npm:^1.11.11": + version: 1.11.11 + resolution: "html-to-image@npm:1.11.11" + checksum: 10c0/0b6349221ad253dfca01d165c589d44341e942faf0273aab28c8b7d86ff2922d3e8e6390f57bf5ddaf6bac9a3b590a8cdaa77d52a363354796dd0e0e05eb35d2 + languageName: node + linkType: hard + +"html-url-attributes@npm:^3.0.0": + version: 3.0.0 + resolution: "html-url-attributes@npm:3.0.0" + checksum: 10c0/af300ae1f3b9cf90aba0d95a165c3f4066ec2b3ee2f36a885a8d842e68675e4133896b00bde42d18ac799d0ce678fa1695baec3f865b01a628922d737c0d035c + languageName: node + linkType: hard + +"html-void-elements@npm:^3.0.0": + version: 3.0.0 + resolution: "html-void-elements@npm:3.0.0" + checksum: 10c0/a8b9ec5db23b7c8053876dad73a0336183e6162bf6d2677376d8b38d654fdc59ba74fdd12f8812688f7db6fad451210c91b300e472afc0909224e0a44c8610d2 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.0, http-cache-semantics@npm:^4.1.1": + version: 4.1.1 + resolution: "http-cache-semantics@npm:4.1.1" + checksum: 10c0/ce1319b8a382eb3cbb4a37c19f6bfe14e5bb5be3d09079e885e8c513ab2d3cd9214902f8a31c9dc4e37022633ceabfc2d697405deeaf1b8f3552bb4ed996fdfc + languageName: node + linkType: hard + +"http-errors@npm:2.0.0": + version: 2.0.0 + resolution: "http-errors@npm:2.0.0" + dependencies: + depd: "npm:2.0.0" + inherits: "npm:2.0.4" + setprototypeof: "npm:1.2.0" + statuses: "npm:2.0.1" + toidentifier: "npm:1.0.1" + checksum: 10c0/fc6f2715fe188d091274b5ffc8b3657bd85c63e969daa68ccb77afb05b071a4b62841acb7a21e417b5539014dff2ebf9550f0b14a9ff126f2734a7c1387f8e19 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" + dependencies: + "@tootallnate/once": "npm:2" + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/32a05e413430b2c1e542e5c74b38a9f14865301dd69dff2e53ddb684989440e3d2ce0c4b64d25eb63cf6283e6265ff979a61cf93e3ca3d23047ddfdc8df34a32 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + +"http2-wrapper@npm:^1.0.0-beta.5.2": + version: 1.0.3 + resolution: "http2-wrapper@npm:1.0.3" + dependencies: + quick-lru: "npm:^5.1.1" + resolve-alpn: "npm:^1.0.0" + checksum: 10c0/6a9b72a033e9812e1476b9d776ce2f387bc94bc46c88aea0d5dab6bd47d0a539b8178830e77054dd26d1142c866d515a28a4dc7c3ff4232c88ff2ebe4f5d12d1 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.1": + version: 7.0.5 + resolution: "https-proxy-agent@npm:7.0.5" + dependencies: + agent-base: "npm:^7.0.2" + debug: "npm:4" + checksum: 10c0/2490e3acec397abeb88807db52cac59102d5ed758feee6df6112ab3ccd8325e8a1ce8bce6f4b66e5470eca102d31e425ace904242e4fa28dbe0c59c4bafa7b2c + languageName: node + linkType: hard + +"humanize-ms@npm:^1.2.1": + version: 1.2.1 + resolution: "humanize-ms@npm:1.2.1" + dependencies: + ms: "npm:^2.0.0" + checksum: 10c0/f34a2c20161d02303c2807badec2f3b49cbfbbb409abd4f95a07377ae01cfe6b59e3d15ac609cffcd8f2521f0eb37b7e1091acf65da99aa2a4f1ad63c21e7e7a + languageName: node + linkType: hard + +"iconv-corefoundation@npm:^1.1.7": + version: 1.1.7 + resolution: "iconv-corefoundation@npm:1.1.7" + dependencies: + cli-truncate: "npm:^2.1.0" + node-addon-api: "npm:^1.6.3" + conditions: os=darwin + languageName: node + linkType: hard + +"iconv-lite@npm:0.4.24": + version: 0.4.24 + resolution: "iconv-lite@npm:0.4.24" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3" + checksum: 10c0/c6886a24cc00f2a059767440ec1bc00d334a89f250db8e0f7feb4961c8727118457e27c495ba94d082e51d3baca378726cd110aaf7ded8b9bbfd6a44760cf1d4 + languageName: node + linkType: hard + +"iconv-lite@npm:0.6, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 + languageName: node + linkType: hard + +"ieee754@npm:^1.1.13": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + +"ignore@npm:^5.2.0, ignore@npm:^5.3.1": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + languageName: node + linkType: hard + +"image-size@npm:~0.5.0": + version: 0.5.5 + resolution: "image-size@npm:0.5.5" + bin: + image-size: bin/image-size.js + checksum: 10c0/655204163af06732f483a9fe7cce9dff4a29b7b2e88f5c957a5852e8143fa750f5e54b1955a2ca83de99c5220dbd680002d0d4e09140b01433520f4d5a0b1f4c + languageName: node + linkType: hard + +"immer@npm:^10.1.1": + version: 10.1.1 + resolution: "immer@npm:10.1.1" + checksum: 10c0/b749e10d137ccae91788f41bd57e9387f32ea6d6ea8fd7eb47b23fd7766681575efc7f86ceef7fe24c3bc9d61e38ff5d2f49c2663b2b0c056e280a4510923653 + languageName: node + linkType: hard + +"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": + version: 3.3.0 + resolution: "import-fresh@npm:3.3.0" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/7f882953aa6b740d1f0e384d0547158bc86efbf2eea0f1483b8900a6f65c5a5123c2cf09b0d542cc419d0b98a759ecaeb394237e97ea427f2da221dc3cd80cc3 + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 + languageName: node + linkType: hard + +"indent-string@npm:^4.0.0": + version: 4.0.0 + resolution: "indent-string@npm:4.0.0" + checksum: 10c0/1e1904ddb0cb3d6cce7cd09e27a90184908b7a5d5c21b92e232c93579d314f0b83c246ffb035493d0504b1e9147ba2c9b21df0030f48673fba0496ecd698161f + languageName: node + linkType: hard + +"infer-owner@npm:^1.0.4": + version: 1.0.4 + resolution: "infer-owner@npm:1.0.4" + checksum: 10c0/a7b241e3149c26e37474e3435779487f42f36883711f198c45794703c7556bc38af224088bd4d1a221a45b8208ae2c2bcf86200383621434d0c099304481c5b9 + languageName: node + linkType: hard + +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 + languageName: node + linkType: hard + +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"inline-style-parser@npm:0.2.3": + version: 0.2.3 + resolution: "inline-style-parser@npm:0.2.3" + checksum: 10c0/21b46d39a39c8aeaa738346650469388e8a412dd276ab75aa3d85b1883311e89c86a1fdbb8c2f1958f4c979bae74067f6ba0385455b125faf4fa77e1dbb94799 + languageName: node + linkType: hard + +"internmap@npm:1 - 2": + version: 2.0.3 + resolution: "internmap@npm:2.0.3" + checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed + languageName: node + linkType: hard + +"interval-tree-1d@npm:^1.0.0": + version: 1.0.4 + resolution: "interval-tree-1d@npm:1.0.4" + dependencies: + binary-search-bounds: "npm:^2.0.0" + checksum: 10c0/914c1a08c6fb99edf5292c47350d18200b61c549b02cb9d6ab4790d099f10c1044fb6df5cfb99cb4086410f412c078b236b747b3420aafd035c3fe5f00893ebf + languageName: node + linkType: hard + +"ip-address@npm:^9.0.5": + version: 9.0.5 + resolution: "ip-address@npm:9.0.5" + dependencies: + jsbn: "npm:1.1.0" + sprintf-js: "npm:^1.1.3" + checksum: 10c0/331cd07fafcb3b24100613e4b53e1a2b4feab11e671e655d46dc09ee233da5011284d09ca40c4ecbdfe1d0004f462958675c224a804259f2f78d2465a87824bc + languageName: node + linkType: hard + +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: 10c0/0486e775047971d3fdb5fb4f063829bac45af299ae0b82dcf3afa2145338e08290563a2a70f34b732d795ecc8311902e541a8530eeb30d75860a78ff4e94ce2a + languageName: node + linkType: hard + +"is-absolute-url@npm:^4.0.0": + version: 4.0.1 + resolution: "is-absolute-url@npm:4.0.1" + checksum: 10c0/6f8f603945bd9f2c6031758bbc12352fc647bd5d807cad10d96cc6300fd0e15240cc091521a61db767e4ec0bacff257b4f1015fd5249c147bbb4a4497356c72e + languageName: node + linkType: hard + +"is-alphabetical@npm:^2.0.0": + version: 2.0.1 + resolution: "is-alphabetical@npm:2.0.1" + checksum: 10c0/932367456f17237533fd1fc9fe179df77957271020b83ea31da50e5cc472d35ef6b5fb8147453274ffd251134472ce24eb6f8d8398d96dee98237cdb81a6c9a7 + languageName: node + linkType: hard + +"is-alphanumerical@npm:^2.0.0": + version: 2.0.1 + resolution: "is-alphanumerical@npm:2.0.1" + dependencies: + is-alphabetical: "npm:^2.0.0" + is-decimal: "npm:^2.0.0" + checksum: 10c0/4b35c42b18e40d41378293f82a3ecd9de77049b476f748db5697c297f686e1e05b072a6aaae2d16f54d2a57f85b00cbbe755c75f6d583d1c77d6657bd0feb5a2 + languageName: node + linkType: hard + +"is-arguments@npm:^1.0.4": + version: 1.1.1 + resolution: "is-arguments@npm:1.1.1" + dependencies: + call-bind: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.0" + checksum: 10c0/5ff1f341ee4475350adfc14b2328b38962564b7c2076be2f5bac7bd9b61779efba99b9f844a7b82ba7654adccf8e8eb19d1bb0cc6d1c1a085e498f6793d4328f + languageName: node + linkType: hard + +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 + languageName: node + linkType: hard + +"is-arrayish@npm:^0.3.1": + version: 0.3.2 + resolution: "is-arrayish@npm:0.3.2" + checksum: 10c0/f59b43dc1d129edb6f0e282595e56477f98c40278a2acdc8b0a5c57097c9eff8fe55470493df5775478cf32a4dc8eaf6d3a749f07ceee5bc263a78b2434f6a54 + languageName: node + linkType: hard + +"is-binary-path@npm:~2.1.0": + version: 2.1.0 + resolution: "is-binary-path@npm:2.1.0" + dependencies: + binary-extensions: "npm:^2.0.0" + checksum: 10c0/a16eaee59ae2b315ba36fad5c5dcaf8e49c3e27318f8ab8fa3cdb8772bf559c8d1ba750a589c2ccb096113bb64497084361a25960899cb6172a6925ab6123d38 + languageName: node + linkType: hard + +"is-builtin-module@npm:^3.2.1": + version: 3.2.1 + resolution: "is-builtin-module@npm:3.2.1" + dependencies: + builtin-modules: "npm:^3.3.0" + checksum: 10c0/5a66937a03f3b18803381518f0ef679752ac18cdb7dd53b5e23ee8df8d440558737bd8dcc04d2aae555909d2ecb4a81b5c0d334d119402584b61e6a003e31af1 + languageName: node + linkType: hard + +"is-callable@npm:^1.1.3": + version: 1.2.7 + resolution: "is-callable@npm:1.2.7" + checksum: 10c0/ceebaeb9d92e8adee604076971dd6000d38d6afc40bb843ea8e45c5579b57671c3f3b50d7f04869618242c6cee08d1b67806a8cb8edaaaf7c0748b3720d6066f + languageName: node + linkType: hard + +"is-ci@npm:^3.0.0": + version: 3.0.1 + resolution: "is-ci@npm:3.0.1" + dependencies: + ci-info: "npm:^3.2.0" + bin: + is-ci: bin.js + checksum: 10c0/0e81caa62f4520d4088a5bef6d6337d773828a88610346c4b1119fb50c842587ed8bef1e5d9a656835a599e7209405b5761ddf2339668f2d0f4e889a92fe6051 + languageName: node + linkType: hard + +"is-core-module@npm:^2.13.0": + version: 2.15.1 + resolution: "is-core-module@npm:2.15.1" + dependencies: + hasown: "npm:^2.0.2" + checksum: 10c0/53432f10c69c40bfd2fa8914133a68709ff9498c86c3bf5fca3cdf3145a56fd2168cbf4a43b29843a6202a120a5f9c5ffba0a4322e1e3441739bc0b641682612 + languageName: node + linkType: hard + +"is-decimal@npm:^2.0.0": + version: 2.0.1 + resolution: "is-decimal@npm:2.0.1" + checksum: 10c0/8085dd66f7d82f9de818fba48b9e9c0429cb4291824e6c5f2622e96b9680b54a07a624cfc663b24148b8e853c62a1c987cfe8b0b5a13f5156991afaf6736e334 + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc + languageName: node + linkType: hard + +"is-generator-function@npm:^1.0.7": + version: 1.0.10 + resolution: "is-generator-function@npm:1.0.10" + dependencies: + has-tostringtag: "npm:^1.0.0" + checksum: 10c0/df03514df01a6098945b5a0cfa1abff715807c8e72f57c49a0686ad54b3b74d394e2d8714e6f709a71eb00c9630d48e73ca1796c1ccc84ac95092c1fecc0d98b + languageName: node + linkType: hard + +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: "npm:^2.1.1" + checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a + languageName: node + linkType: hard + +"is-hexadecimal@npm:^2.0.0": + version: 2.0.1 + resolution: "is-hexadecimal@npm:2.0.1" + checksum: 10c0/3eb60fe2f1e2bbc760b927dcad4d51eaa0c60138cf7fc671803f66353ad90c301605b502c7ea4c6bb0548e1c7e79dfd37b73b632652e3b76030bba603a7e9626 + languageName: node + linkType: hard + +"is-interactive@npm:^1.0.0": + version: 1.0.0 + resolution: "is-interactive@npm:1.0.0" + checksum: 10c0/dd47904dbf286cd20aa58c5192161be1a67138485b9836d5a70433b21a45442e9611b8498b8ab1f839fc962c7620667a50535fdfb4a6bc7989b8858645c06b4d + languageName: node + linkType: hard + +"is-lambda@npm:^1.0.1": + version: 1.0.1 + resolution: "is-lambda@npm:1.0.1" + checksum: 10c0/85fee098ae62ba6f1e24cf22678805473c7afd0fb3978a3aa260e354cb7bcb3a5806cf0a98403188465efedec41ab4348e8e4e79305d409601323855b3839d4d + languageName: node + linkType: hard + +"is-module@npm:^1.0.0": + version: 1.0.0 + resolution: "is-module@npm:1.0.0" + checksum: 10c0/795a3914bcae7c26a1c23a1e5574c42eac13429625045737bf3e324ce865c0601d61aee7a5afbca1bee8cb300c7d9647e7dc98860c9bdbc3b7fdc51d8ac0bffc + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"is-path-inside@npm:^3.0.3": + version: 3.0.3 + resolution: "is-path-inside@npm:3.0.3" + checksum: 10c0/cf7d4ac35fb96bab6a1d2c3598fe5ebb29aafb52c0aaa482b5a3ed9d8ba3edc11631e3ec2637660c44b3ce0e61a08d54946e8af30dec0b60a7c27296c68ffd05 + languageName: node + linkType: hard + +"is-plain-obj@npm:^4.0.0": + version: 4.1.0 + resolution: "is-plain-obj@npm:4.1.0" + checksum: 10c0/32130d651d71d9564dc88ba7e6fda0e91a1010a3694648e9f4f47bb6080438140696d3e3e15c741411d712e47ac9edc1a8a9de1fe76f3487b0d90be06ac9975e + languageName: node + linkType: hard + +"is-plain-object@npm:5.0.0": + version: 5.0.0 + resolution: "is-plain-object@npm:5.0.0" + checksum: 10c0/893e42bad832aae3511c71fd61c0bf61aa3a6d853061c62a307261842727d0d25f761ce9379f7ba7226d6179db2a3157efa918e7fe26360f3bf0842d9f28942c + languageName: node + linkType: hard + +"is-stream@npm:^2.0.0": + version: 2.0.1 + resolution: "is-stream@npm:2.0.1" + checksum: 10c0/7c284241313fc6efc329b8d7f08e16c0efeb6baab1b4cd0ba579eb78e5af1aa5da11e68559896a2067cd6c526bd29241dda4eb1225e627d5aa1a89a76d4635a5 + languageName: node + linkType: hard + +"is-typed-array@npm:^1.1.3": + version: 1.1.13 + resolution: "is-typed-array@npm:1.1.13" + dependencies: + which-typed-array: "npm:^1.1.14" + checksum: 10c0/fa5cb97d4a80e52c2cc8ed3778e39f175a1a2ae4ddf3adae3187d69586a1fd57cfa0b095db31f66aa90331e9e3da79184cea9c6abdcd1abc722dc3c3edd51cca + languageName: node + linkType: hard + +"is-unicode-supported@npm:^0.1.0": + version: 0.1.0 + resolution: "is-unicode-supported@npm:0.1.0" + checksum: 10c0/00cbe3455c3756be68d2542c416cab888aebd5012781d6819749fefb15162ff23e38501fe681b3d751c73e8ff561ac09a5293eba6f58fdf0178462ce6dcb3453 + languageName: node + linkType: hard + +"is-what@npm:^3.14.1": + version: 3.14.1 + resolution: "is-what@npm:3.14.1" + checksum: 10c0/4b770b85454c877b6929a84fd47c318e1f8c2ff70fd72fd625bc3fde8e0c18a6e57345b6e7aa1ee9fbd1c608d27cfe885df473036c5c2e40cd2187250804a2c7 + languageName: node + linkType: hard + +"isbinaryfile@npm:^4.0.8": + version: 4.0.10 + resolution: "isbinaryfile@npm:4.0.10" + checksum: 10c0/0703d8cfeb69ed79e6d173120f327450011a066755150a6bbf97ffecec1069a5f2092777868315b21359098c84b54984871cad1abce877ad9141fb2caf3dcabf + languageName: node + linkType: hard + +"isbinaryfile@npm:^5.0.0": + version: 5.0.2 + resolution: "isbinaryfile@npm:5.0.2" + checksum: 10c0/9696f20cf995e375ba8bfdba3ff7d1c0435346f6fc5dd9c049a55514c56e9f49342bbf8c240dc9f56e104bd3a69176c0421922bcb34d72b3c943f4117ade3f53 + languageName: node + linkType: hard + +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d + languageName: node + linkType: hard + +"isexe@npm:^3.1.1": + version: 3.1.1 + resolution: "isexe@npm:3.1.1" + checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 + languageName: node + linkType: hard + +"isoformat@npm:^0.2.0": + version: 0.2.1 + resolution: "isoformat@npm:0.2.1" + checksum: 10c0/ee54060f464ae9e69574e23010e3b0725dd646337ca75ac1234bf9a2b7f4f4618028884ce872ab2cd7beea2739addbfbaed1ebd294225cc7aafb31eb84bf6163 + languageName: node + linkType: hard + +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-instrument@npm:^6.0.3": + version: 6.0.3 + resolution: "istanbul-lib-instrument@npm:6.0.3" + dependencies: + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^7.5.4" + checksum: 10c0/a1894e060dd2a3b9f046ffdc87b44c00a35516f5e6b7baf4910369acca79e506fc5323a816f811ae23d82334b38e3ddeb8b3b331bd2c860540793b59a8689128 + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.7": + version: 3.1.7 + resolution: "istanbul-reports@npm:3.1.7" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 + languageName: node + linkType: hard + +"jackspeak@npm:^3.1.2": + version: 3.4.3 + resolution: "jackspeak@npm:3.4.3" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 10c0/6acc10d139eaefdbe04d2f679e6191b3abf073f111edf10b1de5302c97ec93fffeb2fdd8681ed17f16268aa9dd4f8c588ed9d1d3bffbbfa6e8bf897cbb3149b9 + languageName: node + linkType: hard + +"jake@npm:^10.8.5": + version: 10.9.2 + resolution: "jake@npm:10.9.2" + dependencies: + async: "npm:^3.2.3" + chalk: "npm:^4.0.2" + filelist: "npm:^1.0.4" + minimatch: "npm:^3.1.2" + bin: + jake: bin/cli.js + checksum: 10c0/c4597b5ed9b6a908252feab296485a4f87cba9e26d6c20e0ca144fb69e0c40203d34a2efddb33b3d297b8bd59605e6c1f44f6221ca1e10e69175ecbf3ff5fe31 + languageName: node + linkType: hard + +"jotai@npm:^2.9.3": + version: 2.9.3 + resolution: "jotai@npm:2.9.3" + peerDependencies: + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + checksum: 10c0/d7a2aedeebd84f332b930c5dc06f6827c11a70c3b6bae0817e0c778f7c34996f3a47d5c544ff1b7ed2662083feed55ea6d77b281adf2e0fba65ad93b1723d687 + languageName: node + linkType: hard + +"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: "npm:^2.0.1" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + languageName: node + linkType: hard + +"jsbn@npm:1.1.0": + version: 1.1.0 + resolution: "jsbn@npm:1.1.0" + checksum: 10c0/4f907fb78d7b712e11dea8c165fe0921f81a657d3443dde75359ed52eb2b5d33ce6773d97985a089f09a65edd80b11cb75c767b57ba47391fee4c969f7215c96 + languageName: node + linkType: hard + +"jsesc@npm:^2.5.1": + version: 2.5.2 + resolution: "jsesc@npm:2.5.2" + bin: + jsesc: bin/jsesc + checksum: 10c0/dbf59312e0ebf2b4405ef413ec2b25abb5f8f4d9bc5fb8d9f90381622ebca5f2af6a6aa9a8578f65903f9e33990a6dc798edd0ce5586894bf0e9e31803a1de88 + languageName: node + linkType: hard + +"json-buffer@npm:3.0.1": + version: 3.0.1 + resolution: "json-buffer@npm:3.0.1" + checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 + languageName: node + linkType: hard + +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 + languageName: node + linkType: hard + +"json-schema-traverse@npm:^0.4.1": + version: 0.4.1 + resolution: "json-schema-traverse@npm:0.4.1" + checksum: 10c0/108fa90d4cc6f08243aedc6da16c408daf81793bf903e9fd5ab21983cda433d5d2da49e40711da016289465ec2e62e0324dcdfbc06275a607fe3233fde4942ce + languageName: node + linkType: hard + +"json-stable-stringify-without-jsonify@npm:^1.0.1": + version: 1.0.1 + resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" + checksum: 10c0/cb168b61fd4de83e58d09aaa6425ef71001bae30d260e2c57e7d09a5fd82223e2f22a042dedaab8db23b7d9ae46854b08bb1f91675a8be11c5cffebef5fb66a5 + languageName: node + linkType: hard + +"json-stringify-safe@npm:^5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 10c0/7dbf35cd0411d1d648dceb6d59ce5857ec939e52e4afc37601aa3da611f0987d5cee5b38d58329ceddf3ed48bd7215229c8d52059ab01f2444a338bf24ed0f37 + languageName: node + linkType: hard + +"json5@npm:^2.2.2, json5@npm:^2.2.3": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c + languageName: node + linkType: hard + +"jsonfile@npm:^4.0.0": + version: 4.0.0 + resolution: "jsonfile@npm:4.0.0" + dependencies: + graceful-fs: "npm:^4.1.6" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10c0/7dc94b628d57a66b71fb1b79510d460d662eb975b5f876d723f81549c2e9cd316d58a2ddf742b2b93a4fa6b17b2accaf1a738a0e2ea114bdfb13a32e5377e480 + languageName: node + linkType: hard + +"jsonfile@npm:^6.0.1, jsonfile@npm:^6.1.0": + version: 6.1.0 + resolution: "jsonfile@npm:6.1.0" + dependencies: + graceful-fs: "npm:^4.1.6" + universalify: "npm:^2.0.0" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10c0/4f95b5e8a5622b1e9e8f33c96b7ef3158122f595998114d1e7f03985649ea99cb3cd99ce1ed1831ae94c8c8543ab45ebd044207612f31a56fd08462140e46865 + languageName: node + linkType: hard + +"keyv@npm:^4.0.0, keyv@npm:^4.5.4": + version: 4.5.4 + resolution: "keyv@npm:4.5.4" + dependencies: + json-buffer: "npm:3.0.1" + checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e + languageName: node + linkType: hard + +"kuler@npm:^2.0.0": + version: 2.0.0 + resolution: "kuler@npm:2.0.0" + checksum: 10c0/0a4e99d92ca373f8f74d1dc37931909c4d0d82aebc94cf2ba265771160fc12c8df34eaaac80805efbda367e2795cb1f1dd4c3d404b6b1cf38aec94035b503d2d + languageName: node + linkType: hard + +"lazy-val@npm:^1.0.5": + version: 1.0.5 + resolution: "lazy-val@npm:1.0.5" + checksum: 10c0/28ba7a0e704895a444eed47d110274090f485b991f2ea6fff2ab0878c529c53f60f2eb2d944cbbd68b91408e7455eabc62861c48289d4757fa9c818b97454f24 + languageName: node + linkType: hard + +"less@npm:^4.2.0": + version: 4.2.0 + resolution: "less@npm:4.2.0" + dependencies: + copy-anything: "npm:^2.0.1" + errno: "npm:^0.1.1" + graceful-fs: "npm:^4.1.2" + image-size: "npm:~0.5.0" + make-dir: "npm:^2.1.0" + mime: "npm:^1.4.1" + needle: "npm:^3.1.0" + parse-node-version: "npm:^1.0.1" + source-map: "npm:~0.6.0" + tslib: "npm:^2.3.0" + dependenciesMeta: + errno: + optional: true + graceful-fs: + optional: true + image-size: + optional: true + make-dir: + optional: true + mime: + optional: true + needle: + optional: true + source-map: + optional: true + bin: + lessc: bin/lessc + checksum: 10c0/8593d547a3e7651555a2c51bac8b148b37ec14e75e6e28ee4ddf27eb49cbcb4b558e50cdefa97d6942a8120fc744ace0d61c43d4c246e098c8828269b14cf5fb + languageName: node + linkType: hard + +"levn@npm:^0.4.1": + version: 0.4.1 + resolution: "levn@npm:0.4.1" + dependencies: + prelude-ls: "npm:^1.2.1" + type-check: "npm:~0.4.0" + checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e + languageName: node + linkType: hard + +"lines-and-columns@npm:^1.1.6": + version: 1.2.4 + resolution: "lines-and-columns@npm:1.2.4" + checksum: 10c0/3da6ee62d4cd9f03f5dc90b4df2540fb85b352081bee77fe4bbcd12c9000ead7f35e0a38b8d09a9bb99b13223446dd8689ff3c4959807620726d788701a83d2d + languageName: node + linkType: hard + +"locate-path@npm:^5.0.0": + version: 5.0.0 + resolution: "locate-path@npm:5.0.0" + dependencies: + p-locate: "npm:^4.1.0" + checksum: 10c0/33a1c5247e87e022f9713e6213a744557a3e9ec32c5d0b5efb10aa3a38177615bf90221a5592674857039c1a0fd2063b82f285702d37b792d973e9e72ace6c59 + languageName: node + linkType: hard + +"locate-path@npm:^6.0.0": + version: 6.0.0 + resolution: "locate-path@npm:6.0.0" + dependencies: + p-locate: "npm:^5.0.0" + checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3 + languageName: node + linkType: hard + +"lodash.escaperegexp@npm:^4.1.2": + version: 4.1.2 + resolution: "lodash.escaperegexp@npm:4.1.2" + checksum: 10c0/484ad4067fa9119bb0f7c19a36ab143d0173a081314993fe977bd00cf2a3c6a487ce417a10f6bac598d968364f992153315f0dbe25c9e38e3eb7581dd333e087 + languageName: node + linkType: hard + +"lodash.isequal@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.isequal@npm:4.5.0" + checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f + languageName: node + linkType: hard + +"lodash.merge@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.merge@npm:4.6.2" + checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 + languageName: node + linkType: hard + +"lodash@npm:^4.17.15, lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c + languageName: node + linkType: hard + +"log-symbols@npm:^4.1.0": + version: 4.1.0 + resolution: "log-symbols@npm:4.1.0" + dependencies: + chalk: "npm:^4.1.0" + is-unicode-supported: "npm:^0.1.0" + checksum: 10c0/67f445a9ffa76db1989d0fa98586e5bc2fd5247260dafb8ad93d9f0ccd5896d53fb830b0e54dade5ad838b9de2006c826831a3c528913093af20dff8bd24aca6 + languageName: node + linkType: hard + +"logform@npm:^2.6.0, logform@npm:^2.6.1": + version: 2.6.1 + resolution: "logform@npm:2.6.1" + dependencies: + "@colors/colors": "npm:1.6.0" + "@types/triple-beam": "npm:^1.3.2" + fecha: "npm:^4.2.0" + ms: "npm:^2.1.1" + safe-stable-stringify: "npm:^2.3.1" + triple-beam: "npm:^1.3.0" + checksum: 10c0/c20019336b1da8c08adea67dd7de2b0effdc6e35289c0156722924b571df94ba9f900ef55620c56bceb07cae7cc46057c9859accdee37a131251ba34d6789bce + languageName: node + linkType: hard + +"longest-streak@npm:^3.0.0": + version: 3.1.0 + resolution: "longest-streak@npm:3.1.0" + checksum: 10c0/7c2f02d0454b52834d1bcedef79c557bd295ee71fdabb02d041ff3aa9da48a90b5df7c0409156dedbc4df9b65da18742652aaea4759d6ece01f08971af6a7eaa + languageName: node + linkType: hard + +"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": + version: 1.4.0 + resolution: "loose-envify@npm:1.4.0" + dependencies: + js-tokens: "npm:^3.0.0 || ^4.0.0" + bin: + loose-envify: cli.js + checksum: 10c0/655d110220983c1a4b9c0c679a2e8016d4b67f6e9c7b5435ff5979ecdb20d0813f4dec0a08674fcbdd4846a3f07edbb50a36811fd37930b94aaa0d9daceb017e + languageName: node + linkType: hard + +"loupe@npm:^3.1.0, loupe@npm:^3.1.1": + version: 3.1.1 + resolution: "loupe@npm:3.1.1" + dependencies: + get-func-name: "npm:^2.0.1" + checksum: 10c0/99f88badc47e894016df0c403de846fedfea61154aadabbf776c8428dd59e8d8378007135d385d737de32ae47980af07d22ba7bec5ef7beebd721de9baa0a0af + languageName: node + linkType: hard + +"lower-case@npm:^2.0.2": + version: 2.0.2 + resolution: "lower-case@npm:2.0.2" + dependencies: + tslib: "npm:^2.0.3" + checksum: 10c0/3d925e090315cf7dc1caa358e0477e186ffa23947740e4314a7429b6e62d72742e0bbe7536a5ae56d19d7618ce998aba05caca53c2902bd5742fdca5fc57fd7b + languageName: node + linkType: hard + +"lowercase-keys@npm:^2.0.0": + version: 2.0.0 + resolution: "lowercase-keys@npm:2.0.0" + checksum: 10c0/f82a2b3568910509da4b7906362efa40f5b54ea14c2584778ddb313226f9cbf21020a5db35f9b9a0e95847a9b781d548601f31793d736b22a2b8ae8eb9ab1082 + languageName: node + linkType: hard + +"lowlight@npm:^3.0.0": + version: 3.1.0 + resolution: "lowlight@npm:3.1.0" + dependencies: + "@types/hast": "npm:^3.0.0" + devlop: "npm:^1.0.0" + highlight.js: "npm:~11.9.0" + checksum: 10c0/ee230ba1da3b339bae640479a09a4c82e5727bae38345421767c6407db4d514c10387300900ba79aa8c64dd79ae7f8d1acff847c01d5b0a20364a5ce04685f27 + languageName: node + linkType: hard + +"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": + version: 10.4.3 + resolution: "lru-cache@npm:10.4.3" + checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb + languageName: node + linkType: hard + +"lru-cache@npm:^5.1.1": + version: 5.1.1 + resolution: "lru-cache@npm:5.1.1" + dependencies: + yallist: "npm:^3.0.2" + checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482 + languageName: node + linkType: hard + +"lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9 + languageName: node + linkType: hard + +"lru-cache@npm:^7.7.1": + version: 7.18.3 + resolution: "lru-cache@npm:7.18.3" + checksum: 10c0/b3a452b491433db885beed95041eb104c157ef7794b9c9b4d647be503be91769d11206bb573849a16b4cc0d03cbd15ffd22df7960997788b74c1d399ac7a4fed + languageName: node + linkType: hard + +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b + languageName: node + linkType: hard + +"magic-string@npm:^0.27.0": + version: 0.27.0 + resolution: "magic-string@npm:0.27.0" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.4.13" + checksum: 10c0/cddacfea14441ca57ae8a307bc3cf90bac69efaa4138dd9a80804cffc2759bf06f32da3a293fb13eaa96334b7d45b7768a34f1d226afae25d2f05b05a3bb37d8 + languageName: node + linkType: hard + +"magic-string@npm:^0.30.0, magic-string@npm:^0.30.10, magic-string@npm:^0.30.11": + version: 0.30.11 + resolution: "magic-string@npm:0.30.11" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + checksum: 10c0/b9eb370773d0bd90ca11a848753409d8e5309b1ad56d2a1aa49d6649da710a6d2fe7237ad1a643c5a5d3800de2b9946ed9690acdfc00e6cc1aeafff3ab1752c4 + languageName: node + linkType: hard + +"magicast@npm:^0.3.4": + version: 0.3.4 + resolution: "magicast@npm:0.3.4" + dependencies: + "@babel/parser": "npm:^7.24.4" + "@babel/types": "npm:^7.24.0" + source-map-js: "npm:^1.2.0" + checksum: 10c0/7ebaaac397b13c31ca05e6d9649296751d76749b945d10a0800107872119fbdf267acdb604571d25e38ec6fd7ab3568a951b6e76eaef1caba9eaa11778fd9783 + languageName: node + linkType: hard + +"make-dir@npm:^2.1.0": + version: 2.1.0 + resolution: "make-dir@npm:2.1.0" + dependencies: + pify: "npm:^4.0.1" + semver: "npm:^5.6.0" + checksum: 10c0/ada869944d866229819735bee5548944caef560d7a8536ecbc6536edca28c72add47cc4f6fc39c54fb25d06b58da1f8994cf7d9df7dadea047064749efc085d8 + languageName: node + linkType: hard + +"make-dir@npm:^3.0.2": + version: 3.1.0 + resolution: "make-dir@npm:3.1.0" + dependencies: + semver: "npm:^6.0.0" + checksum: 10c0/56aaafefc49c2dfef02c5c95f9b196c4eb6988040cf2c712185c7fe5c99b4091591a7fc4d4eafaaefa70ff763a26f6ab8c3ff60b9e75ea19876f49b18667ecaa + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + +"make-error@npm:^1.1.1": + version: 1.3.6 + resolution: "make-error@npm:1.3.6" + checksum: 10c0/171e458d86854c6b3fc46610cfacf0b45149ba043782558c6875d9f42f222124384ad0b468c92e996d815a8a2003817a710c0a160e49c1c394626f76fa45396f + languageName: node + linkType: hard + +"make-fetch-happen@npm:^10.0.3": + version: 10.2.1 + resolution: "make-fetch-happen@npm:10.2.1" + dependencies: + agentkeepalive: "npm:^4.2.1" + cacache: "npm:^16.1.0" + http-cache-semantics: "npm:^4.1.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.0" + is-lambda: "npm:^1.0.1" + lru-cache: "npm:^7.7.1" + minipass: "npm:^3.1.6" + minipass-collect: "npm:^1.0.2" + minipass-fetch: "npm:^2.0.3" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^0.6.3" + promise-retry: "npm:^2.0.1" + socks-proxy-agent: "npm:^7.0.0" + ssri: "npm:^9.0.0" + checksum: 10c0/28ec392f63ab93511f400839dcee83107eeecfaad737d1e8487ea08b4332cd89a8f3319584222edd9f6f1d0833cf516691469496d46491863f9e88c658013949 + languageName: node + linkType: hard + +"make-fetch-happen@npm:^13.0.0": + version: 13.0.1 + resolution: "make-fetch-happen@npm:13.0.1" + dependencies: + "@npmcli/agent": "npm:^2.0.0" + cacache: "npm:^18.0.0" + http-cache-semantics: "npm:^4.1.1" + is-lambda: "npm:^1.0.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^3.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^0.6.3" + proc-log: "npm:^4.2.0" + promise-retry: "npm:^2.0.1" + ssri: "npm:^10.0.0" + checksum: 10c0/df5f4dbb6d98153b751bccf4dc4cc500de85a96a9331db9805596c46aa9f99d9555983954e6c1266d9f981ae37a9e4647f42b9a4bb5466f867f4012e582c9e7e + languageName: node + linkType: hard + +"map-or-similar@npm:^1.5.0": + version: 1.5.0 + resolution: "map-or-similar@npm:1.5.0" + checksum: 10c0/33c6ccfdc272992e33e4e99a69541a3e7faed9de3ac5bc732feb2500a9ee71d3f9d098980a70b7746e7eeb7f859ff7dfb8aa9b5ecc4e34170a32ab78cfb18def + languageName: node + linkType: hard + +"markdown-table@npm:^3.0.0": + version: 3.0.3 + resolution: "markdown-table@npm:3.0.3" + checksum: 10c0/47433a3f31e4637a184e38e873ab1d2fadfb0106a683d466fec329e99a2d8dfa09f091fa42202c6f13ec94aef0199f449a684b28042c636f2edbc1b7e1811dcd + languageName: node + linkType: hard + +"markdown-to-jsx@npm:^7.4.5": + version: 7.5.0 + resolution: "markdown-to-jsx@npm:7.5.0" + peerDependencies: + react: ">= 0.14.0" + checksum: 10c0/88213e64afd41d6934fbb70bcea0e2ef1f9553db1ba4c6f423b17d6e9c2b99c82b0fcbed29036dd5b91704b170803d1fae730ab40ae27af5c7994e2717686ebc + languageName: node + linkType: hard + +"matcher@npm:^3.0.0": + version: 3.0.0 + resolution: "matcher@npm:3.0.0" + dependencies: + escape-string-regexp: "npm:^4.0.0" + checksum: 10c0/2edf24194a2879690bcdb29985fc6bc0d003df44e04df21ebcac721fa6ce2f6201c579866bb92f9380bffe946f11ecd8cd31f34117fb67ebf8aca604918e127e + languageName: node + linkType: hard + +"mdast-util-find-and-replace@npm:^3.0.0": + version: 3.0.1 + resolution: "mdast-util-find-and-replace@npm:3.0.1" + dependencies: + "@types/mdast": "npm:^4.0.0" + escape-string-regexp: "npm:^5.0.0" + unist-util-is: "npm:^6.0.0" + unist-util-visit-parents: "npm:^6.0.0" + checksum: 10c0/1faca98c4ee10a919f23b8cc6d818e5bb6953216a71dfd35f51066ed5d51ef86e5063b43dcfdc6061cd946e016a9f0d44a1dccadd58452cf4ed14e39377f00cb + languageName: node + linkType: hard + +"mdast-util-from-markdown@npm:^2.0.0": + version: 2.0.1 + resolution: "mdast-util-from-markdown@npm:2.0.1" + dependencies: + "@types/mdast": "npm:^4.0.0" + "@types/unist": "npm:^3.0.0" + decode-named-character-reference: "npm:^1.0.0" + devlop: "npm:^1.0.0" + mdast-util-to-string: "npm:^4.0.0" + micromark: "npm:^4.0.0" + micromark-util-decode-numeric-character-reference: "npm:^2.0.0" + micromark-util-decode-string: "npm:^2.0.0" + micromark-util-normalize-identifier: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + unist-util-stringify-position: "npm:^4.0.0" + checksum: 10c0/496596bc6419200ff6258531a0ebcaee576a5c169695f5aa296a79a85f2a221bb9247d565827c709a7c2acfb56ae3c3754bf483d86206617bd299a9658c8121c + languageName: node + linkType: hard + +"mdast-util-gfm-autolink-literal@npm:^2.0.0": + version: 2.0.1 + resolution: "mdast-util-gfm-autolink-literal@npm:2.0.1" + dependencies: + "@types/mdast": "npm:^4.0.0" + ccount: "npm:^2.0.0" + devlop: "npm:^1.0.0" + mdast-util-find-and-replace: "npm:^3.0.0" + micromark-util-character: "npm:^2.0.0" + checksum: 10c0/963cd22bd42aebdec7bdd0a527c9494d024d1ad0739c43dc040fee35bdfb5e29c22564330a7418a72b5eab51d47a6eff32bc0255ef3ccb5cebfe8970e91b81b6 + languageName: node + linkType: hard + +"mdast-util-gfm-footnote@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-gfm-footnote@npm:2.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.1.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + micromark-util-normalize-identifier: "npm:^2.0.0" + checksum: 10c0/c673b22bea24740235e74cfd66765b41a2fa540334f7043fa934b94938b06b7d3c93f2d3b33671910c5492b922c0cc98be833be3b04cfed540e0679650a6d2de + languageName: node + linkType: hard + +"mdast-util-gfm-strikethrough@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-gfm-strikethrough@npm:2.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/b053e93d62c7545019bd914271ea9e5667ad3b3b57d16dbf68e56fea39a7e19b4a345e781312714eb3d43fdd069ff7ee22a3ca7f6149dfa774554f19ce3ac056 + languageName: node + linkType: hard + +"mdast-util-gfm-table@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-gfm-table@npm:2.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + markdown-table: "npm:^3.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/128af47c503a53bd1c79f20642561e54a510ad5e2db1e418d28fefaf1294ab839e6c838e341aef5d7e404f9170b9ca3d1d89605f234efafde93ee51174a6e31e + languageName: node + linkType: hard + +"mdast-util-gfm-task-list-item@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-gfm-task-list-item@npm:2.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/258d725288482b636c0a376c296431390c14b4f29588675297cb6580a8598ed311fc73ebc312acfca12cc8546f07a3a285a53a3b082712e2cbf5c190d677d834 + languageName: node + linkType: hard + +"mdast-util-gfm@npm:^3.0.0": + version: 3.0.0 + resolution: "mdast-util-gfm@npm:3.0.0" + dependencies: + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-gfm-autolink-literal: "npm:^2.0.0" + mdast-util-gfm-footnote: "npm:^2.0.0" + mdast-util-gfm-strikethrough: "npm:^2.0.0" + mdast-util-gfm-table: "npm:^2.0.0" + mdast-util-gfm-task-list-item: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/91596fe9bf3e4a0c546d0c57f88106c17956d9afbe88ceb08308e4da2388aff64489d649ddad599caecfdf755fc3ae4c9b82c219b85281bc0586b67599881fca + languageName: node + linkType: hard + +"mdast-util-mdx-expression@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-mdx-expression@npm:2.0.0" + dependencies: + "@types/estree-jsx": "npm:^1.0.0" + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/512848cbc44b9dc7cffc1bb3f95f7e67f0d6562870e56a67d25647f475d411e136b915ba417c8069fb36eac1839d0209fb05fb323d377f35626a82fcb0879363 + languageName: node + linkType: hard + +"mdast-util-mdx-jsx@npm:^3.0.0": + version: 3.1.2 + resolution: "mdast-util-mdx-jsx@npm:3.1.2" + dependencies: + "@types/estree-jsx": "npm:^1.0.0" + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + "@types/unist": "npm:^3.0.0" + ccount: "npm:^2.0.0" + devlop: "npm:^1.1.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + parse-entities: "npm:^4.0.0" + stringify-entities: "npm:^4.0.0" + unist-util-remove-position: "npm:^5.0.0" + unist-util-stringify-position: "npm:^4.0.0" + vfile-message: "npm:^4.0.0" + checksum: 10c0/855b60c3db9bde2fe142bd366597f7bd5892fc288428ba054e26ffcffc07bfe5648c0792d614ba6e08b1eab9784ffc3c1267cf29dfc6db92b419d68b5bcd487d + languageName: node + linkType: hard + +"mdast-util-mdxjs-esm@npm:^2.0.0": + version: 2.0.1 + resolution: "mdast-util-mdxjs-esm@npm:2.0.1" + dependencies: + "@types/estree-jsx": "npm:^1.0.0" + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/5bda92fc154141705af2b804a534d891f28dac6273186edf1a4c5e3f045d5b01dbcac7400d27aaf91b7e76e8dce007c7b2fdf136c11ea78206ad00bdf9db46bc + languageName: node + linkType: hard + +"mdast-util-phrasing@npm:^4.0.0": + version: 4.1.0 + resolution: "mdast-util-phrasing@npm:4.1.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/bf6c31d51349aa3d74603d5e5a312f59f3f65662ed16c58017169a5fb0f84ca98578f626c5ee9e4aa3e0a81c996db8717096705521bddb4a0185f98c12c9b42f + languageName: node + linkType: hard + +"mdast-util-to-hast@npm:^13.0.0": + version: 13.2.0 + resolution: "mdast-util-to-hast@npm:13.2.0" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + "@ungap/structured-clone": "npm:^1.0.0" + devlop: "npm:^1.0.0" + micromark-util-sanitize-uri: "npm:^2.0.0" + trim-lines: "npm:^3.0.0" + unist-util-position: "npm:^5.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/9ee58def9287df8350cbb6f83ced90f9c088d72d4153780ad37854f87144cadc6f27b20347073b285173b1649b0723ddf0b9c78158608a804dcacb6bda6e1816 + languageName: node + linkType: hard + +"mdast-util-to-markdown@npm:^2.0.0": + version: 2.1.0 + resolution: "mdast-util-to-markdown@npm:2.1.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + "@types/unist": "npm:^3.0.0" + longest-streak: "npm:^3.0.0" + mdast-util-phrasing: "npm:^4.0.0" + mdast-util-to-string: "npm:^4.0.0" + micromark-util-decode-string: "npm:^2.0.0" + unist-util-visit: "npm:^5.0.0" + zwitch: "npm:^2.0.0" + checksum: 10c0/8bd37a9627a438ef6418d6642661904d0cc03c5c732b8b018a8e238ef5cc82fe8aef1940b19c6f563245e58b9659f35e527209bd3fe145f3c723ba14d18fc3e6 + languageName: node + linkType: hard + +"mdast-util-to-string@npm:^4.0.0": + version: 4.0.0 + resolution: "mdast-util-to-string@npm:4.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + checksum: 10c0/2d3c1af29bf3fe9c20f552ee9685af308002488f3b04b12fa66652c9718f66f41a32f8362aa2d770c3ff464c034860b41715902ada2306bb0a055146cef064d7 + languageName: node + linkType: hard + +"mdn-data@npm:2.10.0": + version: 2.10.0 + resolution: "mdn-data@npm:2.10.0" + checksum: 10c0/f6f1a6a6eb092bab250d06f6f6c7cb1733a77a17e7119aac829ad67d4322bbf6a30df3c6d88686e71942e66bd49274b2ddfede22a1d3df0d6c49a56fbd09eb7c + languageName: node + linkType: hard + +"media-typer@npm:0.3.0": + version: 0.3.0 + resolution: "media-typer@npm:0.3.0" + checksum: 10c0/d160f31246907e79fed398470285f21bafb45a62869dc469b1c8877f3f064f5eabc4bcc122f9479b8b605bc5c76187d7871cf84c4ee3ecd3e487da1993279928 + languageName: node + linkType: hard + +"memoizerific@npm:^1.11.3": + version: 1.11.3 + resolution: "memoizerific@npm:1.11.3" + dependencies: + map-or-similar: "npm:^1.5.0" + checksum: 10c0/661bf69b7afbfad57f0208f0c63324f4c96087b480708115b78ee3f0237d86c7f91347f6db31528740b2776c2e34c709bcb034e1e910edee2270c9603a0a469e + languageName: node + linkType: hard + +"merge-descriptors@npm:1.0.3": + version: 1.0.3 + resolution: "merge-descriptors@npm:1.0.3" + checksum: 10c0/866b7094afd9293b5ea5dcd82d71f80e51514bed33b4c4e9f516795dc366612a4cbb4dc94356e943a8a6914889a914530badff27f397191b9b75cda20b6bae93 + languageName: node + linkType: hard + +"merge2@npm:^1.3.0": + version: 1.4.1 + resolution: "merge2@npm:1.4.1" + checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb + languageName: node + linkType: hard + +"methods@npm:~1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 10c0/bdf7cc72ff0a33e3eede03708c08983c4d7a173f91348b4b1e4f47d4cdbf734433ad971e7d1e8c77247d9e5cd8adb81ea4c67b0a2db526b758b2233d7814b8b2 + languageName: node + linkType: hard + +"micromark-core-commonmark@npm:^2.0.0": + version: 2.0.1 + resolution: "micromark-core-commonmark@npm:2.0.1" + dependencies: + decode-named-character-reference: "npm:^1.0.0" + devlop: "npm:^1.0.0" + micromark-factory-destination: "npm:^2.0.0" + micromark-factory-label: "npm:^2.0.0" + micromark-factory-space: "npm:^2.0.0" + micromark-factory-title: "npm:^2.0.0" + micromark-factory-whitespace: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-chunked: "npm:^2.0.0" + micromark-util-classify-character: "npm:^2.0.0" + micromark-util-html-tag-name: "npm:^2.0.0" + micromark-util-normalize-identifier: "npm:^2.0.0" + micromark-util-resolve-all: "npm:^2.0.0" + micromark-util-subtokenize: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/a0b280b1b6132f600518e72cb29a4dd1b2175b85f5ed5b25d2c5695e42b876b045971370daacbcfc6b4ce8cf7acbf78dd3a0284528fb422b450144f4b3bebe19 + languageName: node + linkType: hard + +"micromark-extension-gfm-autolink-literal@npm:^2.0.0": + version: 2.1.0 + resolution: "micromark-extension-gfm-autolink-literal@npm:2.1.0" + dependencies: + micromark-util-character: "npm:^2.0.0" + micromark-util-sanitize-uri: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/84e6fbb84ea7c161dfa179665dc90d51116de4c28f3e958260c0423e5a745372b7dcbc87d3cde98213b532e6812f847eef5ae561c9397d7f7da1e59872ef3efe + languageName: node + linkType: hard + +"micromark-extension-gfm-footnote@npm:^2.0.0": + version: 2.1.0 + resolution: "micromark-extension-gfm-footnote@npm:2.1.0" + dependencies: + devlop: "npm:^1.0.0" + micromark-core-commonmark: "npm:^2.0.0" + micromark-factory-space: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-normalize-identifier: "npm:^2.0.0" + micromark-util-sanitize-uri: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/d172e4218968b7371b9321af5cde8c77423f73b233b2b0fcf3ff6fd6f61d2e0d52c49123a9b7910612478bf1f0d5e88c75a3990dd68f70f3933fe812b9f77edc + languageName: node + linkType: hard + +"micromark-extension-gfm-strikethrough@npm:^2.0.0": + version: 2.1.0 + resolution: "micromark-extension-gfm-strikethrough@npm:2.1.0" + dependencies: + devlop: "npm:^1.0.0" + micromark-util-chunked: "npm:^2.0.0" + micromark-util-classify-character: "npm:^2.0.0" + micromark-util-resolve-all: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/ef4f248b865bdda71303b494671b7487808a340b25552b11ca6814dff3fcfaab9be8d294643060bbdb50f79313e4a686ab18b99cbe4d3ee8a4170fcd134234fb + languageName: node + linkType: hard + +"micromark-extension-gfm-table@npm:^2.0.0": + version: 2.1.0 + resolution: "micromark-extension-gfm-table@npm:2.1.0" + dependencies: + devlop: "npm:^1.0.0" + micromark-factory-space: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/c1b564ab68576406046d825b9574f5b4dbedbb5c44bede49b5babc4db92f015d9057dd79d8e0530f2fecc8970a695c40ac2e5e1d4435ccf3ef161038d0d1463b + languageName: node + linkType: hard + +"micromark-extension-gfm-tagfilter@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-extension-gfm-tagfilter@npm:2.0.0" + dependencies: + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/995558843fff137ae4e46aecb878d8a4691cdf23527dcf1e2f0157d66786be9f7bea0109c52a8ef70e68e3f930af811828ba912239438e31a9cfb9981f44d34d + languageName: node + linkType: hard + +"micromark-extension-gfm-task-list-item@npm:^2.0.0": + version: 2.1.0 + resolution: "micromark-extension-gfm-task-list-item@npm:2.1.0" + dependencies: + devlop: "npm:^1.0.0" + micromark-factory-space: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/78aa537d929e9309f076ba41e5edc99f78d6decd754b6734519ccbbfca8abd52e1c62df68d41a6ae64d2a3fc1646cea955893c79680b0b4385ced4c52296181f + languageName: node + linkType: hard + +"micromark-extension-gfm@npm:^3.0.0": + version: 3.0.0 + resolution: "micromark-extension-gfm@npm:3.0.0" + dependencies: + micromark-extension-gfm-autolink-literal: "npm:^2.0.0" + micromark-extension-gfm-footnote: "npm:^2.0.0" + micromark-extension-gfm-strikethrough: "npm:^2.0.0" + micromark-extension-gfm-table: "npm:^2.0.0" + micromark-extension-gfm-tagfilter: "npm:^2.0.0" + micromark-extension-gfm-task-list-item: "npm:^2.0.0" + micromark-util-combine-extensions: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/970e28df6ebdd7c7249f52a0dda56e0566fbfa9ae56c8eeeb2445d77b6b89d44096880cd57a1c01e7821b1f4e31009109fbaca4e89731bff7b83b8519690e5d9 + languageName: node + linkType: hard + +"micromark-factory-destination@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-factory-destination@npm:2.0.0" + dependencies: + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/b73492f687d41a6a379159c2f3acbf813042346bcea523d9041d0cc6124e6715f0779dbb2a0b3422719e9764c3b09f9707880aa159557e3cb4aeb03b9d274915 + languageName: node + linkType: hard + +"micromark-factory-label@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-factory-label@npm:2.0.0" + dependencies: + devlop: "npm:^1.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/8ffad00487a7891941b1d1f51d53a33c7a659dcf48617edb7a4008dad7aff67ec316baa16d55ca98ae3d75ce1d81628dbf72fedc7c6f108f740dec0d5d21c8ee + languageName: node + linkType: hard + +"micromark-factory-space@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-factory-space@npm:2.0.0" + dependencies: + micromark-util-character: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/103ca954dade963d4ff1d2f27d397833fe855ddc72590205022832ef68b775acdea67949000cee221708e376530b1de78c745267b0bf8366740840783eb37122 + languageName: node + linkType: hard + +"micromark-factory-title@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-factory-title@npm:2.0.0" + dependencies: + micromark-factory-space: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/2b2188e7a011b1b001faf8c860286d246d5c3485ef8819270c60a5808f4c7613e49d4e481dbdff62600ef7acdba0f5100be2d125cbd2a15e236c26b3668a8ebd + languageName: node + linkType: hard + +"micromark-factory-whitespace@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-factory-whitespace@npm:2.0.0" + dependencies: + micromark-factory-space: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/4e91baab0cc71873095134bd0e225d01d9786cde352701402d71b72d317973954754e8f9f1849901f165530e6421202209f4d97c460a27bb0808ec5a3fc3148c + languageName: node + linkType: hard + +"micromark-util-character@npm:^2.0.0": + version: 2.1.0 + resolution: "micromark-util-character@npm:2.1.0" + dependencies: + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/fc37a76aaa5a5138191ba2bef1ac50c36b3bcb476522e98b1a42304ab4ec76f5b036a746ddf795d3de3e7004b2c09f21dd1bad42d161f39b8cfc0acd067e6373 + languageName: node + linkType: hard + +"micromark-util-chunked@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-chunked@npm:2.0.0" + dependencies: + micromark-util-symbol: "npm:^2.0.0" + checksum: 10c0/043b5f2abc8c13a1e2e4c378ead191d1a47ed9e0cd6d0fa5a0a430b2df9e17ada9d5de5a20688a000bbc5932507e746144acec60a9589d9a79fa60918e029203 + languageName: node + linkType: hard + +"micromark-util-classify-character@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-classify-character@npm:2.0.0" + dependencies: + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/2bf5fa5050faa9b69f6c7e51dbaaf02329ab70fabad8229984381b356afbbf69db90f4617bec36d814a7d285fb7cad8e3c4e38d1daf4387dc9e240aa7f9a292a + languageName: node + linkType: hard + +"micromark-util-combine-extensions@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-combine-extensions@npm:2.0.0" + dependencies: + micromark-util-chunked: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/cd4c8d1a85255527facb419ff3b3cc3d7b7f27005c5ef5fa7ef2c4d0e57a9129534fc292a188ec2d467c2c458642d369c5f894bc8a9e142aed6696cc7989d3ea + languageName: node + linkType: hard + +"micromark-util-decode-numeric-character-reference@npm:^2.0.0": + version: 2.0.1 + resolution: "micromark-util-decode-numeric-character-reference@npm:2.0.1" + dependencies: + micromark-util-symbol: "npm:^2.0.0" + checksum: 10c0/3f6d684ee8f317c67806e19b3e761956256cb936a2e0533aad6d49ac5604c6536b2041769c6febdd387ab7175b7b7e551851bf2c1f78da943e7a3671ca7635ac + languageName: node + linkType: hard + +"micromark-util-decode-string@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-decode-string@npm:2.0.0" + dependencies: + decode-named-character-reference: "npm:^1.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-decode-numeric-character-reference: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + checksum: 10c0/f5413bebb21bdb686cfa1bcfa7e9c93093a523d1b42443ead303b062d2d680a94e5e8424549f57b8ba9d786a758e5a26a97f56068991bbdbca5d1885b3aa7227 + languageName: node + linkType: hard + +"micromark-util-encode@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-encode@npm:2.0.0" + checksum: 10c0/ebdaafff23100bbf4c74e63b4b1612a9ddf94cd7211d6a076bc6fb0bc32c1b48d6fb615aa0953e607c62c97d849f97f1042260d3eb135259d63d372f401bbbb2 + languageName: node + linkType: hard + +"micromark-util-html-tag-name@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-html-tag-name@npm:2.0.0" + checksum: 10c0/988aa26367449bd345b627ae32cf605076daabe2dc1db71b578a8a511a47123e14af466bcd6dcbdacec60142f07bc2723ec5f7a0eed0f5319ce83b5e04825429 + languageName: node + linkType: hard + +"micromark-util-normalize-identifier@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-normalize-identifier@npm:2.0.0" + dependencies: + micromark-util-symbol: "npm:^2.0.0" + checksum: 10c0/93bf8789b8449538f22cf82ac9b196363a5f3b2f26efd98aef87c4c1b1f8c05be3ef6391ff38316ff9b03c1a6fd077342567598019ddd12b9bd923dacc556333 + languageName: node + linkType: hard + +"micromark-util-resolve-all@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-resolve-all@npm:2.0.0" + dependencies: + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/3b912e88453dcefe728a9080c8934a75ac4732056d6576ceecbcaf97f42c5d6fa2df66db8abdc8427eb167c5ffddefe26713728cfe500bc0e314ed260d6e2746 + languageName: node + linkType: hard + +"micromark-util-sanitize-uri@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-sanitize-uri@npm:2.0.0" + dependencies: + micromark-util-character: "npm:^2.0.0" + micromark-util-encode: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + checksum: 10c0/74763ca1c927dd520d3ab8fd9856a19740acf76fc091f0a1f5d4e99c8cd5f1b81c5a0be3efb564941a071fb6d85fd951103f2760eb6cff77b5ab3abe08341309 + languageName: node + linkType: hard + +"micromark-util-subtokenize@npm:^2.0.0": + version: 2.0.1 + resolution: "micromark-util-subtokenize@npm:2.0.1" + dependencies: + devlop: "npm:^1.0.0" + micromark-util-chunked: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/000cefde827db129f4ed92b8fbdeb4866c5f9c93068c0115485564b0426abcb9058080aa257df9035e12ca7fa92259d66623ea750b9eb3bcdd8325d3fb6fc237 + languageName: node + linkType: hard + +"micromark-util-symbol@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-symbol@npm:2.0.0" + checksum: 10c0/4e76186c185ce4cefb9cea8584213d9ffacd77099d1da30c0beb09fa21f46f66f6de4c84c781d7e34ff763fe3a06b530e132fa9004882afab9e825238d0aa8b3 + languageName: node + linkType: hard + +"micromark-util-types@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-util-types@npm:2.0.0" + checksum: 10c0/d74e913b9b61268e0d6939f4209e3abe9dada640d1ee782419b04fd153711112cfaaa3c4d5f37225c9aee1e23c3bb91a1f5223e1e33ba92d33e83956a53e61de + languageName: node + linkType: hard + +"micromark@npm:^4.0.0": + version: 4.0.0 + resolution: "micromark@npm:4.0.0" + dependencies: + "@types/debug": "npm:^4.0.0" + debug: "npm:^4.0.0" + decode-named-character-reference: "npm:^1.0.0" + devlop: "npm:^1.0.0" + micromark-core-commonmark: "npm:^2.0.0" + micromark-factory-space: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-chunked: "npm:^2.0.0" + micromark-util-combine-extensions: "npm:^2.0.0" + micromark-util-decode-numeric-character-reference: "npm:^2.0.0" + micromark-util-encode: "npm:^2.0.0" + micromark-util-normalize-identifier: "npm:^2.0.0" + micromark-util-resolve-all: "npm:^2.0.0" + micromark-util-sanitize-uri: "npm:^2.0.0" + micromark-util-subtokenize: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/7e91c8d19ff27bc52964100853f1b3b32bb5b2ece57470a34ba1b2f09f4e2a183d90106c4ae585c9f2046969ee088576fed79b2f7061cba60d16652ccc2c64fd + languageName: node + linkType: hard + +"micromatch@npm:^4.0.4": + version: 4.0.8 + resolution: "micromatch@npm:4.0.8" + dependencies: + braces: "npm:^3.0.3" + picomatch: "npm:^2.3.1" + checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mime@npm:1.6.0, mime@npm:^1.4.1": + version: 1.6.0 + resolution: "mime@npm:1.6.0" + bin: + mime: cli.js + checksum: 10c0/b92cd0adc44888c7135a185bfd0dddc42c32606401c72896a842ae15da71eb88858f17669af41e498b463cd7eb998f7b48939a25b08374c7924a9c8a6f8a81b0 + languageName: node + linkType: hard + +"mime@npm:^2.5.2": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 10c0/a7f2589900d9c16e3bdf7672d16a6274df903da958c1643c9c45771f0478f3846dcb1097f31eb9178452570271361e2149310931ec705c037210fc69639c8e6c + languageName: node + linkType: hard + +"mimic-fn@npm:^2.1.0": + version: 2.1.0 + resolution: "mimic-fn@npm:2.1.0" + checksum: 10c0/b26f5479d7ec6cc2bce275a08f146cf78f5e7b661b18114e2506dd91ec7ec47e7a25bf4360e5438094db0560bcc868079fb3b1fb3892b833c1ecbf63f80c95a4 + languageName: node + linkType: hard + +"mimic-response@npm:^1.0.0": + version: 1.0.1 + resolution: "mimic-response@npm:1.0.1" + checksum: 10c0/c5381a5eae997f1c3b5e90ca7f209ed58c3615caeee850e85329c598f0c000ae7bec40196580eef1781c60c709f47258131dab237cad8786f8f56750594f27fa + languageName: node + linkType: hard + +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 10c0/0d6f07ce6e03e9e4445bee655202153bdb8a98d67ee8dc965ac140900d7a2688343e6b4c9a72cfc9ef2f7944dfd76eef4ab2482eb7b293a68b84916bac735362 + languageName: node + linkType: hard + +"min-indent@npm:^1.0.0, min-indent@npm:^1.0.1": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c + languageName: node + linkType: hard + +"minimatch@npm:^10.0.0": + version: 10.0.1 + resolution: "minimatch@npm:10.0.1" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/e6c29a81fe83e1877ad51348306be2e8aeca18c88fdee7a99df44322314279e15799e41d7cb274e4e8bb0b451a3bc622d6182e157dfa1717d6cda75e9cd8cd5d + languageName: node + linkType: hard + +"minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + languageName: node + linkType: hard + +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/3defdfd230914f22a8da203747c42ee3c405c39d4d37ffda284dac5e45b7e1f6c49aa8be606509002898e73091ff2a3bbfc59c2c6c71d4660609f63aa92f98e3 + languageName: node + linkType: hard + +"minimatch@npm:^9.0.3, minimatch@npm:^9.0.4": + version: 9.0.5 + resolution: "minimatch@npm:9.0.5" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed + languageName: node + linkType: hard + +"minimist@npm:^1.2.6": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + +"minipass-collect@npm:^1.0.2": + version: 1.0.2 + resolution: "minipass-collect@npm:1.0.2" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/8f82bd1f3095b24f53a991b04b67f4c710c894e518b813f0864a31de5570441a509be1ca17e0bb92b047591a8fdbeb886f502764fefb00d2f144f4011791e898 + languageName: node + linkType: hard + +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e + languageName: node + linkType: hard + +"minipass-fetch@npm:^2.0.3": + version: 2.1.2 + resolution: "minipass-fetch@npm:2.1.2" + dependencies: + encoding: "npm:^0.1.13" + minipass: "npm:^3.1.6" + minipass-sized: "npm:^1.0.3" + minizlib: "npm:^2.1.2" + dependenciesMeta: + encoding: + optional: true + checksum: 10c0/33ab2c5bdb3d91b9cb8bc6ae42d7418f4f00f7f7beae14b3bb21ea18f9224e792f560a6e17b6f1be12bbeb70dbe99a269f4204c60e5d99130a0777b153505c43 + languageName: node + linkType: hard + +"minipass-fetch@npm:^3.0.0": + version: 3.0.5 + resolution: "minipass-fetch@npm:3.0.5" + dependencies: + encoding: "npm:^0.1.13" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^1.0.3" + minizlib: "npm:^2.1.2" + dependenciesMeta: + encoding: + optional: true + checksum: 10c0/9d702d57f556274286fdd97e406fc38a2f5c8d15e158b498d7393b1105974b21249289ec571fa2b51e038a4872bfc82710111cf75fae98c662f3d6f95e72152b + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.5 + resolution: "minipass-flush@npm:1.0.5" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2 + languageName: node + linkType: hard + +"minipass-sized@npm:^1.0.3": + version: 1.0.3 + resolution: "minipass-sized@npm:1.0.3" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/298f124753efdc745cfe0f2bdfdd81ba25b9f4e753ca4a2066eb17c821f25d48acea607dfc997633ee5bf7b6dfffb4eee4f2051eb168663f0b99fad2fa4829cb + languageName: node + linkType: hard + +"minipass@npm:^3.0.0, minipass@npm:^3.1.1, minipass@npm:^3.1.6": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c + languageName: node + linkType: hard + +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 10c0/a91d8043f691796a8ac88df039da19933ef0f633e3d7f0d35dcd5373af49131cf2399bfc355f41515dc495e3990369c3858cd319e5c2722b4753c90bf3152462 + languageName: node + linkType: hard + +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 + languageName: node + linkType: hard + +"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": + version: 2.1.2 + resolution: "minizlib@npm:2.1.2" + dependencies: + minipass: "npm:^3.0.0" + yallist: "npm:^4.0.0" + checksum: 10c0/64fae024e1a7d0346a1102bb670085b17b7f95bf6cfdf5b128772ec8faf9ea211464ea4add406a3a6384a7d87a0cd1a96263692134323477b4fb43659a6cab78 + languageName: node + linkType: hard + +"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/46ea0f3ffa8bc6a5bc0c7081ffc3907777f0ed6516888d40a518c5111f8366d97d2678911ad1a6882bf592fa9de6c784fea32e1687bb94e1f4944170af48a5cf + languageName: node + linkType: hard + +"monaco-editor@npm:^0.51.0": + version: 0.51.0 + resolution: "monaco-editor@npm:0.51.0" + checksum: 10c0/7fde310c747e46cd7293e1a0f5e1fa85c389df9a1f8db03b9ccd58fd45356ab021a591b46198d19345566ad54556158f6489e2da4ad428a7a6ca3ea7b504afcb + languageName: node + linkType: hard + +"ms@npm:2.0.0": + version: 2.0.0 + resolution: "ms@npm:2.0.0" + checksum: 10c0/f8fda810b39fd7255bbdc451c46286e549794fcc700dc9cd1d25658bbc4dc2563a5de6fe7c60f798a16a60c6ceb53f033cb353f493f0cf63e5199b702943159d + languageName: node + linkType: hard + +"ms@npm:2.1.2": + version: 2.1.2 + resolution: "ms@npm:2.1.2" + checksum: 10c0/a437714e2f90dbf881b5191d35a6db792efbca5badf112f87b9e1c712aace4b4b9b742dd6537f3edf90fd6f684de897cec230abde57e87883766712ddda297cc + languageName: node + linkType: hard + +"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.7": + version: 3.3.7 + resolution: "nanoid@npm:3.3.7" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/e3fb661aa083454f40500473bb69eedb85dc160e763150b9a2c567c7e9ff560ce028a9f833123b618a6ea742e311138b591910e795614a629029e86e180660f3 + languageName: node + linkType: hard + +"natural-compare@npm:^1.4.0": + version: 1.4.0 + resolution: "natural-compare@npm:1.4.0" + checksum: 10c0/f5f9a7974bfb28a91afafa254b197f0f22c684d4a1731763dda960d2c8e375b36c7d690e0d9dc8fba774c537af14a7e979129bca23d88d052fbeb9466955e447 + languageName: node + linkType: hard + +"needle@npm:^3.1.0": + version: 3.3.1 + resolution: "needle@npm:3.3.1" + dependencies: + iconv-lite: "npm:^0.6.3" + sax: "npm:^1.2.4" + bin: + needle: bin/needle + checksum: 10c0/233b9315d47b735867d03e7a018fb665ee6cacf3a83b991b19538019cf42b538a3e85ca745c840b4c5e9a0ffdca76472f941363bf7c166214ae8cbc650fd4d39 + languageName: node + linkType: hard + +"negotiator@npm:0.6.3, negotiator@npm:^0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 + languageName: node + linkType: hard + +"no-case@npm:^3.0.4": + version: 3.0.4 + resolution: "no-case@npm:3.0.4" + dependencies: + lower-case: "npm:^2.0.2" + tslib: "npm:^2.0.3" + checksum: 10c0/8ef545f0b3f8677c848f86ecbd42ca0ff3cd9dd71c158527b344c69ba14710d816d8489c746b6ca225e7b615108938a0bda0a54706f8c255933703ac1cf8e703 + languageName: node + linkType: hard + +"node-abi@npm:^3.45.0": + version: 3.67.0 + resolution: "node-abi@npm:3.67.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/72ce2edbdfb84745bc201a4e48aa7146fd88a0d2c80046b6b17f28439c9a7683eab846f40f1e819349c31f7d9331ed5c50d1e741208d938dd5f38b29cab2275e + languageName: node + linkType: hard + +"node-addon-api@npm:^1.6.3": + version: 1.7.2 + resolution: "node-addon-api@npm:1.7.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/bcf526f2ce788182730d3c3df5206585873d1e837a6e1378ff84abccf2f19cf3f93a8274f9c1245af0de63a0dbd1bb95ca2f767ecf5c678d6930326aaf396c4e + languageName: node + linkType: hard + +"node-api-version@npm:^0.2.0": + version: 0.2.0 + resolution: "node-api-version@npm:0.2.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/a5bdc7559237d2acebadc9ac0f29dd1279726e4226a8b3256ea605f6ad4a5c48528a2b6684d09237d33f0b714a95573d7f14a2a628642d31e05c79395e2c7873 + languageName: node + linkType: hard + +"node-gyp@npm:^9.0.0": + version: 9.4.1 + resolution: "node-gyp@npm:9.4.1" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + glob: "npm:^7.1.4" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^10.0.3" + nopt: "npm:^6.0.0" + npmlog: "npm:^6.0.0" + rimraf: "npm:^3.0.2" + semver: "npm:^7.3.5" + tar: "npm:^6.1.2" + which: "npm:^2.0.2" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/f7d676cfa79f27d35edf17fe9c80064123670362352d19729e5dc9393d7e99f1397491c3107eddc0c0e8941442a6244a7ba6c860cfbe4b433b4cae248a55fe10 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 10.2.0 + resolution: "node-gyp@npm:10.2.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^13.0.0" + nopt: "npm:^7.0.0" + proc-log: "npm:^4.1.0" + semver: "npm:^7.3.5" + tar: "npm:^6.2.1" + which: "npm:^4.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/00630d67dbd09a45aee0a5d55c05e3916ca9e6d427ee4f7bc392d2d3dc5fad7449b21fc098dd38260a53d9dcc9c879b36704a1994235d4707e7271af7e9a835b + languageName: node + linkType: hard + +"node-releases@npm:^2.0.18": + version: 2.0.18 + resolution: "node-releases@npm:2.0.18" + checksum: 10c0/786ac9db9d7226339e1dc84bbb42007cb054a346bd9257e6aa154d294f01bc6a6cddb1348fa099f079be6580acbb470e3c048effd5f719325abd0179e566fd27 + languageName: node + linkType: hard + +"nopt@npm:^6.0.0": + version: 6.0.0 + resolution: "nopt@npm:6.0.0" + dependencies: + abbrev: "npm:^1.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/837b52c330df16fcaad816b1f54fec6b2854ab1aa771d935c1603fbcf9b023bb073f1466b1b67f48ea4dce127ae675b85b9d9355700e9b109de39db490919786 + languageName: node + linkType: hard + +"nopt@npm:^7.0.0": + version: 7.2.1 + resolution: "nopt@npm:7.2.1" + dependencies: + abbrev: "npm:^2.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/a069c7c736767121242037a22a788863accfa932ab285a1eb569eb8cd534b09d17206f68c37f096ae785647435e0c5a5a0a67b42ec743e481a455e5ae6a6df81 + languageName: node + linkType: hard + +"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: 10c0/e008c8142bcc335b5e38cf0d63cfd39d6cf2d97480af9abdbe9a439221fd4d749763bab492a8ee708ce7a194bb00c9da6d0a115018672310850489137b3da046 + languageName: node + linkType: hard + +"normalize-url@npm:^6.0.1": + version: 6.1.0 + resolution: "normalize-url@npm:6.1.0" + checksum: 10c0/95d948f9bdd2cfde91aa786d1816ae40f8262946e13700bf6628105994fe0ff361662c20af3961161c38a119dc977adeb41fc0b41b1745eb77edaaf9cb22db23 + languageName: node + linkType: hard + +"npmlog@npm:^6.0.0": + version: 6.0.2 + resolution: "npmlog@npm:6.0.2" + dependencies: + are-we-there-yet: "npm:^3.0.0" + console-control-strings: "npm:^1.1.0" + gauge: "npm:^4.0.3" + set-blocking: "npm:^2.0.0" + checksum: 10c0/0cacedfbc2f6139c746d9cd4a85f62718435ad0ca4a2d6459cd331dd33ae58206e91a0742c1558634efcde3f33f8e8e7fd3adf1bfe7978310cf00bd55cccf890 + languageName: node + linkType: hard + +"object-assign@npm:^4.1.1": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414 + languageName: node + linkType: hard + +"object-inspect@npm:^1.13.1": + version: 1.13.2 + resolution: "object-inspect@npm:1.13.2" + checksum: 10c0/b97835b4c91ec37b5fd71add84f21c3f1047d1d155d00c0fcd6699516c256d4fcc6ff17a1aced873197fe447f91a3964178fd2a67a1ee2120cdaf60e81a050b4 + languageName: node + linkType: hard + +"object-keys@npm:^1.1.1": + version: 1.1.1 + resolution: "object-keys@npm:1.1.1" + checksum: 10c0/b11f7ccdbc6d406d1f186cdadb9d54738e347b2692a14439ca5ac70c225fa6db46db809711b78589866d47b25fc3e8dee0b4c722ac751e11180f9380e3d8601d + languageName: node + linkType: hard + +"on-finished@npm:2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: "npm:1.1.1" + checksum: 10c0/46fb11b9063782f2d9968863d9cbba33d77aa13c17f895f56129c274318b86500b22af3a160fe9995aa41317efcd22941b6eba747f718ced08d9a73afdb087b4 + languageName: node + linkType: hard + +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + +"one-time@npm:^1.0.0": + version: 1.0.0 + resolution: "one-time@npm:1.0.0" + dependencies: + fn.name: "npm:1.x.x" + checksum: 10c0/6e4887b331edbb954f4e915831cbec0a7b9956c36f4feb5f6de98c448ac02ff881fd8d9b55a6b1b55030af184c6b648f340a76eb211812f4ad8c9b4b8692fdaa + languageName: node + linkType: hard + +"onetime@npm:^5.1.0": + version: 5.1.2 + resolution: "onetime@npm:5.1.2" + dependencies: + mimic-fn: "npm:^2.1.0" + checksum: 10c0/ffcef6fbb2692c3c40749f31ea2e22677a876daea92959b8a80b521d95cca7a668c884d8b2045d1d8ee7d56796aa405c405462af112a1477594cc63531baeb8f + languageName: node + linkType: hard + +"optionator@npm:^0.9.3": + version: 0.9.4 + resolution: "optionator@npm:0.9.4" + dependencies: + deep-is: "npm:^0.1.3" + fast-levenshtein: "npm:^2.0.6" + levn: "npm:^0.4.1" + prelude-ls: "npm:^1.2.1" + type-check: "npm:^0.4.0" + word-wrap: "npm:^1.2.5" + checksum: 10c0/4afb687a059ee65b61df74dfe87d8d6815cd6883cb8b3d5883a910df72d0f5d029821f37025e4bccf4048873dbdb09acc6d303d27b8f76b1a80dd5a7d5334675 + languageName: node + linkType: hard + +"ora@npm:^5.1.0": + version: 5.4.1 + resolution: "ora@npm:5.4.1" + dependencies: + bl: "npm:^4.1.0" + chalk: "npm:^4.1.0" + cli-cursor: "npm:^3.1.0" + cli-spinners: "npm:^2.5.0" + is-interactive: "npm:^1.0.0" + is-unicode-supported: "npm:^0.1.0" + log-symbols: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + wcwidth: "npm:^1.0.1" + checksum: 10c0/10ff14aace236d0e2f044193362b22edce4784add08b779eccc8f8ef97195cae1248db8ec1ec5f5ff076f91acbe573f5f42a98c19b78dba8c54eefff983cae85 + languageName: node + linkType: hard + +"overlayscrollbars-react@npm:^0.5.6": + version: 0.5.6 + resolution: "overlayscrollbars-react@npm:0.5.6" + peerDependencies: + overlayscrollbars: ^2.0.0 + react: ">=16.8.0" + checksum: 10c0/59a2aad3664d81dc4dee5e747b72bb2645047e0e6d300d9583244167d1e4b19c4cc263932e4b112d5c24358430f78d15d34ea389beb8845e3b5319ccc57db8d8 + languageName: node + linkType: hard + +"overlayscrollbars@npm:^2.10.0": + version: 2.10.0 + resolution: "overlayscrollbars@npm:2.10.0" + checksum: 10c0/d8eead54a8459cade66cac347524858a41f2d207737ea7bbb9df690c242cfd6804023bac85d245168893284cae32e11ed19acf236fad5e8e47116018d7e7d43a + languageName: node + linkType: hard + +"p-cancelable@npm:^2.0.0": + version: 2.1.1 + resolution: "p-cancelable@npm:2.1.1" + checksum: 10c0/8c6dc1f8dd4154fd8b96a10e55a3a832684c4365fb9108056d89e79fbf21a2465027c04a59d0d797b5ffe10b54a61a32043af287d5c4860f1e996cbdbc847f01 + languageName: node + linkType: hard + +"p-limit@npm:^2.2.0": + version: 2.3.0 + resolution: "p-limit@npm:2.3.0" + dependencies: + p-try: "npm:^2.0.0" + checksum: 10c0/8da01ac53efe6a627080fafc127c873da40c18d87b3f5d5492d465bb85ec7207e153948df6b9cbaeb130be70152f874229b8242ee2be84c0794082510af97f12 + languageName: node + linkType: hard + +"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0 ": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: "npm:^0.1.0" + checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a + languageName: node + linkType: hard + +"p-locate@npm:^4.1.0": + version: 4.1.0 + resolution: "p-locate@npm:4.1.0" + dependencies: + p-limit: "npm:^2.2.0" + checksum: 10c0/1b476ad69ad7f6059744f343b26d51ce091508935c1dbb80c4e0a2f397ffce0ca3a1f9f5cd3c7ce19d7929a09719d5c65fe70d8ee289c3f267cd36f2881813e9 + languageName: node + linkType: hard + +"p-locate@npm:^5.0.0": + version: 5.0.0 + resolution: "p-locate@npm:5.0.0" + dependencies: + p-limit: "npm:^3.0.2" + checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a + languageName: node + linkType: hard + +"p-map@npm:^4.0.0": + version: 4.0.0 + resolution: "p-map@npm:4.0.0" + dependencies: + aggregate-error: "npm:^3.0.0" + checksum: 10c0/592c05bd6262c466ce269ff172bb8de7c6975afca9b50c975135b974e9bdaafbfe80e61aaaf5be6d1200ba08b30ead04b88cfa7e25ff1e3b93ab28c9f62a2c75 + languageName: node + linkType: hard + +"p-try@npm:^2.0.0": + version: 2.2.0 + resolution: "p-try@npm:2.2.0" + checksum: 10c0/c36c19907734c904b16994e6535b02c36c2224d433e01a2f1ab777237f4d86e6289fd5fd464850491e940379d4606ed850c03e0f9ab600b0ebddb511312e177f + languageName: node + linkType: hard + +"package-json-from-dist@npm:^1.0.0": + version: 1.0.0 + resolution: "package-json-from-dist@npm:1.0.0" + checksum: 10c0/e3ffaf6ac1040ab6082a658230c041ad14e72fabe99076a2081bb1d5d41210f11872403fc09082daf4387fc0baa6577f96c9c0e94c90c394fd57794b66aa4033 + languageName: node + linkType: hard + +"papaparse@npm:^5.4.1": + version: 5.4.1 + resolution: "papaparse@npm:5.4.1" + checksum: 10c0/201f37c4813453fed5bfb4c01816696b099d2db9ff1e8fb610acc4771fdde91d2a22b6094721edb0fedb21ca3c46f04263f68be4beb3e35b8c72278f0cedc7b7 + languageName: node + linkType: hard + +"parent-module@npm:^1.0.0": + version: 1.0.1 + resolution: "parent-module@npm:1.0.1" + dependencies: + callsites: "npm:^3.0.0" + checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 + languageName: node + linkType: hard + +"parse-entities@npm:^4.0.0": + version: 4.0.1 + resolution: "parse-entities@npm:4.0.1" + dependencies: + "@types/unist": "npm:^2.0.0" + character-entities: "npm:^2.0.0" + character-entities-legacy: "npm:^3.0.0" + character-reference-invalid: "npm:^2.0.0" + decode-named-character-reference: "npm:^1.0.0" + is-alphanumerical: "npm:^2.0.0" + is-decimal: "npm:^2.0.0" + is-hexadecimal: "npm:^2.0.0" + checksum: 10c0/9dfa3b0dc43a913c2558c4bd625b1abcc2d6c6b38aa5724b141ed988471977248f7ad234eed57e1bc70b694dd15b0d710a04f66c2f7c096e35abd91962b7d926 + languageName: node + linkType: hard + +"parse-json@npm:^5.2.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": "npm:^7.0.0" + error-ex: "npm:^1.3.1" + json-parse-even-better-errors: "npm:^2.3.0" + lines-and-columns: "npm:^1.1.6" + checksum: 10c0/77947f2253005be7a12d858aedbafa09c9ae39eb4863adf330f7b416ca4f4a08132e453e08de2db46459256fb66afaac5ee758b44fe6541b7cdaf9d252e59585 + languageName: node + linkType: hard + +"parse-node-version@npm:^1.0.1": + version: 1.0.1 + resolution: "parse-node-version@npm:1.0.1" + checksum: 10c0/999cd3d7da1425c2e182dce82b226c6dc842562d3ed79ec47f5c719c32a7f6c1a5352495b894fc25df164be7f2ede4224758255da9902ddef81f2b77ba46bb2c + languageName: node + linkType: hard + +"parse5@npm:^7.0.0": + version: 7.1.2 + resolution: "parse5@npm:7.1.2" + dependencies: + entities: "npm:^4.4.0" + checksum: 10c0/297d7af8224f4b5cb7f6617ecdae98eeaed7f8cbd78956c42785e230505d5a4f07cef352af10d3006fa5c1544b76b57784d3a22d861ae071bbc460c649482bf4 + languageName: node + linkType: hard + +"parseurl@npm:~1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 10c0/90dd4760d6f6174adb9f20cf0965ae12e23879b5f5464f38e92fce8073354341e4b3b76fa3d878351efe7d01e617121955284cfd002ab087fba1a0726ec0b4f5 + languageName: node + linkType: hard + +"path-exists@npm:^4.0.0": + version: 4.0.0 + resolution: "path-exists@npm:4.0.0" + checksum: 10c0/8c0bd3f5238188197dc78dced15207a4716c51cc4e3624c44fc97acf69558f5ebb9a2afff486fe1b4ee148e0c133e96c5e11a9aa5c48a3006e3467da070e5e1b + languageName: node + linkType: hard + +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 + languageName: node + linkType: hard + +"path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c + languageName: node + linkType: hard + +"path-parse@npm:^1.0.7": + version: 1.0.7 + resolution: "path-parse@npm:1.0.7" + checksum: 10c0/11ce261f9d294cc7a58d6a574b7f1b935842355ec66fba3c3fd79e0f036462eaf07d0aa95bb74ff432f9afef97ce1926c720988c6a7451d8a584930ae7de86e1 + languageName: node + linkType: hard + +"path-scurry@npm:^1.11.1": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" + dependencies: + lru-cache: "npm:^10.2.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: 10c0/32a13711a2a505616ae1cc1b5076801e453e7aae6ac40ab55b388bb91b9d0547a52f5aaceff710ea400205f18691120d4431e520afbe4266b836fadede15872d + languageName: node + linkType: hard + +"path-to-regexp@npm:0.1.10": + version: 0.1.10 + resolution: "path-to-regexp@npm:0.1.10" + checksum: 10c0/34196775b9113ca6df88e94c8d83ba82c0e1a2063dd33bfe2803a980da8d49b91db8104f49d5191b44ea780d46b8670ce2b7f4a5e349b0c48c6779b653f1afe4 + languageName: node + linkType: hard + +"path-type@npm:^4.0.0": + version: 4.0.0 + resolution: "path-type@npm:4.0.0" + checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c + languageName: node + linkType: hard + +"pathe@npm:^1.1.2": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 + languageName: node + linkType: hard + +"pathval@npm:^2.0.0": + version: 2.0.0 + resolution: "pathval@npm:2.0.0" + checksum: 10c0/602e4ee347fba8a599115af2ccd8179836a63c925c23e04bd056d0674a64b39e3a081b643cc7bc0b84390517df2d800a46fcc5598d42c155fe4977095c2f77c5 + languageName: node + linkType: hard + +"pe-library@npm:^0.4.1": + version: 0.4.1 + resolution: "pe-library@npm:0.4.1" + checksum: 10c0/75c772e74c75d9710a2bf6b7e88fb57e4c26788422abd3b38c8100c796e311c72102ef71159b9e0b56f05f616a968e11b8ec218bcd625c896df067235af8da77 + languageName: node + linkType: hard + +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 10c0/8a87e63f7a4afcfb0f9f77b39bb92374afc723418b9cb716ee4257689224171002e07768eeade4ecd0e86f1fa3d8f022994219fb45634f2dbd78c6803e452458 + languageName: node + linkType: hard + +"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1": + version: 1.0.1 + resolution: "picocolors@npm:1.0.1" + checksum: 10c0/c63cdad2bf812ef0d66c8db29583802355d4ca67b9285d846f390cc15c2f6ccb94e8cb7eb6a6e97fc5990a6d3ad4ae42d86c84d3146e667c739a4234ed50d400 + languageName: node + linkType: hard + +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be + languageName: node + linkType: hard + +"pify@npm:^4.0.1": + version: 4.0.1 + resolution: "pify@npm:4.0.1" + checksum: 10c0/6f9d404b0d47a965437403c9b90eca8bb2536407f03de165940e62e72c8c8b75adda5516c6b9b23675a5877cc0bcac6bdfb0ef0e39414cd2476d5495da40e7cf + languageName: node + linkType: hard + +"pkg-dir@npm:^4.1.0": + version: 4.2.0 + resolution: "pkg-dir@npm:4.2.0" + dependencies: + find-up: "npm:^4.0.0" + checksum: 10c0/c56bda7769e04907a88423feb320babaed0711af8c436ce3e56763ab1021ba107c7b0cafb11cde7529f669cfc22bffcaebffb573645cbd63842ea9fb17cd7728 + languageName: node + linkType: hard + +"plist@npm:^3.0.4, plist@npm:^3.0.5, plist@npm:^3.1.0": + version: 3.1.0 + resolution: "plist@npm:3.1.0" + dependencies: + "@xmldom/xmldom": "npm:^0.8.8" + base64-js: "npm:^1.5.1" + xmlbuilder: "npm:^15.1.1" + checksum: 10c0/db19ba50faafc4103df8e79bcd6b08004a56db2a9dd30b3e5c8b0ef30398ef44344a674e594d012c8fc39e539a2b72cb58c60a76b4b4401cbbc7c8f6b028d93d + languageName: node + linkType: hard + +"pngjs@npm:^7.0.0": + version: 7.0.0 + resolution: "pngjs@npm:7.0.0" + checksum: 10c0/0d4c7a0fd476a9c33df7d0a2a73e1d56537628a668841f6995c2bca070cf30819f9254a64363266bc14ef2fee47659dd3b4f2b18eec7ab65143015139f497b38 + languageName: node + linkType: hard + +"polished@npm:^4.2.2": + version: 4.3.1 + resolution: "polished@npm:4.3.1" + dependencies: + "@babel/runtime": "npm:^7.17.8" + checksum: 10c0/45480d4c7281a134281cef092f6ecc202a868475ff66a390fee6e9261386e16f3047b4de46a2f2e1cf7fb7aa8f52d30b4ed631a1e3bcd6f303ca31161d4f07fe + languageName: node + linkType: hard + +"possible-typed-array-names@npm:^1.0.0": + version: 1.0.0 + resolution: "possible-typed-array-names@npm:1.0.0" + checksum: 10c0/d9aa22d31f4f7680e20269db76791b41c3a32c01a373e25f8a4813b4d45f7456bfc2b6d68f752dc4aab0e0bb0721cb3d76fb678c9101cb7a16316664bc2c73fd + languageName: node + linkType: hard + +"postcss@npm:^8.4.43": + version: 8.4.45 + resolution: "postcss@npm:8.4.45" + dependencies: + nanoid: "npm:^3.3.7" + picocolors: "npm:^1.0.1" + source-map-js: "npm:^1.2.0" + checksum: 10c0/ad6f8b9b1157d678560373696109745ab97a947d449f8a997acac41c7f1e4c0f3ca4b092d6df1387f430f2c9a319987b1780dbdc27e35800a88cde9b606c1e8f + languageName: node + linkType: hard + +"prelude-ls@npm:^1.2.1": + version: 1.2.1 + resolution: "prelude-ls@npm:1.2.1" + checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd + languageName: node + linkType: hard + +"prettier-plugin-jsdoc@npm:^1.3.0": + version: 1.3.0 + resolution: "prettier-plugin-jsdoc@npm:1.3.0" + dependencies: + binary-searching: "npm:^2.0.5" + comment-parser: "npm:^1.4.0" + mdast-util-from-markdown: "npm:^2.0.0" + peerDependencies: + prettier: ^3.0.0 + checksum: 10c0/c716ba9257765a33a9e8e3e9302724287f50efccba47602bc73b20a873dc2bb68244e8c600ed993580d5a9948d1db5b5c72766f89e11fe40da43b190fbcc52e1 + languageName: node + linkType: hard + +"prettier-plugin-organize-imports@npm:^4.0.0": + version: 4.0.0 + resolution: "prettier-plugin-organize-imports@npm:4.0.0" + peerDependencies: + "@vue/language-plugin-pug": ^2.0.24 + prettier: ">=2.0" + typescript: ">=2.9" + vue-tsc: ^2.0.24 + peerDependenciesMeta: + "@vue/language-plugin-pug": + optional: true + vue-tsc: + optional: true + checksum: 10c0/6c3c2a0680540c2c27d8e0e47c7cb69d374e47534c467877a99031defa087d52a4fc972156321dadbadc10b2eb90d67398110a0be65f5b3c9db93b85a546d8f7 + languageName: node + linkType: hard + +"prettier@npm:^3.3.3": + version: 3.3.3 + resolution: "prettier@npm:3.3.3" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/b85828b08e7505716324e4245549b9205c0cacb25342a030ba8885aba2039a115dbcf75a0b7ca3b37bc9d101ee61fab8113fc69ca3359f2a226f1ecc07ad2e26 + languageName: node + linkType: hard + +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed + languageName: node + linkType: hard + +"proc-log@npm:^4.1.0, proc-log@npm:^4.2.0": + version: 4.2.0 + resolution: "proc-log@npm:4.2.0" + checksum: 10c0/17db4757c2a5c44c1e545170e6c70a26f7de58feb985091fb1763f5081cab3d01b181fb2dd240c9f4a4255a1d9227d163d5771b7e69c9e49a561692db865efb9 + languageName: node + linkType: hard + +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + +"progress@npm:^2.0.3": + version: 2.0.3 + resolution: "progress@npm:2.0.3" + checksum: 10c0/1697e07cb1068055dbe9fe858d242368ff5d2073639e652b75a7eb1f2a1a8d4afd404d719de23c7b48481a6aa0040686310e2dac2f53d776daa2176d3f96369c + languageName: node + linkType: hard + +"promise-inflight@npm:^1.0.1": + version: 1.0.1 + resolution: "promise-inflight@npm:1.0.1" + checksum: 10c0/d179d148d98fbff3d815752fa9a08a87d3190551d1420f17c4467f628214db12235ae068d98cd001f024453676d8985af8f28f002345646c4ece4600a79620bc + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96 + languageName: node + linkType: hard + +"prop-types@npm:^15.7.2": + version: 15.8.1 + resolution: "prop-types@npm:15.8.1" + dependencies: + loose-envify: "npm:^1.4.0" + object-assign: "npm:^4.1.1" + react-is: "npm:^16.13.1" + checksum: 10c0/59ece7ca2fb9838031d73a48d4becb9a7cc1ed10e610517c7d8f19a1e02fa47f7c27d557d8a5702bec3cfeccddc853579832b43f449e54635803f277b1c78077 + languageName: node + linkType: hard + +"property-information@npm:^6.0.0": + version: 6.5.0 + resolution: "property-information@npm:6.5.0" + checksum: 10c0/981e0f9cc2e5acdb414a6fd48a99dd0fd3a4079e7a91ab41cf97a8534cf43e0e0bc1ffada6602a1b3d047a33db8b5fc2ef46d863507eda712d5ceedac443f0ef + languageName: node + linkType: hard + +"proxy-addr@npm:~2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" + dependencies: + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: 10c0/c3eed999781a35f7fd935f398b6d8920b6fb00bbc14287bc6de78128ccc1a02c89b95b56742bf7cf0362cc333c61d138532049c7dedc7a328ef13343eff81210 + languageName: node + linkType: hard + +"prr@npm:~1.0.1": + version: 1.0.1 + resolution: "prr@npm:1.0.1" + checksum: 10c0/5b9272c602e4f4472a215e58daff88f802923b84bc39c8860376bb1c0e42aaf18c25d69ad974bd06ec6db6f544b783edecd5502cd3d184748d99080d68e4be5f + languageName: node + linkType: hard + +"pump@npm:^3.0.0": + version: 3.0.0 + resolution: "pump@npm:3.0.0" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/bbdeda4f747cdf47db97428f3a135728669e56a0ae5f354a9ac5b74556556f5446a46f720a8f14ca2ece5be9b4d5d23c346db02b555f46739934cc6c093a5478 + languageName: node + linkType: hard + +"punycode@npm:^2.1.0": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 + languageName: node + linkType: hard + +"qs@npm:6.11.0": + version: 6.11.0 + resolution: "qs@npm:6.11.0" + dependencies: + side-channel: "npm:^1.0.4" + checksum: 10c0/4e4875e4d7c7c31c233d07a448e7e4650f456178b9dd3766b7cfa13158fdb24ecb8c4f059fa91e820dc6ab9f2d243721d071c9c0378892dcdad86e9e9a27c68f + languageName: node + linkType: hard + +"qs@npm:6.13.0": + version: 6.13.0 + resolution: "qs@npm:6.13.0" + dependencies: + side-channel: "npm:^1.0.6" + checksum: 10c0/62372cdeec24dc83a9fb240b7533c0fdcf0c5f7e0b83343edd7310f0ab4c8205a5e7c56406531f2e47e1b4878a3821d652be4192c841de5b032ca83619d8f860 + languageName: node + linkType: hard + +"queue-microtask@npm:^1.2.2": + version: 1.2.3 + resolution: "queue-microtask@npm:1.2.3" + checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 + languageName: node + linkType: hard + +"quick-lru@npm:^5.1.1": + version: 5.1.1 + resolution: "quick-lru@npm:5.1.1" + checksum: 10c0/a24cba5da8cec30d70d2484be37622580f64765fb6390a928b17f60cd69e8dbd32a954b3ff9176fa1b86d86ff2ba05252fae55dc4d40d0291c60412b0ad096da + languageName: node + linkType: hard + +"range-parser@npm:~1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 10c0/96c032ac2475c8027b7a4e9fe22dc0dfe0f6d90b85e496e0f016fbdb99d6d066de0112e680805075bd989905e2123b3b3d002765149294dce0c1f7f01fcc2ea0 + languageName: node + linkType: hard + +"raw-body@npm:2.5.2": + version: 2.5.2 + resolution: "raw-body@npm:2.5.2" + dependencies: + bytes: "npm:3.1.2" + http-errors: "npm:2.0.0" + iconv-lite: "npm:0.4.24" + unpipe: "npm:1.0.0" + checksum: 10c0/b201c4b66049369a60e766318caff5cb3cc5a900efd89bdac431463822d976ad0670912c931fdbdcf5543207daf6f6833bca57aa116e1661d2ea91e12ca692c4 + languageName: node + linkType: hard + +"react-colorful@npm:^5.1.2": + version: 5.6.1 + resolution: "react-colorful@npm:5.6.1" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/48eb73cf71e10841c2a61b6b06ab81da9fffa9876134c239bfdebcf348ce2a47e56b146338e35dfb03512c85966bfc9a53844fc56bc50154e71f8daee59ff6f0 + languageName: node + linkType: hard + +"react-confetti@npm:^6.1.0": + version: 6.1.0 + resolution: "react-confetti@npm:6.1.0" + dependencies: + tween-functions: "npm:^1.2.0" + peerDependencies: + react: ^16.3.0 || ^17.0.1 || ^18.0.0 + checksum: 10c0/5b4eb23eef564695f6db1d25b294ed31d5fa21ff4092c6a38e641f85cd10e3e0b50014366e3ac0f7cf772e73faaecd14614e5b11a5531336fa769dda8068ab59 + languageName: node + linkType: hard + +"react-dnd-html5-backend@npm:^16.0.1": + version: 16.0.1 + resolution: "react-dnd-html5-backend@npm:16.0.1" + dependencies: + dnd-core: "npm:^16.0.1" + checksum: 10c0/6e4b632a11e20211d71f5f3bedadf13ecec2fa73372fde388619838294b1375f15b717d1ce128e12c872ff7b15c32d26761d2026b33c14fc55e4fd5477c15289 + languageName: node + linkType: hard + +"react-dnd@npm:^16.0.1": + version: 16.0.1 + resolution: "react-dnd@npm:16.0.1" + dependencies: + "@react-dnd/invariant": "npm:^4.0.1" + "@react-dnd/shallowequal": "npm:^4.0.1" + dnd-core: "npm:^16.0.1" + fast-deep-equal: "npm:^3.1.3" + hoist-non-react-statics: "npm:^3.3.2" + peerDependencies: + "@types/hoist-non-react-statics": ">= 3.3.1" + "@types/node": ">= 12" + "@types/react": ">= 16" + react: ">= 16.14" + peerDependenciesMeta: + "@types/hoist-non-react-statics": + optional: true + "@types/node": + optional: true + "@types/react": + optional: true + checksum: 10c0/d069435750f0d6653cfa2b951cac8abb3583fb144ff134a20176608877d9c5964c63384ebbacaa0fdeef819b592a103de0d8e06f3b742311d64a029ffed0baa3 + languageName: node + linkType: hard + +"react-docgen-typescript@npm:^2.2.2": + version: 2.2.2 + resolution: "react-docgen-typescript@npm:2.2.2" + peerDependencies: + typescript: ">= 4.3.x" + checksum: 10c0/d31a061a21b5d4b67d4af7bc742541fd9e16254bd32861cd29c52565bc2175f40421a3550d52b6a6b0d0478e7cc408558eb0060a0bdd2957b02cfceeb0ee1e88 + languageName: node + linkType: hard + +"react-docgen@npm:^7.0.0": + version: 7.0.3 + resolution: "react-docgen@npm:7.0.3" + dependencies: + "@babel/core": "npm:^7.18.9" + "@babel/traverse": "npm:^7.18.9" + "@babel/types": "npm:^7.18.9" + "@types/babel__core": "npm:^7.18.0" + "@types/babel__traverse": "npm:^7.18.0" + "@types/doctrine": "npm:^0.0.9" + "@types/resolve": "npm:^1.20.2" + doctrine: "npm:^3.0.0" + resolve: "npm:^1.22.1" + strip-indent: "npm:^4.0.0" + checksum: 10c0/74622750e60b287d2897a6887a2bd88303fadd84540247e162e9e970430864ae7b49152de043233d873a0aa7cffa406e5cd8fc1e8e2c277b8da73198b570f16b + languageName: node + linkType: hard + +"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0, react-dom@npm:^18.3.1": + version: 18.3.1 + resolution: "react-dom@npm:18.3.1" + dependencies: + loose-envify: "npm:^1.1.0" + scheduler: "npm:^0.23.2" + peerDependencies: + react: ^18.3.1 + checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85 + languageName: node + linkType: hard + +"react-element-to-jsx-string@npm:^15.0.0": + version: 15.0.0 + resolution: "react-element-to-jsx-string@npm:15.0.0" + dependencies: + "@base2/pretty-print-object": "npm:1.0.1" + is-plain-object: "npm:5.0.0" + react-is: "npm:18.1.0" + peerDependencies: + react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + checksum: 10c0/0d60a0ea758529c32a706d0c69d70b69fb94de3c46442fffdee34f08f51ffceddbb5395b41dfd1565895653e9f60f98ca525835be9d5db1f16d6b22be12f4cd4 + languageName: node + linkType: hard + +"react-frame-component@npm:^5.2.7": + version: 5.2.7 + resolution: "react-frame-component@npm:5.2.7" + peerDependencies: + prop-types: ^15.5.9 + react: ">= 16.3" + react-dom: ">= 16.3" + checksum: 10c0/e138602aa98557c021ae825f51468026c53b9939140c5961d5371b65ad07ff9a5adaf2cd4e4a8a77414a05ae0f95a842939c8e102aa576ef21ff096368b905a3 + languageName: node + linkType: hard + +"react-gauge-chart@npm:^0.5.1": + version: 0.5.1 + resolution: "react-gauge-chart@npm:0.5.1" + dependencies: + d3: "npm:^7.6.1" + peerDependencies: + react: ^16.8.2 || ^17.0 || ^18.x + react-dom: ^16.8.2 || ^17.0 || ^18.x + checksum: 10c0/16d0142130ed56c8c5cd710d596499b44ccf654ea8973a4ed973ff8dca6b01e0d03b9885c34276cf678cd0f6bae1eee836d921a3bcbdbbf4a21eadd9f1686b90 + languageName: node + linkType: hard + +"react-is@npm:18.1.0": + version: 18.1.0 + resolution: "react-is@npm:18.1.0" + checksum: 10c0/558874e4c3bd9805a9294426e090919ee6901be3ab07f80b997c36b5a01a8d691112802e7438d146f6c82fd6495d8c030f276ef05ec3410057f8740a8d723f8c + languageName: node + linkType: hard + +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": + version: 16.13.1 + resolution: "react-is@npm:16.13.1" + checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 + languageName: node + linkType: hard + +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 + languageName: node + linkType: hard + +"react-markdown@npm:^9.0.1": + version: 9.0.1 + resolution: "react-markdown@npm:9.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + devlop: "npm:^1.0.0" + hast-util-to-jsx-runtime: "npm:^2.0.0" + html-url-attributes: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + remark-parse: "npm:^11.0.0" + remark-rehype: "npm:^11.0.0" + unified: "npm:^11.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + peerDependencies: + "@types/react": ">=18" + react: ">=18" + checksum: 10c0/3a3895dbd56647bc864b8da46dd575e71a9e609eb1e43fea8e8e6209d86e208eddd5b07bf8d7b5306a194b405440760a8d134aebd5a4ce5dc7dee4299e84db96 + languageName: node + linkType: hard + +"react@npm:^16.8.0 || ^17.0.0 || ^18.0.0, react@npm:^18.3.1": + version: 18.3.1 + resolution: "react@npm:18.3.1" + dependencies: + loose-envify: "npm:^1.1.0" + checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3 + languageName: node + linkType: hard + +"read-binary-file-arch@npm:^1.0.6": + version: 1.0.6 + resolution: "read-binary-file-arch@npm:1.0.6" + dependencies: + debug: "npm:^4.3.4" + bin: + read-binary-file-arch: cli.js + checksum: 10c0/7665cb4ec61da1f4da7ba6c0fb64f53f6e739373d427dd0e1c4d19f240b3ebff0f596377c01e290a6370f611899b82df09edafa758200bf31216d855e3230058 + languageName: node + linkType: hard + +"read-config-file@npm:6.4.0": + version: 6.4.0 + resolution: "read-config-file@npm:6.4.0" + dependencies: + config-file-ts: "npm:0.2.8-rc1" + dotenv: "npm:^16.4.5" + dotenv-expand: "npm:^11.0.6" + js-yaml: "npm:^4.1.0" + json5: "npm:^2.2.3" + lazy-val: "npm:^1.0.5" + checksum: 10c0/a32f30dbea6f133ec731aad04f6005c6fd341565774ea6ceab88826952eee4238921ec47833d70a879a7b2493b66b3ef993b48a38d8066125384fee5a7338a34 + languageName: node + linkType: hard + +"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 + languageName: node + linkType: hard + +"readdirp@npm:~3.6.0": + version: 3.6.0 + resolution: "readdirp@npm:3.6.0" + dependencies: + picomatch: "npm:^2.2.1" + checksum: 10c0/6fa848cf63d1b82ab4e985f4cf72bd55b7dcfd8e0a376905804e48c3634b7e749170940ba77b32804d5fe93b3cc521aa95a8d7e7d725f830da6d93f3669ce66b + languageName: node + linkType: hard + +"recast@npm:^0.23.5": + version: 0.23.9 + resolution: "recast@npm:0.23.9" + dependencies: + ast-types: "npm:^0.16.1" + esprima: "npm:~4.0.0" + source-map: "npm:~0.6.1" + tiny-invariant: "npm:^1.3.3" + tslib: "npm:^2.0.1" + checksum: 10c0/65d6e780351f0180ea4fe5c9593ac18805bf2b79977f5bedbbbf26f6d9b619ed0f6992c1bf9e06dd40fca1aea727ad6d62463cfb5d3a33342ee5a6e486305fe5 + languageName: node + linkType: hard + +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" + checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae + languageName: node + linkType: hard + +"redux@npm:^4.2.0": + version: 4.2.1 + resolution: "redux@npm:4.2.1" + dependencies: + "@babel/runtime": "npm:^7.9.2" + checksum: 10c0/136d98b3d5dbed1cd6279c8c18a6a74c416db98b8a432a46836bdd668475de6279a2d4fd9d1363f63904e00f0678a8a3e7fa532c897163340baf1e71bb42c742 + languageName: node + linkType: hard + +"regenerator-runtime@npm:^0.14.0": + version: 0.14.1 + resolution: "regenerator-runtime@npm:0.14.1" + checksum: 10c0/1b16eb2c4bceb1665c89de70dcb64126a22bc8eb958feef3cd68fe11ac6d2a4899b5cd1b80b0774c7c03591dc57d16631a7f69d2daa2ec98100e2f29f7ec4cc4 + languageName: node + linkType: hard + +"rehype-external-links@npm:^3.0.0": + version: 3.0.0 + resolution: "rehype-external-links@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + "@ungap/structured-clone": "npm:^1.0.0" + hast-util-is-element: "npm:^3.0.0" + is-absolute-url: "npm:^4.0.0" + space-separated-tokens: "npm:^2.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/486b5db73d8fe72611d62b4eb0b56ec71025ea32bba764ad54473f714ca627be75e057ac29243763f85a77c3810f31727ce3e03c975b3803c1c98643d038e9ae + languageName: node + linkType: hard + +"rehype-highlight@npm:^7.0.0": + version: 7.0.0 + resolution: "rehype-highlight@npm:7.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-to-text: "npm:^4.0.0" + lowlight: "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/bf9eba61ac2635db6c6635d3485456f2d6bdf43e3acba34deb673ddde82dc8e0a7a4ba81c4f26dda85ecc5e99a9e949c05ed1b4fb25c0414e970d9623894c935 + languageName: node + linkType: hard + +"rehype-raw@npm:^7.0.0": + version: 7.0.0 + resolution: "rehype-raw@npm:7.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-raw: "npm:^9.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/1435b4b6640a5bc3abe3b2133885c4dbff5ef2190ef9cfe09d6a63f74dd7d7ffd0cede70603278560ccf1acbfb9da9faae4b68065a28bc5aa88ad18e40f32d52 + languageName: node + linkType: hard + +"rehype-sanitize@npm:^6.0.0": + version: 6.0.0 + resolution: "rehype-sanitize@npm:6.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-sanitize: "npm:^5.0.0" + checksum: 10c0/43d6c056e63c994cf56e5ee0e157052d2030dc5ac160845ee494af9a26e5906bf5ec5af56c7d90c99f9c4dc0091e45a48a168618135fb6c64a76481ad3c449e9 + languageName: node + linkType: hard + +"rehype-slug@npm:^6.0.0": + version: 6.0.0 + resolution: "rehype-slug@npm:6.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + github-slugger: "npm:^2.0.0" + hast-util-heading-rank: "npm:^3.0.0" + hast-util-to-string: "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/51303c33d039c271cabe62161b49fa737be488f70ced62f00c165e47a089a99de2060050385e5c00d0df83ed30c7fa1c79a51b78508702836aefa51f7e7a6760 + languageName: node + linkType: hard + +"remark-flexible-toc@npm:^1.1.1": + version: 1.1.1 + resolution: "remark-flexible-toc@npm:1.1.1" + dependencies: + "@types/mdast": "npm:^4.0.4" + github-slugger: "npm:^2.0.0" + mdast-util-to-string: "npm:^4.0.0" + unified: "npm:^11.0.5" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/e1dfaaa6635b94c23835e3f0fb2f71affda5f08bcfc4e385c2972b4fa6c5217253022d2da4de09c6df8b8a0bcb56b0b787268c0f73f714feeb69a4c685b2117c + languageName: node + linkType: hard + +"remark-gfm@npm:^4.0.0": + version: 4.0.0 + resolution: "remark-gfm@npm:4.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + mdast-util-gfm: "npm:^3.0.0" + micromark-extension-gfm: "npm:^3.0.0" + remark-parse: "npm:^11.0.0" + remark-stringify: "npm:^11.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/db0aa85ab718d475c2596e27c95be9255d3b0fc730a4eda9af076b919f7dd812f7be3ac020611a8dbe5253fd29671d7b12750b56e529fdc32dfebad6dbf77403 + languageName: node + linkType: hard + +"remark-parse@npm:^11.0.0": + version: 11.0.0 + resolution: "remark-parse@npm:11.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/6eed15ddb8680eca93e04fcb2d1b8db65a743dcc0023f5007265dda558b09db595a087f622062ccad2630953cd5cddc1055ce491d25a81f3317c858348a8dd38 + languageName: node + linkType: hard + +"remark-rehype@npm:^11.0.0": + version: 11.1.0 + resolution: "remark-rehype@npm:11.1.0" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + mdast-util-to-hast: "npm:^13.0.0" + unified: "npm:^11.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/7a9534847ea70e78cf09227a4302af7e491f625fd092351a1b1ee27a2de0a369ac4acf069682e8a8ec0a55847b3e83f0be76b2028aa90e98e69e21420b9794c3 + languageName: node + linkType: hard + +"remark-stringify@npm:^11.0.0": + version: 11.0.0 + resolution: "remark-stringify@npm:11.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/0cdb37ce1217578f6f847c7ec9f50cbab35df5b9e3903d543e74b405404e67c07defcb23cd260a567b41b769400f6de03c2c3d9cd6ae7a6707d5c8d89ead489f + languageName: node + linkType: hard + +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + +"resedit@npm:^1.7.0": + version: 1.7.1 + resolution: "resedit@npm:1.7.1" + dependencies: + pe-library: "npm:^0.4.1" + checksum: 10c0/04504d073067a18b96e4a3daf0cc9c325bb8426ba1394d4e01d81c78546197f3e8db5f2db5cd0db37fc699d97354f1c8f142b043f836f8a7da5b06d8122333bf + languageName: node + linkType: hard + +"resolve-alpn@npm:^1.0.0": + version: 1.2.1 + resolution: "resolve-alpn@npm:1.2.1" + checksum: 10c0/b70b29c1843bc39781ef946c8cd4482e6d425976599c0f9c138cec8209e4e0736161bf39319b01676a847000085dfdaf63583c6fb4427bf751a10635bd2aa0c4 + languageName: node + linkType: hard + +"resolve-from@npm:^4.0.0": + version: 4.0.0 + resolution: "resolve-from@npm:4.0.0" + checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 + languageName: node + linkType: hard + +"resolve-pkg-maps@npm:^1.0.0": + version: 1.0.0 + resolution: "resolve-pkg-maps@npm:1.0.0" + checksum: 10c0/fb8f7bbe2ca281a73b7ef423a1cbc786fb244bd7a95cbe5c3fba25b27d327150beca8ba02f622baea65919a57e061eb5005204daa5f93ed590d9b77463a567ab + languageName: node + linkType: hard + +"resolve@npm:^1.22.1, resolve@npm:^1.22.8": + version: 1.22.8 + resolution: "resolve@npm:1.22.8" + dependencies: + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/07e179f4375e1fd072cfb72ad66d78547f86e6196c4014b31cb0b8bb1db5f7ca871f922d08da0fbc05b94e9fd42206f819648fa3b5b873ebbc8e1dc68fec433a + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin": + version: 1.22.8 + resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.13.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/0446f024439cd2e50c6c8fa8ba77eaa8370b4180f401a96abf3d1ebc770ac51c1955e12764cde449fde3fff480a61f84388e3505ecdbab778f4bef5f8212c729 + languageName: node + linkType: hard + +"responselike@npm:^2.0.0": + version: 2.0.1 + resolution: "responselike@npm:2.0.1" + dependencies: + lowercase-keys: "npm:^2.0.0" + checksum: 10c0/360b6deb5f101a9f8a4174f7837c523c3ec78b7ca8a7c1d45a1062b303659308a23757e318b1e91ed8684ad1205721142dd664d94771cd63499353fd4ee732b5 + languageName: node + linkType: hard + +"restore-cursor@npm:^3.1.0": + version: 3.1.0 + resolution: "restore-cursor@npm:3.1.0" + dependencies: + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/8051a371d6aa67ff21625fa94e2357bd81ffdc96267f3fb0fc4aaf4534028343836548ef34c240ffa8c25b280ca35eb36be00b3cb2133fa4f51896d7e73c6b4f + languageName: node + linkType: hard + +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe + languageName: node + linkType: hard + +"reusify@npm:^1.0.4": + version: 1.0.4 + resolution: "reusify@npm:1.0.4" + checksum: 10c0/c19ef26e4e188f408922c46f7ff480d38e8dfc55d448310dfb518736b23ed2c4f547fb64a6ed5bdba92cd7e7ddc889d36ff78f794816d5e71498d645ef476107 + languageName: node + linkType: hard + +"rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "rimraf@npm:3.0.2" + dependencies: + glob: "npm:^7.1.3" + bin: + rimraf: bin.js + checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8 + languageName: node + linkType: hard + +"roarr@npm:^2.15.3": + version: 2.15.4 + resolution: "roarr@npm:2.15.4" + dependencies: + boolean: "npm:^3.0.1" + detect-node: "npm:^2.0.4" + globalthis: "npm:^1.0.1" + json-stringify-safe: "npm:^5.0.1" + semver-compare: "npm:^1.0.0" + sprintf-js: "npm:^1.1.2" + checksum: 10c0/7d01d4c14513c461778dd673a8f9e53255221f8d04173aafeb8e11b23d8b659bb83f1c90cfe81af7f9c213b8084b404b918108fd792bda76678f555340cc64ec + languageName: node + linkType: hard + +"robust-predicates@npm:^3.0.2": + version: 3.0.2 + resolution: "robust-predicates@npm:3.0.2" + checksum: 10c0/4ecd53649f1c2d49529c85518f2fa69ffb2f7a4453f7fd19c042421c7b4d76c3efb48bc1c740c8f7049346d7cb58cf08ee0c9adaae595cc23564d360adb1fde4 + languageName: node + linkType: hard + +"rollup-plugin-flow@npm:^1.1.1": + version: 1.1.1 + resolution: "rollup-plugin-flow@npm:1.1.1" + dependencies: + flow-remove-types: "npm:^1.1.0" + rollup-pluginutils: "npm:^1.5.1" + checksum: 10c0/5dbf9820657cff23efca56d033a80356fc74e0673b2610e165517e8816d087b94f46625cf52386e215f9ec99c029a1e0d785072b44351f95ede9a0e6cef5015d + languageName: node + linkType: hard + +"rollup-pluginutils@npm:^1.5.1": + version: 1.5.2 + resolution: "rollup-pluginutils@npm:1.5.2" + dependencies: + estree-walker: "npm:^0.2.1" + minimatch: "npm:^3.0.2" + checksum: 10c0/dd116e92b66432b987fb0cb1bb71ec7317ec4fa5fe9673a98498fd966e94daef9dc6f34deee204b17bb9438f05b8dfa370bb03e83e06af9e377e4ab9c83b048a + languageName: node + linkType: hard + +"rollup@npm:^4.20.0": + version: 4.21.0 + resolution: "rollup@npm:4.21.0" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.21.0" + "@rollup/rollup-android-arm64": "npm:4.21.0" + "@rollup/rollup-darwin-arm64": "npm:4.21.0" + "@rollup/rollup-darwin-x64": "npm:4.21.0" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.21.0" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.21.0" + "@rollup/rollup-linux-arm64-gnu": "npm:4.21.0" + "@rollup/rollup-linux-arm64-musl": "npm:4.21.0" + "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.21.0" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.21.0" + "@rollup/rollup-linux-s390x-gnu": "npm:4.21.0" + "@rollup/rollup-linux-x64-gnu": "npm:4.21.0" + "@rollup/rollup-linux-x64-musl": "npm:4.21.0" + "@rollup/rollup-win32-arm64-msvc": "npm:4.21.0" + "@rollup/rollup-win32-ia32-msvc": "npm:4.21.0" + "@rollup/rollup-win32-x64-msvc": "npm:4.21.0" + "@types/estree": "npm:1.0.5" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-powerpc64le-gnu": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/984beb858da245c5e3a9027d6d87e67ad6443f1b46eab07685b861d9e49da5856693265c62a6f8262c36d11c9092713a96a9124f43e6de6698eb84d77118496a + languageName: node + linkType: hard + +"run-parallel@npm:^1.1.9": + version: 1.2.0 + resolution: "run-parallel@npm:1.2.0" + dependencies: + queue-microtask: "npm:^1.2.2" + checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 + languageName: node + linkType: hard + +"rw@npm:1": + version: 1.3.3 + resolution: "rw@npm:1.3.3" + checksum: 10c0/b1e1ef37d1e79d9dc7050787866e30b6ddcb2625149276045c262c6b4d53075ddc35f387a856a8e76f0d0df59f4cd58fe24707e40797ebee66e542b840ed6a53 + languageName: node + linkType: hard + +"rxjs@npm:^7.8.1": + version: 7.8.1 + resolution: "rxjs@npm:7.8.1" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/3c49c1ecd66170b175c9cacf5cef67f8914dcbc7cd0162855538d365c83fea631167cacb644b3ce533b2ea0e9a4d0b12175186985f89d75abe73dbd8f7f06f68 + languageName: node + linkType: hard + +"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + +"safe-stable-stringify@npm:^2.3.1": + version: 2.5.0 + resolution: "safe-stable-stringify@npm:2.5.0" + checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"sanitize-filename@npm:^1.6.3": + version: 1.6.3 + resolution: "sanitize-filename@npm:1.6.3" + dependencies: + truncate-utf8-bytes: "npm:^1.0.0" + checksum: 10c0/16ff47556a6e54e228c28db096bedd303da67b030d4bea4925fd71324932d6b02c7b0446f00ad33987b25b6414f24ae968e01a1a1679ce599542e82c4b07eb1f + languageName: node + linkType: hard + +"sax@npm:^1.2.4": + version: 1.4.1 + resolution: "sax@npm:1.4.1" + checksum: 10c0/6bf86318a254c5d898ede6bd3ded15daf68ae08a5495a2739564eb265cd13bcc64a07ab466fb204f67ce472bb534eb8612dac587435515169593f4fffa11de7c + languageName: node + linkType: hard + +"scheduler@npm:^0.23.2": + version: 0.23.2 + resolution: "scheduler@npm:0.23.2" + dependencies: + loose-envify: "npm:^1.1.0" + checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78 + languageName: node + linkType: hard + +"semver-compare@npm:^1.0.0": + version: 1.0.0 + resolution: "semver-compare@npm:1.0.0" + checksum: 10c0/9ef4d8b81847556f0865f46ddc4d276bace118c7cb46811867af82e837b7fc473911981d5a0abc561fa2db487065572217e5b06e18701c4281bcdd2a1affaff1 + languageName: node + linkType: hard + +"semver@npm:^5.6.0": + version: 5.7.2 + resolution: "semver@npm:5.7.2" + bin: + semver: bin/semver + checksum: 10c0/e4cf10f86f168db772ae95d86ba65b3fd6c5967c94d97c708ccb463b778c2ee53b914cd7167620950fc07faf5a564e6efe903836639e512a1aa15fbc9667fa25 + languageName: node + linkType: hard + +"semver@npm:^6.0.0, semver@npm:^6.2.0, semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d + languageName: node + linkType: hard + +"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3": + version: 7.6.3 + resolution: "semver@npm:7.6.3" + bin: + semver: bin/semver.js + checksum: 10c0/88f33e148b210c153873cb08cfe1e281d518aaa9a666d4d148add6560db5cd3c582f3a08ccb91f38d5f379ead256da9931234ed122057f40bb5766e65e58adaf + languageName: node + linkType: hard + +"send@npm:0.19.0": + version: 0.19.0 + resolution: "send@npm:0.19.0" + dependencies: + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:1.2.0" + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + fresh: "npm:0.5.2" + http-errors: "npm:2.0.0" + mime: "npm:1.6.0" + ms: "npm:2.1.3" + on-finished: "npm:2.4.1" + range-parser: "npm:~1.2.1" + statuses: "npm:2.0.1" + checksum: 10c0/ea3f8a67a8f0be3d6bf9080f0baed6d2c51d11d4f7b4470de96a5029c598a7011c497511ccc28968b70ef05508675cebff27da9151dd2ceadd60be4e6cf845e3 + languageName: node + linkType: hard + +"serialize-error@npm:^7.0.1": + version: 7.0.1 + resolution: "serialize-error@npm:7.0.1" + dependencies: + type-fest: "npm:^0.13.1" + checksum: 10c0/7982937d578cd901276c8ab3e2c6ed8a4c174137730f1fb0402d005af209a0e84d04acc874e317c936724c7b5b26c7a96ff7e4b8d11a469f4924a4b0ea814c05 + languageName: node + linkType: hard + +"serve-static@npm:1.16.0": + version: 1.16.0 + resolution: "serve-static@npm:1.16.0" + dependencies: + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + parseurl: "npm:~1.3.3" + send: "npm:0.19.0" + checksum: 10c0/d7a5beca08cc55f92998d8b87c111dd842d642404231c90c11f504f9650935da4599c13256747b0a988442a59851343271fe8e1946e03e92cd79c447b5f3ae01 + languageName: node + linkType: hard + +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454 + languageName: node + linkType: hard + +"set-function-length@npm:^1.2.1": + version: 1.2.2 + resolution: "set-function-length@npm:1.2.2" + dependencies: + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + gopd: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.2" + checksum: 10c0/82850e62f412a258b71e123d4ed3873fa9377c216809551192bb6769329340176f109c2eeae8c22a8d386c76739855f78e8716515c818bcaef384b51110f0f3c + languageName: node + linkType: hard + +"setprototypeof@npm:1.2.0": + version: 1.2.0 + resolution: "setprototypeof@npm:1.2.0" + checksum: 10c0/68733173026766fa0d9ecaeb07f0483f4c2dc70ca376b3b7c40b7cda909f94b0918f6c5ad5ce27a9160bdfb475efaa9d5e705a11d8eaae18f9835d20976028bc + languageName: node + linkType: hard + +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" + dependencies: + shebang-regex: "npm:^3.0.0" + checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e + languageName: node + linkType: hard + +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690 + languageName: node + linkType: hard + +"shell-quote@npm:^1.8.1": + version: 1.8.1 + resolution: "shell-quote@npm:1.8.1" + checksum: 10c0/8cec6fd827bad74d0a49347057d40dfea1e01f12a6123bf82c4649f3ef152fc2bc6d6176e6376bffcd205d9d0ccb4f1f9acae889384d20baff92186f01ea455a + languageName: node + linkType: hard + +"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6": + version: 1.0.6 + resolution: "side-channel@npm:1.0.6" + dependencies: + call-bind: "npm:^1.0.7" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.4" + object-inspect: "npm:^1.13.1" + checksum: 10c0/d2afd163dc733cc0a39aa6f7e39bf0c436293510dbccbff446733daeaf295857dbccf94297092ec8c53e2503acac30f0b78830876f0485991d62a90e9cad305f + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + +"simple-swizzle@npm:^0.2.2": + version: 0.2.2 + resolution: "simple-swizzle@npm:0.2.2" + dependencies: + is-arrayish: "npm:^0.3.1" + checksum: 10c0/df5e4662a8c750bdba69af4e8263c5d96fe4cd0f9fe4bdfa3cbdeb45d2e869dff640beaaeb1ef0e99db4d8d2ec92f85508c269f50c972174851bc1ae5bd64308 + languageName: node + linkType: hard + +"simple-update-notifier@npm:2.0.0": + version: 2.0.0 + resolution: "simple-update-notifier@npm:2.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/2a00bd03bfbcbf8a737c47ab230d7920f8bfb92d1159d421bdd194479f6d01ebc995d13fbe13d45dace23066a78a3dc6642999b4e3b38b847e6664191575b20c + languageName: node + linkType: hard + +"slice-ansi@npm:^3.0.0": + version: 3.0.0 + resolution: "slice-ansi@npm:3.0.0" + dependencies: + ansi-styles: "npm:^4.0.0" + astral-regex: "npm:^2.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + checksum: 10c0/88083c9d0ca67d09f8b4c78f68833d69cabbb7236b74df5d741ad572bbf022deaf243fa54009cd434350622a1174ab267710fcc80a214ecc7689797fe00cb27c + languageName: node + linkType: hard + +"smart-buffer@npm:^4.0.2, smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539 + languageName: node + linkType: hard + +"snake-case@npm:^3.0.4": + version: 3.0.4 + resolution: "snake-case@npm:3.0.4" + dependencies: + dot-case: "npm:^3.0.4" + tslib: "npm:^2.0.3" + checksum: 10c0/ab19a913969f58f4474fe9f6e8a026c8a2142a01f40b52b79368068343177f818cdfef0b0c6b9558f298782441d5ca8ed5932eb57822439fad791d866e62cecd + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^7.0.0": + version: 7.0.0 + resolution: "socks-proxy-agent@npm:7.0.0" + dependencies: + agent-base: "npm:^6.0.2" + debug: "npm:^4.3.3" + socks: "npm:^2.6.2" + checksum: 10c0/b859f7eb8e96ec2c4186beea233ae59c02404094f3eb009946836af27d6e5c1627d1975a69b4d2e20611729ed543b6db3ae8481eb38603433c50d0345c987600 + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^8.0.3": + version: 8.0.4 + resolution: "socks-proxy-agent@npm:8.0.4" + dependencies: + agent-base: "npm:^7.1.1" + debug: "npm:^4.3.4" + socks: "npm:^2.8.3" + checksum: 10c0/345593bb21b95b0508e63e703c84da11549f0a2657d6b4e3ee3612c312cb3a907eac10e53b23ede3557c6601d63252103494caa306b66560f43af7b98f53957a + languageName: node + linkType: hard + +"socks@npm:^2.6.2, socks@npm:^2.8.3": + version: 2.8.3 + resolution: "socks@npm:2.8.3" + dependencies: + ip-address: "npm:^9.0.5" + smart-buffer: "npm:^4.2.0" + checksum: 10c0/d54a52bf9325165770b674a67241143a3d8b4e4c8884560c4e0e078aace2a728dffc7f70150660f51b85797c4e1a3b82f9b7aa25e0a0ceae1a243365da5c51a7 + languageName: node + linkType: hard + +"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0": + version: 1.2.0 + resolution: "source-map-js@npm:1.2.0" + checksum: 10c0/7e5f896ac10a3a50fe2898e5009c58ff0dc102dcb056ed27a354623a0ece8954d4b2649e1a1b2b52ef2e161d26f8859c7710350930751640e71e374fe2d321a4 + languageName: node + linkType: hard + +"source-map-support@npm:^0.5.19": + version: 0.5.21 + resolution: "source-map-support@npm:0.5.21" + dependencies: + buffer-from: "npm:^1.0.0" + source-map: "npm:^0.6.0" + checksum: 10c0/9ee09942f415e0f721d6daad3917ec1516af746a8120bba7bb56278707a37f1eb8642bde456e98454b8a885023af81a16e646869975f06afc1a711fb90484e7d + languageName: node + linkType: hard + +"source-map@npm:^0.6.0, source-map@npm:~0.6.0, source-map@npm:~0.6.1": + version: 0.6.1 + resolution: "source-map@npm:0.6.1" + checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 + languageName: node + linkType: hard + +"space-separated-tokens@npm:^2.0.0": + version: 2.0.2 + resolution: "space-separated-tokens@npm:2.0.2" + checksum: 10c0/6173e1d903dca41dcab6a2deed8b4caf61bd13b6d7af8374713500570aa929ff9414ae09a0519f4f8772df993300305a395d4871f35bc4ca72b6db57e1f30af8 + languageName: node + linkType: hard + +"sprintf-js@npm:^1.1.2, sprintf-js@npm:^1.1.3": + version: 1.1.3 + resolution: "sprintf-js@npm:1.1.3" + checksum: 10c0/09270dc4f30d479e666aee820eacd9e464215cdff53848b443964202bf4051490538e5dd1b42e1a65cf7296916ca17640aebf63dae9812749c7542ee5f288dec + languageName: node + linkType: hard + +"ssri@npm:^10.0.0": + version: 10.0.6 + resolution: "ssri@npm:10.0.6" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/e5a1e23a4057a86a97971465418f22ea89bd439ac36ade88812dd920e4e61873e8abd6a9b72a03a67ef50faa00a2daf1ab745c5a15b46d03e0544a0296354227 + languageName: node + linkType: hard + +"ssri@npm:^9.0.0": + version: 9.0.1 + resolution: "ssri@npm:9.0.1" + dependencies: + minipass: "npm:^3.1.1" + checksum: 10c0/c5d153ce03b5980d683ecaa4d805f6a03d8dc545736213803e168a1907650c46c08a4e5ce6d670a0205482b35c35713d9d286d9133bdd79853a406e22ad81f04 + languageName: node + linkType: hard + +"stack-trace@npm:0.0.x": + version: 0.0.10 + resolution: "stack-trace@npm:0.0.10" + checksum: 10c0/9ff3dabfad4049b635a85456f927a075c9d0c210e3ea336412d18220b2a86cbb9b13ec46d6c37b70a302a4ea4d49e30e5d4944dd60ae784073f1cde778ac8f4b + languageName: node + linkType: hard + +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"stat-mode@npm:^1.0.0": + version: 1.0.0 + resolution: "stat-mode@npm:1.0.0" + checksum: 10c0/89b66a538dbfd45038fefdaf5b2104dc6e911605af1c201793e9629592ed9fdc7bdd1bca42806d0d4167c6d9cacac1f3fda41ddfe334a5c1f898113da38fae74 + languageName: node + linkType: hard + +"state-local@npm:^1.0.6": + version: 1.0.7 + resolution: "state-local@npm:1.0.7" + checksum: 10c0/8dc7daeac71844452fafb514a6d6b6f40d7e2b33df398309ea1c7b3948d6110c57f112b7196500a10c54fdde40291488c52c875575670fb5c819602deca48bd9 + languageName: node + linkType: hard + +"statuses@npm:2.0.1": + version: 2.0.1 + resolution: "statuses@npm:2.0.1" + checksum: 10c0/34378b207a1620a24804ce8b5d230fea0c279f00b18a7209646d5d47e419d1cc23e7cbf33a25a1e51ac38973dc2ac2e1e9c647a8e481ef365f77668d72becfd0 + languageName: node + linkType: hard + +"std-env@npm:^3.7.0": + version: 3.7.0 + resolution: "std-env@npm:3.7.0" + checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e + languageName: node + linkType: hard + +"storybook@npm:^8.3.0": + version: 8.3.0 + resolution: "storybook@npm:8.3.0" + dependencies: + "@storybook/core": "npm:8.3.0" + bin: + getstorybook: ./bin/index.cjs + sb: ./bin/index.cjs + storybook: ./bin/index.cjs + checksum: 10c0/d83f1fa56c2aafb7cc59c0b0710d0938172e72eb4684fae6983eccf4c621f97a28173cdc4e24fdebd945af7b9fda75b139a978d0be858813d7fcc4021a3c29a0 + languageName: node + linkType: hard + +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: "npm:^8.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + strip-ansi: "npm:^6.0.1" + checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b + languageName: node + linkType: hard + +"string-width@npm:^5.0.1, string-width@npm:^5.1.2": + version: 5.1.2 + resolution: "string-width@npm:5.1.2" + dependencies: + eastasianwidth: "npm:^0.2.0" + emoji-regex: "npm:^9.2.2" + strip-ansi: "npm:^7.0.1" + checksum: 10c0/ab9c4264443d35b8b923cbdd513a089a60de339216d3b0ed3be3ba57d6880e1a192b70ae17225f764d7adbf5994e9bb8df253a944736c15a0240eff553c678ca + languageName: node + linkType: hard + +"string_decoder@npm:^1.1.1": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + +"stringify-entities@npm:^4.0.0": + version: 4.0.4 + resolution: "stringify-entities@npm:4.0.4" + dependencies: + character-entities-html4: "npm:^2.0.0" + character-entities-legacy: "npm:^3.0.0" + checksum: 10c0/537c7e656354192406bdd08157d759cd615724e9d0873602d2c9b2f6a5c0a8d0b1d73a0a08677848105c5eebac6db037b57c0b3a4ec86331117fa7319ed50448 + languageName: node + linkType: hard + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: "npm:^5.0.1" + checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 + languageName: node + linkType: hard + +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": + version: 7.1.0 + resolution: "strip-ansi@npm:7.1.0" + dependencies: + ansi-regex: "npm:^6.0.1" + checksum: 10c0/a198c3762e8832505328cbf9e8c8381de14a4fa50a4f9b2160138158ea88c0f5549fb50cb13c651c3088f47e63a108b34622ec18c0499b6c8c3a5ddf6b305ac4 + languageName: node + linkType: hard + +"strip-bom@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-bom@npm:3.0.0" + checksum: 10c0/51201f50e021ef16672593d7434ca239441b7b760e905d9f33df6e4f3954ff54ec0e0a06f100d028af0982d6f25c35cd5cda2ce34eaebccd0250b8befb90d8f1 + languageName: node + linkType: hard + +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: "npm:^1.0.0" + checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679 + languageName: node + linkType: hard + +"strip-indent@npm:^4.0.0": + version: 4.0.0 + resolution: "strip-indent@npm:4.0.0" + dependencies: + min-indent: "npm:^1.0.1" + checksum: 10c0/6b1fb4e22056867f5c9e7a6f3f45922d9a2436cac758607d58aeaac0d3b16ec40b1c43317de7900f1b8dd7a4107352fa47fb960f2c23566538c51e8585c8870e + languageName: node + linkType: hard + +"strip-json-comments@npm:^3.1.1": + version: 3.1.1 + resolution: "strip-json-comments@npm:3.1.1" + checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd + languageName: node + linkType: hard + +"style-to-object@npm:^1.0.0": + version: 1.0.6 + resolution: "style-to-object@npm:1.0.6" + dependencies: + inline-style-parser: "npm:0.2.3" + checksum: 10c0/be5e8e3f0e35c0338de4112b9d861db576a52ebbd97f2501f1fb2c900d05c8fc42c5114407fa3a7f8b39301146cd8ca03a661bf52212394125a9629d5b771aba + languageName: node + linkType: hard + +"sumchecker@npm:^3.0.1": + version: 3.0.1 + resolution: "sumchecker@npm:3.0.1" + dependencies: + debug: "npm:^4.1.0" + checksum: 10c0/43c387be9dfe22dbeaf39dfa4ffb279847aeb37a42a8988c0b066f548bbd209aa8c65e03da29f2b29be1a66b577801bf89fff0007df4183db2f286263a9569e5 + languageName: node + linkType: hard + +"supports-color@npm:^5.3.0": + version: 5.5.0 + resolution: "supports-color@npm:5.5.0" + dependencies: + has-flag: "npm:^3.0.0" + checksum: 10c0/6ae5ff319bfbb021f8a86da8ea1f8db52fac8bd4d499492e30ec17095b58af11f0c55f8577390a749b1c4dde691b6a0315dab78f5f54c9b3d83f8fb5905c1c05 + languageName: node + linkType: hard + +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 + languageName: node + linkType: hard + +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 10c0/6c4032340701a9950865f7ae8ef38578d8d7053f5e10518076e6554a9381fa91bd9c6850193695c141f32b21f979c985db07265a758867bac95de05f7d8aeb39 + languageName: node + linkType: hard + +"svg-parser@npm:^2.0.4": + version: 2.0.4 + resolution: "svg-parser@npm:2.0.4" + checksum: 10c0/02f6cb155dd7b63ebc2f44f36365bc294543bebb81b614b7628f1af3c54ab64f7e1cec20f06e252bf95bdde78441ae295a412c68ad1678f16a6907d924512b7a + languageName: node + linkType: hard + +"tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.12, tar@npm:^6.1.2, tar@npm:^6.2.1": + version: 6.2.1 + resolution: "tar@npm:6.2.1" + dependencies: + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.0.0" + minipass: "npm:^5.0.0" + minizlib: "npm:^2.1.1" + mkdirp: "npm:^1.0.3" + yallist: "npm:^4.0.0" + checksum: 10c0/a5eca3eb50bc11552d453488344e6507156b9193efd7635e98e867fab275d527af53d8866e2370cd09dfe74378a18111622ace35af6a608e5223a7d27fe99537 + languageName: node + linkType: hard + +"telejson@npm:^7.2.0": + version: 7.2.0 + resolution: "telejson@npm:7.2.0" + dependencies: + memoizerific: "npm:^1.11.3" + checksum: 10c0/d26e6cc93e54bfdcdb207b49905508c5db45862e811a2e2193a735409e47b14530e1c19351618a3e03ad2fd4ffc3759364fcd72851aba2df0300fab574b6151c + languageName: node + linkType: hard + +"temp-file@npm:^3.4.0": + version: 3.4.0 + resolution: "temp-file@npm:3.4.0" + dependencies: + async-exit-hook: "npm:^2.0.1" + fs-extra: "npm:^10.0.0" + checksum: 10c0/70e441909097346a930ae02278df9b0133cd02dddf0b49e5ddaade735fef1410a50a448a2a812106f97c045294c99cc19f26943eb88f1d728d41fbc445a40298 + languageName: node + linkType: hard + +"test-exclude@npm:^7.0.1": + version: 7.0.1 + resolution: "test-exclude@npm:7.0.1" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^9.0.4" + checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 + languageName: node + linkType: hard + +"text-hex@npm:1.0.x": + version: 1.0.0 + resolution: "text-hex@npm:1.0.0" + checksum: 10c0/57d8d320d92c79d7c03ffb8339b825bb9637c2cbccf14304309f51d8950015c44464b6fd1b6820a3d4821241c68825634f09f5a2d9d501e84f7c6fd14376860d + languageName: node + linkType: hard + +"text-table@npm:^0.2.0": + version: 0.2.0 + resolution: "text-table@npm:0.2.0" + checksum: 10c0/02805740c12851ea5982686810702e2f14369a5f4c5c40a836821e3eefc65ffeec3131ba324692a37608294b0fd8c1e55a2dd571ffed4909822787668ddbee5c + languageName: node + linkType: hard + +"thenextwave@workspace:.": + version: 0.0.0-use.local + resolution: "thenextwave@workspace:." + dependencies: + "@chromatic-com/storybook": "npm:^2.0.2" + "@eslint/js": "npm:^9.10.0" + "@monaco-editor/loader": "npm:^1.4.0" + "@monaco-editor/react": "npm:^4.6.0" + "@observablehq/plot": "npm:^0.6.16" + "@react-hook/resize-observer": "npm:^2.0.2" + "@rollup/plugin-node-resolve": "npm:^15.2.3" + "@storybook/addon-essentials": "npm:^8.3.0" + "@storybook/addon-interactions": "npm:^8.3.0" + "@storybook/addon-links": "npm:^8.3.0" + "@storybook/blocks": "npm:^8.3.0" + "@storybook/react": "npm:^8.3.0" + "@storybook/react-vite": "npm:^8.3.0" + "@storybook/test": "npm:^8.3.0" + "@table-nav/core": "npm:^0.0.7" + "@table-nav/react": "npm:^0.0.7" + "@tanstack/react-table": "npm:^8.20.5" + "@types/color": "npm:^3.0.6" + "@types/css-tree": "npm:^2" + "@types/debug": "npm:^4" + "@types/electron": "npm:^1.6.10" + "@types/node": "npm:^22.5.4" + "@types/papaparse": "npm:^5" + "@types/pngjs": "npm:^6.0.5" + "@types/react": "npm:^18.3.5" + "@types/react-dom": "npm:^18.3.0" + "@types/semver": "npm:^7" + "@types/shell-quote": "npm:^1" + "@types/sprintf-js": "npm:^1" + "@types/throttle-debounce": "npm:^5" + "@types/tinycolor2": "npm:^1" + "@types/uuid": "npm:^10.0.0" + "@types/ws": "npm:^8" + "@vitejs/plugin-react-swc": "npm:^3.7.0" + "@vitest/coverage-istanbul": "npm:^2.1.1" + "@xterm/addon-fit": "npm:^0.10.0" + "@xterm/addon-serialize": "npm:^0.13.0" + "@xterm/addon-web-links": "npm:^0.11.0" + "@xterm/addon-webgl": "npm:^0.18.0" + "@xterm/xterm": "npm:^5.5.0" + base64-js: "npm:^1.5.1" + clsx: "npm:^2.1.1" + color: "npm:^4.2.3" + css-tree: "npm:^3.0.0" + dayjs: "npm:^1.11.13" + debug: "npm:^4.3.7" + electron: "npm:^32.1.0" + electron-builder: "npm:^25.0.5" + electron-updater: "npm:6.3.4" + electron-vite: "npm:^2.3.0" + eslint: "npm:^9.10.0" + eslint-config-prettier: "npm:^9.1.0" + fast-average-color: "npm:^9.4.0" + htl: "npm:^0.3.1" + html-to-image: "npm:^1.11.11" + immer: "npm:^10.1.1" + jotai: "npm:^2.9.3" + less: "npm:^4.2.0" + monaco-editor: "npm:^0.51.0" + overlayscrollbars: "npm:^2.10.0" + overlayscrollbars-react: "npm:^0.5.6" + papaparse: "npm:^5.4.1" + pngjs: "npm:^7.0.0" + prettier: "npm:^3.3.3" + prettier-plugin-jsdoc: "npm:^1.3.0" + prettier-plugin-organize-imports: "npm:^4.0.0" + react: "npm:^18.3.1" + react-dnd: "npm:^16.0.1" + react-dnd-html5-backend: "npm:^16.0.1" + react-dom: "npm:^18.3.1" + react-frame-component: "npm:^5.2.7" + react-gauge-chart: "npm:^0.5.1" + react-markdown: "npm:^9.0.1" + rehype-highlight: "npm:^7.0.0" + rehype-raw: "npm:^7.0.0" + rehype-sanitize: "npm:^6.0.0" + rehype-slug: "npm:^6.0.0" + remark-flexible-toc: "npm:^1.1.1" + remark-gfm: "npm:^4.0.0" + rollup-plugin-flow: "npm:^1.1.1" + rxjs: "npm:^7.8.1" + semver: "npm:^7.6.3" + shell-quote: "npm:^1.8.1" + sprintf-js: "npm:^1.1.3" + storybook: "npm:^8.3.0" + throttle-debounce: "npm:^5.0.2" + tinycolor2: "npm:^1.6.0" + ts-node: "npm:^10.9.2" + tslib: "npm:^2.6.3" + tsx: "npm:^4.19.1" + typescript: "npm:^5.6.2" + typescript-eslint: "npm:^8.5.0" + use-device-pixel-ratio: "npm:^1.1.2" + vite: "npm:^5.4.6" + vite-plugin-image-optimizer: "npm:^1.1.8" + vite-plugin-static-copy: "npm:^1.0.6" + vite-plugin-svgr: "npm:^4.2.0" + vite-tsconfig-paths: "npm:^5.0.1" + vitest: "npm:^2.1.1" + winston: "npm:^3.14.2" + ws: "npm:^8.18.0" + languageName: unknown + linkType: soft + +"throttle-debounce@npm:^5.0.2": + version: 5.0.2 + resolution: "throttle-debounce@npm:5.0.2" + checksum: 10c0/9a10ac51400b353562770721718486847adb5d7287c94a0c0d47df5326e8d47e5d92fcb74dac53d6734efb9344a2d46d68c7f996c2d0aedfd11446522e4bb356 + languageName: node + linkType: hard + +"tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3": + version: 1.3.3 + resolution: "tiny-invariant@npm:1.3.3" + checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a + languageName: node + linkType: hard + +"tiny-typed-emitter@npm:^2.1.0": + version: 2.1.0 + resolution: "tiny-typed-emitter@npm:2.1.0" + checksum: 10c0/522bed4c579ee7ee16548540cb693a3d098b137496110f5a74bff970b54187e6b7343a359b703e33f77c5b4b90ec6cebc0d0ec3dbdf1bd418723c5c3ce36d8a2 + languageName: node + linkType: hard + +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinycolor2@npm:^1.6.0": + version: 1.6.0 + resolution: "tinycolor2@npm:1.6.0" + checksum: 10c0/9aa79a36ba2c2a87cb221453465cabacd04b9e35f9694373e846fdc78b1c768110f81e581ea41440106c0f24d9a023891d0887e8075885e790ac40eb0e74a5c1 + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.0": + version: 0.3.0 + resolution: "tinyexec@npm:0.3.0" + checksum: 10c0/138a4f4241aea6b6312559508468ab275a31955e66e2f57ed206e0aaabecee622624f208c5740345f0a66e33478fd065e359ed1eb1269eb6fd4fa25d44d0ba3b + languageName: node + linkType: hard + +"tinypool@npm:^1.0.0": + version: 1.0.1 + resolution: "tinypool@npm:1.0.1" + checksum: 10c0/90939d6a03f1519c61007bf416632dc1f0b9c1a9dd673c179ccd9e36a408437384f984fc86555a5d040d45b595abc299c3bb39d354439e98a090766b5952e73d + languageName: node + linkType: hard + +"tinyrainbow@npm:^1.2.0": + version: 1.2.0 + resolution: "tinyrainbow@npm:1.2.0" + checksum: 10c0/7f78a4b997e5ba0f5ecb75e7ed786f30bab9063716e7dff24dd84013fb338802e43d176cb21ed12480561f5649a82184cf31efb296601a29d38145b1cdb4c192 + languageName: node + linkType: hard + +"tinyspy@npm:^3.0.0": + version: 3.0.0 + resolution: "tinyspy@npm:3.0.0" + checksum: 10c0/eb0dec264aa5370efd3d29743825eb115ed7f1ef8a72a431e9a75d5c9e7d67e99d04b0d61d86b8cd70c79ec27863f241ad0317bc453f78762e0cbd76d2c332d0 + languageName: node + linkType: hard + +"tmp-promise@npm:^3.0.2": + version: 3.0.3 + resolution: "tmp-promise@npm:3.0.3" + dependencies: + tmp: "npm:^0.2.0" + checksum: 10c0/23b47dcb2e82b14bbd8f61ed7a9d9353cdb6a6f09d7716616cfd27d0087040cd40152965a518e598d7aabe1489b9569bf1eebde0c5fadeaf3ec8098adcebea4e + languageName: node + linkType: hard + +"tmp@npm:^0.2.0": + version: 0.2.3 + resolution: "tmp@npm:0.2.3" + checksum: 10c0/3e809d9c2f46817475b452725c2aaa5d11985cf18d32a7a970ff25b568438e2c076c2e8609224feef3b7923fa9749b74428e3e634f6b8e520c534eef2fd24125 + languageName: node + linkType: hard + +"to-fast-properties@npm:^2.0.0": + version: 2.0.0 + resolution: "to-fast-properties@npm:2.0.0" + checksum: 10c0/b214d21dbfb4bce3452b6244b336806ffea9c05297148d32ebb428d5c43ce7545bdfc65a1ceb58c9ef4376a65c0cb2854d645f33961658b3e3b4f84910ddcdd7 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: "npm:^7.0.0" + checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 + languageName: node + linkType: hard + +"toidentifier@npm:1.0.1": + version: 1.0.1 + resolution: "toidentifier@npm:1.0.1" + checksum: 10c0/93937279934bd66cc3270016dd8d0afec14fb7c94a05c72dc57321f8bd1fa97e5bea6d1f7c89e728d077ca31ea125b78320a616a6c6cd0e6b9cb94cb864381c1 + languageName: node + linkType: hard + +"trim-lines@npm:^3.0.0": + version: 3.0.1 + resolution: "trim-lines@npm:3.0.1" + checksum: 10c0/3a1611fa9e52aa56a94c69951a9ea15b8aaad760eaa26c56a65330dc8adf99cb282fc07cc9d94968b7d4d88003beba220a7278bbe2063328eb23fb56f9509e94 + languageName: node + linkType: hard + +"triple-beam@npm:^1.3.0": + version: 1.4.1 + resolution: "triple-beam@npm:1.4.1" + checksum: 10c0/4bf1db71e14fe3ff1c3adbe3c302f1fdb553b74d7591a37323a7badb32dc8e9c290738996cbb64f8b10dc5a3833645b5d8c26221aaaaa12e50d1251c9aba2fea + languageName: node + linkType: hard + +"trough@npm:^2.0.0": + version: 2.2.0 + resolution: "trough@npm:2.2.0" + checksum: 10c0/58b671fc970e7867a48514168894396dd94e6d9d6456aca427cc299c004fe67f35ed7172a36449086b2edde10e78a71a284ec0076809add6834fb8f857ccb9b0 + languageName: node + linkType: hard + +"truncate-utf8-bytes@npm:^1.0.0": + version: 1.0.2 + resolution: "truncate-utf8-bytes@npm:1.0.2" + dependencies: + utf8-byte-length: "npm:^1.0.1" + checksum: 10c0/af2b431fc4314f119b551e5fccfad49d4c0ef82e13ba9ca61be6567801195b08e732ce9643542e8ad1b3df44f3df2d7345b3dd34f723954b6bb43a14584d6b3c + languageName: node + linkType: hard + +"ts-api-utils@npm:^1.3.0": + version: 1.3.0 + resolution: "ts-api-utils@npm:1.3.0" + peerDependencies: + typescript: ">=4.2.0" + checksum: 10c0/f54a0ba9ed56ce66baea90a3fa087a484002e807f28a8ccb2d070c75e76bde64bd0f6dce98b3802834156306050871b67eec325cb4e918015a360a3f0868c77c + languageName: node + linkType: hard + +"ts-dedent@npm:^2.0.0, ts-dedent@npm:^2.2.0": + version: 2.2.0 + resolution: "ts-dedent@npm:2.2.0" + checksum: 10c0/175adea838468cc2ff7d5e97f970dcb798bbcb623f29c6088cb21aa2880d207c5784be81ab1741f56b9ac37840cbaba0c0d79f7f8b67ffe61c02634cafa5c303 + languageName: node + linkType: hard + +"ts-node@npm:^10.9.2": + version: 10.9.2 + resolution: "ts-node@npm:10.9.2" + dependencies: + "@cspotcode/source-map-support": "npm:^0.8.0" + "@tsconfig/node10": "npm:^1.0.7" + "@tsconfig/node12": "npm:^1.0.7" + "@tsconfig/node14": "npm:^1.0.0" + "@tsconfig/node16": "npm:^1.0.2" + acorn: "npm:^8.4.1" + acorn-walk: "npm:^8.1.1" + arg: "npm:^4.1.0" + create-require: "npm:^1.1.0" + diff: "npm:^4.0.1" + make-error: "npm:^1.1.1" + v8-compile-cache-lib: "npm:^3.0.1" + yn: "npm:3.1.1" + peerDependencies: + "@swc/core": ">=1.2.50" + "@swc/wasm": ">=1.2.50" + "@types/node": "*" + typescript: ">=2.7" + peerDependenciesMeta: + "@swc/core": + optional: true + "@swc/wasm": + optional: true + bin: + ts-node: dist/bin.js + ts-node-cwd: dist/bin-cwd.js + ts-node-esm: dist/bin-esm.js + ts-node-script: dist/bin-script.js + ts-node-transpile-only: dist/bin-transpile.js + ts-script: dist/bin-script-deprecated.js + checksum: 10c0/5f29938489f96982a25ba650b64218e83a3357d76f7bede80195c65ab44ad279c8357264639b7abdd5d7e75fc269a83daa0e9c62fd8637a3def67254ecc9ddc2 + languageName: node + linkType: hard + +"tsconfck@npm:^3.0.3": + version: 3.1.1 + resolution: "tsconfck@npm:3.1.1" + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: 10c0/e133eb308ba37e8db8dbac1905bddaaf4a62f0e01aa88143e19867e274a877b86b35cf69c9a0172ca3e7d1a4bb32400381ac7f7a1429e34250a8d7ae55aee3e7 + languageName: node + linkType: hard + +"tsconfig-paths@npm:^4.2.0": + version: 4.2.0 + resolution: "tsconfig-paths@npm:4.2.0" + dependencies: + json5: "npm:^2.2.2" + minimist: "npm:^1.2.6" + strip-bom: "npm:^3.0.0" + checksum: 10c0/09a5877402d082bb1134930c10249edeebc0211f36150c35e1c542e5b91f1047b1ccf7da1e59babca1ef1f014c525510f4f870de7c9bda470c73bb4e2721b3ea + languageName: node + linkType: hard + +"tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.6.3": + version: 2.7.0 + resolution: "tslib@npm:2.7.0" + checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6 + languageName: node + linkType: hard + +"tsx@npm:^4.19.1": + version: 4.19.1 + resolution: "tsx@npm:4.19.1" + dependencies: + esbuild: "npm:~0.23.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/cbea9baf57e7406fa0ecc2c03b9bb2501ee740dc28c938f949180a646a28e5d65e7cccbfba340508923bfd45e90320ef9eef7f815cae4515b6ef2ee429edc7ee + languageName: node + linkType: hard + +"tween-functions@npm:^1.2.0": + version: 1.2.0 + resolution: "tween-functions@npm:1.2.0" + checksum: 10c0/7e59295b8b0ee4132ed2fe335f56a9db5c87056dad6b6fd3011be72239fd20398003ddb4403bc98ad9f5c94468890830f64016edbbde35581faf95b32cda8305 + languageName: node + linkType: hard + +"type-check@npm:^0.4.0, type-check@npm:~0.4.0": + version: 0.4.0 + resolution: "type-check@npm:0.4.0" + dependencies: + prelude-ls: "npm:^1.2.1" + checksum: 10c0/7b3fd0ed43891e2080bf0c5c504b418fbb3e5c7b9708d3d015037ba2e6323a28152ec163bcb65212741fa5d2022e3075ac3c76440dbd344c9035f818e8ecee58 + languageName: node + linkType: hard + +"type-fest@npm:^0.13.1": + version: 0.13.1 + resolution: "type-fest@npm:0.13.1" + checksum: 10c0/0c0fa07ae53d4e776cf4dac30d25ad799443e9eef9226f9fddbb69242db86b08584084a99885cfa5a9dfe4c063ebdc9aa7b69da348e735baede8d43f1aeae93b + languageName: node + linkType: hard + +"type-fest@npm:^2.19.0, type-fest@npm:~2.19": + version: 2.19.0 + resolution: "type-fest@npm:2.19.0" + checksum: 10c0/a5a7ecf2e654251613218c215c7493574594951c08e52ab9881c9df6a6da0aeca7528c213c622bc374b4e0cb5c443aa3ab758da4e3c959783ce884c3194e12cb + languageName: node + linkType: hard + +"type-is@npm:~1.6.18": + version: 1.6.18 + resolution: "type-is@npm:1.6.18" + dependencies: + media-typer: "npm:0.3.0" + mime-types: "npm:~2.1.24" + checksum: 10c0/a23daeb538591b7efbd61ecf06b6feb2501b683ffdc9a19c74ef5baba362b4347e42f1b4ed81f5882a8c96a3bfff7f93ce3ffaf0cbbc879b532b04c97a55db9d + languageName: node + linkType: hard + +"typescript-eslint@npm:^8.5.0": + version: 8.5.0 + resolution: "typescript-eslint@npm:8.5.0" + dependencies: + "@typescript-eslint/eslint-plugin": "npm:8.5.0" + "@typescript-eslint/parser": "npm:8.5.0" + "@typescript-eslint/utils": "npm:8.5.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/dd92e8f5fb50bb05810a1a37a4bbb6f60761295b121f6118bc027686ebc1b3ba9e4248ab5223ed4753e1320ef6329dd2e53e8160fa4463264277f307fefefd62 + languageName: node + linkType: hard + +"typescript@npm:^5.4.3": + version: 5.5.4 + resolution: "typescript@npm:5.5.4" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/422be60f89e661eab29ac488c974b6cc0a660fb2228003b297c3d10c32c90f3bcffc1009b43876a082515a3c376b1eefcce823d6e78982e6878408b9a923199c + languageName: node + linkType: hard + +"typescript@npm:^5.6.2": + version: 5.6.2 + resolution: "typescript@npm:5.6.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/3ed8297a8c7c56b7fec282532503d1ac795239d06e7c4966b42d4330c6cf433a170b53bcf93a130a7f14ccc5235de5560df4f1045eb7f3550b46ebed16d3c5e5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin": + version: 5.5.4 + resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=379a07" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/73409d7b9196a5a1217b3aaad929bf76294d3ce7d6e9766dd880ece296ee91cf7d7db6b16c6c6c630ee5096eccde726c0ef17c7dfa52b01a243e57ae1f09ef07 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.6.2#optional!builtin": + version: 5.6.2 + resolution: "typescript@patch:typescript@npm%3A5.6.2#optional!builtin::version=5.6.2&hash=8c6c40" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/94eb47e130d3edd964b76da85975601dcb3604b0c848a36f63ac448d0104e93819d94c8bdf6b07c00120f2ce9c05256b8b6092d23cf5cf1c6fa911159e4d572f + languageName: node + linkType: hard + +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 + languageName: node + linkType: hard + +"unified@npm:^11.0.0, unified@npm:^11.0.5": + version: 11.0.5 + resolution: "unified@npm:11.0.5" + dependencies: + "@types/unist": "npm:^3.0.0" + bail: "npm:^2.0.0" + devlop: "npm:^1.0.0" + extend: "npm:^3.0.0" + is-plain-obj: "npm:^4.0.0" + trough: "npm:^2.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/53c8e685f56d11d9d458a43e0e74328a4d6386af51c8ac37a3dcabec74ce5026da21250590d4aff6733ccd7dc203116aae2b0769abc18cdf9639a54ae528dfc9 + languageName: node + linkType: hard + +"unique-filename@npm:^2.0.0": + version: 2.0.1 + resolution: "unique-filename@npm:2.0.1" + dependencies: + unique-slug: "npm:^3.0.0" + checksum: 10c0/55d95cd670c4a86117ebc34d394936d712d43b56db6bc511f9ca00f666373818bf9f075fb0ab76bcbfaf134592ef26bb75aad20786c1ff1ceba4457eaba90fb8 + languageName: node + linkType: hard + +"unique-filename@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-filename@npm:3.0.0" + dependencies: + unique-slug: "npm:^4.0.0" + checksum: 10c0/6363e40b2fa758eb5ec5e21b3c7fb83e5da8dcfbd866cc0c199d5534c42f03b9ea9ab069769cc388e1d7ab93b4eeef28ef506ab5f18d910ef29617715101884f + languageName: node + linkType: hard + +"unique-slug@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-slug@npm:3.0.0" + dependencies: + imurmurhash: "npm:^0.1.4" + checksum: 10c0/617240eb921af803b47d322d75a71a363dacf2e56c29ae5d1404fad85f64f4ec81ef10ee4fd79215d0202cbe1e5a653edb0558d59c9c81d3bd538c2d58e4c026 + languageName: node + linkType: hard + +"unique-slug@npm:^4.0.0": + version: 4.0.0 + resolution: "unique-slug@npm:4.0.0" + dependencies: + imurmurhash: "npm:^0.1.4" + checksum: 10c0/cb811d9d54eb5821b81b18205750be84cb015c20a4a44280794e915f5a0a70223ce39066781a354e872df3572e8155c228f43ff0cce94c7cbf4da2cc7cbdd635 + languageName: node + linkType: hard + +"unist-util-find-after@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-find-after@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/a7cea473c4384df8de867c456b797ff1221b20f822e1af673ff5812ed505358b36f47f3b084ac14c3622cb879ed833b71b288e8aa71025352a2aab4c2925a6eb + languageName: node + linkType: hard + +"unist-util-is@npm:^6.0.0": + version: 6.0.0 + resolution: "unist-util-is@npm:6.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + checksum: 10c0/9419352181eaa1da35eca9490634a6df70d2217815bb5938a04af3a662c12c5607a2f1014197ec9c426fbef18834f6371bfdb6f033040fa8aa3e965300d70e7e + languageName: node + linkType: hard + +"unist-util-position@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-position@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + checksum: 10c0/dde3b31e314c98f12b4dc6402f9722b2bf35e96a4f2d463233dd90d7cde2d4928074a7a11eff0a5eb1f4e200f27fc1557e0a64a7e8e4da6558542f251b1b7400 + languageName: node + linkType: hard + +"unist-util-remove-position@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-remove-position@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/e8c76da4399446b3da2d1c84a97c607b37d03d1d92561e14838cbe4fdcb485bfc06c06cfadbb808ccb72105a80643976d0660d1fe222ca372203075be9d71105 + languageName: node + linkType: hard + +"unist-util-stringify-position@npm:^4.0.0": + version: 4.0.0 + resolution: "unist-util-stringify-position@npm:4.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + checksum: 10c0/dfe1dbe79ba31f589108cb35e523f14029b6675d741a79dea7e5f3d098785045d556d5650ec6a8338af11e9e78d2a30df12b1ee86529cded1098da3f17ee999e + languageName: node + linkType: hard + +"unist-util-visit-parents@npm:^6.0.0": + version: 6.0.1 + resolution: "unist-util-visit-parents@npm:6.0.1" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/51b1a5b0aa23c97d3e03e7288f0cdf136974df2217d0999d3de573c05001ef04cccd246f51d2ebdfb9e8b0ed2704451ad90ba85ae3f3177cf9772cef67f56206 + languageName: node + linkType: hard + +"unist-util-visit@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-visit@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + unist-util-visit-parents: "npm:^6.0.0" + checksum: 10c0/51434a1d80252c1540cce6271a90fd1a106dbe624997c09ed8879279667fb0b2d3a685e02e92bf66598dcbe6cdffa7a5f5fb363af8fdf90dda6c855449ae39a5 + languageName: node + linkType: hard + +"universalify@npm:^0.1.0": + version: 0.1.2 + resolution: "universalify@npm:0.1.2" + checksum: 10c0/e70e0339f6b36f34c9816f6bf9662372bd241714dc77508d231d08386d94f2c4aa1ba1318614f92015f40d45aae1b9075cd30bd490efbe39387b60a76ca3f045 + languageName: node + linkType: hard + +"universalify@npm:^2.0.0": + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a + languageName: node + linkType: hard + +"unpipe@npm:1.0.0, unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c + languageName: node + linkType: hard + +"unplugin@npm:^1.3.1": + version: 1.12.2 + resolution: "unplugin@npm:1.12.2" + dependencies: + acorn: "npm:^8.12.1" + chokidar: "npm:^3.6.0" + webpack-sources: "npm:^3.2.3" + webpack-virtual-modules: "npm:^0.6.2" + checksum: 10c0/1ebdca5437adcf83f53ef715f336b9e5b85d671c40769c9fc4f3412e59c825c16bb56fb4e706b21991175db6addbc5fce10c8e2f67fb0387703431a8a1601c0d + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.1.0": + version: 1.1.0 + resolution: "update-browserslist-db@npm:1.1.0" + dependencies: + escalade: "npm:^3.1.2" + picocolors: "npm:^1.0.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/a7452de47785842736fb71547651c5bbe5b4dc1e3722ccf48a704b7b34e4dcf633991eaa8e4a6a517ffb738b3252eede3773bef673ef9021baa26b056d63a5b9 + languageName: node + linkType: hard + +"uri-js@npm:^4.2.2": + version: 4.4.1 + resolution: "uri-js@npm:4.4.1" + dependencies: + punycode: "npm:^2.1.0" + checksum: 10c0/4ef57b45aa820d7ac6496e9208559986c665e49447cb072744c13b66925a362d96dd5a46c4530a6b8e203e5db5fe849369444440cb22ecfc26c679359e5dfa3c + languageName: node + linkType: hard + +"use-device-pixel-ratio@npm:^1.1.2": + version: 1.1.2 + resolution: "use-device-pixel-ratio@npm:1.1.2" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/125d3f75b82de0dd754ad2c930a4441e2845cfbacfa6d818857b6991da60dc4b41d33a791ff5b84725a10616d84c7beb6a75ef489c63b3f24fa910779a284099 + languageName: node + linkType: hard + +"utf8-byte-length@npm:^1.0.1": + version: 1.0.5 + resolution: "utf8-byte-length@npm:1.0.5" + checksum: 10c0/e69bda3299608f4cc75976da9fb74ac94801a58b9ca29fdad03a20ec952e7477d7f226c12716b5f36bd4cff8151d1d152d02ee1df3752f017d4b2c725ce3e47a + languageName: node + linkType: hard + +"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 + languageName: node + linkType: hard + +"util@npm:^0.12.4, util@npm:^0.12.5": + version: 0.12.5 + resolution: "util@npm:0.12.5" + dependencies: + inherits: "npm:^2.0.3" + is-arguments: "npm:^1.0.4" + is-generator-function: "npm:^1.0.7" + is-typed-array: "npm:^1.1.3" + which-typed-array: "npm:^1.1.2" + checksum: 10c0/c27054de2cea2229a66c09522d0fa1415fb12d861d08523a8846bf2e4cbf0079d4c3f725f09dcb87493549bcbf05f5798dce1688b53c6c17201a45759e7253f3 + languageName: node + linkType: hard + +"utils-merge@npm:1.0.1": + version: 1.0.1 + resolution: "utils-merge@npm:1.0.1" + checksum: 10c0/02ba649de1b7ca8854bfe20a82f1dfbdda3fb57a22ab4a8972a63a34553cf7aa51bc9081cf7e001b035b88186d23689d69e71b510e610a09a4c66f68aa95b672 + languageName: node + linkType: hard + +"uuid@npm:^9.0.0": + version: 9.0.1 + resolution: "uuid@npm:9.0.1" + bin: + uuid: dist/bin/uuid + checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b + languageName: node + linkType: hard + +"v8-compile-cache-lib@npm:^3.0.1": + version: 3.0.1 + resolution: "v8-compile-cache-lib@npm:3.0.1" + checksum: 10c0/bdc36fb8095d3b41df197f5fb6f11e3a26adf4059df3213e3baa93810d8f0cc76f9a74aaefc18b73e91fe7e19154ed6f134eda6fded2e0f1c8d2272ed2d2d391 + languageName: node + linkType: hard + +"vary@npm:~1.1.2": + version: 1.1.2 + resolution: "vary@npm:1.1.2" + checksum: 10c0/f15d588d79f3675135ba783c91a4083dcd290a2a5be9fcb6514220a1634e23df116847b1cc51f66bfb0644cf9353b2abb7815ae499bab06e46dd33c1a6bf1f4f + languageName: node + linkType: hard + +"verror@npm:^1.10.0": + version: 1.10.1 + resolution: "verror@npm:1.10.1" + dependencies: + assert-plus: "npm:^1.0.0" + core-util-is: "npm:1.0.2" + extsprintf: "npm:^1.2.0" + checksum: 10c0/293fb060a4c9b07965569a0c3e45efa954127818707995a8a4311f691b5d6687be99f972c759838ba6eecae717f9af28e3c49d2afc7bbdf5f0b675238f1426e8 + languageName: node + linkType: hard + +"vfile-location@npm:^5.0.0": + version: 5.0.3 + resolution: "vfile-location@npm:5.0.3" + dependencies: + "@types/unist": "npm:^3.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/1711f67802a5bc175ea69750d59863343ed43d1b1bb25c0a9063e4c70595e673e53e2ed5cdbb6dcdc370059b31605144d95e8c061b9361bcc2b036b8f63a4966 + languageName: node + linkType: hard + +"vfile-message@npm:^4.0.0": + version: 4.0.2 + resolution: "vfile-message@npm:4.0.2" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-stringify-position: "npm:^4.0.0" + checksum: 10c0/07671d239a075f888b78f318bc1d54de02799db4e9dce322474e67c35d75ac4a5ac0aaf37b18801d91c9f8152974ea39678aa72d7198758b07f3ba04fb7d7514 + languageName: node + linkType: hard + +"vfile@npm:^6.0.0": + version: 6.0.2 + resolution: "vfile@npm:6.0.2" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-stringify-position: "npm:^4.0.0" + vfile-message: "npm:^4.0.0" + checksum: 10c0/96b7e060b332ff1b05462053bd9b0f39062c00c5eabb78fc75603cc808d5f77c4379857fffca3e30a28e0aad2d51c065dfcd4a43fbe15b1fc9c2aaa9ac1be8e1 + languageName: node + linkType: hard + +"vite-node@npm:2.1.1": + version: 2.1.1 + resolution: "vite-node@npm:2.1.1" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.6" + pathe: "npm:^1.1.2" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/8a8b958df3d48af915e07e7efb042ee4c036ca0b73d2c411dc29254fd3533ada0807ce5096d8339894d3e786418b7d1a9c4ae02718c6aca11b5098de2b14c336 + languageName: node + linkType: hard + +"vite-plugin-image-optimizer@npm:^1.1.8": + version: 1.1.8 + resolution: "vite-plugin-image-optimizer@npm:1.1.8" + dependencies: + ansi-colors: "npm:^4.1.3" + pathe: "npm:^1.1.2" + peerDependencies: + vite: ">=3" + checksum: 10c0/1631e0a1604b42b12348b99ecf86565f06e3e3ccd2ce3be8c2b9ac85e3292c5ec6101130b4860424e06932c81c5b7fcc2540504d8b7bc73aa4e08e0eb5073247 + languageName: node + linkType: hard + +"vite-plugin-static-copy@npm:^1.0.6": + version: 1.0.6 + resolution: "vite-plugin-static-copy@npm:1.0.6" + dependencies: + chokidar: "npm:^3.5.3" + fast-glob: "npm:^3.2.11" + fs-extra: "npm:^11.1.0" + picocolors: "npm:^1.0.0" + peerDependencies: + vite: ^5.0.0 + checksum: 10c0/997114571bd429481974483465ab78d1ecd5fc48d2de06cbfe9dbf005f9b870aa072b0d9c22e2fd8def759db2c48ad662a6a0cdf47706fba409c82db7c03ed22 + languageName: node + linkType: hard + +"vite-plugin-svgr@npm:^4.2.0": + version: 4.2.0 + resolution: "vite-plugin-svgr@npm:4.2.0" + dependencies: + "@rollup/pluginutils": "npm:^5.0.5" + "@svgr/core": "npm:^8.1.0" + "@svgr/plugin-jsx": "npm:^8.1.0" + peerDependencies: + vite: ^2.6.0 || 3 || 4 || 5 + checksum: 10c0/0a6400f20905f53d08f1ce7d1f22d9a57db403e110e790f80c2e0411a0064a071a36b781f56f6823654f98052219171003f9ea023d4a31d930b4a4fc01776d1f + languageName: node + linkType: hard + +"vite-tsconfig-paths@npm:^5.0.1": + version: 5.0.1 + resolution: "vite-tsconfig-paths@npm:5.0.1" + dependencies: + debug: "npm:^4.1.1" + globrex: "npm:^0.1.2" + tsconfck: "npm:^3.0.3" + peerDependencies: + vite: "*" + peerDependenciesMeta: + vite: + optional: true + checksum: 10c0/3c68a4d5df21ed4ef81749c20e91c5978989ed06bffc01688b3f1a0fe65951b461a68f0c017ad930a088cfe7a8cc04d0c8d955dfb8719d5edc7fb0ba9bf38a73 + languageName: node + linkType: hard + +"vite@npm:^5.0.0, vite@npm:^5.4.6": + version: 5.4.6 + resolution: "vite@npm:5.4.6" + dependencies: + esbuild: "npm:^0.21.3" + fsevents: "npm:~2.3.3" + postcss: "npm:^8.4.43" + rollup: "npm:^4.20.0" + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/5f87be3a10e970eaf9ac52dfab39cf9fff583036685252fb64570b6d7bfa749f6d221fb78058f5ef4b5664c180d45a8e7a7ff68d7f3770e69e24c7c68b958bde + languageName: node + linkType: hard + +"vitest@npm:^2.1.1": + version: 2.1.1 + resolution: "vitest@npm:2.1.1" + dependencies: + "@vitest/expect": "npm:2.1.1" + "@vitest/mocker": "npm:2.1.1" + "@vitest/pretty-format": "npm:^2.1.1" + "@vitest/runner": "npm:2.1.1" + "@vitest/snapshot": "npm:2.1.1" + "@vitest/spy": "npm:2.1.1" + "@vitest/utils": "npm:2.1.1" + chai: "npm:^5.1.1" + debug: "npm:^4.3.6" + magic-string: "npm:^0.30.11" + pathe: "npm:^1.1.2" + std-env: "npm:^3.7.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.0" + tinypool: "npm:^1.0.0" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.1.1" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.1.1 + "@vitest/ui": 2.1.1 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/77a67092338613376dadd8f6f6872383db8409402ce400ac1de48efd87a7214183e798484a3eb2310221c03554e37a00f9fdbc91e49194e7c68e009a5589f494 + languageName: node + linkType: hard + +"vlq@npm:^0.2.1": + version: 0.2.3 + resolution: "vlq@npm:0.2.3" + checksum: 10c0/d1557b404353ca75c7affaaf403d245a3273a7d1c6b3380ed7f04ae3f080e4658f41ac700d6f48acb3cd4875fe7bc7da4924b3572cd5584a5de83b35b1de5e12 + languageName: node + linkType: hard + +"wcwidth@npm:^1.0.1": + version: 1.0.1 + resolution: "wcwidth@npm:1.0.1" + dependencies: + defaults: "npm:^1.0.3" + checksum: 10c0/5b61ca583a95e2dd85d7078400190efd452e05751a64accb8c06ce4db65d7e0b0cde9917d705e826a2e05cc2548f61efde115ffa374c3e436d04be45c889e5b4 + languageName: node + linkType: hard + +"web-namespaces@npm:^2.0.0": + version: 2.0.1 + resolution: "web-namespaces@npm:2.0.1" + checksum: 10c0/df245f466ad83bd5cd80bfffc1674c7f64b7b84d1de0e4d2c0934fb0782e0a599164e7197a4bce310ee3342fd61817b8047ff04f076a1ce12dd470584142a4bd + languageName: node + linkType: hard + +"webpack-sources@npm:^3.2.3": + version: 3.2.3 + resolution: "webpack-sources@npm:3.2.3" + checksum: 10c0/2ef63d77c4fad39de4a6db17323d75eb92897b32674e97d76f0a1e87c003882fc038571266ad0ef581ac734cbe20952912aaa26155f1905e96ce251adbb1eb4e + languageName: node + linkType: hard + +"webpack-virtual-modules@npm:^0.6.2": + version: 0.6.2 + resolution: "webpack-virtual-modules@npm:0.6.2" + checksum: 10c0/5ffbddf0e84bf1562ff86cf6fcf039c74edf09d78358a6904a09bbd4484e8bb6812dc385fe14330b715031892dcd8423f7a88278b57c9f5002c84c2860179add + languageName: node + linkType: hard + +"which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.2": + version: 1.1.15 + resolution: "which-typed-array@npm:1.1.15" + dependencies: + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.7" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/4465d5348c044032032251be54d8988270e69c6b7154f8fcb2a47ff706fe36f7624b3a24246b8d9089435a8f4ec48c1c1025c5d6b499456b9e5eff4f48212983 + languageName: node + linkType: hard + +"which@npm:^2.0.1, which@npm:^2.0.2": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: "npm:^2.0.0" + bin: + node-which: ./bin/node-which + checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f + languageName: node + linkType: hard + +"which@npm:^4.0.0": + version: 4.0.0 + resolution: "which@npm:4.0.0" + dependencies: + isexe: "npm:^3.1.1" + bin: + node-which: bin/which.js + checksum: 10c0/449fa5c44ed120ccecfe18c433296a4978a7583bf2391c50abce13f76878d2476defde04d0f79db8165bdf432853c1f8389d0485ca6e8ebce3bbcded513d5e6a + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + +"wide-align@npm:^1.1.5": + version: 1.1.5 + resolution: "wide-align@npm:1.1.5" + dependencies: + string-width: "npm:^1.0.2 || 2 || 3 || 4" + checksum: 10c0/1d9c2a3e36dfb09832f38e2e699c367ef190f96b82c71f809bc0822c306f5379df87bab47bed27ea99106d86447e50eb972d3c516c2f95782807a9d082fbea95 + languageName: node + linkType: hard + +"winston-transport@npm:^4.7.0": + version: 4.7.1 + resolution: "winston-transport@npm:4.7.1" + dependencies: + logform: "npm:^2.6.1" + readable-stream: "npm:^3.6.2" + triple-beam: "npm:^1.3.0" + checksum: 10c0/99b7b55cc2ef7f38988ab1717e7fd946c81b856b42a9530aef8ee725490ef2f2811f9cb06d63aa2f76a85fe99ae15b3bef10a54afde3be8b5059ce325e78481f + languageName: node + linkType: hard + +"winston@npm:^3.14.2": + version: 3.14.2 + resolution: "winston@npm:3.14.2" + dependencies: + "@colors/colors": "npm:^1.6.0" + "@dabh/diagnostics": "npm:^2.0.2" + async: "npm:^3.2.3" + is-stream: "npm:^2.0.0" + logform: "npm:^2.6.0" + one-time: "npm:^1.0.0" + readable-stream: "npm:^3.4.0" + safe-stable-stringify: "npm:^2.3.1" + stack-trace: "npm:0.0.x" + triple-beam: "npm:^1.3.0" + winston-transport: "npm:^4.7.0" + checksum: 10c0/3f8fe505ea18310982e60452f335dd2b22fdbc9b25839b6ad882971b2416d5adc94a1f1a46e24cb37d967ad01dfe5499adaf5e53575626b5ebb2a25ff30f4e1d + languageName: node + linkType: hard + +"word-wrap@npm:^1.2.5": + version: 1.2.5 + resolution: "word-wrap@npm:1.2.5" + checksum: 10c0/e0e4a1ca27599c92a6ca4c32260e8a92e8a44f4ef6ef93f803f8ed823f486e0889fc0b93be4db59c8d51b3064951d25e43d434e95dc8c960cc3a63d65d00ba20 + languageName: node + linkType: hard + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da + languageName: node + linkType: hard + +"wrap-ansi@npm:^8.1.0": + version: 8.1.0 + resolution: "wrap-ansi@npm:8.1.0" + dependencies: + ansi-styles: "npm:^6.1.0" + string-width: "npm:^5.0.1" + strip-ansi: "npm:^7.0.1" + checksum: 10c0/138ff58a41d2f877eae87e3282c0630fc2789012fc1af4d6bd626eeb9a2f9a65ca92005e6e69a75c7b85a68479fe7443c7dbe1eb8fbaa681a4491364b7c55c60 + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + +"ws@npm:^8.18.0, ws@npm:^8.2.3": + version: 8.18.0 + resolution: "ws@npm:8.18.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/25eb33aff17edcb90721ed6b0eb250976328533ad3cd1a28a274bd263682e7296a6591ff1436d6cbc50fa67463158b062f9d1122013b361cec99a05f84680e06 + languageName: node + linkType: hard + +"xmlbuilder@npm:>=11.0.1, xmlbuilder@npm:^15.1.1": + version: 15.1.1 + resolution: "xmlbuilder@npm:15.1.1" + checksum: 10c0/665266a8916498ff8d82b3d46d3993913477a254b98149ff7cff060d9b7cc0db7cf5a3dae99aed92355254a808c0e2e3ec74ad1b04aa1061bdb8dfbea26c18b8 + languageName: node + linkType: hard + +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + +"yallist@npm:^3.0.2": + version: 3.1.1 + resolution: "yallist@npm:3.1.1" + checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1 + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + +"yargs@npm:^17.0.1, yargs@npm:^17.6.2": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 + languageName: node + linkType: hard + +"yauzl@npm:^2.10.0": + version: 2.10.0 + resolution: "yauzl@npm:2.10.0" + dependencies: + buffer-crc32: "npm:~0.2.3" + fd-slicer: "npm:~1.1.0" + checksum: 10c0/f265002af7541b9ec3589a27f5fb8f11cf348b53cc15e2751272e3c062cd73f3e715bc72d43257de71bbaecae446c3f1b14af7559e8ab0261625375541816422 + languageName: node + linkType: hard + +"yn@npm:3.1.1": + version: 3.1.1 + resolution: "yn@npm:3.1.1" + checksum: 10c0/0732468dd7622ed8a274f640f191f3eaf1f39d5349a1b72836df484998d7d9807fbea094e2f5486d6b0cd2414aad5775972df0e68f8604db89a239f0f4bf7443 + languageName: node + linkType: hard + +"yocto-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "yocto-queue@npm:0.1.0" + checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f + languageName: node + linkType: hard + +"zwitch@npm:^2.0.0": + version: 2.0.4 + resolution: "zwitch@npm:2.0.4" + checksum: 10c0/3c7830cdd3378667e058ffdb4cf2bb78ac5711214e2725900873accb23f3dfe5f9e7e5a06dcdc5f29605da976fc45c26d9a13ca334d6eea2245a15e77b8fc06e + languageName: node + linkType: hard