first commit
55
xterminal/.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- docs/**
|
||||
- README.md
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: https://registry.npmjs.org/
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 10
|
||||
cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm docs:build
|
||||
|
||||
- name: Set CNAME
|
||||
shell: bash
|
||||
run: |
|
||||
echo "xterminal.js.org" > docs/.vitepress/dist/CNAME
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: docs/.vitepress/dist
|
||||
60
xterminal/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Release NPM package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- source/**
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Checkout project repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Setup git profile
|
||||
- name: git config
|
||||
run: |
|
||||
git config user.name "${GITHUB_ACTOR}"
|
||||
git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
|
||||
|
||||
# Setup Node.js environment
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
registry-url: https://registry.npmjs.org/
|
||||
node-version: 22
|
||||
|
||||
# Install pnpm
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 10
|
||||
cache: true
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
# Tests
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
# Setup NPM
|
||||
- name: Create .npmrc
|
||||
run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# Publish version to public repository
|
||||
- name: Release
|
||||
run: pnpx release-it --ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
42
xterminal/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# Typescript build output
|
||||
out/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# VitePress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
.npmrc
|
||||
3
xterminal/.husky/pre-commit
Executable file
@@ -0,0 +1,3 @@
|
||||
echo "Running lint-staged..."
|
||||
|
||||
pnpm exec lint-staged
|
||||
6
xterminal/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
34
xterminal/.release-it.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"git": {
|
||||
"tagName": "v${version}",
|
||||
"commitMessage": "chore(release): v${version}",
|
||||
"requireBranch": "master"
|
||||
},
|
||||
"changelogFile": "CHANGELOG.md",
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "XTerminal v${version}",
|
||||
"autoGenerate": true,
|
||||
"assets": ["dist/"],
|
||||
"comments": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": true,
|
||||
"skipChecks": true
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": "git fetch --tags"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"infile": "CHANGELOG.md",
|
||||
"header": "# Changelog",
|
||||
"preset": {
|
||||
"name": "conventionalcommits"
|
||||
},
|
||||
"gitRawCommitOpts": {
|
||||
"merge": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
294
xterminal/CHANGELOG.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Changelog
|
||||
|
||||
## 2.2.1 (2026-01-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set isMounted to false when terminal is disposed ([988a610](https://github.com/henryhale/xterminal/commit/988a610d38bfe09d6ea66bfde9c55bdf7f8cb1b6))
|
||||
|
||||
## 2.2.0 (2026-01-19)
|
||||
|
||||
### Features
|
||||
|
||||
* updated release-it config ([e0420b6](https://github.com/henryhale/xterminal/commit/e0420b65ddcb5d817906af1857c05cb67042d4cf))
|
||||
|
||||
##
|
||||
|
||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v2.1.17](https://github.com/henryhale/xterminal/compare/v2.1.16...v2.1.17)
|
||||
|
||||
- feature - setting & clearing input buffer [`#44`](https://github.com/henryhale/xterminal/pull/44)
|
||||
|
||||
#### [v2.1.16](https://github.com/henryhale/xterminal/compare/v2.1.15...v2.1.16)
|
||||
|
||||
> 8 January 2026
|
||||
|
||||
- docs: added more levels on right sidebar [`a45001c`](https://github.com/henryhale/xterminal/commit/a45001c321c5721262f8be0a946db34334f7522c)
|
||||
- chore(feat): added escapeHTML static method to sanitize arbitrary data before printing [`9d702d2`](https://github.com/henryhale/xterminal/commit/9d702d2148026b4b2e923132144f503a4023720e)
|
||||
- chore(release): v2.1.16 [`264a63a`](https://github.com/henryhale/xterminal/commit/264a63a80adeefa978ca2543521563c9a671e31d)
|
||||
|
||||
#### [v2.1.15](https://github.com/henryhale/xterminal/compare/v2.1.14...v2.1.15)
|
||||
|
||||
> 31 December 2025
|
||||
|
||||
- chore: audited deps for vulnerabilities [`7f61dbf`](https://github.com/henryhale/xterminal/commit/7f61dbf5208f96850812d959327352844b49bd0f)
|
||||
- chore(release): v2.1.15 [`8b2b13a`](https://github.com/henryhale/xterminal/commit/8b2b13af1a19edcfdde982d043b04c9477a2b8e1)
|
||||
- chore: remove unnecessary code [`0e09581`](https://github.com/henryhale/xterminal/commit/0e0958105591e5a4e7192ac88c659293d15627cb)
|
||||
|
||||
#### [v2.1.14](https://github.com/henryhale/xterminal/compare/v2.1.13...v2.1.14)
|
||||
|
||||
> 31 December 2025
|
||||
|
||||
- feat: added term.writeSafe and term.writelnSafe output methods [`ab8133e`](https://github.com/henryhale/xterminal/commit/ab8133ee653d7119a133abe3c40b4be3cae5531e)
|
||||
- chore(release): v2.1.14 [`370b3dd`](https://github.com/henryhale/xterminal/commit/370b3dd7ea16a7ecb2d4125ae6a9d1601a359b4f)
|
||||
|
||||
#### [v2.1.13](https://github.com/henryhale/xterminal/compare/v2.1.12...v2.1.13)
|
||||
|
||||
> 7 November 2025
|
||||
|
||||
- chore(release): v2.1.13 [`14d3dd3`](https://github.com/henryhale/xterminal/commit/14d3dd3c209a11040691db85d04ed23f87b1af11)
|
||||
- chore(fix): update default export in types.d.ts [`de7a6ce`](https://github.com/henryhale/xterminal/commit/de7a6ce36d4736b72a074120220ec5f7ba0eeabc)
|
||||
|
||||
#### [v2.1.12](https://github.com/henryhale/xterminal/compare/v2.1.11...v2.1.12)
|
||||
|
||||
> 18 May 2025
|
||||
|
||||
- ci: update node and pnpm version [`8a91783`](https://github.com/henryhale/xterminal/commit/8a917832d23c18d4e1c98cdc049b5d0b1ec9472d)
|
||||
- fix: missing css file in package [`02717aa`](https://github.com/henryhale/xterminal/commit/02717aa51a36e67da12871a23b5edf994d85de93)
|
||||
- chore(release): v2.1.12 [`eeb0eb8`](https://github.com/henryhale/xterminal/commit/eeb0eb83474144fe3874534afe67e1f8335ea668)
|
||||
|
||||
#### [v2.1.11](https://github.com/henryhale/xterminal/compare/v2.1.10...v2.1.11)
|
||||
|
||||
> 21 December 2024
|
||||
|
||||
- chore(release): v2.1.11 [`0c46bc4`](https://github.com/henryhale/xterminal/commit/0c46bc465ad03b384b5b04c6e72b2a81194941b0)
|
||||
- chore(fix): ensure proper types resolution [`3dcdb59`](https://github.com/henryhale/xterminal/commit/3dcdb592290a2162a1df0e88b46264f4f7118abd)
|
||||
- chore: update license year [`92f2f16`](https://github.com/henryhale/xterminal/commit/92f2f16c528823250623a9efde9fedafbf1f6db3)
|
||||
|
||||
#### [v2.1.10](https://github.com/henryhale/xterminal/compare/v2.1.9...v2.1.10)
|
||||
|
||||
> 21 December 2024
|
||||
|
||||
- chore: update browserlist db [`2ce43ed`](https://github.com/henryhale/xterminal/commit/2ce43edc63efab6e720addc383dcacf692523afd)
|
||||
- chore: update dependencies [`773a41b`](https://github.com/henryhale/xterminal/commit/773a41b623bd16ad9caee0e99b3dfa6ef0e4ab88)
|
||||
- chore: update showcase page in docs [`7b0b20c`](https://github.com/henryhale/xterminal/commit/7b0b20c5029c74935d805c81032e95b07b6991fb)
|
||||
|
||||
#### [v2.1.9](https://github.com/henryhale/xterminal/compare/v2.1.8...v2.1.9)
|
||||
|
||||
> 3 April 2024
|
||||
|
||||
- chore(release): v2.1.9 [`0023f4f`](https://github.com/henryhale/xterminal/commit/0023f4fe139f4ebcfa2ae4ab5c039357b71fb5dd)
|
||||
- merge: pull request #35 from henryhale/nxt [`6880722`](https://github.com/henryhale/xterminal/commit/68807229cfae841d4a68b245622c516a7c557958)
|
||||
- chore: update dependencies [`1412436`](https://github.com/henryhale/xterminal/commit/141243665843b23ff62a8a7977a96f422e6a122d)
|
||||
|
||||
#### [v2.1.8](https://github.com/henryhale/xterminal/compare/v2.1.7...v2.1.8)
|
||||
|
||||
> 29 December 2023
|
||||
|
||||
- chore(release): v2.1.8 [`7971ece`](https://github.com/henryhale/xterminal/commit/7971eceec218d3bcdd6ae1a35b4825ce01ce7877)
|
||||
- merge: merge pull request #34 from henryhale/docs/showcase [`941f205`](https://github.com/henryhale/xterminal/commit/941f20507b2fd34fe361d25b7335d9810c92c7ee)
|
||||
- chore: update dependencies [`cc5c254`](https://github.com/henryhale/xterminal/commit/cc5c25425d18e3bf32ac8c11cbeb8a72ee5072d2)
|
||||
|
||||
#### [v2.1.7](https://github.com/henryhale/xterminal/compare/v2.1.6...v2.1.7)
|
||||
|
||||
> 8 October 2023
|
||||
|
||||
- chore(release): v2.1.7 [`3dac7ef`](https://github.com/henryhale/xterminal/commit/3dac7ef91f92620383606c2007f5f0c2df173625)
|
||||
- merge: Merge pull request #33 from henryhale/docs/contributors [`6a6ac77`](https://github.com/henryhale/xterminal/commit/6a6ac7751388f8cb928f5ad9919b65e4f94503c0)
|
||||
- docs: add contributors [`8ab974a`](https://github.com/henryhale/xterminal/commit/8ab974a04e68301fcc8d3c59996a83cc7f411d1c)
|
||||
|
||||
#### [v2.1.6](https://github.com/henryhale/xterminal/compare/v2.1.5...v2.1.6)
|
||||
|
||||
> 8 October 2023
|
||||
|
||||
- chore(release): v2.1.6 [`3659562`](https://github.com/henryhale/xterminal/commit/3659562fb33b65f69488304e7a03f52dd53a58b6)
|
||||
- merge: Merge pull request #32 from henryhale/fix/changelog [`749f5aa`](https://github.com/henryhale/xterminal/commit/749f5aa2c870b1a52c4bd141db779234035a6ecb)
|
||||
- chore(fix): provide a better changelog and release notes [`bf65ec9`](https://github.com/henryhale/xterminal/commit/bf65ec9abd16367cc3f9414e5582e317583d6fa7)
|
||||
|
||||
#### [v2.1.5](https://github.com/henryhale/xterminal/compare/v2.1.4...v2.1.5)
|
||||
|
||||
> 7 October 2023
|
||||
|
||||
- chore(release): v2.1.5 [`42cb622`](https://github.com/henryhale/xterminal/commit/42cb622c5bcba8c9e271e32e78a8049742544213)
|
||||
- merge: Merge pull request #24 from henryhale/chore/update-deps [`b400af1`](https://github.com/henryhale/xterminal/commit/b400af17ed8fe67c035851e88810fb63ef6dcb78)
|
||||
- build: update node js version [`094eb4d`](https://github.com/henryhale/xterminal/commit/094eb4d7677dbea4e7963602c9eb1d624e15e0f2)
|
||||
|
||||
#### [v2.1.4](https://github.com/henryhale/xterminal/compare/v2.1.3...v2.1.4)
|
||||
|
||||
> 7 October 2023
|
||||
|
||||
- chore(release): v2.1.4 [`f4ce706`](https://github.com/henryhale/xterminal/commit/f4ce706a812eb0be1dc0789725d9a68e6abe964e)
|
||||
- merge: Merge pull request #28 from henryhale/docs/update [`e7e4e56`](https://github.com/henryhale/xterminal/commit/e7e4e569d1380d0063941864e429b269fe9d2634)
|
||||
- docs: update readme links and ci deploy [`eed9308`](https://github.com/henryhale/xterminal/commit/eed9308d16a276c67265223b12e6a2b163405bc0)
|
||||
|
||||
#### [v2.1.3](https://github.com/henryhale/xterminal/compare/v2.1.2...v2.1.3)
|
||||
|
||||
> 7 October 2023
|
||||
|
||||
- chore(release): v2.1.3 [`2986705`](https://github.com/henryhale/xterminal/commit/2986705db945898fffb2d93b4757d6cc0c9859d2)
|
||||
- merge: Merge pull request #26 from henryhale/docs/update [`f024a90`](https://github.com/henryhale/xterminal/commit/f024a908e14eef400fe15404a959e3cb17c9e835)
|
||||
- fix(docs): update base url prefix [`69bd494`](https://github.com/henryhale/xterminal/commit/69bd494f08e4bf8bd56c1243874bf533d79cd1a8)
|
||||
|
||||
#### [v2.1.2](https://github.com/henryhale/xterminal/compare/v2.1.1...v2.1.2)
|
||||
|
||||
> 6 October 2023
|
||||
|
||||
- chore(ci): update deploy.yml with CNAME [`a883ea1`](https://github.com/henryhale/xterminal/commit/a883ea159e04862e9daa33dd4237c05f0844b94c)
|
||||
- chore(release): v2.1.2 [`17f8bf4`](https://github.com/henryhale/xterminal/commit/17f8bf4acf4a6f1c3037fc765b80f08d9e1a5a10)
|
||||
|
||||
#### [v2.1.1](https://github.com/henryhale/xterminal/compare/v2.1.0...v2.1.1)
|
||||
|
||||
> 6 October 2023
|
||||
|
||||
- chore(release): v2.1.1 [`3d256cd`](https://github.com/henryhale/xterminal/commit/3d256cd06a27c92d7f9df8774233258c9b09d092)
|
||||
- merge: Merge pull request #25 from henryhale/docs/update [`5417017`](https://github.com/henryhale/xterminal/commit/5417017ad4822a0c745f0b789ed4a5dad169f8cf)
|
||||
- docs: update documentation [`145a702`](https://github.com/henryhale/xterminal/commit/145a702a748e77e6cf786dc48744aa6817513845)
|
||||
|
||||
#### [v2.1.0](https://github.com/henryhale/xterminal/compare/v2.0.12...v2.1.0)
|
||||
|
||||
> 2 October 2023
|
||||
|
||||
- refactor(types): updated typings and readme, added types.ts [`f136a62`](https://github.com/henryhale/xterminal/commit/f136a622318f2df933fef36049125bb9a4a49f0c)
|
||||
- chore(types): remove old typings [`3033710`](https://github.com/henryhale/xterminal/commit/3033710b3e581dc6951a90a7c29e0c67bd6565e5)
|
||||
- feat: provide static eventemitter class, update docs [`0bc9694`](https://github.com/henryhale/xterminal/commit/0bc9694710cb22969fa6b968913cc60beab891de)
|
||||
|
||||
#### [v2.0.12](https://github.com/henryhale/xterminal/compare/v2.0.11...v2.0.12)
|
||||
|
||||
> 1 October 2023
|
||||
|
||||
- chore: switch to conventional-changelog [`37955e7`](https://github.com/henryhale/xterminal/commit/37955e7a1229eb58a9c8f050539c9d1c2bf110c7)
|
||||
- chore(release): v2.0.12 [`8571c33`](https://github.com/henryhale/xterminal/commit/8571c33f13835dcd1f0c78ea6b98a96a15b620a0)
|
||||
|
||||
#### [v2.0.11](https://github.com/henryhale/xterminal/compare/v2.0.10...v2.0.11)
|
||||
|
||||
> 21 August 2023
|
||||
|
||||
- chore(release): v2.0.11 [`6feec19`](https://github.com/henryhale/xterminal/commit/6feec19ff36055f9c76f2d18ce750b2eb99c5b56)
|
||||
- ci: update workflow access settings [`aa79b92`](https://github.com/henryhale/xterminal/commit/aa79b9229be937b7edc1f7648180812ef1278dc9)
|
||||
|
||||
#### [v2.0.10](https://github.com/henryhale/xterminal/compare/v2.0.9...v2.0.10)
|
||||
|
||||
> 21 August 2023
|
||||
|
||||
- chore: updated deps [`6de8f9d`](https://github.com/henryhale/xterminal/commit/6de8f9d9eb0339185682f56093f46c5611f633c2)
|
||||
- chore(release): v2.0.10 [`75779f3`](https://github.com/henryhale/xterminal/commit/75779f31c5e0fe4317d896c2369cb82d634d8f67)
|
||||
- refactor(fix): prevent eslint from ignoring the return type [`cffff93`](https://github.com/henryhale/xterminal/commit/cffff93c211b6344552d7dddc8dd4b189e8339b6)
|
||||
|
||||
#### [v2.0.9](https://github.com/henryhale/xterminal/compare/v2.0.8...v2.0.9)
|
||||
|
||||
> 21 August 2023
|
||||
|
||||
- chore: update deps [`35b4f8e`](https://github.com/henryhale/xterminal/commit/35b4f8e695f2469b15348791772951a24ab565b0)
|
||||
- chore(release): v2.0.9 [`4fe5a35`](https://github.com/henryhale/xterminal/commit/4fe5a350c880905e854474e719e8f4f977395876)
|
||||
- fix: css import error in development [`d66ec90`](https://github.com/henryhale/xterminal/commit/d66ec9008d621207ee42bdd7d4839e8577348821)
|
||||
|
||||
#### [v2.0.8](https://github.com/henryhale/xterminal/compare/v2.0.7...v2.0.8)
|
||||
|
||||
> 19 July 2023
|
||||
|
||||
- chore(release): v2.0.8 [`3e2a0e0`](https://github.com/henryhale/xterminal/commit/3e2a0e053313ca23ebddfa4a77e06b9622b21900)
|
||||
- add dev server #17 [`718ecb3`](https://github.com/henryhale/xterminal/commit/718ecb394ef67a645e7970ee1f8e4bb6311fb143)
|
||||
- feat: add vite [`fe068c5`](https://github.com/henryhale/xterminal/commit/fe068c5f9daac12658b6225f82f0b0e562c9cd74)
|
||||
|
||||
#### [v2.0.7](https://github.com/henryhale/xterminal/compare/v2.0.6...v2.0.7)
|
||||
|
||||
> 14 July 2023
|
||||
|
||||
- chore: release/changelog update [`#20`](https://github.com/henryhale/xterminal/pull/20)
|
||||
- chore(release): v2.0.7 [`f8ec9ca`](https://github.com/henryhale/xterminal/commit/f8ec9ca4bb47bbc2cf6051c7c2ee5a91fc7b554b)
|
||||
|
||||
#### [v2.0.6](https://github.com/henryhale/xterminal/compare/v2.0.5...v2.0.6)
|
||||
|
||||
> 14 July 2023
|
||||
|
||||
- ci: update release.yml, bump pnpm version [`#19`](https://github.com/henryhale/xterminal/pull/19)
|
||||
- chore(release): v2.0.6 [`5fef58a`](https://github.com/henryhale/xterminal/commit/5fef58a8d349bab4570a4dd8cf400d905329215d)
|
||||
|
||||
#### [v2.0.5](https://github.com/henryhale/xterminal/compare/v2.0.4...v2.0.5)
|
||||
|
||||
> 14 July 2023
|
||||
|
||||
- revert: "merge: release/changelog update (#16)" [`#18`](https://github.com/henryhale/xterminal/pull/18)
|
||||
- merge: release/changelog update [`#16`](https://github.com/henryhale/xterminal/pull/16)
|
||||
- chore(release): v2.0.5 [`200e07e`](https://github.com/henryhale/xterminal/commit/200e07e41630f886ad8770c8b36a4e5d7b16d6fd)
|
||||
|
||||
#### [v2.0.4](https://github.com/henryhale/xterminal/compare/v2.0.3...v2.0.4)
|
||||
|
||||
> 13 July 2023
|
||||
|
||||
- docs/update [`#15`](https://github.com/henryhale/xterminal/pull/15)
|
||||
- chore(release): v2.0.4 [`80d7015`](https://github.com/henryhale/xterminal/commit/80d7015427e1f3945275ecb3517d6896e9901b9f)
|
||||
|
||||
#### [v2.0.3](https://github.com/henryhale/xterminal/compare/v2.0.2...v2.0.3)
|
||||
|
||||
> 13 July 2023
|
||||
|
||||
- docs/update readme [`#14`](https://github.com/henryhale/xterminal/pull/14)
|
||||
- chore(release): v2.0.3 [`5fffcfc`](https://github.com/henryhale/xterminal/commit/5fffcfca5880a60f05c74d567945ed315f882502)
|
||||
|
||||
#### [v2.0.2](https://github.com/henryhale/xterminal/compare/v2.0.1...v2.0.2)
|
||||
|
||||
> 12 July 2023
|
||||
|
||||
- docs(readme): add contribution guide, docs reference, basic usage and inspiration [`#13`](https://github.com/henryhale/xterminal/pull/13)
|
||||
- chore(release): v2.0.2 [`42ddc40`](https://github.com/henryhale/xterminal/commit/42ddc40897185d80e325fb9ed82cd640eda88d2b)
|
||||
|
||||
#### [v2.0.1](https://github.com/henryhale/xterminal/compare/v2.0.0...v2.0.1)
|
||||
|
||||
> 11 July 2023
|
||||
|
||||
- fix: trigger keypress event on every key pressed [`#12`](https://github.com/henryhale/xterminal/pull/12)
|
||||
- chore(release): v2.0.1 [`a3cf93d`](https://github.com/henryhale/xterminal/commit/a3cf93d3474c5b97933872fab32e9d38790cc323)
|
||||
|
||||
### [v2.0.0](https://github.com/henryhale/xterminal/compare/v1.1.3...v2.0.0)
|
||||
|
||||
> 7 July 2023
|
||||
|
||||
- chore: next codebase [`#10`](https://github.com/henryhale/xterminal/pull/10)
|
||||
- chore: added media files [`#9`](https://github.com/henryhale/xterminal/pull/9)
|
||||
- docs(ci): use pnpm to fix setup error in gh actions [`#8`](https://github.com/henryhale/xterminal/pull/8)
|
||||
- docs(ci): updated documentation, deploy to github pages [`#6`](https://github.com/henryhale/xterminal/pull/6)
|
||||
- chore(release): v2.0.0 [`71c4e5f`](https://github.com/henryhale/xterminal/commit/71c4e5fcd4b7a2e123a53992d1528759f32357f1)
|
||||
|
||||
#### [v1.1.3](https://github.com/henryhale/xterminal/compare/v1.1.2...v1.1.3)
|
||||
|
||||
> 6 July 2023
|
||||
|
||||
- merge(#5) : Produce both ESM and UMD builds [`2643436`](https://github.com/henryhale/xterminal/commit/2643436115a35fc01682f303fd08d705ceb89141)
|
||||
- chore(release): v1.1.3 [`b02a107`](https://github.com/henryhale/xterminal/commit/b02a107363d0416ef77db6683a75a01f0ecffdb4)
|
||||
|
||||
#### [v1.1.2](https://github.com/henryhale/xterminal/compare/v1.1.0...v1.1.2)
|
||||
|
||||
> 6 July 2023
|
||||
|
||||
- chore(ci): switch from semantic-release to release-it [`#4`](https://github.com/henryhale/xterminal/pull/4)
|
||||
- ci: employ semantic-release to manage releases [`5eabc90`](https://github.com/henryhale/xterminal/commit/5eabc90d523d3d5c7da3a17777d7807de656cc38)
|
||||
- docs: initial commit [`3892437`](https://github.com/henryhale/xterminal/commit/3892437eccee70e1b1e29c4fddd50a93aa17ae5c)
|
||||
- chore(changelog): use conventional commits based on angular [`65d5108`](https://github.com/henryhale/xterminal/commit/65d51083ab7d77c3af2c058cc948e6f82e726a0d)
|
||||
|
||||
#### [v1.1.0](https://github.com/henryhale/xterminal/compare/v1.0.0...v1.1.0)
|
||||
|
||||
> 27 April 2023
|
||||
|
||||
- refactor: enable key mapping for old browsers [`bade49d`](https://github.com/henryhale/xterminal/commit/bade49d0a524ca8815b71f594af16198c811e16a)
|
||||
- css: added media query [`f9f203b`](https://github.com/henryhale/xterminal/commit/f9f203b2921ef5bc4011c586a565f87a5049631c)
|
||||
- chore: update type declarations [`3d191bb`](https://github.com/henryhale/xterminal/commit/3d191bbd3353ba4d77dffec9041f6ae18e482c61)
|
||||
|
||||
### [v1.0.0](https://github.com/henryhale/xterminal/compare/v0.1.1...v1.0.0)
|
||||
|
||||
> 24 April 2023
|
||||
|
||||
- chore: added lock file for resolving deps fast [`63f4445`](https://github.com/henryhale/xterminal/commit/63f44455c471268fb9f1eeac18c443ddb9d85531)
|
||||
- build: dev dependencies upgrade [`5823d4c`](https://github.com/henryhale/xterminal/commit/5823d4cefb0147b35b457f0a744303d267d8703b)
|
||||
- chore: added husky, prettier, and some fixes [`e76b4a1`](https://github.com/henryhale/xterminal/commit/e76b4a17f4e1a0e340bad649358cb7692bc8fc85)
|
||||
|
||||
#### v0.1.1
|
||||
|
||||
> 7 March 2023
|
||||
|
||||
- initial commit [`7e3b5f8`](https://github.com/henryhale/xterminal/commit/7e3b5f8cd30809a106c6b3acf24b594652bb0a16)
|
||||
- chore: added github action to publish package [`62c196e`](https://github.com/henryhale/xterminal/commit/62c196efc3bdb7369d207b9bee6b7ca9fab4095a)
|
||||
- fix: github action - install pnpm first [`7611d4d`](https://github.com/henryhale/xterminal/commit/7611d4de02f3fdc70f2cb2443ea3dd64ae6382c3)
|
||||
21
xterminal/LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-present Henry Hale (https://github.com/henryhale)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
112
xterminal/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<div align="center">
|
||||
<img width="75" height="75" src="https://raw.githubusercontent.com/henryhale/xterminal/master/assets/logo-rounded.png" />
|
||||
<h1>XTerminal</h1>
|
||||
<p><i>Build Web-based Command-line Interfaces.</i></p>
|
||||
<img alt="npm" src="https://img.shields.io/npm/v/xterminal">
|
||||
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/henryhale/xterminal">
|
||||
<img alt="npm bundle size" src="https://img.shields.io/bundlephobia/minzip/xterminal">
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/henryhale/xterminal">
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
|
||||
**Welcome to XTerminal!**
|
||||
|
||||
This library provides a simple, lightweight and perfomant solution for creating interactive web-based command-line interfaces (CLIs) with ease in your web applications. It aims to be a minimalist dependency-free alternative to popular libraries like [jquery.terminal](https://github.com/jcubic/jquery.terminal), offering improved performance and a simplified approach.
|
||||
|
||||
## Inspiration
|
||||
|
||||
This project draws inspiration from the powerful [node:readline](https://nodejs.org/api/readline.html) module in Node.js, which has proven to be a reliable and efficient tool for command-line interactions. Additionally, inspiration is derived from the versatility and functionality of [xterm.js](https://github.com/xtermjs), a widely adopted library for web-based terminal emulators.
|
||||
|
||||
By combining the best features and ideas from these sources, this library aims to provide an accessible and performant solution for creating web-based CLIs that meet the needs of modern web applications.
|
||||
|
||||
## Live Demo
|
||||
|
||||
There is a quick demo online: [Try It Yourself](https://xterminal.js.org/demo/).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
To install `xterminal`, use [npm](https://npmjs.org/xterminal):
|
||||
|
||||
```sh
|
||||
npm install xterminal
|
||||
```
|
||||
|
||||
alternatively use [unpkg](https://unpkg.com/xterminal) or [jsdelivr](https://cdn.jsdelivr.net/npm/xterminal).
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Import the package and create a new instance of the `XTerminal` class:
|
||||
|
||||
```html
|
||||
<link rel='stylesheet' href='https://unpkg.com/xterminal/dist/xterminal.css'>
|
||||
|
||||
<div id="app"></div>
|
||||
|
||||
<script src='https://unpkg.com/xterminal/dist/xterminal.umd.js'></script>
|
||||
|
||||
<script>
|
||||
const term = new XTerminal()
|
||||
term.mount('#app');
|
||||
term.write('Hello World!\n# ');
|
||||
</script>
|
||||
```
|
||||
|
||||
For more detailed information, please refer to the [official documentation](https://xterminal.js.org/) online.
|
||||
|
||||
## Documentation
|
||||
|
||||
The complete documentation for `XTerminal` can be found [here](https://xterminal.js.org/). It provides detailed information on installation, configuration, usage, and advanced features. You'll also find code examples and API references.
|
||||
|
||||
The full Public API for `XTerminal` can also be found within this [TypeScript declaration file](https://github.com/henryhale/xterminal/blob/master/source/types.ts).
|
||||
|
||||
## Showcase
|
||||
|
||||
Several projects that are using XTerminal are listed [here](https://xterminal.js.org/showcase). Feel free to open a pull request adding your project in this [file](https://github.com/henryhale/xterminal/blob/master/docs/showcase/index.md).
|
||||
|
||||
## Browser Support
|
||||
|
||||
Supporting wide range of browsers is the goal. Modern browsers, most specifically the latest versions of Chrome, Firefox, Safari, and Edge (for desktop and mobile devices) are supported.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for checking out this awesome project. Any contributions to the project are [appreciated](https://xterminal.js.org/about/team.html), whether it's fixing bugs, adding new features, or improving documentation. To contribute, please follow these guidelines:
|
||||
|
||||
- **Issues**: Before starting to work on a new feature or bug fix, please check the issue tracker to see if the task is already in progress or has been reported. If not, feel free to [open a new issue](https://github.com/henryhale/xterminal/issues/new) to discuss the proposed changes or bug fixes.
|
||||
- **Branching**: Create a new branch for each feature or bug fix you are working on. Use clear descriptive branch names that reflect the purpose of your changes e.g. `feature/events` or `bugfix/issue-1234`.
|
||||
|
||||
## Development
|
||||
|
||||
To get started with development, follow these steps:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org) (>=22)
|
||||
- [pnpm](https://pnpm.io/) (>=10)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone this repository: `git clone https://github.com/henryhale/xterminal.git`
|
||||
2. Navigate to the project directory: `cd xterminal`
|
||||
3. Install dependencies: `pnpm install`
|
||||
4. Start the development server: `pnpm dev`
|
||||
|
||||
### Building the Library
|
||||
|
||||
To build the library, run `pnpm build`
|
||||
|
||||
This will generate the production-ready distribution files in the `dist` directory.
|
||||
|
||||
## Related
|
||||
|
||||
- [chalk-dom](https://github.com/henryhale/chalk-dom) - Chalk for the browser
|
||||
- [inken](https://github.com/henryhale/inken) - Terminal-like string styling for the browser
|
||||
- [viteshell](https://github.com/henryhale/viteshell) - A minimal bash-like shell implementation written in TypeScript
|
||||
|
||||
## License
|
||||
|
||||
Copyright (c) 2023-present [Henry Hale](https://github.com/henryhale/).
|
||||
|
||||
Released under the [MIT License](https://github.com/henryhale/xterminal/blob/master/LICENSE.txt).
|
||||
BIN
xterminal/assets/internals.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
xterminal/assets/logo-rounded.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
xterminal/assets/logo.png
Normal file
|
After Width: | Height: | Size: 310 B |
9
xterminal/assets/logo.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<title>XTerminal Logo</title>
|
||||
<path stroke="#000" d="m-4,-2.5l103.99998,0l0,108l-103.99998,0l0,-108z" stroke-width="0" fill="#444444"/>
|
||||
<rect stroke="#000" height="33" width="16" y="29" x="42" stroke-width="0" fill="#ffffff"/>
|
||||
<rect transform="rotate(-45 23.25 40.25)" stroke="#000" height="18.56497" width="6.17157" y="30.96751" x="20.16421" stroke-width="0" fill="#ffffff"/>
|
||||
<rect transform="rotate(45 22.6036 50.25)" stroke="#000" height="18.75736" width="6" y="40.87132" x="19.60356" stroke-width="0" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 616 B |
1
xterminal/assets/media/black-square.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000" viewBox="0 0 1000 1000"><rect width="1000" height="1000" fill="#ffffff"></rect><g transform="matrix(0.7,0,0,0.7,149.57575757575756,415.9384332941345)"><svg viewBox="0 0 396 95" data-background-color="#01ff70" preserveAspectRatio="xMidYMid meet" height="239" width="1000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="tight-bounds" transform="matrix(1,0,0,1,0.2400000000000091,0.23370494355782512)"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g id="textblocktransform"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52" id="textblock"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><rect width="395.52" height="94.53259011288438" x="0" y="0" opacity="1" fill="#000000" data-fill-palette-color="tertiary"></rect><g transform="matrix(1,0,0,1,23.607599999999998,23.607599999999994)"><svg width="348.3048" viewBox="0.75 -34.900001525878906 256.8800048828125 34.900001525878906" height="47.31739011288439" data-palette-color="#01ff70"><path d="M19.8-17.9L29.85 0 21.6 0 15.25-12.5 14.55-12.5 8.4 0 0.75 0 10.6-17.9 1.3-34.9 9.55-34.9 15.15-23.45 15.95-23.45 21.6-34.9 29.25-34.9 19.8-17.9ZM55.7-28.65L47.35-28.65 47.35 0 40.2 0 40.2-28.65 31.85-28.65 31.85-34.9 55.7-34.9 55.7-28.65ZM81.95 0L60.35 0 60.35-34.9 81.95-34.9 81.95-28.65 67.5-28.65 67.5-20.75 79.75-20.75 79.75-14.55 67.5-14.55 67.5-6.25 81.95-6.25 81.95 0ZM94.94-13.2L94.94 0 87.79 0 87.79-34.9 102.89-34.9Q107.59-34.9 110.04-31.95 112.49-29 112.49-24.05L112.49-24.05Q112.49-20.2 111.04-17.55 109.59-14.9 106.74-13.85L106.74-13.85 112.84 0 105.09 0 99.69-13.2 94.94-13.2ZM94.94-19.15L101.99-19.15Q103.44-19.15 104.27-19.88 105.09-20.6 105.09-22.4L105.09-22.4 105.09-25.4Q105.09-27.2 104.27-27.93 103.44-28.65 101.99-28.65L101.99-28.65 94.94-28.65 94.94-19.15ZM141.64 0L141.64-18.2 142.04-24.05 141.24-24.05 139.09-18.5 133.29-5.85 127.54-18.5 125.39-24.05 124.59-24.05 124.99-18.2 124.99 0 118.29 0 118.29-34.9 125.79-34.9 130.54-24.5 133.14-17.05 133.64-17.05 136.24-24.5 140.89-34.9 148.34-34.9 148.34 0 141.64 0ZM169.39 0L154.04 0 154.04-5.65 158.14-5.65 158.14-29.25 154.04-29.25 154.04-34.9 169.39-34.9 169.39-29.25 165.29-29.25 165.29-5.65 169.39-5.65 169.39 0ZM193.14 0L184.34-18.7 182.14-24.25 181.34-24.25 181.74-18.4 181.74 0 175.04 0 175.04-34.9 182.84-34.9 191.64-16.2 193.84-10.65 194.64-10.65 194.24-16.5 194.24-34.9 200.94-34.9 200.94 0 193.14 0ZM234.23 0L227.18 0 225.03-8.55 214.33-8.55 212.18 0 205.33 0 215.23-34.9 224.33-34.9 234.23 0ZM223.28-14.3L221.13-22.35 220.03-28.4 219.23-28.4 218.13-22.35 216.08-14.3 223.28-14.3ZM257.63 0L238.63 0 238.63-34.9 245.78-34.9 245.78-6.25 257.63-6.25 257.63 0Z" opacity="1" transform="matrix(1,0,0,1,0,0)" fill="#ffffff" class="wordmark-text-0" data-fill-palette-color="quaternary" id="text-0"></path></svg></g></svg></g></svg></g></svg></g></svg></g><defs></defs></svg><rect width="395.52" height="94.53259011288438" fill="none" stroke="none" visibility="hidden"></rect></g></svg></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
1
xterminal/assets/media/black.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
6
xterminal/assets/media/readme.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Media
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
1
xterminal/assets/media/white-square.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.dev/svgjs" width="1000" height="1000" viewBox="0 0 1000 1000"><rect width="1000" height="1000" fill="#000000"></rect><g transform="matrix(0.7,0,0,0.7,149.57575757575756,415.9384332941345)"><svg viewBox="0 0 396 95" data-background-color="#01ff70" preserveAspectRatio="xMidYMid meet" height="239" width="1000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="tight-bounds" transform="matrix(1,0,0,1,0.2400000000000091,0.23370494355782512)"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><g id="textblocktransform"><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52" id="textblock"><g><svg viewBox="0 0 395.52 94.53259011288438" height="94.53259011288438" width="395.52"><rect width="395.52" height="94.53259011288438" x="0" y="0" opacity="1" fill="#ffffff" data-fill-palette-color="tertiary"></rect><g transform="matrix(1,0,0,1,23.607599999999998,23.607599999999994)"><svg width="348.3048" viewBox="0.75 -34.900001525878906 256.8800048828125 34.900001525878906" height="47.31739011288439" data-palette-color="#01ff70"><path d="M19.8-17.9L29.85 0 21.6 0 15.25-12.5 14.55-12.5 8.4 0 0.75 0 10.6-17.9 1.3-34.9 9.55-34.9 15.15-23.45 15.95-23.45 21.6-34.9 29.25-34.9 19.8-17.9ZM55.7-28.65L47.35-28.65 47.35 0 40.2 0 40.2-28.65 31.85-28.65 31.85-34.9 55.7-34.9 55.7-28.65ZM81.95 0L60.35 0 60.35-34.9 81.95-34.9 81.95-28.65 67.5-28.65 67.5-20.75 79.75-20.75 79.75-14.55 67.5-14.55 67.5-6.25 81.95-6.25 81.95 0ZM94.94-13.2L94.94 0 87.79 0 87.79-34.9 102.89-34.9Q107.59-34.9 110.04-31.95 112.49-29 112.49-24.05L112.49-24.05Q112.49-20.2 111.04-17.55 109.59-14.9 106.74-13.85L106.74-13.85 112.84 0 105.09 0 99.69-13.2 94.94-13.2ZM94.94-19.15L101.99-19.15Q103.44-19.15 104.27-19.88 105.09-20.6 105.09-22.4L105.09-22.4 105.09-25.4Q105.09-27.2 104.27-27.93 103.44-28.65 101.99-28.65L101.99-28.65 94.94-28.65 94.94-19.15ZM141.64 0L141.64-18.2 142.04-24.05 141.24-24.05 139.09-18.5 133.29-5.85 127.54-18.5 125.39-24.05 124.59-24.05 124.99-18.2 124.99 0 118.29 0 118.29-34.9 125.79-34.9 130.54-24.5 133.14-17.05 133.64-17.05 136.24-24.5 140.89-34.9 148.34-34.9 148.34 0 141.64 0ZM169.39 0L154.04 0 154.04-5.65 158.14-5.65 158.14-29.25 154.04-29.25 154.04-34.9 169.39-34.9 169.39-29.25 165.29-29.25 165.29-5.65 169.39-5.65 169.39 0ZM193.14 0L184.34-18.7 182.14-24.25 181.34-24.25 181.74-18.4 181.74 0 175.04 0 175.04-34.9 182.84-34.9 191.64-16.2 193.84-10.65 194.64-10.65 194.24-16.5 194.24-34.9 200.94-34.9 200.94 0 193.14 0ZM234.23 0L227.18 0 225.03-8.55 214.33-8.55 212.18 0 205.33 0 215.23-34.9 224.33-34.9 234.23 0ZM223.28-14.3L221.13-22.35 220.03-28.4 219.23-28.4 218.13-22.35 216.08-14.3 223.28-14.3ZM257.63 0L238.63 0 238.63-34.9 245.78-34.9 245.78-6.25 257.63-6.25 257.63 0Z" opacity="1" transform="matrix(1,0,0,1,0,0)" fill="#000000" class="wordmark-text-0" data-fill-palette-color="quaternary" id="text-0"></path></svg></g></svg></g></svg></g></svg></g></svg></g><defs></defs></svg><rect width="395.52" height="94.53259011288438" fill="none" stroke="none" visibility="hidden"></rect></g></svg></g></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
1
xterminal/assets/media/white.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
3
xterminal/babel.config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"]
|
||||
}
|
||||
31
xterminal/demo/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=0"
|
||||
>
|
||||
<title>Demo | XTerminal</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="demo-shell">
|
||||
<div id="app"></div>
|
||||
<div id="touch-tools" class="tc-touch-tools">
|
||||
<button class="tc-key-btn" data-action="up">↑</button>
|
||||
<button class="tc-key-btn" data-action="down">↓</button>
|
||||
<button class="tc-key-btn" data-action="left">←</button>
|
||||
<button class="tc-key-btn" data-action="right">→</button>
|
||||
<button class="tc-key-btn" data-action="tab">Tab</button>
|
||||
<button class="tc-key-btn" data-action="ls">ls</button>
|
||||
<button class="tc-key-btn" data-action="enter">Enter</button>
|
||||
<button class="tc-key-btn" data-action="ctrlc">Ctrl+C</button>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
844
xterminal/demo/main.js
Normal file
@@ -0,0 +1,844 @@
|
||||
import XTerminal from "../out/index.js";
|
||||
import terminalConfig from "../terminal.config.json";
|
||||
|
||||
// 证书路径由网关服务端使用,前端仅保留常量便于排障对齐。
|
||||
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
|
||||
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
|
||||
|
||||
const term = new XTerminal();
|
||||
term.mount("#app");
|
||||
|
||||
let socket = null;
|
||||
let connected = false;
|
||||
let ansiRemainder = "";
|
||||
let lastFingerprint = "";
|
||||
const pendingInputs = [];
|
||||
let pendingNoticeShown = false;
|
||||
// 将短时间内的 stdout/stderr 分片合并后再渲染,避免 `a` 与 `\b as` 跨帧导致视觉重影。
|
||||
const STREAM_BATCH_MS = 12;
|
||||
let stdoutBatch = "";
|
||||
let stderrBatch = "";
|
||||
let streamFlushTimer = 0;
|
||||
// 回车问题定位开关:开启后会在 Console 输出“回车 -> 发送 -> 回显”链路日志。
|
||||
const DEBUG_ENTER_FLOW = false;
|
||||
// 是否启用底部触控工具条
|
||||
const ENABLE_TOUCH_TOOLS = true;
|
||||
const DEBUG_WINDOW_MS = 5000;
|
||||
let enterSeq = 0;
|
||||
let lastEnterMeta = { id: 0, at: 0, input: "" };
|
||||
|
||||
function setConnected(next) {
|
||||
connected = next;
|
||||
}
|
||||
|
||||
function writeSystem(message) {
|
||||
term.writelnSafe(`[system] ${message}`);
|
||||
}
|
||||
|
||||
function toVisibleText(data) {
|
||||
return String(data || "")
|
||||
.replace(/\u001b/g, "<ESC>")
|
||||
.replace(/\r/g, "<CR>")
|
||||
.replace(/\n/g, "<LF>\n")
|
||||
.replace(/\t/g, "<TAB>")
|
||||
.replace(/\x08/g, "<BS>");
|
||||
}
|
||||
|
||||
function toCharCodes(data, limit = 80) {
|
||||
const list = Array.from(String(data || ""), (ch) => ch.charCodeAt(0));
|
||||
const head = list.slice(0, limit).join(" ");
|
||||
return list.length > limit ? `${head} ... (total=${list.length})` : head;
|
||||
}
|
||||
|
||||
function isWithinEnterDebugWindow() {
|
||||
return Date.now() - lastEnterMeta.at <= DEBUG_WINDOW_MS;
|
||||
}
|
||||
|
||||
function logEnterFlow(stage, data, extra = "") {
|
||||
if (!DEBUG_ENTER_FLOW) return;
|
||||
if (!lastEnterMeta.at) return;
|
||||
if (!isWithinEnterDebugWindow()) return;
|
||||
const delta = Date.now() - lastEnterMeta.at;
|
||||
const text = String(data || "");
|
||||
const suffix = extra ? ` ${extra}` : "";
|
||||
console.log(
|
||||
`[xterm-debug][${stage}] enter#${lastEnterMeta.id} +${delta}ms len=${text.length}${suffix}`
|
||||
);
|
||||
console.log(`[xterm-debug][${stage}] text: ${toVisibleText(text)}`);
|
||||
console.log(`[xterm-debug][${stage}] code: ${toCharCodes(text)}`);
|
||||
}
|
||||
|
||||
function findSelectedServer(config) {
|
||||
const list = Array.isArray(config.servers) ? config.servers : [];
|
||||
if (!list.length) {
|
||||
throw new Error("terminal.config.json 未配置 servers");
|
||||
}
|
||||
if (config.selectedServerId) {
|
||||
const found = list.find((item) => item.id === config.selectedServerId);
|
||||
if (found) return found;
|
||||
}
|
||||
return list[0];
|
||||
}
|
||||
|
||||
function buildCredential(server) {
|
||||
if (server.authType === "password") {
|
||||
if (!server.password) {
|
||||
throw new Error("authType=password 但缺少 password");
|
||||
}
|
||||
return { type: "password", password: server.password };
|
||||
}
|
||||
|
||||
if (server.authType === "privateKey") {
|
||||
if (!server.privateKey) {
|
||||
throw new Error("authType=privateKey 但缺少 privateKey");
|
||||
}
|
||||
return {
|
||||
type: "privateKey",
|
||||
privateKey: server.privateKey,
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
if (server.authType === "certificate") {
|
||||
if (!server.privateKey || !server.certificate) {
|
||||
throw new Error(
|
||||
"authType=certificate 但缺少 privateKey 或 certificate"
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "certificate",
|
||||
privateKey: server.privateKey,
|
||||
certificate: server.certificate,
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`不支持的 authType: ${String(server.authType || "")}`);
|
||||
}
|
||||
|
||||
function buildGatewayEndpoint(config) {
|
||||
const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const rawInput = String(config.gatewayUrl || "").trim();
|
||||
|
||||
if (!rawInput) {
|
||||
throw new Error("缺少 gatewayUrl:为避免误连受保护站点,已禁用默认兜底地址");
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
if (rawInput.startsWith("/")) {
|
||||
// 相对路径仅允许落到当前页面域名,避免误连其它受保护站点。
|
||||
url = new URL(`${pageProtocol}//${window.location.host}${rawInput}`);
|
||||
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) {
|
||||
url = new URL(rawInput);
|
||||
} else {
|
||||
url = new URL(`${pageProtocol}//${rawInput}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`gatewayUrl 无效: ${rawInput} (${error instanceof Error ? error.message : String(error)})`
|
||||
);
|
||||
}
|
||||
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
|
||||
const pathname = url.pathname.replace(/\/+$/, "");
|
||||
url.pathname = pathname.endsWith("/ws/terminal")
|
||||
? pathname
|
||||
: `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
|
||||
|
||||
// 握手阶段禁止携带业务参数,仅保留认证信息(token)。
|
||||
url.search = "";
|
||||
const gatewayToken = String(config.gatewayToken || "").trim();
|
||||
if (gatewayToken) {
|
||||
url.searchParams.set("token", gatewayToken);
|
||||
}
|
||||
url.hash = "";
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function sendFrame(frame) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("网关连接未建立");
|
||||
}
|
||||
socket.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
/**
|
||||
* ANSI SGR 颜色映射(标准 16 色)。
|
||||
* 说明:
|
||||
* - 终端输出若包含 `\x1b[31m` 这类序列,会在这里转换为对应 CSS 颜色;
|
||||
* - 不引入额外依赖,避免增加生产包体与维护复杂度。
|
||||
*/
|
||||
const ANSI_COLOR_TABLE = {
|
||||
30: "#1c1c1c",
|
||||
31: "#d70000",
|
||||
32: "#008700",
|
||||
33: "#875f00",
|
||||
34: "#005faf",
|
||||
35: "#870087",
|
||||
36: "#008787",
|
||||
37: "#bcbcbc",
|
||||
90: "#767676",
|
||||
91: "#ff5f5f",
|
||||
92: "#5fff5f",
|
||||
93: "#ffd75f",
|
||||
94: "#5fafff",
|
||||
95: "#ff5fff",
|
||||
96: "#5fffff",
|
||||
97: "#ffffff"
|
||||
};
|
||||
|
||||
function createAnsiState() {
|
||||
return {
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
inverse: false,
|
||||
fg: null,
|
||||
bg: null
|
||||
};
|
||||
}
|
||||
|
||||
const ansiState = createAnsiState();
|
||||
|
||||
function resetAnsiState() {
|
||||
Object.assign(ansiState, createAnsiState());
|
||||
}
|
||||
|
||||
function parseAnsiColor8Bit(code) {
|
||||
if (code < 0 || code > 255) return null;
|
||||
if (code < 16) {
|
||||
const basic = {
|
||||
0: "#000000",
|
||||
1: "#800000",
|
||||
2: "#008000",
|
||||
3: "#808000",
|
||||
4: "#000080",
|
||||
5: "#800080",
|
||||
6: "#008080",
|
||||
7: "#c0c0c0",
|
||||
8: "#808080",
|
||||
9: "#ff0000",
|
||||
10: "#00ff00",
|
||||
11: "#ffff00",
|
||||
12: "#0000ff",
|
||||
13: "#ff00ff",
|
||||
14: "#00ffff",
|
||||
15: "#ffffff"
|
||||
};
|
||||
return basic[code] || null;
|
||||
}
|
||||
if (code >= 16 && code <= 231) {
|
||||
const offset = code - 16;
|
||||
const r = Math.floor(offset / 36);
|
||||
const g = Math.floor((offset % 36) / 6);
|
||||
const b = offset % 6;
|
||||
const steps = [0, 95, 135, 175, 215, 255];
|
||||
return `rgb(${steps[r]},${steps[g]},${steps[b]})`;
|
||||
}
|
||||
const gray = 8 + (code - 232) * 10;
|
||||
return `rgb(${gray},${gray},${gray})`;
|
||||
}
|
||||
|
||||
function applySgr(params) {
|
||||
const values = params.length ? params : [0];
|
||||
for (let i = 0; i < values.length; i += 1) {
|
||||
const p = values[i];
|
||||
if (p === 0) {
|
||||
resetAnsiState();
|
||||
continue;
|
||||
}
|
||||
if (p === 1) {
|
||||
ansiState.bold = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 3) {
|
||||
ansiState.italic = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 4) {
|
||||
ansiState.underline = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 7) {
|
||||
ansiState.inverse = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 22) {
|
||||
ansiState.bold = false;
|
||||
continue;
|
||||
}
|
||||
if (p === 23) {
|
||||
ansiState.italic = false;
|
||||
continue;
|
||||
}
|
||||
if (p === 24) {
|
||||
ansiState.underline = false;
|
||||
continue;
|
||||
}
|
||||
if (p === 27) {
|
||||
ansiState.inverse = false;
|
||||
continue;
|
||||
}
|
||||
if (p >= 30 && p <= 37) {
|
||||
ansiState.fg = ANSI_COLOR_TABLE[p];
|
||||
continue;
|
||||
}
|
||||
if (p >= 90 && p <= 97) {
|
||||
ansiState.fg = ANSI_COLOR_TABLE[p];
|
||||
continue;
|
||||
}
|
||||
if (p >= 40 && p <= 47) {
|
||||
ansiState.bg = ANSI_COLOR_TABLE[p - 10];
|
||||
continue;
|
||||
}
|
||||
if (p >= 100 && p <= 107) {
|
||||
ansiState.bg = ANSI_COLOR_TABLE[p - 10];
|
||||
continue;
|
||||
}
|
||||
if (p === 39) {
|
||||
ansiState.fg = null;
|
||||
continue;
|
||||
}
|
||||
if (p === 49) {
|
||||
ansiState.bg = null;
|
||||
continue;
|
||||
}
|
||||
// 兼容 256 色与 true color:
|
||||
// - 38;5;n / 48;5;n
|
||||
// - 38;2;r;g;b / 48;2;r;g;b
|
||||
if (p === 38 || p === 48) {
|
||||
const isForeground = p === 38;
|
||||
const mode = values[i + 1];
|
||||
if (mode === 5) {
|
||||
const code = values[i + 2];
|
||||
const color = parseAnsiColor8Bit(code);
|
||||
if (color) {
|
||||
if (isForeground) ansiState.fg = color;
|
||||
else ansiState.bg = color;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (mode === 2) {
|
||||
const r = values[i + 2];
|
||||
const g = values[i + 3];
|
||||
const b = values[i + 4];
|
||||
if (
|
||||
Number.isFinite(r) &&
|
||||
Number.isFinite(g) &&
|
||||
Number.isFinite(b)
|
||||
) {
|
||||
const color = `rgb(${Math.max(0, Math.min(255, r))},${Math.max(0, Math.min(255, g))},${Math.max(0, Math.min(255, b))})`;
|
||||
if (isForeground) ansiState.fg = color;
|
||||
else ansiState.bg = color;
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnsiStyle() {
|
||||
let fg = ansiState.fg;
|
||||
let bg = ansiState.bg;
|
||||
if (ansiState.inverse) {
|
||||
const nextFg = bg || "#111";
|
||||
const nextBg = fg || "#e5e5e5";
|
||||
fg = nextFg;
|
||||
bg = nextBg;
|
||||
}
|
||||
const styles = [];
|
||||
if (fg) styles.push(`color:${fg}`);
|
||||
if (bg) styles.push(`background-color:${bg}`);
|
||||
if (ansiState.bold) styles.push("font-weight:700");
|
||||
if (ansiState.italic) styles.push("font-style:italic");
|
||||
if (ansiState.underline) styles.push("text-decoration:underline");
|
||||
return styles.join(";");
|
||||
}
|
||||
|
||||
function pushTextFragment(parts, text) {
|
||||
if (!text) return;
|
||||
const safeText = XTerminal.escapeHTML(text);
|
||||
const style = buildAnsiStyle();
|
||||
if (style) {
|
||||
parts.push(`<span style="${style}">${safeText}</span>`);
|
||||
} else {
|
||||
parts.push(safeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ANSI 序列转换为 HTML 片段(保留样式,过滤控制指令)。
|
||||
* 说明:
|
||||
* - 支持跨帧残缺序列(通过 ansiRemainder 缓冲);
|
||||
* - 未实现光标定位/清屏等复杂控制,仅做样式渲染与噪声过滤,
|
||||
* 可覆盖当前 SSH 输出里的主流序列。
|
||||
*/
|
||||
function renderAnsiToHtml(chunk) {
|
||||
const input = `${ansiRemainder}${String(chunk || "")}`;
|
||||
const parts = [];
|
||||
let textBuffer = "";
|
||||
let i = 0;
|
||||
ansiRemainder = "";
|
||||
|
||||
const applyBackspace = () => {
|
||||
if (!textBuffer.length) return;
|
||||
textBuffer = textBuffer.slice(0, -1);
|
||||
};
|
||||
|
||||
const applyCarriageReturn = () => {
|
||||
// CR 语义:光标回到“当前行首”。
|
||||
// 这里通过清理 textBuffer 里最后一个换行之后的内容来模拟行首覆盖。
|
||||
const lastNewlineIndex = textBuffer.lastIndexOf("\n");
|
||||
textBuffer =
|
||||
lastNewlineIndex === -1
|
||||
? ""
|
||||
: textBuffer.slice(0, lastNewlineIndex + 1);
|
||||
};
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
if (ch === "\x08") {
|
||||
// 退格:删除前一个可见字符,避免出现类似 `a<BS>as` 的重影。
|
||||
applyBackspace();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\r") {
|
||||
// CRLF 统一折叠为 LF,减少重复换行导致的空白行。
|
||||
if (input[i + 1] === "\n") {
|
||||
textBuffer += "\n";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// 裸 CR 按“回到行首”处理。
|
||||
applyCarriageReturn();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch !== "\u001b") {
|
||||
textBuffer += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
pushTextFragment(parts, textBuffer);
|
||||
textBuffer = "";
|
||||
|
||||
const next = input[i + 1];
|
||||
if (!next) {
|
||||
ansiRemainder = "\u001b";
|
||||
break;
|
||||
}
|
||||
|
||||
// CSI: ESC [ ... final
|
||||
if (next === "[") {
|
||||
let j = i + 2;
|
||||
while (j < input.length) {
|
||||
const code = input.charCodeAt(j);
|
||||
if (code >= 0x40 && code <= 0x7e) break;
|
||||
j += 1;
|
||||
}
|
||||
if (j >= input.length) {
|
||||
ansiRemainder = input.slice(i);
|
||||
break;
|
||||
}
|
||||
|
||||
const finalChar = input[j];
|
||||
const rawParams = input.slice(i + 2, j);
|
||||
// 私有模式(如 ?2004h/?2004l)不参与渲染,直接吞掉。
|
||||
if (finalChar === "m") {
|
||||
const params = rawParams
|
||||
.split(";")
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => Number.parseInt(item, 10))
|
||||
.filter((item) => Number.isFinite(item));
|
||||
applySgr(params);
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// OSC: ESC ] ... BEL 或 ESC \
|
||||
if (next === "]") {
|
||||
let j = i + 2;
|
||||
let foundEnd = false;
|
||||
while (j < input.length) {
|
||||
if (input[j] === "\u0007") {
|
||||
foundEnd = true;
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
if (input[j] === "\u001b" && input[j + 1] === "\\") {
|
||||
foundEnd = true;
|
||||
j += 2;
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if (!foundEnd) {
|
||||
ansiRemainder = input.slice(i);
|
||||
break;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其余 ESC 序列按单字符忽略。
|
||||
i += 2;
|
||||
}
|
||||
|
||||
pushTextFragment(parts, textBuffer);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function sendInputData(input) {
|
||||
logEnterFlow("TX stdin", `${input}\n`, "note=append-newline");
|
||||
sendFrame({
|
||||
type: "stdin",
|
||||
payload: {
|
||||
data: `${input}\n`,
|
||||
meta: { source: "keyboard" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function writeStreamOutput(kind, data) {
|
||||
const html = renderAnsiToHtml(data);
|
||||
if (!html) return;
|
||||
if (kind === "stderr") {
|
||||
term.write(`<span class="error">${html}</span>`);
|
||||
return;
|
||||
}
|
||||
term.write(html);
|
||||
}
|
||||
|
||||
function flushStreamBatch() {
|
||||
if (streamFlushTimer) {
|
||||
clearTimeout(streamFlushTimer);
|
||||
streamFlushTimer = 0;
|
||||
}
|
||||
const stdout = stdoutBatch;
|
||||
const stderr = stderrBatch;
|
||||
stdoutBatch = "";
|
||||
stderrBatch = "";
|
||||
|
||||
if (stdout) {
|
||||
logEnterFlow("RX stdout", stdout, "batched=yes");
|
||||
writeStreamOutput("stdout", stdout);
|
||||
}
|
||||
if (stderr) {
|
||||
logEnterFlow("RX stderr", stderr, "batched=yes");
|
||||
writeStreamOutput("stderr", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamBatchFlush() {
|
||||
if (streamFlushTimer) return;
|
||||
streamFlushTimer = window.setTimeout(() => {
|
||||
streamFlushTimer = 0;
|
||||
flushStreamBatch();
|
||||
}, STREAM_BATCH_MS);
|
||||
}
|
||||
|
||||
function queueStreamFrame(kind, data) {
|
||||
const text = String(data || "");
|
||||
if (!text) return;
|
||||
if (kind === "stderr") {
|
||||
stderrBatch += text;
|
||||
logEnterFlow("RX stderr-chunk", text, `queue=${stderrBatch.length}`);
|
||||
} else {
|
||||
stdoutBatch += text;
|
||||
logEnterFlow("RX stdout-chunk", text, `queue=${stdoutBatch.length}`);
|
||||
}
|
||||
scheduleStreamBatchFlush();
|
||||
}
|
||||
|
||||
function flushPendingInputs() {
|
||||
if (!pendingInputs.length) return;
|
||||
const queue = pendingInputs.splice(0, pendingInputs.length);
|
||||
for (const text of queue) {
|
||||
sendInputData(text);
|
||||
}
|
||||
pendingNoticeShown = false;
|
||||
}
|
||||
|
||||
function handleGatewayFrame(frame) {
|
||||
if (!frame || typeof frame !== "object") return;
|
||||
|
||||
if (frame.type === "stdout" && frame.payload?.data) {
|
||||
queueStreamFrame("stdout", frame.payload.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "stderr" && frame.payload?.data) {
|
||||
queueStreamFrame("stderr", frame.payload.data);
|
||||
return;
|
||||
}
|
||||
|
||||
flushStreamBatch();
|
||||
|
||||
if (frame.type === "error") {
|
||||
setConnected(false);
|
||||
writeSystem(
|
||||
`网关错误 ${frame.payload?.code || "UNKNOWN"}: ${frame.payload?.message || "未知错误"}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "control") {
|
||||
const action = frame.payload?.action;
|
||||
if (action === "connected") {
|
||||
const wasConnected = connected;
|
||||
setConnected(true);
|
||||
if (!wasConnected) {
|
||||
term.resume();
|
||||
term.focus();
|
||||
}
|
||||
const nextFingerprint = frame.payload?.fingerprint
|
||||
? String(frame.payload.fingerprint)
|
||||
: "";
|
||||
if (nextFingerprint && nextFingerprint !== lastFingerprint) {
|
||||
lastFingerprint = nextFingerprint;
|
||||
writeSystem(`SSH 已连接,指纹 ${nextFingerprint}`);
|
||||
} else if (!wasConnected) {
|
||||
writeSystem("SSH 已连接");
|
||||
}
|
||||
if (!wasConnected) {
|
||||
flushPendingInputs();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "ping") {
|
||||
sendFrame({ type: "control", payload: { action: "pong" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "pong") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "disconnect") {
|
||||
setConnected(false);
|
||||
writeSystem(`连接断开: ${frame.payload?.reason || "unknown"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectByConfig(config) {
|
||||
const server = findSelectedServer(config);
|
||||
const endpoint = buildGatewayEndpoint(config);
|
||||
const cols = Number(server.cols) > 0 ? Number(server.cols) : 120;
|
||||
const rows = Number(server.rows) > 0 ? Number(server.rows) : 30;
|
||||
|
||||
// writeSystem(`正在连接网关 ${endpoint}`);
|
||||
// writeSystem(`TLS 证书: ${DEV_CERT_PATH}`);
|
||||
// writeSystem(`TLS 私钥: ${DEV_KEY_PATH}`);
|
||||
|
||||
socket = new WebSocket(endpoint);
|
||||
|
||||
socket.onopen = () => {
|
||||
const initFrame = {
|
||||
type: "init",
|
||||
payload: {
|
||||
host: server.host,
|
||||
port: Number(server.port) || 22,
|
||||
username: server.username,
|
||||
clientSessionKey: `xterminal-${Date.now()}`,
|
||||
credential: buildCredential(server),
|
||||
pty: { cols, rows }
|
||||
}
|
||||
};
|
||||
sendFrame(initFrame);
|
||||
term.pause();
|
||||
// writeSystem(
|
||||
// `已发送 init,目标 ${server.username}@${server.host}:${server.port || 22}`
|
||||
// );
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
handleGatewayFrame(JSON.parse(String(event.data || "{}")));
|
||||
} catch (error) {
|
||||
writeSystem(
|
||||
`帧解析失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
flushStreamBatch();
|
||||
setConnected(false);
|
||||
writeSystem(`WebSocket 关闭 code=${event.code}`);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
flushStreamBatch();
|
||||
setConnected(false);
|
||||
writeSystem("WebSocket 异常");
|
||||
};
|
||||
}
|
||||
|
||||
term.on("data", (input) => {
|
||||
enterSeq += 1;
|
||||
lastEnterMeta = { id: enterSeq, at: Date.now(), input };
|
||||
logEnterFlow(
|
||||
"ENTER local",
|
||||
input,
|
||||
`connected=${connected ? "yes" : "no"}`
|
||||
);
|
||||
|
||||
// 关闭本地回显:xterminal 在 Enter 时会先写一行 `${input}\n`,
|
||||
// SSH 远端通常也会回显同一行,二者叠加会出现“中间空一行/偶发两行空白”。
|
||||
term.clearLast();
|
||||
|
||||
if (input === "clear") {
|
||||
term.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
logEnterFlow("QUEUE local", input, "reason=not-connected");
|
||||
pendingInputs.push(input);
|
||||
if (!pendingNoticeShown) {
|
||||
pendingNoticeShown = true;
|
||||
writeSystem("会话建立中,输入已排队,连接后自动发送");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendInputData(input);
|
||||
} catch (error) {
|
||||
writeSystem(
|
||||
`发送失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
try {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
sendFrame({
|
||||
type: "control",
|
||||
payload: { action: "disconnect", reason: "page_unload" }
|
||||
});
|
||||
socket.close();
|
||||
}
|
||||
} catch {
|
||||
// 忽略页面卸载时的关闭异常。
|
||||
}
|
||||
});
|
||||
|
||||
connectByConfig(terminalConfig);
|
||||
|
||||
// --- 触控工具条逻辑 ---
|
||||
function setupTouchTools() {
|
||||
const touchTools = document.getElementById("touch-tools");
|
||||
if (!touchTools) return;
|
||||
|
||||
if (!ENABLE_TOUCH_TOOLS) {
|
||||
touchTools.classList.add("is-hidden");
|
||||
document.documentElement.style.setProperty('--tc-toolbar-height', '0px');
|
||||
return;
|
||||
}
|
||||
|
||||
// 动态调整位置:当键盘弹起时,保持在软键盘和地址胶囊上方
|
||||
const adjustPosition = () => {
|
||||
if (window.visualViewport) {
|
||||
const bottomInset = Math.max(0, window.innerHeight - (window.visualViewport.height + window.visualViewport.offsetTop));
|
||||
document.documentElement.style.setProperty('--tc-bottom-inset', `${bottomInset}px`);
|
||||
}
|
||||
};
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener("resize", adjustPosition);
|
||||
window.visualViewport.addEventListener("scroll", adjustPosition);
|
||||
}
|
||||
|
||||
// 阻止获得焦点,保留 xterminal 的焦点
|
||||
touchTools.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = e.target.closest(".tc-key-btn");
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
|
||||
const textarea = document.querySelector('textarea[name="xterminal_input"]');
|
||||
|
||||
switch (action) {
|
||||
case "up":
|
||||
case "down":
|
||||
if (textarea) {
|
||||
const keyMap = { up: "ArrowUp", down: "ArrowDown" };
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: keyMap[action] });
|
||||
textarea.dispatchEvent(ev);
|
||||
}
|
||||
break;
|
||||
case "tab":
|
||||
if (textarea) {
|
||||
if (socket && connected) {
|
||||
// 发送当前已经输入的字符串以及一个 Tab 字符给网关交互(为了触发远端 Shell 的真实补全逻辑)
|
||||
sendFrame({ type: "stdin", payload: { data: textarea.value + "\t", meta: { source: "keyboard" } } });
|
||||
// 清空本地输入缓冲。远端 PTY 在收到后会主动将该字符串 + 补全的结果完整 Echo 回显到页面上。
|
||||
textarea.value = "";
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: "Tab" });
|
||||
textarea.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "enter":
|
||||
if (textarea) {
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: "Enter" });
|
||||
textarea.dispatchEvent(ev);
|
||||
}
|
||||
break;
|
||||
case "ls":
|
||||
if (textarea) {
|
||||
textarea.value += "ls";
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// 防抖同步有延迟,等下一个事件循环再触发回车,确保 XTerminal 已取到 'ls' 并更新内部的 data.value
|
||||
setTimeout(() => {
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: "Enter" });
|
||||
textarea.dispatchEvent(ev);
|
||||
}, 10);
|
||||
}
|
||||
break;
|
||||
case "left":
|
||||
case "right":
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
if (action === "left") {
|
||||
textarea.setSelectionRange(Math.max(0, start - 1), Math.max(0, start - 1));
|
||||
} else {
|
||||
textarea.setSelectionRange(Math.min(textarea.value.length, start + 1), Math.min(textarea.value.length, start + 1));
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
break;
|
||||
case "ctrlc":
|
||||
if (socket && connected) {
|
||||
sendFrame({ type: "stdin", payload: { data: "\x03", meta: { source: "keyboard" } } });
|
||||
}
|
||||
if (textarea) {
|
||||
textarea.value = "";
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
setupTouchTools();
|
||||
86
xterminal/demo/styles.css
Normal file
@@ -0,0 +1,86 @@
|
||||
@import "../theme/index.css";
|
||||
|
||||
:root {
|
||||
--tc-toolbar-height: calc(48px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
*, *::after, *::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
#demo-shell {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#app {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tc-touch-tools {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--tc-bottom-inset, 0px);
|
||||
height: var(--tc-toolbar-height);
|
||||
z-index: 50;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
/* Transparent frosty look */
|
||||
background: rgba(30,30,30, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(80,80,80, 0.4);
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tc-touch-tools.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tc-touch-tools::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tc-key-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
background: rgba(50, 50, 50, 0.8);
|
||||
color: #e5e5e5;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tc-key-btn:active {
|
||||
background: rgba(100, 100, 100, 0.8);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.tc-touch-tools:not(.is-hidden) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
33
xterminal/demo/vite.https.config.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
/**
|
||||
* HTTPS 开发配置:
|
||||
* - 供 shell.biboer.cn 外部访问开发页面;
|
||||
* - 使用本机 acme 证书,避免浏览器证书不受信告警。
|
||||
*/
|
||||
const DEV_PUBLIC_HOST = "shell.biboer.cn";
|
||||
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
|
||||
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
|
||||
|
||||
if (!fs.existsSync(DEV_CERT_PATH) || !fs.existsSync(DEV_KEY_PATH)) {
|
||||
throw new Error(
|
||||
`HTTPS 证书文件不存在,请检查路径: cert=${DEV_CERT_PATH}, key=${DEV_KEY_PATH}`
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
// demo 目录作为 Vite 根目录,保持当前示例结构不变。
|
||||
root: "demo",
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
https: {
|
||||
cert: fs.readFileSync(DEV_CERT_PATH),
|
||||
key: fs.readFileSync(DEV_KEY_PATH)
|
||||
},
|
||||
// 允许通过 shell.biboer.cn 访问 dev server。
|
||||
allowedHosts: [DEV_PUBLIC_HOST]
|
||||
}
|
||||
});
|
||||
123
xterminal/docs/.vitepress/config.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
const TITLE = "XTerminal";
|
||||
const DESCRIPTION = "Build web-based command line interfaces";
|
||||
const IMAGE = "/logo.svg";
|
||||
const LINK = "https://henryhale.github.io/xterminal";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
base: "/",
|
||||
|
||||
// metadata
|
||||
lang: "en-US",
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
|
||||
head: [
|
||||
// favicon
|
||||
["link", { rel: "shortcut icon", href: IMAGE }],
|
||||
|
||||
// open graph - facebook
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:url", content: LINK }],
|
||||
["meta", { property: "og:title", content: TITLE }],
|
||||
["meta", { property: "og:description", content: DESCRIPTION }],
|
||||
["meta", { property: "og:image", content: IMAGE }],
|
||||
|
||||
// twitter
|
||||
["meta", { property: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { property: "twitter:url", content: LINK }],
|
||||
["meta", { property: "twitter:title", content: TITLE }],
|
||||
["meta", { property: "twitter:description", content: DESCRIPTION }],
|
||||
["meta", { property: "twitter:image", content: IMAGE }]
|
||||
],
|
||||
|
||||
// theme
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
|
||||
siteTitle: TITLE,
|
||||
logo: IMAGE,
|
||||
|
||||
search: {
|
||||
provider: "local"
|
||||
},
|
||||
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "Guide", link: "/guide/" },
|
||||
{ text: "Demo", link: "/demo/" },
|
||||
{ text: "Showcase", link: "/showcase/" },
|
||||
{
|
||||
text: "About",
|
||||
items: [
|
||||
{ text: "Team", link: "/about/team" },
|
||||
{ text: "History", link: "/about/history" },
|
||||
{ text: "Code of Conduct", link: "/about/coc" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: "Guide",
|
||||
items: [{ text: "Introduction", link: "/guide/" }]
|
||||
},
|
||||
{
|
||||
text: "Getting Started",
|
||||
items: [
|
||||
{ text: "Installation", link: "/guide/installation" },
|
||||
{ text: "Quick Start", link: "/guide/quick-start" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Essentials",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Initialization", link: "/guide/initialization" },
|
||||
{ text: "Output", link: "/guide/output" },
|
||||
{ text: "Events", link: "/guide/events" },
|
||||
{ text: "Input", link: "/guide/prompt" },
|
||||
{ text: "History", link: "/guide/history" },
|
||||
{ text: "Key Bindings", link: "/guide/keybindings" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Advanced",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: "AutoComplete", link: "/guide/autocomplete" },
|
||||
{ text: "Batch Mode", link: "/guide/batchmode" },
|
||||
{ text: "Disposal", link: "/guide/disposal" },
|
||||
{ text: "Theme", link: "/guide/theme" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "API Reference",
|
||||
link: "/api/",
|
||||
items: []
|
||||
}
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/henryhale/xterminal" }
|
||||
],
|
||||
|
||||
editLink: {
|
||||
text: "Edit this page on GitHub",
|
||||
pattern:
|
||||
"https://github.com/henryhale/xterminal/edit/master/docs/:path"
|
||||
},
|
||||
|
||||
footer: {
|
||||
message:
|
||||
'Released under the <a href="https://github.com/henryhale/xterminal/blob/master/LICENSE.txt">MIT License</a>.',
|
||||
copyright: `Copyright © 2023-present, <a href="https://github.com/henryhale">Henry Hale</a>.`
|
||||
},
|
||||
|
||||
outline: {
|
||||
level: [2, 3]
|
||||
}
|
||||
}
|
||||
});
|
||||
24
xterminal/docs/.vitepress/theme/assets/styles.css
Normal file
@@ -0,0 +1,24 @@
|
||||
:root {
|
||||
--vp-c-brand-1: var(--vp-c-green-1);
|
||||
--vp-c-brand-2: var(--vp-c-green-2);
|
||||
--vp-button-brand-bg: var(--vp-c-brand-2);
|
||||
--vp-home-hero-image-background-image: linear-gradient(180deg, var(--vp-home-hero-name-color) 50%,#272b33 50%);
|
||||
--vp-home-hero-image-filter: blur(40px);
|
||||
}
|
||||
|
||||
.VPImage {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.VPImage.image-src {
|
||||
width: min(25vw, 200px);
|
||||
height: min(25vw, 200px);
|
||||
}
|
||||
|
||||
.image-src {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.dark .vp-doc .custom-block a {
|
||||
transition: color 0.25s;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
hidelabel: {
|
||||
type: Boolean,
|
||||
default: () => false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!props.hidelabel" class="bp-preview">
|
||||
<b>Result: </b>
|
||||
</div>
|
||||
<div class="bp-container">
|
||||
<header class="bp-header">
|
||||
<div class="bp-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="bp-title">My First Terminal</div>
|
||||
</header>
|
||||
<main class="bp-main">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
*,
|
||||
*::after,
|
||||
*::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bp-preview {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bp-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-code-block-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.bp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.bp-header > .bp-title {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
font-size: min(15px, calc(1.5vw + 7px));
|
||||
}
|
||||
.bp-header .bp-dots > * {
|
||||
display: inline-block;
|
||||
width: min(0.65rem, calc(2.25vw));
|
||||
height: min(0.65rem, calc(2.25vw));
|
||||
border-radius: 50%;
|
||||
margin-right: min(0.25rem, calc(1vw));
|
||||
}
|
||||
.bp-header .bp-dots > *:first-child {
|
||||
background-color: rgba(255, 91, 82, 1);;
|
||||
}
|
||||
.bp-header .bp-dots > *:nth-child(2) {
|
||||
background-color: rgba(83, 195, 43, 1);
|
||||
}
|
||||
.bp-header .bp-dots > *:last-child {
|
||||
background-color: rgba(230, 192, 41, 1);
|
||||
}
|
||||
|
||||
.bp-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 400px;
|
||||
overflow-y: hidden;
|
||||
padding: 0 min(15px, calc(1.5vw + 5px));
|
||||
background-color: rgba(225,225,225,0.05);
|
||||
}
|
||||
|
||||
html.dark .bp-main {
|
||||
background-color: rgb(41, 40, 40);
|
||||
}
|
||||
html.dark .bp-container {
|
||||
color: rgba(239, 239, 239, 0.85);
|
||||
}
|
||||
</style>
|
||||
73
xterminal/docs/.vitepress/theme/components/ProjectCards.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
const props = defineProps(["projects"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="intro">
|
||||
<h1><b>Showcase</b></h1>
|
||||
<p>Below is a list of projects that are using XTerminal.</p>
|
||||
<p>
|
||||
To add yours, edit this
|
||||
<a
|
||||
href="https://github.com/henryhale/xterminal/blob/master/docs/showcase/index.md"
|
||||
>file</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<div v-for="(p, i) in projects" :key="i" class="row">
|
||||
<div class="grow">
|
||||
<div class="row">
|
||||
<img :src="p.logo" :alt="p.name" width="45" />
|
||||
<span>
|
||||
{{ p.name }}<br />{{ p.desc }}<br />
|
||||
<a :href="p?.author.link">@{{ p?.author.username }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a :href="p.link">Visit →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
.grid > * {
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.row > *:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 40px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
16
xterminal/docs/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
|
||||
// @ts-ignore
|
||||
import BrowserPreview from "./components/BrowserPreview.vue";
|
||||
// @ts-ignore
|
||||
import ProjectCards from "./components/ProjectCards.vue";
|
||||
|
||||
import "./assets/styles.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
enhanceApp(ctx) {
|
||||
ctx.app.component('BrowserPreview', BrowserPreview);
|
||||
ctx.app.component('ProjectCards', ProjectCards);
|
||||
}
|
||||
};
|
||||
128
xterminal/docs/about/coc.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[@devhenryhale](https://twitter.com/devhenryhale) on Twitter.
|
||||
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.
|
||||
5
xterminal/docs/about/history.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# History
|
||||
|
||||
The story behind the development of this project.
|
||||
|
||||
Coming soon...
|
||||
61
xterminal/docs/about/team.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
layout: page
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
VPTeamPage,
|
||||
VPTeamPageTitle,
|
||||
VPTeamMembers,
|
||||
VPTeamPageSection
|
||||
} from 'vitepress/theme';
|
||||
|
||||
const members = [
|
||||
{
|
||||
name: 'Henry Hale',
|
||||
title: 'Creator',
|
||||
avatar: 'https://www.github.com/henryhale.png',
|
||||
org: 'xterminal',
|
||||
orgLink: 'https://github.com/henryhale/xterminal',
|
||||
links: [
|
||||
{
|
||||
icon: 'github',
|
||||
link: 'https://github.com/henryhale'
|
||||
},
|
||||
{
|
||||
icon: 'twitter',
|
||||
link: 'https://twitter.com/devhenryhale'
|
||||
}
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const contributors = [
|
||||
{
|
||||
name: 'Enzo Notario',
|
||||
title: 'Full Stack Developer',
|
||||
avatar: 'https://www.github.com/enzonotario.png',
|
||||
links: [
|
||||
{
|
||||
icon: 'github',
|
||||
link: 'https://github.com/enzonotario'
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<VPTeamPage>
|
||||
<VPTeamPageTitle>
|
||||
<template #title>Our Team</template>
|
||||
<template #lead>Say hello to our awesome team.</template>
|
||||
</VPTeamPageTitle>
|
||||
<VPTeamMembers :members="members" />
|
||||
<VPTeamPageSection>
|
||||
<template #title>Contributors</template>
|
||||
<template #lead>A big shout out to these awesome people</template>
|
||||
<template #members>
|
||||
<VPTeamMembers size="small" :members="contributors" />
|
||||
</template>
|
||||
</VPTeamPageSection>
|
||||
</VPTeamPage>
|
||||
663
xterminal/docs/api/index.md
Normal file
@@ -0,0 +1,663 @@
|
||||
# API Reference
|
||||
|
||||
### Application
|
||||
|
||||
- [XTerminal](#xterminal) extends [XEventEmitter](#xeventemitter)
|
||||
- [XTerminal.version](#xterminal-version)
|
||||
- [XTerminal.XEventEmitter](#xterminal-xeventemitter)
|
||||
- [term.mount()](#term-mount)
|
||||
- [term.dispose()](#term-dispose)
|
||||
|
||||
### Input
|
||||
|
||||
- [term.focus()](#term-focus)
|
||||
- [term.blur()](#term-blur)
|
||||
- [term.pause()](#term-pause)
|
||||
- [term.resume()](#term-resume)
|
||||
- [term.setInput()](#term-setinput)
|
||||
- [term.clearInput()](#term-clearinput)
|
||||
- [term.setCompleter()](#term-setcompleter)
|
||||
|
||||
### Output
|
||||
|
||||
- [XTerminal.escapeHTML()](#xterminal-escapehtml)
|
||||
- [term.write()](#term-write)
|
||||
- [term.writeln()](#term-writeln)
|
||||
- [term.writeSafe()](#term-writesafe)
|
||||
- [term.writelnSafe()](#term-writelnsafe)
|
||||
- [term.clear()](#term-clear)
|
||||
- [term.clearLast()](#term-clearlast)
|
||||
|
||||
### History
|
||||
|
||||
- [term.history](#term-history)
|
||||
- [term.clearHistory()](#term-clearhistory)
|
||||
|
||||
---
|
||||
|
||||
## XEventEmitter
|
||||
|
||||
Event emitter
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
// Event Identifier
|
||||
type IEventName = string | symbol;
|
||||
|
||||
// Event Listener
|
||||
type IEventListener = (...args: unknown[]) => void;
|
||||
|
||||
type IEventHandler = (ev: IEventName, listener: IEventListener) => void;
|
||||
|
||||
interface IEventEmitter {
|
||||
on: IEventHandler;
|
||||
once: IEventHandler;
|
||||
off: IEventHandler;
|
||||
emit(ev: IEventName, ...args: unknown[]): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
**Methods**
|
||||
|
||||
`on`: Appends a event listener to the specified event
|
||||
|
||||
`once`: Appends a **one-time** event listener to the specified event
|
||||
|
||||
`off`: Removes an event listener from the specified event
|
||||
|
||||
`emit`: Triggers an event with arguments if any
|
||||
|
||||
- **See also:** [Guide - Events](../guide/events.md)
|
||||
|
||||
## XTerminal
|
||||
|
||||
Creates a terminal instance
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
// ...
|
||||
}
|
||||
|
||||
interface TerminalOptions {
|
||||
target: HTMLElement | string;
|
||||
}
|
||||
|
||||
class Terminal extends XEventEmitter implements XTerminal {
|
||||
constructor(options: TerminalOptions);
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
The constructor takes one argument, `options` containing the `target` element reference.
|
||||
|
||||
If the `target` element is provided, the [term.mount()](#term-mount) method is called automatically.
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
import XTerminal from 'xterminal';
|
||||
|
||||
const term = new XTerminal({/* options */});
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Creating a Terminal](../guide/initialization.md#creating-your-first-terminal)
|
||||
|
||||
## XTerminal.version
|
||||
|
||||
The version number
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
readonly version: string;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This is a static property that stores the version used. It is important when reporting bugs or issues.
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
import XTerminal from 'xterminal';
|
||||
|
||||
console.log(XTerminal.version);
|
||||
```
|
||||
|
||||
## XTerminal.XEventEmitter
|
||||
|
||||
The event emitter class
|
||||
|
||||
- **Type**
|
||||
|
||||
Same as [XEventEmitter](#xeventemitter).
|
||||
|
||||
- **Details**
|
||||
|
||||
This is a static property (class) that can be used to create independent instances of the event emitter
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
const emitter = new XTerminal.XEventEmitter();
|
||||
```
|
||||
|
||||
## XTerminal.escapeHTML()
|
||||
|
||||
Escapes user input so it can be safely rendered as HTML text.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
static escapeHTML(data?: string): string;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
It preserves all characters by converting them to HTML entities where needed.
|
||||
This is **recommended** for use on user input or any arbitrary data.
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
XTerminal.escapeHTML("<b>hello</b>");
|
||||
// => <b>hello</b>
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Safe Output](../guide/output.md#safe-output)
|
||||
|
||||
## term.mount()
|
||||
|
||||
Mounts the terminal instance structure to the specified DOM element.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
mount(target: HTMLElement | string): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
It takes one argument that must be an actual DOM element or a CSS selector. The element's `innerHTML` is cleared first and then the terminal structure is rendered.
|
||||
|
||||
If no argument is passed, it throws an error and nothing is rendered.
|
||||
|
||||
The `term.mount()` method should only be called once for each terminal instance only if the `target` element option in the [constructor](#xterminal) is not provided.
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
import XTerminal from 'xterminal';
|
||||
|
||||
const term = new XTerminal();
|
||||
term.mount('#app');
|
||||
```
|
||||
|
||||
or mount to an actual DOM element directly:
|
||||
|
||||
```js
|
||||
term.mount(
|
||||
document.getElementById('app')
|
||||
);
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Creating a Terminal](../guide/initialization.md#creating-your-first-terminal)
|
||||
|
||||
|
||||
## term.dispose()
|
||||
|
||||
Gracefully close the terminal instance.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
dispose(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This detaches all event listeners, unmounts the terminal from the DOM and clears the backing functionality of the terminal.
|
||||
|
||||
_The terminal should not be used again once disposed._
|
||||
|
||||
- **Example**
|
||||
|
||||
Dispose on window unload event
|
||||
|
||||
```js
|
||||
window.onunload = () => term.dispose();
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Disposal](../guide/disposal.md)
|
||||
|
||||
## term.focus()
|
||||
|
||||
Focus the terminal input component - ready for input.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
focus(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This method takes no argument. It focuses the underlying input component of the terminal.
|
||||
|
||||
Clicking or tapping in the terminal also invokes the method.
|
||||
|
||||
- **Example**
|
||||
|
||||
After mounting the terminal instance
|
||||
|
||||
```js
|
||||
term.focus();
|
||||
```
|
||||
|
||||
## term.blur()
|
||||
|
||||
Blurs the terminal input component.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
blur(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This method blurs the input component of the terminal.
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
term.blur();
|
||||
```
|
||||
|
||||
## term.pause()
|
||||
|
||||
Deactivate the terminal input component.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
pause(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This method will stop events and input from being written to the terminal but rather input will be buffered.
|
||||
|
||||
**NB:** It is used in conjuction with [term.resume()](#term-resume).
|
||||
|
||||
- **Example**
|
||||
|
||||
Prevent a user from sending input (non-interactive mode)
|
||||
|
||||
```js
|
||||
term.pause();
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Pause & Resume](../guide/prompt.md#pause-resume)
|
||||
|
||||
## term.resume()
|
||||
|
||||
Activate the terminal input component
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
resume(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This method will enable events dispatch and user input if they were deactivate using [term.pause()](#term-pause).
|
||||
|
||||
- **Example**
|
||||
|
||||
Pause the terminal until user input is required
|
||||
|
||||
```js
|
||||
term.pause();
|
||||
// ...
|
||||
// do something
|
||||
// ...
|
||||
term.resume();
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Pause & Resume](../guide/prompt.md#pause-resume)
|
||||
|
||||
## term.setInput()
|
||||
|
||||
Sets the value of the terminal input buffer
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
setInput(value: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This method will set/modify the contents of the input buffer.
|
||||
|
||||
- **Example**
|
||||
|
||||
Presetting some input when the terminal is loaded or resumed
|
||||
|
||||
```js
|
||||
term.setInput("echo 'Hello World'");
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Set & Clear](../guide/prompt.md#set-clear)
|
||||
|
||||
## term.clearInput()
|
||||
|
||||
Clears the terminal input buffer
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
clearInput(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This method will empty/clear the contents of the input buffer.
|
||||
|
||||
- **Example**
|
||||
|
||||
Clearing the input buffer when the terminal [resumes](#term-resume)
|
||||
|
||||
```js
|
||||
term.clearInput();
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Set & Clear](../guide/prompt.md#set-clear)
|
||||
|
||||
## term.setCompleter()
|
||||
|
||||
Sets the autocomplete function that is invoked on Tab key.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
setCompleter(fn: (data: string) => string): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This method take one argument that is a function which takes a string parameter and returns a string.
|
||||
|
||||
The autocomplete functionality depends highly on the completer function `fn`.
|
||||
|
||||
The `fn` parameter should return a better match for the input data string.
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
term.setCompleter(data => {
|
||||
const options = ['.help', '.clear', '.exit'];
|
||||
return options.filter(s => s.startsWith(data))[0] || '';
|
||||
});
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Autocomplete](../guide/autocomplete.md)
|
||||
|
||||
## term.write()
|
||||
|
||||
Write data to the terminal.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
write(data: string | number, callback?: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
`data`: The data to write to the terminal
|
||||
|
||||
`callback`: Optional function invoked on successful write
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
term.write('John: Hello ');
|
||||
term.write('from the Eastside', () => console.log('Done!'));
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||
|
||||
## term.writeln()
|
||||
|
||||
Write data to the terminal, followed by a break line character (\n).
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
writeln(data: string | number, callback?: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
`data`: The data to write to the terminal
|
||||
|
||||
`callback`: Optional function invoked on successful write
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
term.writeln('Hello World!');
|
||||
term.writeln('Welcome!', () => console.log('Done!'));
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||
|
||||
|
||||
## term.writeSafe()
|
||||
|
||||
Securely write data to the terminal.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
writeSafe(data: string | number, callback?: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
`data`: The data to write to the terminal
|
||||
|
||||
`callback`: Optional function invoked on successful write
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
term.writeSafe('<h1>hello</h1>');
|
||||
// <h1>hello</h1>
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||
|
||||
## term.writelnSafe()
|
||||
|
||||
Securely write data to the terminal, followed by a break line character (\n).
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
writelnSafe(data: string | number, callback?: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
`data`: The data to write to the terminal
|
||||
|
||||
`callback`: Optional function invoked on successful write
|
||||
|
||||
- **Example**
|
||||
|
||||
```js
|
||||
term.writelnSafe('<h1>hello</h1>');
|
||||
// <h1>hello</h1><br/>
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Output](../guide/output.md#output)
|
||||
|
||||
## term.clear()
|
||||
|
||||
Clear the entire terminal.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
clear(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
When invoked, the entire terminal output is cleared.
|
||||
|
||||
This method also triggers the `clear` event.
|
||||
|
||||
- **Example**
|
||||
|
||||
Clear on CTRL+L using [keypress](../guide/events.md#default-events) event
|
||||
|
||||
```js
|
||||
term.on('keypress', e => {
|
||||
if (e.key == 'l' && e.ctrlKey) {
|
||||
term.clear();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Example using events](../guide/events.md#example)
|
||||
- **See also:** [Guide - Output](../guide/output.md#clear-screen)
|
||||
|
||||
## term.clearLast()
|
||||
|
||||
Remove the element containing the previous output.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
clearLast(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
This is like the undo for only one write operation.
|
||||
|
||||
- **Example**
|
||||
|
||||
Greet with `Hello World` and replace it with `Hello Dev` after 5 seconds
|
||||
|
||||
```js
|
||||
term.writeln('Hello World!');
|
||||
setTimeout(() => {
|
||||
term.clearLast();
|
||||
term.write('Hello Dev!');
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
- **See also:** [Guide - Output](../guide/output.md#clear-last-output)
|
||||
|
||||
|
||||
## term.history
|
||||
|
||||
Access the history stack.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
history: string[];
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
Manages an array of entries in the history stack.
|
||||
|
||||
- **Example**
|
||||
|
||||
Log the history whenever a new entry is added
|
||||
|
||||
```js
|
||||
term.on('data', () => console.log(term.history));
|
||||
```
|
||||
|
||||
- **See also:** [History](../guide/history.md)
|
||||
|
||||
## term.clearHistory()
|
||||
|
||||
Clear the entire history stack.
|
||||
|
||||
- **Type**
|
||||
|
||||
```ts
|
||||
interface XTerminal {
|
||||
clearHistory(): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **Details**
|
||||
|
||||
It take no argument as its sole role is to clear the entire local history of inputs which are accessible iteratively using `ArrowUp` and `ArrowDown` keys.
|
||||
|
||||
- **Example**
|
||||
|
||||
Clear history on `CTRL+H` using [keypress](../guide/events.md#default-events) event
|
||||
|
||||
```js
|
||||
term.on('keypress', e => {
|
||||
if (e.ctrlKey && e.key == 'h') {
|
||||
term.clearHistory();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- **See also:** [History](../guide/history.md)
|
||||
72
xterminal/docs/demo/index.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Live Demo
|
||||
|
||||
---
|
||||
|
||||
<style>
|
||||
iframe { border: 0 none; }
|
||||
.demo .bp-main { padding: 0; }
|
||||
</style>
|
||||
|
||||
<div class="demo">
|
||||
|
||||
<a href="../demo.html" target="_blank" rel="noreferrer">View fullpage demo</a>
|
||||
|
||||
<browser-preview hidelabel>
|
||||
|
||||
<iframe src="../demo.html" height="400px"></iframe>
|
||||
|
||||
</browser-preview>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
:::details Code
|
||||
|
||||
:::code-group
|
||||
|
||||
```css [styles.css]
|
||||
@import url('https://unpkg.com/xterminal/dist/xterminal.css');
|
||||
|
||||
.error {
|
||||
color: rgb(248, 88, 88);
|
||||
}
|
||||
|
||||
.spinner:after {
|
||||
animation: changeContent 0.8s linear infinite;
|
||||
content: "⠋";
|
||||
}
|
||||
|
||||
@keyframes changeContent {
|
||||
10% { content: "⠙"; }
|
||||
20% { content: "⠹"; }
|
||||
30% { content: "⠸"; }
|
||||
40% { content: "⠼"; }
|
||||
50% { content: "⠴"; }
|
||||
60% { content: "⠦"; }
|
||||
70% { content: "⠧"; }
|
||||
80% { content: "⠇"; }
|
||||
90% { content: "⠏"; }
|
||||
}
|
||||
```
|
||||
|
||||
```html [index.html]
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="https://unpkg.com/xterminal/dist/xterminal.umd.js"></script>
|
||||
|
||||
<script src="createShell.js"></script>
|
||||
<script src="createTerminal.js"></script>
|
||||
|
||||
<script>
|
||||
window.onload = () => createTerminal('#app');
|
||||
</script>
|
||||
```
|
||||
|
||||
<<< @/public/demo.js#terminal{js:line-numbers} [createTerminal.js]
|
||||
|
||||
<<< @/public/demo.js#shell{js:line-numbers} [createShell.js]
|
||||
|
||||
:::
|
||||
54
xterminal/docs/guide/autocomplete.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# AutoComplete
|
||||
|
||||
Tab autocompletion saves a lot of time. It enables the user to type less of what
|
||||
they need thereby being not only interactive but also productive.
|
||||
|
||||
## How does it work?
|
||||
|
||||
The user inputs data in the terminal and presses the `Enter` key, the input is saved in an internal history stack (accessible as an array). When the user types a partial string of an already input string, then presses the `Tab` key, you can loop through the history array for matches and set the most recent one as the input value iteratively.
|
||||
|
||||
:::tip
|
||||
In addition to that, you can also include an external list of strings to use when matching.
|
||||
:::
|
||||
|
||||
## Implementation
|
||||
|
||||
To implement the above methodology, you need the [term.history](../api/index.md#history) which provide an copy of the entries.
|
||||
|
||||
Create and add the basic autocomplete function using [term.setCompleter()](../api/index.md#term-setcompleter).
|
||||
|
||||
```js
|
||||
const matches = [];
|
||||
|
||||
term.setCompleter(str => {
|
||||
if (!matches.length) {
|
||||
matches.push(
|
||||
...term.history.filter(c => c.startsWith(str))
|
||||
);
|
||||
}
|
||||
return matches.pop();
|
||||
});
|
||||
```
|
||||
|
||||
The `matches` array is dynamic as it only keeps strings that start with the partial string `str`. The value on top of the stack, `matches`, is retrieved one at a time until it is empty thereby generating a new list of matched strings.
|
||||
|
||||
At this point, typing a few inputs, followed by the `Enter` key appends the input to our history stack. Typing a partial, followed by the `Tab` key, should do the job.
|
||||
|
||||
## Illustration
|
||||
|
||||
Take the following log as a sample, we can test the tab autocompletion after typing a partial `h`
|
||||
|
||||
<browser-preview>
|
||||
|
||||
[user] $ help
|
||||
help
|
||||
[user] $ hack
|
||||
hack
|
||||
[user] $ ls
|
||||
ls
|
||||
[user] $ history
|
||||
history
|
||||
[user] $ h▊
|
||||
</browser-preview>
|
||||
|
||||
Press `Tab` key just once sets the input value to `history`. Then `hack` after another hit, and finally `help`. Deleting two characters from the input string `help` leaves `he`, pressing the `Tab` key once more only moves on cycle to `help`.
|
||||
65
xterminal/docs/guide/batchmode.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Batch Mode
|
||||
|
||||
This refers to the non interactive mode where the user does not input anything and the terminal receives input from elsewhere.
|
||||
|
||||
## Implementation
|
||||
|
||||
Suppose that you want the users to only have a readonly interface or you would like to take control from the user for awhile, here is how you can achieve that;
|
||||
|
||||
- Pause the terminal input using [term.pause()](./prompt.md#pause-resume)
|
||||
|
||||
- Trigger events manually with arguments using [term.emit()](./events.md#arguments)
|
||||
|
||||
By default, the `data` event is triggered by the user input followed by `Enter` key. You can manually trigger the `data` event to mimic user interaction.
|
||||
|
||||
## Example
|
||||
|
||||
```js
|
||||
// no user interactivity
|
||||
term.pause();
|
||||
|
||||
// executes command
|
||||
function handleInput(command) {
|
||||
switch (command) {
|
||||
case 'install':
|
||||
// ...
|
||||
console.log('installing...');
|
||||
// ...
|
||||
break;
|
||||
case 'commit':
|
||||
// ...
|
||||
console.log('commiting changes...');
|
||||
// ...
|
||||
break;
|
||||
case 'fetch':
|
||||
// ...
|
||||
console.log('fetching state...');
|
||||
// ...
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// register input callback
|
||||
term.on('data', handleInput);
|
||||
|
||||
// demo shell script
|
||||
const script = `
|
||||
# install deps...
|
||||
install
|
||||
|
||||
# save changes...
|
||||
commit
|
||||
|
||||
# load state/resource...
|
||||
fetch
|
||||
`;
|
||||
|
||||
// run it
|
||||
for (const line of script.split(/\n/)) {
|
||||
// skip empty lines and comments
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
// execute line
|
||||
term.emit('data', line);
|
||||
}
|
||||
```
|
||||
58
xterminal/docs/guide/disposal.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Disposal
|
||||
|
||||
Nearly everything that makes up the terminal is disposable. Right from the base class to the events that are dispatched, all these can be disposed.
|
||||
|
||||
## Why dispose?
|
||||
|
||||
The `XTerminal` package is lightweight and on top of that, its efficiency during runtime is greatly considered.
|
||||
|
||||
A disposable object refers to an object that can self detach itself from a parent via a [term.dispose()](../api/index.md#term-dispose) method.
|
||||
|
||||
With reference to the DOM, remember how `document.addEventListener` and `document.removeEventListener` work: one adds an event callback function and the other destroys it from the same event.
|
||||
|
||||
It is nearly the same here.
|
||||
|
||||
The significance of the `dispose` method on an object is not only to manage memory but also ensure that certain functionality only runs at specific times it is needed.
|
||||
|
||||
## Example
|
||||
|
||||
Suppose you have multiple instances of objects with each maintaining it's own state. When the use of the instance is done, we can then dispose its state thereby gracefully saving memory.
|
||||
|
||||
We can implement it like this:
|
||||
|
||||
```js
|
||||
const states = new WeakMap();
|
||||
|
||||
class State {
|
||||
// ...
|
||||
}
|
||||
|
||||
function createState(app) {
|
||||
states.set(app, new State());
|
||||
let disposed = false;
|
||||
return {
|
||||
get state() {
|
||||
return states.get(app);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
states.delete(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Whenever a new state is created using `createState` from the above example, a _disposable state object_ is returned. This implies that when the `dispose` method on that object is invoked, the entire state for that app is deleted.
|
||||
|
||||
## Terminal Disposal
|
||||
|
||||
It is possible that you might want to close off the terminal and end its usage. In this case, you can entirely dispose the terminal using [term.dispose()](../api/index.md#term-dispose). This will clear states of the underlying objects, dispose events, remove the HTML elements and their DOM events.
|
||||
|
||||
This tears down the entire terminal and renders it not usable thereafter.
|
||||
|
||||
**Example:** On window unload event (free up resources)
|
||||
|
||||
```js
|
||||
window.onunload = () => term.dispose();
|
||||
```
|
||||
179
xterminal/docs/guide/events.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Events
|
||||
|
||||
The [XTerminal](../api/index.md#xterminal) class, from which we create an instance, extends an internal [EventEmitter](../api/index.md#xeventemitter) class.
|
||||
This implies that we can handle events the same way the browser does to provide interaction through events like:
|
||||
click, keydown, and so on.
|
||||
|
||||
The underlying [EventEmitter](../api/index.md#xeventemitter) exposes, the `on`, `off`, `once`, and `emit` methods.
|
||||
|
||||
- `on` is used to add an event listener that's executed when the event is triggered
|
||||
- `off` is used to remove an event listener from an event
|
||||
- `once` is used to add a one-time event listener, it is triggered only once and then removed using `off`
|
||||
- `emit` is used to trigger an event
|
||||
|
||||
## Custom Events
|
||||
|
||||
Create a `start` event, and as a matter of providing an example, the reaction to the event is a simply outputting to the terminal.
|
||||
|
||||
```js
|
||||
term.on('start', () => {
|
||||
term.writeln('started...');
|
||||
});
|
||||
```
|
||||
|
||||
When we run the `emit` method passing the `start` event,
|
||||
|
||||
```js
|
||||
term.emit('start');
|
||||
```
|
||||
|
||||
the event handler function is triggered, and we get the terminal log.
|
||||
|
||||
### Arguments
|
||||
|
||||
You can pass multiple arguments to the event handler by passing them as additional arguments to `term.emit()`.
|
||||
|
||||
```js
|
||||
term.on('start', (id) => {
|
||||
term.writeln('started...', id);
|
||||
});
|
||||
|
||||
term.emit('start', 5173);
|
||||
```
|
||||
|
||||
Example with multiple arguments:
|
||||
|
||||
```js
|
||||
term.on('start', (start, end) => {
|
||||
term.writeln(`started from ${start} to ${end}`);
|
||||
});
|
||||
|
||||
term.emit('start', 1, 10);
|
||||
```
|
||||
|
||||
### One-Time Event
|
||||
|
||||
In some cases, it might be necessary to only run an operation once and only once.
|
||||
Any event listener added using the `term.once()` method is executed once and deleted thereafter when the event is triggered.
|
||||
|
||||
```js
|
||||
term.once('load', () => {
|
||||
term.writeln('loaded...');
|
||||
});
|
||||
|
||||
term.emit('load');
|
||||
term.emit('load');
|
||||
```
|
||||
|
||||
The `load` event is triggered and will output to the terminal for the first `term.emit('load')`.
|
||||
The second event trigger does nothing since there is no event listener for the `load` event anymore.
|
||||
|
||||
### Symbols
|
||||
|
||||
Apart from strings, JavaScript symbols can as well be used to create events too.
|
||||
|
||||
```js
|
||||
const START_EVENT = Symbol('start');
|
||||
|
||||
term.on(START_EVENT, () => {
|
||||
term.writeln('started with a symbol...');
|
||||
});
|
||||
|
||||
term.emit(START_EVENT);
|
||||
```
|
||||
|
||||
## Default Events
|
||||
|
||||
Every terminal instance has existing events that are used internally and can be used in your application lifecycle.
|
||||
They include:
|
||||
|
||||
- `data` event - triggered when user inputs data and presses the _Enter_ key
|
||||
- `clear` event - triggered on [term.clear()](../api/index.md#term-clear)
|
||||
- `keypress` event - triggered on every key press except _Tab, Enter, ArrowUp_ and _ArrowDown_
|
||||
- `pause` event - triggered on [term.pause()](./prompt.md#pause-resume), when the terminal input is _deactivated_ or _paused_
|
||||
- `resume` event - triggered on [term.resume()](./prompt.md#pause-resume), when the terminal input is _activated_ or _resumed_
|
||||
|
||||
### Example
|
||||
|
||||
In this example, you are going to capture the user's input and simply write it to the terminal.
|
||||
|
||||
First, add an event listener for the `data` event to capture data, output it and then ask for more input thereafter. Clear the terminal on recieving the input matching to `clear` and as a result, everything is erased from the terminal including the prompt style. Additionally, add a `keypress` event to clear the terminal.
|
||||
|
||||
:::details Code
|
||||
|
||||
```js
|
||||
term.on('data', (input) => {
|
||||
if (input == 'clear') {
|
||||
// clear the terminal
|
||||
term.clear();
|
||||
} else {
|
||||
// do something
|
||||
term.writeln('Data: ' + input);
|
||||
}
|
||||
// write the prompt again
|
||||
term.write("$ ");
|
||||
});
|
||||
|
||||
term.on('clear', () => {
|
||||
term.writeln('You cleared the terminal');
|
||||
});
|
||||
|
||||
term.on('keypress', (ev) => {
|
||||
/**
|
||||
* Checkout the event object
|
||||
*/
|
||||
console.log(ev);
|
||||
|
||||
// on CTRL+L - clear
|
||||
if (ev.key.toLowerCase() == 'l' && ev.ctrlKey) {
|
||||
|
||||
// prevent default behaviour
|
||||
ev.cancel();
|
||||
|
||||
// clear and trigger `clear` event
|
||||
term.clear();
|
||||
}
|
||||
});
|
||||
```
|
||||
:::
|
||||
|
||||
The terminal will be cleared incase the user inputs `clear` or presses the shortcut `CTRL+L` which triggers the `clear` event that logs `You cleared the terminal` on the screen.
|
||||
|
||||
## Limitations
|
||||
|
||||
Multiple events can exist on the same terminal instance which is an advantage. However you should keep caution on when every event is triggered.
|
||||
|
||||
:::warning Nested Emits
|
||||
When an event is triggered, it is added on top of the emitting stack and then the listeners attached to the event are invoked synchronously.
|
||||
If you emit the same event within one of the listeners, it will not work.
|
||||
:::
|
||||
|
||||
**Example:**
|
||||
|
||||
The code sample below will not work as expected.
|
||||
|
||||
```js
|
||||
term.on('run', () => {
|
||||
console.log('running...');
|
||||
// ...
|
||||
term.emit('run');
|
||||
});
|
||||
```
|
||||
|
||||
Triggering the event `run` will log in the console: `running...`, do stuff, and attempt to trigger itself again (possible deadlock).
|
||||
|
||||
**Workaround**
|
||||
|
||||
Trigger the same event in the next event loop.
|
||||
|
||||
```js{4}
|
||||
term.on('run', () => {
|
||||
console.log('running...');
|
||||
// ...
|
||||
setTimeout(() => term.emit('run'), 0);
|
||||
});
|
||||
```
|
||||
|
||||
## Next Step
|
||||
|
||||
You'll learn to everything about the prompt including activation, styling, blur and focus.
|
||||
44
xterminal/docs/guide/history.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# History
|
||||
|
||||
## List
|
||||
|
||||
Whenever the user inputs data in the terminal and presses the `Enter` key, the input is saved in an internal history stack (accessible as an array) via [term.history](../api/index.md#term-history).
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
term.on('data', () => console.log(term.history));
|
||||
```
|
||||
|
||||
The above snippet logs the history list in the console everytime a new entry is added.
|
||||
|
||||
## Changing State
|
||||
|
||||
Sometimes, there might arise a need to swap between application state. You can change the history stack using;
|
||||
|
||||
```js
|
||||
const newHistoryState = [/* ... */];
|
||||
|
||||
term.history = newHistoryState;
|
||||
```
|
||||
|
||||
## Clear History
|
||||
|
||||
You might want to clear the entire history list for some reasons. You can do that using the [term.clearHistory()](../api/index.md#term-clearhistory).
|
||||
|
||||
**Example:**
|
||||
|
||||
Clearing the history on `CTRL+H` using the `keypress` event.
|
||||
|
||||
```js
|
||||
term.on('keypress', (ev) => {
|
||||
if (ev.key.toLowerCase() == 'h' && ev.ctrlKey) {
|
||||
ev.cancel();
|
||||
term.clearHistory();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Next Step
|
||||
|
||||
Enhanced user interaction with key bindings to the terminal
|
||||
40
xterminal/docs/guide/index.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Introduction
|
||||
|
||||
:wave: Hello Dev!
|
||||
|
||||
You're reading the official documentation for [XTerminal](https://github.com/henryhale/xterminal).
|
||||
|
||||
## What is XTerminal?
|
||||
|
||||
**XTerminal** is a simple and perfomant web-based component written in TypeScript that lets you create command-line interfaces for use in the browser.
|
||||
|
||||
It builds on top of standard HTML, CSS, and JavaScript to provide a simple yet powerful model that helps you develop command-line interfaces in the browser.
|
||||
|
||||
**XTerminal** is dependency-free as it requires no dependency to work. Just ship it in and you are just close to setting up your own in-browser CLI.
|
||||
|
||||
::: tip What You Should Know
|
||||
Basic understanding and familiarity with HTML, CSS, and JavaScript is a major preresquite as the documentation assumes you already know.
|
||||
If you are totally new to frontend development, it might not be the best idea to jump right into the library as your first step - grasp the basics and then come back!
|
||||
:::
|
||||
|
||||
Now that you know something about **XTerminal**, here is a brief definition of what it does;
|
||||
|
||||
- In a nutshell, it provides you with a single class from which your can create several terminal instances and mount them onto your webpage.
|
||||
|
||||
## What XTerminal is not?
|
||||
|
||||
::: warning
|
||||
- XTerminal is not an application that can be downloaded and used just like others on your computer.
|
||||
- XTerminal is not a fully fledged terminal application that comprises of all fundamental utility functions. It can't be connected to your terminal nor ssh, it's entirely browser based.
|
||||
:::
|
||||
|
||||
|
||||
## Main Objectives
|
||||
|
||||
The primary goals of this project are:
|
||||
|
||||
- **Simplicity**: Provide a simple and intuitive API that allows developers to quickly create web-based CLIs without the need for extensive setup or dependencies.
|
||||
|
||||
- **Performance**: Prioritize performance optimizations to ensure a smooth and responsive CLI experience, even with large outputs or complex interactions.
|
||||
|
||||
- **Flexibility**: Enable developers to customize and extend the library to meet the specific requirements of their applications. Provide a solid foundation while allowing for easy integration and customization.
|
||||
72
xterminal/docs/guide/initialization.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Creating Your First Terminal
|
||||
|
||||
## Terminal instance
|
||||
|
||||
The `XTerminal` package exports the [XTerminal](../api/index.md#xterminal) class by default for public consumption.
|
||||
To create your own terminal, you need to create an instance of it.
|
||||
|
||||
```js
|
||||
const term = new XTerminal();
|
||||
```
|
||||
|
||||
## Mounting the terminal
|
||||
|
||||
There will be nothing rendered on your page not until the `target` element is provided via the [constructor options](../api/index.md#xterminal) or [term.mount()](../api/index.md#term-mount) method is called.
|
||||
|
||||
**For example:** Let's say our app container is `#app`, then the markup should be;
|
||||
|
||||
```html
|
||||
<div id="app"></div>
|
||||
```
|
||||
|
||||
Initialize the terminal instance with `#app` as the target using one of the following:
|
||||
|
||||
- CSS selector
|
||||
|
||||
```js
|
||||
const term = new XTerminal();
|
||||
term.mount("#app"); // [!code ++]
|
||||
```
|
||||
|
||||
- DOM reference
|
||||
|
||||
```js
|
||||
const term = new XTerminal();
|
||||
term.mount( // [!code ++]
|
||||
document.querySelector("#app") // [!code ++]
|
||||
); // [!code ++]
|
||||
```
|
||||
|
||||
- Options object
|
||||
|
||||
```js
|
||||
const term = new XTerminal(); // [!code --]
|
||||
const term = new XTerminal({ // [!code ++]
|
||||
target: "#app" // or document.querySelector("#app") // [!code ++]
|
||||
}); // [!code ++]
|
||||
```
|
||||
|
||||
Choosing one of the above basically sets up the terminal HTML structure, key bindings added, and then rendered in the target element `#app`.
|
||||
|
||||
## Multiple terminal instances
|
||||
|
||||
You can create several single terminal instances on the same page since the [XTerminal](../api/index.md#xterminal) class creates an independent instance for each of them.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
const term1 = new XTerminal();
|
||||
term1.mount("#app1");
|
||||
|
||||
const term2 = new XTerminal();
|
||||
term2.mount("#app2");
|
||||
|
||||
const term3 = new XTerminal();
|
||||
term3.mount("#app3");
|
||||
```
|
||||
|
||||
Each one of the created instances can be configured to work differently independent of the others.
|
||||
|
||||
## Next Step
|
||||
|
||||
Configure the terminal to suite your application needs.
|
||||
87
xterminal/docs/guide/installation.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Getting Started with XTerminal
|
||||
|
||||
## Installation
|
||||
|
||||
Below are some of the ways `XTerminal` can be installed;
|
||||
|
||||
- [CDN](./installation.md#using-cdn) - (for development with a simple setup)
|
||||
- [NPM](./installation.md#using-npm) - (use this if you are using bundlers or having a build step)
|
||||
|
||||
### Production Builds
|
||||
|
||||
There are two production ready builds:
|
||||
|
||||
- `xterminal.umd.js` - for the browser (no build tools), it's minified
|
||||
- `xterminal.esm.js` - in case of build tools like [Vite](https://vitejs.dev) or Webpack
|
||||
|
||||
## Using NPM
|
||||
|
||||
[NPM](https://npmjs.org) is a popular javascript package manager on which [XTerminal](https://npmjs.org/xterminal) is a public npm package that can be installed by anyone.
|
||||
|
||||
To install it, run one of the following commands;
|
||||
|
||||
::: code-group
|
||||
|
||||
```sh [npm]
|
||||
npm install xterminal
|
||||
```
|
||||
|
||||
```sh [pnpm]
|
||||
pnpm add xterminal
|
||||
```
|
||||
|
||||
```sh [yarn]
|
||||
yarn add xterminal
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
It provides a production build of the latest release from it's [GitHub repository](https://github.com/henryhale/xterminal/).
|
||||
|
||||
**Usage**
|
||||
|
||||
First include the styles in your markup:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="./node_modules/xterminal/dist/xterminal.css">
|
||||
```
|
||||
|
||||
Then import the script into your application (ESM build by default).
|
||||
|
||||
```js
|
||||
import XTerminal from 'xterminal';
|
||||
|
||||
console.log(XTerminal.version);
|
||||
```
|
||||
|
||||
## Using CDN
|
||||
|
||||
You can use any CDN that serves npm packages;
|
||||
|
||||
Install via CDN using one of the following;
|
||||
|
||||
::: code-group
|
||||
|
||||
```html [unpkg]
|
||||
<link rel="stylesheet" href="https://unpkg.com/xterminal/dist/xterminal.css">
|
||||
<script src="https://unpkg.com/xterminal/dist/xterminal.umd.js"></script>
|
||||
```
|
||||
|
||||
```html [jsdelivr]
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterminal/dist/xterminal.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterminal/dist/xterminal.umd.js"></script>
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
Including `XTerminal` javascript file defines a global property `window.XTerminal` on the `window` object. This implies that the `XTerminal` class is globally accessible.
|
||||
|
||||
```js
|
||||
console.log(XTerminal.version);
|
||||
//or
|
||||
console.log(window.XTerminal.version);
|
||||
```
|
||||
|
||||
## Next Step
|
||||
|
||||
Now that you have installed `XTerminal`, it is time to dive into the essential parts.
|
||||
76
xterminal/docs/guide/keybindings.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Key Bindings
|
||||
|
||||
These are shortcuts to enhance the command-line experience. Basically, they are keyboard keys
|
||||
bound to your terminal to provide functionality that would ease the use.
|
||||
|
||||
::: info Note
|
||||
Key bindings to your terminal only work when the terminal is **focused** so that the action triggered is bound to that instance.
|
||||
:::
|
||||
|
||||
## Enter Key
|
||||
|
||||
When the `Enter` key is pressed, the terminal captures the current input value, clears the input, adds value to the history stack and then fire the `data` event passing the input value.
|
||||
|
||||
## ArrowUp Key
|
||||
|
||||
When the `ArrowUp` key is pressed, it continously interates through the previous entries as it sets each entry as the current input.
|
||||
|
||||
It runs through the local history stack while setting the corresponding entry at a certian index as the current terminal input.
|
||||
|
||||
::: info Note
|
||||
No duplicate entries are pushed to the history stack. If the previous input is the same as the current, the latter won't be pushed to the history stack.
|
||||
:::
|
||||
|
||||
All in all, this key goes backwards in history.
|
||||
|
||||
## ArrowDown Key
|
||||
|
||||
In case the `ArrowUp` key is hit several times, to return to the most recent input, the `ArrowDown` key is used.
|
||||
|
||||
The `ArrowDown` key goes foreward in history by setting the most recent entry as the current input.If no previous input exist, the input is set to the previously buffered input, nothing otherwise.
|
||||
|
||||
## Tab key
|
||||
|
||||
Just like in real terminal applications, the `Tab` key provides the autocomplete future for the commands starting with the characters currently present in the terminal input.
|
||||
|
||||
If the terminal input is empty, then there are no characters to match.
|
||||
|
||||
For effective autocompletion, you must set a function that will work out the best matches.
|
||||
This is can be done using the [term.setCompleter()](../api/index.md#term-setcompleter) method on the terminal instance which is discussed on the next page.
|
||||
|
||||
## Custom Key Bindings
|
||||
|
||||
You can create your own key bindings and add the desired functionality for each one of them. Employ the [keypress event](./events.md#default-events) to attach the key bindings.
|
||||
|
||||
**Example:**
|
||||
|
||||
Suppose that you want to capture these shortcuts: `CTRL+S`, `ALT+D`, `CTRL+SHIFT+K`
|
||||
|
||||
```js
|
||||
term.on('keypress', (ev) => {
|
||||
|
||||
const key = ev.key.toLowerCase();
|
||||
|
||||
// CTRL+S
|
||||
if (ev.ctrlKey && key == 's') {
|
||||
// use `ev.cancel()` to prevent default behaviour
|
||||
ev.cancel();
|
||||
// do something
|
||||
}
|
||||
|
||||
// ALT+D
|
||||
if (ev.altKey && key == 'd') {
|
||||
// do something
|
||||
}
|
||||
|
||||
// CTRL+SHIFT+K
|
||||
if (ev.ctrlKey && ev.shiftKey && key == 'k') {
|
||||
// do something
|
||||
}
|
||||
|
||||
});
|
||||
```
|
||||
|
||||
## Next Step
|
||||
|
||||
Enhance a rich interactive command-line interface with tab autocompletion.
|
||||
255
xterminal/docs/guide/output.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Output
|
||||
|
||||
Information maybe output in a number of ways and logged in the terminal instance with the help of [term.write()](../api/index.md#term-write), [term.writeln()](../api/index.md#term-writeln), [term.writeSafe()](../api/index.md#term-writesafe) or [term.writelnSafe()](../api/index.md#term-writelnsafe).
|
||||
|
||||
## Raw Data
|
||||
|
||||
Raw data may include strings and numbers.
|
||||
|
||||
Here is a simple `hello world` example:
|
||||
|
||||
```js
|
||||
term.write("Hello World!");
|
||||
```
|
||||
|
||||
<browser-preview>
|
||||
|
||||
Hello World!▊
|
||||
|
||||
</browser-preview>
|
||||
|
||||
With an optional callback function,
|
||||
|
||||
```js
|
||||
term.write("Hello World!", () => console.log("Done!"));
|
||||
// Done!
|
||||
```
|
||||
|
||||
When dealing with arbitrary (untrustworthy) data like user input or remote resources,
|
||||
use `term.writeSafe` to securely print to the terminal. For more details, see [HTML Strings section](#html-strings).
|
||||
|
||||
```js
|
||||
const someData = "...";
|
||||
|
||||
term.writeSafe(someData);
|
||||
```
|
||||
|
||||
## Escape characters
|
||||
|
||||
Below is a list of available and ready to use escape characters;
|
||||
|
||||
- **`\n` - New line**
|
||||
|
||||
When the `\n` character is encountered in the data to output, it moves the cursor to the next line.
|
||||
The data, after every instance of the `\n` character, is rendereed on a new line.
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
term.write(`Hello World!\n$ `);
|
||||
```
|
||||
|
||||
<browser-preview>
|
||||
|
||||
Hello World!
|
||||
$ ▊
|
||||
|
||||
</browser-preview>
|
||||
|
||||
The same can be achieved using [term.writeln()](../api/index.md#term-writeln) which writes the
|
||||
data passed on the current line, followed by a new line character.
|
||||
|
||||
```js
|
||||
term.writeln(`Hello World!`);
|
||||
term.write("$ ");
|
||||
```
|
||||
|
||||
- **`\t` - Tab**
|
||||
|
||||
The tab character defaults to _four_ (4) space characters.
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
term.writeln(`Hello World!\tYou're Welcome.`);
|
||||
```
|
||||
|
||||
<browser-preview>
|
||||
|
||||
Hello World! You're welcome.
|
||||
▊
|
||||
|
||||
</browser-preview>
|
||||
|
||||
## HTML Strings
|
||||
|
||||
You might want to output some HTML string, here is how you can do it;
|
||||
|
||||
```js
|
||||
term.writeln(`<b>Bold Text</b> - <i>Italics</i>`);
|
||||
```
|
||||
|
||||
<browser-preview>
|
||||
|
||||
<b>Bold Text</b> - <i>Italics</i>
|
||||
<br>▊
|
||||
</browser-preview>
|
||||
|
||||
### Safe Output
|
||||
|
||||
::: warning :warning: SECURITY WARNING
|
||||
- **Use [term.writeSafe()](../api/index.md#term-writesafe) or [term.writelnSafe()](../api/index.md#term-writelnsafe) to safely output arbitrary data to the terminal.**
|
||||
**These methods sanitize the data before being output to the terminal, specifically, before appending it to the DOM.**
|
||||
|
||||
Avoid outputting data from arbitrary sources like user input or remote sources (such as images).
|
||||
Doing so has been proved to allow for malicious attacks like XSS where a user may input some HTML
|
||||
code that could potentially expose user information such as session cookies or even inject malicious scripts on the page.
|
||||
|
||||
For example: `term.writeln("<img onerror=alert('hacked') />")` would run the malicious script and you would see an alert dialog.
|
||||
|
||||
- **RECOMMENDED: Additionally use [XTerminal.escapeHTML()](#xterminal-escapehtml) or external libraries like [DOMPurify](https://www.npmjs.com/package/dompurify) to sanitize arbitrary data before outputting it using `term.write()` or `term.writeln()`. See examples below.**
|
||||
:::
|
||||
|
||||
```js
|
||||
term.writelnSafe(`<b>Bold Text</b> - <i>Italics</i>`);
|
||||
```
|
||||
|
||||
<browser-preview>
|
||||
|
||||
\<b>Bold Text<\/b> - \<i>Italics<\/i>
|
||||
<br>▊
|
||||
</browser-preview>
|
||||
|
||||
Use [XTerminal.escapeHTML()](#xterminal-escapehtml) to sanitize some data before printing it.
|
||||
|
||||
This is helpful when using HTML containers for some other data like showing styled error messages.
|
||||
|
||||
```js
|
||||
const err = `<img onerror="alert('hacked')" />`
|
||||
|
||||
term.writeln(`<p class="error">${XTerminal.escapeHTML(err)}</p>`)
|
||||
```
|
||||
|
||||
<browser-preview>
|
||||
|
||||
\<img onerror="alert('hacked')" \/>
|
||||
<br>▊
|
||||
</browser-preview>
|
||||
|
||||
### Attributes
|
||||
|
||||
To output valid HTML tags with attributes, there must be a **single space** separation between the attributes in every opening tag.
|
||||
|
||||
**For example:** The following won't work as expected
|
||||
|
||||
```js
|
||||
term.writeln('<b class="text-blue">Bold Blue Text</b>');
|
||||
|
||||
term.writeln('<b style="color: dodgerblue ">Bold Blue Text</b>');
|
||||
```
|
||||
|
||||
Here is how it should be done
|
||||
|
||||
```js
|
||||
term.writeln('<b class="text-blue">Bold Blue Text</b>'); // [!code --]
|
||||
term.writeln('<b class="text-blue">Bold Blue Text</b>'); // [!code ++]
|
||||
|
||||
term.writeln('<b style="color: dodgerblue ">Bold Blue Text</b>'); // [!code --]
|
||||
term.writeln('<b style="color: dodgerblue">Bold Blue Text</b>'); // [!code ++]
|
||||
```
|
||||
|
||||
However, multiple spaces are **okay** in between the opening and closing tags.
|
||||
|
||||
**For example:**
|
||||
|
||||
```js
|
||||
term.writeln('<b style="color: dodgerblue">Bold Blue Text</b>');
|
||||
```
|
||||
|
||||
## Clear Screen
|
||||
|
||||
To clear the entire terminal, you can do it programmatically using
|
||||
[term.clear()](../api/index.md#term-clear).
|
||||
|
||||
```js
|
||||
term.clear();
|
||||
```
|
||||
|
||||
## Clear Last Output
|
||||
|
||||
To remove the output for the previous write operation, [term.clearLast()](../api/index.md#term-clearlast) does the job.
|
||||
|
||||
:::info
|
||||
This is like the undo method but for only one output operation.
|
||||
:::
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
term.writeln("Welcome to Space!");
|
||||
term.writeln("Loading...");
|
||||
term.clearLast();
|
||||
```
|
||||
|
||||
<browser-preview>
|
||||
|
||||
Welcome to Space!
|
||||
<br>▊
|
||||
</browser-preview>
|
||||
|
||||
It is useful in several cases for example when implementing a loader.
|
||||
|
||||
:::details Example: 5s loader
|
||||
|
||||
- **Styles**
|
||||
|
||||
```css
|
||||
.spinner:after {
|
||||
animation: changeContent 0.8s linear infinite;
|
||||
content: "⠋";
|
||||
}
|
||||
|
||||
@keyframes changeContent {
|
||||
10% {
|
||||
content: "⠙";
|
||||
}
|
||||
20% {
|
||||
content: "⠹";
|
||||
}
|
||||
30% {
|
||||
content: "⠸";
|
||||
}
|
||||
40% {
|
||||
content: "⠼";
|
||||
}
|
||||
50% {
|
||||
content: "⠴";
|
||||
}
|
||||
60% {
|
||||
content: "⠦";
|
||||
}
|
||||
70% {
|
||||
content: "⠧";
|
||||
}
|
||||
80% {
|
||||
content: "⠇";
|
||||
}
|
||||
90% {
|
||||
content: "⠏";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Script**
|
||||
|
||||
```js
|
||||
term.write('<span class="spinner"></span> Loading...');
|
||||
|
||||
setTimeout(() => term.clearLast(), 5000);
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## Next Step
|
||||
|
||||
Work with terminal events that help you trigger actions on the go.
|
||||
167
xterminal/docs/guide/prompt.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Input
|
||||
|
||||
## Prompt Style
|
||||
|
||||
In most terminal emulators, the prompt style appears before the cursor. This is from the backing shell (e.g bash, zsh, fish) that prints it in the terminal.
|
||||
|
||||
For example:
|
||||
|
||||
<browser-preview hidelabel>
|
||||
|
||||
user@host:~ $ ▊
|
||||
</browser-preview>
|
||||
|
||||
Or even
|
||||
|
||||
<browser-preview hidelabel>
|
||||
|
||||
┌[user@host]
|
||||
└$ ▊
|
||||
</browser-preview>
|
||||
|
||||
In the same way, you can organize the flow of input with a prompt style just before the cursor.
|
||||
|
||||
Suppose the state of our app defines the `username` and `hostname` like so
|
||||
|
||||
```js
|
||||
const state = {
|
||||
username: 'root',
|
||||
hostname: 'web'
|
||||
};
|
||||
```
|
||||
|
||||
Create a function to write our prompt style to the terminal, let it be `ask()`.
|
||||
|
||||
```js
|
||||
function ask() {
|
||||
term.write(`┌[${state.username}@${state.hostname}]\n`);
|
||||
term.write('└$ ');
|
||||
}
|
||||
```
|
||||
|
||||
## Pause & Resume
|
||||
|
||||
Using [term.pause()](../api/index.md#term-pause) will pause or deactivate the terminal from recieving user input whereas [term.resume()](../api/index.md#term-resume) will do the opposite.
|
||||
|
||||
When invoked, [term.pause()](../api/index.md#term-pause) will trigger the [pause](./events.md#default-events) event whereas [term.resume()](../api/index.md#term-resume) will trigger the [resume](./events.md#default-events) event.
|
||||
|
||||
:::warning Note
|
||||
In both cases, _input_ is affected but not the _output_. You can still do write operations even when the input is deactivated.
|
||||
:::
|
||||
|
||||
**Example:** Pause input for five (5) seconds and resume thereafter while listening for events.
|
||||
|
||||
```js
|
||||
const term = new XTerminal();
|
||||
|
||||
term.mount('#app');
|
||||
|
||||
// capture `pause` event
|
||||
term.on("pause", () => term.writeln("pausing..."));
|
||||
|
||||
// capture `resume` event
|
||||
term.on("resume", () => term.writeln("resuming..."));
|
||||
|
||||
term.pause(); // triggers the `pause` event
|
||||
|
||||
setTimeout(() => {
|
||||
term.resume(); // triggers the `resume` event
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
In the five seconds, any keypress won't do anything but we can observe to write operations in order of the events.
|
||||
|
||||
---
|
||||
|
||||
Suppose that you want to do an async operation, it is a good
|
||||
practice to [pause](../api/index.md#term-pause) the terminal for input and [resume](../api/index.md#term-resume) later when the operation is done.
|
||||
|
||||
Whenever the input is recieved, you can pause the terminal and handle the async operation first.
|
||||
|
||||
```js
|
||||
term.on("data", async input => {
|
||||
term.pause();
|
||||
// ...
|
||||
// do async task
|
||||
// ...
|
||||
term.resume();
|
||||
});
|
||||
```
|
||||
|
||||
Everytime you write the prompt style, you may want to be able to capture the next command input from the user. In this case, you can use the
|
||||
[term.resume()](../api/index.md#term-resume) method.
|
||||
|
||||
```js
|
||||
function ask() {
|
||||
term.write(`┌[${state.username}@${state.hostname}]\n`);
|
||||
term.write('└$ ');
|
||||
term.resume(); // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
## Focus & Blur
|
||||
|
||||
You can programmatically focus the terminal input, toggling the keyboard in case of mobile devices, using the [term.focus()](../api/index.md#term-focus) method on the terminal instance.
|
||||
|
||||
Focus the input everytime you ask for input using:
|
||||
|
||||
```js
|
||||
function ask() {
|
||||
term.writeln(`┌[${state.username}@${state.hostname}]`);
|
||||
term.write('└$ ');
|
||||
term.resume();
|
||||
term.focus(); // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
In the same way, you might want to blur the terminal for some reason, say after entering
|
||||
data and pressing the Enter key. You can achieve that using the `data` event and the [term.blur()](../api/index.md#term-blur) method.
|
||||
|
||||
```js
|
||||
term.on('data', () => {
|
||||
term.blur();
|
||||
});
|
||||
```
|
||||
|
||||
## Set & Clear
|
||||
|
||||
Use [term.setInput()](../api/index.md#term-setinput) to simulate user input by modifying the value of the input buffer.
|
||||
This is useful in many scenarios, one of which is to preset some input in the terminal a user can use/modify/execute (so that they don't have to type it themselves).
|
||||
|
||||
Here is an example:
|
||||
|
||||
```js
|
||||
term.setInput("help")
|
||||
```
|
||||
|
||||
<browser-preview hidelabel>
|
||||
|
||||
user@host:~ $ help▊
|
||||
</browser-preview>
|
||||
|
||||
Given that we can now change the input buffer, [term.clearInput()](../api/index.md#term-clearinput) allows for clearing the input buffer, for instance, when you want to discard any user input or contents of the input buffer.
|
||||
|
||||
```js
|
||||
term.clearInput()
|
||||
```
|
||||
|
||||
results into
|
||||
|
||||
<browser-preview hidelabel>
|
||||
|
||||
user@host:~ $ ▊
|
||||
</browser-preview>
|
||||
|
||||
A complete example to illustrate both methods in action: the input is set to `help` and cleared after 2 seconds
|
||||
|
||||
```js
|
||||
term.setInput("help")
|
||||
|
||||
setTimeout(() => {
|
||||
term.clearInput()
|
||||
}, 2000)
|
||||
```
|
||||
|
||||
## Next Step
|
||||
|
||||
Learn about the history stack that stores all inputs
|
||||
96
xterminal/docs/guide/quick-start.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Quick Start
|
||||
|
||||
To get started, you need to [install XTerminal](./installation.md) and ship the `CSS` and `JS` from XTerminal `dist` folder into your application.
|
||||
|
||||
Here is a quick setup using the [CDN installation guide](./installation.md#using-cdn). This setup requires a simple project structure with three essential files; `index.html`, `styles.css` and `main.js` in the same directory.
|
||||
|
||||
::: code-group
|
||||
|
||||
```html :line-numbers [index.html]
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My First Terminal</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/xterminal/dist/xterminal.css">
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="https://unpkg.com/xterminal/dist/xterminal.umd.js"></script>
|
||||
<script src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```css :line-numbers [styles.css]
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden; /* prevent page from scrolling */
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh; /* occur the entire page */
|
||||
}
|
||||
```
|
||||
|
||||
```js :line-numbers [main.js]
|
||||
// create a new terminal instance
|
||||
const term = new XTerminal();
|
||||
|
||||
// mount the terminal to page
|
||||
term.mount('#app');
|
||||
|
||||
// prompt style
|
||||
const promptStyle = '[user] $ ';
|
||||
|
||||
// write prompt style and prepare for input
|
||||
function ask() {
|
||||
term.write(promptStyle);
|
||||
}
|
||||
|
||||
// capture data event
|
||||
term.on('data', input => {
|
||||
if (input == 'clear') {
|
||||
// clear screen
|
||||
term.clear();
|
||||
} else {
|
||||
// do something
|
||||
term.writeln('Data: ' + input);
|
||||
}
|
||||
// then prompt user for more input
|
||||
ask();
|
||||
});
|
||||
|
||||
// print greeting message
|
||||
term.writeln('Hello World!');
|
||||
|
||||
// initiate
|
||||
ask();
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
Open the `index.html` file in your browser.
|
||||
|
||||
<browser-preview>
|
||||
|
||||
Hello World!
|
||||
[user] $ ▊
|
||||
</browser-preview>
|
||||
|
||||
::: tip
|
||||
Follow the rest of the guide to customize, add interactivity, and also learn how to setup your own terminal application.
|
||||
:::
|
||||
|
||||
## Next Step
|
||||
|
||||
If you skipped the [introduction](./index.md), you're strongly recommend reading it before moving on to the rest of the documentation.
|
||||
|
||||
Otherwise continue reading the guide. It takes you through details of the library.
|
||||
145
xterminal/docs/guide/theme.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Theme
|
||||
|
||||
Personalize the terminal interface to your desired appearance.
|
||||
|
||||
The entire structure and appearance of the terminal is defined in the CSS file (`xterminal.css`) included during [installation](./installation.md#installation).
|
||||
|
||||
To customize anything, ensure that the new styles are include after the default styles.
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="path/to/xterminal.css"/>
|
||||
|
||||
<link rel="stylesheet" href="custom-styles.css"/>
|
||||
<!-- OR -->
|
||||
<style>
|
||||
/* custom styles */
|
||||
</style>
|
||||
```
|
||||
|
||||
## Width & Height
|
||||
|
||||
By default, the terminal occupies the full width and height of the parent element `#app`.
|
||||
|
||||
```html
|
||||
<div id="app"></div>
|
||||
```
|
||||
|
||||
To adjust the dimensions, use one of the following:
|
||||
|
||||
- Parent Element
|
||||
|
||||
```css
|
||||
#app {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
```
|
||||
|
||||
- Terminal CSS classname: `xt`
|
||||
|
||||
```css
|
||||
.xt {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
```
|
||||
|
||||
## Background
|
||||
|
||||
The default background depends on the value of the css variable: `--xt-bg`
|
||||
|
||||
**Example:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--xt-bg: black;
|
||||
}
|
||||
```
|
||||
|
||||
## Text Color
|
||||
|
||||
To change the color of text, change `--xt-fg` css variable to the desired color
|
||||
|
||||
**Example:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--xt-fg: lime;
|
||||
}
|
||||
```
|
||||
|
||||
## Font Size
|
||||
|
||||
Adjust the font size using `--xt-font-size`
|
||||
|
||||
**Example:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--xt-font-size: 1.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
## Font Family
|
||||
|
||||
Set your favourite font style using `--xt-font-family`
|
||||
|
||||
**Example:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--xt-font-family: 'Lucida Console', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
Using Font URLS
|
||||
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&display=swap');
|
||||
|
||||
:root {
|
||||
--xt-font-family: 'Fira Code', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
## Padding
|
||||
|
||||
Adjust the padding of the terminal container using `--xt-padding` css variable
|
||||
|
||||
**Example:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
--xt-padding: 10px;
|
||||
}
|
||||
```
|
||||
|
||||
## Line Height
|
||||
|
||||
The default line height depends on the current font size. Adjust the line height using the css classname: `xt-stdout` for the output component
|
||||
|
||||
**Example**
|
||||
|
||||
```css
|
||||
.xt-stdout {
|
||||
line-height: 1.25;
|
||||
}
|
||||
```
|
||||
|
||||
## Blinking Cursor
|
||||
|
||||
To add the blinking effect to the cursor, apply the animation using the class name: `xt-cursor`
|
||||
|
||||
**Example:**
|
||||
|
||||
```css
|
||||
@keyframes blink {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.xt-cursor {
|
||||
animation: blink 1s linear infinite;
|
||||
}
|
||||
```
|
||||
41
xterminal/docs/index.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: XTerminal
|
||||
text: Build Web-based Command-line Interfaces
|
||||
tagline: Simple and perfomant front-end tool for building command-line interfaces in the browser.
|
||||
image:
|
||||
src: /logo.svg
|
||||
alt: XTerminal Logo
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get Started
|
||||
link: /guide/index
|
||||
- theme: alt
|
||||
text: Demo
|
||||
link: /demo/index
|
||||
- theme: alt
|
||||
text: View on GitHub
|
||||
link: https://github.com/henryhale/xterminal
|
||||
|
||||
features:
|
||||
- icon: 🚀
|
||||
title: Performant
|
||||
details: It is lightweight, fast and designed with performance in mind. This library offers efficient handling of user input, resulting in a responsive and smooth CLI experience.
|
||||
- icon: 📦
|
||||
title: Dependency-free
|
||||
details: This library does not rely on any external dependencies to work, keeping your project lean and reducing the potential for conflicts or versioning issues.
|
||||
- icon: 🟩
|
||||
title: Inspired by Node.js
|
||||
details: Drawing inspiration from the readline module in Node.js, this library provides similar functionality for input/output management, enabling a smooth transition for developers familiar with Node.js CLI development.
|
||||
- icon: 🖌️
|
||||
title: Customizable
|
||||
details: Tailor the appearance and behavior of your CLI to match your application's style and requirements. Customize colors, themes, fonts, and other visual aspects to create a unique and cohesive user experience.
|
||||
- icon: 💎
|
||||
title: Native text formatting
|
||||
details: Enhance your CLI output with native web technologies (HTML & CSS) including styling, inline images, hyperlinks, and other visual enhancements.
|
||||
- icon: 🔌
|
||||
title: Event-driven API
|
||||
details: Utilize an event-driven API to handle user input, execute commands, and respond to various events. Hook into events such as input, keypress, and completion to create dynamic and interactive CLI interactions.
|
||||
---
|
||||
56
xterminal/docs/public/demo.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Demo | XTerminal</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/xterminal/dist/xterminal.css">
|
||||
<style>
|
||||
*, *::after, *::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
:root {
|
||||
--xt-fg: lime;
|
||||
--xt-bg: black;
|
||||
}
|
||||
.xt {
|
||||
padding: 10px;
|
||||
line-height: 1.215;
|
||||
}
|
||||
.error {
|
||||
color: rgb(248, 88, 88);
|
||||
}
|
||||
.spinner:after {
|
||||
animation: changeContent 0.8s linear infinite;
|
||||
content: "⠋";
|
||||
}
|
||||
|
||||
@keyframes changeContent {
|
||||
10% { content: "⠙"; }
|
||||
20% { content: "⠹"; }
|
||||
30% { content: "⠸"; }
|
||||
40% { content: "⠼"; }
|
||||
50% { content: "⠴"; }
|
||||
60% { content: "⠦"; }
|
||||
70% { content: "⠧"; }
|
||||
80% { content: "⠇"; }
|
||||
90% { content: "⠏"; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="https://unpkg.com/xterminal/dist/xterminal.umd.js"></script>
|
||||
<script src="./demo.js"></script>
|
||||
<script>
|
||||
window.onload = () => createTerminal('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
149
xterminal/docs/public/demo.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Create a demo shell object
|
||||
*/
|
||||
|
||||
// #region shell
|
||||
function createShell() {
|
||||
|
||||
// Help
|
||||
const manual = `XTerminal : version ${XTerminal.version}
|
||||
|
||||
Type 'help' to see this list
|
||||
|
||||
Commands:
|
||||
|
||||
gh (username) search for github users
|
||||
js [expr] execute a JS expression
|
||||
clear clear the terminal screen
|
||||
help display this list
|
||||
`;
|
||||
|
||||
// Get public github user information
|
||||
async function fetchGitHubUser(username) {
|
||||
return fetch('https://api.github.com/users/' + username)
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
return(
|
||||
'<table border="0">' +
|
||||
'<tr>' +
|
||||
`<td rowspan="3" width="100"><img width="75" src="${res.avatar_url}" alt="${res.name}" /></td>` +
|
||||
`<td>Name</td>` +
|
||||
`<td>${res.name}</td>` +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
`<td>Bio</td>` +
|
||||
`<td>${res.bio}</td>` +
|
||||
'</tr>' +
|
||||
'<tr>' +
|
||||
`<td>Repos</td>` +
|
||||
`<td>${res.public_repos}</td>` +
|
||||
'</tr>' +
|
||||
'</table>'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// evaluate user input from the terminal
|
||||
// -> can be shared among several terminal objects
|
||||
function execute(term, command = '') {
|
||||
let args = command.split(' ');
|
||||
let cmd = args.shift();
|
||||
// GitHub User Search
|
||||
if (cmd == 'gh') {
|
||||
return new Promise(async (res, rej) => {
|
||||
let output, error;
|
||||
term.write('<span class="spinner"></span> Searching...');
|
||||
await fetchGitHubUser(args.join(''))
|
||||
.then(val => output = val)
|
||||
.catch(err => error = ':( Not found!')
|
||||
.finally(() => term.clearLast());
|
||||
if (error) rej(error);
|
||||
else res(output);
|
||||
});
|
||||
}
|
||||
// JavaScript Evaluation
|
||||
else if (cmd == 'js') {
|
||||
return new Promise((res, rej) => {
|
||||
try {
|
||||
let output = eval(args.join(' ')) + '\n';
|
||||
res(output);
|
||||
} catch (error) {
|
||||
rej(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Help menu
|
||||
else if (cmd == 'help') {
|
||||
return Promise.resolve(manual);
|
||||
}
|
||||
// Clear the terminal
|
||||
else if (cmd == 'clear') {
|
||||
term.clear();
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
// Oopps!
|
||||
else {
|
||||
return Promise.reject(`sh: '${cmd}' command not found`);
|
||||
}
|
||||
}
|
||||
|
||||
return { execute };
|
||||
|
||||
}
|
||||
// #endregion shell
|
||||
|
||||
/**
|
||||
* Create a fresh terminal object
|
||||
*/
|
||||
|
||||
// #region terminal
|
||||
function createTerminal(target) {
|
||||
|
||||
const term = new XTerminal({ target });
|
||||
|
||||
const state = {
|
||||
username: "user",
|
||||
hostname: "web"
|
||||
};
|
||||
|
||||
// input evaluator
|
||||
const shell = createShell();
|
||||
|
||||
// print prompt and get ready for user input
|
||||
function promptUser() {
|
||||
term.write(`┌[${state.username}@${state.hostname}]\n`);
|
||||
term.write("└$ ");
|
||||
term.resume();
|
||||
term.focus();
|
||||
}
|
||||
|
||||
// user input handler
|
||||
term.on("data", async input => {
|
||||
|
||||
// deactivate until the execution is done
|
||||
term.pause();
|
||||
|
||||
// execute command
|
||||
await shell.execute(term, input)
|
||||
.then(res => res && term.writeln(res))
|
||||
.catch(err => {
|
||||
if (err) {
|
||||
// sanitize error to prevent xss attacks
|
||||
// error may contain user input or HTML strings (like script tags)
|
||||
term.writeln(`<span class="error">${XTerminal.escapeHTML(err)}</span>\n`)
|
||||
}
|
||||
})
|
||||
.finally(promptUser);
|
||||
});
|
||||
|
||||
// greeting message
|
||||
term.writeln("Welcome to XTerminal (v" + XTerminal.version + ")");
|
||||
term.writeln("Type `help` for available commands\n");
|
||||
|
||||
// kickstart
|
||||
promptUser();
|
||||
|
||||
// remember to free resources
|
||||
window.addEventListener('unload', () => term.dispose());
|
||||
}
|
||||
// #endregion terminal
|
||||
BIN
xterminal/docs/public/logo.ico
Normal file
|
After Width: | Height: | Size: 100 KiB |
9
xterminal/docs/public/logo.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<title>XTerminal Logo</title>
|
||||
<path stroke="#000" d="m-4,-2.5l103.99998,0l0,108l-103.99998,0l0,-108z" stroke-width="0" fill="#444444"/>
|
||||
<rect stroke="#000" height="33" width="16" y="29" x="42" stroke-width="0" fill="#ffffff"/>
|
||||
<rect transform="rotate(-45 23.25 40.25)" stroke="#000" height="18.56497" width="6.17157" y="30.96751" x="20.16421" stroke-width="0" fill="#ffffff"/>
|
||||
<rect transform="rotate(45 22.6036 50.25)" stroke="#000" height="18.75736" width="6" y="40.87132" x="19.60356" stroke-width="0" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 616 B |
43
xterminal/docs/question-solved.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 问题闭环记录(Question Solved)
|
||||
|
||||
本文档记录已确认问题的现象、根因、修复与回归要点,避免重复踩坑。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [2026-03-03:回车后首字母重复显示(`aasdf`)](#enter-leading-dup)
|
||||
2. [回归检查清单](#regression-checklist)
|
||||
|
||||
---
|
||||
|
||||
<a id="enter-leading-dup"></a>
|
||||
## 2026-03-03:回车后首字母重复显示(`aasdf`)
|
||||
|
||||
### 现象
|
||||
- 输入英文命令(如 `asdf`)后回车,提示符行偶发显示为 `aasdf`。
|
||||
- 服务端实际收到与执行仍是 `asdf`(例如返回 `zsh: command not found: asdf`)。
|
||||
|
||||
### 根因
|
||||
- SSH 回显是分片到达的,典型序列为:`"a"`、`"\b as"`、`"df..."`。
|
||||
- 旧逻辑按帧即时渲染,第一帧字符已写入后,下一帧开头的 `\b` 不能回退上一帧字符,造成视觉重复。
|
||||
- 回显中还可能夹杂 `\r\r\n` 与 ANSI 控制序列,若不按终端语义处理会放大错位和空行问题。
|
||||
|
||||
### 修复
|
||||
- 文件:`demo/main.js`
|
||||
- 策略:
|
||||
1. 在 `renderAnsiToHtml` 中补齐控制字符处理:`BS(\x08)`、`CRLF(\r\n)`、裸 `CR(\r)`。
|
||||
2. 增加短窗口流式合帧(`STREAM_BATCH_MS`),将连续 `stdout/stderr` 分片先合并后渲染,保证跨帧 `\b/\r` 能生效。
|
||||
3. 保留本地回显清理(`clearLast()`),避免与远端回显叠加。
|
||||
|
||||
### 验证
|
||||
- 复现 `asdf` 回车,不再出现 `aasdf`。
|
||||
- 调试日志显示发送仍为 `asdf\n`,与服务端执行结果一致。
|
||||
|
||||
---
|
||||
|
||||
<a id="regression-checklist"></a>
|
||||
## 回归检查清单
|
||||
|
||||
1. 英文输入后回车不出现首字母重复。
|
||||
2. `stdout/stderr` 样式渲染正常(ANSI 颜色不回归)。
|
||||
3. 回车后不新增多余空行。
|
||||
4. 本地回显与远端回显不叠加。
|
||||
50
xterminal/docs/showcase/index.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
layout: page
|
||||
---
|
||||
|
||||
<script setup>
|
||||
const showcase = [
|
||||
{
|
||||
name: 'ESJS',
|
||||
desc: 'Lenguaje de programación en Español',
|
||||
link: 'https://es.js.org',
|
||||
logo: 'https://es.js.org/assets/logo.png',
|
||||
author: {
|
||||
username: 'enzonotario',
|
||||
link: 'https://github.com/enzonotario'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'VIX',
|
||||
desc: 'Boostrap your own web based CLI application',
|
||||
link: 'https://henryhale.github.io/vix',
|
||||
logo: 'https://henryhale.github.io/vix/xterminal.png',
|
||||
author: {
|
||||
username: 'henryhale',
|
||||
link: 'https://github.com/henryhale'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'TELEMATE',
|
||||
desc: 'A small scale messaging application for devs',
|
||||
link: 'https://github.com/henryhale/telemate',
|
||||
logo: 'https://github.com/henryhale/telemate/raw/master/client/public/logo.svg',
|
||||
author: {
|
||||
username: 'henryhale',
|
||||
link: 'https://github.com/henryhale'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'DB-ADMIN',
|
||||
desc: 'Interact with your databases in the browser',
|
||||
link: 'https://github.com/henryhale/db-admin',
|
||||
logo: 'https://github.com/henryhale/db-admin/raw/master/client/public/favicon.png',
|
||||
author: {
|
||||
username: 'henryhale',
|
||||
link: 'https://github.com/henryhale'
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<project-cards :projects='showcase'></project-cards>
|
||||
12
xterminal/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{files: ["**/*.{js,mjs,cjs,ts}"]},
|
||||
{languageOptions: { globals: globals.browser }},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
];
|
||||
103
xterminal/package.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "xterminal",
|
||||
"version": "2.2.1",
|
||||
"description": "Build web-based cli interfaces",
|
||||
"main": "./dist/xterminal.umd.js",
|
||||
"types": "./dist/types.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"./LICENSE.txt",
|
||||
"./README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./dist/xterminal.esm.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./dist/xterminal.umd.js"
|
||||
}
|
||||
},
|
||||
"./dist/xterminal.css": "./dist/xterminal.css"
|
||||
},
|
||||
"keywords": [
|
||||
"terminal",
|
||||
"terminal-emulator",
|
||||
"command-line",
|
||||
"cli",
|
||||
"web"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/henryhale/xterminal.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Henry Hale",
|
||||
"url": "https://github.com/henryhale"
|
||||
},
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/henryhale/xterminal/issues"
|
||||
},
|
||||
"homepage": "https://github.com/henryhale/xterminal#readme",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "rm -rf out dist",
|
||||
"build:ts": "tsc -b",
|
||||
"build:js": "rollup -c",
|
||||
"build:css": "postcss theme/index.css > dist/xterminal.css",
|
||||
"build": "pnpm clean && pnpm build:ts && pnpm build:js && pnpm build:css",
|
||||
"dev": "concurrently \"tsc -w\" \"vite demo\"",
|
||||
"dev:https": "concurrently \"tsc -w\" \"vite --config demo/vite.https.config.mjs\"",
|
||||
"lint": "eslint source/ tests/ && prettier source/ --check",
|
||||
"lint:fix": "eslint --fix source/ tests/ && prettier source/ tests/ --write",
|
||||
"prepack": "pnpm build",
|
||||
"test": "vitest",
|
||||
"prepare": "husky",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"release": "release-it"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@rollup/plugin-replace": "^6.0.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"concurrently": "^9.2.1",
|
||||
"cssnano": "^7.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"globals": "^17.3.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"release-it": "^19.2.4",
|
||||
"rollup": "^4.57.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 4 versions",
|
||||
">1%"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.ts": "pnpm lint:fix"
|
||||
}
|
||||
}
|
||||
7815
xterminal/pnpm-lock.yaml
generated
Normal file
8
xterminal/postcss.config.cjs
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('autoprefixer'),
|
||||
require('cssnano')({
|
||||
preset: 'default',
|
||||
}),
|
||||
],
|
||||
};
|
||||
55
xterminal/rollup.config.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { copyFileSync, readFileSync } from 'node:fs';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import babel from '@rollup/plugin-babel';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
|
||||
|
||||
const replacer = replace({
|
||||
preventAssignment: true,
|
||||
values: { __VERSION__: pkg.version },
|
||||
});
|
||||
|
||||
const banner = `/**
|
||||
* XTerminal - v${pkg.version}
|
||||
* @author ${pkg.author.name}
|
||||
* @license ${pkg.license}
|
||||
* @url ${pkg.homepage}
|
||||
*/`;
|
||||
|
||||
function copyToDist() {
|
||||
return {
|
||||
closeBundle: () => {
|
||||
copyFileSync('./LICENSE.txt', './dist/LICENSE.txt');
|
||||
console.log(`[Y]: copied license file to dist/`);
|
||||
copyFileSync('./source/types.d.ts', './dist/types.d.ts');
|
||||
console.log(`[Y]: copied typings file to dist/`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
input: 'out/index.js',
|
||||
output: {
|
||||
name: 'XTerminal',
|
||||
file: 'dist/xterminal.umd.js',
|
||||
format: 'umd',
|
||||
banner,
|
||||
},
|
||||
plugins: [
|
||||
replacer,
|
||||
babel({ babelHelpers: 'bundled' }),
|
||||
terser(),
|
||||
],
|
||||
},
|
||||
{
|
||||
input: 'out/index.js',
|
||||
output: {
|
||||
file: 'dist/xterminal.esm.js',
|
||||
format: 'esm',
|
||||
banner,
|
||||
},
|
||||
plugins: [replacer, terser(), copyToDist()],
|
||||
},
|
||||
]
|
||||
29
xterminal/source/base/debouncer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { isFunction } from "../helpers";
|
||||
|
||||
/**
|
||||
* Debouncing functions
|
||||
*
|
||||
* https://www.freecodecamp.org/news/javascript-debounce-example
|
||||
*
|
||||
* https://programmingwithmosh.com/javascript/javascript/throttle-and-debounce-patterns/
|
||||
*/
|
||||
|
||||
const DEBOUNCE_TIME = 0;
|
||||
|
||||
export function bounce(fn: TimerHandler, ...args: unknown[]): number {
|
||||
return setTimeout(fn, DEBOUNCE_TIME, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay the execution of the function until a pause happens
|
||||
* @param fn The function to execute
|
||||
* @returns A function that limits the intermediate calls to `fn`
|
||||
*/
|
||||
export function debounce(fn: TimerHandler) {
|
||||
let flag: number;
|
||||
return (...args: unknown[]) => {
|
||||
if (!isFunction(fn)) return;
|
||||
clearTimeout(flag);
|
||||
flag = bounce(fn, ...args);
|
||||
};
|
||||
}
|
||||
42
xterminal/source/base/disposable.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { IDisposable } from "../types";
|
||||
|
||||
/**
|
||||
* Disposables
|
||||
*
|
||||
* https://blog.hediet.de/post/the_disposable_pattern_in_typescript
|
||||
*
|
||||
* https://github.com/xtermjs/xterm.js/blob/master/src/common/Lifecycle.ts
|
||||
*/
|
||||
|
||||
export default class Disposable implements IDisposable {
|
||||
// private store for states
|
||||
#disposables: IDisposable[];
|
||||
|
||||
public isDisposed: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isDisposed = false;
|
||||
this.#disposables = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a disposable object
|
||||
* @param d The disposable to register
|
||||
*/
|
||||
public register<T extends IDisposable>(d: T): void {
|
||||
if (this.isDisposed) {
|
||||
d?.dispose();
|
||||
} else {
|
||||
this.#disposables.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the object, triggering the `dispose` method on all registered disposables
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.isDisposed) return;
|
||||
this.isDisposed = true;
|
||||
this.#disposables.forEach((d) => d?.dispose());
|
||||
}
|
||||
}
|
||||
13
xterminal/source/base/error.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class XError extends Error {
|
||||
constructor(message: string) {
|
||||
message = "[x] " + message;
|
||||
super(message);
|
||||
this.name = "XTerminalError";
|
||||
}
|
||||
}
|
||||
|
||||
export const TARGET_INVALID_ERR =
|
||||
"mount: A parent HTMLElement (target) is required";
|
||||
|
||||
export const TARGET_NOT_CONNECTED_ERR =
|
||||
"'mount' was called on an HTMLElement (target) that is not attached to DOM.";
|
||||
70
xterminal/source/base/reactivity.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { isFunction } from "../helpers";
|
||||
import type { IDisposable } from "../types";
|
||||
|
||||
/**
|
||||
* Reactivity
|
||||
* => https://github.com/henryhale/reactivity
|
||||
*/
|
||||
|
||||
/**
|
||||
* Effect - callback triggered when a reactive value changes
|
||||
*/
|
||||
export type IEffect = () => void;
|
||||
|
||||
/**
|
||||
* Reactive value -> all effects are disposable
|
||||
*/
|
||||
export interface IReactive<T> extends IDisposable {
|
||||
value: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global observer
|
||||
*/
|
||||
let observer: IEffect | null;
|
||||
|
||||
/**
|
||||
* Function that creates a disposable reactive object from a primitive value
|
||||
* @param value The primitive value (initial)
|
||||
* @returns Reactive object
|
||||
*/
|
||||
export function ref<T>(value: T): IReactive<T> {
|
||||
const observers = new Set<IEffect>();
|
||||
let disposed = false;
|
||||
return {
|
||||
get value() {
|
||||
// allow subscriptons only when the object is not disposed
|
||||
if (!disposed && isFunction(observer)) {
|
||||
observers.add(observer);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set value(newValue) {
|
||||
value = newValue;
|
||||
// alert subscribers only when the object is not disposed
|
||||
if (!disposed) {
|
||||
observers.forEach((o) => o.call(undefined));
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
// remove all subscriptions
|
||||
observers.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that opens a subscription to reactive values
|
||||
* @param fn The subscription (function) to be made
|
||||
*/
|
||||
export function createEffect(fn: IEffect): void {
|
||||
if (!isFunction(fn)) return;
|
||||
observer = fn;
|
||||
try {
|
||||
fn.call(undefined);
|
||||
} finally {
|
||||
observer = null;
|
||||
}
|
||||
}
|
||||
77
xterminal/source/emitter/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import Disposable from "../base/disposable";
|
||||
import type {
|
||||
XEventEmitter as IEventEmitter,
|
||||
IEventListener,
|
||||
IEventName
|
||||
} from "../types";
|
||||
import type { IEmitterState } from "./interface";
|
||||
|
||||
/**
|
||||
* EventEmitter class
|
||||
*
|
||||
* https://nodejs.dev/api/events.html
|
||||
*
|
||||
* https://nodejs.dev/en/learn/the-nodejs-event-emitter/
|
||||
*/
|
||||
export default class XEventEmitter extends Disposable implements IEventEmitter {
|
||||
// private store for states
|
||||
#state: IEmitterState;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#state = {
|
||||
stack: [],
|
||||
store: new Map()
|
||||
};
|
||||
this.register({ dispose: () => this.#state.store.clear() });
|
||||
}
|
||||
|
||||
public on(eventName: IEventName, listener: IEventListener): void {
|
||||
const store = this.#state.store;
|
||||
if (store.has(eventName)) {
|
||||
store.get(eventName)?.add(listener);
|
||||
} else {
|
||||
store.set(eventName, new Set([listener]));
|
||||
}
|
||||
}
|
||||
|
||||
public once(eventName: IEventName, listener: IEventListener): void {
|
||||
const evlistener = (...args: unknown[]) => {
|
||||
listener.call(undefined, ...args);
|
||||
this.off(eventName, evlistener);
|
||||
};
|
||||
const store = this.#state.store;
|
||||
if (store.has(eventName)) {
|
||||
store.get(eventName)?.add(evlistener);
|
||||
} else {
|
||||
store.set(eventName, new Set([evlistener]));
|
||||
}
|
||||
}
|
||||
|
||||
public off(eventName: IEventName, listener: IEventListener): void {
|
||||
const all = this.#state.store.get(eventName);
|
||||
if (all) {
|
||||
for (const fn of all) {
|
||||
if (fn === listener) {
|
||||
all.delete(listener);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public emit(eventName: IEventName, ...args: unknown[]): void {
|
||||
if (this.isDisposed) return;
|
||||
const stack = this.#state.stack;
|
||||
if (stack.includes(eventName)) return;
|
||||
const listeners = this.#state.store.get(eventName);
|
||||
if (listeners) {
|
||||
stack.push(eventName);
|
||||
for (const fn of listeners) {
|
||||
fn.call(undefined, ...args);
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
9
xterminal/source/emitter/interface.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { IEventName, IEventListener } from "../types";
|
||||
|
||||
/**
|
||||
* State of the Event Emitter
|
||||
*/
|
||||
export type IEmitterState = {
|
||||
store: Map<IEventName, Set<IEventListener>>;
|
||||
stack: IEventName[];
|
||||
};
|
||||
28
xterminal/source/helpers.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const isArray = Array.isArray;
|
||||
|
||||
export function isObject(val: unknown): val is object {
|
||||
return typeof val === "object" && val !== null;
|
||||
}
|
||||
|
||||
export function isFunction(val: unknown): val is (...a: unknown[]) => unknown {
|
||||
return typeof val === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecting a mobile browser
|
||||
* https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser
|
||||
*/
|
||||
|
||||
// TODO: compatibility check
|
||||
export function isMobile(): boolean {
|
||||
const _window = window || {};
|
||||
const _navigator = navigator || {};
|
||||
// Check for touch support
|
||||
if ("ontouchstart" in _window || _navigator.maxTouchPoints) {
|
||||
// Check for mobile user agent
|
||||
if (/Mobi/.test(_navigator.userAgent)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
46
xterminal/source/history/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { isArray } from "../helpers";
|
||||
import type { IHistory } from "./interface";
|
||||
|
||||
/**
|
||||
* History stack
|
||||
*/
|
||||
export default class XHistory implements IHistory {
|
||||
private store;
|
||||
private ptr;
|
||||
|
||||
constructor(initialState: string[] = []) {
|
||||
this.store = isArray(initialState) ? initialState : [];
|
||||
this.ptr = -1;
|
||||
}
|
||||
|
||||
private get size(): number {
|
||||
return this.store.length;
|
||||
}
|
||||
|
||||
public get list(): string[] {
|
||||
return [].slice.call(this.store).reverse();
|
||||
}
|
||||
|
||||
add(input: string): void {
|
||||
if (input && input !== this.store[0]) {
|
||||
this.store.unshift(input);
|
||||
}
|
||||
this.ptr = -1;
|
||||
}
|
||||
|
||||
previous(): string {
|
||||
this.ptr++;
|
||||
if (this.ptr >= this.size) this.ptr = this.size - 1;
|
||||
return this.store[this.ptr] || "";
|
||||
}
|
||||
|
||||
next(): string {
|
||||
this.ptr--;
|
||||
if (this.ptr <= -1) this.ptr = -1;
|
||||
return this.store[this.ptr] || "";
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.splice(0);
|
||||
}
|
||||
}
|
||||
33
xterminal/source/history/interface.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Interface: History
|
||||
*/
|
||||
export interface IHistory {
|
||||
/**
|
||||
* Array containing a copy of entries
|
||||
*/
|
||||
list: string[];
|
||||
|
||||
/**
|
||||
* Getter: access one entry at a time (forward)
|
||||
*/
|
||||
next(): string;
|
||||
|
||||
/**
|
||||
* Getter: access one entry at a time (backwards)
|
||||
*/
|
||||
previous(): string;
|
||||
|
||||
/**
|
||||
* Insert an input string to the stack
|
||||
*
|
||||
* Returns `false` if the `input` is the same as the previous entry
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
add(input: string): void;
|
||||
|
||||
/**
|
||||
* Empty the stack of entries
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
139
xterminal/source/index.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import XEventEmitter from "./emitter/index";
|
||||
import { isFunction } from "./helpers";
|
||||
import type { ITerminalOptions } from "./types";
|
||||
import type { ITerminalState } from "./interface";
|
||||
import { CLEAR_EVENT, PAUSE_EVENT, RESUME_EVENT, setup } from "./instance";
|
||||
import {
|
||||
TARGET_INVALID_ERR,
|
||||
TARGET_NOT_CONNECTED_ERR,
|
||||
XError
|
||||
} from "./base/error";
|
||||
import { escapeHTML } from "./output/index";
|
||||
import { NEWLINE } from "./renderer/dom";
|
||||
|
||||
export default class XTerminal extends XEventEmitter {
|
||||
#state!: ITerminalState;
|
||||
|
||||
public isMounted: boolean;
|
||||
|
||||
constructor(options?: ITerminalOptions) {
|
||||
super();
|
||||
this.isMounted = false;
|
||||
if (options && options?.target) {
|
||||
this.mount(options.target);
|
||||
}
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
this.#state.input.focus();
|
||||
}
|
||||
|
||||
public blur(): void {
|
||||
this.#state.input.blur();
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this.#state.input.pause();
|
||||
this.emit(PAUSE_EVENT);
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
this.#state.input.resume();
|
||||
this.emit(RESUME_EVENT);
|
||||
}
|
||||
|
||||
public setInput(value: string): void {
|
||||
this.#state.input.setValue(value);
|
||||
}
|
||||
|
||||
public clearInput(): void {
|
||||
this.#state.input.clear();
|
||||
}
|
||||
|
||||
public write(data: string | number, callback?: () => void): void {
|
||||
this.#state.output.write("" + data, callback);
|
||||
}
|
||||
|
||||
public writeln(data: string | number, callback?: () => void): void {
|
||||
this.#state.output.write("" + data + NEWLINE, callback);
|
||||
}
|
||||
|
||||
public writeSafe(data: string | number, callback?: () => void): void {
|
||||
this.#state.output.writeSafe("" + data, callback);
|
||||
}
|
||||
|
||||
public writelnSafe(data: string | number, callback?: () => void): void {
|
||||
this.#state.output.writeSafe("" + data + NEWLINE, callback);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.#state.output.clear();
|
||||
this.emit(CLEAR_EVENT);
|
||||
}
|
||||
|
||||
public clearLast(): void {
|
||||
this.#state.output.clearLast();
|
||||
}
|
||||
|
||||
public get history(): string[] {
|
||||
return this.#state.history.list || [];
|
||||
}
|
||||
|
||||
public set history(newState: string[]) {
|
||||
newState.forEach((item) => this.#state.history.add(item));
|
||||
}
|
||||
|
||||
public clearHistory(): void {
|
||||
this.#state.history.clear();
|
||||
}
|
||||
|
||||
public setCompleter(fn: (data: string) => string): void {
|
||||
if (!isFunction(fn)) return;
|
||||
this.#state.completer = fn;
|
||||
}
|
||||
|
||||
public mount(target: HTMLElement | string): void {
|
||||
if (this.isMounted) return;
|
||||
|
||||
if (target && typeof target === "string") {
|
||||
target = document.querySelector<HTMLElement>(target) as HTMLElement;
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new XError(TARGET_INVALID_ERR);
|
||||
}
|
||||
|
||||
if (!target.isConnected && console) {
|
||||
console.warn(TARGET_NOT_CONNECTED_ERR);
|
||||
}
|
||||
|
||||
target.innerHTML = "";
|
||||
|
||||
this.#state = setup(this, target);
|
||||
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
super.dispose();
|
||||
const state = this.#state;
|
||||
state.history.clear();
|
||||
state.completer = undefined;
|
||||
state.input.dispose();
|
||||
const box = state.output.el.parentNode;
|
||||
box?.parentNode?.removeChild(box);
|
||||
this.isMounted = false;
|
||||
}
|
||||
|
||||
static get version() {
|
||||
return "__VERSION__";
|
||||
}
|
||||
|
||||
static get XEventEmitter() {
|
||||
return XEventEmitter;
|
||||
}
|
||||
|
||||
static escapeHTML(data?: string): string {
|
||||
return escapeHTML(data);
|
||||
}
|
||||
}
|
||||
29
xterminal/source/input/cursor.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IReactive } from "../base/reactivity";
|
||||
|
||||
/**
|
||||
* Get current cursor position in a textbox
|
||||
* https://stackoverflow.com/16105482/get-current-cursor-position-in-a-textbox
|
||||
*/
|
||||
|
||||
// TODO: compatibility check
|
||||
function getCursorPosition<T extends HTMLElement>(field: T): number {
|
||||
if ("selectionStart" in field) {
|
||||
return field.selectionStart as number;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function updateCursor(
|
||||
el: HTMLTextAreaElement,
|
||||
data: IReactive<string>,
|
||||
ptr: IReactive<number>
|
||||
) {
|
||||
let pos = getCursorPosition(el);
|
||||
const len = data.value.length;
|
||||
if (pos > len) {
|
||||
pos = len;
|
||||
} else if (pos < 0) {
|
||||
pos = 0;
|
||||
}
|
||||
ptr.value = pos;
|
||||
}
|
||||
169
xterminal/source/input/index.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import Disposable from "../base/disposable";
|
||||
import type { IReactive } from "../base/reactivity";
|
||||
import { isFunction } from "../helpers";
|
||||
import { inputBuild } from "../renderer/index";
|
||||
import { SPACE, THEME, h } from "../renderer/dom";
|
||||
import { ENTER_KEY, addEvent } from "../renderer/events";
|
||||
import { updateCursor } from "./cursor";
|
||||
import { debounce } from "../base/debouncer";
|
||||
import { createEffect, ref } from "../base/reactivity";
|
||||
import type { IOutputInterface } from "../output/interface";
|
||||
import type { IInputInterface } from "./interface";
|
||||
import type { IKeyPress } from "../types";
|
||||
|
||||
/**
|
||||
* Input Component
|
||||
*/
|
||||
export default class XInputComponent
|
||||
extends Disposable
|
||||
implements IInputInterface
|
||||
{
|
||||
public readonly el: HTMLTextAreaElement;
|
||||
private buffer: string;
|
||||
private data: IReactive<string>;
|
||||
private ptr: IReactive<number>;
|
||||
private isActive: IReactive<boolean>;
|
||||
public showInput: IReactive<boolean>;
|
||||
public isFocused: IReactive<boolean>;
|
||||
public onkeypress?: ((ev: IKeyPress) => void) | undefined;
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
super();
|
||||
|
||||
this.el = inputBuild(target);
|
||||
this.buffer = "";
|
||||
this.data = ref<string>("");
|
||||
this.ptr = ref<number>(0);
|
||||
this.isActive = ref<boolean>(true);
|
||||
this.showInput = ref<boolean>(true);
|
||||
this.isFocused = ref<boolean>(false);
|
||||
|
||||
// All reactive values are disposable
|
||||
this.register(this.data);
|
||||
this.register(this.ptr);
|
||||
this.register(this.isActive);
|
||||
this.register(this.showInput);
|
||||
this.register(this.isFocused);
|
||||
|
||||
const cursorUpdater = () => updateCursor(this.el, this.data, this.ptr);
|
||||
|
||||
const cursorHandler = debounce(cursorUpdater);
|
||||
|
||||
const inputHandler = debounce(() => {
|
||||
this.data.value = this.buffer = this.el.value;
|
||||
});
|
||||
|
||||
createEffect(cursorUpdater);
|
||||
|
||||
this.register(
|
||||
addEvent(this.el, "blur", () => (this.isFocused.value = false))
|
||||
);
|
||||
|
||||
this.register(
|
||||
addEvent(
|
||||
this.el,
|
||||
"focus",
|
||||
() => (this.isFocused.value = true),
|
||||
false
|
||||
)
|
||||
);
|
||||
|
||||
this.register(
|
||||
addEvent(
|
||||
this.el,
|
||||
"keyup",
|
||||
() => this.isActive.value && cursorHandler(),
|
||||
false
|
||||
)
|
||||
);
|
||||
|
||||
this.register(
|
||||
addEvent(this.el, "input", () => {
|
||||
inputHandler();
|
||||
cursorHandler();
|
||||
})
|
||||
);
|
||||
|
||||
this.register(
|
||||
addEvent(this.el, "keydown", (ev: KeyboardEvent) => {
|
||||
ev.stopImmediatePropagation();
|
||||
const value = this.data.value;
|
||||
if (ev.key === ENTER_KEY) {
|
||||
ev.preventDefault();
|
||||
if (this.el) this.el.value = "";
|
||||
this.data.value = "";
|
||||
this.buffer = "";
|
||||
this.showInput.value = true;
|
||||
}
|
||||
if (!this.isActive.value) return;
|
||||
if (!isFunction(this.onkeypress)) return;
|
||||
this.onkeypress({
|
||||
key: ev.key,
|
||||
altKey: ev.altKey,
|
||||
ctrlKey: ev.ctrlKey,
|
||||
metaKey: ev.metaKey,
|
||||
shiftKey: ev.shiftKey,
|
||||
value,
|
||||
cancel() {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
});
|
||||
cursorHandler();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public blur(): void {
|
||||
if (this.el) this.el.blur();
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
if (this.el) this.el.focus();
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this.isActive.value = false;
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
this.isActive.value = true;
|
||||
}
|
||||
|
||||
public setValue(str: string): void {
|
||||
str = str || this.buffer;
|
||||
if (this.el) this.el.value = str;
|
||||
this.data.value = str;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.buffer = "";
|
||||
this.data.value = "";
|
||||
this.el.value = "";
|
||||
}
|
||||
|
||||
public pipe(output: IOutputInterface): void {
|
||||
const txtBefore = h<HTMLSpanElement>("span");
|
||||
const cursor = h<HTMLSpanElement>("span", {
|
||||
class: THEME.CURSOR,
|
||||
html: SPACE
|
||||
});
|
||||
const txtAfter = h<HTMLSpanElement>("span");
|
||||
|
||||
output.el?.append(txtBefore, cursor, txtAfter);
|
||||
|
||||
createEffect(() => {
|
||||
const i = this.ptr.value;
|
||||
const d = this.data.value;
|
||||
if (!this.isActive.value || !this.showInput.value) {
|
||||
txtBefore.textContent = "";
|
||||
cursor.innerHTML = SPACE;
|
||||
txtAfter.textContent = "";
|
||||
return;
|
||||
}
|
||||
txtBefore.textContent = d.substring(0, i);
|
||||
cursor.innerHTML = d.substring(i, i + 1).trim() || SPACE;
|
||||
txtAfter.textContent = d.substring(i + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
47
xterminal/source/input/interface.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { IDisposable, IKeyPress } from "../types";
|
||||
import type { IOutputInterface } from "../output/interface";
|
||||
|
||||
/**
|
||||
* Interface: Input Component
|
||||
*/
|
||||
export interface IInputInterface extends IDisposable {
|
||||
/**
|
||||
* Blur the input element
|
||||
*/
|
||||
blur(): void;
|
||||
|
||||
/**
|
||||
* Focus the input element
|
||||
*/
|
||||
focus(): void;
|
||||
|
||||
/**
|
||||
* Deactivate the component
|
||||
*/
|
||||
pause(): void;
|
||||
|
||||
/**
|
||||
* Activate the component
|
||||
*/
|
||||
resume(): void;
|
||||
|
||||
/**
|
||||
* Callback function invoked on every key press
|
||||
*/
|
||||
onkeypress?: (ev: IKeyPress) => void;
|
||||
|
||||
/**
|
||||
* Bridge the input to the output component: cursor & input
|
||||
*/
|
||||
pipe(output: IOutputInterface): void;
|
||||
|
||||
/**
|
||||
* Set the value of the input element, updates the cursor
|
||||
*/
|
||||
setValue(str: string): void;
|
||||
|
||||
/**
|
||||
* Clears the value of the input element
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
169
xterminal/source/instance.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import XTerminal from "./index";
|
||||
import XHistory from "./history/index";
|
||||
import XOutputComponent from "./output/index";
|
||||
import XInputComponent from "./input/index";
|
||||
import { createEffect } from "./base/reactivity";
|
||||
import { NEWLINE, THEME, h, scrollDown } from "./renderer/dom";
|
||||
import { isFunction, isMobile } from "./helpers";
|
||||
import {
|
||||
ARROW_DOWN_KEY,
|
||||
ARROW_UP_KEY,
|
||||
ENTER_KEY,
|
||||
TAB_KEY,
|
||||
addEvent
|
||||
} from "./renderer/events";
|
||||
import { bounce } from "./base/debouncer";
|
||||
import { ITerminalState } from "./interface";
|
||||
|
||||
/**
|
||||
* Public Events
|
||||
*/
|
||||
export const DATA_EVENT = "data";
|
||||
export const CLEAR_EVENT = "clear";
|
||||
export const KEYPRESS_EVENT = "keypress";
|
||||
export const PAUSE_EVENT = "pause";
|
||||
export const RESUME_EVENT = "resume";
|
||||
|
||||
/**
|
||||
* Composes the components of the terminal object and integrates them
|
||||
* @param instance The terminal object
|
||||
* @param target The DOM element in which the terminal is to be mounted
|
||||
*/
|
||||
export function setup(
|
||||
instance: XTerminal,
|
||||
target: HTMLElement
|
||||
): ITerminalState {
|
||||
const term = h<HTMLDivElement>("div", {
|
||||
class: THEME.CONTAINER,
|
||||
props: { tabindex: 0 }
|
||||
});
|
||||
|
||||
const xhistory = new XHistory();
|
||||
const output = new XOutputComponent(term);
|
||||
const input = new XInputComponent(term);
|
||||
|
||||
const state = {
|
||||
input,
|
||||
output,
|
||||
history: xhistory,
|
||||
completer: (x: string) => x
|
||||
};
|
||||
|
||||
// connect the input to output (cursor & input text)
|
||||
input.pipe(output);
|
||||
|
||||
// scroll to the bottom on every output operation
|
||||
output.onoutput = () => scrollDown(term);
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
if (input.isFocused.value) {
|
||||
// 给软键盘弹出和页面重排一点时间
|
||||
setTimeout(() => {
|
||||
let bottomInset = 0;
|
||||
if (window.visualViewport) {
|
||||
// 当键盘弹出时,如果布局视口高于可视视口,计算出被遮挡的高度(键盘+辅助栏等)
|
||||
bottomInset = Math.max(
|
||||
0,
|
||||
window.innerHeight -
|
||||
(window.visualViewport.height +
|
||||
window.visualViewport.offsetTop)
|
||||
);
|
||||
}
|
||||
|
||||
// 给输入区底部补充内间距,这样只需滚动内部容器,完全不需要让浏览器滚动 body (这会导致 Safari 底栏失去透明度)
|
||||
term.style.paddingBottom =
|
||||
bottomInset > 0
|
||||
? `calc(var(--xt-padding) + var(--tc-toolbar-height, 0px) + ${bottomInset}px)`
|
||||
: "";
|
||||
|
||||
scrollDown(term);
|
||||
|
||||
const cursor = term.querySelector(
|
||||
`.${THEME.CURSOR}`
|
||||
) as HTMLElement;
|
||||
if (cursor) {
|
||||
try {
|
||||
cursor.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "nearest"
|
||||
});
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
term.style.paddingBottom = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (window.visualViewport) {
|
||||
instance.register(
|
||||
addEvent(window.visualViewport, "resize", checkScrollPosition)
|
||||
);
|
||||
} else {
|
||||
instance.register(addEvent(window, "resize", checkScrollPosition));
|
||||
}
|
||||
|
||||
instance.register(
|
||||
addEvent(term, "keydown", function (ev: KeyboardEvent) {
|
||||
// focus input element
|
||||
input.focus();
|
||||
// redirect the `keyboard` event to the input in the next event loop
|
||||
bounce(() => {
|
||||
input.el.dispatchEvent(new KeyboardEvent("keydown", ev));
|
||||
input.el.dispatchEvent(new KeyboardEvent("input", ev));
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
instance.register(
|
||||
addEvent(term, "focus", () => (input.isFocused.value = true))
|
||||
);
|
||||
|
||||
instance.register(
|
||||
addEvent(term, "blur", () => (input.isFocused.value = false))
|
||||
);
|
||||
|
||||
if (isMobile()) {
|
||||
// Toggle keyboard on mobile devices (touchscreen) on tap
|
||||
instance.register(addEvent(term, "click", input.focus.bind(input)));
|
||||
}
|
||||
|
||||
target.appendChild(term);
|
||||
|
||||
// handle keydown event
|
||||
input.onkeypress = (ev) => {
|
||||
if (ev.key == ENTER_KEY) {
|
||||
ev.cancel();
|
||||
xhistory.add(ev.value);
|
||||
output.writeSafe(ev.value + NEWLINE);
|
||||
instance.emit(DATA_EVENT, ev.value);
|
||||
} else if (ev.key == TAB_KEY) {
|
||||
ev.cancel();
|
||||
if (isFunction(state.completer)) {
|
||||
input.setValue(state.completer(ev.value));
|
||||
}
|
||||
} else if (ev.key == ARROW_DOWN_KEY) {
|
||||
ev.cancel();
|
||||
input.setValue(xhistory.next());
|
||||
} else if (ev.key == ARROW_UP_KEY) {
|
||||
ev.cancel();
|
||||
input.setValue(xhistory.previous());
|
||||
} else {
|
||||
instance.emit(KEYPRESS_EVENT, ev);
|
||||
}
|
||||
scrollDown(term);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
// Fill the cursor if focused, otherwise outline
|
||||
if (input.isFocused.value) {
|
||||
term.classList.remove(THEME.INACTIVE);
|
||||
} else {
|
||||
term.classList.add(THEME.INACTIVE);
|
||||
}
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
28
xterminal/source/interface.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IInputInterface } from "./input/interface";
|
||||
import type { IOutputInterface } from "./output/interface";
|
||||
import type { IHistory } from "./history/interface";
|
||||
|
||||
/**
|
||||
* Terminal State
|
||||
*/
|
||||
export type ITerminalState = {
|
||||
/**
|
||||
* Input component for the terminal
|
||||
*/
|
||||
input: IInputInterface;
|
||||
|
||||
/**
|
||||
* Output component for the terminal
|
||||
*/
|
||||
output: IOutputInterface;
|
||||
|
||||
/**
|
||||
* History: store of inputs
|
||||
*/
|
||||
history: IHistory;
|
||||
|
||||
/**
|
||||
* Autocomplete function invoked on TAB key
|
||||
*/
|
||||
completer?: (data: string) => string;
|
||||
};
|
||||
65
xterminal/source/output/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { isFunction } from "../helpers";
|
||||
import outputBuild from "../renderer/index";
|
||||
import { SPACE, h } from "../renderer/dom";
|
||||
import type { IOutputInterface } from "./interface";
|
||||
|
||||
const TAB_SIZE = 4;
|
||||
|
||||
function parseOutput(data = ""): string {
|
||||
return ("" + data)
|
||||
.replace(/(\n)|(\n\r)|(\r\n)/g, "<br/>")
|
||||
.replace(/\s{2}/g, SPACE.repeat(2))
|
||||
.replace(/\t/g, SPACE.repeat(TAB_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes user input so it can be safely rendered as HTML text.
|
||||
* - preserves all characters by converting them to HTML entities where needed.
|
||||
*/
|
||||
export function escapeHTML(data = ""): string {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = data;
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output Component
|
||||
*/
|
||||
export default class XOutputComponent implements IOutputInterface {
|
||||
public el: HTMLDivElement;
|
||||
private console: HTMLSpanElement;
|
||||
private lastOutput?: HTMLSpanElement;
|
||||
public onoutput?: () => void;
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
const { outputBox, consoleBox } = outputBuild(target);
|
||||
this.el = outputBox;
|
||||
this.console = consoleBox;
|
||||
}
|
||||
|
||||
public write(data: string, callback?: () => void): void {
|
||||
this.lastOutput = h<HTMLSpanElement>("span", {
|
||||
html: parseOutput(data)
|
||||
});
|
||||
this.console.appendChild(this.lastOutput);
|
||||
if (isFunction(this.onoutput)) this.onoutput();
|
||||
if (isFunction(callback)) callback();
|
||||
}
|
||||
|
||||
public writeSafe(data: string, callback?: () => void): void {
|
||||
this.write(escapeHTML(data), callback);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
if (this.console) {
|
||||
this.console.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
public clearLast(): void {
|
||||
if (this.lastOutput) {
|
||||
this.lastOutput.parentNode?.removeChild(this.lastOutput);
|
||||
}
|
||||
this.lastOutput = undefined;
|
||||
}
|
||||
}
|
||||
34
xterminal/source/output/interface.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Interface: Output Component
|
||||
*/
|
||||
export interface IOutputInterface {
|
||||
/**
|
||||
* Container element housing the console box
|
||||
*/
|
||||
el: HTMLDivElement;
|
||||
|
||||
/**
|
||||
* Inbuilt callback function for every write operation
|
||||
*/
|
||||
onoutput?: () => void;
|
||||
|
||||
/**
|
||||
* Output data to the console
|
||||
*/
|
||||
write(data: string, callback?: () => void): void;
|
||||
|
||||
/**
|
||||
* Safely output data to the console
|
||||
*/
|
||||
writeSafe(data: string, callback?: () => void): void;
|
||||
|
||||
/**
|
||||
* Clear the console
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Remove the element containing the previous output
|
||||
*/
|
||||
clearLast(): void;
|
||||
}
|
||||
5
xterminal/source/readme.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# XTerminal
|
||||
|
||||
## Structure
|
||||
|
||||

|
||||
63
xterminal/source/renderer/dom.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { isArray, isObject } from "../helpers";
|
||||
import type { IElementProps } from "./interface";
|
||||
|
||||
export const NEWLINE = "\n";
|
||||
|
||||
export const SPACE = " ";
|
||||
|
||||
/**
|
||||
* CSS class names
|
||||
*/
|
||||
export const THEME = {
|
||||
CONTAINER: "xt",
|
||||
INACTIVE: "xt-inactive",
|
||||
CURSOR: "xt-cursor",
|
||||
OUTPUT: "xt-stdout",
|
||||
INPUT: "xt-stdin"
|
||||
};
|
||||
|
||||
export function scrollDown(el: HTMLElement): void {
|
||||
if (el) el.scrollTo(0, el.scrollHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ready to use HTMLElement
|
||||
*
|
||||
* https://github.com/henryhale/render-functions
|
||||
*
|
||||
* https://vuejs.org/guide/extras/render-function.html
|
||||
*
|
||||
* @param tag The HTML element tag
|
||||
* @param options The some properties of the element
|
||||
* @returns The HTML Element
|
||||
*/
|
||||
export function h<T extends HTMLElement>(
|
||||
tag: string,
|
||||
options?: IElementProps
|
||||
): T {
|
||||
const elem = document.createElement(tag);
|
||||
if (!isObject(options)) {
|
||||
return elem as T;
|
||||
}
|
||||
if (options?.id) {
|
||||
elem.id = options.id || "";
|
||||
}
|
||||
if (options?.class) {
|
||||
elem.className = options.class || "";
|
||||
}
|
||||
if (options?.content) {
|
||||
elem.appendChild(document.createTextNode(options.content || ""));
|
||||
}
|
||||
if (options?.html) {
|
||||
elem.innerHTML = options.html;
|
||||
}
|
||||
if (isArray(options?.children)) {
|
||||
options.children.forEach((c) => elem.append(c));
|
||||
}
|
||||
if (isObject(options?.props)) {
|
||||
Object.entries(options.props).forEach((v) =>
|
||||
elem.setAttribute(v[0], v[1])
|
||||
);
|
||||
}
|
||||
return elem as T;
|
||||
}
|
||||
28
xterminal/source/renderer/events.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { IDisposable } from "../types";
|
||||
|
||||
export const ENTER_KEY = "Enter",
|
||||
TAB_KEY = "Tab",
|
||||
ARROW_UP_KEY = "ArrowUp",
|
||||
ARROW_DOWN_KEY = "ArrowDown";
|
||||
|
||||
/**
|
||||
* Attaches an event listener to the element returning a disposable object
|
||||
* to remove the event listener
|
||||
*/
|
||||
export function addEvent(
|
||||
el: Element | Document | Window | VisualViewport | EventTarget,
|
||||
type: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (e: any) => void,
|
||||
opt?: boolean | AddEventListenerOptions
|
||||
): IDisposable {
|
||||
el.addEventListener(type, handler, opt);
|
||||
let disposed = false;
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
el.removeEventListener(type, handler, opt);
|
||||
disposed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
68
xterminal/source/renderer/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { THEME, h } from "./dom";
|
||||
|
||||
/**
|
||||
* Generate a build for the output component
|
||||
* @param target The parent element in which it is mounted
|
||||
* @returns DOM reference to the console box and output container
|
||||
*/
|
||||
export default function outputBuild(target: HTMLElement) {
|
||||
const consoleBox = h<HTMLSpanElement>("span");
|
||||
|
||||
const outputBox = h<HTMLDivElement>("div", {
|
||||
class: THEME.OUTPUT,
|
||||
children: [consoleBox]
|
||||
});
|
||||
|
||||
target.appendChild(outputBox);
|
||||
|
||||
return { outputBox, consoleBox };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a build for the input component
|
||||
* @param target The parent element in which it is mounted
|
||||
* @returns DOM reference to the input element
|
||||
*/
|
||||
export function inputBuild(target: HTMLElement) {
|
||||
const inputBox = h<HTMLTextAreaElement>("textarea", {
|
||||
props: {
|
||||
// 明确声明为普通文本输入,降低移动端将其识别为登录表单的概率。
|
||||
spellcheck: false,
|
||||
autocorrect: "off",
|
||||
autocapitalize: "off",
|
||||
autocomplete: "off",
|
||||
name: "xterminal_input",
|
||||
inputmode: "text",
|
||||
enterkeyhint: "enter",
|
||||
rows: 1,
|
||||
wrap: "off"
|
||||
}
|
||||
});
|
||||
// 部分密码管理器会读取 data-* 做识别,显式标记为非登录用途。
|
||||
inputBox.setAttribute("data-form-type", "other");
|
||||
inputBox.setAttribute("data-lpignore", "true");
|
||||
inputBox.setAttribute("data-1p-ignore", "true");
|
||||
inputBox.setAttribute("data-bwignore", "true");
|
||||
inputBox.setAttribute("aria-autocomplete", "none");
|
||||
inputBox.setAttribute("autocapitalize", "off");
|
||||
inputBox.setAttribute("autocorrect", "off");
|
||||
// 某些浏览器在首帧点击文本输入框时会误触发凭据弹窗;
|
||||
// 先只读,获得焦点后立刻解除,可显著降低“刷新后点一下就弹登录”的概率。
|
||||
inputBox.readOnly = true;
|
||||
inputBox.addEventListener(
|
||||
"focus",
|
||||
() => {
|
||||
inputBox.readOnly = false;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
const stdin = h<HTMLDivElement>("div", {
|
||||
class: THEME.INPUT,
|
||||
children: [inputBox]
|
||||
});
|
||||
|
||||
target.appendChild(stdin);
|
||||
|
||||
return inputBox;
|
||||
}
|
||||
37
xterminal/source/renderer/interface.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { IDisposable } from "../types";
|
||||
|
||||
/**
|
||||
* Render function - element props
|
||||
*/
|
||||
export interface IElementProps {
|
||||
id?: string;
|
||||
class?: string;
|
||||
content?: string;
|
||||
html?: string;
|
||||
children?: (string | Node)[];
|
||||
props?: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key Bindings to the Input
|
||||
*/
|
||||
interface IKeyBindingAction {
|
||||
(arg1: unknown, arg2?: unknown): void;
|
||||
}
|
||||
|
||||
export interface IKeyBindings {
|
||||
[key: string]: IKeyBindingAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer
|
||||
*/
|
||||
export interface IRenderer extends IDisposable {
|
||||
canInput: boolean;
|
||||
setKeyBindings(options: IKeyBindings): void;
|
||||
mount(el: HTMLElement): void;
|
||||
focusInput(): void;
|
||||
blurInput(): void;
|
||||
clearConsole(): void;
|
||||
output(data: string): void;
|
||||
}
|
||||
283
xterminal/source/types.d.ts
vendored
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @author Henry Hale <https://github.com/henryhale>
|
||||
* @license MIT
|
||||
*
|
||||
* This contains the type declarations for the `xterminal` library. Note that
|
||||
* some interfaces differ between this file and the actual implementation in
|
||||
* source/, that's because this file declares the *Public* API which is intended
|
||||
* to be stable and consumed by external programs.
|
||||
*/
|
||||
|
||||
/// <reference lib="dom"/>
|
||||
|
||||
/**
|
||||
* An object that can be disposed via a dispose function.
|
||||
*/
|
||||
export declare class IDisposable {
|
||||
/**
|
||||
* Clean up
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of event name.
|
||||
*/
|
||||
export type IEventName = string | symbol;
|
||||
|
||||
/**
|
||||
* Callback function invoked when the event is dispatched.
|
||||
*/
|
||||
export type IEventListener = (...args: unknown[]) => void;
|
||||
|
||||
/**
|
||||
* Event map
|
||||
*/
|
||||
interface IEventMap {
|
||||
clear: () => void;
|
||||
data: (input: string) => void;
|
||||
keypress: (ev: IKeyPress) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Object passed to callback functions invoked on `keypress` event
|
||||
*/
|
||||
export type IKeyPress = {
|
||||
key: string;
|
||||
value: string;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
cancel(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event emitter
|
||||
*
|
||||
* It extends the Disposable class.
|
||||
*/
|
||||
export declare class XEventEmitter extends IDisposable {
|
||||
/**
|
||||
* Appends a event listener to the specified event.
|
||||
*
|
||||
* The listener is invoked everytime the event is dispatched.
|
||||
*
|
||||
* - return disposable object to remove the event listener
|
||||
*/
|
||||
on<K extends keyof IEventMap>(event: K, listener: IEventMap[K]): void;
|
||||
on(event: IEventName, listener: IEventListener): void;
|
||||
|
||||
/**
|
||||
* Appends a event listener to the specified event.
|
||||
*
|
||||
* The listener is invoked _only once_ when the event is dispatched.
|
||||
*
|
||||
* _It is deleted thereafter._
|
||||
*/
|
||||
once<K extends keyof IEventMap>(event: K, listener: IEventMap[K]): void;
|
||||
once(event: IEventName, listener: IEventListener): void;
|
||||
|
||||
/**
|
||||
* Removes an event listener from the specified event.
|
||||
*
|
||||
* The listener won't be invoked on event dispatch thereafter.
|
||||
*/
|
||||
off<K extends keyof IEventMap>(event: K, listener: IEventMap[K]): void;
|
||||
off(event: IEventName, listener: IEventListener): void;
|
||||
|
||||
/**
|
||||
* Triggers an event
|
||||
* @param event The event name to dispatch.
|
||||
* @param args data to be passed to the event listener.
|
||||
*/
|
||||
emit<K extends keyof IEventMap>(event: K, ...args: unknown[]): void;
|
||||
emit(event: IEventName, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal Options
|
||||
*/
|
||||
export type ITerminalOptions = {
|
||||
/**
|
||||
* An HTMLElement in which the terminal will be mounted
|
||||
*/
|
||||
target?: HTMLElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin
|
||||
*/
|
||||
// export interface IPlugin {
|
||||
// install(context: IPluginContext): void;
|
||||
// }
|
||||
|
||||
/**
|
||||
* XTerminal
|
||||
*
|
||||
* Create a new terminal instance
|
||||
*/
|
||||
declare class XTerminal extends XEventEmitter {
|
||||
constructor(options?: ITerminalOptions);
|
||||
|
||||
/**
|
||||
* Mounts the terminal instance in the `target` HTMLElement.
|
||||
*
|
||||
* If the selector is given, the first element is used.
|
||||
*
|
||||
* @param target An HTMLElement in which the terminal will be mounted.
|
||||
*/
|
||||
mount(target: HTMLElement | string): void;
|
||||
|
||||
/**
|
||||
* Focus the terminal - ready for input.
|
||||
*/
|
||||
focus(): void;
|
||||
|
||||
/**
|
||||
* Blurs the terminal.
|
||||
*/
|
||||
blur(): void;
|
||||
|
||||
/**
|
||||
* Write data to the terminal.
|
||||
*
|
||||
* - use `XTerminal.writeSafe` when printing arbitrary data like user input
|
||||
*
|
||||
* @param data The data to write to the terminal
|
||||
* @param callback Optional function invoked on successful write
|
||||
* @returns void
|
||||
*/
|
||||
write(data: string | number, callback?: () => void): void;
|
||||
|
||||
/**
|
||||
* Safely write data to the terminal.
|
||||
*
|
||||
* (recommended) to prevent malicious attacks like XSS
|
||||
*
|
||||
* Example:
|
||||
* ```js
|
||||
* term.writeSafe('<h1>hello</h1>');
|
||||
* // <h1>hello</h1>
|
||||
* ```
|
||||
*
|
||||
* @param data The data to write to the terminal
|
||||
* @param callback Optional function invoked on successful write
|
||||
* @returns void
|
||||
*/
|
||||
writeSafe(data: string | number, callback?: () => void): void;
|
||||
|
||||
/**
|
||||
* Write data to the terminal, followed by a break line character (\n).
|
||||
*
|
||||
* - use `XTerminal.writelnSafe` when printing arbitrary data like user input
|
||||
*
|
||||
* @param data The data to write to the terminal
|
||||
* @param callback Optional function invoked on successful write
|
||||
* @returns void
|
||||
*/
|
||||
writeln(data: string | number, callback?: () => void): void;
|
||||
|
||||
/**
|
||||
* Safely write data to the terminal, followed by a break line character (\n).
|
||||
*
|
||||
* (recommended) to prevent malicious attacks like XSS
|
||||
*
|
||||
* Example:
|
||||
* ```js
|
||||
* term.writelnSafe('<h1>hello</h1>');
|
||||
* // <h1>hello</h1><br/>
|
||||
* ```
|
||||
* @param data The data to write to the terminal
|
||||
* @param callback Optional function invoked on successful write
|
||||
* @returns void
|
||||
*/
|
||||
writelnSafe(data: string | number, callback?: () => void): void;
|
||||
|
||||
/**
|
||||
* Clear the entire terminal.
|
||||
*
|
||||
* This method triggers the `clear` event.
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Remove the element containing the previous output
|
||||
*/
|
||||
clearLast(): void;
|
||||
|
||||
/**
|
||||
* Access the history stack
|
||||
*/
|
||||
history: string[];
|
||||
|
||||
/**
|
||||
* Clears the entire history stack.
|
||||
*/
|
||||
clearHistory(): void;
|
||||
|
||||
/**
|
||||
* Sets the autocomplete function that is invoked on Tab key.
|
||||
*
|
||||
* @param fn completer function that takes a string input and return a better
|
||||
* completion.
|
||||
*/
|
||||
setCompleter(fn: (data: string) => string): void;
|
||||
|
||||
/**
|
||||
* Deactivate the terminal input
|
||||
*/
|
||||
pause(): void;
|
||||
|
||||
/**
|
||||
* Activate the terminal input
|
||||
*/
|
||||
resume(): void;
|
||||
|
||||
/**
|
||||
* Gracefully close the terminal instance.
|
||||
*
|
||||
* This detaches all event listeners, unmounts the terminal from the DOM,
|
||||
* and clears the backing functionality of the terminal.
|
||||
*
|
||||
* _The terminal should not be used again once disposed._
|
||||
*
|
||||
*/
|
||||
dispose(): void;
|
||||
|
||||
/**
|
||||
* Install an addon/plugin
|
||||
*/
|
||||
// use(plugin: IPlugin): void;
|
||||
|
||||
/**
|
||||
* Version number
|
||||
*/
|
||||
static readonly version: string;
|
||||
|
||||
/**
|
||||
* Event emitter
|
||||
*/
|
||||
static readonly XEventEmitter: XEventEmitter;
|
||||
|
||||
/**
|
||||
* Escapes user input so it can be safely rendered as HTML text.
|
||||
* - preserves all characters by converting them to HTML entities where needed.
|
||||
* - recommended for use on user input or any arbitrary data
|
||||
*/
|
||||
static escapeHTML(data?: string): string;
|
||||
|
||||
/**
|
||||
* Clears the input buffer
|
||||
*/
|
||||
clearInput(): void;
|
||||
|
||||
/**
|
||||
* Sets the input buffer to some value, updates the cursor
|
||||
*/
|
||||
setInput(str: string): void;
|
||||
}
|
||||
|
||||
export default XTerminal;
|
||||
19
xterminal/terminal.config.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"gatewayUrl": "wss://conn.biboer.cn",
|
||||
"gatewayToken": "remoteconn-dev-token",
|
||||
"selectedServerId": "dev-server",
|
||||
"servers": [
|
||||
{
|
||||
"id": "dev-server",
|
||||
"name": "Dev Server",
|
||||
"host": "shell.biboer.cn",
|
||||
"port": 22,
|
||||
"username": "gavin",
|
||||
"transportMode": "gateway",
|
||||
"authType": "password",
|
||||
"password": "Gavin123",
|
||||
"cols": 80,
|
||||
"rows": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
61
xterminal/tests/disposable.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import Disposable from "../source/base/disposable";
|
||||
|
||||
describe("Disposable", () => {
|
||||
let disposable: Disposable;
|
||||
|
||||
beforeEach(() => {
|
||||
disposable = new Disposable();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should not be disposed initially", () => {
|
||||
expect(disposable.isDisposed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("register", () => {
|
||||
it("should register disposable", () => {
|
||||
let disposed = false;
|
||||
const d = {
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
}
|
||||
};
|
||||
disposable.register(d);
|
||||
disposable.dispose();
|
||||
expect(disposed).toBe(true);
|
||||
});
|
||||
|
||||
it("should dispose immediately if already disposed", () => {
|
||||
disposable.dispose();
|
||||
let disposed = false;
|
||||
const d = {
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
}
|
||||
};
|
||||
disposable.register(d);
|
||||
expect(disposed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should dispose all registered", () => {
|
||||
let count = 0;
|
||||
disposable.register({ dispose: () => count++ });
|
||||
disposable.register({ dispose: () => count++ });
|
||||
disposable.dispose();
|
||||
expect(count).toBe(2);
|
||||
expect(disposable.isDisposed).toBe(true);
|
||||
});
|
||||
|
||||
it("should not dispose twice", () => {
|
||||
let count = 0;
|
||||
disposable.register({ dispose: () => count++ });
|
||||
disposable.dispose();
|
||||
disposable.dispose();
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
91
xterminal/tests/emitter.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import XEventEmitter from "../source/emitter/index";
|
||||
|
||||
describe("XEventEmitter", () => {
|
||||
let emitter: XEventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
emitter = new XEventEmitter();
|
||||
});
|
||||
|
||||
describe("on", () => {
|
||||
it("should add event listener", () => {
|
||||
let called = false;
|
||||
emitter.on("test", () => {
|
||||
called = true;
|
||||
});
|
||||
emitter.emit("test");
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple listeners", () => {
|
||||
let count = 0;
|
||||
emitter.on("test", () => count++);
|
||||
emitter.on("test", () => count++);
|
||||
emitter.emit("test");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("once", () => {
|
||||
it("should call listener only once", () => {
|
||||
let count = 0;
|
||||
emitter.once("test", () => count++);
|
||||
emitter.emit("test");
|
||||
emitter.emit("test");
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("off", () => {
|
||||
it("should remove event listener", () => {
|
||||
let called = false;
|
||||
const listener = () => {
|
||||
called = true;
|
||||
};
|
||||
emitter.on("test", listener);
|
||||
emitter.off("test", listener);
|
||||
emitter.emit("test");
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emit", () => {
|
||||
it("should emit events with arguments", () => {
|
||||
let args: unknown[] = [];
|
||||
emitter.on("test", (...a) => {
|
||||
args = a;
|
||||
});
|
||||
emitter.emit("test", 1, "hello", { key: "value" });
|
||||
expect(args).toEqual([1, "hello", { key: "value" }]);
|
||||
});
|
||||
|
||||
it("should prevent recursive emits", () => {
|
||||
let count = 0;
|
||||
emitter.on("test", () => {
|
||||
count++;
|
||||
if (count < 2) emitter.emit("test");
|
||||
});
|
||||
emitter.emit("test");
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("should not emit if disposed", () => {
|
||||
let called = false;
|
||||
emitter.on("test", () => {
|
||||
called = true;
|
||||
});
|
||||
emitter.dispose();
|
||||
emitter.emit("test");
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should dispose and clear store", () => {
|
||||
emitter.on("test", () => {});
|
||||
emitter.dispose();
|
||||
expect(emitter.isDisposed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
75
xterminal/tests/history.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import XHistory from "../source/history/index";
|
||||
|
||||
describe("XHistory", () => {
|
||||
let history: XHistory;
|
||||
|
||||
beforeEach(() => {
|
||||
history = new XHistory();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create with empty array", () => {
|
||||
expect(history.list).toEqual([]);
|
||||
});
|
||||
|
||||
it("should create with initial state", () => {
|
||||
const h = new XHistory(["cmd1", "cmd2"]);
|
||||
expect(h.list).toEqual(["cmd2", "cmd1"]); // reversed
|
||||
});
|
||||
});
|
||||
|
||||
describe("add", () => {
|
||||
it("should add new entry", () => {
|
||||
history.add("cmd1");
|
||||
expect(history.list).toEqual(["cmd1"]);
|
||||
});
|
||||
|
||||
it("should not add duplicate consecutive entries", () => {
|
||||
history.add("cmd1");
|
||||
history.add("cmd1");
|
||||
expect(history.list).toEqual(["cmd1"]);
|
||||
});
|
||||
|
||||
it("should add different entries", () => {
|
||||
history.add("cmd1");
|
||||
history.add("cmd2");
|
||||
expect(history.list).toEqual(["cmd1", "cmd2"]);
|
||||
});
|
||||
|
||||
it("should not add empty string", () => {
|
||||
history.add("");
|
||||
expect(history.list).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previous and next", () => {
|
||||
beforeEach(() => {
|
||||
history.add("cmd1");
|
||||
history.add("cmd2");
|
||||
history.add("cmd3");
|
||||
});
|
||||
|
||||
it("should navigate previous", () => {
|
||||
expect(history.previous()).toBe("cmd3");
|
||||
expect(history.previous()).toBe("cmd2");
|
||||
expect(history.previous()).toBe("cmd1");
|
||||
expect(history.previous()).toBe("cmd1"); // stays at last
|
||||
});
|
||||
|
||||
it("should navigate next", () => {
|
||||
history.previous(); // cmd3
|
||||
history.previous(); // cmd2
|
||||
expect(history.next()).toBe("cmd3");
|
||||
expect(history.next()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear all entries", () => {
|
||||
history.add("cmd1");
|
||||
history.clear();
|
||||
expect(history.list).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
xterminal/tests/input.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import XInputComponent from "../source/input/index";
|
||||
import type { IOutputInterface } from "../source/output/interface";
|
||||
|
||||
describe("XInputComponent", () => {
|
||||
let input: XInputComponent;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
input = new XInputComponent(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
input.dispose();
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create input element", () => {
|
||||
expect(input.el).toBeInstanceOf(HTMLTextAreaElement);
|
||||
expect(container.contains(input.el)).toBe(true);
|
||||
});
|
||||
|
||||
it("should mark terminal input as non-login field", () => {
|
||||
expect(input.el.getAttribute("autocomplete")).toBe("off");
|
||||
expect(input.el.getAttribute("data-form-type")).toBe("other");
|
||||
expect(input.el.getAttribute("data-lpignore")).toBe("true");
|
||||
expect(input.el.getAttribute("data-1p-ignore")).toBe("true");
|
||||
expect(input.el.getAttribute("data-bwignore")).toBe("true");
|
||||
});
|
||||
|
||||
it("should unlock readonly on first focus", () => {
|
||||
expect(input.el.readOnly).toBe(true);
|
||||
input.focus();
|
||||
expect(input.el.readOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus and blur", () => {
|
||||
it("should focus the input", () => {
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input.el);
|
||||
});
|
||||
|
||||
it("should blur the input", () => {
|
||||
input.focus();
|
||||
input.blur();
|
||||
expect(document.activeElement).not.toBe(input.el);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause and resume", () => {
|
||||
it("should pause and resume", () => {
|
||||
input.pause();
|
||||
// @ts-expect-error testing private property
|
||||
expect(input.isActive.value).toBe(false);
|
||||
input.resume();
|
||||
// @ts-expect-error testing private property
|
||||
expect(input.isActive.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setValue", () => {
|
||||
it("should set input value", () => {
|
||||
input.setValue("test");
|
||||
expect(input.el.value).toBe("test");
|
||||
// @ts-expect-error testing private property
|
||||
expect(input.data.value).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear input value", () => {
|
||||
input.setValue("test");
|
||||
input.clear();
|
||||
expect(input.el.value).toBe("");
|
||||
// @ts-expect-error testing private property
|
||||
expect(input.data.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pipe", () => {
|
||||
it("should pipe to output", () => {
|
||||
const output = {
|
||||
el: document.createElement("div")
|
||||
} as IOutputInterface;
|
||||
input.pipe(output);
|
||||
expect(output.el.children.length).toBe(3); // before, cursor, after
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should dispose", () => {
|
||||
input.dispose();
|
||||
expect(input.isDisposed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
xterminal/tests/output.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import XOutputComponent from "../source/output/index";
|
||||
|
||||
describe("XOutputComponent", () => {
|
||||
let output: XOutputComponent;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
output = new XOutputComponent(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create output elements", () => {
|
||||
expect(output.el).toBeInstanceOf(HTMLDivElement);
|
||||
expect(container.contains(output.el)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write", () => {
|
||||
it("should write data", () => {
|
||||
output.write("Hello World");
|
||||
expect(output.el.textContent).toContain("Hello World");
|
||||
});
|
||||
|
||||
it("should parse newlines", () => {
|
||||
output.write("line1\nline2");
|
||||
expect(output.el.innerHTML).toContain("<br>");
|
||||
});
|
||||
|
||||
it("should call callback", () => {
|
||||
let called = false;
|
||||
output.write("test", () => {
|
||||
called = true;
|
||||
});
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onoutput", () => {
|
||||
let called = false;
|
||||
output.onoutput = () => {
|
||||
called = true;
|
||||
};
|
||||
output.write("test");
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeSafe", () => {
|
||||
it("should escape HTML", () => {
|
||||
output.writeSafe("<script>");
|
||||
expect(output.el.innerHTML).toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear output", () => {
|
||||
output.write("test");
|
||||
output.clear();
|
||||
expect(output.el.innerHTML).toBe("<span></span>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearLast", () => {
|
||||
it("should clear last output", () => {
|
||||
output.write("first");
|
||||
output.write("second");
|
||||
output.clearLast();
|
||||
expect(output.el.textContent).toBe("first");
|
||||
});
|
||||
});
|
||||
});
|
||||
59
xterminal/tests/reactivity.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ref, createEffect } from "../source/base/reactivity";
|
||||
|
||||
describe("Reactivity", () => {
|
||||
describe("ref", () => {
|
||||
it("should create reactive value", () => {
|
||||
const r = ref(10);
|
||||
expect(r.value).toBe(10);
|
||||
});
|
||||
|
||||
it("should update value", () => {
|
||||
const r = ref("hello");
|
||||
r.value = "world";
|
||||
expect(r.value).toBe("world");
|
||||
});
|
||||
|
||||
it("should notify effects", () => {
|
||||
const r = ref(0);
|
||||
let count = 0;
|
||||
createEffect(() => {
|
||||
count = r.value;
|
||||
});
|
||||
r.value = 5;
|
||||
expect(count).toBe(5);
|
||||
});
|
||||
|
||||
it("should dispose", () => {
|
||||
const r = ref(0);
|
||||
let count = 0;
|
||||
createEffect(() => {
|
||||
count = r.value;
|
||||
});
|
||||
r.dispose();
|
||||
r.value = 10;
|
||||
expect(count).toBe(0); // should not update
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEffect", () => {
|
||||
it("should run effect immediately", () => {
|
||||
let ran = false;
|
||||
createEffect(() => {
|
||||
ran = true;
|
||||
});
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
|
||||
it("should subscribe to reactive values", () => {
|
||||
const r = ref(0);
|
||||
let value = 0;
|
||||
createEffect(() => {
|
||||
value = r.value * 2;
|
||||
});
|
||||
expect(value).toBe(0);
|
||||
r.value = 5;
|
||||
expect(value).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
xterminal/tests/setup.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { beforeAll } from "vitest";
|
||||
|
||||
// Mock scrollTo for jsdom
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
||||
writable: true,
|
||||
value: function (x: number, y: number) {
|
||||
this.scrollTop = y;
|
||||
this.scrollLeft = x;
|
||||
}
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
// Any global setup
|
||||
});
|
||||
193
xterminal/tests/xterminal.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import XTerminal from "../source/index";
|
||||
|
||||
describe("XTerminal", () => {
|
||||
let terminal: XTerminal;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new XTerminal();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (terminal.isMounted) {
|
||||
terminal.dispose();
|
||||
}
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create a new instance", () => {
|
||||
expect(terminal).toBeInstanceOf(XTerminal);
|
||||
expect(terminal.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
it("should mount if target is provided", () => {
|
||||
const term = new XTerminal({ target: container });
|
||||
expect(term.isMounted).toBe(true);
|
||||
term.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mount", () => {
|
||||
it("should mount to a valid HTMLElement", () => {
|
||||
terminal.mount(container);
|
||||
expect(terminal.isMounted).toBe(true);
|
||||
});
|
||||
|
||||
it("should mount to a selector string", () => {
|
||||
container.id = "test-terminal";
|
||||
terminal.mount("#test-terminal");
|
||||
expect(terminal.isMounted).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw error for invalid target", () => {
|
||||
// @ts-expect-error testing invalid input
|
||||
expect(() => terminal.mount(null)).toThrow();
|
||||
});
|
||||
|
||||
it("should not mount twice", () => {
|
||||
terminal.mount(container);
|
||||
terminal.mount(container);
|
||||
expect(terminal.isMounted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
});
|
||||
|
||||
it("should write string data", () => {
|
||||
terminal.write("Hello World");
|
||||
// Check if output contains the text
|
||||
expect(container.textContent).toContain("Hello World");
|
||||
});
|
||||
|
||||
it("should write number data", () => {
|
||||
terminal.write(123);
|
||||
expect(container.textContent).toContain("123");
|
||||
});
|
||||
|
||||
it("should call callback after write", () => {
|
||||
let called = false;
|
||||
terminal.write("test", () => {
|
||||
called = true;
|
||||
});
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeln", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
});
|
||||
|
||||
it("should write with newline", () => {
|
||||
terminal.writeln("Hello");
|
||||
expect(container.innerHTML).toContain("<br>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeSafe", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
});
|
||||
|
||||
it("should escape HTML", () => {
|
||||
terminal.writeSafe('<script>alert("xss")</script>');
|
||||
expect(container.innerHTML).toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
terminal.write("test");
|
||||
});
|
||||
|
||||
it("should clear the terminal", () => {
|
||||
terminal.clear();
|
||||
expect(container.textContent.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
});
|
||||
|
||||
it("should have empty history initially", () => {
|
||||
expect(terminal.history).toEqual([]);
|
||||
});
|
||||
|
||||
it("should allow setting history", () => {
|
||||
terminal.history = ["cmd1", "cmd2"];
|
||||
expect(terminal.history).toEqual(["cmd1", "cmd2"]);
|
||||
});
|
||||
|
||||
it("should clear history", () => {
|
||||
terminal.history = ["cmd1"];
|
||||
terminal.clearHistory();
|
||||
expect(terminal.history).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCompleter", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
});
|
||||
|
||||
it("should set completer function", () => {
|
||||
const completer = (data: string) => data + " completed";
|
||||
terminal.setCompleter(completer);
|
||||
// Completer is internal, hard to test directly
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it("should not set non-function", () => {
|
||||
// @ts-expect-error testing invalid input
|
||||
terminal.setCompleter("not a function");
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause and resume", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
});
|
||||
|
||||
it("should pause and resume", () => {
|
||||
terminal.pause();
|
||||
terminal.resume();
|
||||
expect(true).toBe(true); // Events are emitted, but hard to test without spying
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispose", () => {
|
||||
beforeEach(() => {
|
||||
terminal.mount(container);
|
||||
});
|
||||
|
||||
it("should dispose the terminal", () => {
|
||||
terminal.dispose();
|
||||
expect(terminal.isMounted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static methods", () => {
|
||||
it("should have version", () => {
|
||||
expect(typeof XTerminal.version).toBe("string");
|
||||
});
|
||||
|
||||
it("should have XEventEmitter", () => {
|
||||
expect(XTerminal.XEventEmitter).toBeDefined();
|
||||
});
|
||||
|
||||
it("should escape HTML", () => {
|
||||
expect(XTerminal.escapeHTML("<test>")).toBe("<test>");
|
||||
});
|
||||
});
|
||||
});
|
||||
102
xterminal/theme/index.css
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* XTerminal styles
|
||||
* @author Henry Hale
|
||||
* @license MIT
|
||||
* @url https://github.com/henryhale/xterminal
|
||||
*/
|
||||
|
||||
:root {
|
||||
--xt-bg: #191a22;
|
||||
--xt-fg: #efefef;
|
||||
--xt-font-family: monospace;
|
||||
--xt-font-size: min(15px, calc(2vw + 5px));
|
||||
--xt-padding: min(6px, calc(1.25vw + 4px));
|
||||
}
|
||||
|
||||
.xt,
|
||||
.xt *,
|
||||
.xt *::after,
|
||||
.xt *::before {
|
||||
box-sizing: border-box;
|
||||
font-size: var(--xt-font-size);
|
||||
font-family: var(--xt-font-family);
|
||||
}
|
||||
|
||||
.xt {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
background-color: var(--xt-bg);
|
||||
color: var(--xt-fg);
|
||||
padding: var(--xt-padding);
|
||||
padding-bottom: calc(var(--xt-padding) + var(--tc-toolbar-height, 0px));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.xt > .xt-stdout > span:first {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.xt > .xt-stdout > .xt-cursor {
|
||||
background-color: var(--xt-fg);
|
||||
color: var(--xt-bg);
|
||||
border: 0.25px solid transparent;
|
||||
/* 终端聚焦时使用方块光标闪烁,模拟命令行输入体验。 */
|
||||
animation: xt-cursor-blink 1.6s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.xt.xt-inactive > .xt-stdout > .xt-cursor {
|
||||
background-color: transparent;
|
||||
color: var(--xt-fg);
|
||||
border-color: currentColor;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.xt > .xt-stdin {
|
||||
height: 0;
|
||||
position: relative;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.xt > .xt-stdin > input,
|
||||
.xt > .xt-stdin > textarea {
|
||||
border: 0 none;
|
||||
outline: 0 none;
|
||||
/* 使用 fixed 悬浮在顶部顶端,避免 iOS Safari 因为聚焦底部的 input 而强行将整个网页(body)整体上推,从而破坏底部原生工具栏/地址胶囊的透明悬浮效果 */
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
z-index: -100;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
/* 隐藏浏览器原生插入光标,避免与自绘方块光标重叠。 */
|
||||
caret-color: transparent;
|
||||
/* iOS Safari 在输入框字号小于 16px 时会触发页面自动放大,导致聚焦后产生空白黑边。 */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes xt-cursor-blink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.xt .xt-hidden {
|
||||
display: none;
|
||||
}
|
||||
13
xterminal/todo.md
Normal file
@@ -0,0 +1,13 @@
|
||||
网关使用~/remoteconn现有网关,gateway在本地,wss协议,使用8787端口。外部访问web使用https://shell.biboer.cn:5173。
|
||||
|
||||
使用shell.biboer.cn这个域名, https://shell.biboer.cn:5173访问。
|
||||
|
||||
Vite 开发服务本身走 HTTPS。我会给项目加一个 dev:https 启动方式(使用证书和私钥)
|
||||
|
||||
证书信息:
|
||||
const DEV_PUBLIC_HOST = "shell.biboer.cn";
|
||||
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
|
||||
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
|
||||
|
||||
---
|
||||
|
||||
105
xterminal/tsconfig.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
"lib": ["ESNext", "DOM"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "ESNext", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./out/", /* Specify an output folder for all emitted files. */
|
||||
"removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
"noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
"noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": ["source"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
16
xterminal/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './source'),
|
||||
},
|
||||
},
|
||||
})
|
||||