mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,295 @@
|
||||
import dns from "dns/promises";
|
||||
import net from "net";
|
||||
import type { RawData } from "ws";
|
||||
import ssh2Pkg, {
|
||||
type GetStreamCallback,
|
||||
type IdentityCallback,
|
||||
type ParsedKey,
|
||||
type SignCallback,
|
||||
type SigningRequestOptions,
|
||||
} from "ssh2";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const { AgentProtocol, BaseAgent } = ssh2Pkg;
|
||||
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
/** The terminal's structured logging, on top of ctx.log. */
|
||||
export interface TerminalLogger {
|
||||
info: (message: string, meta?: Record<string, unknown>) => void;
|
||||
success: (message: string, meta?: Record<string, unknown>) => void;
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (
|
||||
message: string,
|
||||
error?: unknown,
|
||||
meta?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Record<string, unknown>): string {
|
||||
if (!meta || Object.keys(meta).length === 0) return message;
|
||||
try {
|
||||
return `${message} ${JSON.stringify(meta)}`;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalLogger(log: PluginLogger): TerminalLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
success: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, error, meta) => {
|
||||
// Some call sites pass the meta object where the error goes.
|
||||
if (error && !(error instanceof Error) && typeof error === "object") {
|
||||
log.error(withMeta(message, error as Record<string, unknown>));
|
||||
return;
|
||||
}
|
||||
log.error(
|
||||
withMeta(message, meta),
|
||||
error instanceof Error
|
||||
? error
|
||||
: error
|
||||
? new Error(String(error))
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cap on a single decoded text frame. Legitimate control messages are tiny,
|
||||
// and terminal input is bounded by what a user can type or paste.
|
||||
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
export class WsMessageError extends Error {}
|
||||
|
||||
function rawByteLength(raw: RawData): number {
|
||||
if (Buffer.isBuffer(raw)) return raw.length;
|
||||
if (Array.isArray(raw))
|
||||
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Parses a frame into `{ type, data }`, throwing WsMessageError on anything else. */
|
||||
export function parseWsMessage(raw: RawData): {
|
||||
type: string;
|
||||
data: unknown;
|
||||
} {
|
||||
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
||||
throw new WsMessageError("Message too large");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
throw new WsMessageError("Invalid JSON");
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new WsMessageError("Message must be a JSON object");
|
||||
}
|
||||
|
||||
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||
if (typeof type !== "string") {
|
||||
throw new WsMessageError("Message type must be a string");
|
||||
}
|
||||
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
export function asObject(data: unknown): Record<string, unknown> {
|
||||
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** A sane integer width or height, or 0 when the value is unusable. */
|
||||
export function toTerminalDimension(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
const rounded = Math.floor(n);
|
||||
if (rounded < 1) return 0;
|
||||
return Math.min(rounded, 10000);
|
||||
}
|
||||
|
||||
export function isWindowsSftpPath(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
|
||||
}
|
||||
|
||||
export function sftpPathToLocalPath(sftpPath: string): string {
|
||||
const normalized = sftpPath.replace(/\\/g, "/");
|
||||
if (/^\/[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.slice(1).replace(/\//g, "\\");
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.replace(/\//g, "\\");
|
||||
}
|
||||
return sftpPath;
|
||||
}
|
||||
|
||||
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
|
||||
|
||||
type Lookup = typeof dns.lookup;
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function isRetriableDnsError(error: unknown): boolean {
|
||||
const err = error as { code?: unknown; message?: unknown };
|
||||
return (
|
||||
err.code === "EAI_AGAIN" ||
|
||||
(typeof err.message === "string" && err.message.includes("EAI_AGAIN"))
|
||||
);
|
||||
}
|
||||
|
||||
type LookupResult = { address: string; family: number };
|
||||
|
||||
// Prefer IPv4: ssh2 gets one address and has no fallback, and a name that
|
||||
// also has an AAAA record often points at a v6 address sshd is not bound to.
|
||||
export function pickSshAddress(result: LookupResult | LookupResult[]): string {
|
||||
const list = Array.isArray(result) ? result : [result];
|
||||
if (list.length === 0)
|
||||
throw Object.assign(new Error("No address found"), { code: "ENOTFOUND" });
|
||||
return (list.find((entry) => entry.family === 4) ?? list[0]).address;
|
||||
}
|
||||
|
||||
/** Resolves a hostname up front, retrying transient container DNS failures. */
|
||||
export async function resolveHostForSshConnect(
|
||||
host: string,
|
||||
lookup: Lookup = dns.lookup,
|
||||
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
|
||||
wait: Sleep = sleep,
|
||||
): Promise<{ host: string; resolvedAddress?: string; attempts: number }> {
|
||||
const normalized = host.replace(/^\[|\]$/g, "").trim();
|
||||
if (!normalized || net.isIP(normalized) !== 0) {
|
||||
return { host: normalized || host, attempts: 0 };
|
||||
}
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
const address = pickSshAddress(await lookup(normalized, { all: true }));
|
||||
return {
|
||||
host: address,
|
||||
resolvedAddress: address,
|
||||
attempts: attempt + 1,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isRetriableDnsError(error) || attempt >= retryDelaysMs.length) {
|
||||
throw error;
|
||||
}
|
||||
await wait(retryDelaysMs[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostAddress(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a numeric host id resolved to a different machine than the client
|
||||
* meant. Ids from the desktop app and a sync server drift apart, and the
|
||||
* resolved row supplies the credentials, so a mismatch must be refused.
|
||||
*/
|
||||
export function hostAddressMismatch(
|
||||
clientAddress: unknown,
|
||||
resolvedAddress: unknown,
|
||||
): boolean {
|
||||
const resolved = normalizeHostAddress(resolvedAddress);
|
||||
if (!resolved) return false;
|
||||
return resolved !== normalizeHostAddress(clientAddress);
|
||||
}
|
||||
|
||||
export const HOST_ADDRESS_MISMATCH_MESSAGE =
|
||||
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
|
||||
"The host ids on this device and on the sync server have drifted apart. " +
|
||||
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
|
||||
|
||||
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
||||
"This host does not exist on the sync server, so the connection was refused. " +
|
||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||
|
||||
export function resolveServerJumpHosts(
|
||||
clientJumpHosts: Array<{ hostId: number }> | undefined,
|
||||
serverJumpHosts: Array<{ hostId: number }> | undefined,
|
||||
hostSyncId?: string | null,
|
||||
): Array<{ hostId: number }> | undefined {
|
||||
if (hostSyncId) return serverJumpHosts ?? [];
|
||||
return clientJumpHosts?.length ? clientJumpHosts : serverJumpHosts;
|
||||
}
|
||||
|
||||
export function resolveServerHostId(
|
||||
clientHostId: number | null,
|
||||
resolvedHost: { id?: unknown } | null,
|
||||
): number | null {
|
||||
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
|
||||
}
|
||||
|
||||
/** An in-memory SSH agent holding one key, for agent forwarding. */
|
||||
export class MemoryAgent extends BaseAgent {
|
||||
private key: ParsedKey;
|
||||
|
||||
constructor(key: ParsedKey) {
|
||||
super();
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
getIdentities(cb: IdentityCallback<ParsedKey>): void {
|
||||
cb(null, [this.key]);
|
||||
}
|
||||
|
||||
getStream(cb: GetStreamCallback): void {
|
||||
const protocol = new AgentProtocol(false);
|
||||
|
||||
protocol.on("identities", (request) => {
|
||||
protocol.getIdentitiesReply(request, [this.key]);
|
||||
});
|
||||
|
||||
protocol.on("sign", (request, publicKey, data, options) => {
|
||||
this.sign(publicKey, data, options, (error, signature) => {
|
||||
if (error || !signature) return protocol.failureReply(request);
|
||||
protocol.signReply(request, signature);
|
||||
});
|
||||
});
|
||||
|
||||
cb(null, protocol);
|
||||
}
|
||||
|
||||
sign(
|
||||
_pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||
cb?: SignCallback,
|
||||
): void {
|
||||
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
|
||||
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
|
||||
try {
|
||||
const algo =
|
||||
options.hash === "sha256"
|
||||
? "rsa-sha2-256"
|
||||
: options.hash === "sha512"
|
||||
? "rsa-sha2-512"
|
||||
: undefined;
|
||||
const signature = this.key.sign(data, algo);
|
||||
callback(null, signature);
|
||||
} catch (err) {
|
||||
callback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, which the SDK hands back untyped, and
|
||||
// the drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type HistoryRepository = ReturnType<typeof createHistoryRepository>;
|
||||
|
||||
/** Command history storage. Writes are followed by persist(), reads are not. */
|
||||
export function createHistoryRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async create(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
executedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ userId, hostId, command, executedAt });
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listUniqueCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 500,
|
||||
): Promise<string[]> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select({
|
||||
command: table.command,
|
||||
maxExecutedAt: sql<number>`MAX(${table.executedAt})`,
|
||||
})
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
|
||||
.groupBy(table.command)
|
||||
.orderBy(desc(sql`MAX(${table.executedAt})`))
|
||||
.limit(limit);
|
||||
return rows.map((row: { command: string }) => row.command);
|
||||
},
|
||||
|
||||
async listCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 200,
|
||||
): Promise<Array<{ command: string; executedAt: string }>> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select({ command: table.command, executedAt: table.executedAt })
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
|
||||
.orderBy(desc(table.executedAt))
|
||||
.limit(limit);
|
||||
return rows as Array<{ command: string; executedAt: string }>;
|
||||
},
|
||||
|
||||
async deleteCommandForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.userId, userId),
|
||||
eq(table.hostId, hostId),
|
||||
eq(table.command, command),
|
||||
),
|
||||
);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
hostSettingsFromTerminalConfig,
|
||||
terminalConfigFromHostSettings,
|
||||
} from "../shared/terminal-settings.js";
|
||||
|
||||
const SWITCH_KEYS = [
|
||||
"enableTerminal",
|
||||
"enableTerminalToolbar",
|
||||
"enableCommandHistory",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Registered as ctx.registry.provide("ssh-terminal.hostImportNormalizer", ...)
|
||||
* so a Termix-JSON host import keeps its terminal switches, which used to be
|
||||
* host columns, and its terminal look and behavior, which a 2.8 or early 2.9
|
||||
* export carried in terminalConfig. An export that already carries this
|
||||
* plugin's pluginSettings has had them written by core, so terminalConfig is
|
||||
* only read without them.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of SWITCH_KEYS) {
|
||||
if (raw[key] !== undefined) values[key] = raw[key] !== false;
|
||||
}
|
||||
|
||||
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
|
||||
"ssh-terminal"
|
||||
];
|
||||
const hasCarried =
|
||||
!!carried && typeof carried === "object" && Object.keys(carried).length > 0;
|
||||
if (!hasCarried) {
|
||||
Object.assign(values, hostSettingsFromTerminalConfig(raw.terminalConfig));
|
||||
}
|
||||
|
||||
return Object.keys(values).length > 0 ? values : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "ssh-terminal.hostPayloadLegacy": the terminal keys of the
|
||||
* 2.8 terminalConfig, for clients that still read it (Termix-Mobile). Core
|
||||
* merges them into the terminalConfig it sends, never over a key it set.
|
||||
* Remove once the mobile app reads pluginSettings["ssh-terminal"].
|
||||
*/
|
||||
export function hostPayloadLegacy(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return { terminalConfig: terminalConfigFromHostSettings(values) };
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Terminal image storage modes.
|
||||
*
|
||||
* - `local`: always write to the backend's mapped local storage. Deterministic:
|
||||
* never falls back to the remote SFTP path.
|
||||
* - `remote-sftp`: always write to the connected terminal's SSH host over SFTP.
|
||||
* Deterministic: never falls back to local storage.
|
||||
* - `auto`: pick by capability only — remote SFTP when a connected terminal
|
||||
* session exists, local storage otherwise. Configuration never influences
|
||||
* this choice.
|
||||
*/
|
||||
export const TERMINAL_IMAGE_STORAGE_MODES = [
|
||||
"auto",
|
||||
"local",
|
||||
"remote-sftp",
|
||||
] as const;
|
||||
|
||||
export type TerminalImageStorageMode =
|
||||
(typeof TERMINAL_IMAGE_STORAGE_MODES)[number];
|
||||
|
||||
export interface TerminalImageStorageSettings {
|
||||
mode: TerminalImageStorageMode;
|
||||
/** Absolute path on the Termix backend where local-mode files are written. */
|
||||
localDir: string;
|
||||
/**
|
||||
* Absolute path handed to the terminal agent in local mode. This is the
|
||||
* host-side view of `localDir` (e.g. /tmp mapped into the container); it is
|
||||
* the only path ever exposed to callers.
|
||||
*/
|
||||
hostPath: string;
|
||||
ttlMs: number;
|
||||
maxCount: number;
|
||||
maxBytes: number;
|
||||
/** Both localDir and hostPath were explicitly configured and must be probed. */
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
/** Settings-table keys. Persisted values always win over environment. */
|
||||
export const TERMINAL_IMAGE_STORAGE_KEYS = {
|
||||
mode: "terminal_image_storage_mode",
|
||||
localDir: "terminal_image_local_dir",
|
||||
hostPath: "terminal_image_host_path",
|
||||
ttlMs: "terminal_image_ttl_ms",
|
||||
maxCount: "terminal_image_max_count",
|
||||
maxBytes: "terminal_image_max_storage_bytes",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Legacy environment variables from the original env-only configuration. They
|
||||
* seed defaults only when no database value exists for the same field.
|
||||
*/
|
||||
export const TERMINAL_IMAGE_STORAGE_ENV = {
|
||||
mode: "TERMIX_IMAGE_STORAGE_MODE",
|
||||
localDir: "TERMIX_IMAGE_DIR",
|
||||
hostPath: "TERMIX_IMAGE_HOST_PATH",
|
||||
ttlMs: "TERMIX_IMAGE_TTL_MS",
|
||||
maxCount: "TERMIX_MAX_IMAGE_COUNT",
|
||||
maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_IMAGE_TTL_MS = 3_600_000;
|
||||
export const DEFAULT_IMAGE_MAX_COUNT = 100;
|
||||
export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120;
|
||||
export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0";
|
||||
export const MIN_IMAGE_MAX_BYTES = 1_048_576;
|
||||
|
||||
export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string {
|
||||
return path.resolve(
|
||||
path.join(env.DATA_DIR || "./db/data", "termix-image-v0"),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTerminalImageStorageMode(
|
||||
value: unknown,
|
||||
): TerminalImageStorageMode | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes(
|
||||
normalized,
|
||||
)
|
||||
? (normalized as TerminalImageStorageMode)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local write directory must be an absolute path without NUL bytes. Relative
|
||||
* values are rejected rather than resolved: a relative entry silently depends
|
||||
* on the process cwd, which differs between Docker, systemd and dev runs.
|
||||
*/
|
||||
export function parseImageLocalDir(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
|
||||
if (!path.isAbsolute(trimmed)) return null;
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
/** Agent-visible path. Always POSIX-style and absolute. */
|
||||
export function parseImageHostPath(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
|
||||
if (!path.posix.isAbsolute(trimmed)) return null;
|
||||
return path.posix.normalize(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric fields: unparseable values are rejected (caller falls through to the
|
||||
* next source); parseable but out-of-range values are clamped, matching the
|
||||
* original env-only behavior.
|
||||
*/
|
||||
function parseClampedInt(value: unknown, min: number): number | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") return null;
|
||||
const parsed =
|
||||
typeof value === "number" ? value : Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(min, Math.trunc(parsed));
|
||||
}
|
||||
|
||||
export interface SettingSource {
|
||||
get(key: string): Promise<string | null>;
|
||||
/** Told about a stored or environment value that was ignored. */
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
function hasUnsafePathSyntax(value: string): boolean {
|
||||
return (
|
||||
/[\u0000-\u001f\u007f]/.test(value) ||
|
||||
value.split(/[\\/]+/).some((segment) => segment === "..") ||
|
||||
value.includes("//") ||
|
||||
value.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective image storage settings.
|
||||
*
|
||||
* Per-field precedence: a valid persisted database value wins; a legacy
|
||||
* TERMIX_IMAGE_* variable seeds the default only when no database value
|
||||
* exists; otherwise the built-in default applies. Invalid values are skipped
|
||||
* with a warning and resolution falls through to the next source.
|
||||
*
|
||||
* Mode compatibility: with no mode in the database or environment, an explicit
|
||||
* legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`;
|
||||
* everything else defaults to `auto`.
|
||||
*/
|
||||
export async function resolveTerminalImageStorageSettings(
|
||||
settings: SettingSource,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<TerminalImageStorageSettings> {
|
||||
const warnInvalid = (key: string, source: string) =>
|
||||
settings.warn?.(
|
||||
`Ignoring invalid terminal image storage setting ${key} from ${source}`,
|
||||
);
|
||||
|
||||
async function pick<T>(
|
||||
key: string,
|
||||
envName: string,
|
||||
parse: (value: unknown) => T | null,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
const stored = await settings.get(key);
|
||||
if (stored !== null) {
|
||||
const parsed = parse(stored);
|
||||
if (parsed !== null) return parsed;
|
||||
warnInvalid(key, "database");
|
||||
}
|
||||
const fromEnv = env[envName];
|
||||
if (fromEnv !== undefined) {
|
||||
const parsed = parse(fromEnv);
|
||||
if (parsed !== null) return parsed;
|
||||
warnInvalid(key, "environment");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode);
|
||||
let mode: TerminalImageStorageMode | null = null;
|
||||
if (dbModeRaw !== null) {
|
||||
mode = parseTerminalImageStorageMode(dbModeRaw);
|
||||
if (mode === null)
|
||||
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database");
|
||||
}
|
||||
if (mode === null) {
|
||||
const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode];
|
||||
if (envModeRaw !== undefined) {
|
||||
mode = parseTerminalImageStorageMode(envModeRaw);
|
||||
if (mode === null)
|
||||
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment");
|
||||
}
|
||||
}
|
||||
if (mode === null) {
|
||||
// Legacy deployments configured local storage purely through
|
||||
// TERMIX_IMAGE_DIR; keep them on local mode unless a database value says
|
||||
// otherwise.
|
||||
mode =
|
||||
env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto";
|
||||
}
|
||||
|
||||
const legacyLocalDir = parseImageLocalDir(
|
||||
env[TERMINAL_IMAGE_STORAGE_ENV.localDir],
|
||||
);
|
||||
const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.localDir,
|
||||
parseImageLocalDir,
|
||||
defaultImageLocalDir(env),
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.hostPath,
|
||||
parseImageHostPath,
|
||||
legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.ttlMs,
|
||||
(value) => parseClampedInt(value, 0),
|
||||
DEFAULT_IMAGE_TTL_MS,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.maxCount,
|
||||
(value) => parseClampedInt(value, 1),
|
||||
DEFAULT_IMAGE_MAX_COUNT,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.maxBytes,
|
||||
(value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES),
|
||||
DEFAULT_IMAGE_MAX_BYTES,
|
||||
),
|
||||
]);
|
||||
|
||||
const persistedLocalDir = await settings.get(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
);
|
||||
const persistedHostPath = await settings.get(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
);
|
||||
const localMappingConfigured =
|
||||
((persistedLocalDir !== null &&
|
||||
parseImageLocalDir(persistedLocalDir) !== null) ||
|
||||
parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) &&
|
||||
((persistedHostPath !== null &&
|
||||
parseImageHostPath(persistedHostPath) !== null) ||
|
||||
parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null ||
|
||||
legacyLocalDir !== null);
|
||||
|
||||
return {
|
||||
mode,
|
||||
localDir,
|
||||
hostPath,
|
||||
ttlMs,
|
||||
maxCount,
|
||||
maxBytes,
|
||||
localMappingConfigured,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "./image-utils.js";
|
||||
import type { TerminalImageStorageSettings } from "./image-storage-settings.js";
|
||||
|
||||
/**
|
||||
* Stable error codes for image storage failures. These are part of the upload
|
||||
* route's contract; `IMAGE_REMOTE_WRITE_FAILED` predates this module and must
|
||||
* not change.
|
||||
*/
|
||||
export type TerminalImageStorageErrorCode =
|
||||
| "IMAGE_STORAGE_LIMIT_REACHED"
|
||||
| "IMAGE_LOCAL_WRITE_FAILED"
|
||||
| "IMAGE_LOCAL_INSPECTION_FAILED"
|
||||
| "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
|
||||
| "IMAGE_REMOTE_WRITE_FAILED";
|
||||
|
||||
export class TerminalImageStorageError extends Error {
|
||||
constructor(
|
||||
readonly code: TerminalImageStorageErrorCode,
|
||||
message: string,
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TerminalImageStorageError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredTerminalImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
/** Agent-visible path; never a backend-internal path. */
|
||||
shellPath: string;
|
||||
storage: "local" | "remote-sftp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode selection for one upload. Explicit modes are deterministic — they are
|
||||
* returned regardless of capability and the route reports the failure; only
|
||||
* `auto` falls back, and only on capability (a connected terminal session
|
||||
* with SFTP), never on configuration.
|
||||
*/
|
||||
export function selectImageStorageMode(
|
||||
settings: Pick<
|
||||
TerminalImageStorageSettings,
|
||||
"mode" | "localMappingConfigured"
|
||||
>,
|
||||
capability: { remoteSftpAvailable: boolean; localHostVisible?: boolean },
|
||||
): "local" | "remote-sftp" | "unavailable" {
|
||||
if (settings.mode === "local") return "local";
|
||||
if (settings.mode === "remote-sftp") return "remote-sftp";
|
||||
if (settings.localMappingConfigured && capability.localHostVisible === true) {
|
||||
return "local";
|
||||
}
|
||||
return capability.remoteSftpAvailable ? "remote-sftp" : "unavailable";
|
||||
}
|
||||
|
||||
// Capacity checks and writes are serialized so concurrent uploads cannot
|
||||
// bypass the count or byte limits.
|
||||
let imageStorageQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function withImageStorageLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = imageStorageQueue.then(operation, operation);
|
||||
imageStorageQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cleanupExpiredImages(
|
||||
localDir: string,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
const entries = await fs.readdir(localDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(localDir, entry.name);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat && isExpiredImage(stat.mtimeMs, now, ttlMs)) {
|
||||
await fs.unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getActiveImageStorageUsage(
|
||||
localDir: string,
|
||||
ttlMs: number,
|
||||
): Promise<{ fileCount: number; totalBytes: number }> {
|
||||
const entries = await fs.readdir(localDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
const stats = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
|
||||
.map(async (entry) => {
|
||||
const stat = await fs.stat(path.join(localDir, entry.name));
|
||||
return !isExpiredImage(stat.mtimeMs, now, ttlMs) ? stat : null;
|
||||
}),
|
||||
);
|
||||
|
||||
return stats.reduce(
|
||||
(usage, stat) => {
|
||||
if (stat) {
|
||||
usage.fileCount += 1;
|
||||
usage.totalBytes += stat.size;
|
||||
}
|
||||
return usage;
|
||||
},
|
||||
{ fileCount: 0, totalBytes: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local mapped-storage adapter. Enforces the TTL/count/byte policy and raises
|
||||
* `IMAGE_STORAGE_LIMIT_REACHED` (HTTP 507 at the route) when the caps are hit.
|
||||
* The returned shellPath is built from the agent-visible hostPath — the
|
||||
* backend's own localDir is never exposed.
|
||||
*/
|
||||
export async function storeImageLocally(
|
||||
image: Buffer,
|
||||
settings: TerminalImageStorageSettings,
|
||||
): Promise<StoredTerminalImage> {
|
||||
return withImageStorageLock(async () => {
|
||||
let usage: { fileCount: number; totalBytes: number };
|
||||
try {
|
||||
await fs.mkdir(settings.localDir, { recursive: true });
|
||||
await cleanupExpiredImages(settings.localDir, settings.ttlMs);
|
||||
usage = await getActiveImageStorageUsage(
|
||||
settings.localDir,
|
||||
settings.ttlMs,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
"Unable to inspect local image storage",
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
exceedsImageStorageLimit(
|
||||
usage.fileCount,
|
||||
usage.totalBytes,
|
||||
image.length,
|
||||
settings.maxCount,
|
||||
settings.maxBytes,
|
||||
)
|
||||
) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
"Image storage limit reached",
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const filename = `${id}.png`;
|
||||
try {
|
||||
await fs.writeFile(path.join(settings.localDir, filename), image);
|
||||
} catch (error) {
|
||||
await fs
|
||||
.rm(path.join(settings.localDir, filename), { force: true })
|
||||
.catch(() => undefined);
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_LOCAL_WRITE_FAILED",
|
||||
"Failed to write image to local storage",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
filename,
|
||||
shellPath: path.posix.join(settings.hostPath, filename),
|
||||
storage: "local",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Remote directory (on the SSH host the terminal is connected to) that
|
||||
// uploaded/pasted images are written into. Always POSIX-style: this is a
|
||||
// path on the remote shell, not on the Termix backend's own filesystem.
|
||||
export const REMOTE_IMAGE_DIR = "/tmp/termix-images";
|
||||
|
||||
/** Minimal SFTP surface the remote adapter needs (satisfied by ssh2). */
|
||||
export interface ImageSftpClient {
|
||||
mkdir(
|
||||
dir: string,
|
||||
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
||||
callback?: (err?: Error) => void,
|
||||
): void;
|
||||
createWriteStream(
|
||||
remotePath: string,
|
||||
options?: { mode?: number },
|
||||
): NodeJS.WritableStream;
|
||||
stat?: (
|
||||
dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
attrs?: { mode?: number; mtime?: number },
|
||||
) => void,
|
||||
) => void;
|
||||
lstat?: (
|
||||
dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
attrs?: { mode?: number; mtime?: number },
|
||||
) => void,
|
||||
) => void;
|
||||
chmod?: (
|
||||
dir: string,
|
||||
mode: number,
|
||||
callback: (error?: Error) => void,
|
||||
) => void;
|
||||
readdir?: (
|
||||
dir: string,
|
||||
callback: (error: Error | undefined, entries: ImageSftpEntry[]) => void,
|
||||
) => void;
|
||||
unlink?: (remotePath: string, callback: (error?: Error) => void) => void;
|
||||
rmdir?: (dir: string, callback: (error?: Error) => void) => void;
|
||||
end?: () => void;
|
||||
}
|
||||
|
||||
interface ImageSftpEntry {
|
||||
filename: string;
|
||||
attrs?: { mtime?: number; size?: number };
|
||||
}
|
||||
|
||||
export interface ImageSshExecClient {
|
||||
exec(
|
||||
command: string,
|
||||
callback: (error: Error | undefined, stream?: ImageExecStream) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface ImageExecStream {
|
||||
on(event: "close", listener: (code: number | null) => void): this;
|
||||
on(event: "error", listener: (error: Error) => void): this;
|
||||
resume(): void;
|
||||
}
|
||||
|
||||
function quoteRemotePath(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function execBounded(
|
||||
sshConn: ImageSshExecClient,
|
||||
command: string,
|
||||
timeoutMs = 3_000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
sshConn.exec(command, (error, stream) => {
|
||||
if (error || !stream) {
|
||||
clearTimeout(timer);
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
finish(code === 0);
|
||||
});
|
||||
stream.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
finish(false);
|
||||
});
|
||||
stream.resume();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
operation.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Verify a configured local mapping from the currently connected SSH session. */
|
||||
export async function probeLocalImageVisibility(
|
||||
sshConn: ImageSshExecClient,
|
||||
settings: Pick<TerminalImageStorageSettings, "localDir" | "hostPath">,
|
||||
): Promise<boolean> {
|
||||
const filename = `.termix-image-probe-${randomUUID()}`;
|
||||
const localProbe = path.join(settings.localDir, filename);
|
||||
const remoteProbe = path.posix.join(settings.hostPath, filename);
|
||||
await fs.mkdir(settings.localDir, { recursive: true });
|
||||
await fs.writeFile(localProbe, "termix-image-probe", { flag: "wx" });
|
||||
try {
|
||||
return await execBounded(
|
||||
sshConn,
|
||||
`test -f -- ${quoteRemotePath(remoteProbe)}`,
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(localProbe).catch(() => undefined);
|
||||
await execBounded(sshConn, `rm -f -- ${quoteRemotePath(remoteProbe)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sftpMkdir(sftp: ImageSftpClient, dir: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(dir, { mode: 0o700 }, (err) => {
|
||||
if (!err) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
|
||||
if (!inspect) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
inspect(dir, (inspectError, attrs) => {
|
||||
if (inspectError || !attrs) {
|
||||
reject(inspectError ?? err);
|
||||
return;
|
||||
}
|
||||
if (attrs.mode !== undefined && (attrs.mode & 0o170000) !== 0o040000) {
|
||||
reject(new Error("Remote image path is not a directory"));
|
||||
return;
|
||||
}
|
||||
if (!sftp.chmod) {
|
||||
reject(
|
||||
new Error("Remote image directory permissions cannot be verified"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sftp.chmod(dir, 0o700, (chmodError) => {
|
||||
if (chmodError) reject(chmodError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const REMOTE_IMAGE_LOCK_DIR = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
||||
const REMOTE_IMAGE_LOCK_LEASE_MS = 60_000;
|
||||
|
||||
function waitForRemoteImageLock(
|
||||
sftp: ImageSftpClient,
|
||||
attempts = 60,
|
||||
): Promise<() => Promise<void>> {
|
||||
if (!sftp.rmdir) {
|
||||
return Promise.reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock cannot be verified",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let remaining = attempts;
|
||||
const tryAcquire = () => {
|
||||
sftp.mkdir(REMOTE_IMAGE_LOCK_DIR, { mode: 0o700 }, (error) => {
|
||||
if (!error) {
|
||||
resolve(
|
||||
() =>
|
||||
new Promise<void>((releaseResolve, releaseReject) => {
|
||||
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (releaseError) =>
|
||||
releaseError ? releaseReject(releaseError) : releaseResolve(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
|
||||
if (!inspect) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock cannot be verified",
|
||||
error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inspect(REMOTE_IMAGE_LOCK_DIR, (inspectError, attrs) => {
|
||||
const stale =
|
||||
!inspectError &&
|
||||
typeof attrs?.mtime === "number" &&
|
||||
Date.now() - attrs.mtime * 1000 > REMOTE_IMAGE_LOCK_LEASE_MS;
|
||||
if (stale) {
|
||||
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (removeError) => {
|
||||
if (removeError) {
|
||||
if (--remaining <= 0) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Stale remote image storage lock cannot be removed",
|
||||
removeError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryAcquire, 50);
|
||||
return;
|
||||
}
|
||||
tryAcquire();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (--remaining <= 0) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock is unavailable",
|
||||
error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryAcquire, 50);
|
||||
});
|
||||
});
|
||||
};
|
||||
tryAcquire();
|
||||
});
|
||||
}
|
||||
|
||||
function sftpWriteFile(
|
||||
sftp: ImageSftpClient,
|
||||
remotePath: string,
|
||||
data: Buffer,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = sftp.createWriteStream(remotePath, {
|
||||
mode: 0o600,
|
||||
}) as NodeJS.WritableStream & {
|
||||
destroy?: () => void;
|
||||
};
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stream.destroy?.();
|
||||
reject(new Error("SFTP image write timed out"));
|
||||
}, timeoutMs);
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
stream.on("error", (error: Error) => finish(error));
|
||||
stream.on("close", () => finish());
|
||||
stream.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupExpiredRemoteImages(
|
||||
sftp: ImageSftpClient,
|
||||
ttlMs: number | undefined,
|
||||
nowMs = Date.now(),
|
||||
): Promise<void> {
|
||||
if (!ttlMs || ttlMs <= 0 || !sftp.readdir || !sftp.unlink) return;
|
||||
const entries = await new Promise<ImageSftpEntry[]>((resolve) => {
|
||||
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
|
||||
resolve(error ? [] : result);
|
||||
});
|
||||
});
|
||||
const cutoffSeconds = (nowMs - ttlMs) / 1000;
|
||||
const uuidPng =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
uuidPng.test(entry.filename) &&
|
||||
typeof entry.attrs?.mtime === "number" &&
|
||||
entry.attrs.mtime < cutoffSeconds,
|
||||
)
|
||||
.map((entry) =>
|
||||
withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(`${REMOTE_IMAGE_DIR}/${entry.filename}`, () =>
|
||||
resolve(),
|
||||
);
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const REMOTE_UUID_PNG_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
|
||||
|
||||
async function enforceRemoteImageLimits(
|
||||
sftp: ImageSftpClient,
|
||||
imageBytes: number,
|
||||
maxCount: number,
|
||||
maxBytes: number,
|
||||
): Promise<void> {
|
||||
if (!sftp.readdir) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
);
|
||||
}
|
||||
let entries: ImageSftpEntry[];
|
||||
try {
|
||||
entries = await new Promise<ImageSftpEntry[]>((resolve, reject) => {
|
||||
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
error,
|
||||
);
|
||||
}
|
||||
const images = entries.filter((entry) =>
|
||||
REMOTE_UUID_PNG_PATTERN.test(entry.filename),
|
||||
);
|
||||
const totalBytes = images.reduce((sum, entry) => {
|
||||
if (typeof entry.attrs?.size !== "number") {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
);
|
||||
}
|
||||
return sum + entry.attrs.size;
|
||||
}, 0);
|
||||
if (images.length >= maxCount || totalBytes + imageBytes > maxBytes) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
"Image storage limit reached",
|
||||
);
|
||||
}
|
||||
}
|
||||
async function storeImageViaSftpUnlocked(
|
||||
sftp: ImageSftpClient,
|
||||
image: Buffer,
|
||||
options: {
|
||||
writeTimeoutMs?: number;
|
||||
ttlMs?: number;
|
||||
maxCount?: number;
|
||||
maxBytes?: number;
|
||||
nowMs?: number;
|
||||
} = {},
|
||||
): Promise<StoredTerminalImage> {
|
||||
const id = randomUUID();
|
||||
const filename = `${id}.png`;
|
||||
const remotePath = `${REMOTE_IMAGE_DIR}/${filename}`;
|
||||
|
||||
let releaseRemoteLock: (() => Promise<void>) | undefined;
|
||||
let operationError: TerminalImageStorageError | undefined;
|
||||
try {
|
||||
await withTimeout(
|
||||
sftpMkdir(sftp, REMOTE_IMAGE_DIR),
|
||||
3_000,
|
||||
"SFTP directory operation timed out",
|
||||
);
|
||||
releaseRemoteLock = await withTimeout(
|
||||
waitForRemoteImageLock(sftp),
|
||||
10_000,
|
||||
"SFTP lock operation timed out",
|
||||
);
|
||||
await withTimeout(
|
||||
cleanupExpiredRemoteImages(sftp, options.ttlMs, options.nowMs),
|
||||
5_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
);
|
||||
if (options.maxCount !== undefined || options.maxBytes !== undefined) {
|
||||
await withTimeout(
|
||||
enforceRemoteImageLimits(
|
||||
sftp,
|
||||
image.length,
|
||||
options.maxCount ?? 100,
|
||||
options.maxBytes ?? 5_368_709_120,
|
||||
),
|
||||
5_000,
|
||||
"SFTP quota operation timed out",
|
||||
);
|
||||
}
|
||||
await sftpWriteFile(sftp, remotePath, image, options.writeTimeoutMs);
|
||||
} catch (error) {
|
||||
if (sftp.unlink) {
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(remotePath, () => resolve());
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined);
|
||||
}
|
||||
operationError =
|
||||
error instanceof TerminalImageStorageError
|
||||
? error
|
||||
: new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
"Failed to write image to the remote host",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (releaseRemoteLock) {
|
||||
try {
|
||||
await withTimeout(
|
||||
releaseRemoteLock(),
|
||||
3_000,
|
||||
"SFTP lock release timed out",
|
||||
);
|
||||
} catch (releaseError) {
|
||||
if (!operationError) {
|
||||
if (sftp.unlink) {
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(remotePath, () => resolve());
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined);
|
||||
}
|
||||
operationError = new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
"Failed to release remote image storage lock",
|
||||
releaseError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operationError) throw operationError;
|
||||
|
||||
return { id, filename, shellPath: remotePath, storage: "remote-sftp" };
|
||||
}
|
||||
|
||||
export function storeImageViaSftp(
|
||||
sftp: ImageSftpClient,
|
||||
image: Buffer,
|
||||
options: Parameters<typeof storeImageViaSftpUnlocked>[2] = {},
|
||||
): Promise<StoredTerminalImage> {
|
||||
return withImageStorageLock(() =>
|
||||
withTimeout(
|
||||
storeImageViaSftpUnlocked(sftp, image, options),
|
||||
20_000,
|
||||
"SFTP image operation timed out",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Accepted decoded input formats; uploads are normalized to PNG by the route.
|
||||
export const IMAGE_FORMAT_EXTENSIONS: Record<string, string> = {
|
||||
avif: "avif",
|
||||
gif: "gif",
|
||||
heif: "heif",
|
||||
jpeg: "jpg",
|
||||
jp2: "jp2",
|
||||
jxl: "jxl",
|
||||
png: "png",
|
||||
tiff: "tiff",
|
||||
webp: "webp",
|
||||
};
|
||||
|
||||
export function imageExtensionForFormat(
|
||||
format: string | undefined,
|
||||
): string | undefined {
|
||||
return format ? IMAGE_FORMAT_EXTENSIONS[format] : undefined;
|
||||
}
|
||||
|
||||
export const MAX_NORMALIZED_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function exceedsNormalizedImageSize(
|
||||
byteLength: number,
|
||||
maxBytes = MAX_NORMALIZED_IMAGE_BYTES,
|
||||
): boolean {
|
||||
return byteLength > maxBytes;
|
||||
}
|
||||
|
||||
export function createConcurrencyLimiter(
|
||||
limit: number,
|
||||
maxQueued = Number.POSITIVE_INFINITY,
|
||||
): {
|
||||
acquire: () => Promise<() => void>;
|
||||
readonly active: number;
|
||||
readonly queued: number;
|
||||
} {
|
||||
const max = Math.max(1, Math.floor(limit));
|
||||
const queueLimit = Math.max(0, Math.floor(maxQueued));
|
||||
let active = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
|
||||
const startNext = () => {
|
||||
if (active >= max || waiters.length === 0) return;
|
||||
active += 1;
|
||||
waiters.shift()!();
|
||||
};
|
||||
|
||||
return {
|
||||
acquire: () =>
|
||||
new Promise<() => void>((resolve, reject) => {
|
||||
if (active >= max && waiters.length >= queueLimit) {
|
||||
reject(new Error("Concurrency admission queue is full"));
|
||||
return;
|
||||
}
|
||||
waiters.push(() => {
|
||||
let released = false;
|
||||
resolve(() => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
active -= 1;
|
||||
startNext();
|
||||
});
|
||||
});
|
||||
startNext();
|
||||
}),
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get queued() {
|
||||
return waiters.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const IMAGE_FILENAME_PATTERN = /^[0-9a-f-]{36}\.[a-z0-9]+$/i;
|
||||
|
||||
export function isImageFilename(filename: string): boolean {
|
||||
return IMAGE_FILENAME_PATTERN.test(filename);
|
||||
}
|
||||
|
||||
export function isExpiredImage(
|
||||
modifiedAtMs: number,
|
||||
nowMs: number,
|
||||
ttlMs: number,
|
||||
): boolean {
|
||||
if (ttlMs <= 0) return false;
|
||||
return nowMs - modifiedAtMs > ttlMs;
|
||||
}
|
||||
|
||||
export function exceedsImageStorageLimit(
|
||||
fileCount: number,
|
||||
totalBytes: number,
|
||||
incomingBytes: number,
|
||||
maxFileCount: number,
|
||||
maxStorageBytes: number,
|
||||
): boolean {
|
||||
return (
|
||||
fileCount >= maxFileCount || totalBytes + incomingBytes > maxStorageBytes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createTerminalLogger } from "./helpers.js";
|
||||
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
|
||||
import { createHistoryRepository } from "./history-repository.js";
|
||||
import { registerTerminalRoutes } from "./routes.js";
|
||||
import {
|
||||
DEFAULT_TIMEOUT_MINUTES,
|
||||
TerminalSessionManager,
|
||||
type TerminalSession,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
SESSION_GUESTS_KEY,
|
||||
type LiveSessionInfo,
|
||||
type LiveSessionsV1,
|
||||
type RecordingsWriterV1,
|
||||
type SessionGuestsV1,
|
||||
type SessionSharingV1,
|
||||
type TerminalHistoryV1,
|
||||
type TmuxSessionsV1,
|
||||
} from "./services.js";
|
||||
import { ADMIN_KEYS } from "./settings.js";
|
||||
import { commandHistory } from "./tables.js";
|
||||
import { createTerminalSocket } from "./terminal-socket.js";
|
||||
import {
|
||||
validateAdminSettings,
|
||||
validateUserSettings,
|
||||
} from "./settings-validation.js";
|
||||
|
||||
function toInfo(session: TerminalSession): LiveSessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
isConnected: session.isConnected,
|
||||
createdAt: session.createdAt,
|
||||
lastDetachedAt: session.lastDetachedAt,
|
||||
tabInstanceId:
|
||||
session.attachedTabInstanceId ?? session.tabInstanceId ?? null,
|
||||
tmuxSessionName: session.tmuxSessionName,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A service another plugin may or may not provide. The handle always exists;
|
||||
* whether its provider is up is asked through the proxy's `in` check.
|
||||
*/
|
||||
function optionalService<T extends object>(
|
||||
ctx: PluginContext,
|
||||
service: string,
|
||||
probe: keyof T & string,
|
||||
): T | null {
|
||||
const handle = ctx.services.get<T>(service);
|
||||
return probe in handle ? handle : null;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const log = createTerminalLogger(ctx.log);
|
||||
ctx.settings.onValidate("admin", validateAdminSettings);
|
||||
ctx.settings.onValidate("user", validateUserSettings);
|
||||
|
||||
const table = await ctx.db.define(commandHistory);
|
||||
const history = createHistoryRepository(ctx.db, table);
|
||||
|
||||
// Read on every detach, so it is kept current rather than awaited there.
|
||||
let timeoutMinutes = DEFAULT_TIMEOUT_MINUTES;
|
||||
const applyTimeout = (value: unknown) => {
|
||||
const minutes = Number(value);
|
||||
timeoutMinutes =
|
||||
Number.isFinite(minutes) && minutes > 0
|
||||
? minutes
|
||||
: DEFAULT_TIMEOUT_MINUTES;
|
||||
};
|
||||
const refreshTimeout = async () =>
|
||||
applyTimeout(await ctx.settings.get(ADMIN_KEYS.sessionTimeoutMinutes));
|
||||
await refreshTimeout();
|
||||
ctx.settings.onChange(ADMIN_KEYS.sessionTimeoutMinutes, applyTimeout);
|
||||
|
||||
const sessionManager = new TerminalSessionManager({
|
||||
log,
|
||||
getTimeoutMinutes: () => timeoutMinutes,
|
||||
getRecordings: () =>
|
||||
optionalService<RecordingsWriterV1>(ctx, "recordings.writer", "open"),
|
||||
});
|
||||
ctx.disposables.add(() => sessionManager.destroyAll());
|
||||
|
||||
const socket = createTerminalSocket({
|
||||
ctx,
|
||||
log,
|
||||
sessionManager,
|
||||
getTmux: () =>
|
||||
optionalService<TmuxSessionsV1>(ctx, "tmux.sessions", "detect"),
|
||||
getSharing: () =>
|
||||
optionalService<SessionSharingV1>(
|
||||
ctx,
|
||||
"sessions.sharing",
|
||||
"authorizeJoin",
|
||||
),
|
||||
getGuests: () =>
|
||||
ctx.registry.consume<SessionGuestsV1>(SESSION_GUESTS_KEY) ?? null,
|
||||
});
|
||||
|
||||
// Public with optional auth: a share-link guest arrives with a share token
|
||||
// instead of a session, so that check happens where the token is understood.
|
||||
ctx.ws.route(
|
||||
"/terminal",
|
||||
(connection) => {
|
||||
// Also picks up a value the boot migration wrote after activation.
|
||||
void refreshTimeout().catch(() => {});
|
||||
return socket.handleConnection(connection);
|
||||
},
|
||||
{
|
||||
public: true,
|
||||
optionalAuth: true,
|
||||
},
|
||||
);
|
||||
ctx.disposables.add(() => socket.closeAll());
|
||||
|
||||
registerTerminalRoutes(ctx.http.router<Router>({ rawBody: true }), {
|
||||
ctx,
|
||||
log,
|
||||
sessionManager,
|
||||
history,
|
||||
});
|
||||
|
||||
const liveSessions: LiveSessionsV1 = {
|
||||
getSession: (sessionId) => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return session ? toInfo(session) : null;
|
||||
},
|
||||
listForUser: (userId) => {
|
||||
// A plugin caller only ever sees its actor's sessions; core calls this
|
||||
// outside any actor, for the user its own route authenticated.
|
||||
const actor = ctx.currentActor();
|
||||
if (actor && actor !== userId) return [];
|
||||
return sessionManager.getUserSessions(userId).map(toInfo);
|
||||
},
|
||||
ownerEndSession: (sessionId, reason) =>
|
||||
sessionManager.ownerEndSession(sessionId, reason),
|
||||
disconnectParticipants: (sessionId, shareId, options) =>
|
||||
sessionManager.disconnectShareParticipants(sessionId, shareId, options),
|
||||
listGuests: (sessionId, shareId) =>
|
||||
sessionManager.listShareGuests(sessionId, shareId),
|
||||
setRoomShareControl: (sessionId, shareId, controllerUserId) =>
|
||||
sessionManager.setRoomShareControl(sessionId, shareId, controllerUserId),
|
||||
subscribe: (sessionId, onData) => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) return () => {};
|
||||
session.dataListeners.add(onData);
|
||||
return () => session.dataListeners.delete(onData);
|
||||
},
|
||||
write: (sessionId, data) => {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session?.sshStream || session.sshStream.destroyed) return false;
|
||||
sessionManager.bufferInput(sessionId, data);
|
||||
session.sshStream.write(data);
|
||||
return true;
|
||||
},
|
||||
idleTimeoutMinutes: () => timeoutMinutes,
|
||||
};
|
||||
// sessions.live is keyed by session type; remote desktop provides the others.
|
||||
ctx.services.provide("sessions.live", liveSessions, { name: "ssh" });
|
||||
|
||||
const terminalHistory: TerminalHistoryV1 = {
|
||||
list: async (hostId, limit = 200) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return [];
|
||||
return history.listCommandsForHost(userId, hostId, limit);
|
||||
},
|
||||
};
|
||||
ctx.services.provide("terminal.history", terminalHistory);
|
||||
|
||||
ctx.registry.provide(
|
||||
"ssh-terminal.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
ctx.registry.provide("ssh-terminal.hostPayloadLegacy", hostPayloadLegacy);
|
||||
|
||||
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Keyboard-interactive answering over the terminal WebSocket.
|
||||
*
|
||||
* Core's classifier (ctx.ssh.classifyKeyboardInteractive) decides what a
|
||||
* round is; this keeps the terminal's message protocol (totp_required,
|
||||
* totp_retry, password_required, "<handler>_auth_required" for a browser
|
||||
* sign-in round) and its timeouts exactly as the client expects.
|
||||
*/
|
||||
|
||||
import type { WebSocket } from "ws";
|
||||
import type {
|
||||
PluginBrowserSignInRound,
|
||||
PluginKeyboardInteractivePrompt,
|
||||
PluginSsh,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { TerminalLogger } from "./helpers.js";
|
||||
|
||||
type KeyboardInteractivePrompt = PluginKeyboardInteractivePrompt;
|
||||
|
||||
const PASSWORD_PATTERN = /password/i;
|
||||
|
||||
/** The answer at one index, the stored password for password prompts, else empty. */
|
||||
export function responsesWithAnswer(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
answerIndex: number,
|
||||
answer: string,
|
||||
password: string | null | undefined,
|
||||
): string[] {
|
||||
return prompts.map((p, index) => {
|
||||
if (index === answerIndex) return answer;
|
||||
if (PASSWORD_PATTERN.test(p.prompt) && password) return password;
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
authType?: string;
|
||||
}
|
||||
|
||||
interface HostFlags {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContext {
|
||||
ssh: Pick<PluginSsh, "classifyKeyboardInteractive">;
|
||||
log: TerminalLogger;
|
||||
userId: string;
|
||||
ws: WebSocket;
|
||||
hostId: number;
|
||||
isKeyboardInteractive: boolean;
|
||||
keyboardInteractiveResponded: boolean;
|
||||
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
|
||||
totpPromptSent: boolean;
|
||||
/** The handler whose browser round is waiting for "<id>_auth_continue". */
|
||||
browserSignInId: string | null;
|
||||
totpTimeout: NodeJS.Timeout | null;
|
||||
browserSignInTimeout: NodeJS.Timeout | null;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
const TOTP_TIMEOUT_MS = 180000;
|
||||
const PUSH_TIMEOUT_MS = 300000;
|
||||
const BROWSER_SIGN_IN_TIMEOUT_MS = 300000;
|
||||
|
||||
export class SSHAuthManager {
|
||||
public context: AuthContext;
|
||||
|
||||
constructor(context: AuthContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
handleKeyboardInteractive(
|
||||
name: string,
|
||||
instructions: string,
|
||||
_instructionsLang: string,
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostFlags,
|
||||
): void {
|
||||
this.context.isKeyboardInteractive = true;
|
||||
|
||||
const host = {
|
||||
...(hostConfig ?? {}),
|
||||
id: this.context.hostId,
|
||||
ip: "",
|
||||
port: 22,
|
||||
username: resolvedCredentials.username,
|
||||
password: resolvedCredentials.password,
|
||||
authType: resolvedCredentials.authType,
|
||||
} as PluginSshHost;
|
||||
|
||||
const decision = this.context.ssh.classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host,
|
||||
);
|
||||
|
||||
switch (decision.kind) {
|
||||
case "auto":
|
||||
finish(decision.responses);
|
||||
return;
|
||||
case "browser":
|
||||
this.handleBrowserSignIn(decision, finish);
|
||||
return;
|
||||
case "totp":
|
||||
this.handleTotp(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
finish,
|
||||
host.password as string | undefined,
|
||||
);
|
||||
return;
|
||||
case "input":
|
||||
this.handleInput(
|
||||
prompts,
|
||||
decision.promptIndex,
|
||||
decision.isPush,
|
||||
finish,
|
||||
host.password as string | undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A round finished in a browser. The message is named after the handler
|
||||
* that claimed it (Termix-Mobile listens for these names), and the client
|
||||
* answers with "<id>_auth_continue".
|
||||
*/
|
||||
private handleBrowserSignIn(
|
||||
round: PluginBrowserSignInRound,
|
||||
finish: (responses: string[]) => void,
|
||||
): void {
|
||||
this.context.keyboardInteractiveFinish = () => {
|
||||
finish([""]);
|
||||
};
|
||||
this.context.browserSignInId = round.id;
|
||||
this.sendLog("auth", "info", `${round.label} sign-in required`);
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: `${round.id}_auth_required`,
|
||||
kind: "browser",
|
||||
label: round.label,
|
||||
url: round.url,
|
||||
securityKey: round.code,
|
||||
instructions: round.instructions,
|
||||
}),
|
||||
);
|
||||
|
||||
this.context.browserSignInTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.browserSignInId = null;
|
||||
this.context.log.warn("Browser sign-in timeout", {
|
||||
operation: "browser_sign_in_timeout",
|
||||
handler: round.id,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `${round.label} sign-in timed out. Please reconnect.`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, BROWSER_SIGN_IN_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private handleTotp(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
promptIndex: number,
|
||||
finish: (responses: string[]) => void,
|
||||
password: string | undefined,
|
||||
): void {
|
||||
if (this.context.totpPromptSent) {
|
||||
this.context.log.warn("TOTP prompt asked again - invalid code", {
|
||||
operation: "ssh_keyboard_interactive_totp_retry",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.log.warn("TOTP verification failed for SSH session", {
|
||||
operation: "terminal_totp_failed",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.sendLog("auth", "warning", "Invalid TOTP code");
|
||||
this.context.ws.send(JSON.stringify({ type: "totp_retry" }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.totpPromptSent = true;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
this.context.keyboardInteractiveFinish = (answers: string[]) => {
|
||||
finish(
|
||||
responsesWithAnswer(
|
||||
prompts,
|
||||
promptIndex,
|
||||
(answers[0] || "").trim(),
|
||||
password,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
this.armTimeout(TOTP_TIMEOUT_MS, "totp");
|
||||
this.sendLog("auth", "info", "TOTP verification required");
|
||||
this.context.log.info("TOTP verification prompt sent to client", {
|
||||
operation: "terminal_totp_prompt",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleInput(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
promptIndex: number,
|
||||
isPush: boolean,
|
||||
finish: (responses: string[]) => void,
|
||||
password: string | undefined,
|
||||
): void {
|
||||
if (this.context.keyboardInteractiveResponded) return;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (answers: string[]) => {
|
||||
finish(
|
||||
responsesWithAnswer(
|
||||
prompts,
|
||||
promptIndex,
|
||||
(answers[0] || "").trim(),
|
||||
password,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password");
|
||||
this.sendLog("auth", "info", "Password authentication required");
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
echo: prompts[promptIndex].echo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private armTimeout(ms: number, kind: "totp" | "password"): void {
|
||||
if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (!this.context.keyboardInteractiveFinish) return;
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
if (kind === "totp") {
|
||||
this.context.totpPromptSent = false;
|
||||
} else {
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
this.context.log.warn(
|
||||
`${kind === "totp" ? "TOTP" : "Password"} prompt timeout`,
|
||||
{
|
||||
operation: `${kind}_timeout`,
|
||||
hostId: this.context.hostId,
|
||||
},
|
||||
);
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
kind === "totp"
|
||||
? "TOTP verification timeout. Please reconnect."
|
||||
: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
sendLog(
|
||||
stage: string,
|
||||
level: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "connection_log",
|
||||
data: { stage, level, message, details },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = null;
|
||||
}
|
||||
if (this.context.browserSignInTimeout) {
|
||||
clearTimeout(this.context.browserSignInTimeout);
|
||||
this.context.browserSignInTimeout = null;
|
||||
}
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
this.context.browserSignInId = null;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
type Router,
|
||||
} from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import multer from "multer";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { getErrorMessage, type TerminalLogger } from "./helpers.js";
|
||||
import type { HistoryRepository } from "./history-repository.js";
|
||||
import type { TerminalSessionManager } from "./session-manager.js";
|
||||
import {
|
||||
ADMIN_KEYS,
|
||||
HOST_KEYS,
|
||||
MOVABLE_USER_KEYS,
|
||||
readClientSettings,
|
||||
readImageStorageSettings,
|
||||
} from "./settings.js";
|
||||
import {
|
||||
createConcurrencyLimiter,
|
||||
exceedsNormalizedImageSize,
|
||||
imageExtensionForFormat,
|
||||
} from "./images/image-utils.js";
|
||||
import {
|
||||
selectImageStorageMode,
|
||||
probeLocalImageVisibility,
|
||||
storeImageLocally,
|
||||
storeImageViaSftp,
|
||||
TerminalImageStorageError,
|
||||
type ImageSftpClient,
|
||||
} from "./images/image-storage.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export const SENSITIVE_COMMAND_PATTERNS = [
|
||||
/passw(or)?d/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/PASS(WORD)?=/i,
|
||||
/AWS_SECRET/i,
|
||||
/mysql\b.*-p/i,
|
||||
/sudo\s+-S\b/,
|
||||
/htpasswd/i,
|
||||
/sshpass/i,
|
||||
/curl\b.*-u\s/i,
|
||||
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
type SharpFactory = typeof import("sharp").default;
|
||||
|
||||
export interface TerminalRouteDeps {
|
||||
ctx: PluginContext;
|
||||
log: TerminalLogger;
|
||||
sessionManager: TerminalSessionManager;
|
||||
history: HistoryRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal's HTTP routes, on a router core mounts at
|
||||
* /plugin-api/ssh-terminal/. It is created with rawBody so multer can read
|
||||
* the upload stream; JSON routes parse their own body.
|
||||
*/
|
||||
export function registerTerminalRoutes(
|
||||
router: Router,
|
||||
{ ctx, log, sessionManager, history }: TerminalRouteDeps,
|
||||
): void {
|
||||
const json = express.json({ limit: "2mb" });
|
||||
|
||||
// Browser image handoff for local terminal-agent workflows.
|
||||
const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024,
|
||||
fields: 4,
|
||||
fieldSize: 64 * 1024,
|
||||
files: 1,
|
||||
parts: 5,
|
||||
headerPairs: 200,
|
||||
},
|
||||
});
|
||||
const imageUploadMiddleware = imageUpload.single("image");
|
||||
|
||||
let sharpFactory: SharpFactory | null = null;
|
||||
let sharpLoadFailure: string | null = null;
|
||||
|
||||
// A static import of sharp takes the plugin down with it when the native
|
||||
// binary for the running architecture is missing. Resolve on first use so
|
||||
// only image upload is lost.
|
||||
async function loadSharp(): Promise<SharpFactory> {
|
||||
if (sharpFactory) return sharpFactory;
|
||||
if (sharpLoadFailure !== null) throw new Error(sharpLoadFailure);
|
||||
try {
|
||||
sharpFactory = (await import("sharp")).default;
|
||||
return sharpFactory;
|
||||
} catch (error) {
|
||||
sharpLoadFailure = getErrorMessage(error, "unknown");
|
||||
throw new Error(sharpLoadFailure);
|
||||
}
|
||||
}
|
||||
|
||||
const imageProcessingLimiter = createConcurrencyLimiter(4, 4);
|
||||
const imageMultipartAdmissionLimiter = createConcurrencyLimiter(4, 4);
|
||||
let imageUploadSequence = 0;
|
||||
|
||||
async function handleImageUploadMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
let releaseAdmission: (() => void) | undefined;
|
||||
try {
|
||||
releaseAdmission = await imageMultipartAdmissionLimiter.acquire();
|
||||
} catch {
|
||||
res.status(503).json({
|
||||
error: "Image upload capacity is temporarily unavailable",
|
||||
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
imageUploadMiddleware(req, res, (error: unknown) => {
|
||||
try {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (error instanceof multer.MulterError) {
|
||||
log.warn("Image upload multipart request rejected", {
|
||||
operation: "terminal_image_upload_multipart_rejected",
|
||||
code: error.code,
|
||||
field: error.field,
|
||||
contentType: req.headers["content-type"]?.split(";", 1)[0],
|
||||
});
|
||||
res.status(400).json({
|
||||
error: "Image upload request rejected",
|
||||
code: error.code,
|
||||
field: error.field,
|
||||
});
|
||||
return;
|
||||
}
|
||||
log.warn("Image upload multipart request malformed", {
|
||||
operation: "terminal_image_upload_multipart_invalid",
|
||||
contentType: req.headers["content-type"]?.split(";", 1)[0],
|
||||
});
|
||||
res.status(400).json({
|
||||
error: "Malformed image upload request",
|
||||
code: "IMAGE_MULTIPART_INVALID",
|
||||
});
|
||||
} finally {
|
||||
releaseAdmission?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findTerminalSession(userId: string, instanceId: string) {
|
||||
return sessionManager
|
||||
.getUserSessions(userId)
|
||||
.find(
|
||||
(session) =>
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
instanceId && session.isConnected,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/image-upload:
|
||||
* post:
|
||||
* summary: Upload an image into a terminal session
|
||||
* description: Normalizes an uploaded or pasted image to PNG and stores it where the terminal's programs can read it, over SFTP on the connected host or in the backend's mapped local storage, depending on the image storage settings. Returns the path to hand to the program.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* image:
|
||||
* type: string
|
||||
* format: binary
|
||||
* instanceId:
|
||||
* type: string
|
||||
* source:
|
||||
* type: string
|
||||
* enum: [file, clipboard]
|
||||
* clientUploadTimestamp:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The stored image's path.
|
||||
* 400:
|
||||
* description: Missing or invalid image or session.
|
||||
* 409:
|
||||
* description: The terminal is not connected.
|
||||
* 413:
|
||||
* description: The normalized image is too large.
|
||||
* 502:
|
||||
* description: Writing to the remote host failed.
|
||||
* 503:
|
||||
* description: Image storage or processing is unavailable.
|
||||
* 507:
|
||||
* description: The image storage limit was reached.
|
||||
*/
|
||||
router.post(
|
||||
"/image-upload",
|
||||
handleImageUploadMiddleware,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor();
|
||||
const instanceId = req.body?.instanceId;
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: "Image required",
|
||||
code: "IMAGE_FILE_MISSING",
|
||||
});
|
||||
}
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const sequence = ++imageUploadSequence;
|
||||
const source =
|
||||
req.body?.source === "file" || req.body?.source === "clipboard"
|
||||
? req.body.source
|
||||
: undefined;
|
||||
const clientUploadTimestamp =
|
||||
typeof req.body?.clientUploadTimestamp === "string" &&
|
||||
!Number.isNaN(Date.parse(req.body.clientUploadTimestamp))
|
||||
? req.body.clientUploadTimestamp
|
||||
: undefined;
|
||||
const serverReceivedAt = new Date().toISOString();
|
||||
log.info("Terminal image upload received", {
|
||||
operation: "terminal_image_upload_received",
|
||||
requestId,
|
||||
sequence,
|
||||
source,
|
||||
clientUploadTimestamp,
|
||||
serverReceivedAt,
|
||||
bytes: req.file.size,
|
||||
});
|
||||
|
||||
const storageSettings = await readImageStorageSettings(ctx);
|
||||
const session = isNonEmptyString(instanceId)
|
||||
? findTerminalSession(userId, instanceId)
|
||||
: undefined;
|
||||
let localHostVisible = false;
|
||||
if (storageSettings.localMappingConfigured && session?.sshConn) {
|
||||
localHostVisible = await probeLocalImageVisibility(
|
||||
session.sshConn,
|
||||
storageSettings,
|
||||
).catch(() => false);
|
||||
}
|
||||
const storageMode = selectImageStorageMode(storageSettings, {
|
||||
remoteSftpAvailable: !!session?.sshConn,
|
||||
localHostVisible,
|
||||
});
|
||||
|
||||
if (storageMode === "unavailable") {
|
||||
return res.status(503).json({
|
||||
error: "Image storage is unavailable",
|
||||
code: "IMAGE_STORAGE_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (storageMode === "local" && !storageSettings.localMappingConfigured) {
|
||||
return res.status(503).json({
|
||||
error: "Local image storage is not configured",
|
||||
code: "IMAGE_LOCAL_STORAGE_NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
if (storageMode === "remote-sftp") {
|
||||
if (!isNonEmptyString(instanceId)) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
if (!session || !session.sshConn) {
|
||||
return res.status(409).json({
|
||||
error: "Terminal is not connected",
|
||||
code: "IMAGE_TERMINAL_NOT_CONNECTED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let sharp: SharpFactory;
|
||||
try {
|
||||
sharp = await loadSharp();
|
||||
} catch (error) {
|
||||
log.error("Image processing unavailable: sharp failed to load", error, {
|
||||
operation: "terminal_image_upload_sharp_unavailable",
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
});
|
||||
return res.status(503).json({
|
||||
error: "Image processing is unavailable on this installation",
|
||||
code: "IMAGE_PROCESSING_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
let normalizedImage: Buffer;
|
||||
let releaseImageProcessingSlot: (() => void) | undefined;
|
||||
try {
|
||||
releaseImageProcessingSlot = await imageProcessingLimiter.acquire();
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
error: "Image upload capacity is temporarily exhausted",
|
||||
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const image = sharp(req.file.buffer, {
|
||||
failOn: "error",
|
||||
limitInputPixels: 40_000_000,
|
||||
});
|
||||
const { format } = await image.metadata();
|
||||
if (!imageExtensionForFormat(format)) {
|
||||
return res.status(400).json({
|
||||
error: "Unsupported image format",
|
||||
code: "IMAGE_FORMAT_UNSUPPORTED",
|
||||
});
|
||||
}
|
||||
normalizedImage = await image.rotate().png().toBuffer();
|
||||
if (exceedsNormalizedImageSize(normalizedImage.length)) {
|
||||
return res.status(413).json({
|
||||
error: "Normalized image is too large",
|
||||
code: "IMAGE_NORMALIZED_SIZE_LIMIT",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Image upload failed image decoding", {
|
||||
operation: "terminal_image_upload_decode",
|
||||
mimeType: req.file.mimetype,
|
||||
bytes: req.file.size,
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid image data",
|
||||
code: "IMAGE_DECODE_FAILED",
|
||||
});
|
||||
} finally {
|
||||
releaseImageProcessingSlot?.();
|
||||
}
|
||||
|
||||
let remoteSftp: ImageSftpClient | undefined;
|
||||
try {
|
||||
const stored =
|
||||
storageMode === "remote-sftp"
|
||||
? await (async () => {
|
||||
remoteSftp = await new Promise<ImageSftpClient>(
|
||||
(resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
reject(new Error("SFTP channel acquisition timed out"));
|
||||
}, 3_000);
|
||||
session!.sshConn!.sftp((err, sftp) => {
|
||||
if (settled) {
|
||||
sftp?.end?.();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) return reject(err);
|
||||
resolve(sftp);
|
||||
});
|
||||
},
|
||||
);
|
||||
return storeImageViaSftp(remoteSftp, normalizedImage, {
|
||||
ttlMs: storageSettings.ttlMs,
|
||||
maxCount: storageSettings.maxCount,
|
||||
maxBytes: storageSettings.maxBytes,
|
||||
});
|
||||
})()
|
||||
: await storeImageLocally(normalizedImage, storageSettings);
|
||||
|
||||
res.json(stored);
|
||||
} catch (error) {
|
||||
if (error instanceof TerminalImageStorageError) {
|
||||
const status =
|
||||
error.code === "IMAGE_STORAGE_LIMIT_REACHED"
|
||||
? 507
|
||||
: error.code === "IMAGE_REMOTE_WRITE_FAILED"
|
||||
? 502
|
||||
: error.code === "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
|
||||
? 503
|
||||
: error.code === "IMAGE_LOCAL_INSPECTION_FAILED"
|
||||
? 503
|
||||
: 500;
|
||||
log.warn("Image upload storage write failed", {
|
||||
operation:
|
||||
error.code === "IMAGE_REMOTE_WRITE_FAILED"
|
||||
? "terminal_image_upload_sftp_failed"
|
||||
: "terminal_image_upload_local_failed",
|
||||
code: error.code,
|
||||
userId,
|
||||
instanceId,
|
||||
reason: getErrorMessage(error.cause ?? error, "unknown"),
|
||||
});
|
||||
return res.status(status).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
log.warn("Image upload failed to acquire remote channel", {
|
||||
operation: "terminal_image_upload_sftp_failed",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
userId,
|
||||
instanceId,
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
});
|
||||
return res.status(502).json({
|
||||
error: "Failed to write image to the remote host",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
});
|
||||
} finally {
|
||||
remoteSftp?.end?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/image-storage/test:
|
||||
* post:
|
||||
* summary: Test image storage visibility (admin only)
|
||||
* description: Reports which storage mode an upload would take for one of the caller's already-connected terminal sessions. Uses the bounded local-mapping probe only; it never opens new connections.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* instanceId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Visibility test result.
|
||||
* 400:
|
||||
* description: Missing terminal session instanceId.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to run the test.
|
||||
*/
|
||||
router.post("/image-storage/test", json, async (req, res) => {
|
||||
if (!(await ctx.rbac.has("admin.plugins.manage"))) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const userId = ctx.currentActor() ?? "";
|
||||
const instanceId = req.body?.instanceId;
|
||||
if (typeof instanceId !== "string" || instanceId.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readImageStorageSettings(ctx);
|
||||
const session = findTerminalSession(userId, instanceId);
|
||||
const remoteSftpAvailable = !!session?.sshConn;
|
||||
|
||||
let localHostVisible: boolean | null = null;
|
||||
if (settings.localMappingConfigured && session?.sshConn) {
|
||||
localHostVisible = await probeLocalImageVisibility(
|
||||
session.sshConn,
|
||||
settings,
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
const selectedMode = selectImageStorageMode(settings, {
|
||||
remoteSftpAvailable,
|
||||
...(localHostVisible !== null ? { localHostVisible } : {}),
|
||||
});
|
||||
|
||||
res.json({
|
||||
mode: settings.mode,
|
||||
connected: !!session,
|
||||
remoteSftpAvailable,
|
||||
localHostVisible,
|
||||
selectedMode,
|
||||
localMappingConfigured: settings.localMappingConfigured,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("Failed to test image storage visibility", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to test image storage visibility" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/client-settings:
|
||||
* get:
|
||||
* summary: Terminal settings every user's terminal needs
|
||||
* description: The admin-set session timeout and persistence flag, whether command history is on, and the touch input tuning. Readable by any signed-in user; changing them goes through the plugin's admin settings.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The settings.
|
||||
* 500:
|
||||
* description: Failed to load settings.
|
||||
*/
|
||||
router.get("/client-settings", async (_req, res) => {
|
||||
try {
|
||||
res.json(await readClientSettings(ctx));
|
||||
} catch (err) {
|
||||
log.error("Failed to load terminal client settings", err);
|
||||
res.status(500).json({ error: "Failed to load settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/user-settings:
|
||||
* put:
|
||||
* summary: Save some of the caller's terminal settings
|
||||
* description: Writes the caller's local echo, link click and command autocomplete settings. The terminal uses it once, to move values a browser kept before 2.9.0.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* localEcho:
|
||||
* type: string
|
||||
* linkClickBehavior:
|
||||
* type: string
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: A value the setting does not accept.
|
||||
*/
|
||||
router.put("/user-settings", json, async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return res.status(401).json({ error: "Sign in first" });
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
for (const key of MOVABLE_USER_KEYS) {
|
||||
if (body[key] !== undefined) {
|
||||
await ctx.settings.setUser(userId, key, body[key]);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: getErrorMessage(err, "Invalid setting") });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/hosts/{hostId}/auto-tmux:
|
||||
* put:
|
||||
* summary: Turn auto tmux on or off for a host
|
||||
* description: Sets the host's autoTmux terminal setting. Used by the "enable Auto-Tmux" action shown when a persisted session expires. Needs edit access to the host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: Invalid host id or value.
|
||||
* 403:
|
||||
* description: No edit access to the host.
|
||||
*/
|
||||
router.put("/hosts/:hostId/auto-tmux", json, async (req, res) => {
|
||||
const hostId = Number(req.params.hostId);
|
||||
const enabled = (req.body ?? {}).enabled;
|
||||
if (!Number.isInteger(hostId) || typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid host or value" });
|
||||
}
|
||||
try {
|
||||
const access = await ctx.hosts.checkAccess(hostId, "edit");
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied to host" });
|
||||
}
|
||||
await ctx.settings.setHost(hostId, HOST_KEYS.autoTmux, enabled);
|
||||
res.json({ success: true, autoTmux: enabled });
|
||||
} catch (err) {
|
||||
log.error("Failed to update auto tmux", err);
|
||||
res.status(500).json({ error: "Failed to update auto tmux" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history:
|
||||
* post:
|
||||
* summary: Save command to history
|
||||
* description: Saves a command to the caller's history for a host. Commands that look like they carry a secret, and anything while command history is off globally or for the host, are acknowledged but not stored.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Command saved, or skipped on purpose.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Failed to save command.
|
||||
*/
|
||||
router.post("/command-history", json, async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, command } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
log.warn("Invalid command history save request", {
|
||||
operation: "command_history_save",
|
||||
userId,
|
||||
hasHostId: !!hostId,
|
||||
hasCommand: !!command,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
const trimmedCommand = command.trim();
|
||||
const skipped = {
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (SENSITIVE_COMMAND_PATTERNS.some((p) => p.test(trimmedCommand))) {
|
||||
return res.status(201).json(skipped);
|
||||
}
|
||||
if ((await ctx.settings.get(ADMIN_KEYS.commandHistoryEnabled)) === false) {
|
||||
return res.status(201).json(skipped);
|
||||
}
|
||||
if (
|
||||
(await ctx.settings.getHost(
|
||||
hostIdNum,
|
||||
HOST_KEYS.enableCommandHistory,
|
||||
)) === false
|
||||
) {
|
||||
return res.status(201).json(skipped);
|
||||
}
|
||||
|
||||
try {
|
||||
const executedAt = new Date().toISOString();
|
||||
await history.create(userId, hostIdNum, trimmedCommand, executedAt);
|
||||
res.status(201).json({
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
command: trimmedCommand,
|
||||
executedAt,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("Failed to save command to history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to save command"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/{hostId}:
|
||||
* get:
|
||||
* summary: Get command history
|
||||
* description: The caller's distinct commands on a host, most recently used first, for autocomplete.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid request parameters.
|
||||
* 500:
|
||||
* description: Failed to fetch history.
|
||||
*/
|
||||
router.get("/command-history/:hostId", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdNum = parseInt(String(req.params.hostId), 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
log.warn("Invalid command history fetch request", {
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid request parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(await history.listUniqueCommandsForHost(userId, hostIdNum));
|
||||
} catch (err) {
|
||||
log.error("Failed to fetch command history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to fetch history"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/{hostId}/recent:
|
||||
* get:
|
||||
* summary: Get recent command history
|
||||
* description: The caller's commands on a host, newest first, duplicates included. Needs hosts.view.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid host id.
|
||||
* 403:
|
||||
* description: Missing the hosts.view permission.
|
||||
* 500:
|
||||
* description: Failed to fetch command history.
|
||||
*/
|
||||
router.get("/command-history/:hostId/recent", async (req, res) => {
|
||||
if (!(await ctx.rbac.has("hosts.view"))) {
|
||||
return res.status(403).json({ error: "Insufficient permissions" });
|
||||
}
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdNum = parseInt(String(req.params.hostId), 10);
|
||||
if (!isNonEmptyString(userId) || !hostIdNum) {
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
try {
|
||||
const rows = await history.listCommandsForHost(userId, hostIdNum);
|
||||
res.json(rows.map((row) => row.command));
|
||||
} catch (err) {
|
||||
log.error("Failed to fetch command history from database", err, {
|
||||
operation: "command_history_fetch",
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch command history" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/delete:
|
||||
* post:
|
||||
* summary: Delete a specific command from history
|
||||
* description: Deletes one command from the caller's history for a host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command deleted successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Failed to delete command.
|
||||
*/
|
||||
router.post("/command-history/delete", json, async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const { hostId, command } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
|
||||
log.warn("Invalid command delete request", {
|
||||
operation: "command_history_delete",
|
||||
userId,
|
||||
hasHostId: !!hostId,
|
||||
hasCommand: !!command,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
await history.deleteCommandForHost(
|
||||
userId,
|
||||
parseInt(hostId, 10),
|
||||
command.trim(),
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error("Failed to delete command from history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to delete command"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history/{hostId}:
|
||||
* delete:
|
||||
* summary: Clear command history
|
||||
* description: Clears the caller's whole command history for a host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command history cleared successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 500:
|
||||
* description: Failed to clear history.
|
||||
*/
|
||||
router.delete("/command-history/:hostId", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
const hostIdNum = parseInt(String(req.params.hostId), 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
log.warn("Invalid command history clear request");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
await history.deleteByUserAndHost(userId, hostIdNum);
|
||||
log.info("Terminal history cleared", {
|
||||
operation: "terminal_history_clear",
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error("Failed to clear command history", err);
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to clear history"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The services this plugin provides and consumes.
|
||||
*
|
||||
* Provided: "sessions.live" as its "ssh" provider (live SSH sessions, for
|
||||
* session sharing, collab rooms and recording; remote desktop provides the
|
||||
* other session types) and "terminal.history" (command history, for the AI
|
||||
* assistant). Consumed, all optional: "tmux.sessions" (tmux-monitor),
|
||||
* "sessions.sharing" (session-sharing) and "recordings.writer" (B13). The
|
||||
* terminal keeps working without any of them: no tmux attach, no shared
|
||||
* joins, no recording.
|
||||
*/
|
||||
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
lastDetachedAt: number | null;
|
||||
tabInstanceId: string | null;
|
||||
tmuxSessionName: string | null;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface LiveSessionsV1 {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
/** The caller's own sessions. A plugin caller only ever gets its actor's. */
|
||||
listForUser: (userId: string) => LiveSessionInfo[];
|
||||
/** Tells every guest the owner ended it, then closes the session. */
|
||||
ownerEndSession: (sessionId: string, reason: string) => void;
|
||||
/** Removes the participants that joined through one share. */
|
||||
disconnectParticipants: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
) => number;
|
||||
/** The anonymous link guests watching through one share. */
|
||||
listGuests: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
) => { label: string | null }[];
|
||||
/** Hands input control of a room share to one member, or back to the owner. */
|
||||
setRoomShareControl: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
) => void;
|
||||
/** Output as it arrives. Returns the unsubscribe. */
|
||||
subscribe: (sessionId: string, onData: (data: string) => void) => () => void;
|
||||
/** Types into the session. False when it is not live. */
|
||||
write: (sessionId: string, data: string) => boolean;
|
||||
/** How long a detached session is kept, in minutes. */
|
||||
idleTimeoutMinutes: () => number;
|
||||
}
|
||||
|
||||
export interface CommandHistoryEntry {
|
||||
command: string;
|
||||
executedAt: string;
|
||||
}
|
||||
|
||||
export interface TerminalHistoryV1 {
|
||||
/** The acting user's most recent commands on one host, newest first. */
|
||||
list: (hostId: number, limit?: number) => Promise<CommandHistoryEntry[]>;
|
||||
}
|
||||
|
||||
export interface TmuxDetection {
|
||||
available: boolean;
|
||||
sessions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the terminal asks of the tmux plugin. Clients are ssh2 objects. Every
|
||||
* method is async to a consumer: a service call goes through the permission
|
||||
* check and the audit line first.
|
||||
*/
|
||||
export interface TmuxSessionsV1 {
|
||||
detect: (client: unknown) => Promise<TmuxDetection>;
|
||||
/** Writes the attach (or new-session) command into a shell stream. */
|
||||
attachOrCreate: (
|
||||
stream: unknown,
|
||||
name?: string,
|
||||
newName?: string,
|
||||
hostId?: number,
|
||||
) => Promise<void>;
|
||||
/** Waits for a new session to exist and returns its confirmed name. */
|
||||
waitForSession: (client: unknown, name: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface SharedSessionRef {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
}
|
||||
|
||||
/** For signed-in members; every call runs as the acting user. */
|
||||
export interface SessionSharingV1 {
|
||||
/** A user share targeting the actor, or a room stage they are a member of. */
|
||||
authorizeJoin: (
|
||||
shareId: string,
|
||||
) => Promise<{ share: SharedSessionRef; displayName: string } | null>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
/** Joins the socket to a collab room's event fan-out. False when not a member. */
|
||||
subscribeRoom: (roomId: string, socket: unknown) => Promise<boolean>;
|
||||
unsubscribeRoom: (socket: unknown, roomId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest joins through a share link or a room guest link. A guest has no user,
|
||||
* so the per-call RBAC check a service runs cannot apply: the token is the
|
||||
* authority. Published on ctx.registry as SESSION_GUESTS_KEY instead.
|
||||
*/
|
||||
export interface SessionGuestsV1 {
|
||||
resolve: (request: {
|
||||
shareToken?: string;
|
||||
roomGuestToken?: string;
|
||||
clientIp: string;
|
||||
}) => Promise<
|
||||
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
|
||||
>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SESSION_GUESTS_KEY = "session-sharing.guests";
|
||||
|
||||
export interface RecordingSink {
|
||||
/** Appends one batch; the first batch starts with the asciicast header. */
|
||||
append: (chunk: string) => Promise<void>;
|
||||
/** Writes or updates the recording row. */
|
||||
persist: (summary: {
|
||||
endedAt: number;
|
||||
durationSeconds: number;
|
||||
terminatedByOwner: boolean;
|
||||
terminationReason: string | null;
|
||||
}) => Promise<void>;
|
||||
/** Nothing was recorded; drop whatever was set up. */
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export interface RecordingsWriterV1 {
|
||||
/** Null when recording is off for this user or host. */
|
||||
open: (meta: {
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
protocol: "ssh";
|
||||
format: "asciicast";
|
||||
startedAt: number;
|
||||
}) => Promise<RecordingSink | null>;
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { type Client, type ClientChannel } from "ssh2";
|
||||
import { WebSocket } from "ws";
|
||||
import type { TerminalLogger } from "./helpers.js";
|
||||
import type { RecordingSink, RecordingsWriterV1 } from "./services.js";
|
||||
|
||||
const MAX_BUFFER_BYTES = 512 * 1024;
|
||||
export const DEFAULT_TIMEOUT_MINUTES = 30;
|
||||
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
||||
const MAX_SESSIONS_PER_USER = 10;
|
||||
// Coalesces recording writes: a chatty SSH stream can emit dozens of "data"
|
||||
// events per second, and appending to disk on every single one saturates the
|
||||
// libuv threadpool (default size 4), starving unrelated fs/DNS/crypto work
|
||||
// and stalling the WS ping/pong health check enough to look like connection
|
||||
// drops. Batch pending lines and flush on a short trailing edge instead.
|
||||
const RECORDING_FLUSH_INTERVAL_MS = 300;
|
||||
|
||||
export interface SessionParticipant {
|
||||
ws: WebSocket;
|
||||
userId: string | null; // null for anonymous link guests
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
isOwner: boolean;
|
||||
displayName?: string;
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
joinedViaShareId?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
tabInstanceId?: string;
|
||||
attachedTabInstanceId?: string;
|
||||
|
||||
sshConn: Client | null;
|
||||
sshStream: ClientChannel | null;
|
||||
jumpClient: Client | null;
|
||||
|
||||
cols: number;
|
||||
rows: number;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
|
||||
participants: Map<string, SessionParticipant>;
|
||||
lastDetachedAt: number | null;
|
||||
detachTimeout: NodeJS.Timeout | null;
|
||||
|
||||
outputBuffer: string[];
|
||||
outputBufferBytes: number;
|
||||
/** Output listeners from the sessions.live service. */
|
||||
dataListeners: Set<(data: string) => void>;
|
||||
/** Resolves once the recordings service answered; null when off. */
|
||||
recordingSink: Promise<RecordingSink | null> | null;
|
||||
recordingHeader: string | null;
|
||||
recordingBytes: number;
|
||||
recordingWriteChain: Promise<void>;
|
||||
recordingPersistChain: Promise<void>;
|
||||
pendingRecordingData: string;
|
||||
recordingFlushTimer: NodeJS.Timeout | null;
|
||||
tmuxSessionName: string | null;
|
||||
sessionLoggingEnabled: boolean;
|
||||
sessionStartedAt: number;
|
||||
lastPersistedBytes: number;
|
||||
terminatedByOwner: boolean;
|
||||
terminationReason: string | null;
|
||||
}
|
||||
|
||||
/** Message types a non-owner participant may legally send. */
|
||||
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
|
||||
"input",
|
||||
"ping",
|
||||
"disconnect",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Server-side gate for whether a participant may send a given WS message
|
||||
* type. The owner may send anything; non-owners are limited to input (if
|
||||
* read-write), ping, and disconnect. Pure function so read-only enforcement
|
||||
* is unit-testable without a real WebSocketServer.
|
||||
*/
|
||||
export function isMessageAllowedForParticipant(
|
||||
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
|
||||
messageType: string,
|
||||
): boolean {
|
||||
if (!participant || participant.isOwner) return true;
|
||||
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
|
||||
if (messageType === "input" && participant.permissionLevel === "read-only") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface SessionManagerDeps {
|
||||
log: TerminalLogger;
|
||||
/** Minutes a detached session is kept; read on every detach. */
|
||||
getTimeoutMinutes: () => number;
|
||||
/** The recordings.writer service, when a plugin provides it. */
|
||||
getRecordings: () => RecordingsWriterV1 | null;
|
||||
}
|
||||
|
||||
export class TerminalSessionManager {
|
||||
private sessions = new Map<string, TerminalSession>();
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private readonly log: TerminalLogger;
|
||||
|
||||
constructor(private readonly deps: SessionManagerDeps) {
|
||||
this.log = deps.log;
|
||||
this.healthCheckTimer = setInterval(
|
||||
() => this.healthCheck(),
|
||||
HEALTH_CHECK_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
createSession(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
tabInstanceId?: string,
|
||||
sessionLoggingEnabled = true,
|
||||
): string {
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
||||
const detached = userSessions
|
||||
.filter((s) => this.getOwnerParticipant(s) === null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.lastDetachedAt ?? a.createdAt) -
|
||||
(b.lastDetachedAt ?? b.createdAt),
|
||||
);
|
||||
if (detached.length > 0) {
|
||||
this.destroySession(detached[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
if (tabInstanceId) {
|
||||
const tabSessions = userSessions.filter(
|
||||
(s) => s.tabInstanceId === tabInstanceId,
|
||||
);
|
||||
for (const existing of tabSessions) {
|
||||
const isLiveSession =
|
||||
existing.isConnected &&
|
||||
existing.sshStream != null &&
|
||||
!existing.sshStream.destroyed;
|
||||
if (isLiveSession) {
|
||||
// Don't destroy a live session (even if detached) — the caller should attach instead
|
||||
this.log.warn(
|
||||
"Tab instance has live session, skipping duplicate create",
|
||||
{
|
||||
operation: "session_tab_duplicate_skip",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
|
||||
},
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
this.log.warn("Tab instance already has session, destroying old", {
|
||||
operation: "session_tab_duplicate_cleanup",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
});
|
||||
this.destroySession(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
let recordingSink: Promise<RecordingSink | null> | null = null;
|
||||
let recordingHeader: string | null = null;
|
||||
const recordings = sessionLoggingEnabled ? this.deps.getRecordings() : null;
|
||||
if (recordings) {
|
||||
// Events recorded before this resolves wait in pendingRecordingData.
|
||||
recordingSink = Promise.resolve()
|
||||
.then(() =>
|
||||
recordings.open({
|
||||
sessionId: id,
|
||||
hostId,
|
||||
userId,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
startedAt: now,
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
this.log.warn("Could not start a session recording", {
|
||||
operation: "session_recording_open_error",
|
||||
sessionId: id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (recordingSink) {
|
||||
recordingHeader = `${JSON.stringify({
|
||||
version: 2,
|
||||
width: cols,
|
||||
height: rows,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
|
||||
})}\n`;
|
||||
}
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
userId,
|
||||
hostId,
|
||||
hostName,
|
||||
tabInstanceId,
|
||||
sshConn: null,
|
||||
sshStream: null,
|
||||
jumpClient: null,
|
||||
cols,
|
||||
rows,
|
||||
isConnected: false,
|
||||
createdAt: now,
|
||||
participants: new Map(),
|
||||
lastDetachedAt: null,
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
outputBufferBytes: 0,
|
||||
dataListeners: new Set(),
|
||||
recordingSink,
|
||||
recordingHeader,
|
||||
recordingBytes: 0,
|
||||
recordingWriteChain: Promise.resolve(),
|
||||
recordingPersistChain: Promise.resolve(),
|
||||
pendingRecordingData: "",
|
||||
recordingFlushTimer: null,
|
||||
tmuxSessionName: null,
|
||||
sessionLoggingEnabled: !!recordingSink,
|
||||
sessionStartedAt: now,
|
||||
lastPersistedBytes: 0,
|
||||
terminatedByOwner: false,
|
||||
terminationReason: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
this.log.info("Terminal session created", {
|
||||
operation: "session_created",
|
||||
sessionId: id,
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
getSession(sessionId: string | null): TerminalSession | null {
|
||||
if (!sessionId) return null;
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
setSSHState(
|
||||
sessionId: string,
|
||||
conn: Client,
|
||||
stream: ClientChannel,
|
||||
jumpClient?: Client | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.sshConn = conn;
|
||||
session.sshStream = stream;
|
||||
session.jumpClient = jumpClient ?? null;
|
||||
session.isConnected = true;
|
||||
}
|
||||
|
||||
/** Finds the owner's participant entry, if currently attached. */
|
||||
private getOwnerParticipant(
|
||||
session: TerminalSession,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getOwnerEntry(
|
||||
session: TerminalSession,
|
||||
): [string, SessionParticipant] | null {
|
||||
for (const entry of session.participants.entries()) {
|
||||
if (entry[1].isOwner) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
attachWs(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
ws: WebSocket,
|
||||
tabInstanceId?: string,
|
||||
): TerminalSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
this.log.warn("Session not found for attachment", {
|
||||
operation: "session_attach_not_found",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (session.userId !== userId) {
|
||||
this.log.warn("Session userId mismatch", {
|
||||
operation: "session_attach_user_mismatch",
|
||||
sessionId,
|
||||
expectedUserId: session.userId,
|
||||
providedUserId: userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!session.isConnected) {
|
||||
this.log.warn("Session not connected", {
|
||||
operation: "session_attach_not_connected",
|
||||
sessionId,
|
||||
userId,
|
||||
createdAt: session.createdAt,
|
||||
elapsed: Date.now() - session.createdAt,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerParticipant = this.getOwnerParticipant(session);
|
||||
const isDetached =
|
||||
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
|
||||
const isOriginalTab =
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
tabInstanceId;
|
||||
|
||||
if (
|
||||
!isDetached &&
|
||||
!isOriginalTab &&
|
||||
session.tabInstanceId &&
|
||||
tabInstanceId
|
||||
) {
|
||||
this.log.warn("Session actively attached to different tab instance", {
|
||||
operation: "session_attach_instance_conflict",
|
||||
sessionId,
|
||||
sessionInstanceId: session.tabInstanceId,
|
||||
providedInstanceId: tabInstanceId,
|
||||
});
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: "Session belongs to a different tab instance",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
session.tabInstanceId &&
|
||||
tabInstanceId &&
|
||||
session.tabInstanceId !== tabInstanceId
|
||||
) {
|
||||
this.log.info(
|
||||
"Session attached to different tab instance (split-screen)",
|
||||
{
|
||||
operation: "session_attach_split_screen",
|
||||
originalInstanceId: session.tabInstanceId,
|
||||
newInstanceId: tabInstanceId,
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry && ownerEntry[1].ws !== ws) {
|
||||
try {
|
||||
ownerEntry[1].ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionTakenOver",
|
||||
sessionId,
|
||||
message: "Session was attached from another tab",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
const participantId = randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId,
|
||||
permissionLevel: "read-write",
|
||||
isOwner: true,
|
||||
tabInstanceId,
|
||||
});
|
||||
session.attachedTabInstanceId = tabInstanceId;
|
||||
session.lastDetachedAt = null;
|
||||
this.broadcastParticipants(sessionId);
|
||||
|
||||
this.log.info("WebSocket attached to session", {
|
||||
operation: "session_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
tabInstanceId,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a non-owner participant (in-app share join or anonymous link guest).
|
||||
* Purely additive - never evicts the owner or any other participant.
|
||||
*/
|
||||
joinAsParticipant(
|
||||
sessionId: string,
|
||||
ws: WebSocket,
|
||||
opts: {
|
||||
userId: string | null;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
displayName?: string;
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
shareId?: string;
|
||||
},
|
||||
): TerminalSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !session.isConnected) return null;
|
||||
|
||||
const participantId = randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
isOwner: false,
|
||||
displayName: opts.displayName,
|
||||
guestLabel: opts.guestLabel,
|
||||
tabInstanceId: opts.tabInstanceId,
|
||||
joinedViaShareId: opts.shareId,
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resized",
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.broadcastParticipants(sessionId);
|
||||
|
||||
this.log.info("Participant joined shared session", {
|
||||
operation: "session_join_participant",
|
||||
sessionId,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
shareId: opts.shareId,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells everyone in a shared session who is present. Sent on join and
|
||||
* leave, and only while someone besides the owner is (or just was) in the
|
||||
* room - a solo owner never receives presence traffic.
|
||||
*/
|
||||
private broadcastParticipants(sessionId: string, includeSolo = false): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const participants = Array.from(session.participants.values()).map(
|
||||
(participant) => ({
|
||||
isOwner: participant.isOwner,
|
||||
permissionLevel: participant.permissionLevel,
|
||||
label: participant.displayName ?? participant.guestLabel ?? null,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
!includeSolo &&
|
||||
participants.every((participant) => participant.isOwner)
|
||||
)
|
||||
return;
|
||||
// Link guests are anonymous viewers; they do not get the roster.
|
||||
this.broadcast(
|
||||
sessionId,
|
||||
{ type: "participants", participants },
|
||||
(participant) => participant.userId !== null,
|
||||
);
|
||||
}
|
||||
|
||||
/** The anonymous link guests watching through one share. */
|
||||
listShareGuests(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
): { label: string | null }[] {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return [];
|
||||
return Array.from(session.participants.values())
|
||||
.filter(
|
||||
(participant) =>
|
||||
participant.userId === null &&
|
||||
participant.joinedViaShareId === shareId,
|
||||
)
|
||||
.map((participant) => ({ label: participant.guestLabel ?? null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants stage control: participants joined via this share become
|
||||
* read-write only while they are the controller. The owner is untouched.
|
||||
*/
|
||||
setRoomShareControl(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner || participant.joinedViaShareId !== shareId)
|
||||
continue;
|
||||
participant.permissionLevel =
|
||||
controllerUserId && participant.userId === controllerUserId
|
||||
? "read-write"
|
||||
: "read-only";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects non-owner participants that joined through one share.
|
||||
* Supplying userId narrows the kick to that authenticated user; null targets
|
||||
* anonymous guests. Omitting it revokes the share for every participant.
|
||||
*/
|
||||
disconnectShareParticipants(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { userId?: string | null; reason: string },
|
||||
): number {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return 0;
|
||||
const filterByUser = Object.hasOwn(options, "userId");
|
||||
let disconnected = 0;
|
||||
for (const [id, participant] of session.participants.entries()) {
|
||||
if (
|
||||
participant.isOwner ||
|
||||
participant.joinedViaShareId !== shareId ||
|
||||
(filterByUser && participant.userId !== options.userId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
session.participants.delete(id);
|
||||
disconnected += 1;
|
||||
if (participant.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
participant.ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: options.reason,
|
||||
}),
|
||||
);
|
||||
participant.ws.close(1008, options.reason);
|
||||
} catch {
|
||||
participant.ws.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (disconnected > 0) this.broadcastParticipants(sessionId, true);
|
||||
return disconnected;
|
||||
}
|
||||
|
||||
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||
broadcast(
|
||||
sessionId: string,
|
||||
message: object,
|
||||
include?: (participant: SessionParticipant) => boolean,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
if (include && !include(participant)) continue;
|
||||
try {
|
||||
participant.ws.send(payload);
|
||||
} catch {
|
||||
/* ignore individual send failures, keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the participant entry (owner or not) for a given socket. */
|
||||
getParticipantForWs(
|
||||
session: TerminalSession,
|
||||
ws: WebSocket,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws === ws) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a non-owner participant's socket. No detach timeout or session
|
||||
* destruction side effects - a guest leaving must never end the session.
|
||||
*/
|
||||
removeParticipant(sessionId: string, ws: WebSocket): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const [id, participant] of session.participants.entries()) {
|
||||
if (participant.ws === ws && !participant.isOwner) {
|
||||
session.participants.delete(id);
|
||||
this.broadcastParticipants(sessionId, true);
|
||||
this.log.info("Participant left shared session", {
|
||||
operation: "session_leave_participant",
|
||||
sessionId,
|
||||
userId: participant.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcasts termination to all guests, then destroys the session. */
|
||||
ownerEndSession(sessionId: string, reason: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
|
||||
session.terminatedByOwner = true;
|
||||
session.terminationReason = reason;
|
||||
|
||||
this.log.info("Owner ended shared session", {
|
||||
operation: "session_owner_end",
|
||||
sessionId,
|
||||
reason,
|
||||
});
|
||||
|
||||
this.destroySession(sessionId);
|
||||
}
|
||||
|
||||
detachWs(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry) {
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
session.lastDetachedAt = Date.now();
|
||||
|
||||
// Persist log immediately when the user detaches so it appears right away,
|
||||
// regardless of whether the session is later reattached or times out.
|
||||
this.maybePersistLog(session);
|
||||
|
||||
const timeoutMs = this.getTimeoutMs();
|
||||
|
||||
session.detachTimeout = setTimeout(() => {
|
||||
this.log.info("Session idle timeout expired", {
|
||||
operation: "session_idle_timeout",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
});
|
||||
this.destroySession(sessionId);
|
||||
}, timeoutMs);
|
||||
|
||||
this.log.info("WebSocket detached from session", {
|
||||
operation: "session_detach",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
timeoutMinutes: timeoutMs / 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
destroySession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
this.maybePersistLog(session, true);
|
||||
if (session.recordingSink && session.recordingBytes === 0) {
|
||||
void session.recordingSink.then((sink) => sink?.discard());
|
||||
}
|
||||
session.dataListeners.clear();
|
||||
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) continue;
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
try {
|
||||
participant.ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: "Session has ended",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
session.participants.clear();
|
||||
|
||||
if (session.sshStream) {
|
||||
try {
|
||||
session.sshStream.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.sshStream = null;
|
||||
}
|
||||
|
||||
if (session.sshConn) {
|
||||
try {
|
||||
session.sshConn.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.sshConn = null;
|
||||
}
|
||||
|
||||
if (session.jumpClient) {
|
||||
try {
|
||||
session.jumpClient.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.jumpClient = null;
|
||||
}
|
||||
|
||||
session.isConnected = false;
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
this.log.info("Terminal session destroyed", {
|
||||
operation: "session_destroyed",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
}
|
||||
|
||||
private maybePersistLog(session: TerminalSession, force = false): void {
|
||||
if (!session.sessionLoggingEnabled) return;
|
||||
if (session.recordingFlushTimer) {
|
||||
clearTimeout(session.recordingFlushTimer);
|
||||
session.recordingFlushTimer = null;
|
||||
this.flushRecording(session);
|
||||
}
|
||||
if (session.recordingBytes === 0) return;
|
||||
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
|
||||
session.lastPersistedBytes = session.recordingBytes;
|
||||
session.recordingPersistChain = session.recordingPersistChain
|
||||
.then(() => this.persistSessionLog(session))
|
||||
.catch((err) => {
|
||||
this.log.warn("Failed to persist session log", {
|
||||
operation: "session_log_persist_error",
|
||||
sessionId: session.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async persistSessionLog(session: TerminalSession): Promise<void> {
|
||||
if (!session.recordingSink) return;
|
||||
await session.recordingWriteChain;
|
||||
const sink = await session.recordingSink;
|
||||
if (!sink) return;
|
||||
const endedAt = Date.now();
|
||||
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
||||
|
||||
try {
|
||||
await sink.persist({
|
||||
endedAt,
|
||||
durationSeconds: duration,
|
||||
terminatedByOwner: session.terminatedByOwner,
|
||||
terminationReason: session.terminationReason,
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.warn("Failed to insert session recording row", {
|
||||
operation: "session_recording_insert_error",
|
||||
sessionId: session.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
this.log.info("Session log persisted", {
|
||||
operation: "session_log_persisted",
|
||||
sessionId: session.id,
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
duration,
|
||||
bytes: session.recordingBytes,
|
||||
});
|
||||
}
|
||||
|
||||
getUserSessions(userId: string): TerminalSession[] {
|
||||
const result: TerminalSession[] = [];
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.userId === userId) {
|
||||
result.push(session);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bufferOutput(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.outputBuffer.push(data);
|
||||
session.outputBufferBytes += data.length;
|
||||
|
||||
while (
|
||||
session.outputBufferBytes > MAX_BUFFER_BYTES &&
|
||||
session.outputBuffer.length > 0
|
||||
) {
|
||||
const removed = session.outputBuffer.shift();
|
||||
if (removed) session.outputBufferBytes -= removed.length;
|
||||
}
|
||||
|
||||
for (const listener of session.dataListeners) {
|
||||
try {
|
||||
listener(data);
|
||||
} catch {
|
||||
// A listener that throws must not break the session.
|
||||
}
|
||||
}
|
||||
|
||||
this.recordSessionEvent(session, "o", data);
|
||||
}
|
||||
|
||||
bufferInput(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.recordSessionEvent(session, "i", data);
|
||||
}
|
||||
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.cols = cols;
|
||||
session.rows = rows;
|
||||
this.broadcast(sessionId, { type: "resized", cols, rows });
|
||||
this.bufferResize(sessionId, cols, rows);
|
||||
}
|
||||
|
||||
bufferResize(sessionId: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
|
||||
}
|
||||
|
||||
private recordSessionEvent(
|
||||
session: TerminalSession,
|
||||
type: "i" | "o" | "r",
|
||||
data: string,
|
||||
): void {
|
||||
if (!session.sessionLoggingEnabled || !session.recordingSink || !data)
|
||||
return;
|
||||
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
|
||||
const line = `${JSON.stringify([elapsed, type, data])}\n`;
|
||||
session.recordingBytes += Buffer.byteLength(line);
|
||||
session.pendingRecordingData += line;
|
||||
|
||||
if (!session.recordingFlushTimer) {
|
||||
session.recordingFlushTimer = setTimeout(() => {
|
||||
session.recordingFlushTimer = null;
|
||||
this.flushRecording(session);
|
||||
}, RECORDING_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces buffered recording lines into one write, chained so they land
|
||||
* in order. Never one write per chunk: that starved the libuv threadpool
|
||||
* (issue #1049).
|
||||
*/
|
||||
private flushRecording(session: TerminalSession): void {
|
||||
const pendingSink = session.recordingSink;
|
||||
if (!pendingSink || !session.pendingRecordingData) return;
|
||||
const chunk = session.pendingRecordingData;
|
||||
session.pendingRecordingData = "";
|
||||
const firstWrite = session.recordingBytes === Buffer.byteLength(chunk);
|
||||
|
||||
session.recordingWriteChain = session.recordingWriteChain
|
||||
.then(async () => {
|
||||
const sink = await pendingSink;
|
||||
if (!sink) return;
|
||||
await sink.append(
|
||||
firstWrite ? `${session.recordingHeader}${chunk}` : chunk,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.log.warn("Failed to write session recording", {
|
||||
operation: "session_recording_write_error",
|
||||
sessionId: session.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
flushBuffer(session: TerminalSession): string | null {
|
||||
if (session.outputBuffer.length === 0) return null;
|
||||
const data = session.outputBuffer.join("");
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
getBuffer(session: TerminalSession): string | null {
|
||||
if (session.outputBuffer.length === 0) return null;
|
||||
return session.outputBuffer.join("");
|
||||
}
|
||||
|
||||
private getTimeoutMs(): number {
|
||||
const minutes = this.deps.getTimeoutMinutes();
|
||||
return (
|
||||
(Number.isFinite(minutes) && minutes > 0
|
||||
? minutes
|
||||
: DEFAULT_TIMEOUT_MINUTES) * 60_000
|
||||
);
|
||||
}
|
||||
|
||||
private healthCheck(): void {
|
||||
const toDestroy: string[] = [];
|
||||
const now = Date.now();
|
||||
const GRACE_PERIOD_MS = 10_000;
|
||||
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (!session.isConnected) continue;
|
||||
|
||||
const hasOpenParticipant = Array.from(session.participants.values()).some(
|
||||
(p) => p.ws.readyState === WebSocket.OPEN,
|
||||
);
|
||||
if (hasOpenParticipant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (session.sshStream?.destroyed) {
|
||||
const detachedDuration = session.lastDetachedAt
|
||||
? now - session.lastDetachedAt
|
||||
: 0;
|
||||
|
||||
if (detachedDuration > GRACE_PERIOD_MS) {
|
||||
this.log.info(
|
||||
"SSH stream destroyed during detach window, cleaning up",
|
||||
{
|
||||
operation: "session_health_check_stream_destroyed",
|
||||
sessionId: id,
|
||||
userId: session.userId,
|
||||
detachedFor: detachedDuration,
|
||||
},
|
||||
);
|
||||
toDestroy.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!session.sshConn) {
|
||||
toDestroy.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of toDestroy) {
|
||||
this.destroySession(id);
|
||||
}
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const id of [...this.sessions.keys()]) {
|
||||
this.destroySession(id);
|
||||
}
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
parseImageHostPath,
|
||||
parseImageLocalDir,
|
||||
} from "./images/image-storage-settings.js";
|
||||
import { ADMIN_KEYS } from "./settings.js";
|
||||
|
||||
/**
|
||||
* Save-time checks for the admin settings: the image storage paths must be
|
||||
* absolute, since a relative one silently depends on the server's cwd.
|
||||
*/
|
||||
export function validateAdminSettings(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const localDir = values[ADMIN_KEYS.imageLocalDir];
|
||||
if (
|
||||
typeof localDir === "string" &&
|
||||
localDir.trim() !== "" &&
|
||||
parseImageLocalDir(localDir) === null
|
||||
) {
|
||||
errors[ADMIN_KEYS.imageLocalDir] = "Use an absolute path";
|
||||
}
|
||||
const hostPath = values[ADMIN_KEYS.imageHostPath];
|
||||
if (
|
||||
typeof hostPath === "string" &&
|
||||
hostPath.trim() !== "" &&
|
||||
parseImageHostPath(hostPath) === null
|
||||
) {
|
||||
errors[ADMIN_KEYS.imageHostPath] = "Use an absolute path";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
const MAX_MACROS = 100;
|
||||
const MAX_MACROS_BYTES = 512 * 1024;
|
||||
|
||||
/** Save-time checks for the user settings: macros stay bounded. */
|
||||
export function validateUserSettings(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
if (values.macros === undefined || values.macros === null) return errors;
|
||||
let macros = values.macros;
|
||||
if (typeof macros === "string") {
|
||||
try {
|
||||
macros = JSON.parse(macros);
|
||||
} catch {
|
||||
macros = null;
|
||||
}
|
||||
}
|
||||
const valid =
|
||||
Array.isArray(macros) &&
|
||||
macros.length <= MAX_MACROS &&
|
||||
JSON.stringify(macros).length <= MAX_MACROS_BYTES &&
|
||||
macros.every(
|
||||
(macro) =>
|
||||
!!macro &&
|
||||
typeof macro === "object" &&
|
||||
typeof (macro as { id?: unknown }).id === "string" &&
|
||||
typeof (macro as { name?: unknown }).name === "string" &&
|
||||
Array.isArray((macro as { steps?: unknown }).steps),
|
||||
);
|
||||
if (!valid) {
|
||||
errors.macros = `Macros must be a list of at most ${MAX_MACROS} valid macros`;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
type TouchInputSettings,
|
||||
} from "../shared/touch-input-settings.js";
|
||||
import {
|
||||
resolveTerminalImageStorageSettings,
|
||||
TERMINAL_IMAGE_STORAGE_KEYS,
|
||||
type TerminalImageStorageSettings,
|
||||
} from "./images/image-storage-settings.js";
|
||||
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
|
||||
import {
|
||||
readUserSettings,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings.js";
|
||||
|
||||
/**
|
||||
* Admin setting keys, as declared in manifest.json. The image storage fields
|
||||
* have no default on purpose: an unset field falls through to the old
|
||||
* TERMIX_IMAGE_* environment variables, the way the settings table did.
|
||||
*/
|
||||
export const ADMIN_KEYS = {
|
||||
sessionTimeoutMinutes: "sessionTimeoutMinutes",
|
||||
sessionPersistence: "sessionPersistence",
|
||||
commandHistoryEnabled: "commandHistoryEnabled",
|
||||
touchInput: "touchInput",
|
||||
imageStorageMode: "imageStorageMode",
|
||||
imageLocalDir: "imageLocalDir",
|
||||
imageHostPath: "imageHostPath",
|
||||
imageTtlMs: "imageTtlMs",
|
||||
imageMaxCount: "imageMaxCount",
|
||||
imageMaxBytes: "imageMaxBytes",
|
||||
} as const;
|
||||
|
||||
export const HOST_KEYS = {
|
||||
enableTerminal: "enableTerminal",
|
||||
enableTerminalToolbar: "enableTerminalToolbar",
|
||||
terminalToolbarPosition: "terminalToolbarPosition",
|
||||
terminalToolbarStartState: "terminalToolbarStartState",
|
||||
terminalToolbarDisplay: "terminalToolbarDisplay",
|
||||
terminalToolbarShowStatus: "terminalToolbarShowStatus",
|
||||
terminalToolbarFade: "terminalToolbarFade",
|
||||
enableCommandHistory: "enableCommandHistory",
|
||||
autoTmux: "autoTmux",
|
||||
} as const;
|
||||
|
||||
/** User settings a browser kept in localStorage before 2.9.0. */
|
||||
export const MOVABLE_USER_KEYS = [
|
||||
"localEcho",
|
||||
"linkClickBehavior",
|
||||
"commandAutocomplete",
|
||||
] as const;
|
||||
|
||||
const IMAGE_FIELD_BY_LEGACY_KEY: Record<string, string> = {
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: ADMIN_KEYS.imageStorageMode,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: ADMIN_KEYS.imageLocalDir,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.hostPath]: ADMIN_KEYS.imageHostPath,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: ADMIN_KEYS.imageTtlMs,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: ADMIN_KEYS.imageMaxCount,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.maxBytes]: ADMIN_KEYS.imageMaxBytes,
|
||||
};
|
||||
|
||||
export async function readImageStorageSettings(
|
||||
ctx: PluginContext,
|
||||
): Promise<TerminalImageStorageSettings> {
|
||||
return resolveTerminalImageStorageSettings({
|
||||
get: async (legacyKey) => {
|
||||
const field = IMAGE_FIELD_BY_LEGACY_KEY[legacyKey];
|
||||
if (!field) return null;
|
||||
const value = await ctx.settings.get(field);
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
return String(value);
|
||||
},
|
||||
warn: (message) => ctx.log.warn(message),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ClientSettings {
|
||||
sessionTimeoutMinutes: number;
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
/** The caller's own terminal settings. */
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
/** What every signed-in user's terminal needs, whether or not they are an admin. */
|
||||
export async function readClientSettings(
|
||||
ctx: PluginContext,
|
||||
): Promise<ClientSettings> {
|
||||
const all = await ctx.settings.getAll("admin");
|
||||
const timeout = Number(all[ADMIN_KEYS.sessionTimeoutMinutes]);
|
||||
let touch: unknown = all[ADMIN_KEYS.touchInput];
|
||||
if (typeof touch === "string") {
|
||||
try {
|
||||
touch = JSON.parse(touch);
|
||||
} catch {
|
||||
touch = null;
|
||||
}
|
||||
}
|
||||
const actor = ctx.currentActor();
|
||||
const user = actor ? await ctx.settings.getAll("user", actor) : {};
|
||||
return {
|
||||
sessionTimeoutMinutes:
|
||||
Number.isFinite(timeout) && timeout > 0
|
||||
? timeout
|
||||
: DEFAULT_TIMEOUT_MINUTES,
|
||||
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
|
||||
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
|
||||
touchInput: normalizeTouchInputSettings(touch),
|
||||
user: readUserSettings(user),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Commands typed into terminal sessions, per user and host.
|
||||
*
|
||||
* Adopted from core's command_history, so the column and index names are the
|
||||
* legacy ones: the rename carries the existing rows and index across.
|
||||
*/
|
||||
export const commandHistory = adoptLegacyTable(
|
||||
"command_history",
|
||||
defineTable(
|
||||
"command_history",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
command: text().notNull(),
|
||||
executedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_command_history_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [commandHistory];
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { TabProps } from "@termix/plugin-sdk/frontend";
|
||||
import { TerminalTabContent } from "./terminal/TerminalTabContent";
|
||||
import type { TerminalHandle } from "./terminal/terminal-types";
|
||||
import { registerSession, setActiveSession } from "./session-registry";
|
||||
|
||||
/**
|
||||
* Registers the tab's terminal handle in the session registry, so
|
||||
* terminal.sendToActive/sendToSession (used by the snippets plugin, among
|
||||
* others) can reach a live session.
|
||||
*/
|
||||
export function TerminalTabWithRegistry(props: TabProps) {
|
||||
const tabRecord = props.tab as { id: string };
|
||||
const handle = props.handleRef as { current: TerminalHandle | null } | null;
|
||||
const host = props.host as
|
||||
| {
|
||||
id: string;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
}
|
||||
| undefined;
|
||||
const registeredRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
|
||||
const tryRegister = () => {
|
||||
if (cancelled || registeredRef.current) return;
|
||||
const ref = handle?.current;
|
||||
if (!ref) {
|
||||
if (attempts++ < 40) setTimeout(tryRegister, 250);
|
||||
return;
|
||||
}
|
||||
registeredRef.current = registerSession(
|
||||
{
|
||||
id: tabRecord.id,
|
||||
hostId: host ? Number(host.id) || null : null,
|
||||
hostName: host?.name,
|
||||
label: props.label,
|
||||
ip: host?.ip,
|
||||
username: host?.username,
|
||||
port: host?.port,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
};
|
||||
tryRegister();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
registeredRef.current?.();
|
||||
registeredRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tabRecord.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.isFocusedPane) setActiveSession(tabRecord.id);
|
||||
}, [props.isFocusedPane, tabRecord.id]);
|
||||
|
||||
return <TerminalTabContent {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Suspense, lazy, type Ref } from "react";
|
||||
import type { TerminalHandle } from "./terminal/terminal-types";
|
||||
|
||||
const CommandHistoryProvider = lazy(() =>
|
||||
import("./terminal/command-history/CommandHistoryContext").then((m) => ({
|
||||
default: m.CommandHistoryProvider,
|
||||
})),
|
||||
);
|
||||
const Terminal = lazy(() =>
|
||||
import("./terminal/Terminal").then((m) => ({ default: m.Terminal })),
|
||||
);
|
||||
|
||||
/**
|
||||
* The terminal as other code embeds it, registered as "terminal.view".
|
||||
*
|
||||
* Props are the terminal's own (hostConfig, isVisible, title, splitScreen,
|
||||
* executeCommand and the rest), plus `handleRef` for the TerminalHandle
|
||||
* (connect, disconnect, sendInput, fit), since a registered component cannot
|
||||
* take a React ref across the plugin boundary.
|
||||
*/
|
||||
export function TerminalView({
|
||||
handleRef,
|
||||
...props
|
||||
}: {
|
||||
handleRef?: Ref<TerminalHandle>;
|
||||
[prop: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CommandHistoryProvider>
|
||||
<Terminal
|
||||
ref={handleRef}
|
||||
{...(props as unknown as React.ComponentProps<typeof Terminal>)}
|
||||
/>
|
||||
</CommandHistoryProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Copy, Search, Terminal, Trash2 } from "lucide-react";
|
||||
import { copyToClipboard, Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type PanelProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
clearCommandHistory,
|
||||
deleteCommandFromHistory,
|
||||
getCommandHistory,
|
||||
hostSetting,
|
||||
} from "../terminal-api";
|
||||
|
||||
/** Command history for the terminal the user is working in. */
|
||||
export function HistoryPanel({ targetTab }: PanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [search, setSearch] = useState("");
|
||||
const [commands, setCommands] = useState<string[]>([]);
|
||||
|
||||
const activeTab = targetTab;
|
||||
const activeIsTerminal = !!activeTab;
|
||||
const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null;
|
||||
const trackingEnabled = hostSetting(
|
||||
activeTab?.host,
|
||||
"enableCommandHistory",
|
||||
true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId || !trackingEnabled) {
|
||||
setCommands([]);
|
||||
return;
|
||||
}
|
||||
getCommandHistory(api, hostId)
|
||||
.then(setCommands)
|
||||
.catch(() => setCommands([]));
|
||||
}, [api, hostId, trackingEnabled]);
|
||||
|
||||
if (activeIsTerminal && !trackingEnabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Terminal className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("history.trackingDisabled")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("history.trackingDisabledHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeIsTerminal) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Terminal className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("history.noTerminalSelected")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("history.noTerminalSelectedHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? commands.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
|
||||
: commands;
|
||||
|
||||
async function handleDelete(cmd: string) {
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await deleteCommandFromHistory(api, hostId, cmd);
|
||||
setCommands((prev) => prev.filter((c) => c !== cmd));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/30 border border-border/60">
|
||||
<Terminal className="size-3 shrink-0 text-accent-brand" />
|
||||
<span className="text-xs font-medium truncate text-foreground">
|
||||
{activeTab.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("history.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("history.commandCount", { count: filtered.length })}
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await clearCommandHistory(api, hostId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCommands([]);
|
||||
}}
|
||||
className="text-xs text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("history.clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{filtered.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60 text-center py-8">
|
||||
{t("history.noHistoryEntries")}
|
||||
</span>
|
||||
)}
|
||||
{filtered.map((cmd, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex flex-col gap-1 px-2.5 py-2 border border-border bg-background hover:border-muted-foreground/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-mono text-foreground break-all leading-relaxed">
|
||||
{cmd}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 shrink-0 md:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(cmd)}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(cmd)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { lazy, Suspense, type ComponentType } from "react";
|
||||
import {
|
||||
Braces,
|
||||
Copy,
|
||||
Hammer,
|
||||
History,
|
||||
Laptop,
|
||||
SquareTerminal,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
PanelProps,
|
||||
PluginHostRecord,
|
||||
PluginTabRecord,
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@termix/plugin-sdk/ui";
|
||||
import { loadTerminal } from "./terminal/TerminalTabContent";
|
||||
import TerminalApp from "./terminal/TerminalApp";
|
||||
import {
|
||||
TERMINAL_OVERLAY_SLOT,
|
||||
TERMINAL_SIDE_PANEL_SLOT,
|
||||
TERMINAL_TOOLBAR_SLOT,
|
||||
TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
} from "./terminal/terminal-slots";
|
||||
import { TerminalTabWithRegistry } from "./TerminalTabWithRegistry";
|
||||
import { listSessions, sendToActive, sendToSession } from "./session-registry";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { HistoryPanel } from "./history/HistoryPanel";
|
||||
import { TouchInputSettings } from "./settings/TouchInputSettings";
|
||||
import { ImageStorageTest } from "./settings/ImageStorageTest";
|
||||
import { HostTerminalSection } from "./settings/HostTerminalSection";
|
||||
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
|
||||
import { hostSetting } from "./terminal-api";
|
||||
import { SshToolsPanel } from "./ssh-tools/SshToolsPanel";
|
||||
import { MacrosPanel } from "./macros/MacrosPanel";
|
||||
import {
|
||||
TERMINAL_KEYBINDING_DEFAULTS,
|
||||
validateSendControlCode,
|
||||
validateSendText,
|
||||
} from "./lib/keybinding-dispatch";
|
||||
import {
|
||||
PasteNote,
|
||||
SendControlCodeEditor,
|
||||
SendTextEditor,
|
||||
} from "./lib/keybinding-editors";
|
||||
import { TerminalPreview } from "./look/TerminalPreview";
|
||||
import { installTerminalGlobalStyles } from "./look/terminal-global-styles";
|
||||
import {
|
||||
listTerminalThemes,
|
||||
resolveTerminalLook,
|
||||
type ResolveLookRequest,
|
||||
} from "./look/look-actions";
|
||||
import {
|
||||
invalidateTerminalClientSettings,
|
||||
resetTerminalClientSettings,
|
||||
} from "./terminal-settings";
|
||||
import { moveLocalTerminalPreferences } from "./settings/local-preferences-migration";
|
||||
|
||||
const LocalTerminal = lazy(() =>
|
||||
import("./local-terminal/LocalTerminal").then((m) => ({
|
||||
default: m.LocalTerminal,
|
||||
})),
|
||||
);
|
||||
|
||||
interface TerminalOpenOptions {
|
||||
path?: string;
|
||||
joinSharedSessionId?: string;
|
||||
joinShareId?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** `?view=terminal` full-screen links. */
|
||||
function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
||||
return (
|
||||
<TerminalApp
|
||||
hostId={hostId}
|
||||
tmuxSession={params.get("tmuxSession") ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The desktop app's own shell, one tab per open. */
|
||||
function LocalTerminalTab({ tab, isVisible }: TabProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LocalTerminal
|
||||
instanceId={(tab.instanceId as string | undefined) ?? tab.id}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH terminal: its tab, the desktop app's local terminal, the command
|
||||
* history panel and the places other plugins can plug into a session.
|
||||
*/
|
||||
export function activate(app: TermixApp): void {
|
||||
app.onDispose(installTerminalGlobalStyles());
|
||||
app.registerTab(
|
||||
"terminal",
|
||||
TerminalTabWithRegistry as unknown as ComponentType<TabProps>,
|
||||
{
|
||||
icon: Terminal,
|
||||
titleKey: "nav.terminal",
|
||||
persistent: true,
|
||||
session: true,
|
||||
commandTarget: true,
|
||||
ownBackground: true,
|
||||
restore: (host) => !!host.enableSsh,
|
||||
activityTypes: ["terminal"],
|
||||
standalone: TerminalStandalone,
|
||||
preload: loadTerminal,
|
||||
},
|
||||
);
|
||||
|
||||
app.registerAction(
|
||||
"terminal.duplicateTab",
|
||||
((_handle: unknown, tab?: PluginTabRecord) => {
|
||||
if (tab?.type === "terminal" && tab.host) {
|
||||
app.tabs.openTab(tab.host, "terminal", { forceNewTab: true });
|
||||
}
|
||||
}) as never,
|
||||
{ permission: "use" },
|
||||
);
|
||||
app.registerSlotContribution("tab.menu", {
|
||||
actionId: "terminal.duplicateTab",
|
||||
titleKey: "terminal.duplicateTab",
|
||||
icon: Copy,
|
||||
kind: "button",
|
||||
when: ({ tab }) => {
|
||||
const target = tab as PluginTabRecord | undefined;
|
||||
return target?.type === "terminal" && !!target.host;
|
||||
},
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: "terminal",
|
||||
titleKey: "nav.terminal",
|
||||
icon: Terminal,
|
||||
kind: "connect",
|
||||
priority: 100,
|
||||
order: 10,
|
||||
tabType: "terminal",
|
||||
copyUrlView: "terminal",
|
||||
when: (host) =>
|
||||
!!host.enableSsh && hostSetting(host, "enableTerminal", true),
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "terminal",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabTerminal",
|
||||
icon: SquareTerminal,
|
||||
order: 10,
|
||||
defaults: true,
|
||||
component: HostTerminalSection,
|
||||
});
|
||||
|
||||
if (isElectron()) {
|
||||
app.registerTab("local-terminal", LocalTerminalTab, {
|
||||
icon: Laptop,
|
||||
titleKey: "nav.localTerminal",
|
||||
hostless: true,
|
||||
session: true,
|
||||
ownBackground: true,
|
||||
multiInstance: true,
|
||||
inLayouts: false,
|
||||
});
|
||||
app.registerRailItem({
|
||||
id: "local-terminal",
|
||||
icon: Laptop,
|
||||
titleKey: "nav.localTerminal",
|
||||
kind: "tab",
|
||||
electronOnly: true,
|
||||
hideable: true,
|
||||
separatorAfter: true,
|
||||
});
|
||||
app.registerPaletteEntry({
|
||||
id: "local-terminal",
|
||||
titleKey: "palette.localTerminal",
|
||||
icon: Laptop,
|
||||
keywords: ["local", "shell", "terminal"],
|
||||
scope: "global",
|
||||
run: (shell) => shell.openSingletonTab("local-terminal"),
|
||||
});
|
||||
}
|
||||
|
||||
app.registerPanel(
|
||||
"history",
|
||||
HistoryPanel as unknown as ComponentType<PanelProps>,
|
||||
);
|
||||
app.registerRailItem({
|
||||
id: "history",
|
||||
icon: History,
|
||||
titleKey: "nav.history",
|
||||
hideable: true,
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
app.registerPanel(
|
||||
"ssh-tools",
|
||||
SshToolsPanel as unknown as ComponentType<PanelProps>,
|
||||
);
|
||||
app.registerRailItem({
|
||||
id: "ssh-tools",
|
||||
icon: Hammer,
|
||||
titleKey: "nav.sshTools",
|
||||
after: "quick-connect",
|
||||
hideable: true,
|
||||
mobilePrimary: true,
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
app.registerPanel(
|
||||
"macros",
|
||||
MacrosPanel as unknown as ComponentType<PanelProps>,
|
||||
);
|
||||
app.registerRailItem({
|
||||
id: "macros",
|
||||
icon: Braces,
|
||||
titleKey: "nav.macros",
|
||||
after: "ssh-tools",
|
||||
hideable: true,
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
// The keys a user can bind to the terminal's own actions, and its
|
||||
// built-in keys they can rebind. The terminal runs these itself.
|
||||
app.registerKeybindingAction({ id: "copy", titleKey: "keybindings.copy" });
|
||||
app.registerKeybindingAction({
|
||||
id: "paste",
|
||||
titleKey: "keybindings.paste",
|
||||
editor: PasteNote,
|
||||
});
|
||||
app.registerKeybindingAction({
|
||||
id: "sendControlCode",
|
||||
titleKey: "keybindings.sendControlCode",
|
||||
editor: SendControlCodeEditor,
|
||||
validate: validateSendControlCode,
|
||||
});
|
||||
app.registerKeybindingAction({
|
||||
id: "sendText",
|
||||
titleKey: "keybindings.sendText",
|
||||
editor: SendTextEditor,
|
||||
validate: validateSendText,
|
||||
});
|
||||
for (const binding of TERMINAL_KEYBINDING_DEFAULTS) {
|
||||
app.registerKeybindingDefault(binding);
|
||||
}
|
||||
|
||||
// Places other plugins can fill: toolbar buttons and readouts, a side
|
||||
// panel and overlays that follow the session's connection flow.
|
||||
app.declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
|
||||
app.declareActionSlot({
|
||||
id: TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
accepts: ["component"],
|
||||
});
|
||||
app.declareActionSlot({
|
||||
id: TERMINAL_SIDE_PANEL_SLOT,
|
||||
accepts: ["component"],
|
||||
});
|
||||
app.declareActionSlot({ id: TERMINAL_OVERLAY_SLOT, accepts: ["component"] });
|
||||
|
||||
// A terminal other code can embed (collab rooms, the homepage widget, the
|
||||
// tmux monitor, the file manager's window) without importing this plugin.
|
||||
app.registerComponent(
|
||||
"terminal.view",
|
||||
TerminalView as unknown as ComponentType<Record<string, unknown>>,
|
||||
);
|
||||
|
||||
// Lets another plugin (snippets) push text into a live terminal session
|
||||
// without reaching into the shell's tab state itself.
|
||||
app.registerAction("terminal.listSessions", () => listSessions());
|
||||
app.registerAction("terminal.sendToActive", ((
|
||||
text: string,
|
||||
opts?: { run?: boolean },
|
||||
) => sendToActive(text, opts)) as never);
|
||||
app.registerAction("terminal.sendToSession", ((
|
||||
sessionId: string,
|
||||
text: string,
|
||||
opts?: { run?: boolean },
|
||||
) => sendToSession(sessionId, text, opts)) as never);
|
||||
// Opens a new terminal tab on a host: at a path (the file manager), or
|
||||
// joining someone else's shared session (the connections panel).
|
||||
app.registerAction("terminal.open", ((
|
||||
host: Parameters<TermixApp["tabs"]["openTab"]>[0],
|
||||
options: TerminalOpenOptions = {},
|
||||
) =>
|
||||
app.tabs.openTab(host, "terminal", {
|
||||
forceNewTab: true,
|
||||
label: options.label,
|
||||
data: {
|
||||
...(options.path ? { initialPath: options.path } : {}),
|
||||
...(options.joinSharedSessionId
|
||||
? {
|
||||
joinSharedSessionId: options.joinSharedSessionId,
|
||||
joinShareId: options.joinShareId ?? null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})) as never);
|
||||
|
||||
// The terminal look for other terminal-like surfaces (docker, serial,
|
||||
// proxmox), which must not import this plugin. Each answers undefined
|
||||
// while this plugin is off, and the caller falls back to plain colors.
|
||||
app.registerComponent(
|
||||
"terminal.preview",
|
||||
TerminalPreview as unknown as ComponentType<Record<string, unknown>>,
|
||||
);
|
||||
app.registerAction("terminal.resolveTheme", (async (
|
||||
request: ResolveLookRequest & { hostId?: number | string } = {},
|
||||
) => {
|
||||
let host: PluginHostRecord | null | undefined = request.host;
|
||||
if (!host && request.hostId !== undefined) {
|
||||
host =
|
||||
app.getHost(request.hostId) ??
|
||||
(await app.listHosts().catch(() => [])).find(
|
||||
(entry) => String(entry.id) === String(request.hostId),
|
||||
);
|
||||
}
|
||||
return resolveTerminalLook({ ...request, host });
|
||||
}) as never);
|
||||
app.registerAction("terminal.themes", () => listTerminalThemes());
|
||||
|
||||
app.registerSettingsComponent("touchInput", TouchInputSettings);
|
||||
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
|
||||
|
||||
app.onSettingsChanged(() => invalidateTerminalClientSettings());
|
||||
if (!app.guest) void moveLocalTerminalPreferences(app);
|
||||
|
||||
app.onDispose(resetTouchInputSettingsCache);
|
||||
app.onDispose(resetTerminalClientSettings);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type {
|
||||
KeybindingAction,
|
||||
KeybindingDefaultContribution,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface KeybindingDispatchContext {
|
||||
terminal: Terminal;
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>;
|
||||
writeTextToClipboard: (text: string) => Promise<boolean>;
|
||||
readTextFromClipboard: () => Promise<string>;
|
||||
}
|
||||
|
||||
/** The keybinding actions the terminal runs itself. */
|
||||
export const TERMINAL_KEYBINDING_ACTIONS = [
|
||||
"copy",
|
||||
"paste",
|
||||
"sendControlCode",
|
||||
"sendText",
|
||||
] as const;
|
||||
|
||||
export function isTerminalKeybindingAction(type: string): boolean {
|
||||
return (TERMINAL_KEYBINDING_ACTIONS as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
export function sendRawToSocket(
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>,
|
||||
data: string,
|
||||
): void {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
}
|
||||
|
||||
function text(action: KeybindingAction, key: string): string {
|
||||
const value = action[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one of the terminal's own bound actions. False for any other type,
|
||||
* which the terminal hands on to whoever registered it.
|
||||
*/
|
||||
export function dispatchKeybindingAction(
|
||||
action: KeybindingAction,
|
||||
ctx: KeybindingDispatchContext,
|
||||
): boolean {
|
||||
const sendRaw = (data: string) => sendRawToSocket(ctx.webSocketRef, data);
|
||||
|
||||
switch (action.type) {
|
||||
case "copy": {
|
||||
const selection = ctx.terminal.getSelection();
|
||||
if (selection) {
|
||||
ctx.writeTextToClipboard(selection);
|
||||
ctx.terminal.clearSelection();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "paste": {
|
||||
ctx.readTextFromClipboard().then((value) => {
|
||||
if (value) ctx.terminal.paste(value);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "sendControlCode": {
|
||||
const letter = text(action, "controlCode");
|
||||
if (!letter) return true;
|
||||
const code = letter.toLowerCase().charCodeAt(0) - 96;
|
||||
if (code >= 1 && code <= 26) sendRaw(String.fromCharCode(code));
|
||||
return true;
|
||||
}
|
||||
case "sendText": {
|
||||
sendRaw(text(action, "text") + (action.appendEnter === true ? "\r" : ""));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSendText(action: KeybindingAction): string | null {
|
||||
return text(action, "text").trim() ? null : "keybindings.textRequired";
|
||||
}
|
||||
|
||||
export function validateSendControlCode(
|
||||
action: KeybindingAction,
|
||||
): string | null {
|
||||
return /^[a-zA-Z]$/.test(text(action, "controlCode"))
|
||||
? null
|
||||
: "keybindings.controlCodeRequired";
|
||||
}
|
||||
|
||||
function combo(
|
||||
key: string,
|
||||
modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean },
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
isCode: false,
|
||||
ctrl: !!modifiers.ctrl,
|
||||
alt: !!modifiers.alt,
|
||||
shift: !!modifiers.shift,
|
||||
meta: !!modifiers.meta,
|
||||
};
|
||||
}
|
||||
|
||||
/** The terminal's built-in keys, listed so a user can rebind them. */
|
||||
export const TERMINAL_KEYBINDING_DEFAULTS: KeybindingDefaultContribution[] = [
|
||||
{
|
||||
id: "default-copy-ctrlc",
|
||||
combo: combo("c", { ctrl: true }),
|
||||
descriptionKey: "keybindings.builtIn.copyCtrlC",
|
||||
},
|
||||
{
|
||||
id: "default-copy-ctrlshiftc",
|
||||
combo: combo("c", { ctrl: true, shift: true }),
|
||||
descriptionKey: "keybindings.builtIn.copyCtrlShiftC",
|
||||
},
|
||||
{
|
||||
id: "default-copy-cmdc",
|
||||
combo: combo("c", { meta: true }),
|
||||
descriptionKey: "keybindings.builtIn.copyCmdC",
|
||||
},
|
||||
{
|
||||
id: "default-paste-ctrlshiftv",
|
||||
combo: combo("v", { ctrl: true, shift: true }),
|
||||
descriptionKey: "keybindings.builtIn.pasteCtrlShiftV",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltw",
|
||||
combo: combo("w", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlW",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltt",
|
||||
combo: combo("t", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlT",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltn",
|
||||
combo: combo("n", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlN",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltq",
|
||||
combo: combo("q", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlQ",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
useTranslation,
|
||||
type KeybindingActionEditorProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function PasteNote() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("keybindings.clipboardPermissionNote")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendControlCodeEditor({
|
||||
action,
|
||||
onChange,
|
||||
}: KeybindingActionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const value =
|
||||
typeof action.controlCode === "string" ? action.controlCode : "";
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("keybindings.controlCodeLabel")}
|
||||
</label>
|
||||
<Input
|
||||
aria-label={t("keybindings.controlCodeLabel")}
|
||||
value={value}
|
||||
maxLength={1}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
type: action.type,
|
||||
controlCode: e.target.value.toLowerCase() || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="w"
|
||||
className="w-16 font-mono"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendTextEditor({
|
||||
action,
|
||||
onChange,
|
||||
}: KeybindingActionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const text = typeof action.text === "string" ? action.text : "";
|
||||
const appendEnter = action.appendEnter === true;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("keybindings.textLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
aria-label={t("keybindings.textLabel")}
|
||||
value={text}
|
||||
onChange={(e) =>
|
||||
onChange({ type: action.type, text: e.target.value, appendEnter })
|
||||
}
|
||||
className="w-full h-24 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appendEnter}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
type: action.type,
|
||||
text,
|
||||
appendEnter: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t("keybindings.appendEnterLabel")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isMacPlatform } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function getMacLineNavigationSequence(e: KeyboardEvent): string | null {
|
||||
if (e.type !== "keydown" || !isMacPlatform()) return null;
|
||||
if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return null;
|
||||
|
||||
switch (e.code) {
|
||||
case "ArrowLeft":
|
||||
return "\x01";
|
||||
case "ArrowRight":
|
||||
return "\x05";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export type LocalEchoMode = "off" | "auto" | "on";
|
||||
|
||||
const PASSWORD_PROMPT =
|
||||
/(?:password|passphrase|verification code|one[- ]time|otp|token)(?:[^\r\n::]{0,160})?[::]\s*$/i;
|
||||
const SAFE_INPUT = /^[\x20-\x7e]$/;
|
||||
|
||||
type PendingCharacter = { value: string; sentAt: number; predicted: boolean };
|
||||
|
||||
export class TerminalLocalEcho {
|
||||
private pending: PendingCharacter[] = [];
|
||||
private slowSamples = 0;
|
||||
private secretInput = false;
|
||||
private outputTail = "";
|
||||
|
||||
constructor(
|
||||
private readonly mode: LocalEchoMode,
|
||||
private readonly now: () => number = () => performance.now(),
|
||||
private readonly latencyThreshold = 120,
|
||||
) {}
|
||||
|
||||
handleInput(data: string): string {
|
||||
if (data === "\r" || data === "\n") {
|
||||
return "";
|
||||
}
|
||||
if (this.secretInput || !SAFE_INPUT.test(data)) return "";
|
||||
|
||||
// Do not draw newer input ahead of characters still awaiting remote echo.
|
||||
const predicted =
|
||||
(this.mode === "on" || (this.mode === "auto" && this.slowSamples >= 2)) &&
|
||||
this.pending.every((item) => item.predicted);
|
||||
this.pending.push({ value: data, sentAt: this.now(), predicted });
|
||||
return predicted ? data : "";
|
||||
}
|
||||
|
||||
handleOutput(data: string): string {
|
||||
if (!data) return "";
|
||||
const plainOutput = this.stripAnsi(data);
|
||||
this.outputTail = (this.outputTail + plainOutput).slice(-128);
|
||||
if (PASSWORD_PROMPT.test(this.outputTail)) {
|
||||
const rollback = this.rollback();
|
||||
this.secretInput = true;
|
||||
return rollback + data;
|
||||
}
|
||||
|
||||
if (this.secretInput) {
|
||||
if (data.includes("\n") || data.includes("\r")) this.secretInput = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
if (this.pending.length === 0) return data;
|
||||
|
||||
let consumed = 0;
|
||||
while (
|
||||
consumed < data.length &&
|
||||
consumed < this.pending.length &&
|
||||
data[consumed] === this.pending[consumed].value
|
||||
) {
|
||||
const item = this.pending[consumed];
|
||||
if (
|
||||
!item.predicted &&
|
||||
this.now() - item.sentAt >= this.latencyThreshold
|
||||
) {
|
||||
this.slowSamples += 1;
|
||||
}
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
if (consumed > 0) {
|
||||
const matched = this.pending.splice(0, consumed);
|
||||
const predictedCount = matched.filter((item) => item.predicted).length;
|
||||
const rollback = consumed < data.length ? this.rollback() : "";
|
||||
return rollback + data.slice(predictedCount);
|
||||
}
|
||||
|
||||
return this.rollback() + data;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pending = [];
|
||||
this.slowSamples = 0;
|
||||
this.secretInput = false;
|
||||
this.outputTail = "";
|
||||
}
|
||||
|
||||
private rollback() {
|
||||
const count = this.pending.filter((item) => item.predicted).length;
|
||||
this.pending = [];
|
||||
return count > 0 ? `\x1b[${count}D\x1b[${count}X` : "";
|
||||
}
|
||||
|
||||
private stripAnsi(value: string) {
|
||||
return value.replace(/\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLocalEchoMode(
|
||||
hostMode: "default" | LocalEchoMode | undefined,
|
||||
storedMode: string | null,
|
||||
): LocalEchoMode {
|
||||
if (hostMode && hostMode !== "default") return hostMode;
|
||||
return storedMode === "on" || storedMode === "off" || storedMode === "auto"
|
||||
? storedMode
|
||||
: "auto";
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightCyan: "\x1b[96m",
|
||||
bold: "\x1b[1m",
|
||||
underline: "\x1b[4m",
|
||||
} as const;
|
||||
|
||||
export interface SyntaxHighlightOptions {
|
||||
logLevels?: boolean;
|
||||
paths?: boolean;
|
||||
timestamps?: boolean;
|
||||
ipAddresses?: boolean;
|
||||
urls?: boolean;
|
||||
numbers?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
category: keyof SyntaxHighlightOptions;
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
start: number;
|
||||
end: number;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
// capture group offset: some patterns capture a sub-group rather than whole match
|
||||
captureStart?: number;
|
||||
captureEnd?: number;
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
isAnsi: boolean;
|
||||
content: string;
|
||||
// the last active SGR code before this segment (for state restoration)
|
||||
activeSgr?: string;
|
||||
}
|
||||
|
||||
interface ProtectedRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
// Cursor-positioning, erase, and private mode sequences used by TUI apps
|
||||
// (mc, nano, vim, htop). If a chunk contains these, highlighting can inject
|
||||
// extra SGR bytes into a full-screen redraw and corrupt xterm's cursor state.
|
||||
const TUI_SEQUENCE = /\x1b\[(?:[\d;]*[ABCDEFGHJKST]|\?[\d;]*[hl])/;
|
||||
const TUI_FRAME_SEQUENCE = /[\u2500-\u257f]|\x1b[()][0B]|\x1b\[[0-9;]*[~`]/;
|
||||
const CONTROL_STRING_SEQUENCE = /\x1b[\]P^_]/;
|
||||
|
||||
// A bare \r (not immediately followed by \n) means the terminal is overwriting
|
||||
// the current line (shell prompts, progress bars). Highlighting mid-rewrite
|
||||
// chunks corrupts the cursor state, so we skip the whole chunk.
|
||||
const MID_LINE_CR = /\r(?!\n)/;
|
||||
|
||||
// Detects shell prompt lines (user@host:/path$ or similar) after stripping ANSI.
|
||||
// These should not be highlighted — the server already colored them, and injecting
|
||||
// additional ANSI codes into the prompt fragments causes display corruption.
|
||||
//
|
||||
// The parameter-byte class (0-9;?>=!) intentionally also includes `<` and `:` —
|
||||
// SGR mouse-tracking reports (`ESC[<Cb;Cx;CyM`) use `<` as their private-mode
|
||||
// marker. Without it, mouse reports from TUI apps (especially through a
|
||||
// multiplexer like screen) aren't recognized as escape sequences and leak
|
||||
// through as literal "35;191;1M" text on screen.
|
||||
const STRIP_ANSI_RE = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
const SSH_BRACKET_HEADING_RE =
|
||||
/(?:(?<=^)|(?<=\s))\[[\w.-]+@[\w.-]+(?:[^\]\r\n]*)?\]/g;
|
||||
|
||||
function isShellPromptLine(bare: string): boolean {
|
||||
const plain = bare.replace(STRIP_ANSI_RE, "");
|
||||
// Matches a trailing prompt: "user@host:~$ ", "root@pi:/home/pi# ", "[user@host dir]$ "
|
||||
if (/(?:[\w.-]+@[\w.-]+|[\w.-]+).*?[$#%>]\s*$/.test(plain)) return true;
|
||||
// Matches a leading prompt followed by a command: "user@host:/path$ cmd arg"
|
||||
// This is the echoed command line — the shell colors the prompt prefix itself,
|
||||
// so injecting extra ANSI codes into it causes visual corruption / doubled paths.
|
||||
if (/^(?:\[?[\w.-]+@[\w.-]+[\w./ ~-]*\]?|[\w.-]+).*?[$#%>]\s+\S/.test(plain))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Matches any complete ANSI escape sequence
|
||||
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
|
||||
// Matches SGR sequences (color/style setters) specifically — used to track active color state
|
||||
const SGR_REGEX = /\x1b\[[0-9;]*m/;
|
||||
|
||||
// All patterns, ordered roughly by specificity. Priority determines which wins on overlap.
|
||||
const ALL_PATTERNS: HighlightPattern[] = [
|
||||
// IPv4 with optional :port — well-bounded, very specific
|
||||
{
|
||||
name: "ipv4",
|
||||
regex:
|
||||
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||
ansiCode: ANSI.magenta,
|
||||
priority: 10,
|
||||
category: "ipAddresses",
|
||||
},
|
||||
|
||||
// Bracket timestamps [HH:MM] or [HH:MM:SS] — tight range checks prevent false positives
|
||||
{
|
||||
name: "timestamp-bracket",
|
||||
regex: /\[(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\]/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// ISO 8601 date with optional time. Require non-digit before to avoid matching
|
||||
// inside version strings like "1.2024.3". The \b on the right ensures we don't
|
||||
// match partial tokens.
|
||||
{
|
||||
name: "timestamp-iso",
|
||||
regex:
|
||||
/(?<![0-9])\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// Error-level keywords — case-insensitive, catches "Error", "error", "FAILED", etc.
|
||||
{
|
||||
name: "log-error",
|
||||
regex:
|
||||
/\b(?:error|fatal|critical|fail(?:ed)?|denied|exception)\b|\[(?:error|fatal|critical)\]/gi,
|
||||
ansiCode: ANSI.brightRed,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Warning-level keywords — case-insensitive
|
||||
{
|
||||
name: "log-warn",
|
||||
regex: /\b(?:warn(?:ing)?|alert|caution)\b|\[warn(?:ing)?\]/gi,
|
||||
ansiCode: ANSI.brightYellow,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Success keywords — kept conservative to avoid noise.
|
||||
// Negative lookahead prevents matching when directly followed by a path separator
|
||||
// (e.g. shell `cd` output that prints "success~/new/dir").
|
||||
{
|
||||
name: "log-success",
|
||||
regex:
|
||||
/\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b(?![\\/~])/gi,
|
||||
ansiCode: ANSI.brightGreen,
|
||||
priority: 8,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// URLs — blue + underline
|
||||
{
|
||||
name: "url",
|
||||
regex: /https?:\/\/[^\s\])}>"']+/g,
|
||||
ansiCode: `${ANSI.blue}${ANSI.underline}`,
|
||||
priority: 8,
|
||||
category: "urls",
|
||||
},
|
||||
|
||||
// Absolute paths — permissive character class that allows *, ?, [], globs, etc.
|
||||
// Requires at least one slash after the root segment so we don't match bare /dev or /tmp.
|
||||
// Boundary: must start at beginning of text or after whitespace/colon/comma/paren.
|
||||
{
|
||||
name: "path-absolute",
|
||||
regex:
|
||||
/(?:(?<=^)|(?<=[\s:,;('"]))\/[^\s"'`|<>&;\\]+(?:\/[^\s"'`|<>&;\\]+)+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Home-relative paths
|
||||
{
|
||||
name: "path-home",
|
||||
regex: /~\/[^\s"'`|<>&;(){}[\]\\]+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Info keyword
|
||||
{
|
||||
name: "log-info",
|
||||
regex: /\binfo\b|\[info\]/gi,
|
||||
ansiCode: ANSI.blue,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Debug/trace
|
||||
{
|
||||
name: "log-debug",
|
||||
regex: /\b(?:debug|trace|verbose)\b|\[(?:debug|trace)\]/gi,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Labeled numbers: port 8080, exit 1, code 127, status 404, signal 9, returned 0
|
||||
// Captures just the number portion so only the digit is highlighted.
|
||||
{
|
||||
name: "number-labeled",
|
||||
regex: /\b(?:port|exit|code|status|signal|returned?)\s+(\d+)\b/gi,
|
||||
ansiCode: ANSI.brightCyan,
|
||||
priority: 5,
|
||||
category: "numbers",
|
||||
},
|
||||
];
|
||||
|
||||
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||
return /\x1b\[[0-9:;<=>?!]*$/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether the stream is inside a control string (OSC/DCS/APC/PM) across
|
||||
* chunk boundaries.
|
||||
*
|
||||
* A control string carries text that must never reach the screen — an OSC 0
|
||||
* title, for instance, contains the user, host and path. Its opening `ESC ]`
|
||||
* and its terminator often land in different websocket frames, and the
|
||||
* continuation frame contains no escape byte at all, so every single-chunk
|
||||
* guard here misses it. Highlighting that continuation injects an SGR sequence
|
||||
* into the middle of the string, which aborts it early in xterm.js and dumps
|
||||
* the rest of the payload on screen as ordinary text.
|
||||
*
|
||||
* A trailing lone ESC counts as active for the same reason: its intent is only
|
||||
* knowable from the next chunk.
|
||||
*/
|
||||
export function updateControlStringMode(
|
||||
output: string,
|
||||
currentMode: boolean,
|
||||
): { isActive: boolean; wasActive: boolean } {
|
||||
const wasActive = currentMode;
|
||||
let isActive = currentMode;
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const char = output[i];
|
||||
|
||||
if (isActive) {
|
||||
if (char === "\x07") {
|
||||
isActive = false;
|
||||
} else if (char === "\x1b") {
|
||||
// ST (ESC \) closes it; any other ESC aborts it.
|
||||
isActive = false;
|
||||
if (output[i + 1] === "\\") i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char !== "\x1b") continue;
|
||||
|
||||
const next = output[i + 1];
|
||||
if (next === undefined) return { isActive: true, wasActive };
|
||||
if (next === "]" || next === "P" || next === "^" || next === "_") {
|
||||
isActive = true;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive, wasActive };
|
||||
}
|
||||
|
||||
function parseAnsiSegments(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
ANSI_REGEX.lastIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let activeSgr = "";
|
||||
let match;
|
||||
|
||||
while ((match = ANSI_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex, match.index),
|
||||
activeSgr,
|
||||
});
|
||||
}
|
||||
const seq = match[0];
|
||||
segments.push({ isAnsi: true, content: seq });
|
||||
// Track the active SGR state so we can restore it after a highlight reset
|
||||
if (SGR_REGEX.test(seq)) {
|
||||
activeSgr = seq === ANSI.reset ? "" : seq;
|
||||
}
|
||||
lastIndex = ANSI_REGEX.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ isAnsi: false, content: text.slice(lastIndex), activeSgr });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function highlightPlainText(
|
||||
text: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
activeSgr: string,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): string {
|
||||
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
|
||||
for (const pattern of activePatterns) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = pattern.regex.exec(text)) !== null) {
|
||||
// For patterns with a capture group (labeled numbers), highlight only the capture
|
||||
if (m[1] !== undefined) {
|
||||
const captureOffset = m[0].indexOf(m[1], m[0].search(/\d/));
|
||||
const captureStart = m.index + captureOffset;
|
||||
const captureEnd = captureStart + m[1].length;
|
||||
if (isProtectedRange(captureStart, captureEnd, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
captureStart,
|
||||
captureEnd,
|
||||
});
|
||||
} else {
|
||||
if (isProtectedRange(m.index, m.index + m[0].length, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
// Highest priority wins; ties broken by earlier start position
|
||||
matches.sort((a, b) =>
|
||||
a.priority !== b.priority ? b.priority - a.priority : a.start - b.start,
|
||||
);
|
||||
|
||||
const used: Array<{ start: number; end: number }> = [];
|
||||
const final = matches.filter((m) => {
|
||||
const overlaps = used.some(
|
||||
(r) =>
|
||||
(m.start >= r.start && m.start < r.end) ||
|
||||
(m.end > r.start && m.end <= r.end) ||
|
||||
(m.start <= r.start && m.end >= r.end),
|
||||
);
|
||||
if (!overlaps) {
|
||||
used.push({ start: m.start, end: m.end });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Apply in reverse order so indices stay valid as we insert escape codes
|
||||
final.sort((a, b) => a.start - b.start).reverse();
|
||||
|
||||
let result = text;
|
||||
for (const m of final) {
|
||||
const hs = m.captureStart ?? m.start;
|
||||
const he = m.captureEnd ?? m.end;
|
||||
|
||||
// After reset, re-emit the SGR that was active before this plain-text segment
|
||||
// so we don't clobber colors set by the server earlier in the line.
|
||||
const restore = activeSgr || "";
|
||||
result =
|
||||
result.slice(0, hs) +
|
||||
m.ansiCode +
|
||||
result.slice(hs, he) +
|
||||
ANSI.reset +
|
||||
restore +
|
||||
result.slice(he);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getProtectedRanges(text: string): ProtectedRange[] {
|
||||
const ranges: ProtectedRange[] = [];
|
||||
SSH_BRACKET_HEADING_RE.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = SSH_BRACKET_HEADING_RE.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isProtectedRange(
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ProtectedRange[],
|
||||
): boolean {
|
||||
return ranges.some((range) => start < range.end && end > range.start);
|
||||
}
|
||||
|
||||
function buildActivePatterns(
|
||||
options: SyntaxHighlightOptions,
|
||||
): HighlightPattern[] {
|
||||
return ALL_PATTERNS.filter((p) => options[p.category] !== false);
|
||||
}
|
||||
|
||||
function highlightLine(
|
||||
line: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
): string {
|
||||
const cr = line.endsWith("\r");
|
||||
const bare = cr ? line.slice(0, -1) : line;
|
||||
|
||||
if (!bare.trim()) return line;
|
||||
if (bare.length > MAX_LINE_LENGTH) return line;
|
||||
if (isShellPromptLine(bare)) return line;
|
||||
|
||||
// Compute protected ranges (e.g. SSH bracket headings) against the fully
|
||||
// stripped line rather than per-ANSI-segment text. A colored prompt theme
|
||||
// (e.g. "[<color>user<reset>@<color>host<reset>]") splits the heading across
|
||||
// multiple plain-text segments, so matching per-segment would miss it and
|
||||
// let a username like "warning" get wrongly highlighted as a log level.
|
||||
const plainLine = bare.replace(STRIP_ANSI_RE, "");
|
||||
const lineProtectedRanges = getProtectedRanges(plainLine);
|
||||
|
||||
const segments = parseAnsiSegments(bare);
|
||||
let plainOffset = 0;
|
||||
const result = segments
|
||||
.map((s) => {
|
||||
if (s.isAnsi) return s.content;
|
||||
const segmentStart = plainOffset;
|
||||
plainOffset += s.content.length;
|
||||
const localRanges = lineProtectedRanges
|
||||
.map((r) => ({
|
||||
start: r.start - segmentStart,
|
||||
end: r.end - segmentStart,
|
||||
}))
|
||||
.filter((r) => r.start < s.content.length && r.end > 0);
|
||||
return highlightPlainText(
|
||||
s.content,
|
||||
activePatterns,
|
||||
s.activeSgr ?? "",
|
||||
localRanges,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
return cr ? result + "\r" : result;
|
||||
}
|
||||
|
||||
export function highlightTerminalOutput(
|
||||
text: string,
|
||||
options: SyntaxHighlightOptions = {},
|
||||
): string {
|
||||
if (!text || !text.trim()) return text;
|
||||
if (hasIncompleteAnsiSequence(text)) return text;
|
||||
|
||||
if (TUI_SEQUENCE.test(text)) return text;
|
||||
if (TUI_FRAME_SEQUENCE.test(text)) return text;
|
||||
if (CONTROL_STRING_SEQUENCE.test(text)) return text;
|
||||
if (MID_LINE_CR.test(text)) return text;
|
||||
|
||||
const activePatterns = buildActivePatterns(options);
|
||||
if (activePatterns.length === 0) return text;
|
||||
|
||||
const lines = text.split("\n");
|
||||
const endsWithNewline = text.endsWith("\n");
|
||||
const hasMultipleLines = lines.length > 1;
|
||||
|
||||
// When a multi-line chunk has a trailing fragment without \n, that fragment
|
||||
// could be a live readline input line that bash will redraw with \r +
|
||||
// cursor-positioning sequences. Injecting ANSI bytes into it adds invisible
|
||||
// chars that bash doesn't count, so bash's cursor arithmetic diverges from
|
||||
// xterm's actual column position — causing the cursor to jump and text to
|
||||
// shift when the user types or navigates with arrow keys.
|
||||
// Only skip the last fragment when the chunk already has complete lines
|
||||
// before it (single-line chunks with no \n are plain output, not input).
|
||||
const highlightCount =
|
||||
hasMultipleLines && !endsWithNewline ? lines.length - 1 : lines.length;
|
||||
|
||||
return lines
|
||||
.map((line, i) =>
|
||||
i < highlightCount ? highlightLine(line, activePatterns) : line,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { toast } from "sonner";
|
||||
import { getMacLineNavigationSequence } from "../lib/mac-line-navigation";
|
||||
import {
|
||||
handleTerminalClipboardKeyEvent,
|
||||
createTerminalContextMenuHandler,
|
||||
} from "../terminal/terminal-clipboard";
|
||||
import {
|
||||
useAppTheme as useTheme,
|
||||
RobustClipboardProvider,
|
||||
copyToClipboard,
|
||||
readFromClipboard,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { resolveTermixThemeColors } from "../look/terminal-theme";
|
||||
import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
} from "../look/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "../look/terminal-global-styles";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export function LocalTerminal({
|
||||
instanceId,
|
||||
isVisible,
|
||||
}: {
|
||||
instanceId: string;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Read via a ref inside the session-lifecycle effect below so a language
|
||||
// change (which gives react-i18next a new `t` identity) doesn't tear down
|
||||
// the running local shell and spawn a new session.
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const [isWindows, setIsWindows] = useState(false);
|
||||
const [shell, setShell] = useState<"default" | "wsl">("default");
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.getPlatform().then((platform) => {
|
||||
setIsWindows(platform === "win32");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fit = useCallback(() => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!terminal || !fitAddon) return;
|
||||
fitAddon.fit();
|
||||
if (sessionId) {
|
||||
window.electronAPI.resizeLocalTerminal(
|
||||
sessionId,
|
||||
terminal.cols,
|
||||
terminal.rows,
|
||||
);
|
||||
}
|
||||
}, [terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
const colors = resolveTermixThemeColors("termix", appTheme);
|
||||
const font = TERMINAL_FONTS.find(
|
||||
(item) => item.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
|
||||
);
|
||||
ensureTerminalFontsLoaded(font?.value ?? TERMINAL_FONTS[0].value);
|
||||
terminal.options.theme = colors;
|
||||
terminal.options.fontFamily = font?.fallback ?? TERMINAL_FONTS[0].fallback;
|
||||
terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize;
|
||||
}, [appTheme, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !window.electronAPI?.isElectron) return;
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardProvider = new RobustClipboardProvider();
|
||||
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
fitAddon.fit();
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
const ok = await copyToClipboard(text);
|
||||
if (!ok) toast.error(tRef.current("terminal.clipboardWriteFailed"));
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
const text = await readFromClipboard();
|
||||
if (!text) toast.error(tRef.current("terminal.clipboardReadFailed"));
|
||||
return text;
|
||||
}
|
||||
|
||||
const clipboardActions = { writeTextToClipboard, readTextFromClipboard };
|
||||
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
|
||||
if (e.type !== "keydown") return true;
|
||||
const sequence = getMacLineNavigationSequence(e);
|
||||
if (sequence) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
terminal.input(sequence, true);
|
||||
return false;
|
||||
}
|
||||
// No native "paste" event listener here (unlike the SSH terminal), so
|
||||
// plain Ctrl/Cmd+V reads the clipboard explicitly rather than relying
|
||||
// on the browser's own paste event.
|
||||
return handleTerminalClipboardKeyEvent(e, terminal, clipboardActions, {
|
||||
plainPasteMode: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
const handleContextMenu = createTerminalContextMenuHandler(
|
||||
terminal,
|
||||
clipboardActions,
|
||||
);
|
||||
const element = xtermRef.current;
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
let disposed = false;
|
||||
let removeData = () => {};
|
||||
let removeExit = () => {};
|
||||
const input = terminal.onData((data) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId) window.electronAPI.writeLocalTerminal(sessionId, data);
|
||||
});
|
||||
|
||||
window.electronAPI
|
||||
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell })
|
||||
.then(({ sessionId }) => {
|
||||
if (disposed) {
|
||||
window.electronAPI.closeLocalTerminal(sessionId);
|
||||
return;
|
||||
}
|
||||
sessionIdRef.current = sessionId;
|
||||
removeData = window.electronAPI.onLocalTerminalData(sessionId, (data) =>
|
||||
terminal.write(data),
|
||||
);
|
||||
removeExit = window.electronAPI.onLocalTerminalExit(
|
||||
sessionId,
|
||||
(exitCode) => {
|
||||
sessionIdRef.current = null;
|
||||
terminal.write(
|
||||
`\r\n\x1b[33mProcess exited (${exitCode})\x1b[0m\r\n`,
|
||||
);
|
||||
},
|
||||
);
|
||||
return window.electronAPI.readyLocalTerminal(sessionId);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
terminal.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`);
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => fit());
|
||||
if (xtermRef.current) observer.observe(xtermRef.current);
|
||||
return () => {
|
||||
disposed = true;
|
||||
terminal.attachCustomKeyEventHandler(() => true);
|
||||
observer.disconnect();
|
||||
input.dispose();
|
||||
removeData();
|
||||
removeExit();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
clipboardProvider.dispose();
|
||||
const sessionId = sessionIdRef.current;
|
||||
sessionIdRef.current = null;
|
||||
if (sessionId) window.electronAPI.closeLocalTerminal(sessionId);
|
||||
fitAddonRef.current = null;
|
||||
fitAddon.dispose();
|
||||
};
|
||||
}, [fit, instanceId, shell, terminal, xtermRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) requestAnimationFrame(fit);
|
||||
}, [fit, isVisible]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{isWindows && (
|
||||
<div className="flex justify-end border-b border-border px-2 py-1">
|
||||
<select
|
||||
aria-label={t("terminal.localShell")}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
|
||||
value={shell}
|
||||
onChange={(event) =>
|
||||
setShell(event.target.value === "wsl" ? "wsl" : "default")
|
||||
}
|
||||
>
|
||||
<option value="default">PowerShell</option>
|
||||
<option value="wsl">WSL</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div ref={xtermRef} className="min-h-0 flex-1 p-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useAppTheme } from "@termix/plugin-sdk/ui";
|
||||
import { TERMINAL_THEMES, resolveTerminalFontFamily } from "./terminal-themes";
|
||||
import type { TerminalThemeColors } from "../../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* A few lines of sample output in a terminal look. Also registered as the
|
||||
* "terminal.preview" component for other plugins' pickers.
|
||||
*/
|
||||
export interface TerminalPreviewProps {
|
||||
theme: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
cursorStyle?: "block" | "underline" | "bar";
|
||||
cursorBlink?: boolean;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
customThemeColors?: TerminalThemeColors | null;
|
||||
}
|
||||
|
||||
export function TerminalPreview({
|
||||
theme = "termix",
|
||||
fontSize = 14,
|
||||
fontFamily = "Caskaydia Cove Nerd Font Mono",
|
||||
cursorStyle = "bar",
|
||||
cursorBlink = true,
|
||||
letterSpacing = 0,
|
||||
lineHeight = 1.0,
|
||||
customThemeColors,
|
||||
}: TerminalPreviewProps) {
|
||||
const { theme: appTheme } = useAppTheme();
|
||||
|
||||
const resolvedTheme =
|
||||
theme === "termix"
|
||||
? appTheme === "dark" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
? "termixDark"
|
||||
: "termixLight"
|
||||
: theme;
|
||||
|
||||
const colors =
|
||||
theme === "custom" && customThemeColors
|
||||
? customThemeColors
|
||||
: TERMINAL_THEMES[resolvedTheme]?.colors;
|
||||
const fontFallback = resolveTerminalFontFamily(fontFamily);
|
||||
|
||||
return (
|
||||
<div className="border border-input overflow-hidden">
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: fontFallback,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
lineHeight,
|
||||
background: colors?.background || "var(--bg-base)",
|
||||
color: colors?.foreground || "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: colors?.green }}>deploy@web-01</span>
|
||||
<span style={{ color: colors?.brightBlack }}>:</span>
|
||||
<span style={{ color: colors?.blue }}>~</span>
|
||||
<span style={{ color: colors?.brightBlack }}>$</span>
|
||||
<span> ls -la</span>
|
||||
</div>
|
||||
<div style={{ color: colors?.brightBlack }}>total 48</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
5 deploy deploy 4096 May 1 09:12{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.blue }}>.</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
3 root root 4096 Apr 15 18:44{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.blue }}>..</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>-rw-r--r--</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
1 deploy deploy 220 Apr 15 18:44{" "}
|
||||
</span>
|
||||
<span>.bash_logout</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>-rwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
1 deploy deploy 8192 May 1 08:55{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.green }}>deploy.sh</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 mt-0.5">
|
||||
<span style={{ color: colors?.green }}>deploy@web-01</span>
|
||||
<span style={{ color: colors?.brightBlack }}>:</span>
|
||||
<span style={{ color: colors?.blue }}>~</span>
|
||||
<span style={{ color: colors?.brightBlack }}>$</span>
|
||||
<span> </span>
|
||||
<span
|
||||
className="inline-block"
|
||||
style={{
|
||||
width: cursorStyle === "block" ? "0.6em" : "0.12em",
|
||||
height: cursorStyle === "underline" ? "0.12em" : `${fontSize}px`,
|
||||
background: colors?.cursor || colors?.foreground || "#f7f7f7",
|
||||
animation: cursorBlink
|
||||
? "termPreviewBlink 1s step-end infinite"
|
||||
: "none",
|
||||
verticalAlign:
|
||||
cursorStyle === "underline" ? "bottom" : "text-bottom",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes termPreviewBlink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
resolveTerminalFontFamily,
|
||||
type TerminalTheme,
|
||||
} from "./terminal-themes";
|
||||
import { resolveTermixThemeColors } from "./terminal-theme";
|
||||
import { ensureTerminalFontsLoaded } from "./terminal-global-styles";
|
||||
import { resolveForHost } from "../terminal-settings";
|
||||
import type {
|
||||
CursorStyle,
|
||||
TerminalThemeColors,
|
||||
} from "../../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* What `terminal.resolveTheme` answers: an xterm theme and the font options
|
||||
* a terminal-like surface (a docker console, a serial console) should use to
|
||||
* look like the SSH terminal on the same host.
|
||||
*/
|
||||
export interface ResolvedTerminalLook {
|
||||
themeId: string;
|
||||
colors: TerminalThemeColors;
|
||||
/** A CSS font stack, ready for xterm's fontFamily option. */
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
cursorStyle: CursorStyle;
|
||||
cursorBlink: boolean;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
scrollback: number;
|
||||
minimumContrastRatio: number;
|
||||
backgroundImage: string;
|
||||
backgroundImageOpacity: number;
|
||||
}
|
||||
|
||||
export interface ResolveLookRequest {
|
||||
/** A host record from the shell; its terminal settings decide the look. */
|
||||
host?: PluginHostRecord | null;
|
||||
/** Force a theme id instead of the host's. */
|
||||
theme?: string;
|
||||
/** The app theme ("dark", "light", "system" or a named theme). */
|
||||
appTheme?: string;
|
||||
}
|
||||
|
||||
export function resolveTerminalLook(
|
||||
request: ResolveLookRequest,
|
||||
): ResolvedTerminalLook {
|
||||
const config = resolveForHost(request.host ?? null);
|
||||
const themeId = request.theme || config.theme;
|
||||
ensureTerminalFontsLoaded(config.fontFamily);
|
||||
return {
|
||||
themeId,
|
||||
colors: resolveTermixThemeColors(
|
||||
themeId,
|
||||
request.appTheme ?? "dark",
|
||||
config.customThemeColors,
|
||||
),
|
||||
fontFamily: resolveTerminalFontFamily(config.fontFamily),
|
||||
fontSize: config.fontSize,
|
||||
cursorStyle: config.cursorStyle,
|
||||
cursorBlink: config.cursorBlink,
|
||||
letterSpacing: config.letterSpacing,
|
||||
lineHeight: config.lineHeight,
|
||||
scrollback: config.scrollback,
|
||||
minimumContrastRatio: config.minimumContrastRatio,
|
||||
backgroundImage: config.backgroundImage,
|
||||
backgroundImageOpacity: config.backgroundImageOpacity,
|
||||
};
|
||||
}
|
||||
|
||||
/** What `terminal.themes` answers: every built-in theme, for a picker. */
|
||||
export function listTerminalThemes(): Array<
|
||||
{ id: string } & Pick<TerminalTheme, "name" | "category" | "colors">
|
||||
> {
|
||||
return Object.entries(TERMINAL_THEMES)
|
||||
.filter(([id]) => id !== "termixDark" && id !== "termixLight")
|
||||
.map(([id, theme]) => ({
|
||||
id,
|
||||
name: theme.name,
|
||||
category: theme.category,
|
||||
colors: theme.colors,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const TERMINAL_FONT_ZOOM_MIN = 8;
|
||||
export const TERMINAL_FONT_ZOOM_MAX = 36;
|
||||
|
||||
type ZoomKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"altKey" | "code" | "ctrlKey" | "key" | "metaKey"
|
||||
>;
|
||||
|
||||
export function getTerminalFontZoomDirection(event: ZoomKeyEvent): -1 | 0 | 1 {
|
||||
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return 0;
|
||||
|
||||
if (
|
||||
event.key === "+" ||
|
||||
event.code === "Equal" ||
|
||||
event.code === "NumpadAdd"
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
event.key === "-" ||
|
||||
event.code === "Minus" ||
|
||||
event.code === "NumpadSubtract"
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getNextTerminalFontSize(
|
||||
currentFontSize: number,
|
||||
direction: -1 | 1,
|
||||
): number {
|
||||
return Math.min(
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
Math.max(TERMINAL_FONT_ZOOM_MIN, currentFontSize + direction),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
const TERMINAL_STYLES = `
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.3) transparent;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.dark .xterm .xterm-viewport {
|
||||
scrollbar-color: rgba(255,255,255,0.3) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The terminal font faces and xterm scrollbar rules, added to the page while
|
||||
* this plugin is active. Returns the disposer.
|
||||
*/
|
||||
export function installTerminalGlobalStyles(): () => void {
|
||||
if (typeof document === "undefined") return () => {};
|
||||
const style = document.createElement("style");
|
||||
style.dataset.termixTerminalStyles = "";
|
||||
style.innerHTML = TERMINAL_STYLES;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
||||
// Canvas fillText() does not reliably trigger @font-face fetches on every
|
||||
// browser engine (notably Android WebView) the way rendering real DOM text
|
||||
// does. xterm.js draws glyphs to a <canvas>, so without an explicit load the
|
||||
// terminal can keep painting the fallback font's tofu boxes even after
|
||||
// document.fonts.ready resolves. Forcing the load here ensures the glyph
|
||||
// data is actually fetched before the terminal renders with it.
|
||||
export function ensureTerminalFontsLoaded(fontFamily: string): void {
|
||||
if (typeof document === "undefined" || !document.fonts) return;
|
||||
const specs = [
|
||||
`400 16px "${fontFamily}"`,
|
||||
`700 16px "${fontFamily}"`,
|
||||
`italic 400 16px "${fontFamily}"`,
|
||||
`italic 700 16px "${fontFamily}"`,
|
||||
];
|
||||
for (const spec of specs) {
|
||||
document.fonts.load(spec).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TERMINAL_THEMES, type TerminalTheme } from "./terminal-themes";
|
||||
import type { TerminalThemeColors } from "../../shared/terminal-settings";
|
||||
|
||||
// Background/foreground per UI theme for "Termix Default" - must match index.css
|
||||
const TERMIX_DEFAULT_COLORS: Record<
|
||||
string,
|
||||
{ background: string; foreground: string }
|
||||
> = {
|
||||
dark: { background: "#0c0d0b", foreground: "#fafafa" },
|
||||
light: { background: "#ffffff", foreground: "#111210" },
|
||||
dracula: { background: "#282a36", foreground: "#f8f8f2" },
|
||||
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
|
||||
nord: { background: "#2e3440", foreground: "#eceff4" },
|
||||
solarized: { background: "#002b36", foreground: "#839496" },
|
||||
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
|
||||
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
|
||||
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
|
||||
};
|
||||
|
||||
export function resolveTermixThemeColors(
|
||||
activeTheme: string,
|
||||
appTheme: string,
|
||||
customThemeColors?: TerminalThemeColors | null,
|
||||
): TerminalTheme["colors"] {
|
||||
if (activeTheme === "custom") {
|
||||
if (customThemeColors) {
|
||||
return customThemeColors;
|
||||
}
|
||||
return TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
if (activeTheme !== "termix") {
|
||||
return (
|
||||
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
|
||||
);
|
||||
}
|
||||
let resolvedUiTheme = appTheme;
|
||||
if (appTheme === "system") {
|
||||
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
const uiColors =
|
||||
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
|
||||
const base =
|
||||
resolvedUiTheme === "light"
|
||||
? TERMINAL_THEMES.termixLight.colors
|
||||
: TERMINAL_THEMES.termixDark.colors;
|
||||
return {
|
||||
...base,
|
||||
background: uiColors.background,
|
||||
foreground: uiColors.foreground,
|
||||
cursor: uiColors.foreground,
|
||||
cursorAccent: uiColors.background,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import {
|
||||
DEFAULT_APPEARANCE,
|
||||
DEFAULT_BEHAVIOR,
|
||||
DEFAULT_MOSH_COMMAND,
|
||||
type TerminalThemeColors,
|
||||
} from "../../shared/terminal-settings";
|
||||
|
||||
export interface TerminalTheme {
|
||||
name: string;
|
||||
category: "dark" | "light" | "colorful";
|
||||
colors: TerminalThemeColors;
|
||||
}
|
||||
|
||||
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
termix: {
|
||||
name: "Termix Default",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
termixDark: {
|
||||
name: "Termix Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
termixLight: {
|
||||
name: "Termix Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#ffffff",
|
||||
foreground: "#18181b",
|
||||
cursor: "#18181b",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#d1d5db",
|
||||
black: "#18181b",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#f4f4f5",
|
||||
brightBlack: "#71717a",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#eab308",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
dracula: {
|
||||
name: "Dracula",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
monokai: {
|
||||
name: "Monokai",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f0",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#49483e",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
},
|
||||
},
|
||||
|
||||
nord: {
|
||||
name: "Nord",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#434c5e",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxDark: {
|
||||
name: "Gruvbox Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxLight: {
|
||||
name: "Gruvbox Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fbf1c7",
|
||||
foreground: "#3c3836",
|
||||
cursor: "#3c3836",
|
||||
cursorAccent: "#fbf1c7",
|
||||
selectionBackground: "#d5c4a1",
|
||||
black: "#fbf1c7",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#7c6f64",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#9d0006",
|
||||
brightGreen: "#79740e",
|
||||
brightYellow: "#b57614",
|
||||
brightBlue: "#076678",
|
||||
brightMagenta: "#8f3f71",
|
||||
brightCyan: "#427b58",
|
||||
brightWhite: "#3c3836",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedDark: {
|
||||
name: "Solarized Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#839496",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedLight: {
|
||||
name: "Solarized Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fdf6e3",
|
||||
foreground: "#657b83",
|
||||
cursor: "#657b83",
|
||||
cursorAccent: "#fdf6e3",
|
||||
selectionBackground: "#eee8d5",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
oneDark: {
|
||||
name: "One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#282c34",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
tokyoNight: {
|
||||
name: "Tokyo Night",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#a9b1d6",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#283457",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
|
||||
ayuDark: {
|
||||
name: "Ayu Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0a0e14",
|
||||
foreground: "#b3b1ad",
|
||||
cursor: "#e6b450",
|
||||
cursorAccent: "#0a0e14",
|
||||
selectionBackground: "#253340",
|
||||
black: "#01060e",
|
||||
red: "#ea6c73",
|
||||
green: "#91b362",
|
||||
yellow: "#f9af4f",
|
||||
blue: "#53bdfa",
|
||||
magenta: "#fae994",
|
||||
cyan: "#90e1c6",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#f07178",
|
||||
brightGreen: "#c2d94c",
|
||||
brightYellow: "#ffb454",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#ffee99",
|
||||
brightCyan: "#95e6cb",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
ayuLight: {
|
||||
name: "Ayu Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fafafa",
|
||||
foreground: "#5c6166",
|
||||
cursor: "#ff9940",
|
||||
cursorAccent: "#fafafa",
|
||||
selectionBackground: "#d1e4f4",
|
||||
black: "#000000",
|
||||
red: "#f51818",
|
||||
green: "#86b300",
|
||||
yellow: "#f2ae49",
|
||||
blue: "#399ee6",
|
||||
magenta: "#a37acc",
|
||||
cyan: "#4cbf99",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff3333",
|
||||
brightGreen: "#b8e532",
|
||||
brightYellow: "#ffc849",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#bf7ce0",
|
||||
brightCyan: "#5cf7a0",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
materialTheme: {
|
||||
name: "Material Theme",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#263238",
|
||||
foreground: "#eeffff",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#263238",
|
||||
selectionBackground: "#546e7a",
|
||||
black: "#000000",
|
||||
red: "#e53935",
|
||||
green: "#91b859",
|
||||
yellow: "#ffb62c",
|
||||
blue: "#6182b8",
|
||||
magenta: "#7c4dff",
|
||||
cyan: "#39adb5",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#546e7a",
|
||||
brightRed: "#ff5370",
|
||||
brightGreen: "#c3e88d",
|
||||
brightYellow: "#ffcb6b",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#89ddff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
palenight: {
|
||||
name: "Palenight",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#292d3e",
|
||||
foreground: "#a6accd",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#292d3e",
|
||||
selectionBackground: "#676e95",
|
||||
black: "#292d3e",
|
||||
red: "#f07178",
|
||||
green: "#c3e88d",
|
||||
yellow: "#ffcb6b",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#89ddff",
|
||||
white: "#d0d0d0",
|
||||
brightBlack: "#434758",
|
||||
brightRed: "#ff8b92",
|
||||
brightGreen: "#ddffa7",
|
||||
brightYellow: "#ffe585",
|
||||
brightBlue: "#9cc4ff",
|
||||
brightMagenta: "#e1acff",
|
||||
brightCyan: "#a3f7ff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
oceanicNext: {
|
||||
name: "Oceanic Next",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1b2b34",
|
||||
foreground: "#cdd3de",
|
||||
cursor: "#c0c5ce",
|
||||
cursorAccent: "#1b2b34",
|
||||
selectionBackground: "#343d46",
|
||||
black: "#343d46",
|
||||
red: "#ec5f67",
|
||||
green: "#99c794",
|
||||
yellow: "#fac863",
|
||||
blue: "#6699cc",
|
||||
magenta: "#c594c5",
|
||||
cyan: "#5fb3b3",
|
||||
white: "#cdd3de",
|
||||
brightBlack: "#65737e",
|
||||
brightRed: "#ec5f67",
|
||||
brightGreen: "#99c794",
|
||||
brightYellow: "#fac863",
|
||||
brightBlue: "#6699cc",
|
||||
brightMagenta: "#c594c5",
|
||||
brightCyan: "#5fb3b3",
|
||||
brightWhite: "#d8dee9",
|
||||
},
|
||||
},
|
||||
|
||||
nightOwl: {
|
||||
name: "Night Owl",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#011627",
|
||||
foreground: "#d6deeb",
|
||||
cursor: "#80a4c2",
|
||||
cursorAccent: "#011627",
|
||||
selectionBackground: "#1d3b53",
|
||||
black: "#011627",
|
||||
red: "#ef5350",
|
||||
green: "#22da6e",
|
||||
yellow: "#c5e478",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#21c7a8",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#575656",
|
||||
brightRed: "#ef5350",
|
||||
brightGreen: "#22da6e",
|
||||
brightYellow: "#ffeb95",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#7fdbca",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
synthwave84: {
|
||||
name: "Synthwave '84",
|
||||
category: "colorful",
|
||||
colors: {
|
||||
background: "#241b2f",
|
||||
foreground: "#f92aad",
|
||||
cursor: "#f92aad",
|
||||
cursorAccent: "#241b2f",
|
||||
selectionBackground: "#495495",
|
||||
black: "#000000",
|
||||
red: "#f6188f",
|
||||
green: "#1eff8e",
|
||||
yellow: "#ffe261",
|
||||
blue: "#03edf9",
|
||||
magenta: "#f10596",
|
||||
cyan: "#03edf9",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#5a5a5a",
|
||||
brightRed: "#ff1a8e",
|
||||
brightGreen: "#1eff8e",
|
||||
brightYellow: "#ffff00",
|
||||
brightBlue: "#00d8ff",
|
||||
brightMagenta: "#ff00d4",
|
||||
brightCyan: "#00ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
cobalt2: {
|
||||
name: "Cobalt2",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#193549",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#f0cc09",
|
||||
cursorAccent: "#193549",
|
||||
selectionBackground: "#0050a4",
|
||||
black: "#000000",
|
||||
red: "#ff0000",
|
||||
green: "#38de21",
|
||||
yellow: "#ffe50a",
|
||||
blue: "#1460d2",
|
||||
magenta: "#ff005d",
|
||||
cyan: "#00bbbb",
|
||||
white: "#bbbbbb",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#f40e17",
|
||||
brightGreen: "#3bd01d",
|
||||
brightYellow: "#edc809",
|
||||
brightBlue: "#5555ff",
|
||||
brightMagenta: "#ff55ff",
|
||||
brightCyan: "#6ae3fa",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
snazzy: {
|
||||
name: "Snazzy",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#eff0eb",
|
||||
cursor: "#97979b",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#97979b",
|
||||
black: "#282a36",
|
||||
red: "#ff5c57",
|
||||
green: "#5af78e",
|
||||
yellow: "#f3f99d",
|
||||
blue: "#57c7ff",
|
||||
magenta: "#ff6ac1",
|
||||
cyan: "#9aedfe",
|
||||
white: "#f1f1f0",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff5c57",
|
||||
brightGreen: "#5af78e",
|
||||
brightYellow: "#f3f99d",
|
||||
brightBlue: "#57c7ff",
|
||||
brightMagenta: "#ff6ac1",
|
||||
brightCyan: "#9aedfe",
|
||||
brightWhite: "#eff0eb",
|
||||
},
|
||||
},
|
||||
|
||||
atomOneDark: {
|
||||
name: "Atom One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e2127",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#1e2127",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#000000",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#d19a66",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#d19a66",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
catppuccinMocha: {
|
||||
name: "Catppuccin Mocha",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#585b70",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#f5c2e7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#f5c2e7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
},
|
||||
|
||||
gentlemansChoice: {
|
||||
name: "Gentleman's Choice",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1c1a",
|
||||
foreground: "#d1c7a3",
|
||||
cursor: "#d1c7a3",
|
||||
cursorAccent: "#1a1c1a",
|
||||
selectionBackground: "#3e4437",
|
||||
black: "#1a1c1a",
|
||||
red: "#9d3a3a",
|
||||
green: "#5a7a3a",
|
||||
yellow: "#b39a3a",
|
||||
blue: "#3a5a7a",
|
||||
magenta: "#7a3a5a",
|
||||
cyan: "#3a7a7a",
|
||||
white: "#d1c7a3",
|
||||
brightBlack: "#3e4437",
|
||||
brightRed: "#bf4a4a",
|
||||
brightGreen: "#7a9a4a",
|
||||
brightYellow: "#d1b34a",
|
||||
brightBlue: "#4a7abf",
|
||||
brightMagenta: "#9a4abf",
|
||||
brightCyan: "#4abfbf",
|
||||
brightWhite: "#e3dbc3",
|
||||
},
|
||||
},
|
||||
|
||||
midnightEspresso: {
|
||||
name: "Midnight Espresso",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#120f0d",
|
||||
foreground: "#ceb195",
|
||||
cursor: "#ceb195",
|
||||
cursorAccent: "#120f0d",
|
||||
selectionBackground: "#3d2b1f",
|
||||
black: "#120f0d",
|
||||
red: "#a05a4a",
|
||||
green: "#7a8a5a",
|
||||
yellow: "#b08a4a",
|
||||
blue: "#5a7a9a",
|
||||
magenta: "#8a5a7a",
|
||||
cyan: "#5a8a8a",
|
||||
white: "#ceb195",
|
||||
brightBlack: "#3d2b1f",
|
||||
brightRed: "#c07a6a",
|
||||
brightGreen: "#9aaa7a",
|
||||
brightYellow: "#d0aa6a",
|
||||
brightBlue: "#7a9aba",
|
||||
brightMagenta: "#aa7aba",
|
||||
brightCyan: "#7ababa",
|
||||
brightWhite: "#e0cbb5",
|
||||
},
|
||||
},
|
||||
|
||||
custom: {
|
||||
name: "Custom",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TERMINAL_FONTS = [
|
||||
{
|
||||
value: "Caskaydia Cove Nerd Font Mono",
|
||||
label: "Caskaydia Cove Nerd Font Mono",
|
||||
fallback:
|
||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "JetBrains Mono",
|
||||
label: "JetBrains Mono",
|
||||
fallback:
|
||||
'"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Fira Code",
|
||||
label: "Fira Code",
|
||||
fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Cascadia Code",
|
||||
label: "Cascadia Code",
|
||||
fallback:
|
||||
'"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Source Code Pro",
|
||||
label: "Source Code Pro",
|
||||
fallback:
|
||||
'"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "SF Mono",
|
||||
label: "SF Mono",
|
||||
fallback: '"SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Consolas",
|
||||
label: "Consolas",
|
||||
fallback: 'Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Monaco",
|
||||
label: "Monaco",
|
||||
fallback: 'Monaco, "Liberation Mono", monospace',
|
||||
},
|
||||
];
|
||||
|
||||
// Unknown values are treated as a custom font installed on the user's system.
|
||||
export function resolveTerminalFontFamily(fontFamily?: string): string {
|
||||
const known = TERMINAL_FONTS.find((f) => f.value === fontFamily);
|
||||
if (known) return known.fallback;
|
||||
if (fontFamily && fontFamily.trim()) {
|
||||
return `"${fontFamily.trim()}", "SF Mono", Consolas, "Liberation Mono", monospace`;
|
||||
}
|
||||
return TERMINAL_FONTS[0].fallback;
|
||||
}
|
||||
|
||||
export const CURSOR_STYLES = [
|
||||
{ value: "block", label: "Block", labelKey: "hosts.cursorStyleBlock" },
|
||||
{
|
||||
value: "underline",
|
||||
label: "Underline",
|
||||
labelKey: "hosts.cursorStyleUnderline",
|
||||
},
|
||||
{ value: "bar", label: "Bar", labelKey: "hosts.cursorStyleBar" },
|
||||
] as const;
|
||||
|
||||
export const BELL_STYLES = [
|
||||
{ value: "none", label: "None", labelKey: "hosts.bellStyleNone" },
|
||||
{ value: "sound", label: "Sound", labelKey: "hosts.bellStyleSound" },
|
||||
{ value: "visual", label: "Visual", labelKey: "hosts.bellStyleVisual" },
|
||||
{ value: "both", label: "Both", labelKey: "hosts.bellStyleBoth" },
|
||||
] as const;
|
||||
|
||||
export const FAST_SCROLL_MODIFIERS = [
|
||||
{ value: "alt", label: "Alt", labelKey: "hosts.fastScrollModifierAlt" },
|
||||
{ value: "ctrl", label: "Ctrl", labelKey: "hosts.fastScrollModifierCtrl" },
|
||||
{ value: "shift", label: "Shift", labelKey: "hosts.fastScrollModifierShift" },
|
||||
] as const;
|
||||
|
||||
/** A terminal's full configuration before anything is overridden. */
|
||||
export const DEFAULT_TERMINAL_CONFIG = {
|
||||
...DEFAULT_APPEARANCE,
|
||||
...DEFAULT_BEHAVIOR,
|
||||
moshCommand: DEFAULT_MOSH_COMMAND,
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* The terminal theme the host editor is previewing, so an open terminal for
|
||||
* that host can show it before the host is saved. Null when nothing is being
|
||||
* previewed.
|
||||
*/
|
||||
let preview: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function setThemePreview(theme: string | null): void {
|
||||
if (theme === preview) return;
|
||||
preview = theme;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useThemePreview(): string | null {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => preview,
|
||||
() => preview,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
useSettings,
|
||||
useTranslation,
|
||||
type PanelProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
CornerDownRight,
|
||||
GitBranch,
|
||||
Plus,
|
||||
Repeat,
|
||||
Save,
|
||||
Square,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
mergeLocalMacros,
|
||||
sanitizeTerminalMacros,
|
||||
runTerminalMacro,
|
||||
type MacroStep,
|
||||
type TerminalMacro,
|
||||
} from "./terminal-macros";
|
||||
import {
|
||||
getSessionHandle,
|
||||
sessionsSnapshot,
|
||||
subscribeSessions,
|
||||
} from "../session-registry";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "";
|
||||
}
|
||||
|
||||
const STEP_TYPES = ["send", "wait", "delay", "if", "repeat"] as const;
|
||||
|
||||
const STEP_ICONS = {
|
||||
send: TerminalIcon,
|
||||
wait: CornerDownRight,
|
||||
delay: Clock,
|
||||
if: GitBranch,
|
||||
repeat: Repeat,
|
||||
} as const;
|
||||
|
||||
function id(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function createStep(type: MacroStep["type"]): MacroStep {
|
||||
const stepId = id();
|
||||
switch (type) {
|
||||
case "send":
|
||||
return { id: stepId, type, text: "", pressEnter: true };
|
||||
case "delay":
|
||||
return { id: stepId, type, milliseconds: 1000 };
|
||||
case "wait":
|
||||
return {
|
||||
id: stepId,
|
||||
type,
|
||||
pattern: "",
|
||||
isRegex: false,
|
||||
timeoutMs: 10_000,
|
||||
onTimeout: "stop",
|
||||
};
|
||||
case "if":
|
||||
return {
|
||||
id: stepId,
|
||||
type,
|
||||
pattern: "",
|
||||
isRegex: false,
|
||||
then: [],
|
||||
else: [],
|
||||
};
|
||||
case "repeat":
|
||||
return { id: stepId, type, count: 2, steps: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function isValidRegex(pattern: string): boolean {
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Milliseconds are an implementation detail, so the editor works in seconds. */
|
||||
function SecondsInput({
|
||||
valueMs,
|
||||
onChangeMs,
|
||||
minMs,
|
||||
label,
|
||||
}: {
|
||||
valueMs: number;
|
||||
onChangeMs: (ms: number) => void;
|
||||
minMs: number;
|
||||
label: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<Input
|
||||
className="h-7 w-20 rounded-none text-xs"
|
||||
type="number"
|
||||
min={minMs / 1000}
|
||||
max={300}
|
||||
step={0.5}
|
||||
value={valueMs / 1000}
|
||||
onChange={(event) => {
|
||||
const seconds = Number(event.target.value);
|
||||
onChangeMs(
|
||||
Number.isFinite(seconds)
|
||||
? Math.max(minMs, Math.min(300_000, Math.round(seconds * 1000)))
|
||||
: minMs,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{t("macros.seconds")}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared text/regex matcher field used by both "wait" and "if". */
|
||||
function MatchField({
|
||||
pattern,
|
||||
isRegex,
|
||||
label,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
pattern: string;
|
||||
isRegex: boolean;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
onChange: (next: { pattern?: string; isRegex?: boolean }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const badRegex = isRegex && pattern.length > 0 && !isValidRegex(pattern);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<Input
|
||||
className={`h-8 rounded-none font-mono text-xs ${
|
||||
badRegex ? "border-destructive" : ""
|
||||
}`}
|
||||
placeholder={placeholder}
|
||||
value={pattern}
|
||||
onChange={(event) => onChange({ pattern: event.target.value })}
|
||||
/>
|
||||
{badRegex && (
|
||||
<div className="text-[11px] text-destructive">
|
||||
{t("macros.match.invalidRegex")}
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={isRegex}
|
||||
onCheckedChange={(checked) => onChange({ isRegex: checked === true })}
|
||||
/>
|
||||
{t("macros.match.useRegex")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddStepMenu({
|
||||
onAdd,
|
||||
compact,
|
||||
}: {
|
||||
onAdd: (type: MacroStep["type"]) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`rounded-none ${compact ? "h-7 text-[11px]" : "h-8 w-full text-xs"}`}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
{t("macros.addStep")}
|
||||
<ChevronDown className="ml-1 size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72 rounded-none">
|
||||
{STEP_TYPES.map((type) => {
|
||||
const Icon = STEP_ICONS[type];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="flex-col items-start gap-0.5 rounded-none"
|
||||
onSelect={() => onAdd(type)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium">
|
||||
<Icon className="size-3.5" />
|
||||
{t(`macros.stepTypes.${type}.label`)}
|
||||
</span>
|
||||
<span className="pl-5 text-[11px] leading-snug text-muted-foreground">
|
||||
{t(`macros.stepTypes.${type}.help`)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function StepList({
|
||||
steps,
|
||||
onChange,
|
||||
depth = 0,
|
||||
}: {
|
||||
steps: MacroStep[];
|
||||
onChange: (steps: MacroStep[]) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const update = (index: number, step: MacroStep) => {
|
||||
const next = [...steps];
|
||||
next[index] = step;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = STEP_ICONS[step.type];
|
||||
return (
|
||||
<div key={step.id} className="border border-border bg-muted/20">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-2 py-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">
|
||||
{t(`macros.stepTypes.${step.type}.label`)}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-auto size-6 rounded-none"
|
||||
aria-label={t("macros.removeStep")}
|
||||
onClick={() => onChange(steps.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-2">
|
||||
{step.type === "send" && (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
className="min-h-16 rounded-none font-mono text-xs"
|
||||
placeholder={t("macros.send.textPlaceholder")}
|
||||
value={step.text}
|
||||
onChange={(event) =>
|
||||
update(index, { ...step, text: event.target.value })
|
||||
}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={step.pressEnter}
|
||||
onCheckedChange={(checked) =>
|
||||
update(index, { ...step, pressEnter: checked === true })
|
||||
}
|
||||
/>
|
||||
{t("macros.send.pressEnter")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "delay" && (
|
||||
<SecondsInput
|
||||
label={t("macros.delay.duration")}
|
||||
valueMs={step.milliseconds}
|
||||
minMs={0}
|
||||
onChangeMs={(milliseconds) =>
|
||||
update(index, { ...step, milliseconds })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step.type === "wait" && (
|
||||
<div className="space-y-2">
|
||||
<MatchField
|
||||
pattern={step.pattern}
|
||||
isRegex={step.isRegex !== false}
|
||||
label={t("macros.wait.pattern")}
|
||||
placeholder={t("macros.wait.patternPlaceholder")}
|
||||
onChange={(next) => update(index, { ...step, ...next })}
|
||||
/>
|
||||
<SecondsInput
|
||||
label={t("macros.wait.timeout")}
|
||||
valueMs={step.timeoutMs}
|
||||
minMs={100}
|
||||
onChangeMs={(timeoutMs) =>
|
||||
update(index, { ...step, timeoutMs })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("macros.wait.onTimeout")}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["stop", "continue"] as const).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
size="sm"
|
||||
variant={
|
||||
step.onTimeout === mode ? "default" : "outline"
|
||||
}
|
||||
className="h-7 flex-1 rounded-none text-[11px]"
|
||||
onClick={() =>
|
||||
update(index, { ...step, onTimeout: mode })
|
||||
}
|
||||
>
|
||||
{t(`macros.wait.${mode}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "if" && (
|
||||
<div className="space-y-3">
|
||||
<MatchField
|
||||
pattern={step.pattern}
|
||||
isRegex={step.isRegex !== false}
|
||||
label={t("macros.ifStep.pattern")}
|
||||
placeholder={t("macros.ifStep.patternPlaceholder")}
|
||||
onChange={(next) => update(index, { ...step, ...next })}
|
||||
/>
|
||||
<div className="border-l-2 border-emerald-500/40 pl-2">
|
||||
<div className="mb-1 text-[11px] font-medium text-emerald-500">
|
||||
{t("macros.ifStep.then")}
|
||||
</div>
|
||||
<StepList
|
||||
steps={step.then}
|
||||
depth={depth + 1}
|
||||
onChange={(then) => update(index, { ...step, then })}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-l-2 border-amber-500/40 pl-2">
|
||||
<div className="mb-1 text-[11px] font-medium text-amber-500">
|
||||
{t("macros.ifStep.else")}
|
||||
</div>
|
||||
<StepList
|
||||
steps={step.else}
|
||||
depth={depth + 1}
|
||||
onChange={(elseSteps) =>
|
||||
update(index, { ...step, else: elseSteps })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "repeat" && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("macros.repeat.count")}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 w-20 rounded-none text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={step.count}
|
||||
onChange={(event) =>
|
||||
update(index, {
|
||||
...step,
|
||||
count: Math.max(
|
||||
1,
|
||||
Math.min(100, Number(event.target.value) || 1),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("macros.repeat.times")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="border-l-2 border-border pl-2">
|
||||
<StepList
|
||||
steps={step.steps}
|
||||
depth={depth + 1}
|
||||
onChange={(nested) =>
|
||||
update(index, { ...step, steps: nested })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{depth < 4 && (
|
||||
<AddStepMenu
|
||||
compact={depth > 0}
|
||||
onAdd={(type) => onChange([...steps, createStep(type)])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Macros: a saved series of steps typed into the terminal the user is
|
||||
* working in, waiting for and branching on what the server sends back.
|
||||
* They are the user's own setting, so they follow them to every browser.
|
||||
*/
|
||||
export function MacrosPanel({ targetTab }: PanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const settings = useSettings("user");
|
||||
const [draft, setDraft] = useState<TerminalMacro | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [runningId, setRunningId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<TerminalMacro | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingRun, setPendingRun] = useState<TerminalMacro | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const confirmedRef = useRef<Set<string>>(new Set());
|
||||
const movedLocalRef = useRef(false);
|
||||
const sessions = useSyncExternalStore(subscribeSessions, sessionsSnapshot);
|
||||
|
||||
const macros = useMemo(
|
||||
() => sanitizeTerminalMacros(settings.values.macros),
|
||||
[settings.values.macros],
|
||||
);
|
||||
const { save: saveSettings } = settings;
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
// Macros a browser kept before 2.9.0 move into the setting once.
|
||||
useEffect(() => {
|
||||
if (!settings.loaded || movedLocalRef.current) return;
|
||||
movedLocalRef.current = true;
|
||||
const merged = mergeLocalMacros(macros);
|
||||
if (!merged) return;
|
||||
saveSettings({ macros: merged.macros })
|
||||
.then(merged.clear)
|
||||
.catch(() => toast.error(t("macros.saveFailed")));
|
||||
}, [settings.loaded, macros, saveSettings, t]);
|
||||
|
||||
const target = useMemo(
|
||||
() =>
|
||||
sessions.find((session) => session.id === targetTab?.id) ?? sessions[0],
|
||||
[sessions, targetTab?.id],
|
||||
);
|
||||
|
||||
const updateDraft = useCallback((next: TerminalMacro) => {
|
||||
setDraft(next);
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const createMacro = () => {
|
||||
const now = new Date().toISOString();
|
||||
setDraft({
|
||||
id: id(),
|
||||
name: "",
|
||||
description: "",
|
||||
steps: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const persist = async (next: TerminalMacro[]) => {
|
||||
await saveSettings({ macros: next });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) {
|
||||
toast.error(t("macros.nameRequired"));
|
||||
return;
|
||||
}
|
||||
const saved: TerminalMacro = {
|
||||
...draft,
|
||||
name: draft.name.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
await persist(
|
||||
macros.some((macro) => macro.id === saved.id)
|
||||
? macros.map((macro) => (macro.id === saved.id ? saved : macro))
|
||||
: [...macros, saved],
|
||||
);
|
||||
setDraft(saved);
|
||||
setDirty(false);
|
||||
toast.success(t("macros.saved"));
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
const next = macros.filter((macro) => macro.id !== pendingDelete.id);
|
||||
try {
|
||||
await persist(next);
|
||||
if (draft?.id === pendingDelete.id) {
|
||||
setDraft(null);
|
||||
setDirty(false);
|
||||
}
|
||||
toast.success(t("macros.deleted"));
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const execute = useCallback(
|
||||
async (macro: TerminalMacro) => {
|
||||
const terminal = target ? getSessionHandle(target.id) : null;
|
||||
if (!terminal?.sendInput || !terminal.subscribeOutput) {
|
||||
toast.error(t("macros.needTerminal"));
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setRunningId(macro.id);
|
||||
try {
|
||||
await runTerminalMacro(
|
||||
macro,
|
||||
{ send: terminal.sendInput, subscribe: terminal.subscribeOutput },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
toast.success(t("macros.completed"));
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||
toast.error(getErrorMessage(error) || t("macros.failed"));
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setRunningId(null);
|
||||
}
|
||||
},
|
||||
[target, t],
|
||||
);
|
||||
|
||||
const requestRun = (macro: TerminalMacro) => {
|
||||
if (!target || !getSessionHandle(target.id)?.sendInput) {
|
||||
toast.error(t("macros.needTerminal"));
|
||||
return;
|
||||
}
|
||||
if (confirmedRef.current.has(macro.id)) {
|
||||
void execute(macro);
|
||||
return;
|
||||
}
|
||||
setPendingRun(macro);
|
||||
};
|
||||
|
||||
const targetLabel = target?.label ?? t("macros.noTerminal");
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border p-3">
|
||||
{draft ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 rounded-none"
|
||||
aria-label={t("macros.back")}
|
||||
onClick={() => {
|
||||
setDraft(null);
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Braces className="size-4 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">{t("macros.title")}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{t("macros.target")}: {targetLabel}
|
||||
</div>
|
||||
{!draft && (
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/macros"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{!draft && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-7 rounded-none"
|
||||
aria-label={t("macros.create")}
|
||||
onClick={createMacro}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{!draft ? (
|
||||
macros.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Braces}
|
||||
title={t("macros.empty")}
|
||||
hint={t("macros.emptyHint")}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1 rounded-none"
|
||||
onClick={createMacro}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
{t("macros.create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{macros.map((macro) => {
|
||||
const isRunning = runningId === macro.id;
|
||||
return (
|
||||
<div
|
||||
key={macro.id}
|
||||
className="group flex items-center gap-2 border border-border bg-muted/20 px-2 py-1.5 hover:bg-muted/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => {
|
||||
setDraft(structuredClone(macro));
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
<div className="truncate text-xs font-medium">
|
||||
{macro.name}
|
||||
</div>
|
||||
{macro.description && (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{macro.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 shrink-0 rounded-none"
|
||||
aria-label={
|
||||
isRunning ? t("macros.stop") : t("macros.run")
|
||||
}
|
||||
onClick={() =>
|
||||
isRunning
|
||||
? abortRef.current?.abort()
|
||||
: requestRun(macro)
|
||||
}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Square className="size-3" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("macros.name")}
|
||||
</div>
|
||||
<Input
|
||||
className="h-8 rounded-none text-xs"
|
||||
value={draft.name}
|
||||
placeholder={t("macros.namePlaceholder")}
|
||||
onChange={(event) =>
|
||||
updateDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("macros.descriptionLabel")}
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-14 rounded-none text-xs"
|
||||
value={draft.description ?? ""}
|
||||
placeholder={t("macros.descriptionPlaceholder")}
|
||||
onChange={(event) =>
|
||||
updateDraft({ ...draft, description: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.steps.length === 0 && (
|
||||
<div className="border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted-foreground">
|
||||
{t("macros.noSteps")}
|
||||
</div>
|
||||
)}
|
||||
<StepList
|
||||
steps={draft.steps}
|
||||
onChange={(steps) => updateDraft({ ...draft, steps })}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="rounded-none"
|
||||
onClick={() => setPendingDelete(draft)}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3" />
|
||||
{t("macros.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={save}
|
||||
>
|
||||
<Save className="mr-1 size-3" />
|
||||
{t("macros.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto rounded-none"
|
||||
onClick={() =>
|
||||
runningId === draft.id
|
||||
? abortRef.current?.abort()
|
||||
: requestRun(draft)
|
||||
}
|
||||
>
|
||||
{runningId === draft.id ? (
|
||||
<>
|
||||
<Square className="mr-1 size-3" />
|
||||
{t("macros.stop")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1 size-3" />
|
||||
{t("macros.run")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{dirty && (
|
||||
<div className="text-[11px] text-amber-500">
|
||||
{t("macros.unsaved")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
>
|
||||
<AlertDialogContent className="rounded-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("macros.deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("macros.deleteBody", { name: pendingDelete?.name ?? "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="rounded-none">
|
||||
{t("macros.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction className="rounded-none" onClick={confirmDelete}>
|
||||
{t("macros.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingRun !== null}
|
||||
onOpenChange={(open) => !open && setPendingRun(null)}
|
||||
>
|
||||
<AlertDialogContent className="rounded-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("macros.confirmRunTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("macros.confirmRunBody", {
|
||||
name: pendingRun?.name ?? "",
|
||||
host: targetLabel,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="rounded-none">
|
||||
{t("macros.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="rounded-none"
|
||||
onClick={() => {
|
||||
const macro = pendingRun;
|
||||
setPendingRun(null);
|
||||
if (macro) {
|
||||
confirmedRef.current.add(macro.id);
|
||||
void execute(macro);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("macros.confirmRunAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
export type MacroStep =
|
||||
| { id: string; type: "send"; text: string; pressEnter: boolean }
|
||||
| { id: string; type: "delay"; milliseconds: number }
|
||||
| {
|
||||
id: string;
|
||||
type: "wait";
|
||||
pattern: string;
|
||||
isRegex?: boolean;
|
||||
flags?: string;
|
||||
timeoutMs: number;
|
||||
onTimeout: "stop" | "continue";
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "if";
|
||||
pattern: string;
|
||||
isRegex?: boolean;
|
||||
flags?: string;
|
||||
then: MacroStep[];
|
||||
else: MacroStep[];
|
||||
}
|
||||
| { id: string; type: "repeat"; count: number; steps: MacroStep[] };
|
||||
|
||||
export interface TerminalMacro {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: MacroStep[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MacroTerminalAdapter {
|
||||
send(data: string): void;
|
||||
subscribe(listener: (data: string) => void): () => void;
|
||||
}
|
||||
|
||||
export type MacroRunEvent =
|
||||
| { type: "step"; stepId: string }
|
||||
| { type: "matched"; stepId: string; match: string }
|
||||
| { type: "timeout"; stepId: string }
|
||||
| { type: "complete" };
|
||||
|
||||
const MAX_OUTPUT = 100_000;
|
||||
|
||||
function bounded(value: number, minimum: number, maximum: number): number {
|
||||
return Number.isFinite(value)
|
||||
? Math.min(maximum, Math.max(minimum, Math.floor(value)))
|
||||
: minimum;
|
||||
}
|
||||
|
||||
function escapeLiteral(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function regex(pattern: string, flags = "", isRegex = true): RegExp {
|
||||
const source = isRegex ? pattern : escapeLiteral(pattern);
|
||||
return new RegExp(source, flags.replace(/[gy]/g, ""));
|
||||
}
|
||||
|
||||
// Literal text is the default in the editor, so a bad regex should never kill a
|
||||
// run. Fall back to matching the pattern literally instead.
|
||||
function safeRegex(pattern: string, flags = "", isRegex = true): RegExp {
|
||||
try {
|
||||
return regex(pattern, flags, isRegex);
|
||||
} catch {
|
||||
return new RegExp(escapeLiteral(pattern), flags.replace(/[gy]/g, ""));
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("Macro cancelled", "AbortError");
|
||||
}
|
||||
|
||||
function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(abortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, milliseconds);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runTerminalMacro(
|
||||
macro: TerminalMacro,
|
||||
terminal: MacroTerminalAdapter,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onEvent?: (event: MacroRunEvent) => void;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
let output = "";
|
||||
let scanFrom = 0;
|
||||
let wake: (() => void) | null = null;
|
||||
const unsubscribe = terminal.subscribe((data) => {
|
||||
const combined = output + data;
|
||||
const removed = Math.max(0, combined.length - MAX_OUTPUT);
|
||||
output = combined.slice(removed);
|
||||
scanFrom = Math.max(0, scanFrom - removed);
|
||||
wake?.();
|
||||
});
|
||||
|
||||
const runSteps = async (steps: MacroStep[]): Promise<void> => {
|
||||
for (const step of steps) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
options.onEvent?.({ type: "step", stepId: step.id });
|
||||
|
||||
if (step.type === "send") {
|
||||
scanFrom = output.length;
|
||||
terminal.send(step.text + (step.pressEnter ? "\r" : ""));
|
||||
continue;
|
||||
}
|
||||
if (step.type === "delay") {
|
||||
await delay(bounded(step.milliseconds, 0, 300_000), options.signal);
|
||||
continue;
|
||||
}
|
||||
if (step.type === "if") {
|
||||
await runSteps(
|
||||
safeRegex(step.pattern, step.flags, step.isRegex !== false).test(
|
||||
output,
|
||||
)
|
||||
? step.then
|
||||
: step.else,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (step.type === "repeat") {
|
||||
const count = bounded(step.count, 1, 100);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await runSteps(step.steps);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matcher = safeRegex(
|
||||
step.pattern,
|
||||
step.flags,
|
||||
step.isRegex !== false,
|
||||
);
|
||||
const deadline = Date.now() + bounded(step.timeoutMs, 100, 300_000);
|
||||
let match = matcher.exec(output.slice(scanFrom));
|
||||
while (!match && Date.now() < deadline) {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
}),
|
||||
delay(Math.min(250, deadline - Date.now()), options.signal),
|
||||
]);
|
||||
wake = null;
|
||||
match = matcher.exec(output.slice(scanFrom));
|
||||
}
|
||||
if (match) {
|
||||
scanFrom += match.index + match[0].length;
|
||||
options.onEvent?.({
|
||||
type: "matched",
|
||||
stepId: step.id,
|
||||
match: match[0],
|
||||
});
|
||||
} else {
|
||||
options.onEvent?.({ type: "timeout", stepId: step.id });
|
||||
if (step.onTimeout === "stop") {
|
||||
throw new Error(`Timed out waiting for /${step.pattern}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await runSteps(macro.steps);
|
||||
options.onEvent?.({ type: "complete" });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored macros, bounded and cleaned; accepts the array or its JSON. */
|
||||
export function sanitizeTerminalMacros(value: unknown): TerminalMacro[] {
|
||||
if (value === null || value === undefined || value === "") return [];
|
||||
try {
|
||||
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
let remainingSteps = 500;
|
||||
const sanitizeSteps = (input: unknown, depth: number): MacroStep[] => {
|
||||
if (!Array.isArray(input) || depth > 4 || remainingSteps <= 0) return [];
|
||||
return input.flatMap((raw): MacroStep[] => {
|
||||
if (!raw || typeof raw !== "object" || remainingSteps-- <= 0) return [];
|
||||
const step = raw as Record<string, unknown>;
|
||||
const stepId =
|
||||
typeof step.id === "string" ? step.id : `step-${remainingSteps}`;
|
||||
if (step.type === "send") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "send",
|
||||
text:
|
||||
typeof step.text === "string" ? step.text.slice(0, 65_536) : "",
|
||||
pressEnter: step.pressEnter !== false,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "delay") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "delay",
|
||||
milliseconds: bounded(Number(step.milliseconds), 0, 300_000),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "wait") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "wait",
|
||||
pattern:
|
||||
typeof step.pattern === "string"
|
||||
? step.pattern.slice(0, 1000)
|
||||
: "",
|
||||
isRegex: step.isRegex !== false,
|
||||
flags:
|
||||
typeof step.flags === "string"
|
||||
? step.flags.replace(/[^imsu]/g, "")
|
||||
: undefined,
|
||||
timeoutMs: bounded(Number(step.timeoutMs), 100, 300_000),
|
||||
onTimeout: step.onTimeout === "continue" ? "continue" : "stop",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "if") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "if",
|
||||
pattern:
|
||||
typeof step.pattern === "string"
|
||||
? step.pattern.slice(0, 1000)
|
||||
: "",
|
||||
isRegex: step.isRegex !== false,
|
||||
flags:
|
||||
typeof step.flags === "string"
|
||||
? step.flags.replace(/[^imsu]/g, "")
|
||||
: undefined,
|
||||
then: sanitizeSteps(step.then, depth + 1),
|
||||
else: sanitizeSteps(step.else, depth + 1),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "repeat") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "repeat",
|
||||
count: bounded(Number(step.count), 1, 100),
|
||||
steps: sanitizeSteps(step.steps, depth + 1),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
return parsed.slice(0, 100).flatMap((raw): TerminalMacro[] => {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const macro = raw as Record<string, unknown>;
|
||||
if (typeof macro.id !== "string" || typeof macro.name !== "string")
|
||||
return [];
|
||||
const now = new Date().toISOString();
|
||||
return [
|
||||
{
|
||||
id: macro.id,
|
||||
name: macro.name.slice(0, 200),
|
||||
description:
|
||||
typeof macro.description === "string"
|
||||
? macro.description.slice(0, 2000)
|
||||
: undefined,
|
||||
steps: sanitizeSteps(macro.steps, 0),
|
||||
createdAt:
|
||||
typeof macro.createdAt === "string" ? macro.createdAt : now,
|
||||
updatedAt:
|
||||
typeof macro.updatedAt === "string" ? macro.updatedAt : now,
|
||||
},
|
||||
];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTerminalMacros(value?: string | null): TerminalMacro[] {
|
||||
return sanitizeTerminalMacros(value);
|
||||
}
|
||||
|
||||
/** Where 2.8 kept macros for a user whose storage mode was local. */
|
||||
export const LOCAL_MACROS_KEY = "terminalMacros";
|
||||
|
||||
/**
|
||||
* The saved macros plus any this browser kept on its own before 2.9.0 that
|
||||
* the saved list does not already have, or null when there is nothing to
|
||||
* move. `clear` removes the browser copy once the merged list is saved.
|
||||
*/
|
||||
export function mergeLocalMacros(
|
||||
saved: TerminalMacro[],
|
||||
storage: Pick<Storage, "getItem" | "removeItem"> | null = safeStorage(),
|
||||
): { macros: TerminalMacro[]; clear: () => void } | null {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = storage?.getItem(LOCAL_MACROS_KEY) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (raw === null) return null;
|
||||
const clear = () => {
|
||||
try {
|
||||
storage?.removeItem(LOCAL_MACROS_KEY);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
};
|
||||
const known = new Set(saved.map((macro) => macro.id));
|
||||
const extra = sanitizeTerminalMacros(raw).filter(
|
||||
(macro) => !known.has(macro.id),
|
||||
);
|
||||
if (extra.length === 0) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
return { macros: [...saved, ...extra].slice(0, 100), clear };
|
||||
}
|
||||
|
||||
function safeStorage(): Storage | null {
|
||||
try {
|
||||
return typeof localStorage === "undefined" ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { TerminalHandle } from "./terminal/terminal-types";
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
id: string;
|
||||
hostId: number | null;
|
||||
hostName?: string;
|
||||
/** The tab's title. */
|
||||
label?: string;
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface Session extends TerminalSessionInfo {
|
||||
ref: TerminalHandle;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
let activeId: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: TerminalSessionInfo[] = [];
|
||||
|
||||
function notify(): void {
|
||||
snapshot = listSessions();
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
/** The open sessions, the same array until one opens or closes. */
|
||||
export function sessionsSnapshot(): TerminalSessionInfo[] {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Called by the terminal tab wrapper on mount/unmount and focus change. */
|
||||
export function registerSession(
|
||||
info: TerminalSessionInfo,
|
||||
ref: TerminalHandle,
|
||||
): () => void {
|
||||
sessions.set(info.id, { ...info, ref });
|
||||
notify();
|
||||
return () => {
|
||||
sessions.delete(info.id);
|
||||
if (activeId === info.id) activeId = null;
|
||||
notify();
|
||||
};
|
||||
}
|
||||
|
||||
/** Called whenever a session opens or closes. Returns the unsubscribe. */
|
||||
export function subscribeSessions(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The live terminal of a session, for tools that type into several at once. */
|
||||
export function getSessionHandle(sessionId: string): TerminalHandle | null {
|
||||
return sessions.get(sessionId)?.ref ?? null;
|
||||
}
|
||||
|
||||
export function setActiveSession(id: string | null): void {
|
||||
activeId = id;
|
||||
}
|
||||
|
||||
export function listSessions(): TerminalSessionInfo[] {
|
||||
return [...sessions.values()].map(({ ref: _ref, ...info }) => info);
|
||||
}
|
||||
|
||||
function send(session: Session, text: string, run?: boolean): void {
|
||||
if (run) session.ref.sendInput(text + "\r");
|
||||
else session.ref.paste(text);
|
||||
}
|
||||
|
||||
export function sendToActive(text: string, opts?: { run?: boolean }): boolean {
|
||||
if (!activeId) return false;
|
||||
const session = sessions.get(activeId);
|
||||
if (!session) return false;
|
||||
send(session, text, opts?.run);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sendToSession(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
opts?: { run?: boolean },
|
||||
): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
send(session, text, opts?.run);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
|
||||
import { HostFeatureFields } from "@termix/plugin-sdk/ui";
|
||||
import { HostTerminalSettings } from "./HostTerminalSettings";
|
||||
|
||||
/** The host editor's Terminal tab: this plugin's switches, then the look. */
|
||||
export function HostTerminalSection({
|
||||
form,
|
||||
setField,
|
||||
updateForm,
|
||||
host,
|
||||
}: HostEditorSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<HostFeatureFields form={form} updateForm={updateForm} />
|
||||
<HostTerminalSettings
|
||||
form={form}
|
||||
setField={setField}
|
||||
updateForm={updateForm}
|
||||
host={host}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { setThemePreview } from "../look/theme-preview";
|
||||
import { Info, Palette, X, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
FakeSwitch,
|
||||
HostDefaultBadge,
|
||||
Input,
|
||||
SectionCard,
|
||||
Select2,
|
||||
SettingRow,
|
||||
Slider,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useSettings,
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { TerminalPreview } from "../look/TerminalPreview";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
BELL_STYLES,
|
||||
FAST_SCROLL_MODIFIERS,
|
||||
CURSOR_STYLES,
|
||||
} from "../look/terminal-themes";
|
||||
import {
|
||||
TERMINAL_FONT_ZOOM_MIN,
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
} from "../look/terminal-font-zoom";
|
||||
import {
|
||||
readHostTerminalSettings,
|
||||
readUserSettings,
|
||||
type BackspaceMode,
|
||||
type BellStyle,
|
||||
type CursorStyle,
|
||||
type FastScrollModifier,
|
||||
type HostTerminalSettings as HostTerminalValues,
|
||||
type SavedCustomTheme,
|
||||
} from "../../shared/terminal-settings";
|
||||
import { invalidateTerminalClientSettings } from "../terminal-settings";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
const CUSTOM_FONT_OPTION = "__custom__";
|
||||
|
||||
type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* The host editor's terminal appearance and behavior cards. The values are
|
||||
* this plugin's host settings, kept on the editor form under pluginSettings
|
||||
* and saved with the host.
|
||||
*/
|
||||
export function HostTerminalSettings({
|
||||
form: editorForm,
|
||||
updateForm,
|
||||
}: Pick<HostEditorSectionProps, "form" | "setField" | "updateForm" | "host">) {
|
||||
const { t } = useTranslation();
|
||||
const userSettings = useSettings("user");
|
||||
// The preview ends when the editor section goes away.
|
||||
useEffect(() => () => setThemePreview(null), []);
|
||||
|
||||
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
|
||||
PLUGIN_ID
|
||||
] ?? {}) as Record<string, unknown>;
|
||||
// The form holds what the host follows, so this is what it will look like.
|
||||
const form = readHostTerminalSettings(stored) as HostTerminalValues;
|
||||
|
||||
const writeValues = (values: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
const all = (current.pluginSettings ?? {}) as HostPluginSettings;
|
||||
return {
|
||||
...current,
|
||||
pluginSettings: {
|
||||
...all,
|
||||
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const setField = <K extends keyof HostTerminalValues>(
|
||||
key: K,
|
||||
value: HostTerminalValues[K],
|
||||
) => writeValues({ [key]: value });
|
||||
|
||||
const [isCustomFont, setIsCustomFont] = useState(
|
||||
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
||||
);
|
||||
const savedThemes: SavedCustomTheme[] = useMemo(
|
||||
() => readUserSettings(userSettings.values).customThemes,
|
||||
[userSettings.values],
|
||||
);
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
|
||||
const saveThemes = async (updated: SavedCustomTheme[]) => {
|
||||
await userSettings.save({ customThemes: updated });
|
||||
invalidateTerminalClientSettings();
|
||||
};
|
||||
|
||||
const handleSaveAsGlobalTheme = async () => {
|
||||
const colors = form.customThemeColors;
|
||||
if (!colors) return;
|
||||
const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt"));
|
||||
if (!name || !name.trim()) return;
|
||||
setSavingTheme(true);
|
||||
try {
|
||||
await saveThemes([
|
||||
...savedThemes,
|
||||
{
|
||||
id:
|
||||
"theme-" +
|
||||
Date.now() +
|
||||
"-" +
|
||||
Math.random().toString(36).slice(2, 8),
|
||||
name: name.trim(),
|
||||
colors,
|
||||
},
|
||||
]);
|
||||
toast.success(t("hosts.saveGlobalThemeSuccess"));
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
} finally {
|
||||
setSavingTheme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGlobalTheme = async (id: string) => {
|
||||
try {
|
||||
await saveThemes(savedThemes.filter((theme) => theme.id !== id));
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalTheme = (id: string) => {
|
||||
const theme = savedThemes.find((entry) => entry.id === id);
|
||||
if (!theme) return;
|
||||
setField("customThemeColors", { ...theme.colors });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.terminalAppearance")}
|
||||
icon={<Palette className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.themePreview")}
|
||||
</label>
|
||||
<TerminalPreview
|
||||
theme={form.theme}
|
||||
fontSize={form.fontSize}
|
||||
fontFamily={form.fontFamily}
|
||||
cursorStyle={form.cursorStyle}
|
||||
cursorBlink={form.cursorBlink}
|
||||
letterSpacing={form.letterSpacing}
|
||||
lineHeight={form.lineHeight}
|
||||
customThemeColors={form.customThemeColors ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.colorTheme")}
|
||||
<HostDefaultBadge settingKey="theme" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.theme}
|
||||
onChange={(e) => {
|
||||
const newTheme = e.target.value;
|
||||
setField("theme", newTheme);
|
||||
setThemePreview(newTheme);
|
||||
if (newTheme === "custom" && !form.customThemeColors) {
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(
|
||||
([key]) => key !== "termixDark" && key !== "termixLight",
|
||||
)
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontFamilyLabel")}
|
||||
<HostDefaultBadge settingKey="fontFamily" />
|
||||
</label>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="size-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("hosts.fontFamilyCustomHint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select2
|
||||
value={isCustomFont ? CUSTOM_FONT_OPTION : form.fontFamily}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_FONT_OPTION) {
|
||||
setIsCustomFont(true);
|
||||
setField("fontFamily", "");
|
||||
} else {
|
||||
setIsCustomFont(false);
|
||||
setField("fontFamily", e.target.value);
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
>
|
||||
{TERMINAL_FONTS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_FONT_OPTION}>
|
||||
{t("hosts.fontFamilyCustomOption")}
|
||||
</option>
|
||||
</Select2>
|
||||
{isCustomFont && (
|
||||
<Input
|
||||
value={form.fontFamily}
|
||||
onChange={(e) => setField("fontFamily", e.target.value)}
|
||||
placeholder={t("hosts.fontFamilyCustomPlaceholder")}
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontSizeLabel")}
|
||||
<HostDefaultBadge settingKey="fontSize" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={TERMINAL_FONT_ZOOM_MIN}
|
||||
max={TERMINAL_FONT_ZOOM_MAX}
|
||||
step={1}
|
||||
value={[form.fontSize]}
|
||||
onValueChange={([v]) => setField("fontSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.cursorStyleLabel")}
|
||||
<HostDefaultBadge settingKey="cursorStyle" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.cursorStyle}
|
||||
onChange={(e) =>
|
||||
setField("cursorStyle", e.target.value as CursorStyle)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{CURSOR_STYLES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{t(s.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.letterSpacingPx")}
|
||||
<HostDefaultBadge settingKey="letterSpacing" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.letterSpacing}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[form.letterSpacing]}
|
||||
onValueChange={([v]) => setField("letterSpacing", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.lineHeightLabel")}
|
||||
<HostDefaultBadge settingKey="lineHeight" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.lineHeight.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={[form.lineHeight]}
|
||||
onValueChange={([v]) => setField("lineHeight", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.bellStyleLabel")}
|
||||
<HostDefaultBadge settingKey="bellStyle" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.bellStyle}
|
||||
onChange={(e) =>
|
||||
setField("bellStyle", e.target.value as BellStyle)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{BELL_STYLES.map((b) => (
|
||||
<option key={b.value} value={b.value}>
|
||||
{t(b.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backspaceModeLabel")}
|
||||
<HostDefaultBadge settingKey="backspaceMode" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.backspaceMode}
|
||||
onChange={(e) =>
|
||||
setField("backspaceMode", e.target.value as BackspaceMode)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="normal">
|
||||
{t("hosts.backspaceModeNormal")}
|
||||
</option>
|
||||
<option value="control-h">
|
||||
{t("hosts.backspaceModeControlH")}
|
||||
</option>
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.savedThemesLabel")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px]"
|
||||
disabled={savingTheme || !form.customThemeColors}
|
||||
onClick={handleSaveAsGlobalTheme}
|
||||
>
|
||||
{t("hosts.saveAsGlobalTheme")}
|
||||
</Button>
|
||||
</div>
|
||||
{savedThemes.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.noSavedThemes")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{savedThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between gap-2 border border-border px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.applyGlobalThemeTooltip")}
|
||||
onClick={() => handleApplyGlobalTheme(theme.id)}
|
||||
className="flex items-center gap-2 text-xs text-left flex-1 min-w-0 hover:text-foreground transition-colors"
|
||||
>
|
||||
<span
|
||||
className="size-3.5 shrink-0 border border-border"
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{theme.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.deleteGlobalThemeTooltip")}
|
||||
onClick={() => handleDeleteGlobalTheme(theme.id)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.customThemeColors")}
|
||||
<HostDefaultBadge settingKey="customThemeColors" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.customThemeResetTooltip")}
|
||||
onClick={() =>
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
})
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("hosts.customThemeResetTooltip")}
|
||||
</button>
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
["background", "customThemeBackground"],
|
||||
["foreground", "customThemeForeground"],
|
||||
["cursor", "customThemeCursor"],
|
||||
["cursorAccent", "customThemeCursorAccent"],
|
||||
["selectionBackground", "customThemeSelection"],
|
||||
] as const
|
||||
).map(([key, labelKey]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label className="text-xs text-muted-foreground min-w-0 flex-1">
|
||||
{t(`hosts.${labelKey}`)}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]
|
||||
}
|
||||
onChange={(e) =>
|
||||
setField("customThemeColors", {
|
||||
...(form.customThemeColors ??
|
||||
TERMINAL_THEMES.termixDark.colors),
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
||||
{form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
|
||||
{t("hosts.customThemeAnsiColors")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{(
|
||||
[
|
||||
["black", "customThemeBlack"],
|
||||
["brightBlack", "customThemeBrightBlack"],
|
||||
["red", "customThemeRed"],
|
||||
["brightRed", "customThemeBrightRed"],
|
||||
["green", "customThemeGreen"],
|
||||
["brightGreen", "customThemeBrightGreen"],
|
||||
["yellow", "customThemeYellow"],
|
||||
["brightYellow", "customThemeBrightYellow"],
|
||||
["blue", "customThemeBlue"],
|
||||
["brightBlue", "customThemeBrightBlue"],
|
||||
["magenta", "customThemeMagenta"],
|
||||
["brightMagenta", "customThemeBrightMagenta"],
|
||||
["cyan", "customThemeCyan"],
|
||||
["brightCyan", "customThemeBrightCyan"],
|
||||
["white", "customThemeWhite"],
|
||||
["brightWhite", "customThemeBrightWhite"],
|
||||
] as const
|
||||
).map(([key, labelKey]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label className="text-xs text-muted-foreground min-w-0 flex-1 truncate">
|
||||
{t(`hosts.${labelKey}`)}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]
|
||||
}
|
||||
onChange={(e) =>
|
||||
setField("customThemeColors", {
|
||||
...(form.customThemeColors ??
|
||||
TERMINAL_THEMES.termixDark.colors),
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
||||
{form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("hosts.cursorBlinking")}
|
||||
defaultKey="cursorBlink"
|
||||
description={t("hosts.cursorBlinkingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.cursorBlink}
|
||||
onChange={(v) => setField("cursorBlink", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.rightClickSelectsWordLabel")}
|
||||
defaultKey="rightClickSelectsWord"
|
||||
description={t("hosts.rightClickSelectsWordShortDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.rightClickSelectsWord}
|
||||
onChange={(v) => setField("rightClickSelectsWord", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.macOptionIsMetaLabel")}
|
||||
defaultKey="macOptionIsMeta"
|
||||
description={t("hosts.macOptionIsMetaShortDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.macOptionIsMeta}
|
||||
onChange={(v) => setField("macOptionIsMeta", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.syntaxHighlightingLabel")}
|
||||
defaultKey="syntaxHighlighting"
|
||||
description={t("hosts.syntaxHighlightingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.syntaxHighlighting}
|
||||
onChange={(v) => setField("syntaxHighlighting", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.syntaxHighlighting && (
|
||||
<div className="flex flex-col ml-4">
|
||||
{(
|
||||
[
|
||||
[
|
||||
"logLevels",
|
||||
"syntaxCategoryLogLevels",
|
||||
"syntaxCategoryLogLevelsDesc",
|
||||
],
|
||||
["paths", "syntaxCategoryPaths", "syntaxCategoryPathsDesc"],
|
||||
[
|
||||
"timestamps",
|
||||
"syntaxCategoryTimestamps",
|
||||
"syntaxCategoryTimestampsDesc",
|
||||
],
|
||||
[
|
||||
"ipAddresses",
|
||||
"syntaxCategoryIpAddresses",
|
||||
"syntaxCategoryIpAddressesDesc",
|
||||
],
|
||||
["urls", "syntaxCategoryUrls", "syntaxCategoryUrlsDesc"],
|
||||
[
|
||||
"numbers",
|
||||
"syntaxCategoryNumbers",
|
||||
"syntaxCategoryNumbersDesc",
|
||||
],
|
||||
] as const
|
||||
).map(([key, labelKey, descKey]) => (
|
||||
<SettingRow
|
||||
key={key}
|
||||
defaultKey="syntaxHighlightingOptions"
|
||||
label={t(`hosts.${labelKey}`)}
|
||||
description={t(`hosts.${descKey}`)}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.syntaxHighlightingOptions?.[key] ?? true}
|
||||
onChange={(v) =>
|
||||
setField("syntaxHighlightingOptions", {
|
||||
...form.syntaxHighlightingOptions,
|
||||
[key]: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backgroundImageLabel")}
|
||||
<HostDefaultBadge settingKey="backgroundImage" />
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.backgroundImageDesc")}
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
value={form.backgroundImage}
|
||||
onChange={(e) => setField("backgroundImage", e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
{form.backgroundImage && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backgroundImageOpacityLabel")}
|
||||
<HostDefaultBadge settingKey="backgroundImageOpacity" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{Math.round(form.backgroundImageOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.05}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={[form.backgroundImageOpacity]}
|
||||
onValueChange={([v]) => setField("backgroundImageOpacity", v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.behaviorAndAdvanced")}
|
||||
icon={<Zap className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.scrollbackBufferLabel")}
|
||||
<HostDefaultBadge settingKey="scrollback" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.scrollback.toLocaleString()}{" "}
|
||||
{t("hosts.scrollbackMaxLines")}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={[form.scrollback]}
|
||||
onValueChange={([v]) => setField("scrollback", v)}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.enableAutoTmux")}
|
||||
defaultKey="autoTmux"
|
||||
description={
|
||||
<>
|
||||
{t("hosts.enableAutoTmuxDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/tmux"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.autoTmux}
|
||||
onChange={(v) => setField("autoTmux", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useSSHTitleLabel")}
|
||||
defaultKey="useSSHTitle"
|
||||
description={t("hosts.useSSHTitleDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.useSSHTitle}
|
||||
onChange={(v) => setField("useSSHTitle", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.enableAutoMosh")}
|
||||
defaultKey="autoMosh"
|
||||
description={t("hosts.enableAutoMoshDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.autoMosh}
|
||||
onChange={(v) => setField("autoMosh", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.autoReconnectLabel")}
|
||||
defaultKey="autoReconnect"
|
||||
description={t("hosts.autoReconnectDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.autoReconnect}
|
||||
onChange={(v) => setField("autoReconnect", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.passwordPromptAutoFillLabel")}
|
||||
defaultKey="passwordPromptAutoFill"
|
||||
description={t("hosts.passwordPromptAutoFillDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.passwordPromptAutoFill}
|
||||
onChange={(v) => setField("passwordPromptAutoFill", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.sudoPasswordAutoFillLabel")}
|
||||
defaultKey="sudoPasswordAutoFill"
|
||||
description={t("hosts.sudoPasswordAutoFillDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.sudoPasswordAutoFill}
|
||||
onChange={(v) => setField("sudoPasswordAutoFill", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.localEchoLabel")}
|
||||
<HostDefaultBadge settingKey="localEcho" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.localEcho}
|
||||
onChange={(e) =>
|
||||
setField("localEcho", e.target.value as "off" | "auto" | "on")
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="off">{t("hosts.localEchoOff")}</option>
|
||||
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
||||
<option value="on">{t("hosts.localEchoOn")}</option>
|
||||
</Select2>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.localEchoDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/appearance"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.linkClickBehaviorLabel")}
|
||||
<HostDefaultBadge settingKey="linkClickBehavior" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.linkClickBehavior}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"linkClickBehavior",
|
||||
e.target.value as "confirm" | "direct",
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="confirm">
|
||||
{t("hosts.linkClickBehaviorConfirm")}
|
||||
</option>
|
||||
<option value="direct">
|
||||
{t("hosts.linkClickBehaviorDirect")}
|
||||
</option>
|
||||
</Select2>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.linkClickBehaviorDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fastScrollModifierLabel")}
|
||||
<HostDefaultBadge settingKey="fastScrollModifier" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.fastScrollModifier}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"fastScrollModifier",
|
||||
e.target.value as FastScrollModifier,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{FAST_SCROLL_MODIFIERS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{t(m.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fastScrollSensitivityLabel")}
|
||||
<HostDefaultBadge settingKey="fastScrollSensitivity" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fastScrollSensitivity}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[form.fastScrollSensitivity]}
|
||||
onValueChange={([v]) => setField("fastScrollSensitivity", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.autoMosh && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.moshCommandLabel")}
|
||||
<HostDefaultBadge settingKey="moshCommand" />
|
||||
</label>
|
||||
<Input
|
||||
placeholder="mosh"
|
||||
value={form.moshCommand}
|
||||
onChange={(e) => setField("moshCommand", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TestResult {
|
||||
mode: string;
|
||||
connected: boolean;
|
||||
remoteSftpAvailable: boolean;
|
||||
localHostVisible: boolean | null;
|
||||
selectedMode: "local" | "remote-sftp" | "unavailable";
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
const SELECTED_MODE_LABEL_KEYS: Record<TestResult["selectedMode"], string> = {
|
||||
local: "settings.admin.imageStorageMode.local",
|
||||
"remote-sftp": "settings.admin.imageStorageMode.remoteSftp",
|
||||
unavailable: "imageStorage.selectedModeUnavailable",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks which storage an image upload would use for one of the admin's own
|
||||
* connected terminals, against the saved settings.
|
||||
*/
|
||||
export function ImageStorageTest(_props: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<TestResult | null>(null);
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const { data } = await api.post<TestResult>("/image-storage/test", {
|
||||
instanceId: instanceId.trim(),
|
||||
});
|
||||
setResult(data);
|
||||
} catch {
|
||||
toast.error(t("imageStorage.testFailed"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("imageStorage.instanceId")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("imageStorage.instanceIdDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t("imageStorage.instanceId")}
|
||||
value={instanceId}
|
||||
onChange={(event) => setInstanceId(event.target.value)}
|
||||
className="h-8 w-full @md:w-64 text-xs"
|
||||
/>
|
||||
{result && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div>
|
||||
{result.connected
|
||||
? t("imageStorage.testConnected")
|
||||
: t("imageStorage.testNotConnected")}
|
||||
</div>
|
||||
<div>
|
||||
{t("imageStorage.testSelectedMode", {
|
||||
mode: t(SELECTED_MODE_LABEL_KEYS[result.selectedMode]),
|
||||
})}
|
||||
</div>
|
||||
{result.localHostVisible !== null && (
|
||||
<div>
|
||||
{result.localHostVisible
|
||||
? t("imageStorage.testLocalVisible")
|
||||
: t("imageStorage.testLocalNotVisible")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={testing || !instanceId.trim()}
|
||||
onClick={() => void runTest()}
|
||||
>
|
||||
{testing ? t("imageStorage.testing") : t("imageStorage.test")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
TOUCH_INPUT_NUMERIC_BOUNDS,
|
||||
type TouchInputNumericKey,
|
||||
type TouchInputSettings as TouchInputValues,
|
||||
} from "../../shared/touch-input-settings";
|
||||
import { Button, Input, SettingRow, Switch } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { cacheTouchInputSettings } from "../terminal/touch-input-settings-store";
|
||||
|
||||
const fields: Array<{
|
||||
key: TouchInputNumericKey;
|
||||
label: string;
|
||||
unit: string;
|
||||
}> = [
|
||||
{ key: "dragThresholdPx", label: "dragThreshold", unit: "px" },
|
||||
{ key: "maxWheelDeltaPx", label: "maxWheelDelta", unit: "px" },
|
||||
{
|
||||
key: "momentumSampleWindowMs",
|
||||
label: "momentumSampleWindow",
|
||||
unit: "ms",
|
||||
},
|
||||
{
|
||||
key: "releaseGracePeriodMs",
|
||||
label: "releaseGracePeriod",
|
||||
unit: "ms",
|
||||
},
|
||||
{
|
||||
key: "minimumVelocityPxPerMs",
|
||||
label: "minimumVelocity",
|
||||
unit: "px/ms",
|
||||
},
|
||||
{
|
||||
key: "maximumVelocityPxPerMs",
|
||||
label: "maximumVelocity",
|
||||
unit: "px/ms",
|
||||
},
|
||||
{ key: "maximumDurationMs", label: "maximumDuration", unit: "ms" },
|
||||
{ key: "maximumTravelPx", label: "maximumTravel", unit: "px" },
|
||||
{ key: "decayTimeMs", label: "decayTime", unit: "ms" },
|
||||
{ key: "pixelsPerTick", label: "pixelsPerTick", unit: "px" },
|
||||
{
|
||||
key: "maximumFrameIntervalMs",
|
||||
label: "maximumFrameInterval",
|
||||
unit: "ms",
|
||||
},
|
||||
{
|
||||
key: "maximumTicksPerFrame",
|
||||
label: "maximumTicksPerFrame",
|
||||
unit: "",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Touch scrolling tuning for terminals on touch screens, stored as one
|
||||
* object in the "touchInput" admin setting.
|
||||
*/
|
||||
export function TouchInputSettings({
|
||||
values,
|
||||
setValue,
|
||||
}: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const settings = normalizeTouchInputSettings(values.touchInput);
|
||||
const setSettings = (next: TouchInputValues) => {
|
||||
const normalized = normalizeTouchInputSettings(next);
|
||||
cacheTouchInputSettings(normalized);
|
||||
setValue("touchInput", normalized);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0">
|
||||
<SettingRow
|
||||
label={t("touchInput.enabled")}
|
||||
description={t("touchInput.enabledDesc")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onCheckedChange={(enabled) => setSettings({ ...settings, enabled })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("touchInput.momentumEnabled")}
|
||||
description={t("touchInput.momentumEnabledDesc")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.momentumEnabled}
|
||||
disabled={!settings.enabled}
|
||||
onCheckedChange={(momentumEnabled) =>
|
||||
setSettings({ ...settings, momentumEnabled })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 py-3 text-left text-xs font-semibold text-foreground"
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3.5 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{t("touchInput.advanced")}
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="grid grid-cols-1 gap-3 pb-3">
|
||||
{fields.map(({ key, label, unit }) => {
|
||||
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
|
||||
return (
|
||||
<label key={key} className="flex flex-col gap-1 text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{t(`touchInput.${label}`)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label={t(`touchInput.${label}`)}
|
||||
type="number"
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
step={bounds.step}
|
||||
value={settings[key]}
|
||||
onChange={(event) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
[key]: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
{unit && (
|
||||
<span className="w-10 text-muted-foreground">{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSettings({ ...TOUCH_INPUT_DEFAULTS })}
|
||||
>
|
||||
{t("touchInput.resetDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
invalidateTerminalClientSettings,
|
||||
loadTerminalClientSettings,
|
||||
} from "../terminal-settings";
|
||||
import { DEFAULT_USER_SETTINGS } from "../../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* Browser-only terminal preferences from before 2.9.0, and the user setting
|
||||
* each became. Read once: the key is removed after it is saved.
|
||||
*/
|
||||
const LOCAL_KEYS: Array<{
|
||||
storageKey: string;
|
||||
setting: string;
|
||||
parse: (raw: string) => unknown;
|
||||
}> = [
|
||||
{
|
||||
storageKey: "terminalLocalEchoMode",
|
||||
setting: "localEcho",
|
||||
parse: (raw) => (["off", "auto", "on"].includes(raw) ? raw : undefined),
|
||||
},
|
||||
{
|
||||
storageKey: "terminalLinkClickBehavior",
|
||||
setting: "linkClickBehavior",
|
||||
parse: (raw) => (["confirm", "direct"].includes(raw) ? raw : undefined),
|
||||
},
|
||||
{
|
||||
storageKey: "commandAutocomplete",
|
||||
setting: "commandAutocomplete",
|
||||
parse: (raw) =>
|
||||
raw === "true" ? true : raw === "false" ? false : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the old localStorage values into this plugin's user settings, the
|
||||
* first time this browser runs the plugin. A value the user already changed
|
||||
* on the server wins: the browser's value only replaces a default.
|
||||
*/
|
||||
export async function moveLocalTerminalPreferences(
|
||||
app: Pick<TermixApp, "api">,
|
||||
): Promise<void> {
|
||||
const found = LOCAL_KEYS.map((entry) => ({
|
||||
...entry,
|
||||
raw: readStorage(entry.storageKey),
|
||||
})).filter((entry) => entry.raw !== null);
|
||||
if (found.length === 0) return;
|
||||
|
||||
try {
|
||||
const { user } = await loadTerminalClientSettings(app.api);
|
||||
const current = user as unknown as Record<string, unknown>;
|
||||
const defaults = DEFAULT_USER_SETTINGS as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const entry of found) {
|
||||
const value = entry.parse(entry.raw as string);
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== current[entry.setting] &&
|
||||
current[entry.setting] === defaults[entry.setting]
|
||||
) {
|
||||
values[entry.setting] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(values).length > 0) {
|
||||
await app.api.put("/user-settings", values);
|
||||
invalidateTerminalClientSettings();
|
||||
}
|
||||
for (const entry of found) {
|
||||
try {
|
||||
localStorage.removeItem(entry.storageKey);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try again next time the plugin starts.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useState, useRef, useEffect, useSyncExternalStore } from "react";
|
||||
import { KeyRound, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Separator } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
getClientPreference,
|
||||
getHostPassword,
|
||||
setClientPreference,
|
||||
useTranslation,
|
||||
type PanelProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
getSessionHandle,
|
||||
sessionsSnapshot,
|
||||
subscribeSessions,
|
||||
} from "../session-registry";
|
||||
|
||||
/**
|
||||
* SSH Tools: type into several open terminals at once, fill their saved
|
||||
* passwords, and the terminal's clipboard preferences.
|
||||
*/
|
||||
export function SshToolsPanel({ targetTab }: PanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const terminalTabs = useSyncExternalStore(
|
||||
subscribeSessions,
|
||||
sessionsSnapshot,
|
||||
);
|
||||
const activeTabId = targetTab?.id ?? "";
|
||||
const [keyRecording, setKeyRecording] = useState(false);
|
||||
const [rightClickPaste, setRightClickPaste] = useState(
|
||||
() => getClientPreference("rightClickCopyPaste") !== "false",
|
||||
);
|
||||
const [copyOnSelect, setCopyOnSelect] = useState(
|
||||
() => getClientPreference("copyOnSelect") === "true",
|
||||
);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
|
||||
? [activeTabId]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyRecording) inputRef.current?.focus();
|
||||
}, [keyRecording]);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
setSelectedTabIds(new Set(terminalTabs.map((tab) => tab.id)));
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
setSelectedTabIds(new Set());
|
||||
}
|
||||
|
||||
function broadcast(data: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
getSessionHandle(tabId)?.sendInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function fillPassword() {
|
||||
let filled = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((session) => session.id === tabId);
|
||||
const hostId = tab?.hostId ?? null;
|
||||
const ref = getSessionHandle(tabId);
|
||||
if (!hostId || !ref) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const password = await getHostPassword(hostId, "password");
|
||||
if (!password) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ref.sendInput(password + "\r");
|
||||
filled++;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
toast.success(t("sshTools.fillPasswordSuccess", { count: filled }));
|
||||
}
|
||||
if (missing > 0) {
|
||||
toast.error(t("sshTools.fillPasswordMissing", { count: missing }));
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastArrow(normalSeq: string, appSeq: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
const ref = getSessionHandle(tabId);
|
||||
if (!ref) continue;
|
||||
const appMode = ref.getApplicationCursorKeysMode?.() ?? false;
|
||||
ref.sendInput(appMode ? appSeq : normalSeq);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (text) broadcast(text);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
const ctrl = e.ctrlKey;
|
||||
const { key } = e;
|
||||
|
||||
// Let Ctrl/Cmd+V fall through so the browser fires a paste event
|
||||
// instead of being swallowed by the preventDefault() below.
|
||||
if ((ctrl || e.metaKey) && key.toLowerCase() === "v") {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (ctrl) {
|
||||
const ctrlMap: Record<string, string> = {
|
||||
c: "\x03",
|
||||
d: "\x04",
|
||||
l: "\x0C",
|
||||
u: "\x15",
|
||||
k: "\x0B",
|
||||
a: "\x01",
|
||||
e: "\x05",
|
||||
w: "\x17",
|
||||
z: "\x1A",
|
||||
r: "\x12",
|
||||
};
|
||||
const seq = ctrlMap[key.toLowerCase()];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "ArrowUp") {
|
||||
broadcastArrow("\x1B[A", "\x1BOA");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowDown") {
|
||||
broadcastArrow("\x1B[B", "\x1BOB");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowRight") {
|
||||
broadcastArrow("\x1B[C", "\x1BOC");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowLeft") {
|
||||
broadcastArrow("\x1B[D", "\x1BOD");
|
||||
return;
|
||||
}
|
||||
|
||||
const specialMap: Record<string, string> = {
|
||||
Enter: "\r",
|
||||
Backspace: "\x7F",
|
||||
Delete: "\x1B[3~",
|
||||
Tab: "\t",
|
||||
Escape: "\x1B",
|
||||
Home: "\x1B[H",
|
||||
End: "\x1B[F",
|
||||
PageUp: "\x1B[5~",
|
||||
PageDown: "\x1B[6~",
|
||||
Insert: "\x1B[2~",
|
||||
F1: "\x1BOP",
|
||||
F2: "\x1BOQ",
|
||||
F3: "\x1BOR",
|
||||
F4: "\x1BOS",
|
||||
F5: "\x1B[15~",
|
||||
F6: "\x1B[17~",
|
||||
F7: "\x1B[18~",
|
||||
F8: "\x1B[19~",
|
||||
F9: "\x1B[20~",
|
||||
F10: "\x1B[21~",
|
||||
F11: "\x1B[23~",
|
||||
F12: "\x1B[24~",
|
||||
};
|
||||
|
||||
const seq = specialMap[key];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) {
|
||||
broadcast(key);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
const next = !keyRecording;
|
||||
if (!next) {
|
||||
// clear the phantom text when stopping
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
setKeyRecording(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("sshTools.keyRecordingTitle")}
|
||||
</span>
|
||||
|
||||
{/* Terminal selector */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("sshTools.recordToTerminals")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("sshTools.selectAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("sshTools.selectNone")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{terminalTabs.length === 0 ? (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 border border-dashed border-border/60 text-muted-foreground/40">
|
||||
<Terminal className="size-3 shrink-0" />
|
||||
<span className="text-xs">
|
||||
{t("sshTools.noTerminalTabsOpen")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{terminalTabs.map((tab) => {
|
||||
const selected = selectedTabIds.has(tab.id);
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => toggleTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 border text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="size-1.5 bg-background" />}
|
||||
</div>
|
||||
<Terminal className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{tab.label || tab.hostName || tab.ip}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedTabIds.size === 0}
|
||||
className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`}
|
||||
onClick={toggleRecording}
|
||||
>
|
||||
{keyRecording
|
||||
? `${t("sshTools.stopRecording")} (${selectedTabIds.size})`
|
||||
: selectedTabIds.size === 0
|
||||
? t("sshTools.selectTerminalsAbove")
|
||||
: `${t("sshTools.startRecording")} (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
|
||||
{keyRecording && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onChange={(e) => {
|
||||
// Keystrokes are broadcast directly via handleKeyDown; the
|
||||
// field itself must stay empty. This also catches paste
|
||||
// insertion on browsers that fire "input" before we can
|
||||
// intercept it in onPaste.
|
||||
e.target.value = "";
|
||||
}}
|
||||
placeholder={t("sshTools.broadcastInputPlaceholder")}
|
||||
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedTabIds.size === 0}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
void fillPassword();
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-3.5 mr-2" />
|
||||
{selectedTabIds.size === 0
|
||||
? t("sshTools.selectTerminalsAbove")
|
||||
: `${t("sshTools.fillPassword")} (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("sshTools.settingsTitle")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sshTools.enableRightClickCopyPaste")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !rightClickPaste;
|
||||
setRightClickPaste(next);
|
||||
setClientPreference(
|
||||
"rightClickCopyPaste",
|
||||
next ? "true" : "false",
|
||||
);
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
|
||||
rightClickPaste
|
||||
? "bg-accent-brand border-accent-brand"
|
||||
: "bg-muted border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${rightClickPaste ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sshTools.copyOnSelect")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !copyOnSelect;
|
||||
setCopyOnSelect(next);
|
||||
setClientPreference("copyOnSelect", next ? "true" : "false");
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
|
||||
copyOnSelect
|
||||
? "bg-accent-brand border-accent-brand"
|
||||
: "bg-muted border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${copyOnSelect ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
type TouchInputSettings,
|
||||
} from "../shared/touch-input-settings";
|
||||
import {
|
||||
readUserSettings,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings";
|
||||
|
||||
/** Calls to this plugin's own routes under /plugin-api/ssh-terminal/. */
|
||||
|
||||
export interface TerminalClientSettings {
|
||||
sessionTimeoutMinutes: number;
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
export async function getClientSettings(
|
||||
api: PluginApiClient,
|
||||
): Promise<TerminalClientSettings> {
|
||||
const { data } = await api.get<TerminalClientSettings>("/client-settings");
|
||||
return {
|
||||
...data,
|
||||
touchInput: normalizeTouchInputSettings(data?.touchInput),
|
||||
user: readUserSettings(data?.user as unknown as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
/** Turns auto tmux on for a host (its terminal host setting). */
|
||||
export async function enableHostAutoTmux(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
await api.put(`/hosts/${hostId}/auto-tmux`, { enabled: true });
|
||||
}
|
||||
|
||||
export async function saveCommandToHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
await api.post("/command-history", { hostId, command });
|
||||
}
|
||||
|
||||
export async function getCommandHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
): Promise<string[]> {
|
||||
const { data } = await api.get<string[]>(`/command-history/${hostId}`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function deleteCommandFromHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
await api.post("/command-history/delete", { hostId, command });
|
||||
}
|
||||
|
||||
export async function clearCommandHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
await api.delete(`/command-history/${hostId}`);
|
||||
}
|
||||
|
||||
/** A host-scope setting of this plugin, as the host payload carries it. */
|
||||
export function hostSetting(
|
||||
host: object | null | undefined,
|
||||
key: string,
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
const settings = (
|
||||
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
|
||||
)?.pluginSettings;
|
||||
const value = settings?.["ssh-terminal"]?.[key];
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { getClientSettings, type TerminalClientSettings } from "./terminal-api";
|
||||
import {
|
||||
DEFAULT_MOSH_COMMAND,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
readHostTerminalSettings,
|
||||
resolveTerminalSettings,
|
||||
type TerminalAppearance,
|
||||
type TerminalBehavior,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* The signed-in user's terminal settings, fetched once and shared by every
|
||||
* terminal. Invalidated when one of this plugin's settings is saved.
|
||||
*/
|
||||
|
||||
let cached: TerminalClientSettings | null = null;
|
||||
let pending: Promise<TerminalClientSettings> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function loadTerminalClientSettings(
|
||||
api: PluginApiClient,
|
||||
): Promise<TerminalClientSettings> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (!pending) {
|
||||
const request = getClientSettings(api)
|
||||
.then((settings) => {
|
||||
if (pending === request) cached = settings;
|
||||
return settings;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pending === request) pending = null;
|
||||
});
|
||||
pending = request;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function peekTerminalClientSettings(): TerminalClientSettings | null {
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drops the cache and has every mounted terminal read the settings again. */
|
||||
export function invalidateTerminalClientSettings(): void {
|
||||
cached = null;
|
||||
pending = null;
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
export function resetTerminalClientSettings(): void {
|
||||
cached = null;
|
||||
pending = null;
|
||||
listeners.clear();
|
||||
}
|
||||
|
||||
export function useTerminalClientSettings(): TerminalClientSettings | null {
|
||||
const api = usePluginApi();
|
||||
const apiRef = useRef(api);
|
||||
apiRef.current = api;
|
||||
const [value, setValue] = useState<TerminalClientSettings | null>(cached);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = () => {
|
||||
loadTerminalClientSettings(apiRef.current)
|
||||
.then((settings) => {
|
||||
if (active) setValue(settings);
|
||||
})
|
||||
.catch(() => {
|
||||
// A guest on a share link has no account to read settings for.
|
||||
});
|
||||
};
|
||||
load();
|
||||
listeners.add(load);
|
||||
return () => {
|
||||
active = false;
|
||||
listeners.delete(load);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export type ResolvedTerminalConfig = TerminalAppearance & TerminalBehavior;
|
||||
|
||||
export interface TerminalSettingsForHost {
|
||||
config: ResolvedTerminalConfig;
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
/** This plugin's host values off a host record, as the host payload carries them. */
|
||||
export function hostTerminalValues(
|
||||
host: object | null | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const settings = (
|
||||
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
|
||||
)?.pluginSettings;
|
||||
return settings?.["ssh-terminal"];
|
||||
}
|
||||
|
||||
export function resolveForHost(
|
||||
host: object | null | undefined,
|
||||
): ResolvedTerminalConfig {
|
||||
const resolved = resolveTerminalSettings(
|
||||
readHostTerminalSettings(hostTerminalValues(host)),
|
||||
);
|
||||
return {
|
||||
...resolved,
|
||||
moshCommand: resolved.moshCommand || DEFAULT_MOSH_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a terminal on this host runs with, kept current as settings change. */
|
||||
export function useTerminalSettings(
|
||||
host: object | null | undefined,
|
||||
): TerminalSettingsForHost {
|
||||
const client = useTerminalClientSettings();
|
||||
const own = hostTerminalValues(host);
|
||||
const ownKey = own ? JSON.stringify(own) : "";
|
||||
return useMemo(() => {
|
||||
const user = client?.user ?? DEFAULT_USER_SETTINGS;
|
||||
return { config: resolveForHost(host), user };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ownKey, client]);
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
Pencil,
|
||||
X,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { TerminalHandle } from "./terminal-types";
|
||||
import {
|
||||
cn,
|
||||
Button,
|
||||
Input,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface MobileTerminalKeyboardProps {
|
||||
terminalRef: React.RefObject<TerminalHandle | null>;
|
||||
}
|
||||
|
||||
const CTRL_MAP: Record<string, string> = {
|
||||
a: "\x01",
|
||||
c: "\x03",
|
||||
d: "\x04",
|
||||
k: "\x0B",
|
||||
l: "\x0C",
|
||||
r: "\x12",
|
||||
u: "\x15",
|
||||
w: "\x17",
|
||||
z: "\x1A",
|
||||
};
|
||||
|
||||
const DEFAULT_QUICK_KEYS = [
|
||||
"/",
|
||||
"|",
|
||||
"~",
|
||||
"-",
|
||||
"_",
|
||||
"#",
|
||||
"\\",
|
||||
'"',
|
||||
"'",
|
||||
";",
|
||||
":",
|
||||
"!",
|
||||
"&",
|
||||
];
|
||||
|
||||
const LS_KEY = "termix:mobileQuickKeys";
|
||||
|
||||
function loadQuickKeys(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string"))
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_QUICK_KEYS;
|
||||
}
|
||||
|
||||
function saveQuickKeys(keys: string[]) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(keys));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Shared button styles
|
||||
const KEY_BASE =
|
||||
"flex items-center justify-center rounded border transition-colors select-none touch-none active:scale-95 shrink-0";
|
||||
const KEY_NORMAL = "border-border bg-muted/50 text-foreground hover:bg-muted";
|
||||
const KEY_ACTIVE =
|
||||
"border-accent-brand bg-accent-brand/20 text-accent-brand shadow-[0_0_0_1px_color-mix(in_oklab,var(--accent-brand)_30%,transparent)]";
|
||||
const KEY_MD = "h-9 px-3 min-w-[2.75rem] text-xs font-medium";
|
||||
const KEY_SM = "h-9 w-9";
|
||||
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||
|
||||
// --- QuickKeysSheet ---
|
||||
|
||||
interface QuickKeysSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
quickKeys: string[];
|
||||
onUpdateKeys: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
function QuickKeysSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
quickKeys,
|
||||
onUpdateKeys,
|
||||
}: QuickKeysSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newSymbol, setNewSymbol] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setNewSymbol("");
|
||||
}, [open]);
|
||||
|
||||
function addKey() {
|
||||
const sym = newSymbol.trim();
|
||||
if (!sym || quickKeys.includes(sym) || sym.length > 8) {
|
||||
setNewSymbol("");
|
||||
return;
|
||||
}
|
||||
onUpdateKeys([...quickKeys, sym]);
|
||||
setNewSymbol("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function removeKey(index: number) {
|
||||
onUpdateKeys(quickKeys.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
onUpdateKeys(DEFAULT_QUICK_KEYS);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="max-h-[70vh] flex flex-col border-border"
|
||||
>
|
||||
<SheetHeader className="flex-row items-start justify-between pr-8">
|
||||
<div>
|
||||
<SheetTitle>{t("mobileKeyboard.quickKeysTitle")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("mobileKeyboard.quickKeysDesc")}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={resetDefaults}
|
||||
>
|
||||
<RotateCcw className="size-3 mr-1" />
|
||||
{t("mobileKeyboard.resetDefaults")}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickKeys.map((sym, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 h-8 pl-3 pr-1 rounded border border-border bg-muted/50 text-xs font-medium text-foreground"
|
||||
>
|
||||
<span className="font-mono">{sym}</span>
|
||||
<button
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
onClick={() => removeKey(i)}
|
||||
title={t("mobileKeyboard.removeQuickKey")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 px-4 py-2 border-t border-border">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newSymbol}
|
||||
onChange={(e) => setNewSymbol(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addKey();
|
||||
}}
|
||||
maxLength={8}
|
||||
placeholder={t("mobileKeyboard.quickKeyPlaceholder")}
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 shrink-0"
|
||||
onClick={addKey}
|
||||
disabled={!newSymbol.trim() || quickKeys.includes(newSymbol.trim())}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("mobileKeyboard.addQuickKey")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="pt-0">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t("mobileKeyboard.done")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// --- CtrlPanel ---
|
||||
|
||||
interface CtrlPanelProps {
|
||||
onSend: (letter: string) => void;
|
||||
}
|
||||
|
||||
function CtrlPanel({ onSend }: CtrlPanelProps) {
|
||||
const CTRL_KEYS = ["c", "d", "l", "u", "z", "a", "r", "w", "k"];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-muted/30 border-t border-border overflow-x-auto">
|
||||
{CTRL_KEYS.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSend(k);
|
||||
}}
|
||||
title={`Ctrl+${k.toUpperCase()}`}
|
||||
>
|
||||
^{k.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MobileTerminalKeyboard ---
|
||||
|
||||
export function MobileTerminalKeyboard({
|
||||
terminalRef,
|
||||
}: MobileTerminalKeyboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [ctrlActive, setCtrlActive] = useState(false);
|
||||
const [shiftActive, setShiftActive] = useState(false);
|
||||
const [quickKeys, setQuickKeys] = useState<string[]>(loadQuickKeys);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
function send(seq: string) {
|
||||
terminalRef.current?.sendInput?.(seq);
|
||||
}
|
||||
|
||||
async function handlePaste() {
|
||||
try {
|
||||
const text = window.electronClipboard
|
||||
? await window.electronClipboard.readText()
|
||||
: ((await navigator.clipboard?.readText?.()) ?? "");
|
||||
if (text) terminalRef.current?.paste?.(text);
|
||||
else toast.error(t("terminal.clipboardReadFailed"));
|
||||
} catch {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCtrl() {
|
||||
setCtrlActive((v) => !v);
|
||||
setShiftActive(false);
|
||||
}
|
||||
|
||||
function toggleShift() {
|
||||
setShiftActive((v) => !v);
|
||||
setCtrlActive(false);
|
||||
}
|
||||
|
||||
function sendArrow(normalSeq: string, appSeq: string, shiftSeq: string) {
|
||||
if (shiftActive) {
|
||||
send(shiftSeq);
|
||||
setShiftActive(false);
|
||||
return;
|
||||
}
|
||||
const appMode =
|
||||
terminalRef.current?.getApplicationCursorKeysMode?.() ?? false;
|
||||
send(appMode ? appSeq : normalSeq);
|
||||
}
|
||||
|
||||
function handleTab() {
|
||||
send(shiftActive ? "\x1b[Z" : "\t");
|
||||
setShiftActive(false);
|
||||
}
|
||||
|
||||
function handleCtrlKey(letter: string) {
|
||||
const seq = CTRL_MAP[letter];
|
||||
if (seq) send(seq);
|
||||
setCtrlActive(false);
|
||||
}
|
||||
|
||||
function updateQuickKeys(next: string[]) {
|
||||
setQuickKeys(next);
|
||||
saveQuickKeys(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:hidden flex flex-col bg-sidebar border-t border-border shrink-0">
|
||||
{/* Row 1 — special keys */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto">
|
||||
{/* ESC */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b");
|
||||
}}
|
||||
title={t("mobileKeyboard.esc")}
|
||||
>
|
||||
{t("mobileKeyboard.esc")}
|
||||
</button>
|
||||
|
||||
{/* Tab / back-tab */}
|
||||
<button
|
||||
className={cn(
|
||||
KEY_BASE,
|
||||
KEY_NORMAL,
|
||||
KEY_MD,
|
||||
shiftActive && "ring-1 ring-accent-brand/50",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleTab();
|
||||
}}
|
||||
title={shiftActive ? "Shift+Tab" : t("mobileKeyboard.tab")}
|
||||
>
|
||||
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
|
||||
</button>
|
||||
|
||||
{/* Paste */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePaste();
|
||||
}}
|
||||
title={t("mobileKeyboard.paste")}
|
||||
>
|
||||
<Clipboard className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Ctrl */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_MD, ctrlActive ? KEY_ACTIVE : KEY_NORMAL)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleCtrl();
|
||||
}}
|
||||
title={t("mobileKeyboard.ctrl")}
|
||||
>
|
||||
{t("mobileKeyboard.ctrl")}
|
||||
</button>
|
||||
|
||||
{/* Shift */}
|
||||
<button
|
||||
className={cn(
|
||||
KEY_BASE,
|
||||
KEY_MD,
|
||||
shiftActive ? KEY_ACTIVE : KEY_NORMAL,
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleShift();
|
||||
}}
|
||||
title={t("mobileKeyboard.shift")}
|
||||
>
|
||||
{t("mobileKeyboard.shift")}
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Arrow keys */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[A", "\x1bOA", "\x1b[1;2A");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowUp")}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[B", "\x1bOB", "\x1b[1;2B");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowDown")}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[D", "\x1bOD", "\x1b[1;2D");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowLeft")}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[C", "\x1bOC", "\x1b[1;2C");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowRight")}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Home / End */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[H");
|
||||
}}
|
||||
title={t("mobileKeyboard.home")}
|
||||
>
|
||||
{t("mobileKeyboard.home")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[F");
|
||||
}}
|
||||
title={t("mobileKeyboard.end")}
|
||||
>
|
||||
{t("mobileKeyboard.end")}
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* PgUp / PgDn / Del */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[5~");
|
||||
}}
|
||||
title={t("mobileKeyboard.pageUp")}
|
||||
>
|
||||
{t("mobileKeyboard.pageUp")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[6~");
|
||||
}}
|
||||
title={t("mobileKeyboard.pageDown")}
|
||||
>
|
||||
{t("mobileKeyboard.pageDown")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[3~");
|
||||
}}
|
||||
title={t("mobileKeyboard.delete")}
|
||||
>
|
||||
{t("mobileKeyboard.delete")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ctrl combos panel */}
|
||||
{ctrlActive && <CtrlPanel onSend={handleCtrlKey} />}
|
||||
|
||||
{/* Row 2 — quick keys */}
|
||||
<div className="flex items-center gap-1 px-2 pb-1.5 overflow-x-auto">
|
||||
{quickKeys.map((sym, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send(sym);
|
||||
}}
|
||||
title={sym}
|
||||
>
|
||||
{sym}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ml-auto shrink-0">
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
title={t("mobileKeyboard.editQuickKeys")}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickKeysSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
quickKeys={quickKeys}
|
||||
onUpdateKeys={updateQuickKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Terminal } from "./Terminal";
|
||||
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TerminalAppProps {
|
||||
hostId?: string;
|
||||
/** tmux session to attach to once the shell is ready (tmux monitor "Attach"). */
|
||||
tmuxSession?: string;
|
||||
}
|
||||
|
||||
// Only the session name travels in the URL (never a raw command), so a crafted
|
||||
// link cannot execute arbitrary input. `=` forces exact-name matching in tmux.
|
||||
function tmuxAttachCommand(session: string): string {
|
||||
return `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId, tmuxSession }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
hostConfig={hostConfig}
|
||||
isVisible={true}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => {}}
|
||||
executeCommand={
|
||||
tmuxSession ? tmuxAttachCommand(tmuxSession) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalApp;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useThemePreview } from "../look/theme-preview";
|
||||
import { TerminalSquare } from "lucide-react";
|
||||
import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
|
||||
import { useIsMobile } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
invokeAction,
|
||||
type TabProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { Host } from "../types";
|
||||
import { hostTerminalValues } from "../terminal-settings";
|
||||
import { readHostTerminalSettings } from "../../shared/terminal-settings";
|
||||
|
||||
/** The session fields the shell keeps on a terminal tab. */
|
||||
interface TerminalTabRecord {
|
||||
id: string;
|
||||
instanceId?: string;
|
||||
restoredSessionId?: string | null;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
initialFilePath?: string;
|
||||
data?: {
|
||||
initialPath?: string;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const CommandHistoryProvider = lazy(() =>
|
||||
import("./command-history/CommandHistoryContext").then((m) => ({
|
||||
default: m.CommandHistoryProvider,
|
||||
})),
|
||||
);
|
||||
export const loadTerminal = () =>
|
||||
import("./Terminal").then((m) => ({ default: m.Terminal }));
|
||||
const TerminalFeature = lazy(loadTerminal);
|
||||
const MobileTerminalKeyboard = lazy(() =>
|
||||
import("./MobileTerminalKeyboard").then((m) => ({
|
||||
default: m.MobileTerminalKeyboard,
|
||||
})),
|
||||
);
|
||||
|
||||
function TabChunkFallback() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The SSH terminal as a tab. */
|
||||
export function TerminalTabContent({
|
||||
tab: tabRecord,
|
||||
host: hostRecord,
|
||||
sshHost,
|
||||
label,
|
||||
isVisible,
|
||||
isFocusedPane,
|
||||
inSplit = false,
|
||||
handleRef,
|
||||
shell,
|
||||
}: TabProps) {
|
||||
const tab = tabRecord as unknown as TerminalTabRecord;
|
||||
const host = hostRecord as unknown as Host | undefined;
|
||||
const { t } = useTranslation();
|
||||
const previewTerminalTheme = useThemePreview();
|
||||
const isMobile = useIsMobile();
|
||||
const useSshTitle = readHostTerminalSettings(
|
||||
hostTerminalValues(host),
|
||||
).useSSHTitle;
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<TerminalSquare className="size-5 text-muted-foreground/30" />
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("terminal.noHostSelected")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<TabChunkFallback />}>
|
||||
<CommandHistoryProvider>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalFeature
|
||||
ref={handleRef as React.Ref<TerminalHandle>}
|
||||
hostConfig={
|
||||
{
|
||||
...sshHost,
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
joinSharedSessionId:
|
||||
tab.data?.joinSharedSessionId ??
|
||||
tab.joinSharedSessionId ??
|
||||
null,
|
||||
joinShareId: tab.data?.joinShareId ?? tab.joinShareId ?? null,
|
||||
} as unknown as TerminalHostConfig
|
||||
}
|
||||
isVisible={isVisible}
|
||||
initialPath={tab.data?.initialPath ?? tab.initialFilePath}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={inSplit}
|
||||
onClose={() => shell.closeTab(tab.id)}
|
||||
onTitleChange={
|
||||
useSshTitle
|
||||
? (title) => shell.renameTab(tab.id, title)
|
||||
: undefined
|
||||
}
|
||||
previewTheme={previewTerminalTheme}
|
||||
onOpenFileInEditor={(filePath) =>
|
||||
void invokeAction("files.openEditor", host, filePath)
|
||||
}
|
||||
onOpenFileManager={(path) =>
|
||||
void invokeAction("files.openHost", host, path)
|
||||
}
|
||||
isQuickConnect={String(host.id).startsWith("quick-connect-")}
|
||||
onSaveQuickConnect={
|
||||
shell.saveQuickConnect && hostRecord?.quickConnectSavable
|
||||
? () => shell.saveQuickConnect!(tabRecord, hostRecord!)
|
||||
: undefined
|
||||
}
|
||||
host={host}
|
||||
onOpenTab={(type) => shell.openTab(hostRecord!, type)}
|
||||
isFocusedPane={isFocusedPane}
|
||||
/>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileTerminalKeyboard
|
||||
terminalRef={handleRef as React.RefObject<TerminalHandle | null>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandHistoryProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ClipboardPaste,
|
||||
GripVertical,
|
||||
ImagePlus,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
TERMINAL_TOOLBAR_SLOT,
|
||||
TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
type TerminalSlotApi,
|
||||
} from "./terminal-slots";
|
||||
import type { Host } from "../types";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
getResponsiveToolbarDensity,
|
||||
persistToolbarPosition,
|
||||
readStoredToolbarPosition,
|
||||
type ToolbarPosition,
|
||||
type ToolbarDensity,
|
||||
} from "./toolbar-geometry";
|
||||
import {
|
||||
isLeftAnchor,
|
||||
readToolbarSettings,
|
||||
toolbarAnchorClasses,
|
||||
} from "./toolbar-settings";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
cn,
|
||||
ActionSlot,
|
||||
ComponentSlot,
|
||||
useIsMobile,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginUiPreferences,
|
||||
useHostActions,
|
||||
useTabs,
|
||||
useTranslation,
|
||||
useSlotContributions,
|
||||
type PluginHostRecord,
|
||||
type ShellApi,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
type SelectedToolbarDensity = ToolbarDensity;
|
||||
|
||||
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
|
||||
const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT;
|
||||
|
||||
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
|
||||
{ value: "icon" },
|
||||
{ value: "labeled" },
|
||||
{ value: "expanded" },
|
||||
];
|
||||
const DENSITIES = DENSITY_OPTIONS.map((option) => option.value);
|
||||
|
||||
const LEGACY_DENSITY_KEY = "termix-terminal-toolbar-density";
|
||||
|
||||
function asDensity(value: unknown): ToolbarDensity {
|
||||
return DENSITIES.includes(value as ToolbarDensity)
|
||||
? (value as ToolbarDensity)
|
||||
: "labeled";
|
||||
}
|
||||
|
||||
const CONTROL =
|
||||
"inline-flex min-h-8 min-w-8 items-center justify-center gap-1.5 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:translate-y-px active:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 data-[state=open]:bg-muted data-[state=open]:text-foreground";
|
||||
const SEPARATOR = "mx-0.5 h-5 w-px shrink-0 bg-border";
|
||||
|
||||
interface TerminalToolbarProps {
|
||||
host: Host;
|
||||
terminalIdentity?: string;
|
||||
isConnected: boolean;
|
||||
isTmuxAttached: boolean;
|
||||
onTmuxDetach: () => void;
|
||||
isImageUploading: boolean;
|
||||
onUploadImage: (file: File) => void | Promise<void>;
|
||||
onPasteImage: () => void | Promise<void>;
|
||||
isFocused: boolean;
|
||||
/** Opens the file manager at the shell's working directory. */
|
||||
onOpenFiles?: () => void;
|
||||
/** Hides every contributed action, e.g. while the toolbar is measuring. */
|
||||
actionsEnabled?: boolean;
|
||||
/** Handed to contributed actions when they are invoked. */
|
||||
slotApi?: TerminalSlotApi;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
host,
|
||||
terminalIdentity,
|
||||
isConnected,
|
||||
isTmuxAttached,
|
||||
onTmuxDetach,
|
||||
isImageUploading,
|
||||
onUploadImage,
|
||||
onPasteImage,
|
||||
isFocused,
|
||||
actionsEnabled = true,
|
||||
slotApi,
|
||||
onOpenFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Drives both the measurement copy and the separator, so the toolbar sizes
|
||||
// itself correctly whether or not anything contributed.
|
||||
const allSlotContributions = useSlotContributions(TOOLBAR_SLOT_ID, { host });
|
||||
const statusContributions = useSlotContributions(
|
||||
TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
{ host },
|
||||
);
|
||||
const slotContributions = useMemo(
|
||||
() =>
|
||||
allSlotContributions.filter(
|
||||
(contribution) => contribution.kind !== "component",
|
||||
),
|
||||
[allSlotContributions],
|
||||
);
|
||||
// Quick links to the other tools a plugin offers for this host (tmux
|
||||
// monitor, docker, tunnels, host metrics), from their host actions.
|
||||
const hostActions = useHostActions();
|
||||
const tabs = useTabs();
|
||||
const hostLinks = useMemo(() => {
|
||||
const record = host as unknown as PluginHostRecord;
|
||||
return hostActions.filter(
|
||||
(action) =>
|
||||
action.kind === "open" &&
|
||||
!action.items &&
|
||||
(action.run || action.tabType) &&
|
||||
action.when(record),
|
||||
);
|
||||
}, [hostActions, host]);
|
||||
const openHostLink = (action: (typeof hostLinks)[number]) => {
|
||||
const record = host as unknown as PluginHostRecord;
|
||||
// The file manager opens where the shell is, as in 2.8.
|
||||
if (action.tabType === "files" && onOpenFiles) onOpenFiles();
|
||||
else if (action.run) action.run(record, tabs as unknown as ShellApi);
|
||||
else if (action.tabType) tabs.openTab(record, action.tabType);
|
||||
};
|
||||
const settings = useMemo(() => readToolbarSettings(host), [host]);
|
||||
const { anchor } = settings;
|
||||
const rememberDensity = settings.density === null;
|
||||
// The remembered density is this plugin's UI preference, so an interface
|
||||
// preset sets it too.
|
||||
const { values: uiPrefs, set: setUiPref } = usePluginUiPreferences<{
|
||||
toolbarDensity: ToolbarDensity;
|
||||
}>();
|
||||
const preferredDensity = asDensity(uiPrefs.toolbarDensity);
|
||||
useEffect(() => {
|
||||
// Before 2.9 the density lived in this browser's localStorage.
|
||||
try {
|
||||
const legacy = window.localStorage.getItem(LEGACY_DENSITY_KEY);
|
||||
if (legacy === null) return;
|
||||
window.localStorage.removeItem(LEGACY_DENSITY_KEY);
|
||||
if (DENSITIES.includes(legacy as ToolbarDensity)) {
|
||||
setUiPref("toolbarDensity", legacy as ToolbarDensity);
|
||||
}
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened browser contexts.
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const [density, setDensity] = useState<SelectedToolbarDensity>(
|
||||
() => settings.density ?? preferredDensity,
|
||||
);
|
||||
const [responsiveDensity, setResponsiveDensity] =
|
||||
useState<SelectedToolbarDensity>(() => density);
|
||||
const [position, setPosition] = useState<ToolbarPosition>(() =>
|
||||
readStoredToolbarPosition(anchor),
|
||||
);
|
||||
const positionRef = useRef(position);
|
||||
const [collapsed, setCollapsed] = useState(settings.startCollapsed);
|
||||
const [densityOpen, setDensityOpen] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
|
||||
null,
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hideToolbarRef = useRef<HTMLButtonElement>(null);
|
||||
const pendingExpansionFocusRef = useRef(false);
|
||||
// Keeps the anchored edge still when the toolbar grows or shrinks.
|
||||
const pendingEdgeRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const imageGenerationRef = useRef(0);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const measurementRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
basePosition: ToolbarPosition;
|
||||
} | null>(null);
|
||||
const capturedPointerIdRef = useRef<number | null>(null);
|
||||
const dragMovedRef = useRef(false);
|
||||
const densityId = useId();
|
||||
const [desktopViewportReady, setDesktopViewportReady] = useState<boolean>();
|
||||
const hostIdentity = terminalIdentity ?? `${host.id ?? "unknown"}`;
|
||||
positionRef.current = position;
|
||||
const effectiveDensity: ToolbarDensity = isFocused
|
||||
? responsiveDensity
|
||||
: "icon";
|
||||
const showStats =
|
||||
settings.showStatus &&
|
||||
effectiveDensity === "expanded" &&
|
||||
statusContributions.length > 0;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setDesktopViewportReady(
|
||||
typeof window !== "undefined" && window.innerWidth >= 768,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile === false) setDesktopViewportReady(true);
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.density) setDensity(settings.density);
|
||||
}, [settings.density]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = readStoredToolbarPosition(anchor);
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
}, [anchor]);
|
||||
|
||||
// A preset (or another tab) changing the saved density applies here too.
|
||||
useEffect(() => {
|
||||
if (!rememberDensity) return;
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
setDensity(preferredDensity);
|
||||
}, [preferredDensity, desktopViewportReady, isMobile, rememberDensity]);
|
||||
useEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const measurement = measurementRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!measurement || !host) return;
|
||||
let frameId: number | null = null;
|
||||
let remeasurements = 0;
|
||||
const measure = () => {
|
||||
frameId = null;
|
||||
const available = host.getBoundingClientRect().width;
|
||||
const required = measurement.getBoundingClientRect().width;
|
||||
if (available > 0 && required > 0) {
|
||||
setResponsiveDensity((current) =>
|
||||
getResponsiveToolbarDensity(density, current, available, required),
|
||||
);
|
||||
remeasurements = 0;
|
||||
} else if (remeasurements < 2) {
|
||||
remeasurements += 1;
|
||||
frameId = window.requestAnimationFrame(measure);
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frameId == null) frameId = window.requestAnimationFrame(measure);
|
||||
};
|
||||
schedule();
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(schedule);
|
||||
observer?.observe(host);
|
||||
observer?.observe(measurement);
|
||||
return () => {
|
||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [
|
||||
density,
|
||||
desktopViewportReady,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isTmuxAttached,
|
||||
actionsEnabled,
|
||||
slotContributions,
|
||||
hostLinks,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
imageGenerationRef.current += 1;
|
||||
dragRef.current = null;
|
||||
capturedPointerIdRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const toolbar = toolbarRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!toolbar || !host || dragRef.current) return;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const toolbarRect = toolbar.getBoundingClientRect();
|
||||
const extremeOffset =
|
||||
hostRect.width > 0 &&
|
||||
hostRect.height > 0 &&
|
||||
Math.max(
|
||||
Math.abs(positionRef.current.x),
|
||||
Math.abs(positionRef.current.y),
|
||||
) >
|
||||
Math.max(hostRect.width, hostRect.height) * 4;
|
||||
if (extremeOffset) {
|
||||
positionRef.current = { x: 0, y: 0 };
|
||||
setPosition({ x: 0, y: 0 });
|
||||
persistToolbarPosition({ x: 0, y: 0 }, anchor);
|
||||
return;
|
||||
}
|
||||
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
|
||||
const pendingEdge = pendingEdgeRef.current;
|
||||
pendingEdgeRef.current = null;
|
||||
const baseEdge =
|
||||
(isLeftAnchor(anchor) ? toolbarRect.left : toolbarRect.right) -
|
||||
positionRef.current.x;
|
||||
const next = clampToolbarPosition(
|
||||
{
|
||||
x:
|
||||
pendingEdge == null
|
||||
? positionRef.current.x
|
||||
: pendingEdge - baseEdge,
|
||||
y: positionRef.current.y,
|
||||
},
|
||||
toolbarRect,
|
||||
hostRect,
|
||||
positionRef.current,
|
||||
);
|
||||
if (
|
||||
next.x !== positionRef.current.x ||
|
||||
next.y !== positionRef.current.y
|
||||
) {
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
persistToolbarPosition(next, anchor);
|
||||
}
|
||||
}
|
||||
if (!collapsed && pendingExpansionFocusRef.current) {
|
||||
pendingExpansionFocusRef.current = false;
|
||||
hideToolbarRef.current?.focus();
|
||||
}
|
||||
}, [
|
||||
anchor,
|
||||
collapsed,
|
||||
density,
|
||||
desktopViewportReady,
|
||||
effectiveDensity,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isTmuxAttached,
|
||||
responsiveDensity,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
imageGenerationRef.current += 1;
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
}, [isConnected, hostIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || !pendingExpansionFocusRef.current) return;
|
||||
pendingExpansionFocusRef.current = false;
|
||||
hideToolbarRef.current?.focus();
|
||||
}, [collapsed]);
|
||||
|
||||
if (!isConnected || desktopViewportReady !== true || isMobile !== false)
|
||||
return null;
|
||||
|
||||
const setAndStoreDensity = (next: ToolbarDensity) => {
|
||||
setDensity(next);
|
||||
if (rememberDensity) setUiPref("toolbarDensity", next);
|
||||
};
|
||||
const reportImageResult = (
|
||||
action: () => void | Promise<void>,
|
||||
retry: () => void,
|
||||
) => {
|
||||
const generation = ++imageGenerationRef.current;
|
||||
let result: void | Promise<void>;
|
||||
try {
|
||||
result = action();
|
||||
} catch (error) {
|
||||
result = Promise.reject(error);
|
||||
}
|
||||
void Promise.resolve(result).then(
|
||||
() => {
|
||||
if (!mountedRef.current || generation !== imageGenerationRef.current)
|
||||
return;
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!mountedRef.current || generation !== imageGenerationRef.current)
|
||||
return;
|
||||
setImageError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("terminalToolbar.imageActionFailed"),
|
||||
);
|
||||
setRetryImageAction(() => retry);
|
||||
},
|
||||
);
|
||||
};
|
||||
const uploadFile = (file: File) => {
|
||||
setImageError(null);
|
||||
reportImageResult(
|
||||
() => onUploadImage(file),
|
||||
() => uploadFile(file),
|
||||
);
|
||||
};
|
||||
const pasteImage = () => {
|
||||
setImageError(null);
|
||||
// Invoke clipboard access in this click's user-activation call chain.
|
||||
reportImageResult(onPasteImage, pasteImage);
|
||||
};
|
||||
const chooseFile = () => fileInputRef.current?.click();
|
||||
const anchoredEdge = () => {
|
||||
const rect = toolbarRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
return isLeftAnchor(anchor) ? rect.left : rect.right;
|
||||
};
|
||||
const handleGrabPointerDown = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (event.button !== 0 || event.pointerType === "touch") return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
capturedPointerIdRef.current = event.pointerId;
|
||||
dragMovedRef.current = false;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
basePosition: positionRef.current,
|
||||
};
|
||||
};
|
||||
const handleGrabPointerMove = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const deltaX = event.clientX - drag.startX;
|
||||
const deltaY = event.clientY - drag.startY;
|
||||
const toolbar = toolbarRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!toolbar || !host) return;
|
||||
const next = clampToolbarPosition(
|
||||
{
|
||||
x: drag.basePosition.x + deltaX,
|
||||
y: drag.basePosition.y + deltaY,
|
||||
},
|
||||
toolbar.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
if (event.clientX !== drag.startX || event.clientY !== drag.startY) {
|
||||
dragMovedRef.current = true;
|
||||
}
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
};
|
||||
const handleGrabPointerEnd = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
persistToolbarPosition(positionRef.current, anchor);
|
||||
dragRef.current = null;
|
||||
if (capturedPointerIdRef.current === event.pointerId) {
|
||||
capturedPointerIdRef.current = null;
|
||||
if (event.type !== "lostpointercapture") {
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// The browser may have already released capture for this pointer.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const densityLabels: Record<ToolbarDensity, string> = {
|
||||
icon: t("terminalToolbar.layoutIcon"),
|
||||
labeled: t("terminalToolbar.layoutLabeled"),
|
||||
expanded: t("terminalToolbar.layoutExpanded"),
|
||||
};
|
||||
|
||||
const densitySelect = (
|
||||
<Select
|
||||
value={density}
|
||||
open={densityOpen}
|
||||
onOpenChange={setDensityOpen}
|
||||
onValueChange={(value) => setAndStoreDensity(value as ToolbarDensity)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={densityId}
|
||||
size="sm"
|
||||
aria-label={t("terminalToolbar.layout")}
|
||||
title={t("terminalToolbar.layout")}
|
||||
className={cn(
|
||||
CONTROL,
|
||||
"h-8 border-0 bg-transparent px-2 shadow-none dark:bg-transparent dark:hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DENSITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{densityLabels[option.value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
const imageButtons = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.uploadImage")}
|
||||
title={t("terminalToolbar.uploadImage")}
|
||||
disabled={isImageUploading}
|
||||
onClick={chooseFile}
|
||||
>
|
||||
<ImagePlus className="size-4 shrink-0" />
|
||||
{effectiveDensity !== "icon" && t("terminalToolbar.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.pasteImage")}
|
||||
title={t("terminalToolbar.pasteImage")}
|
||||
disabled={isImageUploading}
|
||||
onClick={pasteImage}
|
||||
>
|
||||
<ClipboardPaste className="size-4 shrink-0" />
|
||||
{effectiveDensity !== "icon" && t("terminalToolbar.paste")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
const expandButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.showToolbar")}
|
||||
title={t("terminalToolbar.showToolbar")}
|
||||
onClick={() => {
|
||||
pendingEdgeRef.current = anchoredEdge();
|
||||
pendingExpansionFocusRef.current = true;
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
<span className="sr-only">{t("terminalToolbar.showToolbar")}</span>
|
||||
</button>
|
||||
);
|
||||
const grabButton = () => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "cursor-grab active:cursor-grabbing")}
|
||||
aria-label={t("terminalToolbar.moveToolbar")}
|
||||
title={t("terminalToolbar.moveToolbarHint")}
|
||||
onPointerDown={handleGrabPointerDown}
|
||||
onPointerMove={handleGrabPointerMove}
|
||||
onPointerUp={handleGrabPointerEnd}
|
||||
onPointerCancel={handleGrabPointerEnd}
|
||||
onLostPointerCapture={handleGrabPointerEnd}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-terminal-toolbar-host
|
||||
data-anchor={anchor}
|
||||
className={cn(
|
||||
"pointer-events-none @container absolute inset-0 z-[110] flex",
|
||||
toolbarAnchorClasses(anchor),
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
disabled={isImageUploading}
|
||||
aria-hidden="true"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) uploadFile(file);
|
||||
}}
|
||||
/>
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
{isImageUploading ? t("terminalToolbar.uploadingImage") : ""}
|
||||
</span>
|
||||
{imageError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="pointer-events-auto absolute bottom-full mb-2 flex min-h-11 max-w-sm items-center gap-2 rounded-sm border border-destructive bg-background p-2 text-xs text-destructive shadow-lg"
|
||||
>
|
||||
<span>{imageError}</span>
|
||||
{retryImageAction && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "text-foreground")}
|
||||
title={t("terminalToolbar.retry")}
|
||||
disabled={isImageUploading}
|
||||
onClick={retryImageAction}
|
||||
>
|
||||
{t("terminalToolbar.retry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "px-2")}
|
||||
aria-label={t("terminalToolbar.dismissError")}
|
||||
title={t("terminalToolbar.dismissError")}
|
||||
onClick={() => {
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={measurementRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex whitespace-nowrap"
|
||||
>
|
||||
{isTmuxAttached && (
|
||||
<span className={CONTROL}>
|
||||
<LogOut className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.detachTmux")}
|
||||
</span>
|
||||
)}
|
||||
<span className={CONTROL}>
|
||||
<ImagePlus className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.upload")}
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<ClipboardPaste className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.paste")}
|
||||
</span>
|
||||
{actionsEnabled &&
|
||||
slotContributions.map((contribution) => (
|
||||
<span key={contribution.actionId} className={CONTROL}>
|
||||
{contribution.icon && <contribution.icon className="size-4" />}
|
||||
{density !== "icon" && t(contribution.titleKey)}
|
||||
</span>
|
||||
))}
|
||||
{actionsEnabled &&
|
||||
hostLinks.map((action) => (
|
||||
<span key={action.id} className={CONTROL}>
|
||||
<action.icon className="size-4" />
|
||||
{density !== "icon" && t(action.titleKey)}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(CONTROL, "h-8 px-2")}>
|
||||
<LayoutGrid className="size-4" />
|
||||
<ChevronDown className="size-4" />
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<Minimize2 className="size-4" />
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
data-terminal-toolbar-wide
|
||||
className={cn(
|
||||
"pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100",
|
||||
collapsed || densityOpen || !settings.fadeWhenIdle
|
||||
? "opacity-100"
|
||||
: "opacity-30",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div
|
||||
data-toolbar-collapsed
|
||||
className="relative flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
{expandButton}
|
||||
{grabButton()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col overflow-hidden rounded-sm border border-border bg-background/90 shadow-lg backdrop-blur-sm">
|
||||
{showStats && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-1 border-b border-border px-1.5 py-0.5">
|
||||
<ComponentSlot
|
||||
slotId={TERMINAL_TOOLBAR_STATUS_SLOT}
|
||||
when={{ host }}
|
||||
props={{
|
||||
host,
|
||||
isConnected,
|
||||
active:
|
||||
isConnected &&
|
||||
desktopViewportReady === true &&
|
||||
isMobile === false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("flex gap-0.5 p-0.5", "items-center")}>
|
||||
{isTmuxAttached && (
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.detachTmuxDescription")}
|
||||
title={t("terminalToolbar.detachTmuxDescription")}
|
||||
onClick={onTmuxDetach}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
{effectiveDensity !== "icon" &&
|
||||
t("terminalToolbar.detachTmux")}
|
||||
</button>
|
||||
)}
|
||||
{isTmuxAttached && <div className={SEPARATOR} />}
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("terminalToolbar.image")}
|
||||
className="flex items-center gap-0.5"
|
||||
>
|
||||
{imageButtons}
|
||||
</div>
|
||||
{actionsEnabled && slotContributions.length > 0 && (
|
||||
<>
|
||||
<div className={SEPARATOR} />
|
||||
<ActionSlot
|
||||
slotId={TOOLBAR_SLOT_ID}
|
||||
className={CONTROL}
|
||||
enabled={actionsEnabled}
|
||||
hideLabels={effectiveDensity === "icon"}
|
||||
when={{ host }}
|
||||
context={() => [slotApi]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{actionsEnabled && hostLinks.length > 0 && (
|
||||
<>
|
||||
<div className={SEPARATOR} />
|
||||
{hostLinks.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t(action.titleKey)}
|
||||
title={t(action.titleKey)}
|
||||
onClick={() => openHostLink(action)}
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
{effectiveDensity !== "icon" && t(action.titleKey)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className={SEPARATOR} />
|
||||
{densitySelect}
|
||||
<button
|
||||
ref={hideToolbarRef}
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.hideToolbar")}
|
||||
title={t("terminalToolbar.hideToolbar")}
|
||||
onClick={() => {
|
||||
pendingEdgeRef.current = anchoredEdge();
|
||||
setCollapsed(true);
|
||||
}}
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("terminalToolbar.hideToolbar")}
|
||||
</span>
|
||||
</button>
|
||||
<div className={SEPARATOR} />
|
||||
{grabButton()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { Terminal, Monitor, Users, Clock } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
interface TmuxSessionPickerProps {
|
||||
isOpen: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
onSelect: (sessionName: string) => void;
|
||||
onCreateNew: () => void;
|
||||
onCancel: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
unix: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (!unix) return "---";
|
||||
const date = new Date(unix * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return t("terminal.tmuxTimeJustNow");
|
||||
if (diffMin < 60) return t("terminal.tmuxTimeMinutes", { count: diffMin });
|
||||
if (diffHr < 24) return t("terminal.tmuxTimeHours", { count: diffHr });
|
||||
if (diffDays < 7) return t("terminal.tmuxTimeDays", { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function TmuxSessionPicker({
|
||||
isOpen,
|
||||
sessions,
|
||||
onSelect,
|
||||
onCreateNew,
|
||||
onCancel,
|
||||
backgroundColor,
|
||||
}: TmuxSessionPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.tmuxSessionPickerTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("terminal.tmuxSessionPickerDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col max-h-60 overflow-y-auto">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.name}
|
||||
onClick={() => onSelect(session.name)}
|
||||
className="w-full text-left px-4 py-3 border-b border-border hover:bg-muted/50 transition-colors last:border-b-0"
|
||||
>
|
||||
<div className="font-mono text-xs font-bold">{session.name}</div>
|
||||
<div className="flex gap-3 mt-1 text-[10px] text-muted-foreground">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxWindows")}
|
||||
>
|
||||
<Monitor className="size-3" />
|
||||
{t("terminal.tmuxWindowCount", { count: session.windows })}
|
||||
</span>
|
||||
{session.attachedClients > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxAttached")}
|
||||
>
|
||||
<Users className="size-3" />
|
||||
{t("terminal.tmuxAttachedCount", {
|
||||
count: session.attachedClients,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxLastActivity")}
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{formatTimestamp(session.lastActivity, t)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="ghost"
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreateNew}
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("terminal.tmuxCreateNew")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HostBackspaceMode } from "../types";
|
||||
|
||||
const CURSOR_SEQUENCES: Record<string, [normal: string, application: string]> =
|
||||
{
|
||||
ArrowUp: ["\x1b[A", "\x1bOA"],
|
||||
ArrowDown: ["\x1b[B", "\x1bOB"],
|
||||
ArrowRight: ["\x1b[C", "\x1bOC"],
|
||||
ArrowLeft: ["\x1b[D", "\x1bOD"],
|
||||
};
|
||||
|
||||
export function getAndroidHardwareKeySequence(
|
||||
event: Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
|
||||
>,
|
||||
applicationCursorKeys: boolean,
|
||||
backspaceMode: HostBackspaceMode | undefined,
|
||||
): string | null {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cursor = CURSOR_SEQUENCES[event.key];
|
||||
if (cursor) return cursor[applicationCursorKeys ? 1 : 0];
|
||||
if (event.key === "Delete") return "\x1b[3~";
|
||||
if (event.key === "Backspace" && backspaceMode !== "control-h") {
|
||||
return "\x7f";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
suggestions: string[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: string) => void;
|
||||
position: { top: number; left: number };
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface CommandAutosuggestionProps {
|
||||
suggestion: string;
|
||||
position: { top: number; left: number };
|
||||
style?: React.CSSProperties;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function CommandAutosuggestion({
|
||||
suggestion,
|
||||
position,
|
||||
style,
|
||||
visible,
|
||||
}: CommandAutosuggestionProps) {
|
||||
if (!visible || !suggestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[9998] whitespace-pre text-muted-foreground/45"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
fontVariantLigatures: "none",
|
||||
fontFeatureSettings: '"liga" 0, "calt" 0',
|
||||
textRendering: "optimizeLegibility",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
transition: "opacity 80ms ease-out",
|
||||
willChange: "contents",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
position,
|
||||
visible,
|
||||
}: CommandAutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && containerRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!visible || suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerHeight = 32;
|
||||
const maxSuggestionsHeight = 240 - footerHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed z-[9999] bg-popover border border-border shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
maxHeight: "240px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto thin-scrollbar"
|
||||
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors text-foreground",
|
||||
"hover:bg-muted",
|
||||
index === selectedIndex && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-border bg-muted/50 shrink-0">
|
||||
<span className="flex flex-wrap gap-x-3">
|
||||
<span>{t("terminal.autocompleteHintComplete")}</span>
|
||||
<span>{t("terminal.autocompleteHintNavigate")}</span>
|
||||
<span>{t("terminal.autocompleteHintClose")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface CommandHistoryContextType {
|
||||
commandHistory: string[];
|
||||
isLoading: boolean;
|
||||
setCommandHistory: (history: string[]) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
onSelectCommand?: (command: string) => void;
|
||||
setOnSelectCommand: (callback: (command: string) => void) => void;
|
||||
onDeleteCommand?: (command: string) => void;
|
||||
setOnDeleteCommand: (callback: (command: string) => void) => void;
|
||||
openCommandHistory: () => void;
|
||||
setOpenCommandHistory: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const CommandHistoryContext = createContext<
|
||||
CommandHistoryContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [onSelectCommand, setOnSelectCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [onDeleteCommand, setOnDeleteCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [openCommandHistory, setOpenCommandHistory] = useState<
|
||||
(() => void) | undefined
|
||||
>(() => () => {});
|
||||
|
||||
const handleSetOnSelectCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnSelectCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOnDeleteCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnDeleteCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
|
||||
setOpenCommandHistory(() => callback);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandHistoryContext.Provider
|
||||
value={{
|
||||
commandHistory,
|
||||
isLoading,
|
||||
setCommandHistory,
|
||||
setIsLoading,
|
||||
onSelectCommand,
|
||||
setOnSelectCommand: handleSetOnSelectCommand,
|
||||
onDeleteCommand,
|
||||
setOnDeleteCommand: handleSetOnDeleteCommand,
|
||||
openCommandHistory: openCommandHistory || (() => {}),
|
||||
setOpenCommandHistory: handleSetOpenCommandHistory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CommandHistoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommandHistory() {
|
||||
const context = useContext(CommandHistoryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useCommandHistory must be used within a CommandHistoryProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import { usePluginApi } from "@termix/plugin-sdk/frontend";
|
||||
import { saveCommandToHistory } from "../../terminal-api";
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/\bpassw(or)?d\b/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/\bPASS(WORD)?=/i,
|
||||
/\bAWS_SECRET/i,
|
||||
/\bmysql\b.*-p/i,
|
||||
/\bsudo\s+-S\b/,
|
||||
/\bhtpasswd\b/i,
|
||||
/\bsshpass\b/i,
|
||||
/\bcurl\b.*-u\s/i,
|
||||
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
interface UseCommandTrackerOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
onCommandExecuted?: (command: string) => void;
|
||||
}
|
||||
|
||||
interface CommandTrackerResult {
|
||||
trackInput: (data: string) => void;
|
||||
getCurrentCommand: () => string;
|
||||
clearCurrentCommand: () => void;
|
||||
updateCurrentCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
export function useCommandTracker({
|
||||
hostId,
|
||||
enabled = true,
|
||||
onCommandExecuted,
|
||||
}: UseCommandTrackerOptions): CommandTrackerResult {
|
||||
const api = usePluginApi();
|
||||
const currentCommandRef = useRef<string>("");
|
||||
const isInEscapeSequenceRef = useRef<boolean>(false);
|
||||
|
||||
const trackInput = useCallback(
|
||||
(data: string) => {
|
||||
if (!enabled || !hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data[i];
|
||||
const charCode = char.charCodeAt(0);
|
||||
|
||||
if (charCode === 27) {
|
||||
isInEscapeSequenceRef.current = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInEscapeSequenceRef.current) {
|
||||
if (
|
||||
(charCode >= 65 && charCode <= 90) ||
|
||||
(charCode >= 97 && charCode <= 122) ||
|
||||
charCode === 126
|
||||
) {
|
||||
isInEscapeSequenceRef.current = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 13 || charCode === 10) {
|
||||
const command = currentCommandRef.current.trim();
|
||||
|
||||
if (command.length > 0) {
|
||||
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
|
||||
|
||||
if (!isSensitive) {
|
||||
saveCommandToHistory(api, hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (onCommandExecuted) {
|
||||
onCommandExecuted(command);
|
||||
}
|
||||
}
|
||||
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 8 || charCode === 127) {
|
||||
if (currentCommandRef.current.length > 0) {
|
||||
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 3 || charCode === 4) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 21) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode >= 32 && charCode <= 126) {
|
||||
currentCommandRef.current += char;
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, hostId, onCommandExecuted],
|
||||
);
|
||||
|
||||
const getCurrentCommand = useCallback(() => {
|
||||
return currentCommandRef.current;
|
||||
}, []);
|
||||
|
||||
const clearCurrentCommand = useCallback(() => {
|
||||
currentCommandRef.current = "";
|
||||
}, []);
|
||||
|
||||
const updateCurrentCommand = useCallback((command: string) => {
|
||||
currentCommandRef.current = command;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
trackInput,
|
||||
getCurrentCommand,
|
||||
clearCurrentCommand,
|
||||
updateCurrentCommand,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export function fontSizeStorageKey(identity: unknown): string | null {
|
||||
return typeof identity === "string" || typeof identity === "number"
|
||||
? `terminal_fontsize_host_${identity}`
|
||||
: null;
|
||||
}
|
||||
|
||||
export function readFontSize(
|
||||
storage: Storage,
|
||||
key: string | null,
|
||||
configured: number,
|
||||
): number | null {
|
||||
if (!key) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || "null");
|
||||
if (
|
||||
value?.configured === configured &&
|
||||
Number.isFinite(value.size) &&
|
||||
value.size > 0
|
||||
)
|
||||
return value.size;
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
/* Storage may be unavailable. */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveFontSize(
|
||||
storage: Storage,
|
||||
key: string | null,
|
||||
configured: number,
|
||||
size: number,
|
||||
): void {
|
||||
if (!key) return;
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ configured, size }));
|
||||
} catch {
|
||||
/* Storage may be unavailable. */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Separator,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
cn,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TerminalSearchBarProps {
|
||||
visible: boolean;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onFindNext: () => void;
|
||||
onFindPrevious: () => void;
|
||||
onClose: () => void;
|
||||
caseSensitive: boolean;
|
||||
onToggleCaseSensitive: () => void;
|
||||
wholeWord: boolean;
|
||||
onToggleWholeWord: () => void;
|
||||
regex: boolean;
|
||||
onToggleRegex: () => void;
|
||||
resultIndex: number;
|
||||
resultCount: number;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
interface SearchToggleProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SearchToggle({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: SearchToggleProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"font-mono text-[11px] leading-none text-muted-foreground",
|
||||
active &&
|
||||
"border-border bg-muted text-foreground hover:bg-muted dark:bg-muted/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalSearchBar({
|
||||
visible,
|
||||
query,
|
||||
onQueryChange,
|
||||
onFindNext,
|
||||
onFindPrevious,
|
||||
onClose,
|
||||
caseSensitive,
|
||||
onToggleCaseSensitive,
|
||||
wholeWord,
|
||||
onToggleWholeWord,
|
||||
regex,
|
||||
onToggleRegex,
|
||||
resultIndex,
|
||||
resultCount,
|
||||
inputRef,
|
||||
}: TerminalSearchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
onFindPrevious();
|
||||
} else {
|
||||
onFindNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop keys like Ctrl+C/Ctrl+V from bubbling up to xterm's document-level
|
||||
// paste/clipboard handling while the search input is focused.
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const hasQuery = query.length > 0;
|
||||
const noResults = hasQuery && resultCount === 0;
|
||||
const resultLabel = !hasQuery
|
||||
? ""
|
||||
: resultCount === 0
|
||||
? t("terminal.searchNoResults")
|
||||
: t("terminal.searchResultCount", {
|
||||
index: resultIndex + 1,
|
||||
count: resultCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<div
|
||||
className="absolute top-2 right-2 z-[130] flex items-center gap-1 border border-border bg-background/95 p-1 shadow-lg backdrop-blur-sm"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("terminal.searchPlaceholder")}
|
||||
aria-label={t("terminal.searchPlaceholder")}
|
||||
className={cn(
|
||||
"h-7 w-48 pr-16 font-mono",
|
||||
noResults &&
|
||||
"border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
|
||||
)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-2 font-mono text-[11px] tabular-nums select-none",
|
||||
noResults ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{resultLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<SearchToggle
|
||||
label={t("terminal.searchCaseSensitive")}
|
||||
active={caseSensitive}
|
||||
onClick={onToggleCaseSensitive}
|
||||
>
|
||||
Aa
|
||||
</SearchToggle>
|
||||
<SearchToggle
|
||||
label={t("terminal.searchWholeWord")}
|
||||
active={wholeWord}
|
||||
onClick={onToggleWholeWord}
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
ab
|
||||
</SearchToggle>
|
||||
<SearchToggle
|
||||
label={t("terminal.searchRegex")}
|
||||
active={regex}
|
||||
onClick={onToggleRegex}
|
||||
>
|
||||
.*
|
||||
</SearchToggle>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t("terminal.searchPrevious")}
|
||||
disabled={!hasQuery || resultCount === 0}
|
||||
onClick={onFindPrevious}
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("terminal.searchPrevious")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t("terminal.searchNext")}
|
||||
disabled={!hasQuery || resultCount === 0}
|
||||
onClick={onFindNext}
|
||||
>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("terminal.searchNext")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t("terminal.searchClose")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("terminal.searchClose")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
|
||||
/**
|
||||
* How much scrollback to hand out by default. Bounded on purpose: a
|
||||
* long-running session holds megabytes, and nothing consuming this wants all
|
||||
* of it.
|
||||
*/
|
||||
export const DEFAULT_BUFFER_CONTEXT_LINES = 200;
|
||||
|
||||
/**
|
||||
* Serializes the tail of a terminal's buffer to plain text.
|
||||
*
|
||||
* Wrapped rows are joined back onto the line they continue. xterm splits one
|
||||
* logical line across several buffer rows when it wraps, so emitting a newline
|
||||
* per row would chop long commands in half.
|
||||
*/
|
||||
export function getTerminalBufferText(
|
||||
terminal: Terminal | null | undefined,
|
||||
maxLines: number = DEFAULT_BUFFER_CONTEXT_LINES,
|
||||
): string {
|
||||
if (!terminal || maxLines <= 0) return "";
|
||||
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const end = buffer.baseY + terminal.rows;
|
||||
const start = Math.max(0, end - maxLines);
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
if (line.isWrapped && lines.length > 0) {
|
||||
lines[lines.length - 1] += text;
|
||||
} else {
|
||||
lines.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
// Called from a click handler. A disposed terminal returns nothing
|
||||
// rather than breaking the click.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type TerminalContextMenuAction = "native" | "copy" | "paste";
|
||||
|
||||
export function resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste,
|
||||
copyOnSelect,
|
||||
hasSelection,
|
||||
}: {
|
||||
rightClickCopyPaste: boolean;
|
||||
copyOnSelect: boolean;
|
||||
hasSelection: boolean;
|
||||
}): TerminalContextMenuAction {
|
||||
if (!rightClickCopyPaste) return "native";
|
||||
return hasSelection && !copyOnSelect ? "copy" : "paste";
|
||||
}
|
||||
|
||||
export function selectedTextToCopy({
|
||||
copyOnSelect,
|
||||
button,
|
||||
selection,
|
||||
}: {
|
||||
copyOnSelect: boolean;
|
||||
button: number;
|
||||
selection: string;
|
||||
}): string | null {
|
||||
return copyOnSelect && button === 0 && selection ? selection : null;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { getClientPreference } from "@termix/plugin-sdk/frontend";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { isPhysicalShortcutKey } from "./terminal-key-event";
|
||||
|
||||
export function getUseRightClickCopyPaste(): boolean {
|
||||
return getClientPreference("rightClickCopyPaste") !== "false";
|
||||
}
|
||||
|
||||
export interface TerminalClipboardActions {
|
||||
writeTextToClipboard: (text: string) => Promise<unknown>;
|
||||
readTextFromClipboard: () => Promise<string>;
|
||||
}
|
||||
|
||||
export interface TerminalClipboardKeyOptions {
|
||||
/**
|
||||
* How a plain Ctrl/Cmd+V with no other modifiers is handled.
|
||||
* "native" (default, matches the SSH terminal) leaves the key alone so the
|
||||
* browser's own paste event fires - this avoids re-triggering the OS
|
||||
* clipboard permission prompt on every keystroke, but requires the caller
|
||||
* to also listen for the DOM "paste" event.
|
||||
* "explicit" reads the clipboard directly instead, for terminals with no
|
||||
* such paste event listener of their own.
|
||||
*/
|
||||
plainPasteMode?: "native" | "explicit";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared copy/paste keyboard shortcuts for xterm-based terminals:
|
||||
* Ctrl/Cmd+C copies the current selection (Ctrl+C also clears it, matching
|
||||
* its dual use as SIGINT when nothing is selected), Ctrl+Insert copies
|
||||
* without clearing, and Ctrl+Shift+C / Ctrl+Shift+V always copy/paste
|
||||
* regardless of selection state. Returns false (having called
|
||||
* preventDefault/stopPropagation) once it has handled the event, true
|
||||
* otherwise so callers can keep processing the keydown.
|
||||
*/
|
||||
export function handleTerminalClipboardKeyEvent(
|
||||
e: KeyboardEvent,
|
||||
terminal: Terminal,
|
||||
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
|
||||
{ plainPasteMode = "native" }: TerminalClipboardKeyOptions = {},
|
||||
): boolean {
|
||||
const isCopyKey = isPhysicalShortcutKey(e, "KeyC", "c");
|
||||
const isPasteKey = isPhysicalShortcutKey(e, "KeyV", "v");
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
isCopyKey &&
|
||||
terminal.hasSelection()
|
||||
) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey && isCopyKey) ||
|
||||
(e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key === "Insert")
|
||||
) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
writeTextToClipboard(selection);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isCopyKey) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isPasteKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const plainModPaste =
|
||||
((e.ctrlKey && !e.metaKey) || (e.metaKey && !e.ctrlKey)) &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
isPasteKey;
|
||||
if (plainModPaste) {
|
||||
if (plainPasteMode === "explicit") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
}
|
||||
// "native": leave the event alone so the browser's own paste event fires.
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface TerminalContextMenuOptions {
|
||||
/** Optional Ctrl+right-click passthrough, e.g. to open a file manager. */
|
||||
onCtrlClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a "contextmenu" listener implementing right-click copy/paste:
|
||||
* copies the selection if one exists, otherwise pastes. Gated behind the
|
||||
* user's "rightClickCopyPaste" preference cookie.
|
||||
*/
|
||||
export function createTerminalContextMenuHandler(
|
||||
terminal: Terminal,
|
||||
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
|
||||
{ onCtrlClick }: TerminalContextMenuOptions = {},
|
||||
): (e: MouseEvent) => void {
|
||||
return (e: MouseEvent) => {
|
||||
if (e.ctrlKey && onCtrlClick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCtrlClick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
const text = terminal.getSelection();
|
||||
writeTextToClipboard(text).then(() => terminal.clearSelection());
|
||||
} else {
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Return an absolute POSIX path as one safe shell argument for terminal input.
|
||||
*
|
||||
* The backend already constrains this to an absolute agent-visible path. This
|
||||
* helper handles spaces and shell metacharacters at the final terminal-input
|
||||
* boundary so configured paths cannot become shell syntax.
|
||||
*/
|
||||
export function quoteTerminalImagePath(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type TerminalImageUploadSource = "file" | "clipboard";
|
||||
|
||||
export function buildImageUploadFormData(
|
||||
file: File,
|
||||
instanceId: string,
|
||||
source: TerminalImageUploadSource,
|
||||
clientUploadTimestamp = new Date().toISOString(),
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("instanceId", instanceId);
|
||||
form.append("source", source);
|
||||
form.append("clientUploadTimestamp", clientUploadTimestamp);
|
||||
return form;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function isTabKeyEvent(event: KeyboardEvent): boolean {
|
||||
return event.key === "Tab" || event.code === "Tab" || event.keyCode === 9;
|
||||
}
|
||||
|
||||
export function isPhysicalShortcutKey(
|
||||
event: Pick<KeyboardEvent, "code" | "key">,
|
||||
code: string,
|
||||
fallbackKey: string,
|
||||
): boolean {
|
||||
return event.code
|
||||
? event.code === code
|
||||
: event.key.toLowerCase() === fallbackKey;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Host } from "../types";
|
||||
|
||||
/**
|
||||
* The slots the terminal offers plugins, and what it hands them.
|
||||
*
|
||||
* - "terminal.toolbar" (buttons): actions are invoked with a TerminalSlotApi.
|
||||
* - "terminal.toolbarStatus" (components): small live readouts at the end
|
||||
* of the toolbar (host metrics), rendered with TerminalToolbarStatusProps.
|
||||
* - "terminal.sidePanel" (components): a side panel an action opens with
|
||||
* `openSidePanel(id)`, rendered with TerminalSidePanelProps.
|
||||
* - "terminal.overlay" (components): always mounted over the terminal and
|
||||
* given the session's server messages, for connection flows a plugin owns
|
||||
* (a Tailscale check, an OPKSSH sign-in). It can answer on the socket.
|
||||
*
|
||||
* Every contribution's `when` sees `{ host }`.
|
||||
*/
|
||||
export const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
|
||||
export const TERMINAL_TOOLBAR_STATUS_SLOT = "terminal.toolbarStatus";
|
||||
export const TERMINAL_SIDE_PANEL_SLOT = "terminal.sidePanel";
|
||||
export const TERMINAL_OVERLAY_SLOT = "terminal.overlay";
|
||||
|
||||
export interface TerminalSlotApi {
|
||||
host: Host | undefined;
|
||||
/** The visible scrollback, read at call time. */
|
||||
getBufferText: () => string;
|
||||
/** The live session's id, once the server created it. */
|
||||
sessionId: () => string | null;
|
||||
/** Opens a "terminal.sidePanel" contribution by its actionId. */
|
||||
openSidePanel: (panelId: string, props?: Record<string, unknown>) => void;
|
||||
/** Types a command into the session and runs it. */
|
||||
runCommand: (command: string) => void;
|
||||
/**
|
||||
* What sharing this session needs, or null while there is nothing to share
|
||||
* (not connected yet, a quick connect, or a session joined from a share).
|
||||
*/
|
||||
getShareTarget: () => {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
protocol: "ssh";
|
||||
tabInstanceId?: string;
|
||||
origin?: "local" | "remote";
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TerminalToolbarStatusProps {
|
||||
host: Host;
|
||||
isConnected: boolean;
|
||||
/** Visible on a desktop viewport and connected: poll only while true. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalSidePanelProps {
|
||||
host: Host | undefined;
|
||||
hostId: number | undefined;
|
||||
hostLabel: string;
|
||||
/** Whatever the opening action passed to openSidePanel. */
|
||||
panelProps: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSessionMessage {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TerminalOverlayProps {
|
||||
host: Host | undefined;
|
||||
backgroundColor?: string;
|
||||
/** Server messages for this session, plus a local "session_closed". */
|
||||
subscribe: (
|
||||
listener: (message: TerminalSessionMessage) => void,
|
||||
) => () => void;
|
||||
/**
|
||||
* Holds the connect timeout while the overlay waits on the user, e.g. an
|
||||
* approval in another window. Release it when done.
|
||||
*/
|
||||
holdConnectTimeout: (held: boolean) => void;
|
||||
/** Ends the attempt with an error shown in the terminal. */
|
||||
fail: (message: string) => void;
|
||||
/** Closes the session's connection. */
|
||||
disconnect: () => void;
|
||||
/** Sends a message on the session's socket, as { type, data }. */
|
||||
send: (type: string, data?: unknown) => void;
|
||||
/**
|
||||
* What the server needs to connect again after a sign-in finished: send it
|
||||
* as the data of "<interaction>_auth_completed".
|
||||
*/
|
||||
connectPayload: () => Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface TerminalHostConfig {
|
||||
id?: number;
|
||||
instanceId?: string;
|
||||
restoredSessionId?: string | null;
|
||||
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
sshOptions?: HostSshOptions | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TerminalHandle {
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
reconnectIfDisconnected: () => boolean;
|
||||
isConnected: () => boolean;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
subscribeOutput: (listener: (data: string) => void) => () => void;
|
||||
paste: (text: string) => void;
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
getApplicationCursorKeysMode: () => boolean;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
export interface ToolbarPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface RectLike {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type ToolbarDensity = "icon" | "labeled" | "expanded";
|
||||
|
||||
export const TOOLBAR_POSITION_STORAGE_KEY =
|
||||
"termix-terminal-toolbar-position-v2";
|
||||
const TOOLBAR_MARGIN = 8;
|
||||
const RECOVERY_SIZE = 44;
|
||||
|
||||
export function getDefaultToolbarPosition(): ToolbarPosition {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
export function sanitizeToolbarPosition(value: unknown): ToolbarPosition {
|
||||
if (!value || typeof value !== "object") return getDefaultToolbarPosition();
|
||||
const candidate = value as Partial<ToolbarPosition>;
|
||||
return Number.isFinite(candidate.x) && Number.isFinite(candidate.y)
|
||||
? { x: Number(candidate.x), y: Number(candidate.y) }
|
||||
: getDefaultToolbarPosition();
|
||||
}
|
||||
|
||||
function readStoredPosition(storageKey: string): ToolbarPosition {
|
||||
if (typeof window === "undefined") return getDefaultToolbarPosition();
|
||||
try {
|
||||
return sanitizeToolbarPosition(
|
||||
JSON.parse(window.localStorage.getItem(storageKey) ?? "null"),
|
||||
);
|
||||
} catch {
|
||||
return getDefaultToolbarPosition();
|
||||
}
|
||||
}
|
||||
|
||||
function persistPosition(storageKey: string, position: ToolbarPosition): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify(sanitizeToolbarPosition(position)),
|
||||
);
|
||||
} catch {
|
||||
// Storage may be unavailable in hardened browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
/** Each anchor keeps its own drag offset; bottom keeps the original key. */
|
||||
export function toolbarPositionStorageKey(anchor = "bottom"): string {
|
||||
return anchor === "bottom"
|
||||
? TOOLBAR_POSITION_STORAGE_KEY
|
||||
: `${TOOLBAR_POSITION_STORAGE_KEY}-${anchor}`;
|
||||
}
|
||||
|
||||
export function readStoredToolbarPosition(anchor?: string): ToolbarPosition {
|
||||
return readStoredPosition(toolbarPositionStorageKey(anchor));
|
||||
}
|
||||
|
||||
export function persistToolbarPosition(
|
||||
position: ToolbarPosition,
|
||||
anchor?: string,
|
||||
): void {
|
||||
persistPosition(toolbarPositionStorageKey(anchor), position);
|
||||
}
|
||||
|
||||
export function clampToolbarPosition(
|
||||
position: ToolbarPosition,
|
||||
toolbarRect: RectLike,
|
||||
hostRect: RectLike,
|
||||
renderedPosition: ToolbarPosition = position,
|
||||
): ToolbarPosition {
|
||||
if (
|
||||
!toolbarRect.width ||
|
||||
!toolbarRect.height ||
|
||||
!hostRect.width ||
|
||||
!hostRect.height
|
||||
)
|
||||
return sanitizeToolbarPosition(position);
|
||||
const left = hostRect.left + TOOLBAR_MARGIN;
|
||||
const right = hostRect.right - TOOLBAR_MARGIN;
|
||||
const top = hostRect.top + TOOLBAR_MARGIN;
|
||||
const bottom = hostRect.bottom - TOOLBAR_MARGIN;
|
||||
const baseLeft = toolbarRect.left - renderedPosition.x;
|
||||
const baseRight = toolbarRect.right - renderedPosition.x;
|
||||
const baseTop = toolbarRect.top - renderedPosition.y;
|
||||
const baseBottom = toolbarRect.bottom - renderedPosition.y;
|
||||
const clamp = (min: number, max: number, value: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
const xMin =
|
||||
toolbarRect.width <= right - left
|
||||
? left - baseLeft
|
||||
: left + RECOVERY_SIZE - baseRight;
|
||||
const xMax = right - baseRight;
|
||||
const yMin =
|
||||
toolbarRect.height <= bottom - top
|
||||
? top - baseTop
|
||||
: top + RECOVERY_SIZE - baseBottom;
|
||||
const yMax = bottom - baseBottom;
|
||||
return { x: clamp(xMin, xMax, position.x), y: clamp(yMin, yMax, position.y) };
|
||||
}
|
||||
|
||||
export function getResponsiveToolbarDensity(
|
||||
selectedDensity: ToolbarDensity,
|
||||
currentDensity: ToolbarDensity,
|
||||
availableInlineSize: number,
|
||||
requiredInlineSize: number,
|
||||
): ToolbarDensity {
|
||||
if (selectedDensity === "icon") return "icon";
|
||||
if (!Number.isFinite(availableInlineSize) || availableInlineSize <= 0)
|
||||
return currentDensity;
|
||||
if (!Number.isFinite(requiredInlineSize) || requiredInlineSize <= 0)
|
||||
return currentDensity;
|
||||
if (currentDensity === "icon")
|
||||
return availableInlineSize >= requiredInlineSize * 1.15
|
||||
? selectedDensity
|
||||
: currentDensity;
|
||||
return availableInlineSize < requiredInlineSize * 1.1
|
||||
? "icon"
|
||||
: selectedDensity;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ToolbarDensity } from "./toolbar-geometry";
|
||||
|
||||
export const TOOLBAR_ANCHORS = [
|
||||
"bottom",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
"top",
|
||||
"top-left",
|
||||
"top-right",
|
||||
] as const;
|
||||
export type ToolbarAnchor = (typeof TOOLBAR_ANCHORS)[number];
|
||||
|
||||
export interface ToolbarSettings {
|
||||
anchor: ToolbarAnchor;
|
||||
startCollapsed: boolean;
|
||||
/** null keeps whatever mode the user last picked in the toolbar. */
|
||||
density: ToolbarDensity | null;
|
||||
showStatus: boolean;
|
||||
fadeWhenIdle: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TOOLBAR_SETTINGS: ToolbarSettings = {
|
||||
anchor: "bottom",
|
||||
startCollapsed: false,
|
||||
density: null,
|
||||
showStatus: true,
|
||||
fadeWhenIdle: true,
|
||||
};
|
||||
|
||||
const DENSITIES: ToolbarDensity[] = ["icon", "labeled", "expanded"];
|
||||
|
||||
/** The toolbar's host settings, as the host payload carries them. */
|
||||
export function readToolbarSettings(
|
||||
host: object | null | undefined,
|
||||
): ToolbarSettings {
|
||||
const values =
|
||||
(
|
||||
host as {
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
} | null
|
||||
)?.pluginSettings?.["ssh-terminal"] ?? {};
|
||||
const anchor = values.terminalToolbarPosition;
|
||||
const density = values.terminalToolbarDisplay;
|
||||
const bool = (value: unknown, fallback: boolean) =>
|
||||
typeof value === "boolean" ? value : fallback;
|
||||
return {
|
||||
anchor: TOOLBAR_ANCHORS.includes(anchor as ToolbarAnchor)
|
||||
? (anchor as ToolbarAnchor)
|
||||
: DEFAULT_TOOLBAR_SETTINGS.anchor,
|
||||
startCollapsed: values.terminalToolbarStartState === "collapsed",
|
||||
density: DENSITIES.includes(density as ToolbarDensity)
|
||||
? (density as ToolbarDensity)
|
||||
: null,
|
||||
showStatus: bool(
|
||||
values.terminalToolbarShowStatus,
|
||||
DEFAULT_TOOLBAR_SETTINGS.showStatus,
|
||||
),
|
||||
fadeWhenIdle: bool(
|
||||
values.terminalToolbarFade,
|
||||
DEFAULT_TOOLBAR_SETTINGS.fadeWhenIdle,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function isLeftAnchor(anchor: ToolbarAnchor): boolean {
|
||||
return anchor.endsWith("-left");
|
||||
}
|
||||
|
||||
export function isTopAnchor(anchor: ToolbarAnchor): boolean {
|
||||
return anchor.startsWith("top");
|
||||
}
|
||||
|
||||
/** Flex placement for the full-size overlay that holds the toolbar. */
|
||||
export function toolbarAnchorClasses(anchor: ToolbarAnchor): string {
|
||||
const vertical = isTopAnchor(anchor) ? "items-start pt-2" : "items-end pb-2";
|
||||
const horizontal = anchor.endsWith("-left")
|
||||
? "justify-start pl-2"
|
||||
: anchor.endsWith("-right")
|
||||
? "justify-end pr-2"
|
||||
: "justify-center";
|
||||
return `${vertical} ${horizontal}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import { getClientSettings } from "../terminal-api";
|
||||
import {
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
type TouchInputSettings,
|
||||
} from "../../shared/touch-input-settings";
|
||||
|
||||
let cached: TouchInputSettings | undefined;
|
||||
let pending: Promise<TouchInputSettings> | undefined;
|
||||
|
||||
export function loadTouchInputSettings(
|
||||
api: PluginApiClient,
|
||||
): Promise<TouchInputSettings> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
pending ??= getClientSettings(api)
|
||||
.then((settings) => (cached = settings.touchInput))
|
||||
.catch(() => (cached = { ...TOUCH_INPUT_DEFAULTS }))
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function cacheTouchInputSettings(settings: TouchInputSettings): void {
|
||||
cached = settings;
|
||||
}
|
||||
|
||||
export function resetTouchInputSettingsCache(): void {
|
||||
cached = undefined;
|
||||
pending = undefined;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
type TouchInputSettings,
|
||||
} from "../../shared/touch-input-settings";
|
||||
|
||||
export interface TouchWheelPoint {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface SyntheticWheelInput extends TouchWheelPoint {
|
||||
deltaY: number;
|
||||
deltaMode: number;
|
||||
}
|
||||
|
||||
interface TouchWheelTiming {
|
||||
now: () => number;
|
||||
requestFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelFrame: (handle: number) => void;
|
||||
momentumAllowed: () => boolean;
|
||||
}
|
||||
|
||||
const defaultTiming: TouchWheelTiming = {
|
||||
now: () => performance.now(),
|
||||
requestFrame: (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => cancelAnimationFrame(handle),
|
||||
momentumAllowed: () => true,
|
||||
};
|
||||
|
||||
interface VelocitySample {
|
||||
deltaY: number;
|
||||
durationMs: number;
|
||||
endedAt: number;
|
||||
}
|
||||
|
||||
export class TouchWheelCoordinator {
|
||||
private startY: number | undefined;
|
||||
private lastPoint: TouchWheelPoint | undefined;
|
||||
private pendingDeltaY = 0;
|
||||
private scrolling = false;
|
||||
private cancelled = false;
|
||||
private velocitySamples: VelocitySample[] = [];
|
||||
private lastMoveTime: number | undefined;
|
||||
private momentumFrame: number | undefined;
|
||||
private momentumRemainderY = 0;
|
||||
|
||||
constructor(
|
||||
private readonly emitWheel: (input: SyntheticWheelInput) => void,
|
||||
private readonly dragThresholdPx: number = TOUCH_INPUT_DEFAULTS.dragThresholdPx,
|
||||
private readonly maxWheelDeltaPx: number = TOUCH_INPUT_DEFAULTS.maxWheelDeltaPx,
|
||||
private readonly timing: TouchWheelTiming = defaultTiming,
|
||||
private readonly settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
|
||||
) {}
|
||||
|
||||
start(point: TouchWheelPoint | undefined, time = this.timing.now()): void {
|
||||
this.reset();
|
||||
if (!point) {
|
||||
this.cancelled = true;
|
||||
return;
|
||||
}
|
||||
this.startY = point.clientY;
|
||||
this.lastPoint = point;
|
||||
this.lastMoveTime = time;
|
||||
}
|
||||
|
||||
move(point: TouchWheelPoint | undefined, time = this.timing.now()): boolean {
|
||||
if (this.cancelled || !point || !this.lastPoint) {
|
||||
if (!point) this.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
const deltaY = this.lastPoint.clientY - point.clientY;
|
||||
this.pendingDeltaY += deltaY;
|
||||
this.lastPoint = point;
|
||||
if (this.lastMoveTime !== undefined && time > this.lastMoveTime) {
|
||||
this.velocitySamples.push({
|
||||
deltaY,
|
||||
durationMs: time - this.lastMoveTime,
|
||||
endedAt: time,
|
||||
});
|
||||
this.velocitySamples = this.velocitySamples.filter(
|
||||
(sample) =>
|
||||
time - sample.endedAt <= this.settings.momentumSampleWindowMs,
|
||||
);
|
||||
}
|
||||
this.lastMoveTime = time;
|
||||
|
||||
if (!this.scrolling) {
|
||||
if (Math.abs(point.clientY - this.startY!) < this.dragThresholdPx) {
|
||||
return true;
|
||||
}
|
||||
this.scrolling = true;
|
||||
}
|
||||
|
||||
this.flush(point);
|
||||
return true;
|
||||
}
|
||||
|
||||
end(time = this.timing.now()): void {
|
||||
const point = this.lastPoint;
|
||||
const velocity = this.scrolling ? this.recentVelocity(time) : 0;
|
||||
this.clearGesture();
|
||||
if (
|
||||
point &&
|
||||
this.timing.momentumAllowed() &&
|
||||
Math.abs(velocity) >= this.settings.minimumVelocityPxPerMs
|
||||
) {
|
||||
this.startMomentum(point, velocity, time);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.reset();
|
||||
this.cancelled = true;
|
||||
}
|
||||
|
||||
private flush(point: TouchWheelPoint): void {
|
||||
while (Math.abs(this.pendingDeltaY) > this.maxWheelDeltaPx) {
|
||||
const deltaY = Math.sign(this.pendingDeltaY) * this.maxWheelDeltaPx;
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
});
|
||||
this.pendingDeltaY -= deltaY;
|
||||
}
|
||||
if (this.pendingDeltaY !== 0) {
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY: this.pendingDeltaY,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
});
|
||||
this.pendingDeltaY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.stopMomentum();
|
||||
this.clearGesture();
|
||||
this.cancelled = false;
|
||||
}
|
||||
|
||||
private clearGesture(): void {
|
||||
this.startY = undefined;
|
||||
this.lastPoint = undefined;
|
||||
this.pendingDeltaY = 0;
|
||||
this.scrolling = false;
|
||||
this.velocitySamples = [];
|
||||
this.lastMoveTime = undefined;
|
||||
}
|
||||
|
||||
private recentVelocity(time: number): number {
|
||||
const newestSampleTime = this.velocitySamples.at(-1)?.endedAt;
|
||||
if (
|
||||
newestSampleTime === undefined ||
|
||||
time - newestSampleTime > this.settings.releaseGracePeriodMs
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const samples = this.velocitySamples.filter(
|
||||
(sample) =>
|
||||
newestSampleTime - sample.endedAt <=
|
||||
this.settings.momentumSampleWindowMs,
|
||||
);
|
||||
const duration = samples.reduce(
|
||||
(total, sample) => total + sample.durationMs,
|
||||
0,
|
||||
);
|
||||
if (duration === 0) return 0;
|
||||
const distance = samples.reduce(
|
||||
(total, sample) => total + sample.deltaY,
|
||||
0,
|
||||
);
|
||||
return Math.max(
|
||||
-this.settings.maximumVelocityPxPerMs,
|
||||
Math.min(this.settings.maximumVelocityPxPerMs, distance / duration),
|
||||
);
|
||||
}
|
||||
|
||||
private startMomentum(
|
||||
point: TouchWheelPoint,
|
||||
initialVelocity: number,
|
||||
startedAt: number,
|
||||
): void {
|
||||
let lastTime = startedAt;
|
||||
let travelled = 0;
|
||||
|
||||
const step = (time: number) => {
|
||||
if (!this.timing.momentumAllowed()) {
|
||||
this.momentumFrame = undefined;
|
||||
this.momentumRemainderY = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, time - startedAt);
|
||||
const remainingTravel = this.settings.maximumTravelPx - travelled;
|
||||
if (elapsed > this.settings.maximumDurationMs || remainingTravel <= 0) {
|
||||
this.momentumFrame = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Math.min(
|
||||
Math.max(0, time - lastTime),
|
||||
this.settings.maximumFrameIntervalMs,
|
||||
);
|
||||
if (duration > 0) {
|
||||
const frameStart = Math.max(0, elapsed - duration);
|
||||
const distance =
|
||||
Math.abs(initialVelocity) *
|
||||
this.settings.decayTimeMs *
|
||||
(Math.exp(-frameStart / this.settings.decayTimeMs) -
|
||||
Math.exp(-elapsed / this.settings.decayTimeMs));
|
||||
const deltaY =
|
||||
Math.sign(initialVelocity) * Math.min(distance, remainingTravel);
|
||||
this.flushMomentum(point, deltaY);
|
||||
travelled += Math.abs(deltaY);
|
||||
}
|
||||
|
||||
lastTime = time;
|
||||
if (
|
||||
elapsed < this.settings.maximumDurationMs &&
|
||||
travelled < this.settings.maximumTravelPx
|
||||
) {
|
||||
this.momentumFrame = this.timing.requestFrame(step);
|
||||
} else {
|
||||
this.momentumFrame = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
this.momentumFrame = this.timing.requestFrame(step);
|
||||
}
|
||||
|
||||
private flushMomentum(point: TouchWheelPoint, deltaY: number): void {
|
||||
this.momentumRemainderY += deltaY;
|
||||
const availableTicks = Math.trunc(
|
||||
Math.abs(this.momentumRemainderY) / this.settings.pixelsPerTick,
|
||||
);
|
||||
const tickCount = Math.min(
|
||||
availableTicks,
|
||||
this.settings.maximumTicksPerFrame,
|
||||
);
|
||||
const direction = Math.sign(this.momentumRemainderY);
|
||||
for (let index = 0; index < tickCount; index++) {
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY: direction,
|
||||
deltaMode: WheelEvent.DOM_DELTA_LINE,
|
||||
});
|
||||
}
|
||||
this.momentumRemainderY -=
|
||||
direction * tickCount * this.settings.pixelsPerTick;
|
||||
}
|
||||
|
||||
private stopMomentum(): void {
|
||||
if (this.momentumFrame !== undefined) {
|
||||
this.timing.cancelFrame(this.momentumFrame);
|
||||
this.momentumFrame = undefined;
|
||||
}
|
||||
this.momentumRemainderY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function installTouchWheelCoordinator(
|
||||
terminalElement: HTMLElement,
|
||||
momentumAllowed: () => boolean = () => true,
|
||||
settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
|
||||
): () => void {
|
||||
if (!settings.enabled) return () => {};
|
||||
const wheelElement = terminalElement.querySelector<HTMLElement>(
|
||||
".xterm-scrollable-element",
|
||||
);
|
||||
if (!wheelElement) return () => {};
|
||||
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
({ deltaY, deltaMode, clientX, clientY }) => {
|
||||
wheelElement.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX,
|
||||
clientY,
|
||||
deltaMode,
|
||||
deltaY,
|
||||
}),
|
||||
);
|
||||
},
|
||||
settings.dragThresholdPx,
|
||||
settings.maxWheelDeltaPx,
|
||||
{
|
||||
...defaultTiming,
|
||||
momentumAllowed: () =>
|
||||
settings.momentumEnabled &&
|
||||
momentumAllowed() &&
|
||||
!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
|
||||
},
|
||||
settings,
|
||||
);
|
||||
|
||||
const getSingleTouch = (event: TouchEvent): Touch | undefined =>
|
||||
event.touches.length === 1
|
||||
? (event.touches.item(0) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const touch = getSingleTouch(event);
|
||||
coordinator.start(touch);
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (coordinator.move(getSingleTouch(event))) event.preventDefault();
|
||||
};
|
||||
const handleTouchEnd = () => coordinator.end();
|
||||
const handleTouchCancel = () => coordinator.cancel();
|
||||
|
||||
terminalElement.addEventListener("touchstart", handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
terminalElement.addEventListener("touchmove", handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
terminalElement.addEventListener("touchend", handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
terminalElement.addEventListener("touchcancel", handleTouchCancel, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
coordinator.cancel();
|
||||
terminalElement.removeEventListener("touchstart", handleTouchStart);
|
||||
terminalElement.removeEventListener("touchmove", handleTouchMove);
|
||||
terminalElement.removeEventListener("touchend", handleTouchEnd);
|
||||
terminalElement.removeEventListener("touchcancel", handleTouchCancel);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* Host shapes as the terminal reads them. The plugin keeps its
|
||||
* own copy instead of importing core's types; only the fields it uses matter,
|
||||
* and the index signatures let the rest of a host record through.
|
||||
*/
|
||||
|
||||
export type { BackspaceMode as HostBackspaceMode } from "../shared/terminal-settings";
|
||||
|
||||
/** A host as the shell hands it to a tab. */
|
||||
export interface Host {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
sshOptions?: HostSshOptions | null;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Enough of a snippet for the variables dialog. */
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type TabType = string;
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* The terminal's look and behavior: this plugin's host, user and admin
|
||||
* settings, and the 2.8 `terminalConfig` shape they replaced.
|
||||
*
|
||||
* Shared by the backend (import, the 2.8 host payload) and the frontend (the
|
||||
* terminal and the host editor), so both read one list of keys.
|
||||
*/
|
||||
|
||||
export interface TerminalThemeColors {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor?: string;
|
||||
cursorAccent?: string;
|
||||
selectionBackground?: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
export interface SyntaxHighlightingOptions {
|
||||
logLevels: boolean;
|
||||
paths: boolean;
|
||||
timestamps: boolean;
|
||||
ipAddresses: boolean;
|
||||
urls: boolean;
|
||||
numbers: boolean;
|
||||
}
|
||||
|
||||
export type CursorStyle = "block" | "underline" | "bar";
|
||||
export type BellStyle = "none" | "sound" | "visual" | "both";
|
||||
export type FastScrollModifier = "alt" | "ctrl" | "shift";
|
||||
export type BackspaceMode = "normal" | "control-h";
|
||||
export type HostLocalEcho = "default" | "off" | "auto" | "on";
|
||||
export type HostLinkClickBehavior = "default" | "confirm" | "direct";
|
||||
|
||||
/** How a terminal looks. Each value follows the host defaults unless the host sets it. */
|
||||
export interface TerminalAppearance {
|
||||
theme: string;
|
||||
cursorBlink: boolean;
|
||||
cursorStyle: CursorStyle;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
scrollback: number;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
bellStyle: BellStyle;
|
||||
minimumContrastRatio: number;
|
||||
backgroundImage: string;
|
||||
backgroundImageOpacity: number;
|
||||
customThemeColors: TerminalThemeColors | null;
|
||||
}
|
||||
|
||||
/** How a terminal behaves on one host. */
|
||||
export interface TerminalBehavior {
|
||||
rightClickSelectsWord: boolean;
|
||||
macOptionIsMeta: boolean;
|
||||
fastScrollModifier: FastScrollModifier;
|
||||
fastScrollSensitivity: number;
|
||||
backspaceMode: BackspaceMode;
|
||||
autoMosh: boolean;
|
||||
moshCommand: string;
|
||||
autoTmux: boolean;
|
||||
useSSHTitle: boolean;
|
||||
syntaxHighlighting: boolean;
|
||||
syntaxHighlightingOptions: SyntaxHighlightingOptions;
|
||||
linkClickBehavior: HostLinkClickBehavior;
|
||||
localEcho: HostLocalEcho;
|
||||
passwordPromptAutoFill: boolean;
|
||||
sudoPasswordAutoFill: boolean;
|
||||
autoReconnect: boolean;
|
||||
}
|
||||
|
||||
/** Everything the host editor's Terminal tab edits. */
|
||||
export interface HostTerminalSettings
|
||||
extends TerminalAppearance, TerminalBehavior {
|
||||
inheritAppearance: boolean;
|
||||
}
|
||||
|
||||
export const APPEARANCE_KEYS = [
|
||||
"theme",
|
||||
"cursorBlink",
|
||||
"cursorStyle",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"bellStyle",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImage",
|
||||
"backgroundImageOpacity",
|
||||
"customThemeColors",
|
||||
] as const satisfies readonly (keyof TerminalAppearance)[];
|
||||
|
||||
export const BEHAVIOR_KEYS = [
|
||||
"rightClickSelectsWord",
|
||||
"macOptionIsMeta",
|
||||
"fastScrollModifier",
|
||||
"fastScrollSensitivity",
|
||||
"backspaceMode",
|
||||
"autoMosh",
|
||||
"moshCommand",
|
||||
"autoTmux",
|
||||
"useSSHTitle",
|
||||
"syntaxHighlighting",
|
||||
"syntaxHighlightingOptions",
|
||||
"linkClickBehavior",
|
||||
"localEcho",
|
||||
"passwordPromptAutoFill",
|
||||
"sudoPasswordAutoFill",
|
||||
"autoReconnect",
|
||||
] as const satisfies readonly (keyof TerminalBehavior)[];
|
||||
|
||||
export const INHERIT_APPEARANCE_KEY = "inheritAppearance";
|
||||
|
||||
export const DEFAULT_MOSH_COMMAND = "mosh-server new -s -l LANG=en_US.UTF-8";
|
||||
export const DEFAULT_FONT_FAMILY = "Caskaydia Cove Nerd Font Mono";
|
||||
|
||||
export const DEFAULT_SYNTAX_HIGHLIGHTING: SyntaxHighlightingOptions = {
|
||||
logLevels: true,
|
||||
paths: true,
|
||||
timestamps: true,
|
||||
ipAddresses: true,
|
||||
urls: true,
|
||||
numbers: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_APPEARANCE: TerminalAppearance = {
|
||||
theme: "termix",
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontSize: 14,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
scrollback: 10000,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
bellStyle: "none",
|
||||
minimumContrastRatio: 1,
|
||||
backgroundImage: "",
|
||||
backgroundImageOpacity: 0.15,
|
||||
customThemeColors: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_BEHAVIOR: TerminalBehavior = {
|
||||
rightClickSelectsWord: false,
|
||||
macOptionIsMeta: false,
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
backspaceMode: "normal",
|
||||
autoMosh: false,
|
||||
moshCommand: "",
|
||||
autoTmux: false,
|
||||
useSSHTitle: false,
|
||||
syntaxHighlighting: true,
|
||||
syntaxHighlightingOptions: DEFAULT_SYNTAX_HIGHLIGHTING,
|
||||
linkClickBehavior: "confirm",
|
||||
localEcho: "auto",
|
||||
passwordPromptAutoFill: true,
|
||||
sudoPasswordAutoFill: false,
|
||||
autoReconnect: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
|
||||
...DEFAULT_APPEARANCE,
|
||||
...DEFAULT_BEHAVIOR,
|
||||
inheritAppearance: true,
|
||||
};
|
||||
|
||||
export interface SavedCustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: TerminalThemeColors;
|
||||
}
|
||||
|
||||
/** The ssh-terminal user settings the terminal reads. */
|
||||
export interface TerminalUserSettings {
|
||||
customThemes: SavedCustomTheme[];
|
||||
commandAutocomplete: boolean;
|
||||
localEcho: "off" | "auto" | "on";
|
||||
linkClickBehavior: "confirm" | "direct";
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_SETTINGS: TerminalUserSettings = {
|
||||
customThemes: [],
|
||||
commandAutocomplete: false,
|
||||
localEcho: "auto",
|
||||
linkClickBehavior: "confirm",
|
||||
};
|
||||
|
||||
const ENUMS: Record<string, readonly string[]> = {
|
||||
cursorStyle: ["block", "underline", "bar"],
|
||||
bellStyle: ["none", "sound", "visual", "both"],
|
||||
fastScrollModifier: ["alt", "ctrl", "shift"],
|
||||
backspaceMode: ["normal", "control-h"],
|
||||
linkClickBehavior: ["default", "confirm", "direct"],
|
||||
localEcho: ["default", "off", "auto", "on"],
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** A JSON string or an object as an object, anything else as null. */
|
||||
export function asObject(value: unknown): Record<string, unknown> | null {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
if (!parsed) return null;
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return isObject(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number | undefined {
|
||||
if (value === null || value === undefined || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** One key's value, typed, or undefined when it does not fit the key. */
|
||||
function pick(key: string, value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (ENUMS[key]) {
|
||||
return typeof value === "string" && ENUMS[key].includes(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
switch (key) {
|
||||
case "theme":
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
// 2.8 spelled the app-following theme several ways.
|
||||
return [
|
||||
"Termix Dark",
|
||||
"Termix Light",
|
||||
"termixDark",
|
||||
"termixLight",
|
||||
].includes(value)
|
||||
? "termix"
|
||||
: value;
|
||||
case "fontFamily":
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
case "backgroundImage":
|
||||
case "moshCommand":
|
||||
return value === null
|
||||
? ""
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: undefined;
|
||||
case "customThemeColors":
|
||||
return value === null ? null : isObject(value) ? value : undefined;
|
||||
case "syntaxHighlightingOptions":
|
||||
return isObject(value)
|
||||
? { ...DEFAULT_SYNTAX_HIGHLIGHTING, ...value }
|
||||
: undefined;
|
||||
case "fontSize":
|
||||
case "scrollback":
|
||||
case "letterSpacing":
|
||||
case "lineHeight":
|
||||
case "minimumContrastRatio":
|
||||
case "backgroundImageOpacity":
|
||||
case "fastScrollSensitivity":
|
||||
return finite(value);
|
||||
default:
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The keys of an object that fit, typed, with everything else dropped. */
|
||||
export function pickTerminalValues(
|
||||
source: Record<string, unknown> | null | undefined,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
if (!source) return values;
|
||||
for (const key of keys) {
|
||||
const value = pick(key, source[key]);
|
||||
if (value !== undefined) values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host settings a 2.8 `terminalConfig` carried: every behavior key it
|
||||
* set, and the appearance keys only when it set any, which is also what
|
||||
* turns `inheritAppearance` off (2.8 stripped them to follow the user).
|
||||
*/
|
||||
export function hostSettingsFromTerminalConfig(
|
||||
terminalConfig: unknown,
|
||||
): Record<string, unknown> {
|
||||
const config = asObject(terminalConfig);
|
||||
if (!config) return {};
|
||||
const behavior = pickTerminalValues(config, BEHAVIOR_KEYS);
|
||||
const appearance = pickTerminalValues(config, APPEARANCE_KEYS);
|
||||
const values: Record<string, unknown> = { ...behavior };
|
||||
if (APPEARANCE_KEYS.some((key) => key in config)) {
|
||||
Object.assign(values, appearance, { [INHERIT_APPEARANCE_KEY]: false });
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Stored host values over the defaults, each one typed. */
|
||||
export function readHostTerminalSettings(
|
||||
values: Record<string, unknown> | null | undefined,
|
||||
): HostTerminalSettings {
|
||||
const typed = pickTerminalValues(values, [
|
||||
...APPEARANCE_KEYS,
|
||||
...BEHAVIOR_KEYS,
|
||||
]);
|
||||
const inherit = values?.[INHERIT_APPEARANCE_KEY];
|
||||
return {
|
||||
...DEFAULT_HOST_TERMINAL_SETTINGS,
|
||||
...typed,
|
||||
inheritAppearance: typeof inherit === "boolean" ? inherit : true,
|
||||
} as HostTerminalSettings;
|
||||
}
|
||||
|
||||
export function readUserSettings(
|
||||
values: Record<string, unknown> | null | undefined,
|
||||
): TerminalUserSettings {
|
||||
const source = values ?? {};
|
||||
const themes = Array.isArray(source.customThemes)
|
||||
? source.customThemes
|
||||
: typeof source.customThemes === "string"
|
||||
? (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(source.customThemes as string);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
: [];
|
||||
return {
|
||||
customThemes: themes.filter(
|
||||
(theme): theme is SavedCustomTheme =>
|
||||
isObject(theme) &&
|
||||
typeof theme.id === "string" &&
|
||||
typeof theme.name === "string" &&
|
||||
isObject(theme.colors),
|
||||
),
|
||||
commandAutocomplete: source.commandAutocomplete === true,
|
||||
localEcho:
|
||||
source.localEcho === "off" || source.localEcho === "on"
|
||||
? source.localEcho
|
||||
: "auto",
|
||||
linkClickBehavior:
|
||||
source.linkClickBehavior === "direct" ? "direct" : "confirm",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a terminal on a host runs with: the host's values over the built-in
|
||||
* ones. The host's values already follow its host defaults, and for a host
|
||||
* shared with this user its look follows theirs.
|
||||
*/
|
||||
export function resolveTerminalSettings(
|
||||
host: HostTerminalSettings | null | undefined,
|
||||
): TerminalAppearance & TerminalBehavior {
|
||||
const own = host ?? DEFAULT_HOST_TERMINAL_SETTINGS;
|
||||
const resolved = {} as Record<string, unknown>;
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
resolved[key] = own[key] ?? DEFAULT_APPEARANCE[key];
|
||||
}
|
||||
for (const key of BEHAVIOR_KEYS) resolved[key] = own[key];
|
||||
return resolved as unknown as TerminalAppearance & TerminalBehavior;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's settings in the 2.8 `terminalConfig` shape, for clients that
|
||||
* still read it (Termix-Mobile).
|
||||
*/
|
||||
export function terminalConfigFromHostSettings(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const settings = readHostTerminalSettings(values);
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const key of BEHAVIOR_KEYS) config[key] = settings[key];
|
||||
for (const key of APPEARANCE_KEYS) config[key] = settings[key];
|
||||
if (config.linkClickBehavior === "default") delete config.linkClickBehavior;
|
||||
if (config.localEcho === "default") delete config.localEcho;
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
export const TOUCH_INPUT_SETTING_KEY = "touch_input_settings";
|
||||
|
||||
export interface TouchInputSettings {
|
||||
enabled: boolean;
|
||||
momentumEnabled: boolean;
|
||||
dragThresholdPx: number;
|
||||
maxWheelDeltaPx: number;
|
||||
momentumSampleWindowMs: number;
|
||||
releaseGracePeriodMs: number;
|
||||
minimumVelocityPxPerMs: number;
|
||||
maximumVelocityPxPerMs: number;
|
||||
maximumDurationMs: number;
|
||||
maximumTravelPx: number;
|
||||
decayTimeMs: number;
|
||||
pixelsPerTick: number;
|
||||
maximumFrameIntervalMs: number;
|
||||
maximumTicksPerFrame: number;
|
||||
}
|
||||
|
||||
export const TOUCH_INPUT_DEFAULTS: TouchInputSettings = {
|
||||
enabled: true,
|
||||
momentumEnabled: true,
|
||||
dragThresholdPx: 6,
|
||||
maxWheelDeltaPx: 120,
|
||||
momentumSampleWindowMs: 100,
|
||||
releaseGracePeriodMs: 120,
|
||||
minimumVelocityPxPerMs: 0.15,
|
||||
maximumVelocityPxPerMs: 2.5,
|
||||
maximumDurationMs: 1_000,
|
||||
maximumTravelPx: 720,
|
||||
decayTimeMs: 300,
|
||||
pixelsPerTick: 12,
|
||||
maximumFrameIntervalMs: 32,
|
||||
maximumTicksPerFrame: 4,
|
||||
};
|
||||
|
||||
export type TouchInputNumericKey = Exclude<
|
||||
keyof TouchInputSettings,
|
||||
"enabled" | "momentumEnabled"
|
||||
>;
|
||||
|
||||
export const TOUCH_INPUT_NUMERIC_BOUNDS: Record<
|
||||
TouchInputNumericKey,
|
||||
{ min: number; max: number; step: number }
|
||||
> = {
|
||||
dragThresholdPx: { min: 0, max: 100, step: 1 },
|
||||
maxWheelDeltaPx: { min: 1, max: 1_000, step: 1 },
|
||||
momentumSampleWindowMs: { min: 10, max: 1_000, step: 10 },
|
||||
releaseGracePeriodMs: { min: 0, max: 2_000, step: 10 },
|
||||
minimumVelocityPxPerMs: { min: 0, max: 10, step: 0.01 },
|
||||
maximumVelocityPxPerMs: { min: 0.01, max: 20, step: 0.01 },
|
||||
maximumDurationMs: { min: 0, max: 10_000, step: 100 },
|
||||
maximumTravelPx: { min: 0, max: 10_000, step: 10 },
|
||||
decayTimeMs: { min: 1, max: 5_000, step: 10 },
|
||||
pixelsPerTick: { min: 1, max: 500, step: 1 },
|
||||
maximumFrameIntervalMs: { min: 1, max: 1_000, step: 1 },
|
||||
maximumTicksPerFrame: { min: 1, max: 100, step: 1 },
|
||||
};
|
||||
|
||||
export function normalizeTouchInputSettings(
|
||||
value: unknown,
|
||||
): TouchInputSettings {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { ...TOUCH_INPUT_DEFAULTS };
|
||||
}
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const normalized: TouchInputSettings = { ...TOUCH_INPUT_DEFAULTS };
|
||||
for (const key of ["enabled", "momentumEnabled"] as const) {
|
||||
if (typeof input[key] === "boolean") normalized[key] = input[key];
|
||||
}
|
||||
for (const [key, bounds] of Object.entries(TOUCH_INPUT_NUMERIC_BOUNDS) as [
|
||||
TouchInputNumericKey,
|
||||
{ min: number; max: number },
|
||||
][]) {
|
||||
const candidate = input[key];
|
||||
if (
|
||||
typeof candidate === "number" &&
|
||||
Number.isFinite(candidate) &&
|
||||
candidate >= bounds.min &&
|
||||
candidate <= bounds.max
|
||||
) {
|
||||
normalized[key] = candidate;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateTouchInputSettingsUpdate(
|
||||
value: unknown,
|
||||
): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return "settings must be an object";
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
const validKeys = new Set(Object.keys(TOUCH_INPUT_DEFAULTS));
|
||||
const unknownKey = Object.keys(input).find((key) => !validKeys.has(key));
|
||||
if (unknownKey) return `Unknown touch input setting: ${unknownKey}`;
|
||||
for (const key of ["enabled", "momentumEnabled"] as const) {
|
||||
if (key in input && typeof input[key] !== "boolean") {
|
||||
return `${key} must be a boolean`;
|
||||
}
|
||||
}
|
||||
for (const [key, bounds] of Object.entries(TOUCH_INPUT_NUMERIC_BOUNDS) as [
|
||||
TouchInputNumericKey,
|
||||
{ min: number; max: number },
|
||||
][]) {
|
||||
if (!(key in input)) continue;
|
||||
const candidate = input[key];
|
||||
if (
|
||||
typeof candidate !== "number" ||
|
||||
!Number.isFinite(candidate) ||
|
||||
candidate < bounds.min ||
|
||||
candidate > bounds.max
|
||||
) {
|
||||
return `${key} must be a number between ${bounds.min} and ${bounds.max}`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof input.minimumVelocityPxPerMs === "number" &&
|
||||
typeof input.maximumVelocityPxPerMs === "number" &&
|
||||
input.minimumVelocityPxPerMs > input.maximumVelocityPxPerMs
|
||||
) {
|
||||
return "minimumVelocityPxPerMs must not exceed maximumVelocityPxPerMs";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user