Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1381e66d7 | ||
|
|
0e6f54901b | ||
|
|
cb2fbf70eb | ||
|
|
739651d548 | ||
|
|
3577b800d1 | ||
|
|
d5295772cb | ||
|
|
58e916ecf8 | ||
|
|
493bfed599 | ||
|
|
fcb19c96c8 | ||
|
|
9bbd9ceccf | ||
|
|
b2fc84846c | ||
|
|
18aa193039 | ||
|
|
ea6a9743d1 | ||
|
|
fb230ee35d | ||
|
|
74f16bfd0d | ||
|
|
2eed4ea548 | ||
|
|
759485fc47 | ||
|
|
cb76b77f1b | ||
|
|
c2aa0349fc | ||
|
|
1ea24bf7fa | ||
|
|
0b6f611110 | ||
|
|
a7cf28b404 | ||
|
|
3ff04feb92 | ||
|
|
96fedb9348 | ||
|
|
523d9b02b4 | ||
|
|
421bdb9ee8 | ||
|
|
c9b5eb10eb | ||
|
|
96c603f3f4 | ||
|
|
23aaa1b53e | ||
|
|
88be0ceb41 | ||
|
|
d0bfb894f0 | ||
|
|
190bf59085 | ||
|
|
c656d2ff44 | ||
|
|
5699d17fb6 | ||
|
|
9577b829a0 | ||
|
|
382f3ca8af | ||
|
|
7f63928dd8 | ||
|
|
025ce1ffeb | ||
|
|
98ca4743e5 | ||
|
|
8fa9fb5be5 | ||
|
|
2ce145ba0f | ||
|
|
7146063da2 | ||
|
|
c5debd9c77 | ||
|
|
1d97e907e0 | ||
|
|
f5aa35ac27 | ||
|
|
b072731aed | ||
|
|
e50473c142 | ||
|
|
9d3b3ff507 | ||
|
|
2446e037ce | ||
|
|
1463394cbb | ||
|
|
63c27470f6 | ||
|
|
b6d77e485c | ||
|
|
9cf2010c70 | ||
|
|
79e6f20d3f | ||
|
|
6ec8c304a9 | ||
|
|
e56496d423 | ||
|
|
f42662c44e | ||
|
|
750ed80e02 | ||
|
|
d84f3e3f10 | ||
|
|
8a9c17046d | ||
|
|
0c0d9d0d32 | ||
|
|
a186be1da6 | ||
|
|
00f6b5af2f | ||
|
|
82aa812f98 | ||
|
|
7cb55e2416 | ||
|
|
f23391881c | ||
|
|
2eb9406f05 | ||
|
|
d5d0a5c27a | ||
|
|
5e40d06a6c | ||
|
|
8c9ce67684 | ||
|
|
da6320c04b | ||
|
|
5a998aefb9 | ||
|
|
6d300f7dba | ||
|
|
5e397195b9 |
@@ -0,0 +1,109 @@
|
||||
# Copilot Instructions — Nexus Terminal
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make deps # go mod download + npm install
|
||||
make build # production build → build/bin/nexus-terminal.app
|
||||
make dev # wails dev with hot reload
|
||||
make test # go test ./... -v -count=1
|
||||
make lint # golangci-lint run ./...
|
||||
|
||||
# Single package test
|
||||
go test ./internal/storage/ -v -count=1 -run TestProfileCRUD
|
||||
```
|
||||
|
||||
The frontend has no separate build step — `npm run build` (called by Wails) copies xterm vendor libs from `node_modules` into `frontend/src/js/lib/` and `frontend/src/css/lib/`. All frontend assets are embedded at compile time via `frontend/embed.go`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Wails v2 desktop app: Go backend + HTML/CSS/JS frontend rendered in a native webview (WKWebView on macOS).
|
||||
|
||||
**Go backend** (`internal/`):
|
||||
- `app/` — lifecycle bootstrap: opens SQLite, inits secret stores, wires everything together
|
||||
- `api/` — single `Service` struct bound to Wails; every public method is callable from JS as `window.go.api.Service.MethodName()`
|
||||
- `sessions/` — manages concurrent protocol sessions; owns SSH/Telnet/RAW/GitHub client lifecycles; handles auto-reconnect, login scripts, broadcast
|
||||
- `storage/` — SQLite repository (WAL mode) with embedded sequential migrations in `storage/migrations/`
|
||||
- `secrets/` — two-tier credential storage: OS keychain (preferred) → AES-256-GCM encrypted file vault (fallback)
|
||||
- `models/` — shared domain types and defaults; protocol configs are JSON-encoded in the DB
|
||||
- `sshclient/` — SSH client with agent, TOTP/2FA, and SOCKS5/HTTP proxy support
|
||||
- `telnetclient/`, `rawclient/`, `githubclient/` — protocol implementations
|
||||
- `sftpclient/` — SFTP file operations (browse, upload, download, rename, delete)
|
||||
- `sshconfig/` — OpenSSH `~/.ssh/config` parser and importer
|
||||
- `recording/` — asciicast v2 session recording and playback
|
||||
- `exportimport/` — encrypted profile bundles (.nexus format)
|
||||
- `updater/` — GitHub Releases update checker
|
||||
- `knownhosts/` — SSH host key management
|
||||
- `validation/` — profile and protocol validation
|
||||
- `audit/` — timestamped audit logging
|
||||
|
||||
**Frontend** (`frontend/`):
|
||||
- Plain HTML/CSS/JS — no framework, no bundler, no ES modules
|
||||
- `index.html` is the app shell; loads CSS then JS in strict order
|
||||
- `src/js/` — 18 modules, each IIFE-wrapped, exporting to `window.NexusTerminal`
|
||||
- `src/css/` — modular stylesheets; `tokens.css` defines all design tokens
|
||||
- Vendor libs (xterm.js, addons) live in `src/js/lib/` and `src/css/lib/` — auto-copied, gitignored
|
||||
|
||||
**Go ↔ JS communication:**
|
||||
- **Synchronous:** Frontend calls `window.go.api.Service.*()` (returns Promise)
|
||||
- **Asynchronous:** Backend pushes events via `wailsRuntime.EventsEmit()`; frontend listens with `window.runtime.EventsOn()`
|
||||
- **Bidirectional events** for secret prompts and host key verification: backend emits a request event with a `requestID`, frontend shows a modal, then emits a response event with the same ID. The Go side blocks on a channel keyed by that ID.
|
||||
|
||||
Key events: `session:data`, `session:state`, `session:closed`, `session:raw:data`, `session:github:repos`, `session:secret:prompt`/`session:secret:response`, `session:hostkey:verify`/`session:hostkey:response`.
|
||||
|
||||
## WKWebView Constraints (Critical)
|
||||
|
||||
The macOS webview silently blocks or breaks several standard web APIs. These constraints affect every frontend change:
|
||||
|
||||
1. **No `window.confirm()`, `window.prompt()`, `window.alert()`** — they either return false/null silently or do nothing. All user prompts must use the custom modal system (DOM-based backdrop + modal with `is-visible` class toggling).
|
||||
|
||||
2. **No ES modules** — `<script type="module">` fails silently. All JS files use plain `<script>` tags and IIFE wrappers: `(function() { ... })();`
|
||||
|
||||
3. **Click swallowing on text-selectable elements** — WKWebView initiates a text selection gesture on `mousedown` for `<div>` elements, which swallows the first `click` event (appears to require double-click). Fix: add `mousedown` handler with `e.preventDefault()` on any clickable non-input element (section headers, list items, etc.).
|
||||
|
||||
4. **Modal visibility pattern** — both `.modal-backdrop` AND `.modal` elements start with `opacity: 0; visibility: hidden` in CSS. You must add the `is-visible` class to **both** elements, not just the backdrop.
|
||||
|
||||
5. **Wails runtime injection** — `window.go` and `window.runtime` are injected by Wails before scripts execute. Do NOT add explicit script tags for Wails scripts. The frontend polls for their availability at startup (max 5s).
|
||||
|
||||
## Conventions
|
||||
|
||||
**Language:** All code comments in British English (colour, initialise, behaviour, etc.).
|
||||
|
||||
**Go patterns:**
|
||||
- String-typed enums: `type Protocol string` with `const ProtocolSSH Protocol = "ssh"` etc.
|
||||
- Protocol configs are pointer fields on `Profile` (`SSH *SSHConfig`, `Telnet *TelnetConfig`, etc.) — nil when not applicable
|
||||
- `NewProfile(protocol)` factory in `defaults.go` populates sensible defaults per protocol
|
||||
- Storage uses `nullableJSON()` helper to write nil for unused config columns
|
||||
- Tests use `t.TempDir()` for isolation, `t.Fatalf()` for setup failures
|
||||
|
||||
**Frontend patterns:**
|
||||
- Every JS file is IIFE-wrapped and exports to `window.NexusTerminal` namespace
|
||||
- Script load order matters (defined in `index.html`) — utils → state → notifications → sidebar → tabs → themes → terminal → profiles → raw → github → settings → commands → secrets → recordings → sftp → splitpane → snippets → app
|
||||
- DOM helpers: `createElement(tag, className, attrs)` from utils.js (supports `textContent`, `innerHTML`, `dataset`, `on*` event handlers)
|
||||
- Profile editor uses `_renderSection(title, fields)` pattern with `_field(type, name, label, value, opts)` helper for form generation
|
||||
- Login script editor uses `_renderLoginScriptSection(prefix, steps)` for expect-style step lists
|
||||
- TOTP section uses `_renderTOTPSection(profile)` with encrypted vault storage
|
||||
- `protocolColour()` returns CSS class names like `proto-ssh`; tab badges use `badge-ssh` class naming (different pattern)
|
||||
|
||||
**CSS patterns:**
|
||||
- Design tokens in `tokens.css`: `--bg-base`, `--bg-surface`, `--bg-elevated`, `--text-primary`, `--accent`, `--protocol-ssh`, etc.
|
||||
- Dark charcoal palette — not Material Design literally, but Material-inspired layering
|
||||
- Protocol colours: SSH=teal `#4ecdc4`, Telnet=amber `#ffa726`, RAW=purple `#ab47bc`, GitHub=indigo `#7c8aff`
|
||||
|
||||
**Adding a new protocol:**
|
||||
1. Add `Protocol` constant + config struct in `models/models.go`
|
||||
2. Add defaults in `models/defaults.go` `NewProfile()` switch
|
||||
3. Create client package in `internal/<proto>client/`
|
||||
4. Add `connect<Proto>()` in `sessions/manager.go` + case in `Connect()` switch
|
||||
5. Add API methods in `api/api.go` if needed
|
||||
6. Add DB migration in `storage/migrations/` (update CHECK constraint, add config column)
|
||||
7. Update `storage/store.go`: all SELECT/INSERT/UPDATE queries + both scan functions
|
||||
8. Frontend: add DEFAULTS in `profiles.js`, render sections, collect form values, protocol tab
|
||||
9. Frontend: add viewer in new JS file (IIFE-wrapped), include in `index.html`, wire in `app.js`
|
||||
10. Add CSS: protocol colour token, sidebar indicator, tab badge, viewer styles
|
||||
|
||||
**Database migrations:**
|
||||
- Numbered SQL files: `001_initial.sql`, `002_github_protocol.sql`, etc.
|
||||
- Each must `INSERT INTO schema_version (version, applied_at) VALUES (N, datetime('now'))` at the end
|
||||
- SQLite has no `ALTER COLUMN` — use the recreate-table pattern (CREATE new → INSERT INTO new SELECT FROM old → DROP old → ALTER TABLE RENAME)
|
||||
- Every query in `store.go` that touches the profiles table must be updated when columns change (there are ~8 SELECT column lists + 2 scan functions)
|
||||
@@ -0,0 +1,137 @@
|
||||
name: Release Builds
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: darwin/amd64
|
||||
runner: macos-13
|
||||
os: darwin
|
||||
arch: amd64
|
||||
archive_type: zip
|
||||
- platform: darwin/arm64
|
||||
runner: macos-14
|
||||
os: darwin
|
||||
arch: arm64
|
||||
archive_type: zip
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
os: linux
|
||||
arch: amd64
|
||||
archive_type: tar
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm64
|
||||
os: linux
|
||||
arch: arm64
|
||||
archive_type: tar
|
||||
- platform: windows/amd64
|
||||
runner: windows-latest
|
||||
os: windows
|
||||
arch: amd64
|
||||
archive_type: zip
|
||||
- platform: windows/arm64
|
||||
runner: ubuntu-latest
|
||||
os: windows
|
||||
arch: arm64
|
||||
archive_type: zip
|
||||
cross: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v2)
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
|
||||
|
||||
- name: Install Windows cross-compiler
|
||||
if: matrix.os == 'windows' && matrix.cross
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-mingw-w64-x86-64 gcc-mingw-w64-aarch64 zip
|
||||
|
||||
- name: Build (non-Windows runners)
|
||||
if: matrix.runner != 'windows-latest'
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
run: |
|
||||
if [ "${{ matrix.os }}" = "windows" ] && [ "${{ matrix.cross }}" = "true" ]; then
|
||||
export CC=aarch64-w64-mingw32-gcc
|
||||
fi
|
||||
wails build -platform "${{ matrix.platform }}" -ldflags "-s -w"
|
||||
|
||||
- name: Build (Windows runner)
|
||||
if: matrix.runner == 'windows-latest'
|
||||
shell: pwsh
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
run: |
|
||||
wails build -platform "${{ matrix.platform }}" -ldflags "-s -w"
|
||||
|
||||
- name: Package (zip)
|
||||
if: matrix.archive_type == 'zip' && matrix.runner != 'windows-latest'
|
||||
run: |
|
||||
archive="nexus-terminal-${{ matrix.platform }}.zip"
|
||||
cd build/bin
|
||||
zip -r "../../${archive}" .
|
||||
|
||||
- name: Package (tar)
|
||||
if: matrix.archive_type == 'tar'
|
||||
run: |
|
||||
archive="nexus-terminal-${{ matrix.platform }}.tar.gz"
|
||||
tar -czf "${archive}" -C build/bin .
|
||||
|
||||
- name: Package (Windows zip)
|
||||
if: matrix.archive_type == 'zip' && matrix.runner == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$archive = "nexus-terminal-${{ matrix.platform }}.zip"
|
||||
Compress-Archive -Path "build/bin/*" -DestinationPath $archive
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nexus-terminal-${{ matrix.platform }}
|
||||
path: |
|
||||
nexus-terminal-${{ matrix.platform }}.zip
|
||||
nexus-terminal-${{ matrix.platform }}.tar.gz
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
nexus-terminal-${{ matrix.platform }}.zip
|
||||
nexus-terminal-${{ matrix.platform }}.tar.gz
|
||||
@@ -4,13 +4,15 @@
|
||||
|
||||
# ── Build output ──────────────────────────────────────────────────────────
|
||||
build/bin/
|
||||
build/darwin/
|
||||
build/windows/
|
||||
build/linux/
|
||||
dist/
|
||||
|
||||
# ── Build platform resources (tracked — icons, plists, manifests) ─────────
|
||||
# build/darwin/, build/windows/, build/linux/ are tracked for icons & metadata
|
||||
|
||||
# ── Go ────────────────────────────────────────────────────────────────────
|
||||
vendor/
|
||||
.tools/
|
||||
.cache/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
@@ -58,3 +60,8 @@ vault.json
|
||||
*.pem
|
||||
*.key
|
||||
!build/appicon.png
|
||||
|
||||
|
||||
# Builds
|
||||
build/releases/v*
|
||||
build/releases/manifest*
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
# Security Audit Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This audit reviewed Nexus Terminal, a Wails desktop SSH/Telnet/RAW/VNC/SFTP client with a Go backend and JavaScript frontend. Fix mode was requested, so confirmed issues were patched, regression-tested, and prepared for commit.
|
||||
|
||||
The highest-risk issues were SSH trust failures: regular SSH sessions were not wired to the existing known-host verification flow, jump-host SSH connections ignored host keys, and VNC SSH tunnels also ignored host keys. These could allow machine-in-the-middle attacks against SSH credentials and terminal/VNC traffic. This run wired all SSH paths to known-host verification and prompt handling.
|
||||
|
||||
Other fixed issues include a local noVNC WebSocket proxy access-control weakness, SSH key-name path traversal in public-key reads, unsafe update-result DOM rendering, inline secret leakage in non-secret profile exports, and current vulnerable dependency versions in `golang.org/x/crypto` and `esbuild`.
|
||||
|
||||
Residual risk remains in user-selected insecure modes such as direct unencrypted VNC, user-configured `HostKeyInsecure`, and large local file transfer workflows that still process complete files in memory.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Go backend packages under `internal/`: API bindings, sessions, SSH, VNC, SFTP, storage, secrets, updater, raw/telnet clients, recording, validation, known hosts, config, app startup.
|
||||
- Frontend JavaScript under `frontend/src/js`: settings/update UI, host-key event handling, terminal and SFTP/VNC/GitHub surfaces, common DOM helpers.
|
||||
- Dependency manifests and lockfiles: `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
|
||||
- Build and release scripts: `Makefile`, `build.sh`, `release.sh`, Wails config.
|
||||
- Tests under `internal/**/*_test.go`.
|
||||
|
||||
Not in scope:
|
||||
|
||||
- Live network penetration testing of remote hosts.
|
||||
- Reverse engineering packaged binaries.
|
||||
- Manual verification of Wails runtime origin values in a built app.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
- Static source review of trust boundaries, file operations, SSH/VNC flows, Wails API bindings, frontend DOM sinks, update checks, storage, crypto, and secret handling.
|
||||
- Dependency review with `govulncheck` and `npm audit`.
|
||||
- Configuration review of Wails, build scripts, update manifests, and release paths.
|
||||
- Threat modelling of desktop renderer compromise, local web-origin attacks, remote SSH/VNC attackers, and malicious update metadata.
|
||||
- Denial-of-service review of network timeouts, WebSocket proxy behavior, transfer paths, and file ingestion.
|
||||
- Regression tests added for fixed Go issues.
|
||||
|
||||
## 4. Risk Rating Method
|
||||
|
||||
Severity considered exploitability, attacker position, affected security property, and impact:
|
||||
|
||||
- Critical: practical remote compromise or credential disclosure without meaningful preconditions.
|
||||
- High: credential/session compromise or machine-in-the-middle exposure on primary connection paths.
|
||||
- Medium: meaningful local, renderer, or network-assisted exploit with constraints.
|
||||
- Low: hardening issue, local DoS, or user-assisted exposure.
|
||||
- Informational: checked area or non-security note.
|
||||
|
||||
Confidence reflects direct code evidence and verification coverage.
|
||||
|
||||
## 5. Findings Summary Table
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---:|---:|---|---|---|
|
||||
| SEC-001 | High | High | SSH sessions and jump hosts bypassed known-host verification | SSH sessions | Fixed |
|
||||
| SEC-002 | High | High | VNC SSH tunnels accepted any SSH host key | VNC over SSH | Fixed |
|
||||
| SEC-003 | Medium | High | Local noVNC WebSocket proxy used a guessable root endpoint | VNC WebSocket proxy | Fixed |
|
||||
| SEC-004 | Medium | High | SSH public-key read accepted traversal key names | SSH key API | Fixed |
|
||||
| SEC-005 | Medium | High | Update metadata rendered through dynamic HTML and inline handler | Settings/update UI | Fixed |
|
||||
| SEC-006 | Medium | High | Non-secret profile export leaked inline SSH secrets | Profile export | Fixed |
|
||||
| SEC-007 | High | High | `golang.org/x/crypto` vulnerable in reachable SSH code | Go dependencies | Fixed |
|
||||
| SEC-008 | Medium | High | `esbuild` dev dependency matched current npm advisory | Frontend dependency | Fixed |
|
||||
| SEC-009 | Low | Medium | Large local file upload paths remain memory-heavy | File transfer UX | Open |
|
||||
| SEC-010 | Low | Medium | Direct VNC remains unencrypted by protocol choice | VNC direct mode | Open |
|
||||
|
||||
## 6. Detailed Findings
|
||||
|
||||
### Finding ID: SEC-001
|
||||
|
||||
#### Title
|
||||
|
||||
SSH sessions and jump hosts bypassed known-host verification
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/sessions/manager.go`, SSH connection setup, now fixed at lines 657 and 1450-1502.
|
||||
- `internal/sshclient/sshclient.go`, jump-host config, now fixed at lines 599 and 646-652.
|
||||
- `frontend/src/js/app.js`, host-key response correlation, now fixed at lines 334-342.
|
||||
|
||||
#### Description
|
||||
|
||||
The project already contained known-host storage, a host-key verifier, and frontend prompt UI, but the main SSH session manager previously called the SSH client's default `Connect` path. That default path used `ssh.InsecureIgnoreHostKey()`. Jump hosts also built their own `ssh.ClientConfig` with `ssh.InsecureIgnoreHostKey()`.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix now calls `ConnectWithHostKeyCallback(...)` for regular SSH sessions and propagates the final host-key callback into jump-host configs. A request ID is included in host-key prompt responses to prevent cross-session prompt confusion.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
An attacker on the network path presents a fake SSH server or jump host. Without host-key verification, Nexus Terminal would accept the attacker's key, allowing credential capture, terminal tampering, or traffic interception.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible SSH credential theft, terminal command tampering, and loss of server authenticity.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Always use the known-host verifier for every SSH hop. Unknown host keys should follow the configured profile policy: strict, ask, accept-new, or explicit insecure.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `Manager.hostKeyCallback` builds `sshclient.NewHostKeyVerifier(...)`, emits a prompt when needed, and `connectSSH` calls `ConnectWithHostKeyCallback(...)`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added regression coverage:
|
||||
|
||||
- `TestHostKeyCallbackAcceptNewStoresHost`
|
||||
- `TestHostKeyCallbackStrictRejectsUnknownHost`
|
||||
- `TestJumpHostClientConfigUsesFinalHostKeyCallback`
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-295: Improper Certificate Validation, analogous host-key trust failure.
|
||||
- OWASP ASVS 9.1.2: server certificate validation principles.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
Profiles may still explicitly choose `HostKeyInsecure`; that is a user-selected insecure mode and remains visible as residual risk.
|
||||
|
||||
### Finding ID: SEC-002
|
||||
|
||||
#### Title
|
||||
|
||||
VNC SSH tunnels accepted any SSH host key
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/vncclient/vncclient.go`, VNC SSH config, now fixed at lines 49 and 89-95.
|
||||
- `internal/sessions/manager.go`, VNC SSH callback wiring, now fixed at line 1016.
|
||||
|
||||
#### Description
|
||||
|
||||
VNC SSH tunnels established SSH connections with `ssh.InsecureIgnoreHostKey()`, allowing a network attacker to impersonate the SSH tunnel endpoint.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The VNC client now requires `SSHHostKeyCallback` for tunneled SSH. The session manager supplies the same known-host/prompt path used by SSH sessions.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
An attacker controlling DNS, Wi-Fi, or a gateway presents a fake SSH tunnel host for VNC traffic. The prior client accepted the key and tunneled VNC traffic through the attacker.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible VNC credential exposure, remote desktop traffic interception, and remote interaction tampering.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Require known-host verification for VNC SSH tunnels and reject tunnel setup if no callback is configured.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `vncclient.Connect` returns an error if `cfg.SSHHostKeyCallback` is nil, and uses the provided callback in `ssh.ClientConfig`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Covered indirectly by session host-key callback tests and VNC package tests.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-295.
|
||||
- OWASP ASVS 9.1.2.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
VNC tunnel host-key policy defaults to ask mode because VNC profiles do not currently expose a separate host-key policy field.
|
||||
|
||||
### Finding ID: SEC-003
|
||||
|
||||
#### Title
|
||||
|
||||
Local noVNC WebSocket proxy used a guessable root endpoint
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/vncclient/vncclient.go`, WebSocket proxy setup, now fixed at lines 171-183 and 255-260.
|
||||
|
||||
#### Description
|
||||
|
||||
The local VNC WebSocket proxy listened on `127.0.0.1` with a random port, but accepted upgrades on `/` and allowed any Origin. A malicious local web page could scan localhost ports and attempt to connect to an active proxy.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The proxy now generates a 128-bit random token and exposes only `ws://127.0.0.1:<port>/<token>` to the Wails frontend.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user visits a malicious web page while a VNC session is active. The page scans localhost WebSocket ports and connects to the proxy endpoint, potentially reading or injecting VNC traffic.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible local cross-origin access to VNC session traffic if the port is discovered.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Require an unguessable per-session URL token and continue binding only to loopback.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `newProxyToken()` uses `crypto/rand` and the proxy route is registered only at the token path.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added `TestNewProxyTokenIsRandomHex`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-346: Origin Validation Error.
|
||||
- OWASP ASVS 14.4.3: cross-origin resource restrictions.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
`CheckOrigin` still returns true because Wails/noVNC origin behavior needs built-app verification. The unguessable path materially reduces exposure.
|
||||
|
||||
### Finding ID: SEC-004
|
||||
|
||||
#### Title
|
||||
|
||||
SSH public-key read accepted traversal key names
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, SSH key API, now fixed at lines 1653-1708.
|
||||
|
||||
#### Description
|
||||
|
||||
`GetSSHPublicKey(name)` previously built `~/.ssh/<name>.pub` directly from a renderer-provided name. Unlike key generation, it did not restrict names to simple key basenames. Names such as `../somefile` could read `~/.ssh/../somefile.pub`.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix centralizes key name validation through `sshKeyPairPaths(...)`, allowing only alphanumeric characters, hyphens, and underscores.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
If renderer-side JavaScript is compromised or a malicious plugin/flow can invoke the Wails API, it can request public-key contents outside the intended `.ssh` key namespace if the target path ends in `.pub`.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local file disclosure for matching `.pub` paths under paths reachable through traversal.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Treat key names as basenames, not paths. Apply the same validation to generate, read, and delete operations.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `GetSSHPublicKey`, `GenerateSSHKey`, and `DeleteSSHKey` now all use `sshKeyPairPaths`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added:
|
||||
|
||||
- `TestSSHKeyPairPathsAcceptsSafeName`
|
||||
- `TestSSHKeyPairPathsRejectsTraversal`
|
||||
- `TestSSHKeyPairPathsRejectsNestedPath`
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-22: Path Traversal.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The API still intentionally lists and reads legitimate SSH public keys in `~/.ssh`.
|
||||
|
||||
### Finding ID: SEC-005
|
||||
|
||||
#### Title
|
||||
|
||||
Update metadata rendered through dynamic HTML and inline handler
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/src/js/settings.js`, update-check UI, now fixed at lines 940-958.
|
||||
|
||||
#### Description
|
||||
|
||||
Update metadata from GitHub or a configured update manifest was inserted into `innerHTML`, including an inline `onclick` that interpolated `info.releaseURL`. A malicious or compromised update metadata source could inject script into the Wails renderer.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix constructs DOM nodes, sets text via `textContent`, and attaches a click listener with `addEventListener`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
An attacker controlling a self-hosted update manifest returns a release URL containing a quote and JavaScript payload. The prior inline handler interpolation could execute renderer JavaScript.
|
||||
|
||||
#### Impact
|
||||
|
||||
Renderer code execution. In a Wails app, renderer execution can call exposed backend APIs, increasing impact.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Never render remote metadata through string-built HTML. Use `textContent` and event listeners.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented in `settings.js` with `createElement`, `document.createTextNode`, and `addEventListener`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
No frontend unit harness exists. Verified with `npm run build`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-79: Cross-Site Scripting.
|
||||
- OWASP ASVS 5.3: output encoding.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
Other static modal templates still use `innerHTML`; reviewed high-risk dynamic remote metadata paths first.
|
||||
|
||||
### Finding ID: SEC-006
|
||||
|
||||
#### Title
|
||||
|
||||
Non-secret profile export leaked inline SSH secrets
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, Wails export wrapper, now fixed at lines 683-684.
|
||||
- `internal/storage/store.go`, storage export, now fixed at lines 356-358 and 726-738.
|
||||
|
||||
#### Description
|
||||
|
||||
The plain profile export path accepted `includeSecrets=false` but profiles can still contain inline `ssh.proxyPassword` and `ssh.totpSecret` fields. Those fields could be serialized despite a non-secret export request.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fix redacts `ProxyPassword` and `TOTPSecret` for non-secret exports in both API and storage layers.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user creates a plain profile export believing secrets are excluded. The exported JSON contains proxy credentials or TOTP secrets that are then shared, backed up, or stored insecurely.
|
||||
|
||||
#### Impact
|
||||
|
||||
Credential leakage and potential 2FA bypass for affected profiles.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Redact inline secret fields whenever `includeSecrets=false`. Prefer encrypted export for any export that intentionally includes secrets.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `redactProfileSecrets(...)` in storage and API packages.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added:
|
||||
|
||||
- `TestExportProfilesRedactsInlineSSHSecrets`
|
||||
- `TestRedactProfileSecretsClearsInlineSSHSecrets`
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-200: Exposure of Sensitive Information.
|
||||
- OWASP ASVS 8.3: sensitive data protection.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The encrypted export path intentionally includes selected profile data and is password-protected.
|
||||
|
||||
### Finding ID: SEC-007
|
||||
|
||||
#### Title
|
||||
|
||||
`golang.org/x/crypto` vulnerable in reachable SSH code
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `go.mod`, `golang.org/x/crypto`, now fixed at line 14.
|
||||
- `go.mod`, indirect `golang.org/x/sys`, now updated at line 45.
|
||||
- Reachable SSH code in `internal/sshclient`, `internal/vncclient`, `internal/sftpclient`.
|
||||
|
||||
#### Description
|
||||
|
||||
`govulncheck` reported seven reachable vulnerabilities in `golang.org/x/crypto@v0.51.0`, including SSH DoS/panic issues, FIDO/U2F security-key interaction bypass risk, and SSH agent forwarding constraint handling issues.
|
||||
|
||||
#### Evidence
|
||||
|
||||
`govulncheck` identified GO-2026-5033, GO-2026-5020, GO-2026-5019, GO-2026-5018, GO-2026-5017, GO-2026-5013, and GO-2026-5006 as reachable before the update. The dependency is now `v0.52.0`, and rerun output reports: "No vulnerabilities found. Your code is affected by 0 vulnerabilities."
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A malicious or compromised SSH server, SSH agent, key material, or protocol peer triggers vulnerable SSH library behavior during connection, channel use, key parsing, or agent forwarding.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible client panic, denial of service, weakened security-key interaction, or agent forwarding constraint mishandling depending on the CVE path.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep `golang.org/x/crypto` at or above `v0.52.0` and run `govulncheck` in CI.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `go get golang.org/x/crypto@v0.52.0`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Ran `govulncheck` and `go test ./...`.
|
||||
|
||||
#### References
|
||||
|
||||
- https://pkg.go.dev/vuln/GO-2026-5033
|
||||
- https://pkg.go.dev/vuln/GO-2026-5020
|
||||
- https://pkg.go.dev/vuln/GO-2026-5019
|
||||
- https://pkg.go.dev/vuln/GO-2026-5018
|
||||
- https://pkg.go.dev/vuln/GO-2026-5017
|
||||
- https://pkg.go.dev/vuln/GO-2026-5013
|
||||
- https://pkg.go.dev/vuln/GO-2026-5006
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
`govulncheck` also noted vulnerabilities in required modules that are not called by this code.
|
||||
|
||||
### Finding ID: SEC-008
|
||||
|
||||
#### Title
|
||||
|
||||
`esbuild` dev dependency matched current npm advisory
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/package.json`, `esbuild`, now fixed at line 22.
|
||||
- `frontend/package-lock.json`.
|
||||
|
||||
#### Description
|
||||
|
||||
`npm audit` reported GHSA-67mh-4wv8-2f99 for `esbuild <=0.24.2`: any website could send requests to the dev server and read responses under affected versions.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The frontend previously resolved `esbuild@0.24.2`. It now uses `^0.28.0`, and `npm audit --json` reports zero vulnerabilities.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
During local development, a malicious website could interact with the esbuild dev server and read responses if a developer runs an affected dev server.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible local development data exposure.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Use a fixed esbuild release and keep npm audit in CI.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `npm install esbuild@0.28.0`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Ran `npm audit --json` and `npm run build`.
|
||||
|
||||
#### References
|
||||
|
||||
- https://github.com/advisories/GHSA-67mh-4wv8-2f99
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This is a devDependency, so production exploitability is lower than runtime dependencies.
|
||||
|
||||
### Finding ID: SEC-009
|
||||
|
||||
#### Title
|
||||
|
||||
Large local file upload paths remain memory-heavy
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
Medium
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/src/js/terminal.js`, dropped-file paste/upload flows.
|
||||
- `internal/api/api.go`, `SFTPUploadData`.
|
||||
|
||||
#### Description
|
||||
|
||||
Some upload paths read whole files into memory and send base64 payloads to Go before uploading. This can be expensive for very large local files.
|
||||
|
||||
#### Evidence
|
||||
|
||||
`SFTPUploadData` decodes the entire base64 payload before calling session upload. The frontend drag/drop paste path also reads file contents into strings.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A local user or compromised renderer submits very large files, causing high memory use and potential application unresponsiveness.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local denial of service.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Add size limits for paste-as-text, prefer path-based streaming upload for large files, and surface user-facing warnings for files above a threshold.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Use file path streaming upload where possible and reject in-memory uploads above a configured maximum.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add boundary tests for maximum accepted in-memory upload size and UI tests for large-file warnings.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-400: Uncontrolled Resource Consumption.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This is primarily local/user-assisted. It was left open to avoid changing UX and transfer semantics during the security fix pass.
|
||||
|
||||
### Finding ID: SEC-010
|
||||
|
||||
#### Title
|
||||
|
||||
Direct VNC remains unencrypted by protocol choice
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
Medium
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/vncclient/vncclient.go`, direct TCP VNC mode.
|
||||
- VNC profile settings.
|
||||
|
||||
#### Description
|
||||
|
||||
Direct VNC connections use raw TCP to the VNC server. VNC is commonly unencrypted unless tunneled or separately protected.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The direct branch calls `net.DialTimeout` to the VNC endpoint. SSH tunnel mode is available and now host-key verified.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
On an untrusted network, direct VNC traffic may expose screen contents or credentials depending on server configuration.
|
||||
|
||||
#### Impact
|
||||
|
||||
Sensitive remote desktop data exposure.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Warn users before direct VNC connections and prefer SSH tunnel mode by default.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Default new VNC profiles to SSH tunnel where feasible, or show an explicit "unencrypted direct VNC" warning.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add UI tests that direct VNC profiles show a warning before connection.
|
||||
|
||||
#### References
|
||||
|
||||
- OWASP ASVS 9.1: communications security.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
Some VNC servers may provide their own encryption; this audit did not test remote server configurations.
|
||||
|
||||
## 7. Denial-of-Service Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- Updated `golang.org/x/crypto` to resolve reachable SSH panic/deadlock/DoS vulnerabilities.
|
||||
- Added random-token access control to the local VNC WebSocket proxy, reducing local cross-origin abuse.
|
||||
|
||||
Reviewed and not changed:
|
||||
|
||||
- HTTP clients for GitHub and update checks use finite timeouts.
|
||||
- SSH/Telnet/RAW connection setup uses timeouts.
|
||||
- VNC WebSocket bridge is intentionally long-lived and uses no read/write timeout.
|
||||
- Large in-memory file upload/paste paths remain a local DoS risk; see SEC-009.
|
||||
|
||||
## 8. Dependency and Supply-Chain Review
|
||||
|
||||
Checks run:
|
||||
|
||||
- `govulncheck`: fixed reachable `golang.org/x/crypto` vulnerabilities by updating to `v0.52.0`; rerun found zero reachable vulnerabilities.
|
||||
- `npm audit --json`: fixed `esbuild` advisory by updating to `0.28.0`; rerun found zero vulnerabilities.
|
||||
- `npm run build`: passed after dependency update.
|
||||
|
||||
Notes:
|
||||
|
||||
- `xterm` packages are deprecated upstream in favor of `@xterm/*`, but no current audit advisory was reported in this run.
|
||||
- `go.mod` still has transitive modules with vulnerabilities that `govulncheck` reports as not called by this code.
|
||||
|
||||
## 9. Secrets and Sensitive Data Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- Non-secret profile export now redacts inline `ProxyPassword` and `TOTPSecret`.
|
||||
- SSH host-key verification now protects credential submission from basic MITM paths.
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Vault uses Argon2id and AES-256-GCM with random salts/nonces.
|
||||
- Vault file is written with `0600` permissions and store directories with `0700`.
|
||||
- OS keychain is preferred where available.
|
||||
|
||||
Residual:
|
||||
|
||||
- Secret values still exist in process memory while sessions need them.
|
||||
- Proxy password is still present in profile model state when entered inline.
|
||||
|
||||
## 10. Authentication and Authorisation Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- SSH, jump host, and VNC-over-SSH authentication now has host authenticity checks before credential use.
|
||||
- SSH host-key prompt responses are request-correlated.
|
||||
|
||||
Reviewed:
|
||||
|
||||
- SSH/TOTP flows use profile-specific secret callbacks.
|
||||
- Agent forwarding is explicit profile configuration.
|
||||
- Known-host policies include strict, ask, accept-new, and insecure.
|
||||
|
||||
Residual:
|
||||
|
||||
- `HostKeyInsecure` remains available for user-selected insecure connections.
|
||||
|
||||
## 11. Input Validation and Output Encoding Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- SSH key names now reject traversal and nested path separators.
|
||||
- Update metadata is rendered with DOM APIs instead of string-built HTML.
|
||||
- Non-secret export redacts inline secret fields.
|
||||
|
||||
Reviewed:
|
||||
|
||||
- SQL operations use parameterized queries.
|
||||
- Profile validation checks host, port, username, protocol-specific fields, and port-forwarding basics.
|
||||
- Frontend frequently uses `textContent` or `escapeHtml`; remaining `innerHTML` static templates should still be reviewed during future frontend hardening.
|
||||
|
||||
## 12. Infrastructure and Deployment Review
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Wails app binds Go services directly to the renderer. This makes frontend XSS more impactful than in a browser-only app.
|
||||
- Build scripts use `set -euo pipefail`.
|
||||
- Update checking is opt-in and does not auto-install binaries.
|
||||
|
||||
Residual:
|
||||
|
||||
- No CI configuration was present in the reviewed file list, so dependency scanning is not currently enforced by repository automation.
|
||||
|
||||
## 13. Logging, Monitoring, and Error Handling Review
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Audit logger records profile create/update/delete, connection events, secret access, import/export.
|
||||
- Errors are generally returned to the frontend with enough context for troubleshooting.
|
||||
|
||||
Residual:
|
||||
|
||||
- Some error messages include file paths, which is acceptable for a local desktop tool but should be considered if telemetry is later added.
|
||||
|
||||
## 14. Test Coverage and Security Regression Gaps
|
||||
|
||||
Added tests:
|
||||
|
||||
- API SSH key path validation and export redaction.
|
||||
- Session host-key callback storage and strict rejection behavior.
|
||||
- Jump-host host-key callback propagation.
|
||||
- Storage export redaction.
|
||||
- VNC proxy token generation.
|
||||
|
||||
Remaining gaps:
|
||||
|
||||
- No frontend unit test harness for DOM injection regressions.
|
||||
- No integration test for full Wails host-key prompt round trip.
|
||||
- No VNC WebSocket integration test verifying token-only connection behavior.
|
||||
- No large-file upload boundary tests.
|
||||
|
||||
## 15. Prioritised Remediation Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Keep the fixes in this audit and ensure they are merged.
|
||||
- Add dependency scanning to CI: `govulncheck ./...` and `npm audit --audit-level=moderate`.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Add a frontend test harness for update UI and other dynamic DOM surfaces.
|
||||
- Add direct VNC warning UX and default to SSH tunnel where possible.
|
||||
- Add explicit in-memory upload size limits.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Expose host-key policy for VNC SSH tunnels instead of always using ask mode.
|
||||
- Review all Wails-exposed methods under a compromised-renderer threat model.
|
||||
- Migrate deprecated `xterm` packages to maintained `@xterm/*` packages.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Consider capability gating or confirmation for high-risk renderer-callable APIs such as file deletion, SSH key deletion, local shell selection, and profile import/export.
|
||||
- Add signed update metadata if self-hosted update manifests become a production channel.
|
||||
|
||||
## 16. False Positives / Needs Manual Verification
|
||||
|
||||
- Wails/noVNC Origin behavior in a packaged app should be manually verified. Tokenized VNC WebSocket paths are in place, but Origin policy may still be tightened later.
|
||||
- Direct VNC encryption depends on remote VNC server configuration and network placement.
|
||||
- `HostKeyInsecure` is intentionally user-configurable; confirm UI wording makes the risk clear.
|
||||
- Large-file memory usage thresholds need product decisions before enforcement.
|
||||
|
||||
## 17. Final Notes
|
||||
|
||||
Fix mode completed with code changes, tests, and dependency updates. The strongest residual concern is the general Wails threat model: a frontend XSS can call powerful local backend APIs. This audit fixed the confirmed dynamic update UI injection path and several backend trust-boundary issues, but future work should continue reducing string-built HTML and adding capability checks around sensitive local operations.
|
||||
@@ -1,7 +1,13 @@
|
||||
.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package help
|
||||
.PHONY: all build build-compressed dev clean test test-cover lint install deps frontend-deps upx-check run package release help
|
||||
|
||||
# Resolve the Wails binary. Prefer wails on PATH; fall back to GOPATH/bin.
|
||||
WAILS := $(or $(shell which wails 2>/dev/null),$(shell go env GOPATH)/bin/wails)
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev)-$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
LD_UPDATE_URL := $(if $(NEXUS_UPDATE_URL),-X github.com/andydixon/nexus/internal/api.appUpdateURL=$(NEXUS_UPDATE_URL),)
|
||||
UPX_FLAGS ?=
|
||||
NEXUS_FORCE_MACOS_UPX ?= 0
|
||||
GOBIN_DIR := $(shell go env GOPATH)/bin
|
||||
export PATH := $(GOBIN_DIR):$(PATH)
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
@@ -10,6 +16,17 @@ all: build
|
||||
deps: frontend-deps
|
||||
go mod download
|
||||
|
||||
upx-check:
|
||||
@command -v upx >/dev/null 2>&1 || { echo "UPX is required for compressed builds. Install it first, for example: brew install upx"; exit 1; }
|
||||
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(NEXUS_FORCE_MACOS_UPX)" != "1" ]; then \
|
||||
echo "UPX compression is disabled for macOS by default. Use ./release.sh for a zipped .app, or run NEXUS_FORCE_MACOS_UPX=1 UPX_FLAGS='--force-macos --best --no-color --no-progress' make build-compressed."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(NEXUS_FORCE_MACOS_UPX)" = "1" ] && ! printf '%s\n' "$(UPX_FLAGS)" | grep -q -- '--force-macos'; then \
|
||||
echo "macOS UPX builds must pass --force-macos in UPX_FLAGS."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Install frontend dependencies
|
||||
frontend-deps:
|
||||
cd frontend && npm install
|
||||
@@ -20,7 +37,11 @@ dev: deps
|
||||
|
||||
# Production build
|
||||
build: deps
|
||||
$(WAILS) build
|
||||
CGO_CFLAGS="$${CGO_CFLAGS:--O2 -g0}" CGO_CXXFLAGS="$${CGO_CXXFLAGS:--O2 -g0}" $(WAILS) build -trimpath -ldflags "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
|
||||
|
||||
# Production build with UPX compression
|
||||
build-compressed: deps upx-check
|
||||
CGO_CFLAGS="$${CGO_CFLAGS:--O2 -g0}" CGO_CXXFLAGS="$${CGO_CXXFLAGS:--O2 -g0}" $(WAILS) build -trimpath -upx $(if $(UPX_FLAGS),-upxflags "$(UPX_FLAGS)",) -ldflags "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@@ -47,6 +68,10 @@ lint:
|
||||
package: build
|
||||
@echo "Binary built at build/bin/"
|
||||
|
||||
# Build and package release artefacts for all platforms
|
||||
release:
|
||||
./release.sh
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "Nexus Terminal — Build Targets"
|
||||
@@ -54,9 +79,11 @@ help:
|
||||
@echo " make deps — Install all dependencies"
|
||||
@echo " make dev — Run in development mode with hot reload"
|
||||
@echo " make build — Production build"
|
||||
@echo " make build-compressed — Production build with UPX compression"
|
||||
@echo " make test — Run tests"
|
||||
@echo " make test-cover — Run tests with coverage report"
|
||||
@echo " make clean — Clean build artefacts"
|
||||
@echo " make lint — Run linter (requires golangci-lint)"
|
||||
@echo " make package — Build and package for distribution"
|
||||
@echo " make release — Build multi-platform release artefacts"
|
||||
@echo " make help — Show this help"
|
||||
|
||||
@@ -1,399 +1,55 @@
|
||||
# Nexus Terminal
|
||||
|
||||
**A premium desktop SSH, Telnet, and RAW TCP client** built with [Wails v2](https://wails.io/) (Go backend, web frontend).
|
||||
**A desktop client for SSH, Telnet, RAW TCP, VNC, GitHub, local terminals, and Leviathan binary analysis — with secure credential handling and a focused, multi-session workflow.**
|
||||
|
||||
Nexus Terminal provides a modern, tabbed interface for managing remote connections over SSH, Telnet, and raw TCP — with encrypted credential storage, host key verification, audit logging, and a dark Material-inspired design.
|
||||
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence.
|
||||
|
||||
---
|
||||
## Highlights
|
||||
|
||||
## Features
|
||||
|
||||
### Implemented
|
||||
|
||||
- ✅ **SSH connections** — password, public key, and keyboard-interactive authentication
|
||||
- ✅ **Telnet** — option negotiation, character encoding, local echo, configurable line modes
|
||||
- ✅ **RAW TCP** — binary-safe text, hex, and split views with payload presets
|
||||
- ✅ **Saved connection profiles** — tags, folders, environments, favourites
|
||||
- ✅ **Encrypted secret storage** — AES-256-GCM with Argon2id key derivation
|
||||
- ✅ **OS keychain integration** — macOS Keychain, Linux Secret Service, Windows Credential Manager
|
||||
- ✅ **Known hosts verification** — configurable policies (strict / ask / accept-new / insecure)
|
||||
- ✅ **Tabbed multi-session management** — rename, reorder, pin, context menu
|
||||
- ✅ **xterm.js terminal** — search, web links, fit-to-container
|
||||
- ✅ **Profile import/export** — JSON format, secrets excluded by default
|
||||
- ✅ **Command palette** — quick access via Ctrl+K / Cmd+K
|
||||
- ✅ **Dark charcoal Material-inspired UI**
|
||||
- ✅ **Audit logging** — timestamped records of connections and actions
|
||||
- ✅ **Input validation** — comprehensive profile and protocol validation
|
||||
|
||||
### In Progress
|
||||
|
||||
- 🔨 **Jump host / bastion support** — architecture in place, single-hop implemented
|
||||
- 🔨 **Session transcript export** — partial implementation
|
||||
|
||||
### Planned
|
||||
|
||||
- 📋 Port forwarding (extension point architected)
|
||||
- 📋 SFTP file transfer (extension point architected)
|
||||
- 📋 Split panes
|
||||
- 📋 Theme customisation
|
||||
- 📋 OpenSSH config import (partial parser exists)
|
||||
- 📋 Encrypted profile export
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
> _Screenshots will be added once the UI is feature-complete._
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Install |
|
||||
|------------|---------|-------------------------------------------------------------------|
|
||||
| Go | 1.21+ | [golang.org/dl](https://golang.org/dl/) |
|
||||
| Node.js | 18+ | [nodejs.org](https://nodejs.org/) |
|
||||
| Wails CLI | v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` |
|
||||
|
||||
> **Note:** On Linux you may also need GTK, WebKit, and related system libraries.
|
||||
> Run `wails doctor` to verify your environment.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/nexus-terminal.git
|
||||
cd nexus-terminal
|
||||
make deps # install Go modules and frontend npm packages
|
||||
make dev # launch in development mode with hot reload
|
||||
```
|
||||
|
||||
Or using the shell script:
|
||||
|
||||
```bash
|
||||
./build.sh deps
|
||||
./build.sh dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
| Command | Description |
|
||||
|--------------------|------------------------------------------|
|
||||
| `make deps` | Install all dependencies |
|
||||
| `make dev` | Run in development mode with hot reload |
|
||||
| `make build` | Production build |
|
||||
| `make test` | Run Go tests |
|
||||
| `make test-cover` | Run tests with HTML coverage report |
|
||||
| `make clean` | Remove build artefacts and caches |
|
||||
| `make lint` | Run golangci-lint |
|
||||
| `make package` | Build and package for distribution |
|
||||
| `make help` | Show all available targets |
|
||||
|
||||
The production binary is written to `build/bin/NexusTerminal`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
copilot-ssh-client/
|
||||
├── cmd/
|
||||
│ └── app/
|
||||
│ └── main.go # Application entry point (Wails bootstrap)
|
||||
├── frontend/
|
||||
│ ├── dist/
|
||||
│ │ └── index.html # HTML entry point (embedded by Wails)
|
||||
│ ├── scripts/
|
||||
│ │ ├── build.js # Frontend build & watch script
|
||||
│ │ └── copy-assets.js # Copies xterm assets from node_modules
|
||||
│ ├── src/
|
||||
│ │ ├── assets/icons/ # Application icons
|
||||
│ │ ├── components/ # UI components (planned)
|
||||
│ │ ├── css/ # Stylesheets
|
||||
│ │ │ └── lib/ # Vendored CSS (xterm.css — auto-copied)
|
||||
│ │ └── js/ # JavaScript source
|
||||
│ │ └── lib/ # Vendored JS (xterm — auto-copied)
|
||||
│ └── package.json
|
||||
├── internal/
|
||||
│ ├── api/ # Wails-bound API methods (planned)
|
||||
│ ├── app/
|
||||
│ │ └── app.go # Application lifecycle management
|
||||
│ ├── audit/ # Audit log service (planned)
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Platform-specific path resolution
|
||||
│ ├── knownhosts/ # Known hosts management (planned)
|
||||
│ ├── models/
|
||||
│ │ ├── models.go # Core domain types (Profile, Session, etc.)
|
||||
│ │ └── defaults.go # Default values and factory functions
|
||||
│ ├── preferences/ # Preferences service (planned)
|
||||
│ ├── rawclient/
|
||||
│ │ └── encoding.go # Hex, escaped, and character encoding utilities
|
||||
│ ├── secrets/
|
||||
│ │ ├── keychain.go # OS keychain integration
|
||||
│ │ ├── redact.go # Secret redaction for logs and UI
|
||||
│ │ └── secrets.go # AES-256-GCM encrypted vault
|
||||
│ ├── sessions/ # Session lifecycle management (planned)
|
||||
│ ├── sshclient/
|
||||
│ │ └── hostkey.go # SSH host key verification with policies
|
||||
│ ├── storage/
|
||||
│ │ ├── migrations/
|
||||
│ │ │ ├── 001_initial.sql # Initial database schema
|
||||
│ │ │ └── migrations.go # Embedded migration files
|
||||
│ │ └── storage.go # SQLite persistence layer
|
||||
│ ├── telnetclient/ # Telnet client (planned)
|
||||
│ └── validation/
|
||||
│ └── validation.go # Profile and protocol validation
|
||||
├── build.sh # Shell build script for CI
|
||||
├── Makefile # Make-based build targets
|
||||
├── wails.json # Wails v2 project configuration
|
||||
├── go.mod # Go module definition
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Key Packages
|
||||
|
||||
| Package | Purpose |
|
||||
|--------------------|------------------------------------------------------------------------|
|
||||
| `cmd/app` | Wails application bootstrap — window size, title, lifecycle hooks |
|
||||
| `internal/app` | Wires together all subsystems; manages startup and shutdown |
|
||||
| `internal/config` | Resolves platform-specific data, database, and vault paths |
|
||||
| `internal/models` | Domain types shared across all packages — profiles, sessions, enums |
|
||||
| `internal/storage` | SQLite repository with WAL mode, migrations, and import/export |
|
||||
| `internal/secrets` | Encrypted vault (AES-256-GCM + Argon2id) and OS keychain abstraction |
|
||||
| `internal/sshclient` | SSH host key verification with strict / ask / accept-new policies |
|
||||
| `internal/rawclient` | Hex dump, escape parsing, and character encoding for raw TCP |
|
||||
| `internal/validation` | Multi-field validation for profiles and protocol-specific configs |
|
||||
|
||||
### Frontend–Backend Interaction
|
||||
|
||||
Wails v2 exposes Go methods to the frontend as JavaScript functions. The `App` struct (and future API types) are bound in `main.go`. Frontend calls arrive as synchronous RPC; asynchronous data (terminal output, session events) flows via the Wails event system.
|
||||
|
||||
```
|
||||
┌─────────────┐ Wails bindings ┌──────────────┐
|
||||
│ Frontend │ ◄────────────────► │ Go backend │
|
||||
│ (xterm.js) │ Wails events │ (App struct) │
|
||||
└─────────────┘ └──────────────┘
|
||||
│ │
|
||||
│ xterm.js writes │ golang.org/x/crypto/ssh
|
||||
│ to terminal │ net.Dial (telnet, raw)
|
||||
▼ ▼
|
||||
Browser DOM Remote hosts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### Secret Storage
|
||||
|
||||
Nexus Terminal uses a two-tier approach to credential storage:
|
||||
|
||||
1. **OS keychain (preferred)** — macOS Keychain, Linux Secret Service (via D-Bus), Windows Credential Manager. Probed at startup; used automatically when available.
|
||||
2. **Encrypted file vault (fallback)** — AES-256-GCM encrypted JSON file at `<data-dir>/vault`. Requires a master password to unlock.
|
||||
|
||||
### Encryption Details
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------------|-----------------------------------|
|
||||
| Cipher | AES-256-GCM |
|
||||
| Key derivation | Argon2id (time=3, mem=64 KB, threads=4) |
|
||||
| Key length | 256 bits (32 bytes) |
|
||||
| Salt | 32 random bytes (per vault) |
|
||||
| Nonce | Random, prepended to ciphertext |
|
||||
|
||||
### Threat Model
|
||||
|
||||
**Protects against:**
|
||||
- Secrets exposed via disk theft or backup exfiltration
|
||||
- Accidental secret leakage in logs, exports, or UI (redaction layer)
|
||||
- Credential access without the master password
|
||||
|
||||
**Does NOT protect against:**
|
||||
- A compromised operating system or process with memory access
|
||||
- Memory forensics whilst the vault is unlocked
|
||||
- Keyloggers capturing the master password
|
||||
- Weak master passwords (no strength enforcement currently)
|
||||
|
||||
### Secret Key Format
|
||||
|
||||
Secrets are keyed by profile: `profile:<uuid>:password`, `profile:<uuid>:passphrase`. The `ListKeys()` method exposes keys but never values.
|
||||
|
||||
### Export Handling
|
||||
|
||||
Profile exports exclude secrets by default. The JSON export format contains connection metadata but no passwords or private key passphrases unless explicitly requested (future encrypted export).
|
||||
|
||||
---
|
||||
|
||||
## Protocol Support
|
||||
|
||||
| Feature | SSH | Telnet | RAW TCP |
|
||||
|----------------------------------|:---:|:------:|:-------:|
|
||||
| Password authentication | ✅ | — | — |
|
||||
| Public key authentication | ✅ | — | — |
|
||||
| Keyboard-interactive auth | ✅ | — | — |
|
||||
| Host key verification | ✅ | — | — |
|
||||
| Keepalive | ✅ | — | — |
|
||||
| Option negotiation | — | ✅ | — |
|
||||
| Local echo | — | ✅ | — |
|
||||
| Character encoding (UTF-8, etc.) | — | ✅ | ✅ |
|
||||
| Text view | ✅ | ✅ | ✅ |
|
||||
| Hex view | — | — | ✅ |
|
||||
| Split (text + hex) view | — | — | ✅ |
|
||||
| Payload presets | — | — | ✅ |
|
||||
| Configurable line endings | — | ✅ | ✅ |
|
||||
| Auto-reconnect | — | — | ✅ |
|
||||
| Jump hosts / bastions | 🔨 | — | — |
|
||||
| Port forwarding | 📋 | — | — |
|
||||
| SFTP | 📋 | — | — |
|
||||
|
||||
**Legend:** ✅ implemented · 🔨 in progress · 📋 planned · — not applicable
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
### Database
|
||||
|
||||
Nexus Terminal stores profiles, known hosts, preferences, and audit entries in a SQLite database with WAL (Write-Ahead Logging) mode for concurrent read performance.
|
||||
|
||||
| Platform | Default location |
|
||||
|----------|--------------------------------------------------------|
|
||||
| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` |
|
||||
| Linux | `~/.config/NexusTerminal/nexus.db` |
|
||||
| Windows | `%APPDATA%\NexusTerminal\nexus.db` |
|
||||
|
||||
### Schema Versioning
|
||||
|
||||
Migrations are embedded at compile time via `embed.FS` and applied sequentially at startup. Each migration records its version in the `schema_version` table to prevent re-application.
|
||||
|
||||
Current schema (v001):
|
||||
- `profiles` — connection profiles with JSON-encoded protocol configs
|
||||
- `known_hosts` — SSH host key fingerprints with trust state
|
||||
- `preferences` — key-value application preferences (JSON)
|
||||
- `audit_log` — timestamped action records
|
||||
- `schema_version` — applied migration tracking
|
||||
|
||||
---
|
||||
|
||||
## Import / Export
|
||||
|
||||
### JSON Export
|
||||
|
||||
```bash
|
||||
# Via the application UI: Profiles → Export
|
||||
```
|
||||
|
||||
The export format is a JSON array of profile objects. Each profile includes connection settings, terminal preferences, and protocol-specific configuration — but **not** secrets (passwords, passphrases) by default.
|
||||
|
||||
### Import Strategies
|
||||
|
||||
| Strategy | Behaviour |
|
||||
|-------------|---------------------------------------------------------------|
|
||||
| `skip` | Skip profiles whose ID already exists |
|
||||
| `overwrite` | Replace existing profiles with the imported version |
|
||||
| `duplicate` | Import as a new profile with a fresh UUID |
|
||||
|
||||
### Format
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"exported_at": "2024-01-15T10:30:00Z",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"display_name": "Production Server",
|
||||
"protocol": "ssh",
|
||||
"host": "prod.example.com",
|
||||
"port": 22,
|
||||
"username": "deploy",
|
||||
"tags": ["production", "web"],
|
||||
"folder": "Servers",
|
||||
"ssh_config": { "..." : "..." }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### Adding a New Protocol
|
||||
|
||||
1. Create a new package under `internal/` (e.g., `internal/mystclient/`).
|
||||
2. Define a protocol-specific config struct in `internal/models/models.go`.
|
||||
3. Add the protocol constant to the `Protocol` type.
|
||||
4. Update `internal/models/defaults.go` with default values and a factory case in `NewProfile()`.
|
||||
5. Add validation rules in `internal/validation/validation.go`.
|
||||
6. Wire the new client into `internal/app/app.go` and `internal/sessions/`.
|
||||
7. Update the database migration if new columns are required.
|
||||
|
||||
### Adding Tests
|
||||
|
||||
Tests live alongside the code they exercise:
|
||||
|
||||
```bash
|
||||
go test ./internal/validation/ -v # run validation tests
|
||||
go test ./internal/secrets/ -v # run secrets tests
|
||||
go test ./... -v -count=1 # run all tests
|
||||
go test ./... -coverprofile=coverage.out # with coverage
|
||||
```
|
||||
|
||||
Use `_test.go` files in the same package for white-box tests, or in a `_test` package for black-box tests. Table-driven tests are preferred.
|
||||
|
||||
### Event System
|
||||
|
||||
The Wails runtime provides an event bus for asynchronous communication:
|
||||
|
||||
- **Backend → Frontend:** `runtime.EventsEmit(ctx, "event:name", payload)`
|
||||
- **Frontend → Backend:** `runtime.EventsOn("event:name", callback)`
|
||||
|
||||
Key events:
|
||||
- `backend:ready` — emitted when the Go backend has finished initialisation
|
||||
- `session:data` — terminal output data for a specific session
|
||||
- `session:state` — session state changes (connecting, connected, disconnected, error)
|
||||
|
||||
---
|
||||
|
||||
## Limitations and Future Work
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Single-hop jump hosts only** — the jump host architecture supports chaining, but only one hop is currently implemented.
|
||||
- **No SFTP** — the extension point is designed but file transfer is not yet available.
|
||||
- **No port forwarding** — local/remote/dynamic forwarding is planned but not implemented.
|
||||
- **No split panes** — single terminal per tab; split view is planned.
|
||||
- **No theme customisation** — the dark charcoal theme is currently the only option.
|
||||
- **No master password strength enforcement** — the vault accepts any master password.
|
||||
- **Argon2id memory parameter is conservative** — 64 KB is low for production use; will be increased in a future release.
|
||||
- **No SSH agent forwarding** — authentication uses direct key/password only.
|
||||
- **Linux recursive file watch** — the development watch script may not detect all changes on Linux due to `fs.watch` limitations.
|
||||
|
||||
### Planned Improvements
|
||||
|
||||
- Increase Argon2id memory to 64 MB+ for stronger key derivation
|
||||
- SFTP file browser and transfer
|
||||
- Local, remote, and dynamic port forwarding
|
||||
- Multi-hop jump host chains
|
||||
- Split pane terminal layout
|
||||
- Custom themes and colour schemes
|
||||
- OpenSSH `~/.ssh/config` import
|
||||
- SSH, Telnet, RAW TCP, VNC, GitHub, and local terminal sessions in one app
|
||||
- Tabbed sessions with split panes, pinning, and reordering
|
||||
- SFTP browser with drag-and-drop uploads and a directory picker
|
||||
- Leviathan binary workbench with hex editing, entropy, carving, BinSnoop, structure parsers, transforms, and spectrogram tools
|
||||
- Disconnected overlay with one-click reconnect
|
||||
- Broadcast input, snippets, and terminal search
|
||||
- Session recording and playback
|
||||
- SSH agent forwarding
|
||||
- Encrypted profile export/import
|
||||
- Master password strength meter
|
||||
- Automatic update checking
|
||||
- Secure credential storage with OS keychain and encrypted vault fallback
|
||||
|
||||
---
|
||||
## Quick start
|
||||
|
||||
## Licence
|
||||
1. Launch Nexus Terminal.
|
||||
2. Click **New** in the sidebar and create a connection profile.
|
||||
3. Click **Connect** to open a session in a new tab.
|
||||
|
||||
This project is licensed under the MIT Licence. See [LICENCE](LICENCE) for details.
|
||||
Prefer a one-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** to quick connect.
|
||||
|
||||
---
|
||||
If a session drops, use the **Reconnect** button in the disconnected overlay.
|
||||
|
||||
<sub>Built with ❤ using [Wails](https://wails.io/), [xterm.js](https://xtermjs.org/), and [Go](https://go.dev/).</sub>
|
||||
## Documentation
|
||||
|
||||
- Product overview: [docs/overview.md](docs/overview.md)
|
||||
- Quick start (shareable): [docs/quick-start.md](docs/quick-start.md)
|
||||
- User manual: [docs/user-manual.md](docs/user-manual.md)
|
||||
- Build and release: [docs/build-and-release.md](docs/build-and-release.md)
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
|
||||
|
||||
| macOS Shortcut | Windows/Linux Shortcut | Action |
|
||||
| ----------------- | ---------------------- | --------------------------- |
|
||||
| Cmd + K | Ctrl + K | Open command palette |
|
||||
| Cmd + N | Ctrl + N | New connection |
|
||||
| Cmd + W | Ctrl + W | Close current tab |
|
||||
| Cmd + Tab | Ctrl + Tab | Next tab |
|
||||
| Cmd + Shift + Tab | Ctrl + Shift + Tab | Previous tab |
|
||||
| Cmd + 1–9 | Ctrl + 1–9 | Switch to tab by number |
|
||||
| Cmd + , | Ctrl + , | Open settings |
|
||||
| Cmd + F | Ctrl + F | Find in terminal |
|
||||
| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
|
||||
| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
|
||||
| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
|
||||
| Cmd + Shift + B | Ctrl + Shift + B | Toggle broadcast input mode |
|
||||
| Cmd + Shift + S | Ctrl + Shift + S | Open snippet library |
|
||||
| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
|
||||
| F11 | F11 | Toggle fullscreen |
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Functionality and Next Steps Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix-mode audit improved immediate security and robustness around profile validation, imports, drag-drop upload, frontend encoding, dependencies, and release reproducibility.
|
||||
|
||||
The most useful next work is to turn these fixes into durable product quality: add frontend interaction tests, make large file upload streaming and consistent, warn users about intentionally insecure protocol choices, and strengthen release provenance.
|
||||
|
||||
## 2. Current Functional Risks
|
||||
|
||||
Direct VNC can still be used without SSH tunnelling. That is valid for trusted networks, but the UI should make the risk visible when profiles are saved or connected.
|
||||
|
||||
Drag-drop SFTP upload is now capped at 25 MiB because it still uses an in-memory browser-to-Go base64 path. Larger files should use the existing native SFTP upload flow or a future streaming drag-drop implementation.
|
||||
|
||||
Profile import now rejects malformed or unsafe profiles before storage. That is safer, but older third-party exports with missing defaults may need a clearer import error UI or a migration/repair preview.
|
||||
|
||||
RAW session history and recordings can still grow over long sessions. Users need limits, retention controls, and clear storage management.
|
||||
|
||||
## 3. Code Quality Improvements
|
||||
|
||||
Profile validation is now centralized enough for create/update/import, but profile normalization is still split between frontend defaults, Go defaults, and import-time cleanup. A schema-versioned import normalizer would make future protocol additions safer.
|
||||
|
||||
Frontend still has many `innerHTML` templates. Some are static or escaped, but a small helper pattern for safe modal construction would reduce future XSS mistakes.
|
||||
|
||||
Release scripts and CI now use pinned Wails and `npm ci`; follow-up work should avoid duplicate release logic between `build.sh`, `release.sh`, and GitHub Actions.
|
||||
|
||||
## 4. Frontend Interaction and Error-Handling Improvements
|
||||
|
||||
Add Wails/browser interaction tests for:
|
||||
|
||||
- Dropping a file with HTML metacharacters in its filename.
|
||||
- Dropping a file over 25 MiB.
|
||||
- Importing malformed and oversized profile bundles.
|
||||
- Saving GitHub profiles with HTTP and HTTPS API base URLs.
|
||||
- Creating direct VNC profiles and seeing a warning.
|
||||
|
||||
Improve import UX by showing which profile entry failed validation without exposing secret values.
|
||||
|
||||
## 5. Testing Roadmap
|
||||
|
||||
Immediate tests to add:
|
||||
|
||||
- Frontend DOM regression for file-drop title rendering.
|
||||
- API tests for successful valid GitHub Enterprise HTTPS profile creation.
|
||||
- Import tests for mixed valid/invalid profile arrays.
|
||||
|
||||
Short-term tests:
|
||||
|
||||
- End-to-end Wails smoke tests for create profile, connect prompt flow, import/export, SFTP upload, and VNC tunnel setup.
|
||||
- Load tests for large SFTP directories, RAW captures, and recordings.
|
||||
- CI security job with `npm audit`, govulncheck, and secret scanning.
|
||||
|
||||
Longer-term tests:
|
||||
|
||||
- Release workflow dry-run or artifact validation tests.
|
||||
- Migration tests for profile schema changes.
|
||||
|
||||
## 6. Recommended New Functionality
|
||||
|
||||
Add a direct-VNC safety warning and a per-profile “prefer SSH tunnel” nudge.
|
||||
|
||||
Add streaming drag-drop SFTP upload for larger files, or route drag-drop files above 25 MiB into the native upload flow.
|
||||
|
||||
Add import preview: list profile names, protocols, validation warnings, and duplicate handling before writing.
|
||||
|
||||
Add recording retention settings: max recording size, max age, and one-click cleanup.
|
||||
|
||||
Add security diagnostics in settings: dependency check status, known-host summary, direct insecure profile count, and last audit-log events.
|
||||
|
||||
## 7. Next-Level Opportunities
|
||||
|
||||
Release hardening:
|
||||
|
||||
- Sign and notarize release artifacts where platform-appropriate.
|
||||
- Publish checksums and provenance.
|
||||
- Pin or cache dependency sources in CI.
|
||||
|
||||
Observability:
|
||||
|
||||
- Add structured non-sensitive debug logs for import rejection, update checks, VNC proxy lifecycle, and SFTP transfer failures.
|
||||
- Add audit entries for validation failures that indicate suspicious imports, without storing payload contents.
|
||||
|
||||
Developer experience:
|
||||
|
||||
- Consolidate release/build dependency resolution into one reusable script.
|
||||
- Add a frontend lint rule or custom check for unsafe `innerHTML` with dynamic values.
|
||||
|
||||
Accessibility/product quality:
|
||||
|
||||
- Ensure modal error states are keyboard reachable and announced.
|
||||
- Add clearer status messages for blocked uploads and import validation failures.
|
||||
|
||||
## 8. Prioritised Action Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Add direct VNC warning copy and tests.
|
||||
- Add frontend file-drop XSS and oversize tests.
|
||||
- Add CI jobs for `go test ./...`, `npm run build`, `npm audit`, and govulncheck.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Replace deprecated `xterm` packages with maintained `@xterm/*` packages.
|
||||
- Build import preview and validation repair UI.
|
||||
- Add recording and RAW capture storage limits.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Implement streaming drag-drop upload.
|
||||
- Add release signing, checksums, and provenance.
|
||||
- Add schema-versioned profile import/export.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Build a security/settings dashboard for insecure profiles, known hosts, audit status, and update health.
|
||||
- Add comprehensive Wails end-to-end smoke coverage for core workflows.
|
||||
|
||||
## 9. Caveats and Validation Needed
|
||||
|
||||
The fixes were verified with automated local checks, but not with live Wails UI automation.
|
||||
|
||||
The new import validation may reject malformed legacy profiles that were previously accepted. That is intentional for safety, but the UX should help users repair old data.
|
||||
|
||||
The 25 MiB drag-drop upload cap is a guardrail, not a final large-file strategy. Streaming support would be the better long-term user experience.
|
||||
|
||||
Direct VNC was left open because some users need it on trusted networks; the next step is product warning and safer defaults, not silent removal.
|
||||
@@ -0,0 +1,752 @@
|
||||
# Security Audit Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix-mode audit reviewed Nexus Terminal, a Wails desktop client with a Go backend and JavaScript frontend for SSH, Telnet, RAW TCP, VNC, SFTP, GitHub, recordings, local shells, encrypted secrets, and profile import/export.
|
||||
|
||||
The highest-risk issues fixed in this run were unsafe profile persistence/import validation, a local DOM XSS trigger in the file-drop modal, memory-exhaustion paths in import and drag-drop upload flows, vulnerable dependency versions, and release tooling that installed unpinned build dependencies. I also removed the stale insecure SSH connection helper so future code cannot silently bypass host-key verification.
|
||||
|
||||
Post-fix verification passed: `go test ./...`, focused Go tests, `npm run build`, `npm audit --json`, `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`, `bash -n build.sh release.sh`, and `git diff --check`.
|
||||
|
||||
Residual risk remains where users intentionally choose insecure protocols or workflows, especially direct VNC without SSH tunnelling. No high-confidence secrets or private keys were found in the repository search performed during this audit.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
Reviewed code and configuration:
|
||||
|
||||
- Go backend under `internal/`: API bindings, sessions, SSH, VNC, SFTP, storage, secrets, export/import, validation, updater, recordings, local shell, GitHub client, raw and telnet clients.
|
||||
- Frontend JavaScript under `frontend/src/js`, especially file drop, terminal, settings, GitHub, profile, SFTP, VNC, notification, and import/export flows.
|
||||
- Dependency manifests and lockfiles: `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
|
||||
- Build/release configuration: `build.sh`, `release.sh`, `.github/workflows/release-builds.yml`, `wails.json`.
|
||||
- Existing tests under `internal/**/*_test.go`.
|
||||
|
||||
Not reviewed with live external testing:
|
||||
|
||||
- Real SSH/VNC/GitHub endpoints.
|
||||
- Packaged binaries and platform installers.
|
||||
- Manual UI testing inside a running Wails app.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
I used a two-pass review:
|
||||
|
||||
- Attack-surface mapping of Wails API methods, import/export paths, file handling, DOM sinks, dependency/build chain, protocol clients, local proxy behavior, and credential flows.
|
||||
- Defensive validation from source and tests, followed by smallest safe fixes with regression tests.
|
||||
|
||||
Tools/checks used:
|
||||
|
||||
- Static review with `rg`, direct source reads, and line evidence.
|
||||
- `go test ./...`.
|
||||
- Focused tests for API, storage, export/import, validation, SSH client, and sessions.
|
||||
- `npm audit --json`.
|
||||
- `npm run build`.
|
||||
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`.
|
||||
- `bash -n build.sh release.sh`.
|
||||
- `git diff --check`.
|
||||
|
||||
## 4. Risk Rating Method
|
||||
|
||||
Severity considered attacker position, exploit preconditions, likely impact, and whether sensitive credentials or code execution could be affected.
|
||||
|
||||
- Critical: practical remote compromise or credential disclosure with minimal preconditions.
|
||||
- High: credible dependency, credential, or supply-chain compromise.
|
||||
- Medium: user-assisted or renderer/local attack that can affect confidentiality, integrity, or availability.
|
||||
- Low: hardening, defense-in-depth, or intentionally insecure user-selected modes.
|
||||
- Informational: verified area, non-exploitable issue, or test gap.
|
||||
|
||||
Confidence is High where source evidence and regression checks directly support the finding.
|
||||
|
||||
## 5. Attack Surface Map
|
||||
|
||||
Entry points:
|
||||
|
||||
- Wails-bound Go API methods in `internal/api/api.go`.
|
||||
- Profile create/update/import/export and encrypted `.nexus` imports.
|
||||
- Drag-and-drop file upload/paste in `frontend/src/js/terminal.js`.
|
||||
- SSH, Telnet, RAW TCP, VNC, SFTP, GitHub API, and local shell sessions.
|
||||
- Local VNC WebSocket proxy on loopback.
|
||||
- Build/release automation and dependency installation.
|
||||
|
||||
Trust boundaries:
|
||||
|
||||
- Frontend renderer to Wails API.
|
||||
- User-selected/imported local files to backend parsers.
|
||||
- Saved/imported profiles to outbound network clients.
|
||||
- Remote hosts and GitHub/GitHub Enterprise APIs to frontend rendering.
|
||||
- Local filesystem/keychain/vault to application process.
|
||||
- CI/release environment to dependency registries and Go module proxy.
|
||||
|
||||
Attacker starting positions considered:
|
||||
|
||||
- Malicious imported profile bundle.
|
||||
- Crafted local file or filename dropped into the terminal.
|
||||
- Compromised renderer or malicious frontend input reaching Wails APIs.
|
||||
- Network attacker on SSH/VNC/GitHub paths.
|
||||
- Dependency registry or release-time supply-chain manipulation.
|
||||
|
||||
## 6. Findings Summary Table
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---:|---:|---|---|---|
|
||||
| SEC-20260615-001 | Medium | High | Profile persistence and imports accepted unsafe or malformed profiles | API, validation, import | Fixed |
|
||||
| SEC-20260615-002 | Medium | High | Import and drag-drop upload paths allowed memory exhaustion | Import/export, SFTP upload | Fixed |
|
||||
| SEC-20260615-003 | Medium | High | Dropped filenames were rendered through `innerHTML` | Frontend terminal file-drop modal | Fixed |
|
||||
| SEC-20260615-004 | Medium | High | Unused SSH helper still defaulted to insecure host-key verification | SSH client | Fixed |
|
||||
| SEC-20260615-005 | High | High | Dependency advisories affected `esbuild` and required `x/net` module versions | Dependencies | Fixed |
|
||||
| SEC-20260615-006 | Medium | High | Release automation installed unpinned build dependencies | CI/release supply chain | Fixed |
|
||||
| SEC-20260615-007 | Low | High | Direct VNC remains unencrypted when SSH tunnelling is disabled | VNC client | Open |
|
||||
|
||||
## 7. Detailed Findings
|
||||
|
||||
### Finding ID: SEC-20260615-001
|
||||
|
||||
#### Title
|
||||
|
||||
Profile persistence and imports accepted unsafe or malformed profiles
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, `CreateProfile`, `UpdateProfile`, `ImportProfiles`, `ImportProfilesEncrypted`, lines 94-131, 715-728, 1176-1189, 1754-1791.
|
||||
- `internal/validation/validation.go`, profile, GitHub, and VNC validation, lines 51-140 and 325-410.
|
||||
- `internal/storage/store.go`, `ImportProfiles`, lines 365-380.
|
||||
|
||||
#### Description
|
||||
|
||||
The API comment said profile creation validates input, but the Wails API previously persisted profiles without calling the validation package. Plain JSON import also decoded into `[]*models.Profile` and dereferenced entries without rejecting `null`. A malicious or corrupt import could therefore crash the app or store profiles with unsafe GitHub API base URLs that would later receive a saved or prompted token.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed code now calls `validation.ValidateProfile` before create/update and validates every imported profile before writing. GitHub API base URLs must be HTTPS unless loopback, must be absolute, and must not include URL credentials. Import strategy and null entries are rejected before persistence.
|
||||
|
||||
Regression tests were added in `internal/api/api_test.go` and `internal/storage/storage_test.go`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user imports a profile JSON containing a GitHub profile with `http://evil.example/api/v3` as the API base URL, or a `null` profile entry. Before the fix, malformed data could be saved or crash import handling. If the malicious GitHub profile were later used, the app could send a token to an attacker-controlled endpoint.
|
||||
|
||||
#### Impact
|
||||
|
||||
Possible token exposure through malicious profile configuration, app crash during import, and fragile persisted state.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Validate all Wails-bound persistence inputs and imported profiles before storage. Reject non-HTTPS remote GitHub API URLs and malformed entries.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented:
|
||||
|
||||
- `CreateProfile` and `UpdateProfile` call `validation.ValidateProfile`.
|
||||
- `ValidateGitHubConfig` rejects remote HTTP and URL credentials.
|
||||
- Import helpers reject oversized, null, or invalid entries before writes.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented:
|
||||
|
||||
- Unsafe GitHub base URL rejection.
|
||||
- Null import entry rejection.
|
||||
- Oversized JSON import rejection.
|
||||
- Storage-level null import entry rejection.
|
||||
- GitHub and VNC validation cases.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-20: Improper Input Validation.
|
||||
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor.
|
||||
- OWASP API Security Top 10: API3 Broken Object Property Level Authorization / unsafe object properties.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
GitHub Enterprise over non-loopback HTTP is now rejected. If a legitimate deployment needs HTTP, it should be placed behind local loopback or HTTPS.
|
||||
|
||||
### Finding ID: SEC-20260615-002
|
||||
|
||||
#### Title
|
||||
|
||||
Import and drag-drop upload paths allowed memory exhaustion
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`, plain import and SFTP drag-drop upload, lines 715-718 and 1336-1350.
|
||||
- `internal/exportimport/exportimport.go`, encrypted import file read, lines 37 and 131-144.
|
||||
- `frontend/src/js/terminal.js`, drag-drop file upload cap, lines 10 and 829-849.
|
||||
|
||||
#### Description
|
||||
|
||||
Several local file paths loaded attacker-controlled or user-selected content fully into memory without a size cap. Plain profile JSON import accepted arbitrary string size from the renderer. Encrypted `.nexus` import used `os.ReadFile` on the selected path. Drag-drop SFTP upload read each browser `File` into an `ArrayBuffer`, converted it to base64, and sent it to the backend, where it was decoded into another byte slice.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed code adds:
|
||||
|
||||
- 5 MiB cap for plain profile JSON import.
|
||||
- 10 MiB cap for encrypted `.nexus` import files.
|
||||
- 25 MiB frontend and backend caps for inline drag-drop SFTP uploads.
|
||||
|
||||
Regression tests cover oversized plain import, encrypted import, and backend upload payload rejection.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user drops or imports a very large crafted file. Before the fix, the renderer and backend could allocate multiple full copies, causing high memory pressure or an app crash.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local denial of service against the desktop application.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep bounded inline payload paths and prefer streaming APIs for larger transfers. The existing native SFTP upload path streams local files and should remain the recommended path for large uploads.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented size checks before decode/read where practical:
|
||||
|
||||
- `base64.StdEncoding.DecodedLen` guard before decoding drag-drop upload payloads.
|
||||
- `os.Stat` size check before encrypted import `os.ReadFile`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented:
|
||||
|
||||
- `TestImportProfilesRejectsOversizedJSON`.
|
||||
- `TestDecryptRejectsOversizedFile`.
|
||||
- `TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-400: Uncontrolled Resource Consumption.
|
||||
- OWASP ASVS 12.4: file and resource limits.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The caps are conservative application-level limits. Larger profile bundles or drag-drop uploads now require a streaming implementation or the existing native SFTP upload flow.
|
||||
|
||||
### Finding ID: SEC-20260615-003
|
||||
|
||||
#### Title
|
||||
|
||||
Dropped filenames were rendered through `innerHTML`
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/src/js/terminal.js`, file-drop modal, lines 720-742.
|
||||
|
||||
#### Description
|
||||
|
||||
The file-drop modal interpolated the dropped filename into an `innerHTML` template. Local files can have names containing HTML metacharacters. In a Wails desktop app, DOM script execution is higher impact than a normal static page because the renderer can call privileged Wails APIs.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed modal escapes the file-drop title with `escapeHtml(title)` before injecting it into the template.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user downloads or receives a file with a crafted name such as an HTML tag with an event handler, then drops it into a terminal. Before the fix, the modal could parse the filename as markup.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential renderer-side script execution and access to Wails-bound APIs from malicious DOM content.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Do not put user-controlled strings into `innerHTML`. Use `textContent` or escape before insertion.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `escapeHtml(title)` in the file-drop modal.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add a frontend interaction test that drops a file named with `<img ...>`-style metacharacters and asserts the modal text is rendered literally. Manual Wails/browser verification was not run in this audit.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-79: Improper Neutralization of Input During Web Page Generation.
|
||||
- OWASP ASVS 5.3: output encoding.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This fix covers the reviewed file-drop title sink. Other `innerHTML` templates were spot-checked; future work should add an automated DOM-sink lint or test pass.
|
||||
|
||||
### Finding ID: SEC-20260615-004
|
||||
|
||||
#### Title
|
||||
|
||||
Unused SSH helper still defaulted to insecure host-key verification
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/sshclient/sshclient.go`, `Connect`, lines 72-75.
|
||||
- `internal/sshclient/sshclient_test.go`, regression test, lines 12-19.
|
||||
|
||||
#### Description
|
||||
|
||||
Current production session code uses explicit host-key callbacks, and no remaining reviewed SSH/VNC path calls `ssh.InsecureIgnoreHostKey`. However, the internal `sshclient.Connect` helper still defaulted to an insecure host-key callback. That made future misuse easy.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The helper now fails closed and instructs callers to use `ConnectWithHostKeyCallback`. `rg` found no remaining `InsecureIgnoreHostKey` references after the change.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A future feature calls the convenience method instead of `ConnectWithHostKeyCallback`. Before the fix, that new path would silently accept any SSH host key.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential SSH machine-in-the-middle exposure if the helper were reused.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep host-key policy explicit on every SSH path. Do not provide insecure convenience APIs in internal packages.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented: `Connect` returns `sshclient: host key callback is required`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented `TestConnectRequiresHostKeyCallback`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-295: Improper Certificate Validation, analogous to SSH host-key validation.
|
||||
- OWASP ASVS 9.1.2: server authenticity validation.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This is a preventive fix. The reviewed production paths already used explicit callbacks.
|
||||
|
||||
### Finding ID: SEC-20260615-005
|
||||
|
||||
#### Title
|
||||
|
||||
Dependency advisories affected `esbuild` and required `x/net` module versions
|
||||
|
||||
#### Severity
|
||||
|
||||
High
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/package.json`, `esbuild`, line 22.
|
||||
- `frontend/package-lock.json`, `esbuild` and platform packages.
|
||||
- `go.mod`, `golang.org/x/net`, line 15.
|
||||
- `go.sum`.
|
||||
|
||||
#### Description
|
||||
|
||||
`npm audit` reported `esbuild 0.28.0` affected by a high-severity advisory and a low-severity advisory. `govulncheck -show verbose` reported six vulnerabilities in required `golang.org/x/net v0.54.0` modules, though no reachable symbols were called by this code.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed versions are:
|
||||
|
||||
- `esbuild` bumped to `^0.28.1`.
|
||||
- `golang.org/x/net` bumped to `v0.55.0`.
|
||||
|
||||
Post-fix `npm audit --json` reports zero vulnerabilities and `govulncheck` reports `No vulnerabilities found.`
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
For `esbuild`, a compromised or manipulated package installation environment could exploit known build-tool weaknesses. For `x/net`, future code changes could start calling vulnerable symbols if the module stayed on an affected version.
|
||||
|
||||
#### Impact
|
||||
|
||||
Supply-chain compromise risk during build/development and latent dependency risk.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep audited dependency patch versions current and include `npm audit` and `govulncheck` in regular release checks.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented package/module upgrades and lockfile updates.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented verification:
|
||||
|
||||
- `npm audit --json`.
|
||||
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`.
|
||||
- `go test ./...`.
|
||||
- `npm run build`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-494: Download of Code Without Integrity Check.
|
||||
- CWE-1104: Use of Unmaintained Third Party Components.
|
||||
- Go vulnerability database advisories for `golang.org/x/net`.
|
||||
- GitHub Advisory Database for `esbuild`.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The `x/net` advisories were not reachable before the bump, according to govulncheck. The upgrade removes the latent module-level finding anyway.
|
||||
|
||||
### Finding ID: SEC-20260615-006
|
||||
|
||||
#### Title
|
||||
|
||||
Release automation installed unpinned build dependencies
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `.github/workflows/release-builds.yml`, Wails and npm install steps, lines 66-70.
|
||||
- `release.sh`, Wails and npm install logic, lines 76-99.
|
||||
|
||||
#### Description
|
||||
|
||||
The release workflow installed the Wails CLI with `@latest` and used `npm install` rather than the lockfile-enforcing `npm ci`. Local release scripting also instructed or performed unpinned dependency installation in some paths.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The fixed workflow installs the Wails CLI at the version resolved from `go.mod` and uses `npm ci`. `release.sh` now installs the pinned Wails CLI version from `go.mod` when needed and uses `npm ci` when `frontend/package-lock.json` exists.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A release build runs after a compromised or breaking upstream release, or after lockfile drift. Before the fix, release outputs could include unreviewed dependency changes.
|
||||
|
||||
#### Impact
|
||||
|
||||
Build reproducibility loss and supply-chain risk in release artifacts.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Use pinned module versions and lockfile-enforced installs in CI/release paths.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented:
|
||||
|
||||
- `go install ...@$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v2)`.
|
||||
- `npm ci` in CI and release script.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Implemented:
|
||||
|
||||
- `bash -n build.sh release.sh`.
|
||||
- Frontend build after `npm ci`.
|
||||
|
||||
#### References
|
||||
|
||||
- SLSA build integrity principles.
|
||||
- CWE-494.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The workflow still allows network dependency retrieval during CI. Stronger controls would include dependency caching, provenance, and artifact signing.
|
||||
|
||||
### Finding ID: SEC-20260615-007
|
||||
|
||||
#### Title
|
||||
|
||||
Direct VNC remains unencrypted when SSH tunnelling is disabled
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/sessions/manager.go`, direct VNC branch, lines 1012-1062.
|
||||
- `internal/vncclient/vncclient.go`, direct TCP dial, lines 139-144.
|
||||
|
||||
#### Description
|
||||
|
||||
When VNC SSH tunnelling is disabled, the client makes a direct TCP VNC connection. Classic VNC deployments often provide weak or no transport encryption.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The VNC client intentionally supports both SSH-tunnelled and direct modes. The direct branch uses `net.DialTimeout` to the VNC address.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user connects directly to VNC over an untrusted network. A network attacker observes or tampers with remote desktop traffic, depending on the VNC server's negotiated security type.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential confidentiality and integrity loss for remote desktop sessions.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Keep direct VNC available for trusted networks, but clearly warn users when SSH tunnelling is disabled and consider requiring explicit confirmation for saved direct-VNC profiles.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Prefer enabling `vnc.sshTunnel` and verifying the SSH host key for the tunnel host.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Add a UI test that direct VNC profiles show a warning, and an integration test that tunnelled VNC requires a host-key callback.
|
||||
|
||||
#### References
|
||||
|
||||
- OWASP ASVS 9.1: secure channel requirements.
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
This was not changed in this run because direct VNC can be a deliberate user choice for isolated networks.
|
||||
|
||||
## 8. Blackhat-Style Abuse Paths
|
||||
|
||||
Path A: malicious profile import to token exfiltration, now blocked.
|
||||
|
||||
1. Provide a profile bundle with a GitHub API base URL pointing to attacker infrastructure.
|
||||
2. User imports and connects.
|
||||
3. App prompts for or retrieves a token and sends it to the configured endpoint.
|
||||
4. Defensive breakpoints now added: profile validation, HTTPS requirement for remote GitHub APIs, import validation before storage.
|
||||
|
||||
Path B: crafted dropped filename to renderer script execution, now blocked.
|
||||
|
||||
1. User receives a file with HTML metacharacters in the filename.
|
||||
2. User drops it into a terminal.
|
||||
3. Filename is rendered in the file-drop modal.
|
||||
4. Defensive breakpoint now added: title is escaped before `innerHTML` insertion.
|
||||
|
||||
Path C: oversized file to app memory exhaustion, now blocked for reviewed inline paths.
|
||||
|
||||
1. User imports or drops an extremely large file.
|
||||
2. Renderer/backend allocate full file contents and base64 copies.
|
||||
3. App becomes slow or crashes.
|
||||
4. Defensive breakpoints now added: import and inline upload size caps.
|
||||
|
||||
Path D: direct VNC over hostile network, still open by design.
|
||||
|
||||
1. User disables SSH tunnel and connects to a VNC server over an untrusted network.
|
||||
2. Network attacker observes/tampers with VNC traffic if protocol security is weak.
|
||||
3. Defensive breakpoint: use SSH tunnel; future UI warning recommended.
|
||||
|
||||
## 9. Denial-of-Service Review
|
||||
|
||||
Fixed DoS controls:
|
||||
|
||||
- Plain profile import now rejects payloads over 5 MiB.
|
||||
- Encrypted profile import now rejects files over 10 MiB before `os.ReadFile`.
|
||||
- Drag-drop SFTP upload now rejects files over 25 MiB in the frontend and backend before base64 decode.
|
||||
- Null profile entries no longer panic import paths.
|
||||
|
||||
Checked and not found:
|
||||
|
||||
- SQL queries use parameters for profile and audit operations reviewed.
|
||||
- GitHub repo fetch has timeouts and caps repository listing at 5000 repos.
|
||||
- SSH/Telnet/RAW connection setup uses dial timeouts.
|
||||
- VNC local proxy binds to loopback and uses a per-session token in the current code.
|
||||
|
||||
Residual DoS risks:
|
||||
|
||||
- RAW session history and terminal recordings can grow with long sessions.
|
||||
- SFTP directory listing still depends on remote server behavior and could return very large directories.
|
||||
- Direct VNC proxy bridges long-lived streams by design.
|
||||
|
||||
## 10. Dependency and Supply-Chain Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- `esbuild` upgraded to `^0.28.1`; `npm audit --json` now reports zero vulnerabilities.
|
||||
- `golang.org/x/net` upgraded to `v0.55.0`; govulncheck now reports no vulnerabilities.
|
||||
- Release workflow uses `npm ci` and pinned Wails CLI version.
|
||||
- `release.sh` uses `npm ci` when a lockfile exists and installs the Wails CLI at the version from `go.mod`.
|
||||
|
||||
Checked:
|
||||
|
||||
- `go.mod`, `go.sum`, `frontend/package.json`, `frontend/package-lock.json`.
|
||||
- `.github/workflows/release-builds.yml`, `build.sh`, `release.sh`.
|
||||
|
||||
Notes:
|
||||
|
||||
- npm warns that `xterm` packages are deprecated in favor of the newer `@xterm/*` packages. That is not an active vulnerability from `npm audit`, but it is a maintenance risk.
|
||||
|
||||
## 11. Secrets and Sensitive Data Review
|
||||
|
||||
Checked:
|
||||
|
||||
- Repository search for private key markers, common cloud tokens, GitHub token prefixes, and obvious password assignments.
|
||||
- Secret store uses AES-GCM with Argon2id-derived keys and writes vault data with `0600`.
|
||||
- Profile export redacts inline SSH proxy/TOTP secrets when non-secret export mode is used.
|
||||
|
||||
No high-confidence committed secrets or private keys were found.
|
||||
|
||||
Residual notes:
|
||||
|
||||
- GitHub and remote connection tokens are still available to the running app process by design.
|
||||
- Audit logging should continue to avoid logging raw secrets; reviewed audit messages do not include secret values.
|
||||
|
||||
## 12. Authentication and Authorisation Review
|
||||
|
||||
This is a desktop app, so there are no multi-user server authz roles in the reviewed code.
|
||||
|
||||
Checked/fixed:
|
||||
|
||||
- SSH host-key bypass helper removed by making `sshclient.Connect` fail closed.
|
||||
- Current SSH and VNC tunnel paths use explicit host-key callbacks.
|
||||
- GitHub API base URL validation now reduces token leakage to remote HTTP endpoints.
|
||||
|
||||
Residual:
|
||||
|
||||
- Users can still choose insecure protocols/modes such as direct VNC or legacy Telnet/RAW TCP.
|
||||
|
||||
## 13. Input Validation and Output Encoding Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- API create/update now validates profiles before persistence.
|
||||
- Plain and encrypted import validate every profile before storage.
|
||||
- GitHub API URLs now require HTTPS except loopback and reject URL credentials.
|
||||
- VNC validation supports VNC-specific host fallback and validates tunnel fields.
|
||||
- File-drop filename is escaped before template insertion.
|
||||
|
||||
Checked:
|
||||
|
||||
- SSH key management already rejects path traversal key names with alphanumeric, hyphen, and underscore constraints.
|
||||
- SFTP dropped filenames use `filepath.Base` before remote path construction.
|
||||
|
||||
## 14. Frontend HTML/JavaScript Robustness Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- File-drop modal filename sink now uses `escapeHtml`.
|
||||
- Drag-drop upload rejects files over 25 MiB before FileReader allocation.
|
||||
|
||||
Checked:
|
||||
|
||||
- Notifications render messages with `textContent`.
|
||||
- GitHub repository card fields reviewed use `textContent` or escaping for remote metadata.
|
||||
|
||||
Not fully exercised:
|
||||
|
||||
- No live Wails UI/browser automation was run in this audit. Add frontend interaction tests for file-drop, import/export, and update UI in future work.
|
||||
|
||||
## 15. Infrastructure and Deployment Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- GitHub Actions release workflow now uses pinned Wails CLI and `npm ci`.
|
||||
- Local `release.sh` now uses pinned Wails CLI and `npm ci`.
|
||||
|
||||
Checked:
|
||||
|
||||
- No Docker, Kubernetes, Terraform, or cloud deployment config was present in the reviewed file list.
|
||||
- Wails config was reviewed for frontend build/install commands.
|
||||
|
||||
Residual:
|
||||
|
||||
- Release artifacts are not signed/notarized in the reviewed workflow.
|
||||
- CI workflow has `contents: write`, required for release attachment, but should remain limited to release jobs.
|
||||
|
||||
## 16. Logging, Monitoring, and Error Handling Review
|
||||
|
||||
Checked:
|
||||
|
||||
- Audit log records profile changes, import/export counts, connects/disconnects, and secret access without raw secret values.
|
||||
- Fixed import validation returns actionable errors rather than panics.
|
||||
- Frontend upload errors now surface a user-safe oversized-file message.
|
||||
|
||||
Residual:
|
||||
|
||||
- Some protocol errors are emitted directly to the UI. Continue avoiding credentials in error strings from remote services.
|
||||
- Consider adding structured debug logs for rejected imports without dumping payloads.
|
||||
|
||||
## 17. Test Coverage and Security Regression Gaps
|
||||
|
||||
Added/updated tests:
|
||||
|
||||
- API rejects unsafe GitHub base URL.
|
||||
- API rejects null and oversized imports.
|
||||
- API rejects oversized inline SFTP upload before session lookup.
|
||||
- Export/import rejects oversized encrypted files.
|
||||
- Storage import rejects null profile entries and invalid strategy before looping.
|
||||
- Validation covers GitHub URL and VNC host/tunnel cases.
|
||||
- SSH client `Connect` requires explicit host-key callback.
|
||||
|
||||
Remaining gaps:
|
||||
|
||||
- Browser/Wails interaction tests for file-drop XSS and upload cap.
|
||||
- Load tests for very large SFTP directories, RAW history, and recordings.
|
||||
- End-to-end import/export tests through the actual Wails UI.
|
||||
- Release workflow provenance/signing tests.
|
||||
|
||||
## 18. Prioritised Remediation Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Keep the fixed tests in CI.
|
||||
- Add `npm audit` and govulncheck to CI if not already present.
|
||||
- Warn users when saving or connecting direct VNC profiles without SSH tunnelling.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Replace deprecated `xterm` packages with maintained `@xterm/*` packages.
|
||||
- Add frontend tests for DOM injection via filenames and imported profile metadata.
|
||||
- Add streaming drag-drop upload support or route large drops through the native file upload path.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Add release artifact signing/notarization and checksums with provenance.
|
||||
- Add application-level limits for RAW history, terminal recording size, SFTP listing size, and GitHub error body reads.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Add a security-focused CI job: tests, `npm audit`, govulncheck, secret scanning, and static DOM-sink checks.
|
||||
- Introduce a profile schema version and migration/normalization layer for imports.
|
||||
|
||||
## 19. False Positives / Needs Manual Verification
|
||||
|
||||
- `govulncheck -show verbose` initially reported module-level `x/net` advisories that were not reachable. The module was still upgraded to remove latent risk.
|
||||
- Direct VNC is intentionally supported; risk depends on network trust and VNC server security type.
|
||||
- File-drop XSS was fixed by code inspection; manual Wails UI verification should still be added.
|
||||
- No live external service testing was performed.
|
||||
|
||||
## 20. Final Notes
|
||||
|
||||
The repo is in a materially better state after this run: profile data is validated before storage, local resource-consumption paths are bounded, the file-drop DOM sink is escaped, dependency scanners are clean, and release dependency installation is more reproducible.
|
||||
|
||||
The main residual security posture issue is user-selected insecure connectivity: Telnet/RAW/direct VNC remain useful but should be presented with clear safety context.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Functionality and Next Steps Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix pass moves Nexus closer to predictable large-file behavior: file-backed Leviathan documents stay file-backed for save/export, recordings parse incrementally, and unavoidable in-memory parsers now advertise and enforce clear limits.
|
||||
|
||||
The most useful next work is to surface these limits in the Leviathan UI and add user-facing guidance when an operation needs a smaller selection or a streaming implementation.
|
||||
|
||||
## 2. Current Functional Risks
|
||||
|
||||
Leviathan spectrogram conversion still uses bounded in-memory audio/image buffers because the current encoder/decoder APIs operate on byte slices. Very large images, MP3s, WAVs, or decoded MP3 output will now be rejected with an error rather than processed.
|
||||
|
||||
PDF, certificate, and key scanners still operate on bounded whole-document buffers for structural parsing. The cap avoids memory denial of service, but users working with very large firmware or document corpora may need range-based scans in future.
|
||||
|
||||
Recording playback is now line-streamed, but the returned API shape still collects parsed events before sending them to the frontend. Very long recordings are still protected by the playback cap; true progressive playback would require an API contract change.
|
||||
|
||||
## 3. Code Quality Improvements Completed
|
||||
|
||||
Chunk-first behavior:
|
||||
|
||||
- Leviathan working-copy save now writes with `Document.ForEachChunk`.
|
||||
- Leviathan case export now streams `working.bin` into the zip file on disk.
|
||||
- Recording playback parses line-by-line instead of reading and splitting the whole file.
|
||||
|
||||
Bounded read behavior:
|
||||
|
||||
- Spectrogram image/audio inputs reject oversize data before decode.
|
||||
- Image decode checks dimensions before allocating decoded pixels.
|
||||
- HTTP response helpers cap JSON and error bodies.
|
||||
- SSH key, VNC key, vault, public-key, PDF, and BinSnoop file reads have explicit limits.
|
||||
|
||||
Responsiveness:
|
||||
|
||||
- Heavy Leviathan spectrogram/export operations no longer hold the engine lock while processing or showing native save dialogs.
|
||||
|
||||
## 4. Frontend Interaction and Error Handling
|
||||
|
||||
Recommended UI follow-up:
|
||||
|
||||
- Show limit-aware messages for spectrogram tools, such as image max size, audio max size, and decoded image dimension limits.
|
||||
- For PDF/cert/key tools, show “scan a smaller file or selected range” guidance when caps are hit.
|
||||
- Add a small status line in Leviathan for whether an operation is streaming, bounded-buffer, or selection-based.
|
||||
- For recording playback, consider progressive playback loading so long recordings do not need all parsed events returned at once.
|
||||
|
||||
## 5. Testing Roadmap
|
||||
|
||||
Completed in this pass:
|
||||
|
||||
- Oversized spectrogram image dimensions are rejected before full decode.
|
||||
- Leviathan bounded document reads reject oversize documents.
|
||||
- Patched working-file writes preserve changed bytes.
|
||||
- Oversized GitHub/update responses are rejected.
|
||||
- Oversized SSH public keys, vault files, PDFs, and BinSnoop files are rejected.
|
||||
|
||||
Recommended next tests:
|
||||
|
||||
- Wails UI tests for Leviathan oversized image/audio/PDF error messages.
|
||||
- A case-export test that opens a sparse file-backed document and verifies the zip is produced without materializing `working.bin`.
|
||||
- A recording playback test with malformed lines and a long valid event stream.
|
||||
- Browser interaction tests for the Leviathan tab while a long analysis is running.
|
||||
|
||||
## 6. Recommended New Functionality
|
||||
|
||||
Add range-based versions of whole-document Leviathan tools:
|
||||
|
||||
- `keys.scanRange`
|
||||
- `certs.scanRange`
|
||||
- `pdf.quickScan` for headers/xref/trailer without full structural recovery
|
||||
- `spectrogram.renderRange` for selected audio windows
|
||||
|
||||
Add streaming media support later:
|
||||
|
||||
- Decode audio from file/range readers instead of complete byte slices.
|
||||
- Stream WAV output directly to disk for spectrogram generation.
|
||||
- Stream image insertion output to disk when carrier audio is large.
|
||||
|
||||
## 7. Caveats
|
||||
|
||||
The new caps trade permissiveness for stability. Files above the limits now fail fast instead of attempting best-effort processing.
|
||||
|
||||
The frontend build and regression checks passed, but I did not run a packaged Wails app manually in this pass.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Security Audit Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix-mode audit focused on file-reading and response-reading paths that could allocate complete files or unbounded remote bodies in memory.
|
||||
|
||||
The main risk fixed was local denial of service from large user-selected files, imported Leviathan artefacts, recordings, SSH key files, vault files, and remote update/GitHub API responses. Where the codebase already had a chunked document model, saves and exports now use it. Where a parser still requires a complete byte slice, the code now rejects oversized inputs before allocation.
|
||||
|
||||
Verification passed with targeted package tests, `go test ./...`, frontend regression tests, frontend build, and `git diff --check`.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
Reviewed and changed:
|
||||
|
||||
- Leviathan file-backed document save, case export, spectrogram image/audio conversion, PDF parsing, and BinSnoop file helpers.
|
||||
- Wails API helpers that read SSH public keys and Leviathan image/audio inputs.
|
||||
- SSH and VNC tunnel private-key loading.
|
||||
- GitHub client and update-checker HTTP response handling.
|
||||
- Recording playback parsing.
|
||||
- Encrypted secrets vault loading.
|
||||
|
||||
Not live-tested:
|
||||
|
||||
- A packaged Wails desktop binary.
|
||||
- Real remote SSH/VNC/GitHub endpoints.
|
||||
- Very large real-world media corpora beyond automated guard tests.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
I searched for `ReadFile`, `ReadAll`, and whole-document `Range(0, size)` patterns, then classified each as chunked, bounded, embedded/test-only, or unsafe. Fixes were kept to low-risk local changes:
|
||||
|
||||
- Prefer `Document.ForEachChunk` for writing/exporting working file bytes.
|
||||
- Stream line-oriented recordings with `bufio.Scanner`.
|
||||
- Limit HTTP response reads with `io.LimitReader`.
|
||||
- Add `os.Stat` size checks before unavoidable bounded `os.ReadFile` calls.
|
||||
- Add parser-specific caps before whole-document reads.
|
||||
|
||||
## 4. Findings Summary
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---:|---:|---|---|---|
|
||||
| SEC-20260616-001 | Medium | High | Leviathan save/export and spectrogram tools could materialize large files | Leviathan API, spectrogram | Fixed |
|
||||
| SEC-20260616-002 | Medium | High | Remote API/update responses were read without size limits | GitHub client, updater | Fixed |
|
||||
| SEC-20260616-003 | Medium | High | Local app data/key reads lacked size caps | SSH keys, VNC keys, vault, BinSnoop | Fixed |
|
||||
| SEC-20260616-004 | Low | High | Recording playback loaded the whole capped recording file | Recording playback | Fixed |
|
||||
| SEC-20260616-005 | Low | Medium | Some parsers still require complete bounded buffers | PDF, cert/key scanners, media decoders | Accepted with caps |
|
||||
|
||||
## 5. Detailed Findings
|
||||
|
||||
### SEC-20260616-001: Leviathan full-file allocation paths
|
||||
|
||||
Leviathan save/export and spectrogram operations could duplicate entire working files in memory. Case export also built the zip archive in memory before writing it.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/api/leviathan.go`
|
||||
- `internal/leviathan/spectrogram/spectrogram.go`
|
||||
- `internal/leviathan/pdf/pdf.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- Working-copy save writes via `Document.ForEachChunk`.
|
||||
- Case export streams `working.bin` directly into `zip.Writer` backed by the destination file.
|
||||
- Spectrogram encode/view/insert rejects oversized image/audio documents before bounded decode.
|
||||
- Image decoding checks compressed byte size and decoded pixel dimensions before full image decode.
|
||||
- PDF parsing rejects files above 128 MiB before whole-document structural parsing.
|
||||
- Long-running spectrogram/export tools no longer hold the Leviathan engine lock while doing heavy work or native save dialogs.
|
||||
|
||||
### SEC-20260616-002: Unbounded remote response reads
|
||||
|
||||
GitHub and update-check response bodies could be read without an explicit maximum.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/githubclient/githubclient.go`
|
||||
- `internal/updater/updater.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- GitHub JSON responses are capped at 4 MiB.
|
||||
- GitHub error bodies are capped at 64 KiB.
|
||||
- Update release/manifest responses are capped at 1 MiB.
|
||||
- Repository archive downloads continue to stream to disk instead of memory.
|
||||
|
||||
### SEC-20260616-003: Local key/vault/helper reads lacked caps
|
||||
|
||||
Several local helper paths read files directly even though the expected files are small.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/api/api.go`
|
||||
- `internal/sshclient/sshclient.go`
|
||||
- `internal/sessions/manager.go`
|
||||
- `internal/secrets/secrets.go`
|
||||
- `internal/leviathan/binsnoop/common.go`
|
||||
- `internal/leviathan/binsnoop/binsnoop.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- SSH public keys are capped at 64 KiB.
|
||||
- SSH private keys for SSH and VNC tunnel auth are capped at 1 MiB.
|
||||
- Secret vault files are capped at 10 MiB.
|
||||
- BinSnoop direct file helpers are capped at 64 MiB; the UI path already uses a bounded buffer.
|
||||
|
||||
### SEC-20260616-004: Recording playback whole-file read
|
||||
|
||||
Recording playback had a 100 MiB file cap, but still read and split the full file in memory.
|
||||
|
||||
Fixed in:
|
||||
|
||||
- `internal/recording/recorder.go`
|
||||
|
||||
Changes:
|
||||
|
||||
- Playback now opens the recording and scans line-by-line.
|
||||
- Individual playback lines are capped at 8 MiB.
|
||||
- Existing malformed-event tolerance is preserved.
|
||||
|
||||
## 6. Residual Risk
|
||||
|
||||
Some operations still need bounded complete buffers because downstream parsers or decoders require byte slices: image/audio spectrogram conversion, PDF structural parsing, certificate/key scanning, and small SSH/vault/public-key parsing. These now fail before allocation when inputs exceed explicit caps.
|
||||
|
||||
Truly streaming versions of every binary parser would be a larger Leviathan architecture project. The current fixes remove the unbounded allocation behavior while preserving tool behavior for normal files.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
Passed:
|
||||
|
||||
- `go test ./internal/api ./internal/leviathan/spectrogram ./internal/leviathan/pdf ./internal/leviathan/binsnoop ./internal/updater ./internal/githubclient ./internal/secrets ./internal/recording ./internal/sshclient ./internal/sessions -count=1`
|
||||
- `go test ./... -count=1`
|
||||
- `npm --prefix frontend test`
|
||||
- `npm --prefix frontend run build`
|
||||
- `git diff --check`
|
||||
|
||||
New tests cover oversized GitHub/update bodies, oversized spectrogram images, bounded Leviathan document reads, chunked patched-file writes, oversized public keys, oversized vault files, oversized PDFs, and BinSnoop file caps.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Functionality and Next Steps Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix pass improved robustness around local proxying, file imports, remote downloads, SSH key management, and frontend rendering. The app now fails earlier and more predictably when inputs are malformed or too large.
|
||||
|
||||
Highest-impact next steps:
|
||||
|
||||
- Manually verify packaged VNC connections after the new Origin allowlist.
|
||||
- Add UI copy for newly enforced limits.
|
||||
- Add CI coverage for the security checks run in this pass.
|
||||
- Continue frontend cleanup around dynamic `innerHTML`.
|
||||
|
||||
## 2. Current Functional Risks
|
||||
|
||||
VNC runtime compatibility:
|
||||
|
||||
- The backend now allows missing, `wails://`, and loopback HTTP(S) origins. This should work for Wails/noVNC, but packaged app validation is still needed.
|
||||
|
||||
Large-file and large-download behavior:
|
||||
|
||||
- GitHub archive downloads now stop at 2 GiB. Very large repositories will fail fast.
|
||||
- SSH config imports now reject files above 5 MiB or more than 5000 Host blocks.
|
||||
|
||||
SSH key generation:
|
||||
|
||||
- RSA key generation is limited to 2048, 3072, and 4096 bits and times out after 30 seconds.
|
||||
- Slow systems should be checked with RSA 4096 to ensure the timeout is not too tight.
|
||||
|
||||
Frontend:
|
||||
|
||||
- SSH key manager dynamic rendering is safer. Other static `innerHTML` templates remain and should be reviewed when they take dynamic data.
|
||||
|
||||
## 3. Code Quality Improvements
|
||||
|
||||
Completed:
|
||||
|
||||
- Added focused helper functions for VNC Origin validation.
|
||||
- Centralized GitHub endpoint construction and owner/repo parsing.
|
||||
- Added reusable archive stream limiting.
|
||||
- Added backend SSH key generation option validation.
|
||||
- Added import bounds to the SSH config parser.
|
||||
- Replaced SSH key manager dynamic modal HTML with DOM construction.
|
||||
|
||||
Recommended refactors:
|
||||
|
||||
- Move file-size and stream-size caps into named settings or a shared limits package if more subsystems need them.
|
||||
- Add a small frontend helper for safe modal construction so feature code stops hand-rolling modal `innerHTML`.
|
||||
- Consider a common backend "safe basename" helper for local and remote file operations.
|
||||
|
||||
## 4. Frontend Interaction and Error-Handling Improvements
|
||||
|
||||
Recommended:
|
||||
|
||||
- Show a specific VNC connection error if the proxy rejects an unexpected Origin in packaged builds.
|
||||
- Show clear messages for SSH config files that exceed size or Host-block limits.
|
||||
- Show clear messages for GitHub archives above the cap.
|
||||
- Add a hostile-filename E2E test for the SSH key delete confirmation modal.
|
||||
- Continue converting dynamic modal content to DOM nodes or `textContent`.
|
||||
|
||||
## 5. Testing Roadmap
|
||||
|
||||
Added in this pass:
|
||||
|
||||
- VNC Origin allowlist tests.
|
||||
- SFTP upload parent-directory rejection tests.
|
||||
- SSH config file and Host-block limit tests.
|
||||
- SSH key generation option validation tests.
|
||||
- GitHub path escaping and archive cap tests.
|
||||
- Frontend regression checks for SSH key manager text rendering.
|
||||
|
||||
Next tests:
|
||||
|
||||
- Packaged Wails VNC smoke test on macOS, Windows, and Linux.
|
||||
- Browser/E2E test for SSH key filenames containing `<`, `>`, quotes, and ampersands.
|
||||
- HTTP integration test for GitHub archive download caps and escaped branch refs.
|
||||
- UI tests for limit-aware error messages.
|
||||
- CI job that runs Go tests, frontend tests, frontend build, `npm audit --omit=dev`, `govulncheck`, and `git diff --check`.
|
||||
|
||||
## 6. Recommended New Functionality
|
||||
|
||||
Useful additions:
|
||||
|
||||
- Preferences for maximum GitHub archive download size.
|
||||
- A pre-download confirmation for large GitHub archives when Content-Length is known.
|
||||
- Import preview for OpenSSH config showing Host count before persistence.
|
||||
- A diagnostics panel for VNC proxy status, including local URL redaction, Origin decisions, and connection state.
|
||||
- A unified "Limits" help page that lists SFTP upload caps, GitHub archive cap, SSH config import cap, and Leviathan file caps.
|
||||
|
||||
## 7. Next-Level Opportunities
|
||||
|
||||
Architecture:
|
||||
|
||||
- Centralize resource limits and expose them to both Go and frontend UI copy.
|
||||
- Add a secure modal builder and migrate risky frontend dialogs gradually.
|
||||
- Add structured security regression tests for every Wails method that accepts file paths, filenames, URLs, or base64 payloads.
|
||||
|
||||
Operations:
|
||||
|
||||
- Generate checksums for release artifacts.
|
||||
- Generate an SBOM during release.
|
||||
- Publish a short security hardening checklist in `docs/build-and-release.md`.
|
||||
|
||||
Developer experience:
|
||||
|
||||
- Add a `make security-check` target that runs the same commands used in this audit.
|
||||
- Add pre-commit or CI checks for `gofmt`, `go test`, frontend regression tests, and `git diff --check`.
|
||||
|
||||
## 8. Prioritised Action Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Manually test packaged VNC on all supported platforms.
|
||||
- Keep the security patch tests in CI before release.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Add user-facing messages for the new SSH config, GitHub archive, and SSH keygen limits.
|
||||
- Add E2E coverage for hostile SSH key filenames.
|
||||
- Add an HTTP test around GitHub archive download behavior.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Centralize app resource limits and expose them to UI/help.
|
||||
- Migrate high-risk frontend dynamic HTML patterns to safe modal helpers.
|
||||
- Add release checksums and SBOM generation.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Add progressive/streaming UX for large GitHub and SFTP transfers.
|
||||
- Build a cross-platform packaged-app smoke test suite for Wails integrations.
|
||||
|
||||
## 9. Caveats and Validation Needed
|
||||
|
||||
- VNC Origin compatibility must be validated in packaged builds, not only unit tests.
|
||||
- The 2 GiB GitHub archive cap may need product tuning for users with very large repositories.
|
||||
- RSA 4096 key generation timeout should be checked on lower-powered machines.
|
||||
- The frontend build passed, but this pass did not interactively click through the SSH key modal in a real browser/WebView.
|
||||
@@ -0,0 +1,641 @@
|
||||
# Security Audit Report
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This fix-mode audit reviewed the Wails API surface, local proxy behavior, file import paths, SSH key management, GitHub integration, frontend DOM sinks, dependency manifests, tests, and release workflow.
|
||||
|
||||
Six concrete issues were fixed:
|
||||
|
||||
- The local VNC WebSocket proxy accepted any browser `Origin` and did not cap client-to-proxy WebSocket message size.
|
||||
- SFTP browser uploads accepted the special filename `..`, allowing a Wails caller to escape the selected remote directory when the name was joined.
|
||||
- SSH config import parsed line-by-line but had no total file or Host-block cap.
|
||||
- Backend SSH key generation trusted caller-supplied key type and RSA bit count and had no process timeout.
|
||||
- GitHub repo/ref values were interpolated into API paths without escaping, and archive downloads had no explicit stream cap.
|
||||
- SSH key manager UI interpolated backend errors and filesystem-derived key names into `innerHTML`.
|
||||
|
||||
Verification passed with targeted Go tests, full Go tests, frontend regression checks, frontend build, `npm audit --omit=dev`, `govulncheck`, and `git diff --check`.
|
||||
|
||||
## 2. Scope Reviewed
|
||||
|
||||
Reviewed:
|
||||
|
||||
- Go backend packages under `internal/`: API bindings, VNC proxy, GitHub client, SSH config import, SSH key management, SFTP upload helpers, validation, sessions, storage, secrets, updater, recording, Leviathan parser bounds, and tests.
|
||||
- Browser-facing JavaScript under `frontend/src/js`, especially SSH key management, SFTP upload flows, VNC integration, and dynamic HTML usage.
|
||||
- Frontend package manifest, lockfile, build scripts, and regression script.
|
||||
- Go module dependencies and reachable vulnerability state.
|
||||
- GitHub Actions release workflow.
|
||||
|
||||
Not fully live-tested:
|
||||
|
||||
- A packaged Wails desktop binary.
|
||||
- Real noVNC connections inside the packaged WebView after the new Origin policy.
|
||||
- Real GitHub Enterprise archive downloads above normal repository sizes.
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
The review used static analysis, targeted source searches, dependency/advisory tooling, and focused regression tests. I mapped attacker-controlled inputs from the renderer and local files into Go methods, network clients, local HTTP/WebSocket services, subprocesses, remote SFTP paths, and DOM sinks. I prioritized issues that could be fixed with small behavior-preserving changes and backed by tests.
|
||||
|
||||
Commands run:
|
||||
|
||||
- `go test ./internal/vncclient ./internal/api ./internal/sshconfig ./internal/githubclient -count=1`
|
||||
- `npm --prefix frontend test`
|
||||
- `git diff --check`
|
||||
- `go test ./... -count=1`
|
||||
- `npm --prefix frontend run build`
|
||||
- `npm --prefix frontend audit --omit=dev`
|
||||
- `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`
|
||||
|
||||
## 4. Risk Rating Method
|
||||
|
||||
Severity reflects exploit prerequisite, affected trust boundary, likely impact, and availability/confidentiality/integrity outcome:
|
||||
|
||||
- Critical: remote unauthenticated compromise or secret disclosure.
|
||||
- High: practical compromise of credentials, host access, or arbitrary code execution.
|
||||
- Medium: meaningful local, renderer, remote service, or connected-server abuse with realistic prerequisites.
|
||||
- Low: local robustness issues, bounded DoS, or defense-in-depth gaps.
|
||||
- Informational: observations and hardening opportunities.
|
||||
|
||||
Confidence reflects direct code evidence and testability. Findings fixed here are High confidence because they map to concrete files and new regression tests.
|
||||
|
||||
## 5. Attack Surface Map
|
||||
|
||||
Entry points:
|
||||
|
||||
- Wails-bound `api.Service` methods callable from the renderer.
|
||||
- Local VNC WebSocket proxy bound to `127.0.0.1` on a random port and random token path.
|
||||
- SSH, Telnet, RAW TCP, VNC, SFTP, GitHub, updater, and Leviathan binary-analysis workflows.
|
||||
- Profile import/export, encrypted `.nexus` import, OpenSSH config import, recording playback, SSH key generation/deletion, and drag/drop upload flows.
|
||||
|
||||
Trust boundaries:
|
||||
|
||||
- Renderer to Go backend.
|
||||
- Local browser/WebView to loopback VNC proxy.
|
||||
- Local filesystem files selected or scanned by the app.
|
||||
- Remote SSH/SFTP/VNC/GitHub/update endpoints.
|
||||
- OS keychain/encrypted vault to profile storage and session runtime.
|
||||
|
||||
Attacker starting positions considered:
|
||||
|
||||
- Malicious local webpage attempting loopback WebSocket access.
|
||||
- Malicious or compromised renderer script.
|
||||
- Malicious local filename or selected config/import file.
|
||||
- Malicious GitHub Enterprise/API endpoint or unusual repo/ref metadata.
|
||||
- Malicious connected SSH/SFTP/VNC host.
|
||||
|
||||
## 6. Findings Summary Table
|
||||
|
||||
| ID | Severity | Confidence | Title | Component | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| SEC-20260616-1702-001 | Medium | High | VNC proxy allowed all origins and uncapped WebSocket frames | `internal/vncclient` | Fixed |
|
||||
| SEC-20260616-1702-002 | Medium | High | SFTP upload name `..` escaped selected remote directory | `internal/api` SFTP upload helpers | Fixed |
|
||||
| SEC-20260616-1702-003 | Low | High | SSH config import lacked total file and Host-block bounds | `internal/sshconfig` | Fixed |
|
||||
| SEC-20260616-1702-004 | Low | High | SSH key generation allowed expensive caller-controlled options without timeout | `internal/api` SSH key management | Fixed |
|
||||
| SEC-20260616-1702-005 | Medium | High | GitHub repo/ref path interpolation and uncapped archive stream | `internal/githubclient` | Fixed |
|
||||
| SEC-20260616-1702-006 | Medium | High | SSH key manager rendered key names/errors through dynamic `innerHTML` | `frontend/src/js/sshkeys.js` | Fixed |
|
||||
|
||||
## 7. Detailed Findings
|
||||
|
||||
### Finding ID: SEC-20260616-1702-001
|
||||
|
||||
#### Title
|
||||
|
||||
VNC proxy allowed all origins and uncapped WebSocket frames
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/vncclient/vncclient.go`
|
||||
- `internal/vncclient/vncclient_test.go`
|
||||
- Local noVNC WebSocket proxy
|
||||
|
||||
#### Description
|
||||
|
||||
The VNC proxy was bound to loopback and protected by a random token path, but its Gorilla WebSocket upgrader accepted every `Origin`. It also read WebSocket messages without an explicit message size limit. If a proxy URL token leaked through logs, UI state, injected renderer code, or another local channel, a non-local browser origin could attempt to drive the active VNC TCP connection or send oversized frames that force memory allocation.
|
||||
|
||||
#### Evidence
|
||||
|
||||
Before the fix, `websocket.Upgrader.CheckOrigin` returned `true` for every request, and the proxy called `ws.ReadMessage()` without `SetReadLimit`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A malicious local webpage or injected renderer script obtains an active `ws://127.0.0.1:<port>/<token>` URL. Because all origins were accepted, it could connect from a non-app origin and send very large WebSocket messages or VNC protocol bytes into the bridged TCP connection.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential local cross-origin abuse of active VNC sessions if the token is exposed, and avoidable memory pressure from oversized client-to-proxy WebSocket frames.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Allow only Wails and loopback browser origins, keep accepting missing origins for non-browser clients, and cap inbound WebSocket message size.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `allowProxyOrigin`, `isLoopbackHost`, and `ws.SetReadLimit(maxWebSocketMessageBytes)`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added `TestAllowProxyOrigin` for missing, Wails, loopback, remote, lookalike, and malformed origins.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-346: Origin Validation Error
|
||||
- CWE-400: Uncontrolled Resource Consumption
|
||||
- OWASP ASVS 14.4: HTTP security configuration
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The packaged Wails WebView should be manually tested to confirm its runtime Origin is covered by the `wails://` allow rule.
|
||||
|
||||
### Finding ID: SEC-20260616-1702-002
|
||||
|
||||
#### Title
|
||||
|
||||
SFTP upload name `..` escaped selected remote directory
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`
|
||||
- `internal/api/api_test.go`
|
||||
- `SFTPUploadData`
|
||||
- `SFTPUploadStart`
|
||||
|
||||
#### Description
|
||||
|
||||
`safeSFTPUploadName` normalized slash variants and used `path.Base`, but did not reject the special basename `..`. `path.Join(remoteDir, "..")` resolves to the parent directory, so a Wails caller could escape the selected upload directory.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The previous rejection covered empty, `.`, `/`, and NUL, but not `..`. The upload methods then used `path.Join(remoteDir, safeName)`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A malicious renderer call invokes chunked upload with `remoteDir="/home/user/upload"` and `fileName=".."`. The destination path resolves to `/home/user`, outside the selected directory.
|
||||
|
||||
#### Impact
|
||||
|
||||
Unexpected remote writes/truncation attempts outside the user's selected SFTP directory, depending on server permissions and target type.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Reject `..` after basename normalization.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `safeName == ".."` rejection.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added `TestSafeSFTPUploadNameRejectsParentDirectory`.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-22: Path Traversal
|
||||
- OWASP ASVS 12.3: File path control
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The browser `File.name` API normally does not produce `..`, but Wails API methods are still a trust boundary and should validate independently.
|
||||
|
||||
### Finding ID: SEC-20260616-1702-003
|
||||
|
||||
#### Title
|
||||
|
||||
SSH config import lacked total file and Host-block bounds
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/sshconfig/sshconfig.go`
|
||||
- `internal/sshconfig/sshconfig_test.go`
|
||||
- `ImportSSHConfig`
|
||||
|
||||
#### Description
|
||||
|
||||
The OpenSSH config parser scanned files line-by-line, but it did not cap total file size or the number of accumulated Host blocks. A very large selected config could allocate a large slice of parsed blocks and profiles.
|
||||
|
||||
#### Evidence
|
||||
|
||||
`ParseFile` opened the file and appended Host blocks to an in-memory slice until EOF.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A user imports a hostile or accidentally huge SSH config containing thousands of Host blocks. The app spends unnecessary CPU and memory parsing and converting entries.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local denial of service or UI unresponsiveness during import.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Reject oversized config files, set a scanner line cap, and reject excessive Host-block counts.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `maxConfigFileBytes`, `maxConfigLineBytes`, and `maxHostBlocks`.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added oversized-file and excessive-Host-block tests.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-400: Uncontrolled Resource Consumption
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The selected caps are conservative for normal OpenSSH config files. Extremely large enterprise configs may need batch import support later.
|
||||
|
||||
### Finding ID: SEC-20260616-1702-004
|
||||
|
||||
#### Title
|
||||
|
||||
SSH key generation allowed expensive caller-controlled options without timeout
|
||||
|
||||
#### Severity
|
||||
|
||||
Low
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/api/api.go`
|
||||
- `internal/api/api_test.go`
|
||||
- `GenerateSSHKey`
|
||||
|
||||
#### Description
|
||||
|
||||
The frontend exposes fixed key-type and bit-size choices, but the backend method accepted caller-supplied values directly and ran `ssh-keygen` without a context timeout. A malformed renderer call could request unsupported types or excessive RSA bit sizes, tying up the subprocess.
|
||||
|
||||
#### Evidence
|
||||
|
||||
`GenerateSSHKey` passed `keyType` to `ssh-keygen -t` and only checked `bits > 0` for RSA before adding `-b`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A malicious renderer call requests a very large RSA key size. `ssh-keygen` consumes CPU for an extended period or fails slowly.
|
||||
|
||||
#### Impact
|
||||
|
||||
Local CPU denial of service and poor recovery if the subprocess hangs.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Validate allowed key types and RSA sizes server-side, and execute `ssh-keygen` with a timeout.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `validateSSHKeyGenerationOptions` and `exec.CommandContext` with a 30 second timeout.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added validation tests for defaults, RSA defaults, oversized RSA sizes, and unsupported key types.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-400: Uncontrolled Resource Consumption
|
||||
- CWE-20: Improper Input Validation
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The timeout is a safety net, not a substitute for platform-level process controls. Real key generation should still be manually tested on slower hardware.
|
||||
|
||||
### Finding ID: SEC-20260616-1702-005
|
||||
|
||||
#### Title
|
||||
|
||||
GitHub repo/ref path interpolation and uncapped archive stream
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `internal/githubclient/githubclient.go`
|
||||
- `internal/githubclient/githubclient_test.go`
|
||||
- `FetchLatestCommit`
|
||||
- `DownloadRepoArchive`
|
||||
|
||||
#### Description
|
||||
|
||||
Repository full names and refs were interpolated into REST API paths without escaping. The archive download also streamed response bodies to disk without an explicit maximum. A malformed renderer call, unusual branch name, or compromised GitHub Enterprise endpoint could create surprising request paths or consume disk.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The client built paths with `fmt.Sprintf("%s/repos/%s/commits/%s", ...)` and copied archives with `io.Copy(file, resp.Body)`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A renderer calls archive download with a malformed repo full name containing extra path segments, or a malicious GitHub Enterprise endpoint returns an unbounded archive response.
|
||||
|
||||
#### Impact
|
||||
|
||||
Potential confused API requests using the user's GitHub token and local disk exhaustion from unbounded archive streams.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Require `owner/repo` format, URL-escape each API path segment, and cap archive streams.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented `splitRepoFullName`, `endpointURL`, `copyLimitedArchive`, and a 2 GiB archive cap.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added tests for escaped refs, malformed repo names, archive limit rejection, and at-limit copies.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-20: Improper Input Validation
|
||||
- CWE-22: Path Traversal style path confusion
|
||||
- CWE-400: Uncontrolled Resource Consumption
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
The 2 GiB archive cap may reject unusually large repositories. A future preferences setting could make this user-configurable.
|
||||
|
||||
### Finding ID: SEC-20260616-1702-006
|
||||
|
||||
#### Title
|
||||
|
||||
SSH key manager rendered key names/errors through dynamic `innerHTML`
|
||||
|
||||
#### Severity
|
||||
|
||||
Medium
|
||||
|
||||
#### Confidence
|
||||
|
||||
High
|
||||
|
||||
#### Affected Files / Components
|
||||
|
||||
- `frontend/src/js/sshkeys.js`
|
||||
- `frontend/scripts/test-regressions.js`
|
||||
- SSH key manager modal
|
||||
|
||||
#### Description
|
||||
|
||||
The SSH key manager interpolated backend error text and confirmation title/message/button text into `innerHTML`. Key names are derived from local `~/.ssh/*.pub` filenames, so a malicious local filename could inject HTML into the delete confirmation modal.
|
||||
|
||||
#### Evidence
|
||||
|
||||
The previous code used `list.innerHTML = `<div ...>${err}</div>`` and built confirmation modal HTML with `${title}`, `${message}`, and `${confirmLabel}`.
|
||||
|
||||
#### Attack Scenario
|
||||
|
||||
A local attacker creates an SSH public key file with an HTML-like filename. When the user opens the key manager and deletes it, the filename is interpolated into modal HTML.
|
||||
|
||||
#### Impact
|
||||
|
||||
DOM XSS in the Wails renderer, with possible access to app UI actions exposed to the renderer.
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Render dynamic strings with `textContent` or DOM node construction.
|
||||
|
||||
#### Safer Example
|
||||
|
||||
Implemented text-node rendering for key load errors and confirmation modal dynamic text.
|
||||
|
||||
#### Suggested Tests
|
||||
|
||||
Added frontend regression checks to prevent the vulnerable interpolation pattern from returning.
|
||||
|
||||
#### References
|
||||
|
||||
- CWE-79: Cross-Site Scripting
|
||||
- OWASP ASVS 5.3: Output encoding
|
||||
|
||||
#### Assumptions / Limitations
|
||||
|
||||
Other static-template `innerHTML` use remains in the frontend and should continue to be reviewed when it accepts dynamic values.
|
||||
|
||||
## 8. Blackhat-Style Abuse Paths
|
||||
|
||||
Path 1: Token leak to VNC abuse
|
||||
|
||||
- Prerequisite: an active VNC proxy URL leaks to a non-app webpage or injected script.
|
||||
- Old path: any Origin could connect and send arbitrary WebSocket frames to the VNC TCP bridge.
|
||||
- Breakpoint added: Wails/loopback Origin allowlist plus message cap.
|
||||
|
||||
Path 2: Renderer-to-remote path escape
|
||||
|
||||
- Prerequisite: malicious renderer code or direct Wails call.
|
||||
- Old path: call SFTP upload with filename `..`, causing `path.Join(remoteDir, "..")`.
|
||||
- Breakpoint added: `..` is rejected after basename normalization.
|
||||
|
||||
Path 3: Local filename to renderer script injection
|
||||
|
||||
- Prerequisite: crafted key filename in `~/.ssh`.
|
||||
- Old path: key name appears in confirmation modal HTML.
|
||||
- Breakpoint added: key names and errors use `textContent`.
|
||||
|
||||
Path 4: Malicious GitHub Enterprise response to disk pressure
|
||||
|
||||
- Prerequisite: user connects to compromised or malicious API base URL allowed by profile validation.
|
||||
- Old path: archive stream was copied until EOF.
|
||||
- Breakpoint added: Content-Length precheck and streaming cap.
|
||||
|
||||
## 9. Denial-of-Service Review
|
||||
|
||||
Fixed resource-control issues:
|
||||
|
||||
- VNC inbound WebSocket message cap.
|
||||
- SSH config file size, line size, and Host-block caps.
|
||||
- SSH key generation input validation and subprocess timeout.
|
||||
- GitHub archive stream cap.
|
||||
|
||||
Previously fixed and rechecked from recent audit context:
|
||||
|
||||
- GitHub/update JSON response caps.
|
||||
- Leviathan bounded reads and chunked save/export paths.
|
||||
- SFTP inline/chunk upload caps.
|
||||
- Secret vault and SSH key file read caps.
|
||||
|
||||
Residual DoS considerations:
|
||||
|
||||
- SFTP directory listings depend on remote server behavior and can still return large lists.
|
||||
- GitHub repository enrichment intentionally caps total repositories but still performs concurrent commit lookups.
|
||||
- Very large legitimate GitHub archives above 2 GiB now fail fast.
|
||||
|
||||
## 10. Dependency and Supply-Chain Review
|
||||
|
||||
Results:
|
||||
|
||||
- `npm --prefix frontend audit --omit=dev`: 0 vulnerabilities.
|
||||
- `govulncheck ./...`: no vulnerabilities found.
|
||||
|
||||
Reviewed manifests:
|
||||
|
||||
- `go.mod`
|
||||
- `go.sum`
|
||||
- `frontend/package.json`
|
||||
- `frontend/package-lock.json`
|
||||
- `.github/workflows/release-builds.yml`
|
||||
|
||||
No high-confidence dependency findings were found in this pass.
|
||||
|
||||
## 11. Secrets and Sensitive Data Review
|
||||
|
||||
Checked:
|
||||
|
||||
- No obvious private keys, GitHub tokens, cloud keys, or hardcoded credentials were found in source outside documentation/examples.
|
||||
- Non-secret profile export redaction was already covered by tests.
|
||||
- Secret access logging records secret type, not value.
|
||||
- GitHub Authorization headers are set only in HTTP requests and not logged by this code.
|
||||
|
||||
Residual:
|
||||
|
||||
- Plain exports without secrets are safer, but encrypted export remains the recommended path for portable backups.
|
||||
- Local audit logs should continue to avoid host/user secrets in future features.
|
||||
|
||||
## 12. Authentication and Authorisation Review
|
||||
|
||||
This is a local desktop application, so there are no server-side user roles or tenant boundaries. Relevant controls reviewed:
|
||||
|
||||
- SSH host-key callback requirement for VNC SSH tunnels.
|
||||
- Known-host storage and verification paths.
|
||||
- Secret retrieval via keychain/vault and prompt callbacks.
|
||||
- GitHub token validation before repo fetch.
|
||||
|
||||
No missing multi-user authorization checks apply to the current architecture.
|
||||
|
||||
## 13. Input Validation and Output Encoding Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- SFTP upload basename `..`.
|
||||
- SSH key generation key type and RSA bit sizes.
|
||||
- GitHub repo full-name format and path segment escaping.
|
||||
- SSH key manager dynamic output encoding.
|
||||
|
||||
Previously present controls:
|
||||
|
||||
- Profile validation rejects invalid hosts, ports, secret modes, GitHub HTTP non-loopback base URLs, and malformed protocol configs.
|
||||
- Import profiles are size-capped and validated before persistence.
|
||||
|
||||
## 14. Frontend HTML/JavaScript Robustness Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- SSH key manager load errors now render via `textContent`.
|
||||
- SSH key deletion confirmation modal dynamic strings now render via DOM nodes/text.
|
||||
- Regression script now checks these patterns.
|
||||
|
||||
Checked:
|
||||
|
||||
- Wails service bridge ordering remains covered by regression checks.
|
||||
- SFTP drag/drop upload still uses chunked methods.
|
||||
- Direct VNC warning flow remains covered by regression checks.
|
||||
|
||||
Residual:
|
||||
|
||||
- Frontend still contains many static `innerHTML` templates. Future reviews should focus on any template that accepts local filesystem, remote host, profile, update, or GitHub data.
|
||||
|
||||
## 15. Infrastructure and Deployment Review
|
||||
|
||||
Reviewed:
|
||||
|
||||
- GitHub Actions release workflow.
|
||||
- Wails build config.
|
||||
- Frontend build scripts.
|
||||
|
||||
No new high-confidence release workflow finding was fixed. The workflow uses pinned major versions for common actions and default GitHub token permissions scoped to release attachment needs.
|
||||
|
||||
Residual:
|
||||
|
||||
- Consider adding checksums/SBOM generation for release artifacts.
|
||||
- Consider a CI job for `go test ./...`, frontend regression tests, `npm audit --omit=dev`, and `govulncheck`.
|
||||
|
||||
## 16. Logging, Monitoring, and Error Handling Review
|
||||
|
||||
Fixed:
|
||||
|
||||
- SSH key manager no longer injects raw error strings into HTML.
|
||||
- GitHub malformed repo names now fail before request construction.
|
||||
- SSH keygen timeout returns a specific timeout error.
|
||||
|
||||
Checked:
|
||||
|
||||
- Error messages added in this pass do not include tokens, passwords, or key material.
|
||||
- Existing audit logger records profile/session actions and secret access metadata.
|
||||
|
||||
## 17. Test Coverage and Security Regression Gaps
|
||||
|
||||
Added:
|
||||
|
||||
- VNC Origin allowlist tests.
|
||||
- SFTP parent-directory upload name tests.
|
||||
- SSH key generation option validation tests.
|
||||
- SSH config oversized file and Host-block cap tests.
|
||||
- GitHub URL escaping, malformed full-name, and archive cap tests.
|
||||
- Frontend static regression checks for SSH key manager safe rendering.
|
||||
|
||||
Remaining gaps:
|
||||
|
||||
- No packaged Wails/noVNC integration test for Origin behavior.
|
||||
- No E2E browser test for SSH key modal rendering with hostile filenames.
|
||||
- No integration test for GitHub archive cap against an HTTP test server.
|
||||
|
||||
## 18. Prioritised Remediation Plan
|
||||
|
||||
Immediate:
|
||||
|
||||
- Manually test VNC connections in a packaged Wails app to verify the Origin allowlist.
|
||||
- Keep the new security tests in CI.
|
||||
|
||||
Short-term:
|
||||
|
||||
- Add an E2E test for SSH key manager hostile filenames.
|
||||
- Add an HTTP integration test for GitHub archive caps and escaped refs.
|
||||
- Surface user-friendly errors for new caps in the UI.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Add CI security checks for `govulncheck`, `npm audit --omit=dev`, and `git diff --check`.
|
||||
- Add release artifact checksums and an SBOM.
|
||||
|
||||
Long-term:
|
||||
|
||||
- Continue replacing dynamic frontend `innerHTML` where data provenance is mixed.
|
||||
- Add configurable large-download limits for GitHub archives and SFTP operations.
|
||||
|
||||
## 19. False Positives / Needs Manual Verification
|
||||
|
||||
- VNC Origin policy should be validated in macOS, Windows, and Linux packaged Wails builds.
|
||||
- The 2 GiB GitHub archive cap should be validated against expected user repository sizes.
|
||||
- The SSH keygen 30 second timeout should be checked on slow hardware, especially for RSA 4096.
|
||||
|
||||
## 20. Final Notes
|
||||
|
||||
This pass focused on concrete, testable hardening rather than broad speculative rewrites. The remaining risk is mostly runtime validation and UX around newly explicit limits. The repo still has an unrelated sidebar CSS spacing change in the working tree that was not part of this security audit.
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-all.sh — Cross-build helper for Nexus Terminal (macOS/Linux hosts)
|
||||
# Builds all supported targets where the required toolchains are installed.
|
||||
# Usage: ./build-all.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
source "$SCRIPT_DIR/scripts/build-common.sh"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
MIN_GO_VERSION="1.25.0"
|
||||
WAILS_VERSION="$(awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' go.mod)"
|
||||
WAILS_BIN=""
|
||||
WAILS_USE_GO_RUN=0
|
||||
BUILT_TARGETS=0
|
||||
SKIPPED_TARGETS=0
|
||||
|
||||
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
|
||||
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
|
||||
fi
|
||||
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
||||
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
||||
mkdir -p "$GOCACHE" "$GOPATH"
|
||||
export PATH="$GOPATH/bin:$PATH"
|
||||
|
||||
version_at_least() {
|
||||
local have="${1#go}"
|
||||
local want="$2"
|
||||
local IFS=.
|
||||
local have_parts want_parts
|
||||
read -r -a have_parts <<< "$have"
|
||||
read -r -a want_parts <<< "$want"
|
||||
for i in 0 1 2; do
|
||||
local h="${have_parts[$i]:-0}"
|
||||
local w="${want_parts[$i]:-0}"
|
||||
if (( h > w )); then return 0; fi
|
||||
if (( h < w )); then return 1; fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
check_go() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
|
||||
local version
|
||||
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
|
||||
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
|
||||
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
|
||||
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_wails() {
|
||||
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
local gopath
|
||||
gopath="$(go env GOPATH 2>/dev/null || true)"
|
||||
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
|
||||
WAILS_BIN="$gopath/bin/wails"
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$WAILS_VERSION" ]; then
|
||||
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
|
||||
WAILS_USE_GO_RUN=1
|
||||
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
|
||||
return
|
||||
fi
|
||||
|
||||
err "Wails is not installed and no Wails version was found in go.mod"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wails_cmd() {
|
||||
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
|
||||
go run "$WAILS_BIN" "$@"
|
||||
else
|
||||
"$WAILS_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_upx() {
|
||||
if ! nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
|
||||
return
|
||||
fi
|
||||
if command -v upx >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
warn "UPX is not installed; compressed targets will be skipped unless the target does not use UPX."
|
||||
}
|
||||
|
||||
linux_deps_ok() {
|
||||
command -v pkg-config >/dev/null 2>&1 || return 1
|
||||
pkg-config --exists gtk+-3.0 || return 1
|
||||
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
|
||||
return 1
|
||||
fi
|
||||
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
}
|
||||
|
||||
build_version() {
|
||||
local tag
|
||||
local commit
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
|
||||
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
echo "${tag}-${commit}"
|
||||
}
|
||||
|
||||
build_ldflags() {
|
||||
local version="$1"
|
||||
local flags="-s -w -X github.com/andydixon/nexus/internal/api.appVersion=${version}"
|
||||
if [ -n "${NEXUS_UPDATE_URL:-}" ]; then
|
||||
flags="${flags} -X github.com/andydixon/nexus/internal/api.appUpdateURL=${NEXUS_UPDATE_URL}"
|
||||
fi
|
||||
echo "${flags}"
|
||||
}
|
||||
|
||||
check_deps() {
|
||||
check_go
|
||||
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
|
||||
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
|
||||
resolve_wails
|
||||
ensure_upx
|
||||
|
||||
log "Go: $(go version)"
|
||||
log "Node: $(node --version)"
|
||||
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
|
||||
}
|
||||
|
||||
install_deps() {
|
||||
log "Installing Go dependencies…"
|
||||
go mod download
|
||||
|
||||
log "Installing frontend dependencies…"
|
||||
if [ -f frontend/package-lock.json ]; then
|
||||
(cd frontend && npm ci)
|
||||
else
|
||||
(cd frontend && npm install)
|
||||
fi
|
||||
}
|
||||
|
||||
host_os() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) echo "darwin" ;;
|
||||
Linux) echo "linux" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
host_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64" ;;
|
||||
arm64|aarch64) echo "arm64" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pick_cc() {
|
||||
local target_os="$1"
|
||||
local target_arch="$2"
|
||||
|
||||
if [ "$target_os" = "windows" ]; then
|
||||
if [ "$target_arch" = "amd64" ]; then
|
||||
echo "x86_64-w64-mingw32-gcc"
|
||||
else
|
||||
echo "aarch64-w64-mingw32-gcc"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$target_os" = "linux" ]; then
|
||||
if [ "$target_arch" = "amd64" ]; then
|
||||
echo "x86_64-linux-gnu-gcc"
|
||||
else
|
||||
echo "aarch64-linux-gnu-gcc"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
build_target() {
|
||||
local target_os="$1"
|
||||
local target_arch="$2"
|
||||
local host="$3"
|
||||
local host_arch_value="$4"
|
||||
local version="$5"
|
||||
local platform="${target_os}/${target_arch}"
|
||||
nexus_configure_release_cgo
|
||||
local ldflags
|
||||
ldflags="$(build_ldflags "${version}")"
|
||||
local tags
|
||||
tags="$(build_tags)"
|
||||
local build_args=("-platform" "$target_os/$target_arch" "-trimpath")
|
||||
if nexus_use_upx_for_target "$target_os"; then
|
||||
if ! command -v upx >/dev/null 2>&1; then
|
||||
warn "Skipping ${platform} (NEXUS_COMPRESS=1 requires UPX)"
|
||||
((SKIPPED_TARGETS += 1))
|
||||
return 0
|
||||
fi
|
||||
build_args+=("-upx")
|
||||
if [ -n "${NEXUS_UPX_FLAGS:-}" ]; then
|
||||
build_args+=("-upxflags" "$NEXUS_UPX_FLAGS")
|
||||
fi
|
||||
elif nexus_skips_upx_for_target "$target_os"; then
|
||||
warn "Skipping UPX compression for ${platform}; distribute the zipped .app instead, or set NEXUS_FORCE_MACOS_UPX=1 with suitable NEXUS_UPX_FLAGS."
|
||||
fi
|
||||
if [ -n "$tags" ]; then
|
||||
build_args+=("-tags" "$tags")
|
||||
fi
|
||||
build_args+=("-ldflags" "$ldflags")
|
||||
|
||||
local cc=""
|
||||
|
||||
if [ "$target_os" = "linux" ] && ! linux_deps_ok; then
|
||||
warn "Skipping ${platform} (missing Linux GTK/WebKit build dependencies)"
|
||||
((SKIPPED_TARGETS += 1))
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$target_os" != "$host" ] || [ "$target_arch" != "$host_arch_value" ]; then
|
||||
cc="$(pick_cc "$target_os" "$target_arch")"
|
||||
if [ -n "$cc" ] && ! command -v "$cc" >/dev/null 2>&1; then
|
||||
warn "Skipping ${platform} (missing C compiler: ${cc})"
|
||||
((SKIPPED_TARGETS += 1))
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Building ${platform}…"
|
||||
if [ -n "$cc" ]; then
|
||||
CC="$cc" CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
|
||||
else
|
||||
CGO_ENABLED=1 wails_cmd build "${build_args[@]}"
|
||||
fi
|
||||
((BUILT_TARGETS += 1))
|
||||
}
|
||||
|
||||
main() {
|
||||
check_deps
|
||||
install_deps
|
||||
|
||||
local version
|
||||
version="$(build_version)"
|
||||
|
||||
local host
|
||||
host="$(host_os)"
|
||||
local arch
|
||||
arch="$(host_arch)"
|
||||
if [ "$host" = "unknown" ] || [ "$arch" = "unknown" ]; then
|
||||
err "Unsupported host platform: $(uname -s)/$(uname -m)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_target "$host" "$arch" "$host" "$arch" "$version"
|
||||
|
||||
# Cross-build Linux and Windows targets where toolchains are available
|
||||
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
|
||||
for target in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
local target_os="${target%%/*}"
|
||||
local target_arch="${target##*/}"
|
||||
if [ "$target_os" = "$host" ] && [ "$target_arch" = "$arch" ]; then
|
||||
continue
|
||||
fi
|
||||
build_target "$target_os" "$target_arch" "$host" "$arch" "$version"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$BUILT_TARGETS" -eq 0 ]; then
|
||||
err "No targets were built; ${SKIPPED_TARGETS} target(s) were skipped because required system toolchains are missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Builds complete (${BUILT_TARGETS} built, ${SKIPPED_TARGETS} skipped). Output: build/bin/"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,143 @@
|
||||
# build.ps1 — Windows build script for Nexus Terminal
|
||||
# Usage: .\build.ps1 [dev|build|test|clean|deps]
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info($Message) { Write-Host "[nexus] $Message" -ForegroundColor Green }
|
||||
function Write-Warn($Message) { Write-Host "[nexus] $Message" -ForegroundColor Yellow }
|
||||
function Write-Err($Message) { Write-Host "[nexus] $Message" -ForegroundColor Red }
|
||||
|
||||
function Test-BoolEnabled([string]$Value) {
|
||||
return $Value -match '^(1|true|TRUE|yes|YES|on|ON)$'
|
||||
}
|
||||
|
||||
function Get-WailsPath {
|
||||
$wails = Get-Command wails -ErrorAction SilentlyContinue
|
||||
if ($wails) { return $wails.Source }
|
||||
|
||||
$goPath = & go env GOPATH
|
||||
$fallback = Join-Path $goPath 'bin\wails.exe'
|
||||
if (Test-Path $fallback) { return $fallback }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Check-Deps {
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue)) { throw 'Go is not installed.' }
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'Node.js is not installed.' }
|
||||
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { throw 'npm is not installed.' }
|
||||
|
||||
$wailsPath = Get-WailsPath
|
||||
if (-not $wailsPath) {
|
||||
throw 'Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest'
|
||||
}
|
||||
|
||||
Write-Info "Go: $(& go version)"
|
||||
Write-Info "Node: $(& node --version)"
|
||||
Write-Info "Wails: $(& $wailsPath version | Select-Object -First 1)"
|
||||
|
||||
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
|
||||
Write-Warn 'gcc not found. SQLite requires cgo + a C compiler (e.g. MSYS2 mingw-w64).'
|
||||
}
|
||||
|
||||
return $wailsPath
|
||||
}
|
||||
|
||||
function Install-Deps {
|
||||
Write-Info 'Installing Go dependencies...'
|
||||
& go mod download
|
||||
|
||||
Write-Info 'Installing frontend dependencies...'
|
||||
Push-Location 'frontend'
|
||||
& npm install
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
function Get-BuildVersion {
|
||||
try {
|
||||
$tag = & git describe --tags --abbrev=0 2>$null
|
||||
if (-not $tag) { $tag = 'dev' }
|
||||
} catch {
|
||||
$tag = 'dev'
|
||||
}
|
||||
|
||||
try {
|
||||
$commit = & git rev-parse --short HEAD 2>$null
|
||||
if (-not $commit) { $commit = 'unknown' }
|
||||
} catch {
|
||||
$commit = 'unknown'
|
||||
}
|
||||
|
||||
return "$tag-$commit"
|
||||
}
|
||||
|
||||
function Get-Ldflags([string]$Version) {
|
||||
$flags = "-s -w -buildid= -X github.com/andydixon/nexus/internal/api.appVersion=$Version"
|
||||
if ($env:NEXUS_UPDATE_URL) {
|
||||
$flags += " -X github.com/andydixon/nexus/internal/api.appUpdateURL=$($env:NEXUS_UPDATE_URL)"
|
||||
}
|
||||
return $flags
|
||||
}
|
||||
|
||||
function Dev {
|
||||
$wailsPath = Check-Deps
|
||||
Install-Deps
|
||||
Write-Info 'Starting development mode...'
|
||||
& $wailsPath dev
|
||||
}
|
||||
|
||||
function Build {
|
||||
$wailsPath = Check-Deps
|
||||
Install-Deps
|
||||
$version = Get-BuildVersion
|
||||
$ldflags = Get-Ldflags $version
|
||||
|
||||
$env:CGO_ENABLED = '1'
|
||||
if (-not $env:CGO_CFLAGS) { $env:CGO_CFLAGS = '-O2 -g0' }
|
||||
if (-not $env:CGO_CXXFLAGS) { $env:CGO_CXXFLAGS = '-O2 -g0' }
|
||||
|
||||
$buildArgs = @('build', '-trimpath')
|
||||
if (Test-BoolEnabled $env:NEXUS_COMPRESS) {
|
||||
if (-not (Get-Command upx -ErrorAction SilentlyContinue)) {
|
||||
throw 'NEXUS_COMPRESS=1 requires UPX. Install it first.'
|
||||
}
|
||||
$buildArgs += '-upx'
|
||||
if ($env:NEXUS_UPX_FLAGS) {
|
||||
$buildArgs += @('-upxflags', $env:NEXUS_UPX_FLAGS)
|
||||
}
|
||||
}
|
||||
$buildArgs += @('-ldflags', $ldflags)
|
||||
|
||||
Write-Info 'Building production binary (CGO_ENABLED=1)...'
|
||||
& $wailsPath @buildArgs
|
||||
Write-Info 'Build complete: build\bin\'
|
||||
}
|
||||
|
||||
function Run-Tests {
|
||||
Write-Info 'Running tests...'
|
||||
& go test ./... -v -count=1
|
||||
}
|
||||
|
||||
function Clean {
|
||||
Write-Info 'Cleaning build artefacts...'
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue build\bin
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue frontend\node_modules
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue frontend\src\js\lib
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue frontend\src\css\lib
|
||||
& go clean -testcache
|
||||
Write-Info 'Clean complete'
|
||||
}
|
||||
|
||||
$task = $args[0]
|
||||
if (-not $task) { $task = 'build' }
|
||||
|
||||
switch ($task) {
|
||||
'dev' { Dev }
|
||||
'build' { Build }
|
||||
'test' { Run-Tests }
|
||||
'clean' { Clean }
|
||||
'deps' { Check-Deps | Out-Null; Install-Deps }
|
||||
default {
|
||||
Write-Err 'Usage: .\\build.ps1 [dev|build|test|clean|deps]'
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
source "$SCRIPT_DIR/scripts/build-common.sh"
|
||||
|
||||
# Colours for output
|
||||
RED='\033[0;31m'
|
||||
@@ -17,25 +18,154 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
MIN_GO_VERSION="1.25.0"
|
||||
WAILS_VERSION="$(nexus_wails_version go.mod)"
|
||||
WAILS_BIN=""
|
||||
WAILS_USE_GO_RUN=0
|
||||
|
||||
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
|
||||
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
|
||||
fi
|
||||
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
|
||||
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
|
||||
mkdir -p "$GOCACHE" "$GOPATH"
|
||||
export PATH="$GOPATH/bin:$PATH"
|
||||
|
||||
version_at_least() {
|
||||
local have="${1#go}"
|
||||
local want="$2"
|
||||
local IFS=.
|
||||
local have_parts want_parts
|
||||
read -r -a have_parts <<< "$have"
|
||||
read -r -a want_parts <<< "$want"
|
||||
for i in 0 1 2; do
|
||||
local h="${have_parts[$i]:-0}"
|
||||
local w="${want_parts[$i]:-0}"
|
||||
if (( h > w )); then return 0; fi
|
||||
if (( h < w )); then return 1; fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
check_go() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
|
||||
local version
|
||||
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
|
||||
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
|
||||
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
|
||||
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_wails() {
|
||||
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
local gopath
|
||||
gopath="$(go env GOPATH 2>/dev/null || true)"
|
||||
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
|
||||
WAILS_BIN="$gopath/bin/wails"
|
||||
WAILS_USE_GO_RUN=0
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$WAILS_VERSION" ]; then
|
||||
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
|
||||
WAILS_USE_GO_RUN=1
|
||||
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
|
||||
return
|
||||
fi
|
||||
|
||||
err "Wails is not installed and no Wails version was found in go.mod"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wails_cmd() {
|
||||
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
|
||||
go run "$WAILS_BIN" "$@"
|
||||
else
|
||||
"$WAILS_BIN" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_upx() {
|
||||
if ! nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
|
||||
return
|
||||
fi
|
||||
if nexus_skips_upx_for_target "$(host_os)"; then
|
||||
return
|
||||
fi
|
||||
if command -v upx >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
err "NEXUS_COMPRESS=1 requires UPX. Install it first, for example: brew install upx"
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_linux_deps() {
|
||||
if [ "$(uname -s)" != "Linux" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
command -v pkg-config >/dev/null 2>&1 || {
|
||||
err "pkg-config is required for Linux Wails builds."
|
||||
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
|
||||
exit 1
|
||||
}
|
||||
|
||||
local missing=()
|
||||
pkg-config --exists gtk+-3.0 || missing+=("gtk+-3.0")
|
||||
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
|
||||
missing+=("webkit2gtk-4.1 or webkit2gtk-4.0")
|
||||
fi
|
||||
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
|
||||
missing+=("ayatana-appindicator3-0.1 or appindicator3-0.1")
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
err "Missing Linux build dependencies: ${missing[*]}"
|
||||
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_tags() {
|
||||
nexus_build_tags
|
||||
}
|
||||
|
||||
host_os() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) echo "darwin" ;;
|
||||
Linux) echo "linux" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_version() {
|
||||
nexus_build_version
|
||||
}
|
||||
|
||||
build_ldflags() {
|
||||
nexus_build_ldflags "$1"
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
check_deps() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
check_go
|
||||
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
|
||||
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
|
||||
|
||||
if ! command -v wails >/dev/null 2>&1; then
|
||||
if [ -f "$HOME/go/bin/wails" ]; then
|
||||
export PATH="$PATH:$HOME/go/bin"
|
||||
else
|
||||
err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
check_linux_deps
|
||||
resolve_wails
|
||||
ensure_upx
|
||||
|
||||
log "All prerequisites found"
|
||||
log "Go: $(go version)"
|
||||
log "Node: $(node --version)"
|
||||
log "Wails: $(wails version 2>/dev/null | head -1)"
|
||||
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
|
||||
}
|
||||
|
||||
# Install dependencies
|
||||
@@ -44,7 +174,11 @@ install_deps() {
|
||||
go mod download
|
||||
|
||||
log "Installing frontend dependencies…"
|
||||
cd frontend && npm install && cd ..
|
||||
if [ -f frontend/package-lock.json ]; then
|
||||
(cd frontend && npm ci)
|
||||
else
|
||||
(cd frontend && npm install)
|
||||
fi
|
||||
|
||||
log "Dependencies installed"
|
||||
}
|
||||
@@ -54,7 +188,7 @@ dev() {
|
||||
check_deps
|
||||
install_deps
|
||||
log "Starting development mode…"
|
||||
wails dev
|
||||
wails_cmd dev
|
||||
}
|
||||
|
||||
# Production build
|
||||
@@ -62,14 +196,49 @@ build() {
|
||||
check_deps
|
||||
install_deps
|
||||
log "Building production binary…"
|
||||
wails build
|
||||
local version
|
||||
version="$(build_version)"
|
||||
local tags
|
||||
tags="$(build_tags)"
|
||||
nexus_configure_release_cgo
|
||||
local build_args=("-trimpath")
|
||||
if nexus_bool_enabled "${NEXUS_COMPRESS:-0}"; then
|
||||
if nexus_skips_upx_for_target "$(host_os)"; then
|
||||
warn "Skipping UPX compression on macOS; distribute the zipped .app instead, or set NEXUS_FORCE_MACOS_UPX=1 with suitable NEXUS_UPX_FLAGS."
|
||||
else
|
||||
log "Using UPX compression"
|
||||
build_args+=("-upx")
|
||||
if [ -n "${NEXUS_UPX_FLAGS:-}" ]; then
|
||||
build_args+=("-upxflags" "$NEXUS_UPX_FLAGS")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$tags" ]; then
|
||||
log "Using Wails build tags: ${tags}"
|
||||
wails_cmd build "${build_args[@]}" -tags "$tags" -ldflags "$(build_ldflags "${version}")"
|
||||
else
|
||||
wails_cmd build "${build_args[@]}" -ldflags "$(build_ldflags "${version}")"
|
||||
fi
|
||||
|
||||
# Replace Wails-generated icon with custom app icon on macOS.
|
||||
if [[ "$OSTYPE" == darwin* ]]; then
|
||||
local app_bundle="build/bin/nexus-terminal.app"
|
||||
local custom_icns="build/darwin/AppIcon.icns"
|
||||
if [ -d "$app_bundle" ] && [ -f "$custom_icns" ]; then
|
||||
cp "$custom_icns" "$app_bundle/Contents/Resources/iconfile.icns"
|
||||
log "Replaced app icon with custom AppIcon.icns"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Build complete: build/bin/"
|
||||
}
|
||||
|
||||
# Run tests
|
||||
run_tests() {
|
||||
check_go
|
||||
log "Running tests…"
|
||||
go test ./... -v -count=1
|
||||
(cd frontend && npm test)
|
||||
}
|
||||
|
||||
# Clean build artefacts
|
||||
@@ -78,8 +247,9 @@ clean() {
|
||||
rm -rf build/bin
|
||||
rm -rf frontend/node_modules
|
||||
rm -rf frontend/src/js/lib
|
||||
rm -rf frontend/src/css/lib
|
||||
go clean -testcache
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
go clean -testcache
|
||||
fi
|
||||
log "Clean complete"
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.andydixon.nexusterminal</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,137 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||
<defs>
|
||||
<!-- Background gradient: deep navy -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0D1117;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1A1F2E;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Cyan glow for terminal elements -->
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#00D4FF;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#00FF88;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Inner glow effect for terminal text -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Subtle glow for grid lines -->
|
||||
<filter id="softGlow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Clip to rounded rect -->
|
||||
<clipPath id="roundedClip">
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Rounded rectangle background -->
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224" fill="url(#bgGrad)"/>
|
||||
|
||||
<!-- Clipped content group -->
|
||||
<g clip-path="url(#roundedClip)">
|
||||
|
||||
<!-- Subtle grid lines (circuit board / network feel) -->
|
||||
<g opacity="0.07" stroke="#00D4FF" stroke-width="1.5" filter="url(#softGlow)">
|
||||
<!-- Horizontal lines -->
|
||||
<line x1="0" y1="200" x2="1024" y2="200"/>
|
||||
<line x1="0" y1="350" x2="1024" y2="350"/>
|
||||
<line x1="0" y1="500" x2="1024" y2="500"/>
|
||||
<line x1="0" y1="650" x2="1024" y2="650"/>
|
||||
<line x1="0" y1="800" x2="1024" y2="800"/>
|
||||
<!-- Vertical lines -->
|
||||
<line x1="200" y1="0" x2="200" y2="1024"/>
|
||||
<line x1="350" y1="0" x2="350" y2="1024"/>
|
||||
<line x1="500" y1="0" x2="500" y2="1024"/>
|
||||
<line x1="650" y1="0" x2="650" y2="1024"/>
|
||||
<line x1="800" y1="0" x2="800" y2="1024"/>
|
||||
</g>
|
||||
|
||||
<!-- Network node dots at grid intersections (subtle) -->
|
||||
<g opacity="0.12" fill="#00D4FF">
|
||||
<circle cx="200" cy="200" r="4"/>
|
||||
<circle cx="350" cy="200" r="4"/>
|
||||
<circle cx="500" cy="200" r="4"/>
|
||||
<circle cx="650" cy="200" r="4"/>
|
||||
<circle cx="800" cy="200" r="4"/>
|
||||
<circle cx="200" cy="350" r="4"/>
|
||||
<circle cx="350" cy="350" r="4"/>
|
||||
<circle cx="650" cy="350" r="4"/>
|
||||
<circle cx="800" cy="350" r="4"/>
|
||||
<circle cx="200" cy="500" r="4"/>
|
||||
<circle cx="800" cy="500" r="4"/>
|
||||
<circle cx="200" cy="650" r="4"/>
|
||||
<circle cx="350" cy="650" r="4"/>
|
||||
<circle cx="650" cy="650" r="4"/>
|
||||
<circle cx="800" cy="650" r="4"/>
|
||||
<circle cx="200" cy="800" r="4"/>
|
||||
<circle cx="350" cy="800" r="4"/>
|
||||
<circle cx="500" cy="800" r="4"/>
|
||||
<circle cx="650" cy="800" r="4"/>
|
||||
<circle cx="800" cy="800" r="4"/>
|
||||
</g>
|
||||
|
||||
<!-- Central terminal window panel -->
|
||||
<rect x="112" y="240" width="800" height="544" rx="16" ry="16"
|
||||
fill="#0A0E17" fill-opacity="0.85"
|
||||
stroke="#00D4FF" stroke-width="1.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Terminal title bar -->
|
||||
<rect x="112" y="240" width="800" height="52" rx="16" ry="16"
|
||||
fill="#161B27" fill-opacity="0.9"/>
|
||||
<!-- Fix bottom corners of title bar -->
|
||||
<rect x="112" y="268" width="800" height="24"
|
||||
fill="#161B27" fill-opacity="0.9"/>
|
||||
|
||||
<!-- Window control dots -->
|
||||
<circle cx="162" cy="266" r="10" fill="#FF5F57"/>
|
||||
<circle cx="198" cy="266" r="10" fill="#FFBD2E"/>
|
||||
<circle cx="234" cy="266" r="10" fill="#28C840"/>
|
||||
|
||||
<!-- Title bar text: "nexus ssh" -->
|
||||
<text x="512" y="273" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="20" fill="#8892A4" text-anchor="middle" dominant-baseline="middle"
|
||||
letter-spacing="3">nexus — ssh</text>
|
||||
|
||||
<!-- Terminal separator line -->
|
||||
<line x1="112" y1="292" x2="912" y2="292" stroke="#00D4FF" stroke-width="1" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- MAIN TERMINAL CONTENT -->
|
||||
<!-- Line 1: prompt line -->
|
||||
<text x="152" y="360" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="38" fill="#8892A4" filter="url(#softGlow)">user@nexus:~$</text>
|
||||
|
||||
<!-- THE BIG PROMPT SYMBOL: ">_" — cyan glowing, centered -->
|
||||
<text x="512" y="560" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="260" font-weight="bold"
|
||||
fill="url(#cyanGrad)"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
filter="url(#glow)">>_</text>
|
||||
|
||||
<!-- Blinking cursor line at bottom of terminal -->
|
||||
<rect x="152" y="726" width="22" height="36" rx="2" ry="2"
|
||||
fill="#00D4FF" opacity="0.9" filter="url(#softGlow)"/>
|
||||
|
||||
<!-- Bottom status line -->
|
||||
<text x="740" y="748" font-family="'SF Mono', 'Courier New', monospace"
|
||||
font-size="22" fill="#00FF88" fill-opacity="0.6" text-anchor="end">SSH v2 AES-256</text>
|
||||
|
||||
</g>
|
||||
|
||||
<!-- Outer border glow (subtle cyan rim) -->
|
||||
<rect x="2" y="2" width="1020" height="1020" rx="223" ry="223"
|
||||
fill="none"
|
||||
stroke="url(#cyanGrad)" stroke-width="4" stroke-opacity="0.25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,77 @@
|
||||
# Build and Release
|
||||
|
||||
This project uses [Wails](https://wails.io/) for desktop packaging.
|
||||
|
||||
## Ubuntu 24.04 prerequisites
|
||||
|
||||
Install the Linux build toolchain and Wails runtime headers with:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
pkg-config \
|
||||
zip
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Ubuntu 24.04 does not ship `libwebkit2gtk-4.0-dev`.
|
||||
- Wails `v2.12.0` supports Ubuntu 24.04 by building with the `webkit2_41` tag.
|
||||
- The local build scripts detect that package layout automatically and add the tag when needed.
|
||||
|
||||
You will also need these tools available in `PATH`:
|
||||
|
||||
- `go`
|
||||
- `node`
|
||||
- `npm`
|
||||
- `wails`
|
||||
|
||||
Install the Wails CLI with:
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||
```
|
||||
|
||||
## Build commands
|
||||
|
||||
Build the app binary for the current host:
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Build a packaged release for the current host platform:
|
||||
|
||||
```bash
|
||||
./release.sh
|
||||
```
|
||||
|
||||
By default, `./release.sh` now builds only the current host OS/architecture. On this Ubuntu 24.04 `amd64` host that means `linux/amd64`.
|
||||
|
||||
## Optional cross-compilers
|
||||
|
||||
If you want to add extra Linux/Windows targets from Ubuntu, install the relevant cross-compiler packages first:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-mingw-w64-x86-64
|
||||
```
|
||||
|
||||
Then request explicit targets:
|
||||
|
||||
```bash
|
||||
NEXUS_RELEASE_TARGETS=linux/amd64,windows/amd64 ./release.sh
|
||||
```
|
||||
|
||||
## Platform caveats
|
||||
|
||||
- macOS targets cannot be cross-built from Linux with Wails. Build those on macOS.
|
||||
- `windows/arm64` is not covered by the default Ubuntu toolchain here.
|
||||
- `linux/arm64` needs more than `gcc-aarch64-linux-gnu`; it also needs an `arm64` sysroot and target GTK/WebKit development libraries for CGO to link cleanly.
|
||||
- Tested on Ubuntu 24.04 `amd64`: `linux/amd64` and `windows/amd64` build successfully; `linux/arm64` does not in the default host setup above.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Nexus Terminal Overview
|
||||
|
||||
Nexus Terminal is a desktop connection and analysis workspace for people who manage servers, network services, remote desktops, repositories, and binary artefacts every day. It brings SSH, Telnet, RAW TCP, VNC, GitHub, local shell, and Leviathan binary-analysis workflows into one focused app with secure credential handling, multi-session organisation, file transfer, automation, recording, and inspection tooling built in.
|
||||
|
||||
## Product positioning
|
||||
|
||||
Nexus is more than an SSH client. It is a single command centre for remote access, terminal operations, file movement, raw socket inspection, GitHub repository visibility, binary triage, and repeatable operational workflows.
|
||||
|
||||
Use Nexus when you need to:
|
||||
|
||||
- Connect quickly to many systems without re-entering credentials.
|
||||
- Keep multiple live sessions organised with tabs, pinned tabs, split panes, and status indicators.
|
||||
- Move files over SSH without leaving the terminal workspace.
|
||||
- Work safely with host key verification, encrypted secrets, TOTP support, and audit logging.
|
||||
- Reuse commands, automate login flows, record sessions, and react to terminal output.
|
||||
- Support mixed estates that include SSH servers, Telnet devices, raw TCP services, VNC desktops, GitHub repositories, local shells, firmware images, binaries, archives, and other artefacts.
|
||||
|
||||
## Main feature groups
|
||||
|
||||
### Multi-protocol connections
|
||||
|
||||
- **SSH** for secure remote terminal access.
|
||||
- **Telnet** for legacy devices and services.
|
||||
- **RAW TCP** for direct socket interaction, protocol testing, and device troubleshooting.
|
||||
- **VNC** for remote desktop access.
|
||||
- **GitHub** for repository browsing and download workflows.
|
||||
- **Local terminal** for opening a shell on the local machine.
|
||||
- **Leviathan** for binary inspection, editing, carving, transforms, and spectrogram workflows.
|
||||
- **Quick connect** for one-off sessions without saving a profile.
|
||||
- **Saved profiles** for repeat connections.
|
||||
|
||||
### Profile management
|
||||
|
||||
- Create, edit, duplicate, and delete connection profiles.
|
||||
- Store display names, descriptions, notes, groups, tags, environment labels, and favourites.
|
||||
- Search profiles by name, host, or tag.
|
||||
- Filter profiles by protocol, group, tag, and favourite status.
|
||||
- Test basic host and port reachability before connecting.
|
||||
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files, and jump hosts.
|
||||
- Export and import profiles using encrypted `.nexus` bundles.
|
||||
- Choose import behaviour for duplicates: skip, overwrite, or duplicate.
|
||||
|
||||
### Multi-session workspace
|
||||
|
||||
- Tabbed sessions with connection state indicators.
|
||||
- Pin tabs to keep important systems visible.
|
||||
- Rename sessions.
|
||||
- Reorder tabs.
|
||||
- Scroll overflowing tabs and hover them to see full names and connection start times.
|
||||
- Close one session or all sessions.
|
||||
- Split panes vertically or horizontally.
|
||||
- Resize split panes.
|
||||
- Switch tabs by keyboard shortcut.
|
||||
- Broadcast input to multiple connected sessions.
|
||||
- Show disconnected overlays with a one-click reconnect action.
|
||||
- Auto-reconnect support for SSH, Telnet, and RAW TCP profiles.
|
||||
- Command palette for fast access to connections, settings, terminal actions, security tools, import/export, snippets, triggers, and local terminal.
|
||||
|
||||
### Terminal experience
|
||||
|
||||
- xterm.js-powered terminal rendering.
|
||||
- Configurable terminal font family, font size, scrollback, cursor style, and colour theme.
|
||||
- Search within terminal output.
|
||||
- Search options for case sensitivity, regular expressions, and whole-word matching.
|
||||
- Copy, paste, select all, clear, and reset from the terminal context menu.
|
||||
- Clipboard-aware paste behaviour.
|
||||
- Clickable web links where supported.
|
||||
- Alternate-screen scroll handling for tools such as `tmux`, `screen`, and `byobu`.
|
||||
- Export terminal scrollback as plain text.
|
||||
- Send break signals where supported.
|
||||
- Ping active sessions to check responsiveness.
|
||||
|
||||
### SSH capabilities
|
||||
|
||||
- Password authentication.
|
||||
- Public key authentication.
|
||||
- Keyboard-interactive authentication.
|
||||
- SSH agent authentication.
|
||||
- SSH agent forwarding.
|
||||
- Private key path discovery from `~/.ssh`.
|
||||
- SSH key manager for listing, generating, copying, and deleting key pairs.
|
||||
- Ed25519, RSA, and ECDSA key generation.
|
||||
- Optional private key passphrase handling.
|
||||
- TOTP / 2FA secret storage and code generation for keyboard-interactive login flows.
|
||||
- Host key verification policies per profile: Strict, Ask, Accept new, and Insecure.
|
||||
- Known hosts storage and management.
|
||||
- Jump host support for bastion and multi-hop access.
|
||||
- SOCKS5 proxy support.
|
||||
- HTTP CONNECT proxy support.
|
||||
- Local and remote port forwarding.
|
||||
- Runtime port forward management for active SSH sessions.
|
||||
- Startup commands.
|
||||
- Custom environment variables.
|
||||
- Terminal type configuration.
|
||||
- Keepalive interval and missed-keepalive settings.
|
||||
- Compression option.
|
||||
- Expect-style login scripts with wait and send steps.
|
||||
- Built-in SFTP browser for file transfer.
|
||||
|
||||
### SFTP file browser
|
||||
|
||||
- Open a side panel for SSH file browsing.
|
||||
- Navigate remote folders with breadcrumbs.
|
||||
- Go directly to a remote path.
|
||||
- Bookmark remote locations.
|
||||
- Refresh remote listings.
|
||||
- Create folders.
|
||||
- Upload files.
|
||||
- Download single files or multiple selected files.
|
||||
- Rename files and folders.
|
||||
- Delete files and folders.
|
||||
- Multi-select support for batch actions.
|
||||
- Resizable SFTP panel alongside the terminal.
|
||||
- Drag and drop files into an SSH terminal.
|
||||
- Choose whether dropped files are uploaded or pasted into the terminal as text.
|
||||
- Upload directory picker with folder filtering, breadcrumbs, keyboard navigation, and permission badges.
|
||||
- Permission labels for read-only and inaccessible folders.
|
||||
|
||||
### Telnet capabilities
|
||||
|
||||
- Telnet option negotiation.
|
||||
- Terminal type negotiation.
|
||||
- NAWS window-size updates.
|
||||
- Local echo option.
|
||||
- Line mode option.
|
||||
- Configurable line endings: LF, CR, CRLF, or none.
|
||||
- Configurable encoding: UTF-8, ASCII, and ISO 8859-1.
|
||||
- Negotiation debug mode.
|
||||
- Expect-style login scripts.
|
||||
- Auto-reconnect with configurable retry count.
|
||||
- Break signal support.
|
||||
|
||||
### RAW TCP capabilities
|
||||
|
||||
- Direct TCP socket sessions.
|
||||
- Text, hex, and split data views.
|
||||
- Send data as text, hex, or escaped bytes.
|
||||
- Quick insert controls for common bytes such as NUL, CR, LF, ETX, and EOT.
|
||||
- Payload presets for reusable test messages.
|
||||
- Optional timestamps.
|
||||
- Optional send/receive direction labels.
|
||||
- Pause and resume display updates.
|
||||
- Clear the capture buffer.
|
||||
- Save RAW session captures.
|
||||
- Configurable encoding and line endings.
|
||||
- Connect and read timeouts.
|
||||
- Auto-reconnect option.
|
||||
|
||||
### VNC capabilities
|
||||
|
||||
- VNC remote desktop sessions.
|
||||
- noVNC-based viewer inside the Nexus workspace.
|
||||
- Password prompt and password storage modes.
|
||||
- View-only mode.
|
||||
- Scale-to-fit display.
|
||||
- Shared-session option.
|
||||
- Quality setting.
|
||||
- SSH tunnel option for VNC traffic.
|
||||
- SSH tunnel authentication by password, public key, or SSH agent.
|
||||
|
||||
### GitHub capabilities
|
||||
|
||||
- GitHub profile type using a personal access token.
|
||||
- GitHub.com and GitHub Enterprise API base URL support.
|
||||
- Repository viewer grouped by owner or organisation.
|
||||
- Repository filtering by name, description, or language.
|
||||
- Repositories sorted by latest commit activity.
|
||||
- Shows repository metadata such as language, stars, private/fork/archived state, latest committer, and latest commit time.
|
||||
- Manual refresh.
|
||||
- Configurable refresh interval.
|
||||
- Open repositories in the browser.
|
||||
- Download repository ZIP archives.
|
||||
|
||||
### Leviathan binary workbench
|
||||
|
||||
- Switch from **Nexus Terminal** to **Leviathan** from the sidebar header.
|
||||
- Load a local file into a working document, inspect it with multiple tools, then save the patched working copy.
|
||||
- Tools open as normal tabs beside connection sessions.
|
||||
- Leviathan tabs close immediately without connection-disconnect confirmation.
|
||||
- Shared undo, redo, and revert actions for byte-level edits.
|
||||
- File-backed document model for large local files, with chunked save/export where supported.
|
||||
- Clear limits for tools that require bounded in-memory buffers, such as image/audio spectrogram conversion and some parsers.
|
||||
|
||||
### Leviathan editor and triage tools
|
||||
|
||||
- **Hex Editor** with editable byte cells, live ASCII preview, 16-byte and 32-byte rows, byte search, string/hex search, previous/next match navigation, and range opening from analysis views.
|
||||
- **Overview** for first-pass summary across hashes, entropy, strings, indicators, and carve candidates.
|
||||
- **Hashes** for CRC32, MD5, SHA-1, SHA-256, and SHA-512.
|
||||
- **Entropy** graph over the whole file, with selectable ranges that can open a new hex editor tab.
|
||||
- **Strings** extraction for ASCII and UTF-16 text.
|
||||
- **IOCs** for URLs, domains, paths, hashes, and other indicators.
|
||||
- **Search** and **Multi-search** for text, hex, and regular-expression matching.
|
||||
- Exportable results from tools that find files, ranges, or structured artefacts.
|
||||
|
||||
### Leviathan structure, recovery, and transform tools
|
||||
|
||||
- **Structures** parser for common binary formats such as PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1, and certificates.
|
||||
- **Disasm** heuristic disassembly for common CPU families, with raw byte fallback for unsupported architectures.
|
||||
- **PDF** object, stream, xref, and suspicious-surface inspection.
|
||||
- **SquashFS** superblock and filesystem metadata inspection with human-readable bad-magic errors.
|
||||
- **Carve**, **Keys**, **Timestamps**, **Stego**, and **Metadata** tools for embedded artefacts and recovery workflows.
|
||||
- **BinSnoop** deep signature scanning across the whole file, with export for discovered files and ranges.
|
||||
- **Rules** for YARA-like rule scans.
|
||||
- **Patch Plan** and **Patch Apply** for validating and applying patch scripts.
|
||||
- **Transforms** for hex, Base64, URL, XOR, inflate, and cipher pipelines.
|
||||
- **Case Export** for bundling useful artefacts from a working investigation.
|
||||
|
||||
### Leviathan spectrogram tools
|
||||
|
||||
- Convert an image into WAV audio so the image appears in a spectrogram.
|
||||
- Insert an encoded image into an existing WAV or MP3 carrier.
|
||||
- Visualise WAV or MP3 audio as a spectrogram image.
|
||||
- Control FFT size, height, maximum width, aspect ratio, frequency range, brightness, and contrast.
|
||||
|
||||
### Automation and reusable workflows
|
||||
|
||||
- Snippet library for saved commands.
|
||||
- Snippet folders, descriptions, and tags.
|
||||
- Send snippets to active sessions.
|
||||
- Snippet variables before sending commands.
|
||||
- Broadcast input mode for running the same command across connected sessions.
|
||||
- Trigger rules that watch terminal output.
|
||||
- Trigger matching by text or regular expression.
|
||||
- Trigger actions for notifications, tab highlighting, sound, or combined notify/highlight behaviour.
|
||||
- Expect-style login scripts for SSH and Telnet.
|
||||
- Startup commands for SSH sessions.
|
||||
|
||||
### Session recording
|
||||
|
||||
- Start and stop terminal recording per session.
|
||||
- Recording indicator on active tabs.
|
||||
- Recordings saved in asciicast v2-compatible format.
|
||||
- Recording browser for listing saved recordings.
|
||||
- Playback controls.
|
||||
- Delete saved recordings.
|
||||
- Recording metadata includes title, dimensions, duration, size, creation time, and file path.
|
||||
|
||||
### Security and credential handling
|
||||
|
||||
- OS keychain first where available, including macOS Keychain, Linux Secret Service, and Windows Credential Manager.
|
||||
- Encrypted vault fallback.
|
||||
- Vault encryption with AES-256-GCM and Argon2id-derived keys.
|
||||
- Secret store lock and unlock workflow.
|
||||
- Per-profile secret modes: Saved, Session only, and Ask every time.
|
||||
- Encrypted storage for TOTP secrets.
|
||||
- Known host verification and known host management.
|
||||
- Audit log for security-relevant operations.
|
||||
- Audit entries for connections, disconnections, profile changes, secret access, imports, and exports.
|
||||
- Secrets are not written into audit entries.
|
||||
- Encrypted profile exports with password-based AES-256-GCM encryption and Argon2id key derivation.
|
||||
- Plain profile export can redact inline secrets.
|
||||
|
||||
### Settings and maintenance
|
||||
|
||||
- Application theme setting.
|
||||
- Terminal theme selection.
|
||||
- Default protocol preference.
|
||||
- Default terminal font, size, scrollback, and cursor settings.
|
||||
- Confirm-on-close and confirm-on-disconnect preferences.
|
||||
- Status bar visibility setting.
|
||||
- Sidebar width preference.
|
||||
- Native paste preference.
|
||||
- Local shell selection.
|
||||
- Secret store settings.
|
||||
- Known hosts management.
|
||||
- Help and troubleshooting content inside settings.
|
||||
- Background and manual update checks.
|
||||
- Update checks report availability, current version, latest version, release notes, release URL, and publish date.
|
||||
|
||||
## Website-ready summary
|
||||
|
||||
### Suggested headline
|
||||
|
||||
Nexus Terminal: one desktop workspace for SSH, files, sockets, remote desktops, repositories, and binary artefacts.
|
||||
|
||||
### Suggested subheadline
|
||||
|
||||
Manage SSH, Telnet, RAW TCP, VNC, GitHub, local shell, and Leviathan binary-analysis sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings, automation, and artefact-inspection tools built in.
|
||||
|
||||
### Short product description
|
||||
|
||||
Nexus Terminal helps developers, operators, support engineers, reverse engineers, firmware analysts, and homelab users manage many connections and artefacts without juggling separate tools. Save profiles, organise them with groups and tags, connect in tabs or split panes, move files over SFTP, inspect raw TCP traffic, open VNC desktops, browse GitHub repositories, triage binaries with Leviathan, and keep credentials protected with keychain and encrypted-vault storage.
|
||||
|
||||
### Product page feature bullets
|
||||
|
||||
- All-in-one connection and analysis workspace for SSH, Telnet, RAW TCP, VNC, GitHub, local shells, and Leviathan tools.
|
||||
- Fast multi-session interface with tabs, pinned sessions, split panes, reconnect, and broadcast input.
|
||||
- Rich SSH support including agent auth, TOTP, jump hosts, proxies, port forwarding, startup commands, login scripts, and host key policies.
|
||||
- Built-in SFTP browser with upload, download, rename, delete, folders, bookmarks, drag-and-drop upload, and permission-aware directory picking.
|
||||
- RAW TCP tooling with text, hex, split views, payload presets, captures, timestamps, and byte-level sending.
|
||||
- VNC viewer with scale-to-fit, view-only mode, quality controls, shared sessions, and optional SSH tunnelling.
|
||||
- GitHub repository browser with organisation grouping, filtering, activity sorting, metadata, ZIP downloads, and GitHub Enterprise support.
|
||||
- Leviathan binary workbench with hex editing, hashes, entropy, strings, IOCs, carving, BinSnoop, structures, PDF/SquashFS inspection, transforms, rules, patching, case export, and spectrogram image/audio tools.
|
||||
- Secure credential model using OS keychain first, encrypted vault fallback, per-profile secret modes, known hosts, encrypted exports, and audit logging.
|
||||
- Productivity tools including command palette, snippets, trigger rules, session recording, terminal search, scrollback export, and local terminal.
|
||||
|
||||
## Who it is for
|
||||
|
||||
- Developers who connect to many application servers, staging environments, and production hosts.
|
||||
- Platform and DevOps teams who need organised, secure access across infrastructure.
|
||||
- Support teams who troubleshoot customer systems, network appliances, and legacy Telnet devices.
|
||||
- Network and hardware engineers who need RAW TCP and Telnet tools beside normal SSH workflows.
|
||||
- Homelab users who want SSH, VNC, file transfer, and repo access in one desktop app.
|
||||
- Reverse engineers, firmware analysts, and security researchers who want binary triage beside their live operational sessions.
|
||||
|
||||
## Learn more
|
||||
|
||||
For a short guide, see the quick start: [docs/quick-start.md](quick-start.md).
|
||||
|
||||
For a full guide, see the user manual: [docs/user-manual.md](user-manual.md).
|
||||
@@ -0,0 +1,71 @@
|
||||
# Nexus Terminal Quick Start
|
||||
|
||||
Nexus Terminal is a desktop client for SSH, Telnet, RAW TCP, VNC, GitHub, local terminal sessions, and Leviathan binary analysis. This page is a shareable quick start for everyday use.
|
||||
|
||||
## Get connected in minutes
|
||||
|
||||
1. Open Nexus Terminal.
|
||||
2. Click **New** in the sidebar.
|
||||
3. Choose a protocol and enter the connection details.
|
||||
4. Click **Connect** to open the session in a new tab.
|
||||
|
||||
One-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** for quick connect.
|
||||
|
||||
## Daily workflow
|
||||
|
||||
- **Tabs and splits**: Drag tabs to reorder, and split the workspace to view multiple sessions at once.
|
||||
- **Scrollable tabs**: Use the tab bar scrollbar or mouse wheel/trackpad over the tab strip when many tabs are open. Hover a tab to see its full name and connection start time.
|
||||
- **Broadcast input**: Toggle with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux) to send the same command to all connected sessions.
|
||||
- **Snippets**: Save common commands and run them with one click.
|
||||
- **Search**: Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search within the active terminal.
|
||||
- **Reconnect quickly**: If a session drops, click **Reconnect** on the disconnected overlay.
|
||||
|
||||
## VNC remote desktops
|
||||
|
||||
Create a **VNC** profile for remote desktop sessions. Use SSH tunnelling when possible, especially outside trusted networks. Direct VNC is available for isolated or trusted networks, but classic VNC traffic may not be encrypted.
|
||||
|
||||
## Leviathan binary tools
|
||||
|
||||
Click **Leviathan** beside **Nexus Terminal** in the sidebar header to switch from connection profiles to binary-analysis tools. Use **Load** to choose a file, then open tools such as Hex Editor, Overview, Hashes, Entropy, Strings, Carve, BinSnoop, Structures, PDF, SquashFS, Transforms, and Spectrogram tools. Tools open in normal tabs and can be closed without a disconnect confirmation.
|
||||
|
||||
## File transfers on SSH
|
||||
|
||||
Click the **folder** icon on an SSH tab to open the SFTP browser. You can browse, upload, download, rename, and delete files.
|
||||
|
||||
### Drag and drop files
|
||||
|
||||
Drop file(s) into the terminal and choose to:
|
||||
|
||||
- **Upload** to a selected remote folder, or
|
||||
- **Paste contents** into the terminal as if typed.
|
||||
|
||||
The upload picker lists folders only and marks permissions:
|
||||
|
||||
- **READ ONLY**: you can read but cannot write
|
||||
- **NO READ**: you cannot access the folder
|
||||
|
||||
## Security basics
|
||||
|
||||
- Credentials are stored in the OS keychain where available.
|
||||
- An encrypted vault is used as a fallback.
|
||||
- SSH host key checks can be set per profile (Strict, Ask, Accept new, Insecure).
|
||||
|
||||
## Common shortcuts
|
||||
|
||||
| macOS Shortcut | Windows/Linux Shortcut | Action |
|
||||
| --------------- | ---------------------- | ------------------------ |
|
||||
| Cmd + K | Ctrl + K | Command palette |
|
||||
| Cmd + N | Ctrl + N | New connection |
|
||||
| Cmd + W | Ctrl + W | Close tab |
|
||||
| Cmd + F | Ctrl + F | Find in terminal |
|
||||
| Cmd + Shift + S | Ctrl + Shift + S | Snippet library |
|
||||
| Cmd + Shift + B | Ctrl + Shift + B | Broadcast input |
|
||||
| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
|
||||
| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
|
||||
| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
|
||||
| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
|
||||
|
||||
## Need more detail?
|
||||
|
||||
- Product overview: [docs/overview.md](overview.md)
|
||||
- User manual: [docs/user-manual.md](user-manual.md)
|
||||
@@ -0,0 +1,343 @@
|
||||
# Nexus Terminal User Manual
|
||||
|
||||
## Contents
|
||||
|
||||
1. Getting started
|
||||
2. The interface at a glance
|
||||
3. Creating and managing profiles
|
||||
4. Connection types
|
||||
5. Tabs and split panes
|
||||
6. Terminal actions and search
|
||||
7. Snippets and broadcast input
|
||||
8. SFTP file browser
|
||||
9. Drag-and-drop uploads and paste
|
||||
10. Session recordings
|
||||
11. Leviathan binary workbench
|
||||
12. Security and credentials
|
||||
13. Settings
|
||||
14. Troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## 1. Getting started
|
||||
|
||||
### Launch and connect
|
||||
|
||||
- Start Nexus Terminal.
|
||||
- Click **New** in the sidebar, choose a protocol, and fill in the profile details.
|
||||
- Click **Connect** to open a session in a new tab.
|
||||
|
||||
### Quick connect
|
||||
|
||||
Use **Cmd+Shift+Q** (macOS) or **Ctrl+Shift+Q** (Windows/Linux) to open a lightweight connect dialog. This starts a one-off session without saving a profile.
|
||||
|
||||
---
|
||||
|
||||
## 2. The interface at a glance
|
||||
|
||||
- **Sidebar**: Profiles grouped by folders or tags, with search at the top.
|
||||
- **Tab bar**: One tab per session with status indicators, badges, and quick actions.
|
||||
- **Workspace**: The active terminal or viewer; split panes appear here.
|
||||
- **Status bar**: Connection state and session details.
|
||||
|
||||
Screenshot: Main window with sidebar, tab bar, and active terminal.
|
||||
|
||||
Tip: You can drag the divider between split panes to rebalance space.
|
||||
|
||||
---
|
||||
|
||||
## 3. Creating and managing profiles
|
||||
|
||||
### Create a new profile
|
||||
|
||||
1. Click **New** in the sidebar.
|
||||
2. Choose the protocol (SSH, Telnet, RAW, VNC, or GitHub).
|
||||
3. Fill in the required fields and optional settings.
|
||||
4. Save and connect.
|
||||
|
||||
Screenshot: New profile form with protocol selector and connection fields.
|
||||
|
||||
### Edit, duplicate, or delete
|
||||
|
||||
Right-click a profile in the sidebar to edit, duplicate, or delete it. You can also click the pencil icon on hover, or double-click the item to edit.
|
||||
|
||||
### Folders, tags, and favourites
|
||||
|
||||
Use folders and tags to organise profiles. Mark profiles as favourites for quick access.
|
||||
|
||||
Tip: Use the search box to filter profiles by name, host, or tag.
|
||||
|
||||
---
|
||||
|
||||
## 4. Connection types
|
||||
|
||||
### SSH
|
||||
|
||||
- Password, public key, keyboard-interactive, SSH agent, and TOTP/2FA.
|
||||
- Jump hosts, port forwarding, proxy support, and auto-reconnect.
|
||||
- Built-in SFTP browser and session recording.
|
||||
|
||||
### Telnet
|
||||
|
||||
- Option negotiation, encoding, local echo, and line endings.
|
||||
- Auto-login scripts and auto-reconnect.
|
||||
|
||||
### RAW TCP
|
||||
|
||||
- Text, hex, and split views for binary-safe data.
|
||||
- Payload presets and configurable encoding and line endings.
|
||||
|
||||
### VNC
|
||||
|
||||
- Remote desktop viewer embedded in the Nexus workspace.
|
||||
- Password prompt and saved-password modes.
|
||||
- View-only mode, scale-to-fit, shared sessions, and quality controls.
|
||||
- Optional SSH tunnel for safer VNC transport.
|
||||
|
||||
Use SSH tunnelling for VNC whenever possible. Direct VNC is useful on trusted or isolated networks, but many classic VNC deployments do not provide strong transport encryption.
|
||||
|
||||
### GitHub
|
||||
|
||||
- Browse repositories by organisation.
|
||||
- Sorted by most recent activity.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tabs and split panes
|
||||
|
||||
- **Reorder** tabs by dragging them.
|
||||
- **Pin** important sessions to keep them at the front.
|
||||
- **Scroll** the tab bar with the scrollbar or mouse wheel/trackpad when many tabs are open.
|
||||
- **Hover** a tab to see the full name and, for connected sessions, the connection start time.
|
||||
- **Close** tabs with the close button on the left side of each tab. Leviathan tool tabs close without a disconnect confirmation.
|
||||
- **Split** the workspace vertically or horizontally to view multiple sessions at once.
|
||||
- **Broadcast** input to multiple connected sessions when needed.
|
||||
|
||||
Screenshot: Tab bar with pinned sessions and split panes.
|
||||
|
||||
Tip: Pinned tabs stay at the front so key systems never get buried.
|
||||
|
||||
---
|
||||
|
||||
## 6. Terminal actions and search
|
||||
|
||||
### Context menu
|
||||
|
||||
Right-click in the terminal for:
|
||||
|
||||
- Copy and paste
|
||||
- Select all
|
||||
- Find
|
||||
- Send snippet
|
||||
- Clear and reset
|
||||
|
||||
Screenshot: Terminal context menu and search bar.
|
||||
|
||||
### Search
|
||||
|
||||
Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search in the active terminal. Toggle case sensitivity and regular expressions as needed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Snippets and broadcast input
|
||||
|
||||
### Snippets
|
||||
|
||||
Open the snippet library with **Cmd+Shift+S** (macOS) or **Ctrl+Shift+S** (Windows/Linux) and save frequently-used commands. Send a snippet to the active session with one click.
|
||||
|
||||
Screenshot: Snippet library with search and folder grouping.
|
||||
|
||||
### Broadcast input
|
||||
|
||||
Toggle broadcast mode with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux). While active, all input is sent to every connected session.
|
||||
|
||||
---
|
||||
|
||||
## 8. SFTP file browser
|
||||
|
||||
Open the file browser with the **folder** icon on an SSH tab. You can:
|
||||
|
||||
- Navigate folders using the breadcrumb trail.
|
||||
- Upload, download, rename, and delete files.
|
||||
- Refresh and create folders.
|
||||
|
||||
The browser runs inside the SSH session and shows remote contents in a side panel.
|
||||
|
||||
Screenshot: SFTP browser open alongside the terminal.
|
||||
|
||||
---
|
||||
|
||||
## 9. Drag-and-drop uploads and paste
|
||||
|
||||
When you drop file(s) into an active terminal, Nexus Terminal asks whether to:
|
||||
|
||||
- **Upload** to a chosen remote directory, or
|
||||
- **Paste contents** into the terminal as if typed.
|
||||
|
||||
### Upload directory picker
|
||||
|
||||
The picker looks like the SFTP browser and lists remote folders only. It shows:
|
||||
|
||||
- **READ ONLY** for folders you cannot write to.
|
||||
- **NO READ** for folders you cannot access.
|
||||
|
||||
Use the filter box to narrow the list, and the breadcrumb trail or **Up** button to move around. You can also use the keyboard:
|
||||
|
||||
- **Up/Down**: move selection
|
||||
- **Enter**: choose the selected folder
|
||||
- **Escape**: cancel
|
||||
|
||||
If the SFTP browser is already open, the picker starts in the same directory.
|
||||
|
||||
Screenshot: Upload directory picker with permissions badges.
|
||||
|
||||
Tip: Use the filter box to jump quickly to common folders.
|
||||
|
||||
---
|
||||
|
||||
## 10. Session recordings
|
||||
|
||||
Toggle recording with **Cmd+Shift+R** (macOS) or **Ctrl+Shift+R** (Windows/Linux). Recordings are saved as asciicast v2 files and can be replayed from the recordings panel.
|
||||
|
||||
Screenshot: Recordings list with playback controls.
|
||||
|
||||
---
|
||||
|
||||
## 11. Leviathan binary workbench
|
||||
|
||||
Leviathan is the integrated binary-analysis and editing workspace inside Nexus Terminal. It is useful for firmware images, unknown binaries, archives, captures, embedded files, suspicious documents, and audio spectrogram experiments.
|
||||
|
||||
### Switching to Leviathan
|
||||
|
||||
Click **Leviathan** in the top-left sidebar header. The normal connection list changes into a tool list. Click **Nexus Terminal** to return to saved servers and groups.
|
||||
|
||||
Use **Load** to select a local file. The selected file becomes the current Leviathan working document. Use **Save** to write the patched working copy to a chosen destination.
|
||||
|
||||
Leviathan tools open as regular tabs. You can open multiple tool tabs at once, switch between them, and close them without a connection-disconnect confirmation.
|
||||
|
||||
### Hex Editor
|
||||
|
||||
The Hex Editor is the primary byte-level view.
|
||||
|
||||
- Choose 16 or 32 bytes per row.
|
||||
- Edit byte cells directly; typing two hex digits advances to the next byte.
|
||||
- The ASCII preview updates as bytes are edited.
|
||||
- Paste hex runs such as `de ad be ef` or `deadbeef` into consecutive cells.
|
||||
- Use Search to find strings or hex values, then move through matches with previous/next.
|
||||
- Undo, redo, or revert edits from the Leviathan panel header.
|
||||
|
||||
### Triage tools
|
||||
|
||||
- **Overview**: first-pass summary across hashes, entropy, strings, indicators, and carve candidates.
|
||||
- **Hashes**: CRC32, MD5, SHA-1, SHA-256, and SHA-512.
|
||||
- **Entropy**: graph entropy across the whole file; select a region to open that range in a new Hex Editor tab.
|
||||
- **Strings**: ASCII and UTF-16 string extraction.
|
||||
- **IOCs**: URLs, domains, paths, hashes, and other indicators.
|
||||
- **Search** and **Multi-search**: text, hex, and regular-expression matching.
|
||||
|
||||
### Structure and recovery tools
|
||||
|
||||
- **Structures** parses common formats such as PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1, and certificates.
|
||||
- **Disasm** provides heuristic disassembly for common CPU families and raw byte fallback where decoding is not implemented.
|
||||
- **PDF** inspects objects, streams, xrefs, and suspicious surfaces.
|
||||
- **SquashFS** inspects filesystem superblocks and metadata; non-SquashFS input shows a human-readable bad-magic error.
|
||||
- **Carve** and **BinSnoop** scan for embedded files and signatures and can export discovered files or ranges.
|
||||
- **Keys**, **Timestamps**, **Stego**, and **Metadata** help recover common artefacts from binaries and containers.
|
||||
|
||||
### Editing, transforms, and cases
|
||||
|
||||
- **Patch Plan** validates patch scripts without modifying the working copy.
|
||||
- **Patch Apply** applies validated patch scripts.
|
||||
- **Transforms** runs pipelines such as hex, Base64, URL, XOR, inflate, and cipher transforms over selected data.
|
||||
- **Rules** runs YARA-like scans over the current document.
|
||||
- **Case Export** packages investigation artefacts from the working document.
|
||||
|
||||
### Spectrogram tools
|
||||
|
||||
- **Spectrogram Encode** converts an image into WAV audio so the image appears in a spectrogram.
|
||||
- It can also insert an encoded image into an existing WAV or MP3 carrier.
|
||||
- **Spectrogram View** renders WAV or MP3 audio as a spectrogram image.
|
||||
- Controls include FFT size, height, maximum width, aspect ratio, frequency range, brightness, and contrast.
|
||||
|
||||
### Large file behaviour
|
||||
|
||||
Leviathan keeps loaded files file-backed where possible and writes patched working copies in chunks. Some tools still need bounded in-memory buffers because the underlying parser or decoder works on complete byte slices; those tools reject oversized inputs with a clear message rather than trying to allocate unbounded memory.
|
||||
|
||||
---
|
||||
|
||||
## 12. Security and credentials
|
||||
|
||||
Nexus Terminal stores credentials securely:
|
||||
|
||||
- **OS keychain first** (Keychain, Secret Service, or Credential Manager).
|
||||
- **Encrypted vault** fallback using AES-256-GCM with Argon2id.
|
||||
|
||||
### Host key verification
|
||||
|
||||
Choose one of the following policies per SSH profile:
|
||||
|
||||
- **Strict**: only accept previously saved fingerprints.
|
||||
- **Ask**: prompt before trusting a new host key.
|
||||
- **Accept new**: trust first-time keys, reject changes.
|
||||
- **Insecure**: accept any key (not recommended).
|
||||
|
||||
### TOTP / 2FA
|
||||
|
||||
Store a TOTP secret in the profile editor to auto-fill verification codes during SSH authentication.
|
||||
|
||||
Screenshot: TOTP configuration in the profile editor.
|
||||
|
||||
---
|
||||
|
||||
## 13. Settings
|
||||
|
||||
Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux) and configure:
|
||||
|
||||
- Terminal themes and fonts
|
||||
- Default behaviours
|
||||
- Secrets and vault settings
|
||||
- Recording and shortcut preferences
|
||||
|
||||
Screenshot: Settings panel with appearance and security options.
|
||||
|
||||
---
|
||||
|
||||
## 14. Troubleshooting
|
||||
|
||||
### Session shows as disconnected
|
||||
|
||||
- The terminal is blurred and shows a **DISCONNECTED** overlay when the connection drops.
|
||||
- Click **Reconnect** to reopen the session with the same profile.
|
||||
|
||||
### Upload fails or is greyed out
|
||||
|
||||
- Check the folder is writable; **READ ONLY** indicates no write permission.
|
||||
- Confirm the SSH session is connected.
|
||||
|
||||
### VNC will not connect
|
||||
|
||||
- Confirm the VNC host and port are correct. The common VNC port is `5900` plus the display number.
|
||||
- If using a password, check whether the profile is set to saved, session-only, or ask-every-time mode.
|
||||
- If using an SSH tunnel, verify the tunnel host, username, authentication method, and SSH host key policy.
|
||||
- If direct VNC is enabled, test from a trusted network first and prefer an SSH tunnel for untrusted networks.
|
||||
|
||||
### Leviathan cannot load or save a file
|
||||
|
||||
- Check that the file still exists and that your user account can read it.
|
||||
- When saving, choose a writable destination and avoid overwriting a file that another process has locked.
|
||||
- Some tools enforce explicit size limits for safety. If a tool rejects an oversized input, try selecting a smaller range or using a tool that works on the file-backed document.
|
||||
|
||||
### Cannot read a folder
|
||||
|
||||
- **NO READ** indicates insufficient permissions.
|
||||
- Try a parent folder or check remote access controls.
|
||||
|
||||
### Session keeps reconnecting
|
||||
|
||||
- Check network stability and credentials.
|
||||
- Review auto-reconnect settings in the profile.
|
||||
|
||||
### Terminal size looks wrong
|
||||
|
||||
- Try disconnecting and reconnecting the session.
|
||||
- Avoid heavy UI resizing during initial connection.
|
||||
@@ -1,82 +1,191 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nexus Terminal</title>
|
||||
|
||||
<!-- Vendor styles -->
|
||||
<link rel="stylesheet" href="/src/css/lib/xterm.css">
|
||||
<link rel="stylesheet" href="/src/css/lib/xterm.css" />
|
||||
|
||||
<!-- Design system -->
|
||||
<link rel="stylesheet" href="/src/css/tokens.css">
|
||||
<link rel="stylesheet" href="/src/css/base.css">
|
||||
<link rel="stylesheet" href="/src/css/components.css">
|
||||
<link rel="stylesheet" href="/src/css/layout.css">
|
||||
<link rel="stylesheet" href="/src/css/sidebar.css">
|
||||
<link rel="stylesheet" href="/src/css/tabs.css">
|
||||
<link rel="stylesheet" href="/src/css/terminal.css">
|
||||
<link rel="stylesheet" href="/src/css/forms.css">
|
||||
<link rel="stylesheet" href="/src/css/modals.css">
|
||||
<link rel="stylesheet" href="/src/css/github.css">
|
||||
</head>
|
||||
<body>
|
||||
<link rel="stylesheet" href="/src/css/tokens.css" />
|
||||
<link rel="stylesheet" href="/src/css/base.css" />
|
||||
<link rel="stylesheet" href="/src/css/components.css" />
|
||||
<link rel="stylesheet" href="/src/css/layout.css" />
|
||||
<link rel="stylesheet" href="/src/css/sidebar.css" />
|
||||
<link rel="stylesheet" href="/src/css/tabs.css" />
|
||||
<link rel="stylesheet" href="/src/css/terminal.css" />
|
||||
<link rel="stylesheet" href="/src/css/splitpane.css" />
|
||||
<link rel="stylesheet" href="/src/css/recordings.css" />
|
||||
<link rel="stylesheet" href="/src/css/sftp.css" />
|
||||
<link rel="stylesheet" href="/src/css/forms.css" />
|
||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||
<link rel="stylesheet" href="/src/css/github.css" />
|
||||
<link rel="stylesheet" href="/src/css/vnc.css" />
|
||||
<link rel="stylesheet" href="/src/css/irc.css" />
|
||||
<link rel="stylesheet" href="/src/css/leviathan.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app">
|
||||
|
||||
<!-- ============================================================
|
||||
<!-- ============================================================
|
||||
Sidebar — connection manager
|
||||
============================================================ -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header wails-drag">
|
||||
<div class="sidebar-logo">
|
||||
<!-- Terminal prompt icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
<span class="sidebar-title">Nexus Terminal</span>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header wails-drag">
|
||||
<div class="sidebar-logo">
|
||||
<!-- Terminal prompt icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
<span class="sidebar-product-switch" aria-label="Application mode">
|
||||
<button
|
||||
class="sidebar-title sidebar-mode-toggle is-active"
|
||||
id="mode-nexus"
|
||||
type="button"
|
||||
>
|
||||
Nexus Terminal
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-title sidebar-mode-toggle"
|
||||
id="mode-leviathan"
|
||||
type="button"
|
||||
>
|
||||
Leviathan
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-search">
|
||||
<div class="search-wrapper">
|
||||
<input type="text"
|
||||
class="search-input"
|
||||
id="profile-search"
|
||||
placeholder="Search connections…"
|
||||
autocomplete="off"
|
||||
spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-search">
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="profile-search"
|
||||
placeholder="Search connections…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav" id="sidebar-nav">
|
||||
<!-- Dynamically managed by sidebar.js -->
|
||||
<div id="sidebar-profiles" class="sidebar-profiles"></div>
|
||||
</nav>
|
||||
<nav class="sidebar-nav" id="sidebar-nav">
|
||||
<!-- Dynamically managed by sidebar.js -->
|
||||
<div id="sidebar-profiles" class="sidebar-profiles"></div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn btn-ghost btn-sm" id="btn-new-connection" title="New Connection">
|
||||
<span>+ New</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-import" title="Import">
|
||||
<!-- Upload icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-settings" title="Settings">
|
||||
<!-- Cog icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
|
||||
<div class="sidebar-footer">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
id="btn-new-connection"
|
||||
title="New Connection"
|
||||
>
|
||||
<span>+ New</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
id="btn-command-palette"
|
||||
title="Command Palette (⌘K)"
|
||||
>
|
||||
<span>⌘K</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-import"
|
||||
title="Import Profiles"
|
||||
>
|
||||
<!-- Upload icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-export"
|
||||
title="Export Profiles"
|
||||
>
|
||||
<!-- Download icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-recordings"
|
||||
title="Session Recordings"
|
||||
>
|
||||
<!-- Film icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-settings"
|
||||
title="Settings"
|
||||
>
|
||||
<!-- Cog icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
|
||||
0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65
|
||||
0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0
|
||||
0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9
|
||||
@@ -92,62 +201,109 @@
|
||||
2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65
|
||||
1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51
|
||||
1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65
|
||||
1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
1.65 0 0 0-1.51 1z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============================================================
|
||||
<!-- ============================================================
|
||||
Tab bar — session tabs
|
||||
============================================================ -->
|
||||
<div class="tab-bar" id="tab-bar">
|
||||
<div class="tab-list" id="tab-list">
|
||||
<!-- Tabs are injected dynamically by JS -->
|
||||
</div>
|
||||
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
|
||||
<div class="tab-bar" id="tab-bar">
|
||||
<div class="tab-list" id="tab-list">
|
||||
<!-- Tabs are injected dynamically by JS -->
|
||||
</div>
|
||||
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
|
||||
<button
|
||||
class="tab-broadcast"
|
||||
id="btn-broadcast"
|
||||
title="Toggle Broadcast Mode (Ctrl+Shift+B)"
|
||||
>
|
||||
<!-- Broadcast icon (loudspeaker) -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
|
||||
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
|
||||
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
<!-- ============================================================
|
||||
Workspace — terminal area
|
||||
============================================================ -->
|
||||
<main class="workspace" id="workspace">
|
||||
<!-- Empty state — shown when no sessions are active -->
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<!-- Terminal icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No active sessions</h2>
|
||||
<p>Select a connection from the sidebar or create a new one to get started.</p>
|
||||
<div class="empty-state-actions">
|
||||
<button class="btn btn-primary" id="btn-empty-connect">New Connection</button>
|
||||
<button class="btn btn-secondary" id="btn-empty-quick">Quick Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
<main class="workspace" id="workspace">
|
||||
<!-- Empty state — shown when no sessions are active -->
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<!-- Terminal icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No active sessions</h2>
|
||||
<p>
|
||||
Select a connection from the sidebar or create a new one to get
|
||||
started.
|
||||
</p>
|
||||
<div class="empty-state-actions">
|
||||
<button class="btn btn-primary" id="btn-empty-connect">
|
||||
New Connection
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="btn-empty-quick">
|
||||
Quick Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session panels are injected here dynamically by tabs.js -->
|
||||
<div id="panel-container" class="panel-container"></div>
|
||||
</main>
|
||||
<!-- Session panels are injected here dynamically by tabs.js -->
|
||||
<div id="panel-container" class="panel-container"></div>
|
||||
</main>
|
||||
|
||||
<!-- ============================================================
|
||||
<!-- ============================================================
|
||||
Status bar
|
||||
============================================================ -->
|
||||
<footer class="status-bar" id="status-bar">
|
||||
<span id="status-connection">
|
||||
<span class="status-dot is-disconnected"></span>
|
||||
Disconnected
|
||||
</span>
|
||||
<span id="status-protocol"></span>
|
||||
<span id="status-session-count">0 sessions</span>
|
||||
<span class="status-spacer"></span>
|
||||
<span id="status-vault">🔒 Locked</span>
|
||||
</footer>
|
||||
<footer class="status-bar" id="status-bar">
|
||||
<span id="status-connection">
|
||||
<span class="status-dot is-disconnected"></span>
|
||||
Disconnected
|
||||
</span>
|
||||
<span id="status-protocol"></span>
|
||||
<span id="status-latency"></span>
|
||||
<span id="status-session-count">0 sessions</span>
|
||||
<span class="status-spacer"></span>
|
||||
<button
|
||||
class="status-update is-hidden"
|
||||
id="status-update"
|
||||
type="button"
|
||||
>
|
||||
Update available
|
||||
</button>
|
||||
<span id="status-vault">🔒 Locked</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Modal container (dynamically populated) -->
|
||||
@@ -160,21 +316,36 @@
|
||||
<script src="/src/js/lib/xterm.min.js"></script>
|
||||
<script src="/src/js/lib/xterm-addon-fit.min.js"></script>
|
||||
<script src="/src/js/lib/xterm-addon-search.min.js"></script>
|
||||
<script src="/src/js/lib/xterm-addon-unicode11.min.js"></script>
|
||||
<script src="/src/js/lib/xterm-addon-web-links.min.js"></script>
|
||||
|
||||
<!-- Wails service bridge; loaded before application code. -->
|
||||
<script src="/src/js/wails-service.js"></script>
|
||||
|
||||
<!-- Application scripts (loaded in dependency order) -->
|
||||
<script src="/src/js/utils.js"></script>
|
||||
<script src="/src/js/state.js"></script>
|
||||
<script src="/src/js/notifications.js"></script>
|
||||
<script src="/src/js/sidebar.js"></script>
|
||||
<script src="/src/js/tabs.js"></script>
|
||||
<script src="/src/js/themes.js"></script>
|
||||
<script src="/src/js/terminal.js"></script>
|
||||
<script src="/src/js/profiles.js"></script>
|
||||
<script src="/src/js/raw.js"></script>
|
||||
<script src="/src/js/github.js"></script>
|
||||
<script src="/src/js/lib/novnc.min.js"></script>
|
||||
<script src="/src/js/vnc.js"></script>
|
||||
<script src="/src/js/irc.js"></script>
|
||||
<script src="/src/js/settings.js"></script>
|
||||
<script src="/src/js/commands.js"></script>
|
||||
<script src="/src/js/secrets.js"></script>
|
||||
<script src="/src/js/recordings.js"></script>
|
||||
<script src="/src/js/sftp.js"></script>
|
||||
<script src="/src/js/splitpane.js"></script>
|
||||
<script src="/src/js/snippets.js"></script>
|
||||
<script src="/src/js/sshkeys.js"></script>
|
||||
<script src="/src/js/triggers.js"></script>
|
||||
<script src="/src/js/leviathan.js"></script>
|
||||
<script src="/src/js/app.js"></script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,47 +9,538 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-web-links": "0.9.0"
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/addon-search": "0.16.0",
|
||||
"@xterm/addon-unicode11": "0.9.0",
|
||||
"@xterm/addon-web-links": "0.12.0",
|
||||
"@xterm/xterm": "6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@novnc/novnc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
|
||||
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-search": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz",
|
||||
"integrity": "sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-search instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-web-links": {
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
||||
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,21 @@
|
||||
"description": "Frontend for Nexus Terminal desktop SSH client",
|
||||
"scripts": {
|
||||
"install:assets": "node scripts/copy-assets.js",
|
||||
"bundle:novnc": "node scripts/bundle-novnc.js",
|
||||
"build": "node scripts/build.js",
|
||||
"dev": "node scripts/build.js --watch",
|
||||
"postinstall": "npm run install:assets"
|
||||
"test": "node scripts/test-regressions.js",
|
||||
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
||||
},
|
||||
"dependencies": {
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-web-links": "0.9.0"
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/addon-search": "0.16.0",
|
||||
"@xterm/addon-unicode11": "0.9.0",
|
||||
"@xterm/addon-web-links": "0.12.0",
|
||||
"@xterm/xterm": "6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
a25ea4f4df93e50eb6c41092b5187a9d
|
||||
2f3e27ceb2ed8494c242e0b06d9f4da3
|
||||
@@ -28,6 +28,7 @@ const requiredFiles = [
|
||||
'dist/src/js/lib/xterm-addon-fit.min.js',
|
||||
'dist/src/js/lib/xterm-addon-search.min.js',
|
||||
'dist/src/js/lib/xterm-addon-web-links.min.js',
|
||||
'dist/src/js/wails-service.js',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,14 @@ function copyDirSync(src, dest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies one file and creates the parent directory if needed.
|
||||
*/
|
||||
function copyFileSync(src, dest) {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a directory tree if it exists.
|
||||
*/
|
||||
@@ -55,6 +64,53 @@ function cleanDir(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyWailsRuntimeBindings(srcRoot, destRoot) {
|
||||
const runtimeFiles = [
|
||||
'go/api/Service.js',
|
||||
'runtime/runtime.js',
|
||||
];
|
||||
|
||||
for (const relPath of runtimeFiles) {
|
||||
const src = path.join(srcRoot, relPath);
|
||||
if (fs.existsSync(src)) {
|
||||
copyFileSync(src, path.join(destRoot, relPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walkFiles(dir, predicate, files = []) {
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(full, predicate, files);
|
||||
} else if (entry.isFile() && predicate(full)) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function cleanupDistAssets() {
|
||||
console.log('[build] Cleaning production frontend assets...');
|
||||
const sourceMapFiles = walkFiles(distDir, (file) => file.endsWith('.js') || file.endsWith('.css'));
|
||||
let mapCommentCount = 0;
|
||||
for (const file of sourceMapFiles) {
|
||||
const input = fs.readFileSync(file, 'utf8');
|
||||
const output = input
|
||||
.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*?(?=\r?\n|$)/g, '')
|
||||
.replace(/(?:\r?\n)?\/\*# sourceMappingURL=.*?\*\/(?=\r?\n|$)/g, '');
|
||||
if (output !== input) {
|
||||
fs.writeFileSync(file, output);
|
||||
mapCommentCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (mapCommentCount > 0) {
|
||||
console.log(`[build] Removed ${mapCommentCount} source-map reference(s).`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the asset copy script to pull xterm files into src/js/lib/ and
|
||||
* src/css/lib/.
|
||||
@@ -65,6 +121,10 @@ function copyAssets() {
|
||||
cwd: frontendDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
execSync('node scripts/bundle-novnc.js', {
|
||||
cwd: frontendDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +132,7 @@ function copyAssets() {
|
||||
* dist/index.html
|
||||
* dist/src/css/ (application + lib stylesheets)
|
||||
* dist/src/js/ (application + lib scripts)
|
||||
* dist/wailsjs/ (generated backend bindings, if present)
|
||||
* dist/src/components/ (if present)
|
||||
* dist/src/assets/ (if present)
|
||||
*/
|
||||
@@ -94,6 +155,13 @@ function buildDist() {
|
||||
copyDirSync(srcDir, path.join(distDir, 'src'));
|
||||
}
|
||||
|
||||
// Copy only runtime Wails bindings so embedded production assets do not
|
||||
// carry TypeScript declarations and model files.
|
||||
const wailsjsDir = path.join(frontendDir, 'wailsjs');
|
||||
if (fs.existsSync(wailsjsDir)) {
|
||||
copyWailsRuntimeBindings(wailsjsDir, path.join(distDir, 'wailsjs'));
|
||||
}
|
||||
|
||||
console.log('[build] dist/ assembled.');
|
||||
}
|
||||
|
||||
@@ -151,6 +219,7 @@ try {
|
||||
}
|
||||
|
||||
buildDist();
|
||||
cleanupDistAssets();
|
||||
|
||||
if (!validate()) {
|
||||
console.error('[build] Validation failed — some required files are missing.');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* bundle-novnc.js — Uses esbuild to bundle noVNC's RFB class into a single
|
||||
* script that exposes window.noVNC.RFB for use in the frontend.
|
||||
*
|
||||
* noVNC 1.5+ uses top-level await which is incompatible with IIFE format,
|
||||
* so we bundle as ESM then wrap in an async IIFE via banner/footer.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const esbuild = require("esbuild");
|
||||
const path = require("path");
|
||||
|
||||
const entryPoint = path.resolve(__dirname, "novnc-entry.js");
|
||||
const outFile = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"js",
|
||||
"lib",
|
||||
"novnc.min.js",
|
||||
);
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: "esm",
|
||||
outfile: outFile,
|
||||
target: ["safari15"],
|
||||
logLevel: "info",
|
||||
banner: { js: ";(async function() {" },
|
||||
footer: { js: "})();" },
|
||||
})
|
||||
.then(() => {
|
||||
console.log(
|
||||
"[bundle-novnc] noVNC bundled successfully → src/js/lib/novnc.min.js",
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[bundle-novnc] Failed:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,34 +4,38 @@
|
||||
*
|
||||
* Run automatically via the postinstall hook in package.json.
|
||||
*/
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const frontendDir = path.resolve(__dirname, '..');
|
||||
const frontendDir = path.resolve(__dirname, "..");
|
||||
|
||||
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
|
||||
const assets = [
|
||||
{
|
||||
src: 'xterm/css/xterm.css',
|
||||
dest: 'src/css/lib/xterm.css',
|
||||
src: "@xterm/xterm/css/xterm.css",
|
||||
dest: "src/css/lib/xterm.css",
|
||||
},
|
||||
{
|
||||
src: 'xterm/lib/xterm.js',
|
||||
dest: 'src/js/lib/xterm.min.js',
|
||||
src: "@xterm/xterm/lib/xterm.js",
|
||||
dest: "src/js/lib/xterm.min.js",
|
||||
},
|
||||
{
|
||||
src: 'xterm-addon-fit/lib/xterm-addon-fit.js',
|
||||
dest: 'src/js/lib/xterm-addon-fit.min.js',
|
||||
src: "@xterm/addon-fit/lib/addon-fit.js",
|
||||
dest: "src/js/lib/xterm-addon-fit.min.js",
|
||||
},
|
||||
{
|
||||
src: 'xterm-addon-search/lib/xterm-addon-search.js',
|
||||
dest: 'src/js/lib/xterm-addon-search.min.js',
|
||||
src: "@xterm/addon-search/lib/addon-search.js",
|
||||
dest: "src/js/lib/xterm-addon-search.min.js",
|
||||
},
|
||||
{
|
||||
src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js',
|
||||
dest: 'src/js/lib/xterm-addon-web-links.min.js',
|
||||
src: "@xterm/addon-unicode11/lib/addon-unicode11.js",
|
||||
dest: "src/js/lib/xterm-addon-unicode11.min.js",
|
||||
},
|
||||
{
|
||||
src: "@xterm/addon-web-links/lib/addon-web-links.js",
|
||||
dest: "src/js/lib/xterm-addon-web-links.min.js",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -49,7 +53,7 @@ function ensureDir(filePath) {
|
||||
* Copies a single asset, logging the result.
|
||||
*/
|
||||
function copyAsset(entry) {
|
||||
const srcPath = path.join(frontendDir, 'node_modules', entry.src);
|
||||
const srcPath = path.join(frontendDir, "node_modules", entry.src);
|
||||
const destPath = path.join(frontendDir, entry.dest);
|
||||
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
@@ -65,7 +69,7 @@ function copyAsset(entry) {
|
||||
|
||||
// --- Main ---
|
||||
|
||||
console.log('[copy-assets] Copying xterm library files…');
|
||||
console.log("[copy-assets] Copying xterm library files…");
|
||||
|
||||
let failures = 0;
|
||||
for (const entry of assets) {
|
||||
@@ -80,4 +84,4 @@ if (failures > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[copy-assets] All assets copied successfully.');
|
||||
console.log("[copy-assets] All assets copied successfully.");
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Entry point for esbuild bundling of noVNC.
|
||||
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
|
||||
import RFB from "@novnc/novnc";
|
||||
import { initLogging } from "../node_modules/@novnc/novnc/core/util/logging.js";
|
||||
|
||||
initLogging("error");
|
||||
window.noVNC = { RFB: RFB };
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const repo = path.resolve(root, "..");
|
||||
|
||||
function read(rel) {
|
||||
return fs.readFileSync(path.join(repo, rel), "utf8");
|
||||
}
|
||||
|
||||
function assertContains(file, needle, message) {
|
||||
const text = read(file);
|
||||
if (!text.includes(needle)) {
|
||||
throw new Error(`${message}\nMissing ${JSON.stringify(needle)} in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotContains(file, needle, message) {
|
||||
const text = read(file);
|
||||
if (text.includes(needle)) {
|
||||
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTreeNotContains(relDir, needle, message) {
|
||||
const dir = path.join(repo, relDir);
|
||||
const offenders = [];
|
||||
const walk = (current) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".js")) continue;
|
||||
const rel = path.relative(repo, full);
|
||||
if (read(rel).includes(needle)) offenders.push(rel);
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
if (offenders.length) {
|
||||
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${offenders.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"escapeHtml(title)",
|
||||
"file-drop prompt titles must escape filenames containing HTML characters",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"SFTPUploadStart",
|
||||
"drag-drop upload must use chunked SFTP streaming",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"SFTPUploadChunk",
|
||||
"drag-drop upload must send bounded chunks",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"scheduleFit",
|
||||
"terminal resize handling must be throttled through scheduled fits",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"dimensionsChanged",
|
||||
"terminal resize handling must skip unchanged container dimensions",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"cancelScheduledFit",
|
||||
"terminal teardown must cancel pending fit work",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"new ResizeObserver(() => {\n try {\n fitAddon.fit();",
|
||||
"ResizeObserver must not call fitAddon.fit() directly",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/src/js/terminal.js",
|
||||
"Use SFTP Upload for files over 25 MB",
|
||||
"drag-drop upload must not reject files only because they exceed the old inline limit",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/app.js",
|
||||
"confirmDirectVNCProfile",
|
||||
"direct VNC connections must show an explicit warning",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/profiles.js",
|
||||
"skipDirectVNCWarning",
|
||||
"save-and-connect should not show duplicate direct VNC prompts",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/recordings.js",
|
||||
"import-status",
|
||||
"profile import failures must be visible inside the import modal",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/recordings.js",
|
||||
"_formatImportError",
|
||||
"profile import failures must be formatted without using HTML injection",
|
||||
);
|
||||
assertContains(
|
||||
"internal/api/api_test.go",
|
||||
"TestCreateProfileRejectsUnsafeGitHubBaseURL",
|
||||
"GitHub HTTP/HTTPS URL validation must stay covered",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/index.html",
|
||||
"mode-leviathan",
|
||||
"Leviathan mode toggle must be present beside Nexus Terminal",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/sidebar.css",
|
||||
"user-select: none",
|
||||
"sidebar title and mode switcher text must not be selectable",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/sidebar.css",
|
||||
"#mode-leviathan",
|
||||
"Leviathan mode label must have its own right-alignment rule",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/sidebar.css",
|
||||
"margin-left: auto;",
|
||||
"Leviathan mode label must sit on the right side of the sidebar header",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/tokens.css",
|
||||
"--tab-bar-height: 44px;",
|
||||
"tab bar row must match the sidebar header height",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/sidebar.css",
|
||||
"height: var(--tab-bar-height);",
|
||||
"sidebar header must use the shared tab bar height",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/layout.css",
|
||||
"height: var(--tab-bar-height);",
|
||||
"tab bar must use the shared sidebar header height",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"this._setupScrolling();",
|
||||
"tab bar must enable horizontal scroll handling for overflowed tabs",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"this._setupTooltip();",
|
||||
"tab bar must show rich hover hints for truncated tabs",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"removeTab(sessionId) {\n this._hideTabTooltip();",
|
||||
"removing a tab must clear any visible tab tooltip",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"async closeTab(sessionId) {\n this._hideTabTooltip();",
|
||||
"closing a tab must clear any visible or pending tab tooltip",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"!tab?.isConnected",
|
||||
"tab tooltip timers must not show hints for removed tab elements",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"_clearTabTooltipTimer",
|
||||
"tab tooltip cleanup must cancel pending hover timers",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"session.protocol !== \"leviathan\"",
|
||||
"Leviathan tabs must close without connected-session confirmation",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"session.protocol !== \"github\"",
|
||||
"GitHub tabs must close without connected-session confirmation",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"Connected since:",
|
||||
"connection tab hover hints must include the connected-since timestamp",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/tabs.js",
|
||||
"tab.appendChild(closeBtn);\n\n // Protocol badge",
|
||||
"tab close button must render on the left side of each tab",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/layout.css",
|
||||
"scrollbar-gutter: stable;",
|
||||
"overflowing tab lists must expose a stable horizontal scrollbar",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/css/tabs.css",
|
||||
".tab-tooltip",
|
||||
"tab hover hints must have dedicated tooltip styling",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"Hex Editor",
|
||||
"Leviathan mode must put Hex Editor in the tool list",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/settings.js",
|
||||
"Leviathan Binary Tools",
|
||||
"in-app help must document the integrated Leviathan workbench",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/settings.js",
|
||||
"VNC Connections",
|
||||
"in-app help must document VNC connection profiles",
|
||||
);
|
||||
assertContains(
|
||||
"docs/user-manual.md",
|
||||
"## 11. Leviathan binary workbench",
|
||||
"user manual must include a full Leviathan section",
|
||||
);
|
||||
assertContains(
|
||||
"docs/user-manual.md",
|
||||
"### VNC",
|
||||
"user manual must include VNC connection guidance",
|
||||
);
|
||||
assertContains(
|
||||
"docs/overview.md",
|
||||
"### Leviathan binary workbench",
|
||||
"product overview must include Leviathan as a first-class feature group",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"LeviathanSetByte",
|
||||
"Leviathan hex view must support byte editing",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"_handleHexByteInput",
|
||||
"Leviathan hex byte edits must update while typing",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"_updateHexAsciiCell",
|
||||
"Leviathan hex byte edits must update the ASCII preview immediately",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"_focusHexByte(cell, 1)",
|
||||
"Leviathan hex byte edits must advance to the next byte after entry",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"spectrogram-encode",
|
||||
"Leviathan must expose an image-to-audio spectrogram encoder",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"spectrogram-view",
|
||||
"Leviathan must expose an audio spectrogram visualiser",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"preserveAspectRatio",
|
||||
"Leviathan spectrogram encoding must expose aspect-ratio preservation",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"aspectRatio",
|
||||
"Leviathan spectrogram visualiser must expose display aspect-ratio control",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/leviathan.js",
|
||||
"spectrogram-insert",
|
||||
"Leviathan spectrogram encoder must support inserting images into carrier audio",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/app.js",
|
||||
"setMode(\"leviathan\")",
|
||||
"App mode switch must route to Leviathan",
|
||||
);
|
||||
assertContains(
|
||||
"internal/api/leviathan.go",
|
||||
"LeviathanRunTool",
|
||||
"Nexus API must expose the imported Leviathan engine",
|
||||
);
|
||||
assertNotContains(
|
||||
"internal/api/leviathan.go",
|
||||
"Pattern: \"*\"",
|
||||
"Leviathan open dialogs must not use wildcard filters that grey out files on macOS",
|
||||
);
|
||||
assertContains(
|
||||
"internal/leviathan/spectrogram/spectrogram.go",
|
||||
"github.com/hajimehoshi/go-mp3",
|
||||
"Leviathan spectrogram visualiser must support MP3 decoding",
|
||||
);
|
||||
assertNotContains(
|
||||
"wails.json",
|
||||
"\"obfuscated\": true",
|
||||
"Wails obfuscation must stay disabled because this app embeds frontend/src and uses normal Wails globals",
|
||||
);
|
||||
assertNotContains(
|
||||
"wails.json",
|
||||
"\"garbleargs\"",
|
||||
"garble arguments must not be configured when Wails obfuscation is disabled",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/index.html",
|
||||
"wails-service.js",
|
||||
"frontend must load the Wails service bridge before app code",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/index.html",
|
||||
'type="module" src="/src/js/wails-service.js"',
|
||||
"Wails bridge must load as a synchronous classic script before app code",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/wails-service.js",
|
||||
'prop === "then"',
|
||||
"Wails service proxy must not masquerade as a Promise thenable",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/wails-service.js",
|
||||
"window.go?.api?.Service",
|
||||
"Wails service bridge must use the normal named Wails API",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/sshkeys.js",
|
||||
"textContent: \"Failed to load keys: \" + String(err)",
|
||||
"SSH key manager errors must be rendered as text rather than injected HTML",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/src/js/sshkeys.js",
|
||||
"textContent: message",
|
||||
"SSH key delete confirmation messages must be rendered as text",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/src/js/sshkeys.js",
|
||||
"Failed to load keys: ${err}",
|
||||
"SSH key manager must not interpolate backend errors into innerHTML",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/package.json",
|
||||
'"@xterm/xterm":',
|
||||
"frontend dependencies must use the maintained scoped xterm package",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/package.json",
|
||||
'"@xterm/addon-fit":',
|
||||
"frontend dependencies must use the maintained scoped xterm addons",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/scripts/copy-assets.js",
|
||||
"@xterm/xterm/lib/xterm.js",
|
||||
"xterm browser assets must be copied from the maintained scoped package",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/scripts/copy-assets.js",
|
||||
"@xterm/addon-fit/lib/addon-fit.js",
|
||||
"xterm addon browser assets must be copied from the maintained scoped packages",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/package.json",
|
||||
'"xterm":',
|
||||
"deprecated unscoped xterm dependency must not be restored",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/package.json",
|
||||
'"xterm-addon-',
|
||||
"deprecated unscoped xterm addon dependencies must not be restored",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/package-lock.json",
|
||||
'"node_modules/xterm"',
|
||||
"package lock must not contain the deprecated unscoped xterm package",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/package-lock.json",
|
||||
'"node_modules/xterm-addon-',
|
||||
"package lock must not contain deprecated unscoped xterm addon packages",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/scripts/build.js",
|
||||
"sourceMappingURL",
|
||||
"frontend production build must remove source-map references",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/scripts/build.js",
|
||||
"bundle-novnc.js",
|
||||
"frontend production build must regenerate the noVNC bundle",
|
||||
);
|
||||
assertContains(
|
||||
"frontend/scripts/novnc-entry.js",
|
||||
"initLogging(\"error\")",
|
||||
"bundled noVNC logging must default to errors only",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/src/js/app.js",
|
||||
"console.debug(\"[session:debug]\"",
|
||||
"release UI must not print telnet negotiation debug events",
|
||||
);
|
||||
assertNotContains(
|
||||
"frontend/src/js/app.js",
|
||||
"console.log(\"Backend is ready\")",
|
||||
"release UI must not print backend-ready lifecycle events",
|
||||
);
|
||||
assertContains(
|
||||
"build.sh",
|
||||
"NEXUS_COMPRESS",
|
||||
"local builds must expose an explicit compression switch",
|
||||
);
|
||||
assertContains(
|
||||
"scripts/build-common.sh",
|
||||
"nexus_use_upx_for_target",
|
||||
"build scripts must share one UPX target-selection helper",
|
||||
);
|
||||
assertContains(
|
||||
"build.sh",
|
||||
"Skipping UPX compression on macOS",
|
||||
"local compressed builds must avoid unsupported macOS UPX by default",
|
||||
);
|
||||
assertContains(
|
||||
"build.sh",
|
||||
"-trimpath",
|
||||
"local production builds should trim file paths from binaries",
|
||||
);
|
||||
assertContains(
|
||||
"scripts/build-common.sh",
|
||||
"-buildid=",
|
||||
"production builds must strip the Go build ID",
|
||||
);
|
||||
assertContains(
|
||||
"scripts/build-common.sh",
|
||||
"-O2 -g0",
|
||||
"production builds must remove cgo debug output by default",
|
||||
);
|
||||
assertContains(
|
||||
"Makefile",
|
||||
"build-compressed",
|
||||
"Makefile must expose an explicit compressed build target",
|
||||
);
|
||||
assertContains(
|
||||
"Makefile",
|
||||
"NEXUS_FORCE_MACOS_UPX",
|
||||
"Makefile compressed builds must make macOS UPX opt-in",
|
||||
);
|
||||
assertTreeNotContains(
|
||||
"frontend/src/js",
|
||||
"window.go.api.Service",
|
||||
"app code must not call named Wails backend methods directly",
|
||||
);
|
||||
|
||||
console.log("frontend regression checks passed");
|
||||
@@ -8,132 +8,132 @@
|
||||
Form panel (profile editor container)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-panel-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.form-panel-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Form sections (disclosure-based grouping)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-section {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: var(--bg-elevated);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: var(--bg-elevated);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-section-header:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.form-section-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--transition-normal);
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--transition-normal);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-section.is-open .form-section-chevron {
|
||||
transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.form-section-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-section-body,
|
||||
.form-section-content {
|
||||
padding: var(--space-4);
|
||||
display: none;
|
||||
padding: var(--space-4);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-section.is-open .form-section-body,
|
||||
.form-section.is-open .form-section-content {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Field grid (two-column layout for compact forms)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Single-column override */
|
||||
.form-row.full-width {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Three-column for port + protocol combos */
|
||||
.form-row.triple {
|
||||
grid-template-columns: 1fr 100px 120px;
|
||||
grid-template-columns: 1fr 100px 120px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -143,14 +143,14 @@
|
||||
.proto-only-ssh,
|
||||
.proto-only-telnet,
|
||||
.proto-only-raw {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.protocol-ssh .proto-only-ssh,
|
||||
.protocol-telnet .proto-only-telnet,
|
||||
.protocol-raw .proto-only-raw,
|
||||
.protocol-github .proto-only-github {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* For block-level protocol fields */
|
||||
@@ -158,354 +158,433 @@
|
||||
.protocol-telnet .proto-only-telnet.form-section,
|
||||
.protocol-raw .proto-only-raw.form-section,
|
||||
.protocol-github .proto-only-github.form-section {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tag input
|
||||
----------------------------------------------------------------------- */
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-height: 30px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: text;
|
||||
transition: border-color var(--transition-fast);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-height: 30px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: text;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag-input-wrapper:hover {
|
||||
border-color: var(--border-strong);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.tag-input-wrapper:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-item-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: var(--accent);
|
||||
transition: background var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: var(--accent);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag-item-remove:hover {
|
||||
background: var(--accent-strong);
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
height: 22px;
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.tag-input::placeholder {
|
||||
color: var(--text-disabled);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Jump host / proxy list
|
||||
----------------------------------------------------------------------- */
|
||||
.jump-host-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.jump-host-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.jump-host-item .jump-host-order {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jump-host-item .jump-host-value {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jump-host-item .jump-host-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.jump-host-item:hover .jump-host-actions {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.jump-host-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.jump-host-add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Key file selector row
|
||||
----------------------------------------------------------------------- */
|
||||
.file-selector {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.file-selector .form-input {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-selector .btn {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* File path display */
|
||||
.file-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Inline validation
|
||||
----------------------------------------------------------------------- */
|
||||
.form-group.is-valid .form-input {
|
||||
border-color: var(--success);
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.form-group.is-valid .form-input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.20);
|
||||
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.2);
|
||||
}
|
||||
|
||||
.form-group.is-invalid .form-input {
|
||||
border-color: var(--error);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.form-group.is-invalid .form-input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.20);
|
||||
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2);
|
||||
}
|
||||
|
||||
.form-validation-icon {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: var(--text-sm);
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.form-group.is-valid .form-validation-icon { color: var(--success); }
|
||||
.form-group.is-invalid .form-validation-icon { color: var(--error); }
|
||||
.form-group.is-valid .form-validation-icon {
|
||||
color: var(--success);
|
||||
}
|
||||
.form-group.is-invalid .form-validation-icon {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Help text under sections
|
||||
----------------------------------------------------------------------- */
|
||||
.form-section-help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
line-height: var(--leading-relaxed);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Colour preview (for terminal colour settings)
|
||||
----------------------------------------------------------------------- */
|
||||
.colour-preview {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-default);
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-default);
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Number input with stepper
|
||||
----------------------------------------------------------------------- */
|
||||
.form-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-number .form-input {
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
/* Hide browser spinners */
|
||||
-moz-appearance: textfield;
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
/* Hide browser spinners */
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.form-number .form-input::-webkit-inner-spin-button,
|
||||
.form-number .form-input::-webkit-outer-spin-button {
|
||||
appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.form-number-btn {
|
||||
width: 24px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-left: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
width: 24px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-left: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-number-btn:last-child {
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
|
||||
.form-number-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Form action row (test connection, etc.)
|
||||
----------------------------------------------------------------------- */
|
||||
.form-actions-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.form-actions-inline .btn {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-actions-inline .form-actions-status {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex: 1;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Form hint — inline help text below fields
|
||||
----------------------------------------------------------------------- */
|
||||
.form-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--space-1);
|
||||
line-height: var(--leading-relaxed);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--space-1);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.form-hint.secret-error {
|
||||
color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.settings-shell-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-shell-row .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--success);
|
||||
margin-top: var(--space-1);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Toggle wrapper — label + toggle switch in a row
|
||||
----------------------------------------------------------------------- */
|
||||
.form-toggle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.form-toggle-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Jump host rows
|
||||
----------------------------------------------------------------------- */
|
||||
.jump-hosts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.jump-host-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 1fr auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.jump-host-fields {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Port-forwarding rows
|
||||
----------------------------------------------------------------------- */
|
||||
.port-forwards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.port-forward-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.port-forward-fields {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.port-forward-arrow {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.port-forward-enabled-label {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.port-forward-enabled-label input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Payload preset rows
|
||||
----------------------------------------------------------------------- */
|
||||
.payload-presets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.preset-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 100px auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 100px auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -513,7 +592,154 @@
|
||||
----------------------------------------------------------------------- */
|
||||
.form-input-sm,
|
||||
.form-select-sm {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Login Script Editor — expect-style send/wait steps
|
||||
----------------------------------------------------------------------- */
|
||||
.form-help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-script-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.login-script-empty {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.login-script-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.login-script-type {
|
||||
width: 90px;
|
||||
min-width: 90px;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.login-script-value {
|
||||
flex: 1;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.login-script-timeout {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.login-script-timeout-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
min-width: 10px;
|
||||
}
|
||||
|
||||
.login-script-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-script-btn {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xs);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.login-script-btn:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-script-btn-remove:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-script-add-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
TOTP / 2FA — secret input row with action buttons
|
||||
----------------------------------------------------------------------- */
|
||||
.totp-input-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.totp-input-row .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* SSH key path row */
|
||||
.key-path-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-path-row .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jump-hosts-list .form-error {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.totp-toggle,
|
||||
.totp-test,
|
||||
.totp-save,
|
||||
.totp-delete {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
@@ -4,263 +4,301 @@
|
||||
========================================================================= */
|
||||
|
||||
.github-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
/* ── Header bar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--protocol-github);
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--protocol-github);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.github-viewer-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.github-viewer-search {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.github-viewer-search-input {
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.github-viewer-search-input:focus {
|
||||
border-color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
}
|
||||
|
||||
.github-viewer-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.github-viewer-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-refresh:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
background: var(--bg-hover);
|
||||
color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
}
|
||||
|
||||
/* ── Status bar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-status {
|
||||
padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-viewer-status-error {
|
||||
color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ── Repo list ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
|
||||
}
|
||||
|
||||
.github-viewer-empty {
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ── Group ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-group {
|
||||
margin-bottom: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.github-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) 0;
|
||||
margin-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) 0;
|
||||
margin-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.github-group-name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.github-group-count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* ── Repo card ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.github-repo-card {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.github-repo-card:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.github-repo-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.github-repo-commit {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.github-repo-name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--protocol-github);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--protocol-github);
|
||||
}
|
||||
|
||||
.github-repo-badge {
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.github-repo-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.github-repo-download,
|
||||
.github-repo-visit {
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.github-repo-download:hover,
|
||||
.github-repo-visit:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--protocol-github);
|
||||
border-color: var(--protocol-github);
|
||||
}
|
||||
|
||||
.github-repo-private {
|
||||
background: rgba(255, 167, 38, 0.12);
|
||||
color: #ffa726;
|
||||
background: rgba(255, 167, 38, 0.12);
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.github-repo-fork {
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: #4ecdc4;
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
.github-repo-archived {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.github-repo-desc {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.github-repo-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.github-repo-lang {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.github-lang-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.github-repo-stars {
|
||||
/* Star colour is inherent in the text */
|
||||
/* Star colour is inherent in the text */
|
||||
}
|
||||
|
||||
.github-repo-pushed {
|
||||
/* Relative time text */
|
||||
/* Relative time text */
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.github-viewer-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/* ==========================================================================
|
||||
Nexus Terminal - IRC viewer
|
||||
========================================================================== */
|
||||
|
||||
.irc-viewer-panel {
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.irc-viewer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.irc-main {
|
||||
display: grid;
|
||||
grid-template-rows: 34px minmax(0, 1fr) 42px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.irc-room-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.irc-room-tab {
|
||||
height: 24px;
|
||||
max-width: 180px;
|
||||
min-width: 44px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.irc-room-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.irc-room-tab.active {
|
||||
background: var(--bg-active);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.irc-room-tab.has-unread::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin-left: var(--space-1);
|
||||
border-radius: 50%;
|
||||
background: var(--protocol-irc);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.irc-terminal {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.irc-terminal .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.irc-composer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.irc-input {
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.irc-input:focus,
|
||||
.irc-join-input:focus,
|
||||
.irc-filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.irc-send {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.irc-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(88px, auto) minmax(120px, 1fr) minmax(120px, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.irc-sidebar-section {
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.irc-users-section {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.irc-sidebar-title,
|
||||
.irc-sidebar-title-row {
|
||||
min-height: 24px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.irc-sidebar-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.irc-join-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.irc-join-input,
|
||||
.irc-filter-input {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.irc-filter-input {
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.irc-sidebar-list,
|
||||
.irc-room-list,
|
||||
.irc-user-list {
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.irc-room-row,
|
||||
.irc-user-row,
|
||||
.irc-listed-room-main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.irc-room-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 26px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.irc-room-row:hover,
|
||||
.irc-user-row:hover,
|
||||
.irc-listed-room:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.irc-room-row.active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.irc-room-name,
|
||||
.irc-listed-room-name,
|
||||
.irc-listed-room-topic {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.irc-room-count,
|
||||
.irc-listed-room-count {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.irc-listed-room {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.irc-listed-room-main {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.irc-listed-room-name {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.irc-listed-room-topic {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.irc-user-row {
|
||||
display: block;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.irc-empty {
|
||||
padding: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.irc-viewer {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.irc-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -7,149 +7,235 @@
|
||||
Main application grid
|
||||
----------------------------------------------------------------------- */
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-rows: var(--tab-bar-height) 1fr var(--status-bar-height);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-rows: var(--tab-bar-height) 1fr var(--status-bar-height);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
/* Collapsed sidebar variant */
|
||||
.app.sidebar-collapsed {
|
||||
grid-template-columns: var(--sidebar-width-collapsed) 1fr;
|
||||
grid-template-columns: var(--sidebar-width-collapsed) 1fr;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: var(--z-sticky);
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tab bar
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-bar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--tab-bar-height);
|
||||
overflow: hidden;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--tab-bar-height);
|
||||
min-height: var(--tab-bar-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.tab-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 6px;
|
||||
}
|
||||
.tab-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.tab-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tab-bar::before,
|
||||
.tab-bar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 1px;
|
||||
z-index: 2;
|
||||
width: 18px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-bar::before {
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, var(--bg-surface), transparent);
|
||||
}
|
||||
|
||||
.tab-bar::after {
|
||||
right: calc(var(--tab-bar-height) * 2);
|
||||
background: linear-gradient(-90deg, var(--bg-surface), transparent);
|
||||
}
|
||||
|
||||
.tab-bar.has-overflow-left::before,
|
||||
.tab-bar.has-overflow-right::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Workspace
|
||||
----------------------------------------------------------------------- */
|
||||
.workspace {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Panel container — fills workspace, holds session panels */
|
||||
.panel-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Individual session panel — one per tab, fills the panel container */
|
||||
.session-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Terminal container — fills workspace */
|
||||
.terminal-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: var(--space-1);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Status bar
|
||||
----------------------------------------------------------------------- */
|
||||
.status-bar {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
gap: var(--space-4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
gap: var(--space-4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-bar span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.status-spacer {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-update {
|
||||
border: 1px solid var(--accent);
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.status-update:hover {
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-strong, var(--accent));
|
||||
}
|
||||
|
||||
.status-update.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Status bar indicator dots */
|
||||
.status-bar .status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.is-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.status-dot.is-connecting {
|
||||
background: var(--warning);
|
||||
}
|
||||
.status-dot.is-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.status-dot.is-disconnected {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Latency indicator */
|
||||
#status-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#status-latency.latency-good {
|
||||
color: var(--success);
|
||||
}
|
||||
#status-latency.latency-warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
#status-latency.latency-bad {
|
||||
color: var(--error);
|
||||
}
|
||||
.status-dot.is-connected { background: var(--success); }
|
||||
.status-dot.is-connecting { background: var(--warning); }
|
||||
.status-dot.is-error { background: var(--error); }
|
||||
.status-dot.is-disconnected { background: var(--text-tertiary); }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Resize handle (between sidebar and workspace)
|
||||
----------------------------------------------------------------------- */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: var(--z-sticky);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
.resize-handle:hover,
|
||||
.resize-handle.is-dragging {
|
||||
background: var(--accent-muted);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
/* Leviathan mode inside the Nexus shell */
|
||||
|
||||
.leviathan-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.leviathan-group {
|
||||
margin: var(--space-3) var(--space-3) var(--space-1);
|
||||
padding: 0;
|
||||
color: var(--sidebar-group-title);
|
||||
cursor: default;
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.leviathan-tool {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 30px;
|
||||
margin: 1px var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.leviathan-tool:hover {
|
||||
border-color: var(--border-subtle);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.leviathan-tool.sidebar-item-active {
|
||||
border-color: rgba(78, 205, 196, 0.35);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.leviathan-tool-ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--warning);
|
||||
background: rgba(255, 167, 38, 0.08);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.leviathan-tool.sidebar-item-active .leviathan-tool-ico {
|
||||
color: var(--accent);
|
||||
border-color: rgba(78, 205, 196, 0.35);
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
}
|
||||
|
||||
.badge-leviathan {
|
||||
color: var(--warning);
|
||||
border-color: rgba(255, 167, 38, 0.35);
|
||||
}
|
||||
|
||||
.leviathan-panel {
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #181818;
|
||||
}
|
||||
|
||||
.leviathan-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 48px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.leviathan-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leviathan-tool-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
border: 1px solid rgba(255, 167, 38, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--warning);
|
||||
background: rgba(255, 167, 38, 0.08);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.leviathan-heading {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leviathan-title-wrap h2 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-eyebrow,
|
||||
.leviathan-status {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-tight);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-header-actions,
|
||||
.leviathan-tool-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.leviathan-header-actions .btn,
|
||||
.leviathan-tool-toolbar .btn {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.leviathan-panel-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leviathan-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 330px) minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
height: 100%;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.leviathan-top-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.leviathan-hex-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.leviathan-control-plane,
|
||||
.leviathan-output-plane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.leviathan-control-plane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.leviathan-top-workbench .leviathan-control-plane {
|
||||
align-items: flex-end;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
min-height: 42px;
|
||||
overflow: visible;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.leviathan-output-plane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.leviathan-params {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.leviathan-top-workbench .leviathan-params {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.leviathan-param,
|
||||
.leviathan-param-inline {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.leviathan-param > span,
|
||||
.leviathan-param-inline > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-top-workbench .leviathan-param,
|
||||
.leviathan-top-workbench .leviathan-param-inline {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.leviathan-top-workbench .leviathan-param {
|
||||
flex: 0 1 150px;
|
||||
}
|
||||
|
||||
.leviathan-top-workbench .leviathan-param-inline {
|
||||
flex: 0 0 112px;
|
||||
}
|
||||
|
||||
.leviathan-hex-workbench .leviathan-param-inline {
|
||||
flex-basis: 104px;
|
||||
}
|
||||
|
||||
.leviathan-param .form-input,
|
||||
.leviathan-param-inline .form-input {
|
||||
height: 30px;
|
||||
border-color: var(--border-subtle);
|
||||
background: var(--bg-input);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.leviathan-param textarea.form-input {
|
||||
min-height: 118px;
|
||||
padding: var(--space-2);
|
||||
line-height: var(--leading-normal);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.leviathan-top-workbench .leviathan-tool-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.leviathan-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.leviathan-file-picker {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.leviathan-file-path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 1px;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.leviathan-summary:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.leviathan-pill {
|
||||
min-width: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.leviathan-pill span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
line-height: var(--leading-tight);
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-pill strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-results {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leviathan-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.leviathan-metric {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.leviathan-metric span,
|
||||
.leviathan-kv-row span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
line-height: var(--leading-tight);
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-metric strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.leviathan-section {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.leviathan-section h3 {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.leviathan-kv-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.leviathan-kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 120px) minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.leviathan-kv-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.leviathan-kv-row strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-entropy-frame {
|
||||
min-height: 100%;
|
||||
padding: var(--space-3);
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.leviathan-entropy-canvas {
|
||||
width: 100%;
|
||||
height: min(280px, 42vh);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: #101010;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.leviathan-spectrogram-frame {
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: #05070c;
|
||||
}
|
||||
|
||||
.leviathan-spectrogram-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.leviathan-empty,
|
||||
.leviathan-json {
|
||||
min-height: 100%;
|
||||
color: var(--text-secondary);
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.leviathan-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-6);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.leviathan-error-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100%;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.leviathan-error-icon {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 58px;
|
||||
height: 52px;
|
||||
color: #161616;
|
||||
font-size: 24px;
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.leviathan-error-icon::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--warning);
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.leviathan-error-icon span {
|
||||
position: relative;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.leviathan-error-state strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.leviathan-error-state p {
|
||||
max-width: 520px;
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
.leviathan-json {
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-normal);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.leviathan-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.leviathan-table th,
|
||||
.leviathan-table td {
|
||||
max-width: 320px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leviathan-offset-link {
|
||||
color: var(--warning);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.leviathan-offset-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.leviathan-export-found {
|
||||
min-width: 58px;
|
||||
}
|
||||
|
||||
.leviathan-muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.leviathan-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.leviathan-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.leviathan-table-note {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.leviathan-tree {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.leviathan-tree-node {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
padding: var(--space-1) 0 var(--space-1) var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.leviathan-tree-node strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.leviathan-tree-node span {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.leviathan-hex-grid {
|
||||
--leviathan-hex-width: 16;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0;
|
||||
min-height: 100%;
|
||||
padding: var(--space-1);
|
||||
overflow: auto;
|
||||
background: #101010;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.leviathan-hex-row {
|
||||
display: grid;
|
||||
grid-template-columns: 82px max-content max-content;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 23px;
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.leviathan-hex-row:hover {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.leviathan-offset {
|
||||
color: var(--warning);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.leviathan-byte-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.leviathan-byte {
|
||||
width: 22px;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
background: #1d1d1d;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.leviathan-byte:hover,
|
||||
.leviathan-byte:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.leviathan-byte.is-editing {
|
||||
border-color: var(--warning);
|
||||
}
|
||||
|
||||
.leviathan-byte.is-saving {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.leviathan-byte.is-search-hit {
|
||||
border-color: var(--warning);
|
||||
color: var(--text-inverse);
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.leviathan-ascii {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
line-height: 20px;
|
||||
text-overflow: clip;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.leviathan-modal-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.leviathan-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.leviathan-control-plane {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.leviathan-params {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.leviathan-panel-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.leviathan-header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.leviathan-hex-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.leviathan-byte-cells {
|
||||
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
|
||||
}
|
||||
}
|
||||
@@ -112,10 +112,6 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
@@ -140,7 +136,7 @@
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -152,6 +148,21 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
font-family: monospace;
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree > div {
|
||||
transform-origin: left;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
@@ -207,3 +218,68 @@
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
|
||||
|
||||
/* xterm.js customization: Override xterm's cursor style */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.xterm .xterm-scrollable-element > .scrollbar > .scra {
|
||||
cursor: pointer;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .visible {
|
||||
opacity: 1;
|
||||
|
||||
/* Background rule added for IE9 - to allow clicks on dom node */
|
||||
background:rgba(0,0,0,0);
|
||||
|
||||
transition: opacity 100ms linear;
|
||||
/* In front of peek view */
|
||||
z-index: 11;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible.fade {
|
||||
transition: opacity 800ms linear;
|
||||
}
|
||||
|
||||
/* Scrollable Content Inset Shadow */
|
||||
.xterm .xterm-scrollable-element > .shadow {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.left {
|
||||
display: block;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 3px;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top.left {
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/* ── Session Recording & Playback ────────────────────────────────────── */
|
||||
|
||||
/* Recording indicator dot on tabs */
|
||||
.recording-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--error, #ef4444);
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
animation: recording-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes recording-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Recordings list in browser modal */
|
||||
.recording-storage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 0 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border, #3f3f46);
|
||||
}
|
||||
|
||||
.recording-storage-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
}
|
||||
|
||||
.recording-cleanup-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recording-cleanup-status {
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.recordings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.recording-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-2, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.recording-item:hover {
|
||||
background: var(--surface-3, #333338);
|
||||
}
|
||||
|
||||
.recording-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recording-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recording-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #71717a);
|
||||
}
|
||||
|
||||
.recording-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.recording-cleanup-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Playback modal — larger to fit a terminal */
|
||||
.modal-playback {
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.playback-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.playback-terminal {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
background: #1a1a1e;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.playback-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 12px;
|
||||
background: var(--surface-1, #1e1e22);
|
||||
border-top: 1px solid var(--border, #3f3f46);
|
||||
}
|
||||
|
||||
.playback-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--surface-3, #333338);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.playback-progress::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #3b82f6);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.playback-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #71717a);
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.playback-speed {
|
||||
font-size: 11px;
|
||||
background: var(--surface-2, #2a2a2e);
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
border: 1px solid var(--border, #3f3f46);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Export/import profile list */
|
||||
.export-profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: var(--surface-2, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #3f3f46);
|
||||
}
|
||||
|
||||
.export-profile-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.export-profile-item:hover {
|
||||
background: var(--surface-3, #333338);
|
||||
}
|
||||
|
||||
.export-profile-item input[type="checkbox"] {
|
||||
accent-color: var(--accent, #3b82f6);
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
/* ==========================================================================
|
||||
Nexus Terminal — SFTP File Browser
|
||||
Right-side panel for browsing remote files alongside the terminal.
|
||||
========================================================================== */
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Panel wrapper — overlays the terminal from the right side
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
min-width: 220px;
|
||||
max-width: 50vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
z-index: var(--z-dropdown, 50);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Resize handle — positioned at the left edge of the SFTP panel */
|
||||
.sftp-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -3px;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: var(--z-dropdown, 50);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sftp-resize-handle:hover,
|
||||
.sftp-resize-handle.is-dragging {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Toolbar (top bar with path breadcrumb + action buttons)
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
flex-shrink: 0;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.sftp-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sftp-toolbar-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sftp-toolbar-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sftp-toolbar-btn.is-accent:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sftp-toolbar-btn.is-close:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Breadcrumb path
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-breadcrumb {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sftp-breadcrumb-segment {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sftp-breadcrumb-segment:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sftp-breadcrumb-segment:last-child {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.sftp-breadcrumb-sep {
|
||||
color: var(--text-disabled);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
File list
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-file-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.sftp-file-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.sftp-file-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.sftp-file-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong, var(--bg-active));
|
||||
border-radius: 3px;
|
||||
}
|
||||
.sftp-file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
File row
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
user-select: none;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.sftp-file-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sftp-file-row.is-selected {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.sftp-file-row.is-selected:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.sftp-file-row.is-readonly .sftp-file-meta {
|
||||
color: var(--warning);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.sftp-file-row.is-unreadable {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sftp-file-row.is-unreadable .sftp-file-name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sftp-file-icon {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sftp-file-icon.is-dir {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sftp-file-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sftp-file-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Parent directory row (..)
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-file-row.is-parent .sftp-file-name {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Context actions bar (shown below file list when a file is selected)
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-context-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
flex-shrink: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.sftp-context-bar .sftp-ctx-label {
|
||||
flex: 1;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sftp-context-bar .sftp-ctx-btn {
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sftp-context-bar .sftp-ctx-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sftp-context-bar .sftp-ctx-btn.is-danger:hover {
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.sftp-context-bar .sftp-ctx-btn.is-accent:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
States: loading, empty, error
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
text-align: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sftp-state-icon {
|
||||
font-size: 28px;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.sftp-state-msg {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: var(--leading-relaxed);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.sftp-state-retry {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Loading spinner (pure CSS) */
|
||||
.sftp-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: sftp-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sftp-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Inline input (for rename / mkdir)
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-inline-input {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||
}
|
||||
|
||||
/* Picker-specific input in the toolbar */
|
||||
.sftp-picker-filter {
|
||||
width: 160px;
|
||||
height: 26px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sftp-picker-filter:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sftp-picker-status {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.sftp-picker-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sftp-picker-badge.is-readonly {
|
||||
border-color: rgba(255, 167, 38, 0.35);
|
||||
color: var(--warning);
|
||||
background: rgba(255, 167, 38, 0.12);
|
||||
}
|
||||
|
||||
.sftp-picker-badge.is-noread {
|
||||
border-color: rgba(239, 83, 80, 0.35);
|
||||
color: var(--error);
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
}
|
||||
|
||||
.sftp-picker-legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Transfer progress queue
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-transfer-queue {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sftp-transfer-header {
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
padding: var(--space-1) var(--space-2) 0;
|
||||
}
|
||||
|
||||
.sftp-transfer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 4px var(--space-2);
|
||||
}
|
||||
|
||||
.sftp-transfer-icon {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sftp-transfer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sftp-transfer-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sftp-transfer-progress {
|
||||
height: 3px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sftp-transfer-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
|
||||
.sftp-transfer-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Bookmark list
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-bookmark-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sftp-bookmark-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sftp-bookmark-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
@@ -7,313 +7,531 @@
|
||||
Sidebar header (logo / title / drag region)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
min-height: var(--tab-bar-height);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
height: var(--tab-bar-height);
|
||||
min-height: var(--tab-bar-height);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sidebar-product-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-mode-toggle {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#mode-leviathan {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sidebar-mode-toggle.is-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-mode-toggle:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-mode-toggle.is-active:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mode-leviathan .sidebar-search,
|
||||
.mode-leviathan .sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar search
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-search {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar navigation (scrollable area)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: var(--space-2);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Bulk actions bar (multi-select)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-bulk-actions {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-bulk-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.sidebar-bulk-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sections (Favourites, Recent, All Connections)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-section {
|
||||
/* Each section can be collapsed */
|
||||
/* Each section can be collapsed */
|
||||
}
|
||||
|
||||
.sidebar-section + .sidebar-section {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-section-header.sidebar-section-header-group .section-title {
|
||||
color: var(--sidebar-group-title);
|
||||
}
|
||||
|
||||
.sidebar-section-header:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-section-header.sidebar-section-header-group:hover .section-title {
|
||||
color: var(--sidebar-group-title);
|
||||
}
|
||||
|
||||
.sidebar-section-header .section-icon {
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sidebar-section-header .section-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-normal);
|
||||
color: var(--text-disabled);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-normal);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.sidebar-section.is-collapsed .section-chevron {
|
||||
transform: rotate(-90deg);
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.section-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-overlay);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-overlay);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar list (container for items)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-list {
|
||||
padding: 0 var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-section.is-collapsed .sidebar-list {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar item (individual connection profile)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-2);
|
||||
margin-bottom: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-2);
|
||||
margin-bottom: 0.5px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-item:active {
|
||||
background: var(--bg-active);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.sidebar-item-selected {
|
||||
background: var(--accent-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-item-selected .sidebar-item-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active / selected state */
|
||||
.sidebar-item.active {
|
||||
background: var(--accent-muted);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.sidebar-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Protocol colour indicator dot */
|
||||
/* Protocol indicator icon */
|
||||
.sidebar-item .protocol-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-tertiary);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: 1;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sidebar-item .protocol-indicator::before {
|
||||
content: "?";
|
||||
}
|
||||
|
||||
.sidebar-item .protocol-indicator.proto-ssh::before {
|
||||
content: "⌨";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-telnet::before {
|
||||
content: "⇄";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-raw::before {
|
||||
content: "≋";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-github::before {
|
||||
content: "GH";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-vnc::before {
|
||||
content: "⊞";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-irc::before {
|
||||
content: "#";
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-local::before {
|
||||
content: "›_";
|
||||
}
|
||||
|
||||
.sidebar-item .protocol-indicator.proto-ssh {
|
||||
background: rgba(78, 205, 196, 0.18);
|
||||
color: var(--protocol-ssh);
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-telnet {
|
||||
background: rgba(255, 167, 38, 0.18);
|
||||
color: var(--protocol-telnet);
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-raw {
|
||||
background: rgba(171, 71, 188, 0.18);
|
||||
color: var(--protocol-raw);
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-github {
|
||||
background: rgba(124, 138, 255, 0.18);
|
||||
color: var(--protocol-github);
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-vnc {
|
||||
background: rgba(232, 93, 74, 0.18);
|
||||
color: var(--protocol-vnc);
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-irc {
|
||||
background: rgba(216, 178, 76, 0.18);
|
||||
color: var(--protocol-irc);
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-local {
|
||||
background: rgba(139, 195, 74, 0.18);
|
||||
color: var(--protocol-local);
|
||||
}
|
||||
|
||||
/* Local machine item separator */
|
||||
.sidebar-item-local {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin: var(--space-2) var(--space-2) var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
.sidebar-item .protocol-indicator.proto-ssh { background: var(--protocol-ssh); }
|
||||
.sidebar-item .protocol-indicator.proto-telnet { background: var(--protocol-telnet); }
|
||||
.sidebar-item .protocol-indicator.proto-raw { background: var(--protocol-raw); }
|
||||
.sidebar-item .protocol-indicator.proto-github { background: var(--protocol-github); }
|
||||
|
||||
/* Text block (name + host) */
|
||||
.sidebar-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-item-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-tight);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-tight);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-item-host {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--leading-tight);
|
||||
margin-top: 1px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--leading-tight);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Edit button (appears on hover, beside name) */
|
||||
.sidebar-item-edit {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-disabled);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-disabled);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-item:hover .sidebar-item-edit {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-edit:hover {
|
||||
color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Favourite star */
|
||||
.sidebar-item-star {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-disabled);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-disabled);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-item:hover .sidebar-item-star {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-star.is-favourite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.sidebar-item-star:hover {
|
||||
color: var(--warning);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Inline status dot (right side, tiny) */
|
||||
.sidebar-item-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--space-1);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
.sidebar-item-status.is-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.sidebar-item-status.is-connecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 1.2s ease infinite;
|
||||
}
|
||||
.sidebar-item-status.is-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.sidebar-item-status.is-connected { background: var(--success); }
|
||||
.sidebar-item-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
||||
.sidebar-item-status.is-error { background: var(--error); }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar empty list placeholder
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-6) var(--space-3) 0;
|
||||
padding: var(--space-5) var(--space-3);
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-empty-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-empty-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.sidebar-empty-text {
|
||||
max-width: 190px;
|
||||
margin: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
.sidebar-empty-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.sidebar-list-empty {
|
||||
padding: var(--space-3) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar footer (actions)
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer .btn:first-child {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar collapsed state
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar-collapsed .sidebar-header {
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
.sidebar-collapsed .sidebar-title,
|
||||
.sidebar-collapsed .sidebar-search,
|
||||
@@ -322,19 +540,19 @@
|
||||
.sidebar-collapsed .sidebar-item-star,
|
||||
.sidebar-collapsed .section-count,
|
||||
.sidebar-collapsed .sidebar-footer .btn:first-child span {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-section-header {
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-footer {
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* ==========================================================================
|
||||
Nexus Terminal — Split Pane Layout
|
||||
Draggable split panes within session panels for multi-terminal views.
|
||||
========================================================================== */
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Split container — replaces direct terminal-container when split
|
||||
----------------------------------------------------------------------- */
|
||||
.split-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-container.split-horizontal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-container.split-vertical {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Individual pane inside a split
|
||||
----------------------------------------------------------------------- */
|
||||
.split-pane {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Active pane indicator (subtle border glow) */
|
||||
.split-pane.is-focused {
|
||||
box-shadow: inset 0 0 0 1px var(--accent-muted);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Pane close button — only visible on hover
|
||||
----------------------------------------------------------------------- */
|
||||
.split-pane-close {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.split-pane:hover > .split-pane-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.split-pane-close:hover {
|
||||
background: var(--error);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Draggable divider between panes
|
||||
----------------------------------------------------------------------- */
|
||||
.split-divider {
|
||||
flex-shrink: 0;
|
||||
background: var(--border-subtle);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.is-dragging {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Vertical split → side-by-side panes → vertical divider bar */
|
||||
.split-container.split-vertical > .split-divider {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Horizontal split → stacked panes → horizontal divider bar */
|
||||
.split-container.split-horizontal > .split-divider {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
/* Invisible larger hit-area for easier grabbing */
|
||||
.split-divider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.split-container.split-vertical > .split-divider::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.split-container.split-horizontal > .split-divider::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Empty pane placeholder — shown when a new pane needs a session
|
||||
----------------------------------------------------------------------- */
|
||||
.split-pane-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--bg-base);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.split-pane-placeholder-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.split-pane-placeholder-text {
|
||||
text-align: center;
|
||||
max-width: 200px;
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
/* Profile picker list in the placeholder */
|
||||
.split-pane-profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.split-pane-profile-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.split-pane-profile-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.split-pane-profile-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.split-pane-profile-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.split-pane-profile-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-muted);
|
||||
}
|
||||
|
||||
.split-pane-profile-btn .protocol-badge {
|
||||
font-size: 9px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Prevent text selection while dragging dividers
|
||||
----------------------------------------------------------------------- */
|
||||
body.split-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
body.split-resizing-row {
|
||||
cursor: row-resize !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
/* Disable pointer events on iframes / xterm canvases during drag */
|
||||
body.split-resizing .xterm,
|
||||
body.split-resizing-row .xterm {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -8,262 +8,425 @@
|
||||
Individual tab
|
||||
----------------------------------------------------------------------- */
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--tab-bar-height);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
user-select: none;
|
||||
max-width: 200px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--tab-bar-height);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
user-select: none;
|
||||
max-width: 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active tab */
|
||||
.tab.active {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
.tab.tab-active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active indicator — accent bar at the bottom */
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full) var(--radius-full) 0 0;
|
||||
.tab.tab-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full) var(--radius-full) 0 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tab icon (small terminal/protocol icon)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tab.active .tab-icon {
|
||||
color: var(--text-secondary);
|
||||
.tab.tab-active .tab-icon {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tab label
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
.tab-label,
|
||||
.tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Protocol mini-badge inside tab
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
font-size: 9px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
font-size: 9px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-badge.badge-ssh {
|
||||
background: rgba(78, 205, 196, 0.15);
|
||||
color: var(--protocol-ssh);
|
||||
background: rgba(78, 205, 196, 0.15);
|
||||
color: var(--protocol-ssh);
|
||||
}
|
||||
.tab-badge.badge-telnet {
|
||||
background: rgba(255, 167, 38, 0.15);
|
||||
color: var(--protocol-telnet);
|
||||
background: rgba(255, 167, 38, 0.15);
|
||||
color: var(--protocol-telnet);
|
||||
}
|
||||
.tab-badge.badge-raw {
|
||||
background: rgba(171, 71, 188, 0.15);
|
||||
color: var(--protocol-raw);
|
||||
background: rgba(171, 71, 188, 0.15);
|
||||
color: var(--protocol-raw);
|
||||
}
|
||||
.tab-badge.badge-github {
|
||||
background: rgba(124, 138, 255, 0.15);
|
||||
color: var(--protocol-github);
|
||||
background: rgba(124, 138, 255, 0.15);
|
||||
color: var(--protocol-github);
|
||||
}
|
||||
.tab-badge.badge-vnc {
|
||||
background: rgba(232, 93, 74, 0.15);
|
||||
color: var(--protocol-vnc);
|
||||
}
|
||||
.tab-badge.badge-irc {
|
||||
background: rgba(216, 178, 76, 0.15);
|
||||
color: var(--protocol-irc);
|
||||
}
|
||||
.tab-badge.badge-local {
|
||||
background: rgba(139, 195, 74, 0.15);
|
||||
color: var(--protocol-local);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Status dot (connection state)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-tertiary);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tab-status.is-connected { background: var(--success); }
|
||||
.tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
||||
.tab-status.is-error { background: var(--error); }
|
||||
.tab-status.is-disconnected { background: var(--text-tertiary); }
|
||||
.tab-status.is-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.tab-status.is-connecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 1.2s ease infinite;
|
||||
}
|
||||
.tab-status.is-reconnecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 0.8s ease infinite;
|
||||
}
|
||||
.tab-status.is-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.tab-status.is-disconnected {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Also match the status-* pattern used by updateSessionState */
|
||||
.tab-status.status-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.tab-status.status-connecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 1.2s ease infinite;
|
||||
}
|
||||
.tab-status.status-reconnecting {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 0.8s ease infinite;
|
||||
}
|
||||
.tab-status.status-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.tab-status.status-disconnected {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Activity indicator — pulses when background tab has new output
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-activity {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.tab-has-activity .tab-activity {
|
||||
opacity: 1;
|
||||
animation: pulse-dot 1.5s ease infinite;
|
||||
}
|
||||
|
||||
.tab-active .tab-activity {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Trigger flash — brief glow when a trigger rule matches */
|
||||
.tab-trigger-flash {
|
||||
animation: trigger-flash 2s ease;
|
||||
}
|
||||
|
||||
@keyframes trigger-flash {
|
||||
0%,
|
||||
100% {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
15% {
|
||||
background: rgba(239, 83, 80, 0.25);
|
||||
}
|
||||
30% {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
45% {
|
||||
background: rgba(239, 83, 80, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
SFTP toggle button — revealed on hover (SSH tabs only)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-sftp {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tab:hover .tab-sftp,
|
||||
.tab.tab-active .tab-sftp {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-sftp:hover {
|
||||
background: var(--bg-active);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Close button — revealed on hover
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab:hover .tab-close,
|
||||
.tab.active .tab-close {
|
||||
opacity: 1;
|
||||
.tab.tab-active .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Pin indicator
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-pin {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
color: var(--text-disabled);
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
color: var(--text-disabled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab.is-pinned .tab-pin {
|
||||
color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Pinned tabs are always narrow */
|
||||
.tab.is-pinned {
|
||||
max-width: 100px;
|
||||
padding: 0 var(--space-2);
|
||||
max-width: 100px;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.tab.is-pinned .tab-label {
|
||||
/* Hide label on pinned tabs to save space */
|
||||
display: none;
|
||||
/* Hide label on pinned tabs to save space */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab.is-pinned .tab-close {
|
||||
/* Pinned tabs don't show close by default */
|
||||
display: none;
|
||||
/* Pinned tabs don't show close by default */
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Drag handle area (for tab reordering)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-drag-handle {
|
||||
cursor: grab;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Drag ghost styling */
|
||||
.tab.is-dragging {
|
||||
opacity: 0.5;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab.is-drag-over-left::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.tab.is-drag-over-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
/* Note: this re-uses ::after — active tab indicator is overridden while dragging */
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
/* Note: this re-uses ::after — active tab indicator is overridden while dragging */
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
New-tab button (+)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--tab-bar-height);
|
||||
height: var(--tab-bar-height);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--tab-bar-height);
|
||||
height: var(--tab-bar-height);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.tab-new:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-new:active {
|
||||
background: var(--bg-active);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Broadcast toggle button
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-broadcast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--tab-bar-height);
|
||||
height: var(--tab-bar-height);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab-broadcast:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tab-broadcast.is-active {
|
||||
color: var(--error);
|
||||
opacity: 1;
|
||||
animation: pulse-dot 1.5s ease infinite;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -271,41 +434,63 @@
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-bar-overflow-left,
|
||||
.tab-bar-overflow-right {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
background: linear-gradient(90deg, var(--bg-surface), transparent);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
background: linear-gradient(90deg, var(--bg-surface), transparent);
|
||||
}
|
||||
|
||||
.tab-bar-overflow-right {
|
||||
background: linear-gradient(-90deg, var(--bg-surface), transparent);
|
||||
background: linear-gradient(-90deg, var(--bg-surface), transparent);
|
||||
}
|
||||
|
||||
.tab-bar.has-overflow-left .tab-bar-overflow-left,
|
||||
.tab-bar.has-overflow-right .tab-bar-overflow-right {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tab context menu trigger (on right-click)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-context-area {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
/* Transparent hit area for right-click context menu */
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
/* Transparent hit area for right-click context menu */
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tab hover tooltip
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-tooltip {
|
||||
max-width: 360px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.tab-tooltip-title {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-tooltip-meta {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--weight-normal);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Modified indicator (unsaved changes)
|
||||
----------------------------------------------------------------------- */
|
||||
.tab.is-modified .tab-close::before {
|
||||
content: '●';
|
||||
font-size: 8px;
|
||||
color: var(--accent);
|
||||
content: "●";
|
||||
font-size: 8px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
Terminal wrapper — fills the workspace panel
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -18,157 +18,227 @@
|
||||
Shows connection info above the terminal.
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.terminal-header-label {
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-header-value {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.terminal-header-separator {
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: var(--border-subtle);
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.terminal-header-spacer {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.terminal-header-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
xterm.js container
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Slight padding so terminal text doesn't hug edges */
|
||||
padding: var(--space-1);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Disconnected overlay
|
||||
----------------------------------------------------------------------- */
|
||||
.session-panel.is-disconnected > *:not(.terminal-disconnect-overlay) {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.terminal-disconnect-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 14, 18, 0.68);
|
||||
z-index: 6;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-disconnect-overlay.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.terminal-disconnect-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
text-align: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.terminal-disconnect-title {
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.terminal-disconnect-message {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* xterm.js overrides for consistent look */
|
||||
.terminal-container .xterm,
|
||||
.session-panel .xterm {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-1);
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 3px 0 0 3px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-screen,
|
||||
.session-panel .xterm-screen {
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport,
|
||||
.session-panel .xterm-viewport {
|
||||
/* Allow xterm's own scrollbar to work */
|
||||
overflow-y: auto !important;
|
||||
/* Allow xterm's own scrollbar to work */
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-track,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover,
|
||||
.session-panel .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Ensure cursor blink is visible */
|
||||
.terminal-container .xterm-cursor-layer,
|
||||
.session-panel .xterm-cursor-layer {
|
||||
z-index: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Selection styling within terminal */
|
||||
.terminal-container .xterm .xterm-selection div,
|
||||
.session-panel .xterm .xterm-selection div {
|
||||
background: var(--accent-strong) !important;
|
||||
background: var(--accent-strong) !important;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Terminal search overlay
|
||||
----------------------------------------------------------------------- */
|
||||
.terminal-search {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
visibility var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-search.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.terminal-search input {
|
||||
width: 200px;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 200px;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.terminal-search input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.terminal-search-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
white-space: nowrap;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.terminal-search-toggle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.5;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.terminal-search-toggle.is-active {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
@@ -177,231 +247,233 @@
|
||||
|
||||
/* RAW text view — scrollable, monospace output */
|
||||
.raw-text-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* RAW hex view */
|
||||
.raw-hex-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.raw-hex-row {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.raw-hex-offset {
|
||||
color: var(--text-tertiary);
|
||||
min-width: 60px;
|
||||
color: var(--text-tertiary);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.raw-hex-bytes {
|
||||
min-width: 350px;
|
||||
letter-spacing: 0.08em;
|
||||
min-width: 350px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.raw-hex-bytes .byte-highlight {
|
||||
color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.raw-hex-ascii {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.raw-hex-ascii .non-printable {
|
||||
color: var(--text-disabled);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* RAW split view (text on left, hex on right) */
|
||||
.raw-split-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.raw-split-view .raw-text-view,
|
||||
.raw-split-view .raw-hex-view {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.raw-split-divider {
|
||||
width: 1px;
|
||||
background: var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
background: var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
RAW mode view toggle buttons
|
||||
----------------------------------------------------------------------- */
|
||||
.raw-view-toggle {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.raw-view-toggle-btn {
|
||||
padding: 0 var(--space-2);
|
||||
height: 22px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
padding: 0 var(--space-2);
|
||||
height: 22px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.raw-view-toggle-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.raw-view-toggle-btn.active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Send bar (RAW TCP mode — manual data entry)
|
||||
----------------------------------------------------------------------- */
|
||||
.send-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-bar-input {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.send-bar-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.send-bar-input::placeholder {
|
||||
color: var(--text-disabled);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* Line ending selector */
|
||||
.send-bar-eol {
|
||||
height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.send-bar-eol:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Encoding selector */
|
||||
.send-bar-encoding {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Session overlay (connecting / error states)
|
||||
----------------------------------------------------------------------- */
|
||||
.session-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(28, 28, 28, 0.85);
|
||||
z-index: 5;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(28, 28, 28, 0.85);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.session-overlay-connecting {
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-overlay-connecting .spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 auto var(--space-4);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
|
||||
.session-overlay-connecting p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-overlay-error {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.session-overlay-error h3 {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--error);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--error);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.session-overlay-error p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.session-overlay-error .session-error-detail {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
text-align: left;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-disabled);
|
||||
background: var(--bg-input);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
text-align: left;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.session-overlay-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -5,137 +5,155 @@
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Colour palette — dark charcoal theme
|
||||
----------------------------------------------------------------------- */
|
||||
--bg-base: #1c1c1c; /* deepest background */
|
||||
--bg-surface: #232323; /* primary surface (sidebar, bars) */
|
||||
--bg-elevated: #2a2a2a; /* elevated panels, cards */
|
||||
--bg-overlay: #303030; /* overlays, dropdowns */
|
||||
--bg-hover: #363636; /* hover state backgrounds */
|
||||
--bg-active: #3c3c3c; /* active / pressed state */
|
||||
--bg-input: #1e1e1e; /* input field backgrounds */
|
||||
--bg-base: #1c1c1c; /* deepest background */
|
||||
--bg-surface: #232323; /* primary surface (sidebar, bars) */
|
||||
--bg-elevated: #2a2a2a; /* elevated panels, cards */
|
||||
--bg-overlay: #303030; /* overlays, dropdowns */
|
||||
--bg-hover: #363636; /* hover state backgrounds */
|
||||
--bg-active: #3c3c3c; /* active / pressed state */
|
||||
--bg-input: #1e1e1e; /* input field backgrounds */
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Border colours
|
||||
----------------------------------------------------------------------- */
|
||||
--border-subtle: rgba(255, 255, 255, 0.07);
|
||||
--border-default: rgba(255, 255, 255, 0.12);
|
||||
--border-strong: rgba(255, 255, 255, 0.18);
|
||||
--border-focus: var(--accent);
|
||||
--border-subtle: rgba(255, 255, 255, 0.07);
|
||||
--border-default: rgba(255, 255, 255, 0.12);
|
||||
--border-strong: rgba(255, 255, 255, 0.18);
|
||||
--border-focus: var(--accent);
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Text colours
|
||||
----------------------------------------------------------------------- */
|
||||
--text-primary: #e4e4e4;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-tertiary: #707070;
|
||||
--text-disabled: #4a4a4a;
|
||||
--text-inverse: #1c1c1c;
|
||||
--text-primary: #e4e4e4;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-tertiary: #707070;
|
||||
--text-disabled: #4a4a4a;
|
||||
--text-inverse: #1c1c1c;
|
||||
--sidebar-group-title: #b89d5f;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Accent colours — teal / cyan
|
||||
----------------------------------------------------------------------- */
|
||||
--accent: #4ecdc4;
|
||||
--accent-hover: #45b7af;
|
||||
--accent-muted: rgba(78, 205, 196, 0.12);
|
||||
--accent-strong: rgba(78, 205, 196, 0.22);
|
||||
--accent: #4ecdc4;
|
||||
--accent-hover: #45b7af;
|
||||
--accent-muted: rgba(78, 205, 196, 0.12);
|
||||
--accent-strong: rgba(78, 205, 196, 0.22);
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Status colours
|
||||
----------------------------------------------------------------------- */
|
||||
--success: #66bb6a;
|
||||
--warning: #ffa726;
|
||||
--error: #ef5350;
|
||||
--info: #42a5f5;
|
||||
--success: #66bb6a;
|
||||
--warning: #ffa726;
|
||||
--error: #ef5350;
|
||||
--info: #42a5f5;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Protocol badge colours
|
||||
----------------------------------------------------------------------- */
|
||||
--protocol-ssh: #4ecdc4;
|
||||
--protocol-telnet: #ffa726;
|
||||
--protocol-raw: #ab47bc;
|
||||
--protocol-github: #7c8aff;
|
||||
--protocol-ssh: #4ecdc4;
|
||||
--protocol-telnet: #ffa726;
|
||||
--protocol-raw: #ab47bc;
|
||||
--protocol-github: #7c8aff;
|
||||
--protocol-vnc: #e85d4a;
|
||||
--protocol-irc: #d8b24c;
|
||||
--protocol-local: #8bc34a;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Spacing scale (4 px base unit)
|
||||
----------------------------------------------------------------------- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Typography
|
||||
----------------------------------------------------------------------- */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco,
|
||||
'Cascadia Code', monospace;
|
||||
--font-sans:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "Fira Code", "SF Mono", Monaco, "Cascadia Code", monospace;
|
||||
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 22px;
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
--text-base: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-2xl: 22px;
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Border radius
|
||||
----------------------------------------------------------------------- */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Shadows
|
||||
----------------------------------------------------------------------- */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.40);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.50);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.60);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Transitions
|
||||
----------------------------------------------------------------------- */
|
||||
--transition-fast: 100ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
--transition-fast: 100ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Z-index layers
|
||||
----------------------------------------------------------------------- */
|
||||
--z-base: 0;
|
||||
--z-sticky: 100;
|
||||
--z-dropdown: 200;
|
||||
--z-modal-backdrop: 300;
|
||||
--z-modal: 400;
|
||||
--z-notification: 500;
|
||||
--z-tooltip: 600;
|
||||
--z-base: 0;
|
||||
--z-sticky: 100;
|
||||
--z-dropdown: 200;
|
||||
--z-modal-backdrop: 300;
|
||||
--z-modal: 400;
|
||||
--z-notification: 500;
|
||||
--z-tooltip: 600;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
/* -----------------------------------------------------------------------
|
||||
Layout
|
||||
----------------------------------------------------------------------- */
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-width-collapsed: 48px;
|
||||
--tab-bar-height: 36px;
|
||||
--toolbar-height: 40px;
|
||||
--status-bar-height: 24px;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-width-collapsed: 48px;
|
||||
--tab-bar-height: 44px;
|
||||
--toolbar-height: 40px;
|
||||
--status-bar-height: 24px;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Global input overrides — disable macOS/WebKit auto-capitalisation,
|
||||
autocorrect, and spellcheck on all text fields.
|
||||
----------------------------------------------------------------------- */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
autocapitalize: none;
|
||||
autocorrect: off;
|
||||
spellcheck: false;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* VNC viewer styles */
|
||||
|
||||
.vnc-viewer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.vnc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vnc-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.vnc-status-connected {
|
||||
color: var(--protocol-ssh);
|
||||
}
|
||||
|
||||
.vnc-status-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-vnc-action {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.btn-vnc-action:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vnc-display {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.vnc-display canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Protocol colour for VNC */
|
||||
.proto-vnc {
|
||||
color: var(--protocol-vnc);
|
||||
}
|
||||
|
||||
.badge-vnc {
|
||||
background: var(--protocol-vnc);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-item-indicator--vnc {
|
||||
background: var(--protocol-vnc);
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — command palette (Ctrl+K).
|
||||
* Provides a searchable overlay for quick access to application commands,
|
||||
* profile connections, and utility actions.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — command palette (Ctrl+K).
|
||||
* Provides a searchable overlay for quick access to application commands,
|
||||
* profile connections, and utility actions.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
|
||||
|
||||
class CommandPalette {
|
||||
class CommandPalette {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.app = app;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._overlay = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._overlay = null;
|
||||
|
||||
/** @type {string} Current filter query. */
|
||||
this._query = '';
|
||||
/** @type {string} Current filter query. */
|
||||
this._query = "";
|
||||
|
||||
/** @type {number} Index of the highlighted command. */
|
||||
this._selectedIndex = 0;
|
||||
/** @type {number} Index of the highlighted command. */
|
||||
this._selectedIndex = 0;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -30,31 +30,31 @@ class CommandPalette {
|
||||
* Toggles the command palette open/closed.
|
||||
*/
|
||||
toggle() {
|
||||
if (this._overlay) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
if (this._overlay) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the command palette.
|
||||
*/
|
||||
open() {
|
||||
if (this._overlay) return;
|
||||
this._query = '';
|
||||
this._selectedIndex = 0;
|
||||
this._render();
|
||||
if (this._overlay) return;
|
||||
this._query = "";
|
||||
this._selectedIndex = 0;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the command palette.
|
||||
*/
|
||||
close() {
|
||||
if (this._overlay) {
|
||||
this._overlay.remove();
|
||||
this._overlay = null;
|
||||
}
|
||||
if (this._overlay) {
|
||||
this._overlay.remove();
|
||||
this._overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command definitions ──────────────────────────────────────────
|
||||
@@ -64,151 +64,192 @@ class CommandPalette {
|
||||
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
|
||||
*/
|
||||
getCommands() {
|
||||
const cmds = [];
|
||||
const cmds = [];
|
||||
|
||||
// Profile connections
|
||||
const profiles = this.app.state.profiles || [];
|
||||
for (const p of profiles) {
|
||||
cmds.push({
|
||||
id: `connect-${p.id}`,
|
||||
label: `Connect to ${p.displayName || p.host}`,
|
||||
section: 'Connections',
|
||||
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
|
||||
action: () => this.app.connectProfile(p.id),
|
||||
});
|
||||
}
|
||||
// Profile connections
|
||||
const profiles = this.app.state.profiles || [];
|
||||
for (const p of profiles) {
|
||||
cmds.push({
|
||||
id: `connect-${p.id}`,
|
||||
label: `Connect to ${p.displayName || p.host}`,
|
||||
section: "Connections",
|
||||
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
|
||||
action: () => this.app.connectProfile(p.id),
|
||||
});
|
||||
}
|
||||
|
||||
// New connections
|
||||
cmds.push({
|
||||
id: 'new-ssh',
|
||||
label: 'New SSH Connection',
|
||||
section: 'Actions',
|
||||
shortcut: '⌘N',
|
||||
action: () => this.app.profileEditor.openNew('ssh'),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'new-telnet',
|
||||
label: 'New Telnet Connection',
|
||||
section: 'Actions',
|
||||
action: () => this.app.profileEditor.openNew('telnet'),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'new-raw',
|
||||
label: 'New RAW Connection',
|
||||
section: 'Actions',
|
||||
action: () => this.app.profileEditor.openNew('raw'),
|
||||
});
|
||||
// New connections
|
||||
cmds.push({
|
||||
id: "new-ssh",
|
||||
label: "New SSH Connection",
|
||||
section: "Actions",
|
||||
shortcut: "⌘N",
|
||||
action: () => this.app.profileEditor.openNew("ssh"),
|
||||
});
|
||||
cmds.push({
|
||||
id: "new-telnet",
|
||||
label: "New Telnet Connection",
|
||||
section: "Actions",
|
||||
action: () => this.app.profileEditor.openNew("telnet"),
|
||||
});
|
||||
cmds.push({
|
||||
id: "new-raw",
|
||||
label: "New RAW Connection",
|
||||
section: "Actions",
|
||||
action: () => this.app.profileEditor.openNew("raw"),
|
||||
});
|
||||
|
||||
// Quick connect
|
||||
cmds.push({
|
||||
id: 'quick-connect',
|
||||
label: 'Quick Connect…',
|
||||
section: 'Actions',
|
||||
action: () => this.app.quickConnect(),
|
||||
});
|
||||
// Quick connect
|
||||
cmds.push({
|
||||
id: "quick-connect",
|
||||
label: "Quick Connect…",
|
||||
section: "Actions",
|
||||
action: () => this.app.quickConnect(),
|
||||
});
|
||||
|
||||
// Tab actions
|
||||
cmds.push({
|
||||
id: 'close-tab',
|
||||
label: 'Close Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘W',
|
||||
action: () => this.app.tabs.closeActive(),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'next-tab',
|
||||
label: 'Next Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘Tab',
|
||||
action: () => {
|
||||
const next = this.app.tabs.getNextTab();
|
||||
if (next) this.app.tabs.setActive(next);
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
id: 'prev-tab',
|
||||
label: 'Previous Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘⇧Tab',
|
||||
action: () => {
|
||||
const prev = this.app.tabs.getPrevTab();
|
||||
if (prev) this.app.tabs.setActive(prev);
|
||||
},
|
||||
});
|
||||
// Local terminal
|
||||
cmds.push({
|
||||
id: "local-terminal",
|
||||
label: "Open Local Terminal",
|
||||
section: "Actions",
|
||||
action: () => this.app.connectLocal(),
|
||||
});
|
||||
|
||||
// Settings & utility
|
||||
// Tab actions
|
||||
cmds.push({
|
||||
id: "close-tab",
|
||||
label: "Close Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘W",
|
||||
action: () => this.app.tabs.closeActive(),
|
||||
});
|
||||
cmds.push({
|
||||
id: "next-tab",
|
||||
label: "Next Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘Tab",
|
||||
action: () => {
|
||||
const next = this.app.tabs.getNextTab();
|
||||
if (next) this.app.tabs.setActive(next);
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
id: "prev-tab",
|
||||
label: "Previous Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘⇧Tab",
|
||||
action: () => {
|
||||
const prev = this.app.tabs.getPrevTab();
|
||||
if (prev) this.app.tabs.setActive(prev);
|
||||
},
|
||||
});
|
||||
|
||||
// Settings & utility
|
||||
cmds.push({
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
section: "Application",
|
||||
action: () => this.app.settings.open(),
|
||||
});
|
||||
cmds.push({
|
||||
id: "import-profiles",
|
||||
label: "Import Profiles",
|
||||
section: "Application",
|
||||
action: () => this.app.importProfiles(),
|
||||
});
|
||||
cmds.push({
|
||||
id: "export-profiles",
|
||||
label: "Export Profiles",
|
||||
section: "Application",
|
||||
action: () => this.app.exportProfiles(),
|
||||
});
|
||||
|
||||
// Secret store
|
||||
if (this.app.state.secretStoreUnlocked) {
|
||||
cmds.push({
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
section: 'Application',
|
||||
action: () => this.app.settings.open(),
|
||||
id: "lock-secrets",
|
||||
label: "Lock Secret Store",
|
||||
section: "Security",
|
||||
action: async () => {
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.LockSecretStore();
|
||||
this.app.state.setSecretStoreState(false);
|
||||
this.app.notifications.info("Secret store locked");
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to lock: " + err);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
cmds.push({
|
||||
id: "unlock-secrets",
|
||||
label: "Unlock Secret Store",
|
||||
section: "Security",
|
||||
action: async () => {
|
||||
try {
|
||||
const pw =
|
||||
await this.app.secretPrompt.promptMasterPassword(false);
|
||||
await window.NexusTerminal.wailsService.UnlockSecretStore(pw);
|
||||
this.app.state.setSecretStoreState(true);
|
||||
this.app.notifications.success("Secret store unlocked");
|
||||
} catch (err) {
|
||||
if (err.message !== "User cancelled") {
|
||||
this.app.notifications.error("Unlock failed: " + err);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// SSH Key Manager
|
||||
cmds.push({
|
||||
id: "ssh-keys",
|
||||
label: "SSH Key Manager",
|
||||
section: "Security",
|
||||
action: () => this.app.sshKeys.open(),
|
||||
});
|
||||
|
||||
// Trigger Rules
|
||||
cmds.push({
|
||||
id: "trigger-rules",
|
||||
label: "Trigger Rules",
|
||||
section: "Terminal",
|
||||
action: () => this.app.triggers.open(),
|
||||
});
|
||||
|
||||
// Terminal actions (only when a session is active)
|
||||
if (this.app.state.activeSessionId) {
|
||||
cmds.push({
|
||||
id: "split-vertical",
|
||||
label: "Split Pane Right",
|
||||
section: "Terminal",
|
||||
shortcut: "⌘⇧D",
|
||||
action: () => this.app.splitPane("vertical"),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'import-profiles',
|
||||
label: 'Import Profiles',
|
||||
section: 'Application',
|
||||
action: () => this.app.importProfiles(),
|
||||
id: "split-horizontal",
|
||||
label: "Split Pane Down",
|
||||
section: "Terminal",
|
||||
shortcut: "⌘⇧E",
|
||||
action: () => this.app.splitPane("horizontal"),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'export-profiles',
|
||||
label: 'Export Profiles',
|
||||
section: 'Application',
|
||||
action: () => this.app.exportProfiles(),
|
||||
id: "clear-terminal",
|
||||
label: "Clear Terminal",
|
||||
section: "Terminal",
|
||||
action: () =>
|
||||
this.app.terminals.clearTerminal(this.app.state.activeSessionId),
|
||||
});
|
||||
cmds.push({
|
||||
id: "reset-terminal",
|
||||
label: "Reset Terminal",
|
||||
section: "Terminal",
|
||||
action: () =>
|
||||
this.app.terminals.resetTerminal(this.app.state.activeSessionId),
|
||||
});
|
||||
}
|
||||
|
||||
// Secret store
|
||||
if (this.app.state.secretStoreUnlocked) {
|
||||
cmds.push({
|
||||
id: 'lock-secrets',
|
||||
label: 'Lock Secret Store',
|
||||
section: 'Security',
|
||||
action: async () => {
|
||||
try {
|
||||
await window.go.api.Service.LockSecretStore();
|
||||
this.app.state.setSecretStoreState(false);
|
||||
this.app.notifications.info('Secret store locked');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to lock: ' + err);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
cmds.push({
|
||||
id: 'unlock-secrets',
|
||||
label: 'Unlock Secret Store',
|
||||
section: 'Security',
|
||||
action: async () => {
|
||||
try {
|
||||
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
||||
await window.go.api.Service.UnlockSecretStore(pw);
|
||||
this.app.state.setSecretStoreState(true);
|
||||
this.app.notifications.success('Secret store unlocked');
|
||||
} catch (err) {
|
||||
if (err.message !== 'User cancelled') {
|
||||
this.app.notifications.error('Unlock failed: ' + err);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Terminal actions (only when a session is active)
|
||||
if (this.app.state.activeSessionId) {
|
||||
cmds.push({
|
||||
id: 'clear-terminal',
|
||||
label: 'Clear Terminal',
|
||||
section: 'Terminal',
|
||||
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'reset-terminal',
|
||||
label: 'Reset Terminal',
|
||||
section: 'Terminal',
|
||||
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
|
||||
});
|
||||
}
|
||||
|
||||
return cmds;
|
||||
return cmds;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,14 +258,17 @@ class CommandPalette {
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
filter(query) {
|
||||
if (!query) return this.getCommands();
|
||||
return this.getCommands()
|
||||
.map((cmd) => {
|
||||
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
|
||||
return { ...cmd, ...result };
|
||||
})
|
||||
.filter((c) => c.match)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
if (!query) return this.getCommands();
|
||||
return this.getCommands()
|
||||
.map((cmd) => {
|
||||
const result = fuzzyMatch(
|
||||
cmd.label + " " + (cmd.detail || ""),
|
||||
query,
|
||||
);
|
||||
return { ...cmd, ...result };
|
||||
})
|
||||
.filter((c) => c.match)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,82 +276,85 @@ class CommandPalette {
|
||||
* @param {Object} command
|
||||
*/
|
||||
execute(command) {
|
||||
this.close();
|
||||
if (command && typeof command.action === 'function') {
|
||||
command.action();
|
||||
}
|
||||
this.close();
|
||||
if (command && typeof command.action === "function") {
|
||||
command.action();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────
|
||||
|
||||
/** @private */
|
||||
_render() {
|
||||
const overlay = createElement('div', 'palette-overlay');
|
||||
const palette = createElement('div', 'palette');
|
||||
const overlay = createElement("div", "palette-overlay");
|
||||
const palette = createElement("div", "palette");
|
||||
|
||||
// Search input
|
||||
const input = createElement('input', 'palette-input', {
|
||||
type: 'text',
|
||||
placeholder: 'Type a command…',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
});
|
||||
// Search input
|
||||
const input = createElement("input", "palette-input", {
|
||||
type: "text",
|
||||
placeholder: "Type a command…",
|
||||
autocomplete: "off",
|
||||
spellcheck: "false",
|
||||
});
|
||||
|
||||
palette.appendChild(input);
|
||||
palette.appendChild(input);
|
||||
|
||||
// Results list
|
||||
const list = createElement('div', 'palette-list');
|
||||
palette.appendChild(list);
|
||||
// Results list
|
||||
const list = createElement("div", "palette-list");
|
||||
palette.appendChild(list);
|
||||
|
||||
overlay.appendChild(palette);
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
overlay.appendChild(palette);
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
|
||||
// Initial render
|
||||
this._renderList(list, this.filter(''));
|
||||
// Initial render
|
||||
this._renderList(list, this.filter(""));
|
||||
|
||||
// Focus the input
|
||||
input.focus();
|
||||
// Focus the input
|
||||
input.focus();
|
||||
|
||||
// Event handlers
|
||||
input.addEventListener('input', () => {
|
||||
this._query = input.value;
|
||||
this._selectedIndex = 0;
|
||||
this._renderList(list, this.filter(this._query));
|
||||
});
|
||||
// Event handlers
|
||||
input.addEventListener("input", () => {
|
||||
this._query = input.value;
|
||||
this._selectedIndex = 0;
|
||||
this._renderList(list, this.filter(this._query));
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = list.querySelectorAll('.palette-item');
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'Enter': {
|
||||
e.preventDefault();
|
||||
const selected = items[this._selectedIndex];
|
||||
if (selected && selected._command) {
|
||||
this.execute(selected._command);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
input.addEventListener("keydown", (e) => {
|
||||
const items = list.querySelectorAll(".palette-item");
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.min(
|
||||
this._selectedIndex + 1,
|
||||
items.length - 1,
|
||||
);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const selected = items[this._selectedIndex];
|
||||
if (selected && selected._command) {
|
||||
this.execute(selected._command);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) this.close();
|
||||
});
|
||||
// Click outside to close
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) this.close();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,52 +364,65 @@ class CommandPalette {
|
||||
* @private
|
||||
*/
|
||||
_renderList(list, commands) {
|
||||
list.innerHTML = '';
|
||||
let currentSection = null;
|
||||
list.innerHTML = "";
|
||||
let currentSection = null;
|
||||
|
||||
if (commands.length === 0) {
|
||||
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
if (commands.length === 0) {
|
||||
const empty = createElement("div", "palette-empty", {
|
||||
textContent: "No commands found",
|
||||
});
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const cmd = commands[i];
|
||||
|
||||
// Section header
|
||||
if (cmd.section && cmd.section !== currentSection) {
|
||||
currentSection = cmd.section;
|
||||
const sectionEl = createElement("div", "palette-section", {
|
||||
textContent: cmd.section,
|
||||
});
|
||||
list.appendChild(sectionEl);
|
||||
}
|
||||
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const cmd = commands[i];
|
||||
const item = createElement(
|
||||
"div",
|
||||
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
|
||||
);
|
||||
item._command = cmd;
|
||||
|
||||
// Section header
|
||||
if (cmd.section && cmd.section !== currentSection) {
|
||||
currentSection = cmd.section;
|
||||
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
|
||||
list.appendChild(sectionEl);
|
||||
}
|
||||
// Label
|
||||
const label = createElement("span", "palette-item-label", {
|
||||
textContent: cmd.label,
|
||||
});
|
||||
item.appendChild(label);
|
||||
|
||||
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
|
||||
item._command = cmd;
|
||||
|
||||
// Label
|
||||
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
|
||||
item.appendChild(label);
|
||||
|
||||
// Detail
|
||||
if (cmd.detail) {
|
||||
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
|
||||
item.appendChild(detail);
|
||||
}
|
||||
|
||||
// Shortcut badge
|
||||
if (cmd.shortcut) {
|
||||
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => this.execute(cmd));
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this._selectedIndex = i;
|
||||
this._highlightItem(list.querySelectorAll('.palette-item'));
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
// Detail
|
||||
if (cmd.detail) {
|
||||
const detail = createElement("span", "palette-item-detail", {
|
||||
textContent: cmd.detail,
|
||||
});
|
||||
item.appendChild(detail);
|
||||
}
|
||||
|
||||
// Shortcut badge
|
||||
if (cmd.shortcut) {
|
||||
const badge = createElement("span", "palette-item-shortcut", {
|
||||
textContent: cmd.shortcut,
|
||||
});
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener("click", () => this.execute(cmd));
|
||||
item.addEventListener("mouseenter", () => {
|
||||
this._selectedIndex = i;
|
||||
this._highlightItem(list.querySelectorAll(".palette-item"));
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,17 +431,16 @@ class CommandPalette {
|
||||
* @private
|
||||
*/
|
||||
_highlightItem(items) {
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle('palette-item-active', i === this._selectedIndex);
|
||||
});
|
||||
const active = items[this._selectedIndex];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle("palette-item-active", i === this._selectedIndex);
|
||||
});
|
||||
const active = items[this._selectedIndex];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.CommandPalette = CommandPalette;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.CommandPalette = CommandPalette;
|
||||
})();
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — GitHub repository viewer.
|
||||
* Renders a scrollable list of repositories grouped by owner/organisation,
|
||||
* sorted by last commit date (newest first). Automatically refreshes via
|
||||
* Wails events emitted by the Go backend.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — GitHub repository viewer.
|
||||
* Renders a scrollable list of repositories grouped by owner/organisation,
|
||||
* sorted by last commit date (newest first). Automatically refreshes via
|
||||
* Wails events emitted by the Go backend.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal;
|
||||
|
||||
class GitHubViewer {
|
||||
class GitHubViewer {
|
||||
constructor() {
|
||||
/** @type {Map<string, GitHubViewerInstance>} sessionID → viewer */
|
||||
this._viewers = new Map();
|
||||
/** @type {Map<string, GitHubViewerInstance>} sessionID → viewer */
|
||||
this._viewers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,11 +21,15 @@ class GitHubViewer {
|
||||
* @param {string} profileName
|
||||
*/
|
||||
createViewer(sessionID, container, profileName) {
|
||||
if (this._viewers.has(sessionID)) return;
|
||||
if (this._viewers.has(sessionID)) return;
|
||||
|
||||
const viewer = new GitHubViewerInstance(sessionID, container, profileName);
|
||||
this._viewers.set(sessionID, viewer);
|
||||
viewer.render();
|
||||
const viewer = new GitHubViewerInstance(
|
||||
sessionID,
|
||||
container,
|
||||
profileName,
|
||||
);
|
||||
this._viewers.set(sessionID, viewer);
|
||||
viewer.render();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,11 +37,11 @@ class GitHubViewer {
|
||||
* @param {string} sessionID
|
||||
*/
|
||||
destroyViewer(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
this._viewers.delete(sessionID);
|
||||
}
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
this._viewers.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +51,22 @@ class GitHubViewer {
|
||||
* @param {Array} repos
|
||||
*/
|
||||
updateRepos(sessionID, login, repos) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.setData(login, repos);
|
||||
}
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.setData(login, repos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status line for a session.
|
||||
* @param {string} sessionID
|
||||
* @param {string} message
|
||||
*/
|
||||
setStatus(sessionID, message) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.setStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,98 +75,98 @@ class GitHubViewer {
|
||||
* @param {string} message
|
||||
*/
|
||||
showError(sessionID, message) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.showError(message);
|
||||
}
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.showError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GitHubViewerInstance {
|
||||
class GitHubViewerInstance {
|
||||
/**
|
||||
* @param {string} sessionID
|
||||
* @param {HTMLElement} container
|
||||
* @param {string} profileName
|
||||
*/
|
||||
constructor(sessionID, container, profileName) {
|
||||
this.sessionID = sessionID;
|
||||
this.container = container;
|
||||
this.profileName = profileName;
|
||||
this.sessionID = sessionID;
|
||||
this.container = container;
|
||||
this.profileName = profileName;
|
||||
|
||||
/** @type {string} */
|
||||
this._login = '';
|
||||
/** @type {string} */
|
||||
this._login = "";
|
||||
|
||||
/** @type {Array} */
|
||||
this._repos = [];
|
||||
/** @type {Array} */
|
||||
this._repos = [];
|
||||
|
||||
/** @type {string} */
|
||||
this._filter = '';
|
||||
/** @type {string} */
|
||||
this._filter = "";
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._el = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._el = null;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._listEl = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._listEl = null;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._statusEl = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._statusEl = null;
|
||||
|
||||
/** @type {number|null} */
|
||||
this._refreshTimer = null;
|
||||
/** @type {number|null} */
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = '';
|
||||
this.container.innerHTML = "";
|
||||
|
||||
const wrapper = createElement('div', 'github-viewer');
|
||||
const wrapper = createElement("div", "github-viewer");
|
||||
|
||||
// ── Header bar ──────────────────────────────────────────
|
||||
const header = createElement('div', 'github-viewer-header');
|
||||
// ── Header bar ──────────────────────────────────────────
|
||||
const header = createElement("div", "github-viewer-header");
|
||||
|
||||
const titleArea = createElement('div', 'github-viewer-title-area');
|
||||
const icon = createElement('span', 'github-viewer-icon');
|
||||
icon.innerHTML = this._githubSVG();
|
||||
titleArea.appendChild(icon);
|
||||
const titleArea = createElement("div", "github-viewer-title-area");
|
||||
const icon = createElement("span", "github-viewer-icon");
|
||||
icon.innerHTML = this._githubSVG();
|
||||
titleArea.appendChild(icon);
|
||||
|
||||
const titleText = createElement('span', 'github-viewer-title');
|
||||
titleText.textContent = this.profileName || 'GitHub';
|
||||
titleArea.appendChild(titleText);
|
||||
header.appendChild(titleArea);
|
||||
const titleText = createElement("span", "github-viewer-title");
|
||||
titleText.textContent = this.profileName || "GitHub";
|
||||
titleArea.appendChild(titleText);
|
||||
header.appendChild(titleArea);
|
||||
|
||||
// Search / filter
|
||||
const searchWrap = createElement('div', 'github-viewer-search');
|
||||
const searchInput = createElement('input', 'github-viewer-search-input', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter repositories…',
|
||||
});
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
this._filter = e.target.value.toLowerCase();
|
||||
this._renderList();
|
||||
});
|
||||
searchWrap.appendChild(searchInput);
|
||||
header.appendChild(searchWrap);
|
||||
// Search / filter
|
||||
const searchWrap = createElement("div", "github-viewer-search");
|
||||
const searchInput = createElement("input", "github-viewer-search-input", {
|
||||
type: "text",
|
||||
placeholder: "Filter repositories…",
|
||||
});
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
this._filter = e.target.value.toLowerCase();
|
||||
this._renderList();
|
||||
});
|
||||
searchWrap.appendChild(searchInput);
|
||||
header.appendChild(searchWrap);
|
||||
|
||||
// Refresh button
|
||||
const refreshBtn = createElement('button', 'github-viewer-refresh', {
|
||||
title: 'Refresh now',
|
||||
});
|
||||
refreshBtn.innerHTML = '↻';
|
||||
refreshBtn.addEventListener('click', () => this._manualRefresh());
|
||||
header.appendChild(refreshBtn);
|
||||
// Refresh button
|
||||
const refreshBtn = createElement("button", "github-viewer-refresh", {
|
||||
title: "Refresh now",
|
||||
});
|
||||
refreshBtn.innerHTML = "↻";
|
||||
refreshBtn.addEventListener("click", () => this._manualRefresh());
|
||||
header.appendChild(refreshBtn);
|
||||
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
// ── Status bar ──────────────────────────────────────────
|
||||
this._statusEl = createElement('div', 'github-viewer-status');
|
||||
this._statusEl.textContent = 'Connecting…';
|
||||
wrapper.appendChild(this._statusEl);
|
||||
// ── Status bar ──────────────────────────────────────────
|
||||
this._statusEl = createElement("div", "github-viewer-status");
|
||||
this._statusEl.textContent = "Connecting…";
|
||||
wrapper.appendChild(this._statusEl);
|
||||
|
||||
// ── Repo list ───────────────────────────────────────────
|
||||
this._listEl = createElement('div', 'github-viewer-list');
|
||||
wrapper.appendChild(this._listEl);
|
||||
// ── Repo list ───────────────────────────────────────────
|
||||
this._listEl = createElement("div", "github-viewer-list");
|
||||
wrapper.appendChild(this._listEl);
|
||||
|
||||
this.container.appendChild(wrapper);
|
||||
this._el = wrapper;
|
||||
this.container.appendChild(wrapper);
|
||||
this._el = wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,10 +175,20 @@ class GitHubViewerInstance {
|
||||
* @param {Array} repos
|
||||
*/
|
||||
setData(login, repos) {
|
||||
this._login = login;
|
||||
this._repos = repos || [];
|
||||
this._updateStatus();
|
||||
this._renderList();
|
||||
this._login = login;
|
||||
this._repos = repos || [];
|
||||
this._updateStatus();
|
||||
this._renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status line with a custom message.
|
||||
* @param {string} message
|
||||
*/
|
||||
setStatus(message) {
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.classList.remove("github-viewer-status-error");
|
||||
this._statusEl.textContent = message || "";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,89 +196,108 @@ class GitHubViewerInstance {
|
||||
* @param {string} message
|
||||
*/
|
||||
showError(message) {
|
||||
if (this._statusEl) {
|
||||
this._statusEl.textContent = `⚠ ${message}`;
|
||||
this._statusEl.classList.add('github-viewer-status-error');
|
||||
}
|
||||
if (this._statusEl) {
|
||||
this._statusEl.textContent = `⚠ ${message}`;
|
||||
this._statusEl.classList.add("github-viewer-status-error");
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._refreshTimer) {
|
||||
clearInterval(this._refreshTimer);
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
if (this._el) {
|
||||
this._el.remove();
|
||||
this._el = null;
|
||||
}
|
||||
if (this._refreshTimer) {
|
||||
clearInterval(this._refreshTimer);
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
if (this._el) {
|
||||
this._el.remove();
|
||||
this._el = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────
|
||||
|
||||
_updateStatus() {
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.classList.remove('github-viewer-status-error');
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.classList.remove("github-viewer-status-error");
|
||||
|
||||
const count = this._repos.length;
|
||||
const now = new Date().toLocaleTimeString();
|
||||
this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? 'y' : 'ies'} · updated ${now}`;
|
||||
const count = this._repos.length;
|
||||
const now = new Date().toLocaleTimeString();
|
||||
this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? "y" : "ies"} · updated ${now}`;
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
if (!this._listEl) return;
|
||||
this._listEl.innerHTML = '';
|
||||
if (!this._listEl) return;
|
||||
this._listEl.innerHTML = "";
|
||||
|
||||
// Filter
|
||||
let filtered = this._repos;
|
||||
if (this._filter) {
|
||||
filtered = this._repos.filter((r) =>
|
||||
r.fullName.toLowerCase().includes(this._filter) ||
|
||||
(r.description || '').toLowerCase().includes(this._filter) ||
|
||||
(r.language || '').toLowerCase().includes(this._filter)
|
||||
);
|
||||
}
|
||||
// Filter
|
||||
let filtered = this._repos;
|
||||
if (this._filter) {
|
||||
filtered = this._repos.filter(
|
||||
(r) =>
|
||||
r.fullName.toLowerCase().includes(this._filter) ||
|
||||
(r.description || "").toLowerCase().includes(this._filter) ||
|
||||
(r.language || "").toLowerCase().includes(this._filter),
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const empty = createElement('div', 'github-viewer-empty');
|
||||
empty.textContent = this._filter ? 'No matching repositories.' : 'No repositories found.';
|
||||
this._listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
const empty = createElement("div", "github-viewer-empty");
|
||||
empty.textContent = this._filter
|
||||
? "No matching repositories."
|
||||
: "No repositories found.";
|
||||
this._listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by owner
|
||||
const groups = new Map();
|
||||
for (const repo of filtered) {
|
||||
const owner = repo.owner || 'unknown';
|
||||
if (!groups.has(owner)) groups.set(owner, []);
|
||||
groups.get(owner).push(repo);
|
||||
}
|
||||
const commitTimestamp = (repo) => {
|
||||
const raw = repo.latestCommitAt || repo.pushedAt || repo.updatedAt || "";
|
||||
const ts = new Date(raw).getTime();
|
||||
return Number.isNaN(ts) ? 0 : ts;
|
||||
};
|
||||
|
||||
// Sort groups alphabetically (user's own repos first)
|
||||
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||
if (a === this._login) return -1;
|
||||
if (b === this._login) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
// Group by owner
|
||||
const groups = new Map();
|
||||
for (const repo of filtered) {
|
||||
const owner = repo.owner || "unknown";
|
||||
if (!groups.has(owner)) groups.set(owner, []);
|
||||
groups.get(owner).push(repo);
|
||||
}
|
||||
|
||||
for (const owner of sortedKeys) {
|
||||
const repos = groups.get(owner);
|
||||
// Sort repos within each group by latest commit (newest first)
|
||||
for (const repos of groups.values()) {
|
||||
repos.sort((a, b) => commitTimestamp(b) - commitTimestamp(a));
|
||||
}
|
||||
|
||||
// Group header
|
||||
const groupEl = createElement('div', 'github-group');
|
||||
const groupHeader = createElement('div', 'github-group-header');
|
||||
groupHeader.innerHTML = `
|
||||
// Sort groups by most recent commit in each group (login first on ties)
|
||||
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||
const aRepos = groups.get(a) || [];
|
||||
const bRepos = groups.get(b) || [];
|
||||
const aMax = aRepos.length > 0 ? commitTimestamp(aRepos[0]) : 0;
|
||||
const bMax = bRepos.length > 0 ? commitTimestamp(bRepos[0]) : 0;
|
||||
if (aMax !== bMax) return bMax - aMax;
|
||||
if (a === this._login) return -1;
|
||||
if (b === this._login) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (const owner of sortedKeys) {
|
||||
const repos = groups.get(owner);
|
||||
|
||||
// Group header
|
||||
const groupEl = createElement("div", "github-group");
|
||||
const groupHeader = createElement("div", "github-group-header");
|
||||
groupHeader.innerHTML = `
|
||||
<span class="github-group-name">${escapeHtml(owner)}</span>
|
||||
<span class="github-group-count">${repos.length}</span>
|
||||
`;
|
||||
groupEl.appendChild(groupHeader);
|
||||
groupEl.appendChild(groupHeader);
|
||||
|
||||
// Repo cards
|
||||
for (const repo of repos) {
|
||||
groupEl.appendChild(this._renderRepoCard(repo));
|
||||
}
|
||||
|
||||
this._listEl.appendChild(groupEl);
|
||||
// Repo cards
|
||||
for (const repo of repos) {
|
||||
groupEl.appendChild(this._renderRepoCard(repo));
|
||||
}
|
||||
|
||||
this._listEl.appendChild(groupEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,77 +306,145 @@ class GitHubViewerInstance {
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_renderRepoCard(repo) {
|
||||
const card = createElement('div', 'github-repo-card');
|
||||
card.addEventListener('click', () => {
|
||||
if (repo.htmlURL) window.open(repo.htmlURL, '_blank');
|
||||
});
|
||||
const card = createElement("div", "github-repo-card");
|
||||
card.addEventListener("click", () => {
|
||||
if (repo.htmlURL) window.open(repo.htmlURL, "_blank");
|
||||
});
|
||||
|
||||
// Top row: name + badges
|
||||
const topRow = createElement('div', 'github-repo-top');
|
||||
const nameEl = createElement('span', 'github-repo-name');
|
||||
nameEl.textContent = repo.name;
|
||||
topRow.appendChild(nameEl);
|
||||
// Top row: name + badges
|
||||
const topRow = createElement("div", "github-repo-top");
|
||||
const nameEl = createElement("span", "github-repo-name");
|
||||
nameEl.textContent = repo.name;
|
||||
topRow.appendChild(nameEl);
|
||||
|
||||
if (repo.private) {
|
||||
const badge = createElement('span', 'github-repo-badge github-repo-private');
|
||||
badge.textContent = 'Private';
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.fork) {
|
||||
const badge = createElement('span', 'github-repo-badge github-repo-fork');
|
||||
badge.textContent = 'Fork';
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.archived) {
|
||||
const badge = createElement('span', 'github-repo-badge github-repo-archived');
|
||||
badge.textContent = 'Archived';
|
||||
topRow.appendChild(badge);
|
||||
if (repo.private) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
"github-repo-badge github-repo-private",
|
||||
);
|
||||
badge.textContent = "Private";
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.fork) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
"github-repo-badge github-repo-fork",
|
||||
);
|
||||
badge.textContent = "Fork";
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
if (repo.archived) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
"github-repo-badge github-repo-archived",
|
||||
);
|
||||
badge.textContent = "Archived";
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
|
||||
const actions = createElement("div", "github-repo-actions");
|
||||
|
||||
const downloadBtn = createElement("button", "github-repo-download", {
|
||||
textContent: "ZIP",
|
||||
title: "Download repository as zip",
|
||||
});
|
||||
downloadBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this._downloadRepo(repo);
|
||||
});
|
||||
actions.appendChild(downloadBtn);
|
||||
|
||||
const visitLink = createElement("button", "github-repo-visit", {
|
||||
textContent: "Visit",
|
||||
title: "Open repository in browser",
|
||||
});
|
||||
visitLink.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (repo.htmlURL) {
|
||||
if (window.runtime && window.runtime.BrowserOpenURL) {
|
||||
window.runtime.BrowserOpenURL(repo.htmlURL);
|
||||
} else {
|
||||
window.open(repo.htmlURL, "_blank");
|
||||
}
|
||||
}
|
||||
});
|
||||
actions.appendChild(visitLink);
|
||||
|
||||
card.appendChild(topRow);
|
||||
topRow.appendChild(actions);
|
||||
|
||||
// Description
|
||||
if (repo.description) {
|
||||
const desc = createElement('div', 'github-repo-desc');
|
||||
desc.textContent = repo.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
card.appendChild(topRow);
|
||||
|
||||
// Meta row: language, stars, pushed
|
||||
const meta = createElement('div', 'github-repo-meta');
|
||||
if (repo.latestCommitter && repo.latestCommitAt) {
|
||||
const commitLine = createElement("div", "github-repo-commit");
|
||||
const when = new Date(repo.latestCommitAt);
|
||||
const whenText = isNaN(when.getTime())
|
||||
? repo.latestCommitAt
|
||||
: when.toLocaleString();
|
||||
commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`;
|
||||
card.appendChild(commitLine);
|
||||
}
|
||||
|
||||
if (repo.language) {
|
||||
const lang = createElement('span', 'github-repo-lang');
|
||||
lang.innerHTML = `<span class="github-lang-dot" style="background:${this._langColour(repo.language)}"></span> ${escapeHtml(repo.language)}`;
|
||||
meta.appendChild(lang);
|
||||
}
|
||||
// Description
|
||||
if (repo.description) {
|
||||
const desc = createElement("div", "github-repo-desc");
|
||||
desc.textContent = repo.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
|
||||
if (repo.stars > 0) {
|
||||
const stars = createElement('span', 'github-repo-stars');
|
||||
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
|
||||
meta.appendChild(stars);
|
||||
}
|
||||
// Meta row: language, stars, pushed
|
||||
const meta = createElement("div", "github-repo-meta");
|
||||
|
||||
if (repo.pushedAt) {
|
||||
const pushed = createElement('span', 'github-repo-pushed');
|
||||
pushed.textContent = relativeTime(repo.pushedAt);
|
||||
pushed.title = new Date(repo.pushedAt).toLocaleString();
|
||||
meta.appendChild(pushed);
|
||||
}
|
||||
if (repo.language) {
|
||||
const lang = createElement("span", "github-repo-lang");
|
||||
lang.innerHTML = `<span class="github-lang-dot" style="background:${this._langColour(repo.language)}"></span> ${escapeHtml(repo.language)}`;
|
||||
meta.appendChild(lang);
|
||||
}
|
||||
|
||||
card.appendChild(meta);
|
||||
if (repo.stars > 0) {
|
||||
const stars = createElement("span", "github-repo-stars");
|
||||
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
|
||||
meta.appendChild(stars);
|
||||
}
|
||||
|
||||
return card;
|
||||
if (repo.pushedAt) {
|
||||
const pushed = createElement("span", "github-repo-pushed");
|
||||
pushed.textContent = relativeTime(repo.pushedAt);
|
||||
pushed.title = new Date(repo.pushedAt).toLocaleString();
|
||||
meta.appendChild(pushed);
|
||||
}
|
||||
|
||||
card.appendChild(meta);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async _downloadRepo(repo) {
|
||||
if (!this._statusEl) return;
|
||||
const ref = repo.defaultBranch || "";
|
||||
this._statusEl.classList.remove("github-viewer-status-error");
|
||||
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
||||
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.DownloadGitHubRepoArchive(
|
||||
this.sessionID,
|
||||
repo.fullName,
|
||||
ref,
|
||||
);
|
||||
this._updateStatus();
|
||||
} catch (err) {
|
||||
this._statusEl.textContent = `⚠ Download failed: ${err}`;
|
||||
this._statusEl.classList.add("github-viewer-status-error");
|
||||
}
|
||||
}
|
||||
|
||||
async _manualRefresh() {
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.textContent = 'Refreshing…';
|
||||
try {
|
||||
await window.go.api.Service.RefreshGitHubRepos(this.sessionID);
|
||||
} catch (err) {
|
||||
this.showError(err.message || 'Refresh failed');
|
||||
}
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.textContent = "Refreshing…";
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.RefreshGitHubRepos(this.sessionID);
|
||||
} catch (err) {
|
||||
this.showError(err.message || "Refresh failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,33 +454,33 @@ class GitHubViewerInstance {
|
||||
* @returns {string} CSS colour value.
|
||||
*/
|
||||
_langColour(lang) {
|
||||
const colours = {
|
||||
'JavaScript': '#f1e05a',
|
||||
'TypeScript': '#3178c6',
|
||||
'Python': '#3572A5',
|
||||
'Go': '#00ADD8',
|
||||
'Rust': '#dea584',
|
||||
'Java': '#b07219',
|
||||
'C': '#555555',
|
||||
'C++': '#f34b7d',
|
||||
'C#': '#178600',
|
||||
'Ruby': '#701516',
|
||||
'PHP': '#4F5D95',
|
||||
'Swift': '#F05138',
|
||||
'Kotlin': '#A97BFF',
|
||||
'Shell': '#89e051',
|
||||
'HTML': '#e34c26',
|
||||
'CSS': '#563d7c',
|
||||
'Dart': '#00B4AB',
|
||||
'Lua': '#000080',
|
||||
'Scala': '#c22d40',
|
||||
'Elixir': '#6e4a7e',
|
||||
'Haskell': '#5e5086',
|
||||
'Zig': '#ec915c',
|
||||
'Vue': '#41b883',
|
||||
'SCSS': '#c6538c',
|
||||
};
|
||||
return colours[lang] || '#8b949e';
|
||||
const colours = {
|
||||
JavaScript: "#f1e05a",
|
||||
TypeScript: "#3178c6",
|
||||
Python: "#3572A5",
|
||||
Go: "#00ADD8",
|
||||
Rust: "#dea584",
|
||||
Java: "#b07219",
|
||||
C: "#555555",
|
||||
"C++": "#f34b7d",
|
||||
"C#": "#178600",
|
||||
Ruby: "#701516",
|
||||
PHP: "#4F5D95",
|
||||
Swift: "#F05138",
|
||||
Kotlin: "#A97BFF",
|
||||
Shell: "#89e051",
|
||||
HTML: "#e34c26",
|
||||
CSS: "#563d7c",
|
||||
Dart: "#00B4AB",
|
||||
Lua: "#000080",
|
||||
Scala: "#c22d40",
|
||||
Elixir: "#6e4a7e",
|
||||
Haskell: "#5e5086",
|
||||
Zig: "#ec915c",
|
||||
Vue: "#41b883",
|
||||
SCSS: "#c6538c",
|
||||
};
|
||||
return colours[lang] || "#8b949e";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,11 +488,10 @@ class GitHubViewerInstance {
|
||||
* @returns {string}
|
||||
*/
|
||||
_githubSVG() {
|
||||
return '<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
|
||||
return '<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.GitHubViewer = new GitHubViewer();
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.GitHubViewer = new GitHubViewer();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal - IRC viewer.
|
||||
* Uses xterm.js as the chat stream, with room subtabs, a composer, and
|
||||
* room/user sidebars.
|
||||
*/
|
||||
|
||||
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */
|
||||
|
||||
const { createElement } = window.NexusTerminal;
|
||||
const SERVER_TARGET = "server";
|
||||
const MAX_LINES_PER_TARGET = 2000;
|
||||
const MAX_LISTED_ROOMS = 200;
|
||||
|
||||
class IRCViewer {
|
||||
constructor() {
|
||||
this._viewers = new Map();
|
||||
this.app = null;
|
||||
}
|
||||
|
||||
setApp(app) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
createViewer(sessionID, container, profile) {
|
||||
if (this._viewers.has(sessionID) || !container) return;
|
||||
const viewer = new IRCViewerInstance(sessionID, container, profile, this.app);
|
||||
this._viewers.set(sessionID, viewer);
|
||||
viewer.render();
|
||||
}
|
||||
|
||||
destroyViewer(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (!viewer) return;
|
||||
viewer.destroy();
|
||||
this._viewers.delete(sessionID);
|
||||
}
|
||||
|
||||
handleEvent(payload) {
|
||||
const sessionID = payload?.sessionID;
|
||||
const event = payload?.event || payload;
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) viewer.handleEvent(event);
|
||||
}
|
||||
|
||||
fit(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) viewer.fit();
|
||||
}
|
||||
}
|
||||
|
||||
class IRCViewerInstance {
|
||||
constructor(sessionID, container, profile, app) {
|
||||
this.sessionID = sessionID;
|
||||
this.container = container;
|
||||
this.profile = profile || {};
|
||||
this.app = app || null;
|
||||
|
||||
this.term = null;
|
||||
this.fitAddon = null;
|
||||
this.searchAddon = null;
|
||||
this.observer = null;
|
||||
|
||||
this.activeTarget = SERVER_TARGET;
|
||||
this.targets = new Map();
|
||||
this.users = new Map();
|
||||
this.joinedRooms = [];
|
||||
this.roomList = [];
|
||||
this.roomFilter = "";
|
||||
this.unreadTargets = new Set();
|
||||
|
||||
this.tabsEl = null;
|
||||
this.termHost = null;
|
||||
this.inputEl = null;
|
||||
this.roomsEl = null;
|
||||
this.usersEl = null;
|
||||
this.roomListEl = null;
|
||||
this.roomFilterEl = null;
|
||||
|
||||
this._ensureTarget(SERVER_TARGET, "Server");
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.classList.add("irc-viewer-panel");
|
||||
|
||||
const wrapper = createElement("div", "irc-viewer");
|
||||
const main = createElement("div", "irc-main");
|
||||
|
||||
this.tabsEl = createElement("div", "irc-room-tabs");
|
||||
main.appendChild(this.tabsEl);
|
||||
|
||||
this.termHost = createElement("div", "irc-terminal");
|
||||
main.appendChild(this.termHost);
|
||||
|
||||
const composer = createElement("form", "irc-composer");
|
||||
this.inputEl = createElement("input", "irc-input", {
|
||||
type: "text",
|
||||
placeholder: "Message or /command",
|
||||
autocomplete: "off",
|
||||
});
|
||||
const sendBtn = createElement("button", "btn btn-sm btn-primary irc-send", {
|
||||
type: "submit",
|
||||
textContent: "Send",
|
||||
});
|
||||
composer.appendChild(this.inputEl);
|
||||
composer.appendChild(sendBtn);
|
||||
composer.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this._sendComposer();
|
||||
});
|
||||
this.inputEl.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Tab" && this._completeMention()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
main.appendChild(composer);
|
||||
|
||||
const sidebar = this._buildSidebar();
|
||||
wrapper.appendChild(main);
|
||||
wrapper.appendChild(sidebar);
|
||||
this.container.appendChild(wrapper);
|
||||
|
||||
this._openTerminal();
|
||||
this._renderTabs();
|
||||
this._renderRooms();
|
||||
this._renderUsers();
|
||||
this._renderRoomList();
|
||||
this._appendLine(SERVER_TARGET, {
|
||||
type: "system",
|
||||
text: "Connecting...",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
if (!event || !event.type) return;
|
||||
|
||||
if (event.type === "rooms") {
|
||||
this.joinedRooms = Array.isArray(event.rooms) ? event.rooms : [];
|
||||
for (const room of this.joinedRooms) {
|
||||
this._ensureTarget(room.name, room.name);
|
||||
}
|
||||
this._renderTabs();
|
||||
this._renderRooms();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "users") {
|
||||
const target = event.target;
|
||||
if (target) {
|
||||
this.users.set(target, Array.isArray(event.users) ? event.users : []);
|
||||
if (target === this.activeTarget) this._renderUsers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "room-list") {
|
||||
this.roomList = Array.isArray(event.rooms) ? event.rooms : [];
|
||||
this._renderRoomList();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this._eventTarget(event);
|
||||
this._ensureTarget(target, this._targetLabel(target));
|
||||
this._appendLine(target, event);
|
||||
|
||||
if (target !== this.activeTarget) {
|
||||
this.unreadTargets.add(target);
|
||||
this._renderTabs();
|
||||
}
|
||||
|
||||
if (["join", "part", "topic"].includes(event.type)) {
|
||||
this._renderRooms();
|
||||
}
|
||||
}
|
||||
|
||||
fit() {
|
||||
if (!this.fitAddon) return;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
if (this.term) {
|
||||
this.term.dispose();
|
||||
this.term = null;
|
||||
}
|
||||
this.container.classList.remove("irc-viewer-panel");
|
||||
this.container.innerHTML = "";
|
||||
}
|
||||
|
||||
_buildSidebar() {
|
||||
const sidebar = createElement("aside", "irc-sidebar");
|
||||
|
||||
const joinSection = createElement("div", "irc-sidebar-section");
|
||||
joinSection.appendChild(
|
||||
createElement("div", "irc-sidebar-title", { textContent: "Rooms" }),
|
||||
);
|
||||
const joinRow = createElement("form", "irc-join-row");
|
||||
const joinInput = createElement("input", "irc-join-input", {
|
||||
type: "text",
|
||||
placeholder: "#room",
|
||||
autocomplete: "off",
|
||||
});
|
||||
const joinBtn = createElement("button", "btn btn-xs btn-secondary", {
|
||||
type: "submit",
|
||||
textContent: "Join",
|
||||
});
|
||||
joinRow.appendChild(joinInput);
|
||||
joinRow.appendChild(joinBtn);
|
||||
joinRow.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const room = joinInput.value.trim();
|
||||
if (!room) return;
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
|
||||
joinInput.value = "";
|
||||
} catch (err) {
|
||||
this._notifyError("Join failed: " + err);
|
||||
}
|
||||
});
|
||||
joinSection.appendChild(joinRow);
|
||||
this.roomsEl = createElement("div", "irc-sidebar-list");
|
||||
joinSection.appendChild(this.roomsEl);
|
||||
sidebar.appendChild(joinSection);
|
||||
|
||||
const listSection = createElement("div", "irc-sidebar-section");
|
||||
const listTitleRow = createElement("div", "irc-sidebar-title-row");
|
||||
listTitleRow.appendChild(
|
||||
createElement("div", "irc-sidebar-title", { textContent: "Room List" }),
|
||||
);
|
||||
const listBtn = createElement("button", "btn btn-xs btn-outline", {
|
||||
type: "button",
|
||||
textContent: "List",
|
||||
});
|
||||
listBtn.addEventListener("click", async () => {
|
||||
this.roomList = [];
|
||||
this._renderRoomList("Loading rooms...");
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.IRCListRooms(this.sessionID);
|
||||
} catch (err) {
|
||||
this._renderRoomList("Room list failed");
|
||||
this._notifyError("Room list failed: " + err);
|
||||
}
|
||||
});
|
||||
listTitleRow.appendChild(listBtn);
|
||||
listSection.appendChild(listTitleRow);
|
||||
this.roomFilterEl = createElement("input", "irc-filter-input", {
|
||||
type: "text",
|
||||
placeholder: "Filter rooms",
|
||||
autocomplete: "off",
|
||||
});
|
||||
this.roomFilterEl.addEventListener("input", () => {
|
||||
this.roomFilter = this.roomFilterEl.value.toLowerCase();
|
||||
this._renderRoomList();
|
||||
});
|
||||
listSection.appendChild(this.roomFilterEl);
|
||||
this.roomListEl = createElement("div", "irc-room-list");
|
||||
listSection.appendChild(this.roomListEl);
|
||||
sidebar.appendChild(listSection);
|
||||
|
||||
const usersSection = createElement("div", "irc-sidebar-section irc-users-section");
|
||||
usersSection.appendChild(
|
||||
createElement("div", "irc-sidebar-title", { textContent: "Users" }),
|
||||
);
|
||||
this.usersEl = createElement("div", "irc-user-list");
|
||||
usersSection.appendChild(this.usersEl);
|
||||
sidebar.appendChild(usersSection);
|
||||
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
_openTerminal() {
|
||||
const themeName =
|
||||
(this.app?.state?.preferences || {}).terminalTheme || "charcoal";
|
||||
this.term = new Terminal({
|
||||
theme: window.NexusTerminal.getTerminalTheme(themeName),
|
||||
fontFamily:
|
||||
this.profile.terminalFontFamily ||
|
||||
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
||||
fontSize: this.profile.terminalFontSize || 14,
|
||||
scrollback: this.profile.terminalScrollback || 10000,
|
||||
cursorBlink: false,
|
||||
convertEol: true,
|
||||
disableStdin: true,
|
||||
});
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.searchAddon = new SearchAddon.SearchAddon();
|
||||
this.term.loadAddon(this.fitAddon);
|
||||
this.term.loadAddon(this.searchAddon);
|
||||
try {
|
||||
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_event, uri) => {
|
||||
window.runtime.BrowserOpenURL(uri);
|
||||
}));
|
||||
} catch (_) {
|
||||
/* optional */
|
||||
}
|
||||
this.term.open(this.termHost);
|
||||
this.fit();
|
||||
this.observer = new ResizeObserver(() => this.fit());
|
||||
this.observer.observe(this.termHost);
|
||||
}
|
||||
|
||||
_renderTabs() {
|
||||
if (!this.tabsEl) return;
|
||||
this.tabsEl.innerHTML = "";
|
||||
for (const [target, state] of this.targets) {
|
||||
const btn = createElement(
|
||||
"button",
|
||||
`irc-room-tab ${target === this.activeTarget ? "active" : ""} ${
|
||||
this.unreadTargets.has(target) ? "has-unread" : ""
|
||||
}`,
|
||||
{
|
||||
type: "button",
|
||||
textContent: state.label,
|
||||
title: state.label,
|
||||
},
|
||||
);
|
||||
btn.addEventListener("click", () => this._setActiveTarget(target));
|
||||
this.tabsEl.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
_renderRooms() {
|
||||
if (!this.roomsEl) return;
|
||||
this.roomsEl.innerHTML = "";
|
||||
if (this.joinedRooms.length === 0) {
|
||||
this.roomsEl.appendChild(
|
||||
createElement("div", "irc-empty", { textContent: "No joined rooms" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const room of this.joinedRooms) {
|
||||
const row = createElement("button", "irc-room-row", {
|
||||
type: "button",
|
||||
title: room.topic || room.name,
|
||||
});
|
||||
row.classList.toggle("active", room.name === this.activeTarget);
|
||||
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
|
||||
row.appendChild(
|
||||
createElement("span", "irc-room-count", {
|
||||
textContent: String(room.users || 0),
|
||||
}),
|
||||
);
|
||||
row.addEventListener("click", () => this._setActiveTarget(room.name));
|
||||
this.roomsEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
_renderUsers() {
|
||||
if (!this.usersEl) return;
|
||||
this.usersEl.innerHTML = "";
|
||||
const users = this.users.get(this.activeTarget) || [];
|
||||
if (!this._isRoom(this.activeTarget)) {
|
||||
this.usersEl.appendChild(
|
||||
createElement("div", "irc-empty", { textContent: "No room selected" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (users.length === 0) {
|
||||
this.usersEl.appendChild(
|
||||
createElement("div", "irc-empty", { textContent: "No users yet" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const user of users) {
|
||||
const row = createElement("button", "irc-user-row", {
|
||||
type: "button",
|
||||
textContent: this._displayNick(user),
|
||||
title: user,
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
this._insertMention(this._displayNick(user));
|
||||
});
|
||||
this.usersEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
_renderRoomList(message = "") {
|
||||
if (!this.roomListEl) return;
|
||||
this.roomListEl.innerHTML = "";
|
||||
if (message) {
|
||||
this.roomListEl.appendChild(
|
||||
createElement("div", "irc-empty", { textContent: message }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let rooms = this.roomList;
|
||||
if (this.roomFilter) {
|
||||
rooms = rooms.filter((room) => {
|
||||
const haystack = `${room.name || ""} ${room.topic || ""}`.toLowerCase();
|
||||
return haystack.includes(this.roomFilter);
|
||||
});
|
||||
}
|
||||
rooms = rooms.slice(0, MAX_LISTED_ROOMS);
|
||||
if (rooms.length === 0) {
|
||||
this.roomListEl.appendChild(
|
||||
createElement("div", "irc-empty", { textContent: "No rooms loaded" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const room of rooms) {
|
||||
const row = createElement("div", "irc-listed-room");
|
||||
const meta = createElement("button", "irc-listed-room-main", {
|
||||
type: "button",
|
||||
title: room.topic || room.name,
|
||||
});
|
||||
meta.appendChild(
|
||||
createElement("span", "irc-listed-room-name", {
|
||||
textContent: room.name || "",
|
||||
}),
|
||||
);
|
||||
meta.appendChild(
|
||||
createElement("span", "irc-listed-room-topic", {
|
||||
textContent: room.topic || "",
|
||||
}),
|
||||
);
|
||||
meta.addEventListener("click", () => this._joinRoom(room.name));
|
||||
const count = createElement("span", "irc-listed-room-count", {
|
||||
textContent: String(room.users || 0),
|
||||
});
|
||||
row.appendChild(meta);
|
||||
row.appendChild(count);
|
||||
this.roomListEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
_setActiveTarget(target) {
|
||||
this._ensureTarget(target, this._targetLabel(target));
|
||||
this.activeTarget = target;
|
||||
this.unreadTargets.delete(target);
|
||||
this._renderTabs();
|
||||
this._renderRooms();
|
||||
this._renderUsers();
|
||||
this._replayActiveTarget();
|
||||
if (this.inputEl) this.inputEl.focus();
|
||||
}
|
||||
|
||||
_appendLine(target, event) {
|
||||
const state = this._ensureTarget(target, this._targetLabel(target));
|
||||
const line = this._formatEvent(event);
|
||||
if (!line) return;
|
||||
state.lines.push(line);
|
||||
if (state.lines.length > MAX_LINES_PER_TARGET) {
|
||||
state.lines.splice(0, state.lines.length - MAX_LINES_PER_TARGET);
|
||||
}
|
||||
if (target === this.activeTarget && this.term) {
|
||||
this.term.writeln(line);
|
||||
this.term.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
_replayActiveTarget() {
|
||||
if (!this.term) return;
|
||||
this.term.clear();
|
||||
const state = this.targets.get(this.activeTarget);
|
||||
for (const line of state?.lines || []) {
|
||||
this.term.writeln(line);
|
||||
}
|
||||
this.term.scrollToBottom();
|
||||
this.fit();
|
||||
}
|
||||
|
||||
async _sendComposer() {
|
||||
const text = this.inputEl.value;
|
||||
if (!text.trim()) return;
|
||||
this.inputEl.value = "";
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.IRCHandleInput(
|
||||
this.sessionID,
|
||||
this.activeTarget,
|
||||
text,
|
||||
);
|
||||
} catch (err) {
|
||||
this._notifyError(String(err));
|
||||
this._appendLine(this.activeTarget, {
|
||||
type: "error",
|
||||
text: String(err),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async _joinRoom(room) {
|
||||
if (!room) return;
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.IRCJoin(this.sessionID, room);
|
||||
} catch (err) {
|
||||
this._notifyError("Join failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
_completeMention() {
|
||||
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
|
||||
const start = this.inputEl.selectionStart || 0;
|
||||
const before = this.inputEl.value.slice(0, start);
|
||||
const match = before.match(/(^|\s)@([^\s@]*)$/);
|
||||
if (!match) return false;
|
||||
const prefix = match[2].toLowerCase();
|
||||
const users = this.users.get(this.activeTarget) || [];
|
||||
const nick = users
|
||||
.map((user) => this._displayNick(user))
|
||||
.find((user) => user.toLowerCase().startsWith(prefix));
|
||||
if (!nick) return false;
|
||||
const replacement = `${match[1]}@${nick} `;
|
||||
const nextValue =
|
||||
this.inputEl.value.slice(0, start - match[0].length) +
|
||||
replacement +
|
||||
this.inputEl.value.slice(start);
|
||||
const caret = start - match[0].length + replacement.length;
|
||||
this.inputEl.value = nextValue;
|
||||
this.inputEl.setSelectionRange(caret, caret);
|
||||
return true;
|
||||
}
|
||||
|
||||
_insertMention(nick) {
|
||||
if (!this.inputEl) return;
|
||||
const current = this.inputEl.value;
|
||||
const spacer = current && !/\s$/.test(current) ? " " : "";
|
||||
this.inputEl.value = `${current}${spacer}@${nick} `;
|
||||
this.inputEl.focus();
|
||||
}
|
||||
|
||||
_ensureTarget(target, label) {
|
||||
const safeTarget = target || SERVER_TARGET;
|
||||
if (!this.targets.has(safeTarget)) {
|
||||
this.targets.set(safeTarget, {
|
||||
label: label || this._targetLabel(safeTarget),
|
||||
lines: [],
|
||||
});
|
||||
}
|
||||
return this.targets.get(safeTarget);
|
||||
}
|
||||
|
||||
_eventTarget(event) {
|
||||
if (!event) return SERVER_TARGET;
|
||||
if (event.target) return event.target;
|
||||
return SERVER_TARGET;
|
||||
}
|
||||
|
||||
_targetLabel(target) {
|
||||
return target === SERVER_TARGET ? "Server" : target;
|
||||
}
|
||||
|
||||
_formatEvent(event) {
|
||||
const time = this._time(event.timestamp);
|
||||
const from = this._sanitize(event.from || "");
|
||||
const text = this._sanitize(event.text || "");
|
||||
switch (event.type) {
|
||||
case "message":
|
||||
return `[${time}] <${from}> ${text}`;
|
||||
case "action":
|
||||
return `[${time}] * ${from} ${text}`;
|
||||
case "notice":
|
||||
return `[${time}] -${from || "notice"}- ${text}`;
|
||||
case "join":
|
||||
return `[${time}] --> ${from || "user"} joined`;
|
||||
case "part":
|
||||
return `[${time}] <-- ${from || "user"} left${text ? ` (${text})` : ""}`;
|
||||
case "quit":
|
||||
return `[${time}] <-- ${from || "user"} quit${text ? ` (${text})` : ""}`;
|
||||
case "nick":
|
||||
return `[${time}] ${from || "user"} is now known as ${text}`;
|
||||
case "topic":
|
||||
return `[${time}] topic: ${text}`;
|
||||
case "error":
|
||||
return `[${time}] error: ${text}`;
|
||||
case "system":
|
||||
default:
|
||||
return `[${time}] ${text}`;
|
||||
}
|
||||
}
|
||||
|
||||
_time(value) {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const mins = String(date.getMinutes()).padStart(2, "0");
|
||||
const secs = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${hours}:${mins}:${secs}`;
|
||||
}
|
||||
|
||||
_sanitize(value) {
|
||||
return String(value || "")
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
||||
.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "")
|
||||
.replace(/[\x02\x0f\x16\x1d\x1f]/g, "");
|
||||
}
|
||||
|
||||
_displayNick(user) {
|
||||
return String(user || "").replace(/^[~&@%+]+/, "");
|
||||
}
|
||||
|
||||
_isRoom(target) {
|
||||
return /^[#&+!]/.test(target || "");
|
||||
}
|
||||
|
||||
_notifyError(message) {
|
||||
if (this.app?.notifications) {
|
||||
this.app.notifications.error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.IRCViewer = new IRCViewer();
|
||||
})();
|
||||
@@ -1,2 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
@@ -1,2 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})()));
|
||||
//# sourceMappingURL=xterm-addon-web-links.js.map
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
@@ -1,30 +1,31 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — RAW TCP data viewer.
|
||||
* Provides text, hex, and split display modes for raw socket data,
|
||||
* along with a send bar, pause/resume, and capture saving.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — RAW TCP data viewer.
|
||||
* Provides text, hex, and split display modes for raw socket data,
|
||||
* along with a send bar, pause/resume, and capture saving.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, formatTimestamp, formatBytes } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, formatTimestamp, formatBytes } =
|
||||
window.NexusTerminal;
|
||||
|
||||
/**
|
||||
* Maximum number of data entries retained in the scrollback buffer.
|
||||
* @type {number}
|
||||
*/
|
||||
const MAX_BUFFER_ENTRIES = 5000;
|
||||
/**
|
||||
* Maximum number of data entries retained in the scrollback buffer.
|
||||
* @type {number}
|
||||
*/
|
||||
const MAX_BUFFER_ENTRIES = 5000;
|
||||
|
||||
class RawViewer {
|
||||
class RawViewer {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.app = app;
|
||||
|
||||
/**
|
||||
* Map of sessionId → viewer state.
|
||||
* @type {Map<string, Object>}
|
||||
*/
|
||||
this.viewers = new Map();
|
||||
/**
|
||||
* Map of sessionId → viewer state.
|
||||
* @type {Map<string, Object>}
|
||||
*/
|
||||
this.viewers = new Map();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -36,71 +37,85 @@ class RawViewer {
|
||||
* @param {Object} profile
|
||||
*/
|
||||
createViewer(sessionId, containerId, profile) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const state = {
|
||||
mode: 'text', // text | hex | split
|
||||
paused: false,
|
||||
buffer: [],
|
||||
totalBytes: 0,
|
||||
sendFormat: 'text', // text | hex | escaped
|
||||
};
|
||||
const state = {
|
||||
mode: "text", // text | hex | split
|
||||
paused: false,
|
||||
buffer: [],
|
||||
totalBytes: 0,
|
||||
sendFormat: "text", // text | hex | escaped
|
||||
};
|
||||
|
||||
// ── Toolbar ──────────────────────────────────────────────
|
||||
const toolbar = createElement('div', 'raw-toolbar');
|
||||
// ── Toolbar ──────────────────────────────────────────────
|
||||
const toolbar = createElement("div", "raw-toolbar");
|
||||
|
||||
// Mode toggle
|
||||
const modeGroup = createElement('div', 'raw-mode-group');
|
||||
for (const mode of ['text', 'hex', 'split']) {
|
||||
const btn = createElement('button', `raw-mode-btn ${mode === state.mode ? 'active' : ''}`, {
|
||||
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
|
||||
dataset: { mode },
|
||||
});
|
||||
btn.addEventListener('click', () => this._setMode(sessionId, mode));
|
||||
modeGroup.appendChild(btn);
|
||||
}
|
||||
toolbar.appendChild(modeGroup);
|
||||
// Mode toggle
|
||||
const modeGroup = createElement("div", "raw-mode-group");
|
||||
for (const mode of ["text", "hex", "split"]) {
|
||||
const btn = createElement(
|
||||
"button",
|
||||
`raw-mode-btn ${mode === state.mode ? "active" : ""}`,
|
||||
{
|
||||
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
|
||||
dataset: { mode },
|
||||
},
|
||||
);
|
||||
btn.addEventListener("click", () => this._setMode(sessionId, mode));
|
||||
modeGroup.appendChild(btn);
|
||||
}
|
||||
toolbar.appendChild(modeGroup);
|
||||
|
||||
// Controls
|
||||
const controls = createElement('div', 'raw-controls');
|
||||
// Controls
|
||||
const controls = createElement("div", "raw-controls");
|
||||
|
||||
const pauseBtn = createElement('button', 'btn btn-sm btn-outline raw-pause', { textContent: '⏸ Pause' });
|
||||
pauseBtn.addEventListener('click', () => this.togglePause(sessionId));
|
||||
controls.appendChild(pauseBtn);
|
||||
const pauseBtn = createElement(
|
||||
"button",
|
||||
"btn btn-sm btn-outline raw-pause",
|
||||
{ textContent: "⏸ Pause" },
|
||||
);
|
||||
pauseBtn.addEventListener("click", () => this.togglePause(sessionId));
|
||||
controls.appendChild(pauseBtn);
|
||||
|
||||
const clearBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '✕ Clear' });
|
||||
clearBtn.addEventListener('click', () => this.clearBuffer(sessionId));
|
||||
controls.appendChild(clearBtn);
|
||||
const clearBtn = createElement("button", "btn btn-sm btn-outline", {
|
||||
textContent: "✕ Clear",
|
||||
});
|
||||
clearBtn.addEventListener("click", () => this.clearBuffer(sessionId));
|
||||
controls.appendChild(clearBtn);
|
||||
|
||||
const saveBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '💾 Save' });
|
||||
saveBtn.addEventListener('click', () => this.saveCapture(sessionId));
|
||||
controls.appendChild(saveBtn);
|
||||
const saveBtn = createElement("button", "btn btn-sm btn-outline", {
|
||||
textContent: "💾 Save",
|
||||
});
|
||||
saveBtn.addEventListener("click", () => this.saveCapture(sessionId));
|
||||
controls.appendChild(saveBtn);
|
||||
|
||||
// Byte counter
|
||||
const counter = createElement('span', 'raw-counter', { textContent: '0 B' });
|
||||
controls.appendChild(counter);
|
||||
// Byte counter
|
||||
const counter = createElement("span", "raw-counter", {
|
||||
textContent: "0 B",
|
||||
});
|
||||
controls.appendChild(counter);
|
||||
|
||||
toolbar.appendChild(controls);
|
||||
container.appendChild(toolbar);
|
||||
toolbar.appendChild(controls);
|
||||
container.appendChild(toolbar);
|
||||
|
||||
// ── Data display ─────────────────────────────────────────
|
||||
const display = createElement('div', 'raw-display');
|
||||
container.appendChild(display);
|
||||
// ── Data display ─────────────────────────────────────────
|
||||
const display = createElement("div", "raw-display");
|
||||
container.appendChild(display);
|
||||
|
||||
// ── Send bar ─────────────────────────────────────────────
|
||||
const sendBar = this._renderSendBar(sessionId, profile);
|
||||
container.appendChild(sendBar);
|
||||
// ── Send bar ─────────────────────────────────────────────
|
||||
const sendBar = this._renderSendBar(sessionId, profile);
|
||||
container.appendChild(sendBar);
|
||||
|
||||
this.viewers.set(sessionId, {
|
||||
...state,
|
||||
container,
|
||||
display,
|
||||
toolbar,
|
||||
pauseBtn,
|
||||
counter,
|
||||
profile,
|
||||
});
|
||||
this.viewers.set(sessionId, {
|
||||
...state,
|
||||
container,
|
||||
display,
|
||||
toolbar,
|
||||
pauseBtn,
|
||||
counter,
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,45 +123,45 @@ class RawViewer {
|
||||
* @param {Object} payload - { sessionID, data, direction, timestamp }
|
||||
*/
|
||||
handleData(payload) {
|
||||
const viewer = this.viewers.get(payload.sessionID);
|
||||
if (!viewer) return;
|
||||
const viewer = this.viewers.get(payload.sessionID);
|
||||
if (!viewer) return;
|
||||
|
||||
// Decode base64 payload
|
||||
let rawStr;
|
||||
try {
|
||||
rawStr = atob(payload.data);
|
||||
} catch (_) {
|
||||
rawStr = payload.data || '';
|
||||
}
|
||||
// Decode base64 payload
|
||||
let rawStr;
|
||||
try {
|
||||
rawStr = atob(payload.data);
|
||||
} catch (_) {
|
||||
rawStr = payload.data || "";
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(rawStr.length);
|
||||
for (let i = 0; i < rawStr.length; i++) {
|
||||
bytes[i] = rawStr.charCodeAt(i);
|
||||
}
|
||||
const bytes = new Uint8Array(rawStr.length);
|
||||
for (let i = 0; i < rawStr.length; i++) {
|
||||
bytes[i] = rawStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
const entry = {
|
||||
direction: payload.direction || 'recv', // 'send' | 'recv'
|
||||
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
|
||||
data: rawStr,
|
||||
bytes,
|
||||
};
|
||||
const entry = {
|
||||
direction: payload.direction || "recv", // 'send' | 'recv'
|
||||
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
|
||||
data: rawStr,
|
||||
bytes,
|
||||
};
|
||||
|
||||
viewer.buffer.push(entry);
|
||||
viewer.totalBytes += bytes.length;
|
||||
viewer.buffer.push(entry);
|
||||
viewer.totalBytes += bytes.length;
|
||||
|
||||
// Trim buffer if it exceeds the maximum
|
||||
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
|
||||
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
|
||||
}
|
||||
// Trim buffer if it exceeds the maximum
|
||||
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
|
||||
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
|
||||
}
|
||||
|
||||
// Update counter
|
||||
viewer.counter.textContent = formatBytes(viewer.totalBytes);
|
||||
// Update counter
|
||||
viewer.counter.textContent = formatBytes(viewer.totalBytes);
|
||||
|
||||
// Render unless paused
|
||||
if (!viewer.paused) {
|
||||
this._renderEntry(viewer, entry);
|
||||
this._scrollToBottom(viewer);
|
||||
}
|
||||
// Render unless paused
|
||||
if (!viewer.paused) {
|
||||
this._renderEntry(viewer, entry);
|
||||
this._scrollToBottom(viewer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,17 +169,17 @@ class RawViewer {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
togglePause(sessionId) {
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
|
||||
viewer.paused = !viewer.paused;
|
||||
viewer.pauseBtn.textContent = viewer.paused ? '▶ Resume' : '⏸ Pause';
|
||||
viewer.pauseBtn.classList.toggle('active', viewer.paused);
|
||||
viewer.paused = !viewer.paused;
|
||||
viewer.pauseBtn.textContent = viewer.paused ? "▶ Resume" : "⏸ Pause";
|
||||
viewer.pauseBtn.classList.toggle("active", viewer.paused);
|
||||
|
||||
// On resume, flush any buffered entries
|
||||
if (!viewer.paused) {
|
||||
this._renderAllEntries(viewer);
|
||||
}
|
||||
// On resume, flush any buffered entries
|
||||
if (!viewer.paused) {
|
||||
this._renderAllEntries(viewer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,13 +187,13 @@ class RawViewer {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
clearBuffer(sessionId) {
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
|
||||
viewer.buffer = [];
|
||||
viewer.totalBytes = 0;
|
||||
viewer.display.innerHTML = '';
|
||||
viewer.counter.textContent = '0 B';
|
||||
viewer.buffer = [];
|
||||
viewer.totalBytes = 0;
|
||||
viewer.display.innerHTML = "";
|
||||
viewer.counter.textContent = "0 B";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,24 +201,24 @@ class RawViewer {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async saveCapture(sessionId) {
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer || viewer.buffer.length === 0) {
|
||||
this.app.notifications.warning('Nothing to save');
|
||||
return;
|
||||
}
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer || viewer.buffer.length === 0) {
|
||||
this.app.notifications.warning("Nothing to save");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = viewer.buffer.map((e) => {
|
||||
const ts = formatTimestamp(e.timestamp);
|
||||
const dir = e.direction === 'send' ? 'TX' : 'RX';
|
||||
return `[${ts}] [${dir}] ${e.data}`;
|
||||
});
|
||||
const content = lines.join('\n');
|
||||
await window.go.api.Service.SaveSessionCapture(sessionId, content);
|
||||
this.app.notifications.success('Capture saved');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Save failed: ' + err);
|
||||
}
|
||||
try {
|
||||
const lines = viewer.buffer.map((e) => {
|
||||
const ts = formatTimestamp(e.timestamp);
|
||||
const dir = e.direction === "send" ? "TX" : "RX";
|
||||
return `[${ts}] [${dir}] ${e.data}`;
|
||||
});
|
||||
const content = lines.join("\n");
|
||||
await window.NexusTerminal.wailsService.SaveSessionCapture(sessionId, content);
|
||||
this.app.notifications.success("Capture saved");
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Save failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,45 +226,50 @@ class RawViewer {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async send(sessionId) {
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
|
||||
const input = viewer.container.querySelector('.raw-send-input');
|
||||
const formatSelect = viewer.container.querySelector('.raw-send-format');
|
||||
if (!input) return;
|
||||
const input = viewer.container.querySelector(".raw-send-input");
|
||||
const formatSelect = viewer.container.querySelector(".raw-send-format");
|
||||
if (!input) return;
|
||||
|
||||
const rawValue = input.value;
|
||||
if (!rawValue) return;
|
||||
const rawValue = input.value;
|
||||
if (!rawValue) return;
|
||||
|
||||
const format = formatSelect?.value || 'text';
|
||||
let dataToSend;
|
||||
const format = formatSelect?.value || "text";
|
||||
let dataToSend;
|
||||
|
||||
switch (format) {
|
||||
case 'hex':
|
||||
dataToSend = this._hexToString(rawValue);
|
||||
break;
|
||||
case 'escaped':
|
||||
dataToSend = this._unescapeString(rawValue);
|
||||
break;
|
||||
default:
|
||||
dataToSend = rawValue;
|
||||
}
|
||||
switch (format) {
|
||||
case "hex":
|
||||
dataToSend = this._hexToString(rawValue);
|
||||
break;
|
||||
case "escaped":
|
||||
dataToSend = this._unescapeString(rawValue);
|
||||
break;
|
||||
default:
|
||||
dataToSend = rawValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const b64 = btoa(dataToSend);
|
||||
await window.go.api.Service.WriteSession(sessionId, b64);
|
||||
input.value = '';
|
||||
try {
|
||||
// For hex/escaped, data is already raw bytes (0-255) so btoa is correct.
|
||||
// For text mode, UTF-8 encode so non-ASCII characters are transmitted properly.
|
||||
const b64 =
|
||||
format === "hex" || format === "escaped"
|
||||
? btoa(dataToSend)
|
||||
: window.NexusTerminal.utf8ToBase64(dataToSend);
|
||||
await window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||
input.value = "";
|
||||
|
||||
// Also show in viewer as sent data
|
||||
this.handleData({
|
||||
sessionID: sessionId,
|
||||
data: b64,
|
||||
direction: 'send',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Send failed: ' + err);
|
||||
}
|
||||
// Also show in viewer as sent data
|
||||
this.handleData({
|
||||
sessionID: sessionId,
|
||||
data: b64,
|
||||
direction: "send",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Send failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,7 +277,7 @@ class RawViewer {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
destroyViewer(sessionId) {
|
||||
this.viewers.delete(sessionId);
|
||||
this.viewers.delete(sessionId);
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────
|
||||
@@ -270,76 +290,97 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderSendBar(sessionId, profile) {
|
||||
const bar = createElement('div', 'raw-send-bar');
|
||||
const bar = createElement("div", "raw-send-bar");
|
||||
|
||||
// Format selector
|
||||
const formatSelect = createElement('select', 'form-select form-select-sm raw-send-format');
|
||||
for (const [value, label] of [['text', 'Text'], ['hex', 'Hex'], ['escaped', 'Escaped']]) {
|
||||
const opt = createElement('option', '', { value, textContent: label });
|
||||
formatSelect.appendChild(opt);
|
||||
}
|
||||
bar.appendChild(formatSelect);
|
||||
// Format selector
|
||||
const formatSelect = createElement(
|
||||
"select",
|
||||
"form-select form-select-sm raw-send-format",
|
||||
);
|
||||
for (const [value, label] of [
|
||||
["text", "Text"],
|
||||
["hex", "Hex"],
|
||||
["escaped", "Escaped"],
|
||||
]) {
|
||||
const opt = createElement("option", "", { value, textContent: label });
|
||||
formatSelect.appendChild(opt);
|
||||
}
|
||||
bar.appendChild(formatSelect);
|
||||
|
||||
// Input
|
||||
const input = createElement('input', 'form-input raw-send-input', {
|
||||
type: 'text',
|
||||
placeholder: 'Type data to send…',
|
||||
// Input
|
||||
const input = createElement("input", "form-input raw-send-input", {
|
||||
type: "text",
|
||||
placeholder: "Type data to send…",
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") this.send(sessionId);
|
||||
});
|
||||
bar.appendChild(input);
|
||||
|
||||
// Quick-insert buttons for common control bytes
|
||||
const quickBtns = createElement("div", "raw-quick-btns");
|
||||
const controlBytes = [
|
||||
{ label: "NUL", value: "\\x00" },
|
||||
{ label: "CR", value: "\\r" },
|
||||
{ label: "LF", value: "\\n" },
|
||||
{ label: "ETX", value: "\\x03" },
|
||||
{ label: "EOT", value: "\\x04" },
|
||||
];
|
||||
for (const cb of controlBytes) {
|
||||
const btn = createElement("button", "btn btn-xs btn-outline", {
|
||||
textContent: cb.label,
|
||||
title: cb.value,
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this.send(sessionId);
|
||||
btn.addEventListener("click", () => {
|
||||
input.value += cb.value;
|
||||
input.focus();
|
||||
});
|
||||
bar.appendChild(input);
|
||||
quickBtns.appendChild(btn);
|
||||
}
|
||||
bar.appendChild(quickBtns);
|
||||
|
||||
// Quick-insert buttons for common control bytes
|
||||
const quickBtns = createElement('div', 'raw-quick-btns');
|
||||
const controlBytes = [
|
||||
{ label: 'NUL', value: '\\x00' },
|
||||
{ label: 'CR', value: '\\r' },
|
||||
{ label: 'LF', value: '\\n' },
|
||||
{ label: 'ETX', value: '\\x03' },
|
||||
{ label: 'EOT', value: '\\x04' },
|
||||
];
|
||||
for (const cb of controlBytes) {
|
||||
const btn = createElement('button', 'btn btn-xs btn-outline', { textContent: cb.label, title: cb.value });
|
||||
btn.addEventListener('click', () => {
|
||||
input.value += cb.value;
|
||||
input.focus();
|
||||
});
|
||||
quickBtns.appendChild(btn);
|
||||
// Payload preset dropdown
|
||||
const presets = profile?.raw?.payloadPresets || [];
|
||||
if (presets.length > 0) {
|
||||
const presetSelect = createElement(
|
||||
"select",
|
||||
"form-select form-select-sm raw-preset-select",
|
||||
);
|
||||
const defaultOpt = createElement("option", "", {
|
||||
value: "",
|
||||
textContent: "Presets…",
|
||||
});
|
||||
presetSelect.appendChild(defaultOpt);
|
||||
for (const ps of presets) {
|
||||
const opt = createElement("option", "", {
|
||||
value: ps.payload,
|
||||
textContent: ps.name,
|
||||
});
|
||||
opt.dataset.format = ps.format;
|
||||
presetSelect.appendChild(opt);
|
||||
}
|
||||
bar.appendChild(quickBtns);
|
||||
|
||||
// Payload preset dropdown
|
||||
const presets = profile?.raw?.payloadPresets || [];
|
||||
if (presets.length > 0) {
|
||||
const presetSelect = createElement('select', 'form-select form-select-sm raw-preset-select');
|
||||
const defaultOpt = createElement('option', '', { value: '', textContent: 'Presets…' });
|
||||
presetSelect.appendChild(defaultOpt);
|
||||
for (const ps of presets) {
|
||||
const opt = createElement('option', '', { value: ps.payload, textContent: ps.name });
|
||||
opt.dataset.format = ps.format;
|
||||
presetSelect.appendChild(opt);
|
||||
presetSelect.addEventListener("change", () => {
|
||||
if (presetSelect.value) {
|
||||
input.value = presetSelect.value;
|
||||
const selOpt = presetSelect.selectedOptions[0];
|
||||
if (selOpt?.dataset.format) {
|
||||
formatSelect.value = selOpt.dataset.format;
|
||||
}
|
||||
presetSelect.addEventListener('change', () => {
|
||||
if (presetSelect.value) {
|
||||
input.value = presetSelect.value;
|
||||
const selOpt = presetSelect.selectedOptions[0];
|
||||
if (selOpt?.dataset.format) {
|
||||
formatSelect.value = selOpt.dataset.format;
|
||||
}
|
||||
presetSelect.value = '';
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
bar.appendChild(presetSelect);
|
||||
}
|
||||
presetSelect.value = "";
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
bar.appendChild(presetSelect);
|
||||
}
|
||||
|
||||
// Send button
|
||||
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send' });
|
||||
sendBtn.addEventListener('click', () => this.send(sessionId));
|
||||
bar.appendChild(sendBtn);
|
||||
// Send button
|
||||
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
|
||||
textContent: "Send",
|
||||
});
|
||||
sendBtn.addEventListener("click", () => this.send(sessionId));
|
||||
bar.appendChild(sendBtn);
|
||||
|
||||
return bar;
|
||||
return bar;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,18 +390,18 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderEntry(viewer, entry) {
|
||||
let el;
|
||||
switch (viewer.mode) {
|
||||
case 'hex':
|
||||
el = this._renderHexView(entry);
|
||||
break;
|
||||
case 'split':
|
||||
el = this._renderSplitView(entry, viewer.profile);
|
||||
break;
|
||||
default:
|
||||
el = this._renderTextView(entry, viewer.profile);
|
||||
}
|
||||
viewer.display.appendChild(el);
|
||||
let el;
|
||||
switch (viewer.mode) {
|
||||
case "hex":
|
||||
el = this._renderHexView(entry);
|
||||
break;
|
||||
case "split":
|
||||
el = this._renderSplitView(entry, viewer.profile);
|
||||
break;
|
||||
default:
|
||||
el = this._renderTextView(entry, viewer.profile);
|
||||
}
|
||||
viewer.display.appendChild(el);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,11 +410,11 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderAllEntries(viewer) {
|
||||
viewer.display.innerHTML = '';
|
||||
for (const entry of viewer.buffer) {
|
||||
this._renderEntry(viewer, entry);
|
||||
}
|
||||
this._scrollToBottom(viewer);
|
||||
viewer.display.innerHTML = "";
|
||||
for (const entry of viewer.buffer) {
|
||||
this._renderEntry(viewer, entry);
|
||||
}
|
||||
this._scrollToBottom(viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,21 +425,24 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderTextView(entry, profile) {
|
||||
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
|
||||
const raw = profile?.raw || {};
|
||||
const row = createElement(
|
||||
"div",
|
||||
`raw-entry raw-entry-${entry.direction}`,
|
||||
);
|
||||
const raw = profile?.raw || {};
|
||||
|
||||
let prefix = '';
|
||||
if (raw.showTimestamps) {
|
||||
prefix += `[${formatTimestamp(entry.timestamp)}] `;
|
||||
}
|
||||
if (raw.showDirection) {
|
||||
prefix += entry.direction === 'send' ? '[TX] ' : '[RX] ';
|
||||
}
|
||||
let prefix = "";
|
||||
if (raw.showTimestamps) {
|
||||
prefix += `[${formatTimestamp(entry.timestamp)}] `;
|
||||
}
|
||||
if (raw.showDirection) {
|
||||
prefix += entry.direction === "send" ? "[TX] " : "[RX] ";
|
||||
}
|
||||
|
||||
const pre = createElement('pre', 'raw-text');
|
||||
pre.textContent = prefix + entry.data;
|
||||
row.appendChild(pre);
|
||||
return row;
|
||||
const pre = createElement("pre", "raw-text");
|
||||
pre.textContent = prefix + entry.data;
|
||||
row.appendChild(pre);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,11 +452,14 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderHexView(entry) {
|
||||
const row = createElement('div', `raw-entry raw-entry-${entry.direction}`);
|
||||
const pre = createElement('pre', 'raw-hex');
|
||||
pre.textContent = this.formatHexDump(entry.bytes);
|
||||
row.appendChild(pre);
|
||||
return row;
|
||||
const row = createElement(
|
||||
"div",
|
||||
`raw-entry raw-entry-${entry.direction}`,
|
||||
);
|
||||
const pre = createElement("pre", "raw-hex");
|
||||
pre.textContent = this.formatHexDump(entry.bytes);
|
||||
row.appendChild(pre);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,30 +470,34 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_renderSplitView(entry, profile) {
|
||||
const row = createElement('div', `raw-entry raw-entry-split raw-entry-${entry.direction}`);
|
||||
const raw = profile?.raw || {};
|
||||
const row = createElement(
|
||||
"div",
|
||||
`raw-entry raw-entry-split raw-entry-${entry.direction}`,
|
||||
);
|
||||
const raw = profile?.raw || {};
|
||||
|
||||
// Direction/timestamp header
|
||||
const header = createElement('div', 'raw-split-header');
|
||||
let headerText = '';
|
||||
if (raw.showTimestamps) headerText += formatTimestamp(entry.timestamp) + ' ';
|
||||
headerText += entry.direction === 'send' ? '▲ TX' : '▼ RX';
|
||||
header.textContent = headerText;
|
||||
row.appendChild(header);
|
||||
// Direction/timestamp header
|
||||
const header = createElement("div", "raw-split-header");
|
||||
let headerText = "";
|
||||
if (raw.showTimestamps)
|
||||
headerText += formatTimestamp(entry.timestamp) + " ";
|
||||
headerText += entry.direction === "send" ? "▲ TX" : "▼ RX";
|
||||
header.textContent = headerText;
|
||||
row.appendChild(header);
|
||||
|
||||
// Split panes
|
||||
const panes = createElement('div', 'raw-split-panes');
|
||||
// Split panes
|
||||
const panes = createElement("div", "raw-split-panes");
|
||||
|
||||
const textPane = createElement('pre', 'raw-text raw-split-text');
|
||||
textPane.textContent = entry.data;
|
||||
panes.appendChild(textPane);
|
||||
const textPane = createElement("pre", "raw-text raw-split-text");
|
||||
textPane.textContent = entry.data;
|
||||
panes.appendChild(textPane);
|
||||
|
||||
const hexPane = createElement('pre', 'raw-hex raw-split-hex');
|
||||
hexPane.textContent = this.formatHexDump(entry.bytes);
|
||||
panes.appendChild(hexPane);
|
||||
const hexPane = createElement("pre", "raw-hex raw-split-hex");
|
||||
hexPane.textContent = this.formatHexDump(entry.bytes);
|
||||
panes.appendChild(hexPane);
|
||||
|
||||
row.appendChild(panes);
|
||||
return row;
|
||||
row.appendChild(panes);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,39 +507,39 @@ class RawViewer {
|
||||
* @returns {string}
|
||||
*/
|
||||
formatHexDump(bytes) {
|
||||
const lines = [];
|
||||
const bytesPerLine = 16;
|
||||
const lines = [];
|
||||
const bytesPerLine = 16;
|
||||
|
||||
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
|
||||
const chunk = bytes.slice(offset, offset + bytesPerLine);
|
||||
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
|
||||
const chunk = bytes.slice(offset, offset + bytesPerLine);
|
||||
|
||||
// Offset column
|
||||
const offsetStr = offset.toString(16).padStart(8, '0');
|
||||
// Offset column
|
||||
const offsetStr = offset.toString(16).padStart(8, "0");
|
||||
|
||||
// Hex column
|
||||
const hexParts = [];
|
||||
for (let i = 0; i < bytesPerLine; i++) {
|
||||
if (i < chunk.length) {
|
||||
hexParts.push(chunk[i].toString(16).padStart(2, '0'));
|
||||
} else {
|
||||
hexParts.push(' ');
|
||||
}
|
||||
// Extra space after 8th byte for readability
|
||||
if (i === 7) hexParts.push('');
|
||||
}
|
||||
const hexStr = hexParts.join(' ');
|
||||
// Hex column
|
||||
const hexParts = [];
|
||||
for (let i = 0; i < bytesPerLine; i++) {
|
||||
if (i < chunk.length) {
|
||||
hexParts.push(chunk[i].toString(16).padStart(2, "0"));
|
||||
} else {
|
||||
hexParts.push(" ");
|
||||
}
|
||||
// Extra space after 8th byte for readability
|
||||
if (i === 7) hexParts.push("");
|
||||
}
|
||||
const hexStr = hexParts.join(" ");
|
||||
|
||||
// ASCII column
|
||||
let ascii = '';
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const b = chunk[i];
|
||||
ascii += (b >= 0x20 && b <= 0x7e) ? String.fromCharCode(b) : '.';
|
||||
}
|
||||
|
||||
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
|
||||
// ASCII column
|
||||
let ascii = "";
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const b = chunk[i];
|
||||
ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : ".";
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────
|
||||
@@ -500,17 +551,17 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_setMode(sessionId, mode) {
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
const viewer = this.viewers.get(sessionId);
|
||||
if (!viewer) return;
|
||||
|
||||
viewer.mode = mode;
|
||||
viewer.mode = mode;
|
||||
|
||||
// Update active button
|
||||
viewer.toolbar.querySelectorAll('.raw-mode-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
});
|
||||
// Update active button
|
||||
viewer.toolbar.querySelectorAll(".raw-mode-btn").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.mode === mode);
|
||||
});
|
||||
|
||||
this._renderAllEntries(viewer);
|
||||
this._renderAllEntries(viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,9 +570,9 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_scrollToBottom(viewer) {
|
||||
requestAnimationFrame(() => {
|
||||
viewer.display.scrollTop = viewer.display.scrollHeight;
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
viewer.display.scrollTop = viewer.display.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -531,12 +582,12 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_hexToString(hex) {
|
||||
const cleaned = hex.replace(/\s+/g, '');
|
||||
let str = '';
|
||||
for (let i = 0; i < cleaned.length; i += 2) {
|
||||
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
|
||||
}
|
||||
return str;
|
||||
const cleaned = hex.replace(/\s+/g, "");
|
||||
let str = "";
|
||||
for (let i = 0; i < cleaned.length; i += 2) {
|
||||
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -546,25 +597,29 @@ class RawViewer {
|
||||
* @private
|
||||
*/
|
||||
_unescapeString(str) {
|
||||
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
|
||||
switch (seq) {
|
||||
case 'n': return '\n';
|
||||
case 'r': return '\r';
|
||||
case 't': return '\t';
|
||||
case '0': return '\0';
|
||||
case '\\': return '\\';
|
||||
default:
|
||||
// \\xHH
|
||||
if (seq.startsWith('x')) {
|
||||
return String.fromCharCode(parseInt(seq.slice(1), 16));
|
||||
}
|
||||
return match;
|
||||
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
|
||||
switch (seq) {
|
||||
case "n":
|
||||
return "\n";
|
||||
case "r":
|
||||
return "\r";
|
||||
case "t":
|
||||
return "\t";
|
||||
case "0":
|
||||
return "\0";
|
||||
case "\\":
|
||||
return "\\";
|
||||
default:
|
||||
// \\xHH
|
||||
if (seq.startsWith("x")) {
|
||||
return String.fromCharCode(parseInt(seq.slice(1), 16));
|
||||
}
|
||||
});
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.RawViewer = RawViewer;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.RawViewer = RawViewer;
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — Session Recording & Playback.
|
||||
* Provides controls for starting/stopping recordings on active sessions,
|
||||
* listing saved recordings, and a terminal-based playback viewer with
|
||||
* speed controls and a progress scrubber.
|
||||
*/
|
||||
|
||||
const { createElement, formatDuration } = window.NexusTerminal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recording Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RecordingManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
/** @type {Set<string>} Session IDs currently being recorded. */
|
||||
this.activeSessions = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles recording for the given session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async toggle(sessionId) {
|
||||
if (this.activeSessions.has(sessionId)) {
|
||||
await this.stop(sessionId);
|
||||
} else {
|
||||
await this.start(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts recording a session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async start(sessionId) {
|
||||
try {
|
||||
const entry = this.app.terminals.terminals.get(sessionId);
|
||||
const cols = entry ? entry.term.cols : 80;
|
||||
const rows = entry ? entry.term.rows : 24;
|
||||
|
||||
await window.NexusTerminal.wailsService.StartRecording(sessionId, cols, rows);
|
||||
this.activeSessions.add(sessionId);
|
||||
this._updateRecordingIndicator(sessionId, true);
|
||||
this.app.notifications.success('Recording started');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to start recording: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops recording a session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async stop(sessionId) {
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.StopRecording(sessionId);
|
||||
this.activeSessions.delete(sessionId);
|
||||
this._updateRecordingIndicator(sessionId, false);
|
||||
this.app.notifications.success('Recording saved');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to stop recording: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the recordings browser modal.
|
||||
*/
|
||||
async openBrowser() {
|
||||
try {
|
||||
const [recordings, storageInfo] = await Promise.all([
|
||||
window.NexusTerminal.wailsService.ListRecordings(),
|
||||
window.NexusTerminal.wailsService.GetRecordingStorageInfo(),
|
||||
]);
|
||||
this._showBrowserModal(recordings || [], storageInfo || {});
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to list recordings: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays back a recording in a modal terminal viewer.
|
||||
* @param {string} id — Recording ID.
|
||||
*/
|
||||
async playback(id) {
|
||||
try {
|
||||
const data = await window.NexusTerminal.wailsService.GetRecording(id);
|
||||
this._showPlaybackModal(data);
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to load recording: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a recording by ID.
|
||||
* @param {string} id
|
||||
*/
|
||||
async deleteRecording(id) {
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.DeleteRecording(id);
|
||||
this.app.notifications.success('Recording deleted');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to delete recording: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording indicator on tabs ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Updates the recording indicator on a session tab.
|
||||
* @param {string} sessionId
|
||||
* @param {boolean} recording
|
||||
* @private
|
||||
*/
|
||||
_updateRecordingIndicator(sessionId, recording) {
|
||||
const tab = document.querySelector(`.session-tab[data-session-id="${sessionId}"]`);
|
||||
if (!tab) return;
|
||||
|
||||
let dot = tab.querySelector('.recording-dot');
|
||||
if (recording) {
|
||||
if (!dot) {
|
||||
dot = createElement('span', 'recording-dot');
|
||||
dot.title = 'Recording';
|
||||
const label = tab.querySelector('.tab-label');
|
||||
if (label) label.parentNode.insertBefore(dot, label);
|
||||
}
|
||||
} else {
|
||||
if (dot) dot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recordings browser modal ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a modal listing all saved recordings.
|
||||
* @param {Array} recordings
|
||||
* @param {Object} storageInfo
|
||||
* @private
|
||||
*/
|
||||
_showBrowserModal(recordings, storageInfo = {}) {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-lg');
|
||||
|
||||
let listHTML = '';
|
||||
if (recordings.length === 0) {
|
||||
listHTML = '<p class="text-muted" style="padding:1rem;text-align:center;">No recordings yet. Use the record button on a terminal tab to capture a session.</p>';
|
||||
} else {
|
||||
listHTML = '<div class="recordings-list">';
|
||||
for (const rec of recordings) {
|
||||
const date = new Date(rec.createdAt).toLocaleString();
|
||||
const dur = _formatSeconds(rec.duration);
|
||||
const size = _formatBytes(rec.size);
|
||||
listHTML += `
|
||||
<div class="recording-item" data-id="${rec.id}">
|
||||
<div class="recording-info">
|
||||
<span class="recording-title">${_esc(rec.title || rec.id)}</span>
|
||||
<span class="recording-meta">${date} · ${dur} · ${size} · ${rec.width}×${rec.height}</span>
|
||||
</div>
|
||||
<div class="recording-actions">
|
||||
<button class="btn btn-xs btn-primary rec-play" data-id="${rec.id}" title="Play">▶</button>
|
||||
<button class="btn btn-xs btn-danger rec-delete" data-id="${rec.id}" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
listHTML += '</div>';
|
||||
}
|
||||
|
||||
const totalSize = _formatBytes(storageInfo.totalBytes || 0);
|
||||
const count = storageInfo.count ?? recordings.length;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Session Recordings</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
|
||||
<div class="recording-storage">
|
||||
<div class="recording-storage-summary">
|
||||
<span>${count} recording${count === 1 ? '' : 's'}</span>
|
||||
<span>${totalSize}</span>
|
||||
</div>
|
||||
<div class="recording-cleanup-controls">
|
||||
<select class="form-input recording-cleanup-age">
|
||||
<option value="30">Older than 30 days</option>
|
||||
<option value="7">Older than 7 days</option>
|
||||
<option value="90">Older than 90 days</option>
|
||||
<option value="0">Ignore age</option>
|
||||
</select>
|
||||
<select class="form-input recording-cleanup-size">
|
||||
<option value="1073741824">Keep under 1 GB</option>
|
||||
<option value="5368709120">Keep under 5 GB</option>
|
||||
<option value="0">Ignore size</option>
|
||||
</select>
|
||||
<button class="btn btn-sm recording-cleanup">Clean up</button>
|
||||
</div>
|
||||
<div class="form-help recording-cleanup-status"></div>
|
||||
</div>
|
||||
${listHTML}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm modal-close-btn">Close</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
modal.querySelector('.modal-close-btn').addEventListener('click', close);
|
||||
|
||||
modal.querySelector('.recording-cleanup').addEventListener('click', async () => {
|
||||
const ageDays = parseInt(modal.querySelector('.recording-cleanup-age').value, 10) || 0;
|
||||
const maxBytes = parseInt(modal.querySelector('.recording-cleanup-size').value, 10) || 0;
|
||||
const status = modal.querySelector('.recording-cleanup-status');
|
||||
const btn = modal.querySelector('.recording-cleanup');
|
||||
status.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await window.NexusTerminal.wailsService.CleanupRecordings(ageDays, maxBytes);
|
||||
status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`;
|
||||
const fresh = await window.NexusTerminal.wailsService.ListRecordings();
|
||||
close();
|
||||
this._showBrowserModal(fresh || [], await window.NexusTerminal.wailsService.GetRecordingStorageInfo());
|
||||
} catch (err) {
|
||||
status.textContent = 'Cleanup failed: ' + err;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Play buttons
|
||||
modal.querySelectorAll('.rec-play').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
close();
|
||||
this.playback(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete buttons
|
||||
modal.querySelectorAll('.rec-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await this.deleteRecording(btn.dataset.id);
|
||||
const item = btn.closest('.recording-item');
|
||||
if (item) item.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Playback modal with embedded terminal ────────────────────────
|
||||
|
||||
/**
|
||||
* Shows a playback modal with an xterm.js terminal, progress bar, and speed controls.
|
||||
* @param {Object} data — { header, events }
|
||||
* @private
|
||||
*/
|
||||
_showPlaybackModal(data) {
|
||||
const header = data.header;
|
||||
const events = data.events || [];
|
||||
if (events.length === 0) {
|
||||
this.app.notifications.info('Recording is empty — nothing to play back.');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDuration = events[events.length - 1][0] || 1;
|
||||
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-playback');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">${_esc(header.title || 'Recording Playback')}</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body playback-body">
|
||||
<div id="playback-terminal" class="playback-terminal"></div>
|
||||
<div class="playback-controls">
|
||||
<button class="btn btn-xs btn-primary playback-play-pause" title="Play/Pause">▶</button>
|
||||
<button class="btn btn-xs btn-outline playback-restart" title="Restart">⟲</button>
|
||||
<input type="range" class="playback-progress" min="0" max="1000" value="0" />
|
||||
<span class="playback-time">0:00 / ${_formatSeconds(totalDuration)}</span>
|
||||
<select class="playback-speed form-input" style="width:auto;padding:2px 6px;font-size:11px;">
|
||||
<option value="0.5">0.5×</option>
|
||||
<option value="1" selected>1×</option>
|
||||
<option value="2">2×</option>
|
||||
<option value="5">5×</option>
|
||||
<option value="10">10×</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||
|
||||
// Create xterm instance for playback.
|
||||
const termContainer = modal.querySelector('#playback-terminal');
|
||||
const { getTerminalTheme } = window.NexusTerminal;
|
||||
const themeName = (this.app.state.preferences && this.app.state.preferences.terminalTheme) || 'charcoal';
|
||||
const theme = getTerminalTheme ? getTerminalTheme(themeName) : {};
|
||||
|
||||
const term = new window.Terminal({
|
||||
cols: header.width || 80,
|
||||
rows: header.height || 24,
|
||||
theme: theme,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", "Menlo", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
scrollback: 10000,
|
||||
});
|
||||
term.open(termContainer);
|
||||
|
||||
const fitAddon = new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
setTimeout(() => fitAddon.fit(), 50);
|
||||
|
||||
// Playback state.
|
||||
let playing = false;
|
||||
let speed = 1;
|
||||
let eventIndex = 0;
|
||||
let playbackTimer = null;
|
||||
let startRealTime = 0;
|
||||
let pausedAt = 0; // elapsed virtual time when paused
|
||||
|
||||
const playPauseBtn = modal.querySelector('.playback-play-pause');
|
||||
const restartBtn = modal.querySelector('.playback-restart');
|
||||
const progressInput = modal.querySelector('.playback-progress');
|
||||
const timeLabel = modal.querySelector('.playback-time');
|
||||
const speedSelect = modal.querySelector('.playback-speed');
|
||||
|
||||
const updateTimeDisplay = (elapsed) => {
|
||||
timeLabel.textContent = `${_formatSeconds(elapsed)} / ${_formatSeconds(totalDuration)}`;
|
||||
progressInput.value = Math.round((elapsed / totalDuration) * 1000);
|
||||
};
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (!playing || eventIndex >= events.length) {
|
||||
if (eventIndex >= events.length) {
|
||||
playing = false;
|
||||
playPauseBtn.textContent = '▶';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const evt = events[eventIndex];
|
||||
const evtTime = evt[0]; // seconds from start
|
||||
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||
const virtualElapsed = pausedAt + realElapsed * speed;
|
||||
const delay = Math.max(0, (evtTime - virtualElapsed) / speed * 1000);
|
||||
|
||||
playbackTimer = setTimeout(() => {
|
||||
if (!playing) return;
|
||||
|
||||
// Write output events to the terminal.
|
||||
if (evt[1] === 'o') {
|
||||
term.write(evt[2]);
|
||||
}
|
||||
|
||||
eventIndex++;
|
||||
const currentVirtual = pausedAt + ((Date.now() - startRealTime) / 1000) * speed;
|
||||
updateTimeDisplay(Math.min(currentVirtual, totalDuration));
|
||||
scheduleNext();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
if (eventIndex >= events.length) {
|
||||
// Restart from beginning.
|
||||
eventIndex = 0;
|
||||
pausedAt = 0;
|
||||
term.reset();
|
||||
}
|
||||
playing = true;
|
||||
playPauseBtn.textContent = '⏸';
|
||||
startRealTime = Date.now();
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
playing = false;
|
||||
playPauseBtn.textContent = '▶';
|
||||
if (playbackTimer) {
|
||||
clearTimeout(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||
pausedAt = pausedAt + realElapsed * speed;
|
||||
};
|
||||
|
||||
const seekTo = (fraction) => {
|
||||
const wasPlaying = playing;
|
||||
if (playing) pause();
|
||||
|
||||
const targetTime = fraction * totalDuration;
|
||||
term.reset();
|
||||
eventIndex = 0;
|
||||
|
||||
// Replay all events up to targetTime.
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
if (events[i][0] > targetTime) break;
|
||||
if (events[i][1] === 'o') {
|
||||
term.write(events[i][2]);
|
||||
}
|
||||
eventIndex = i + 1;
|
||||
}
|
||||
|
||||
pausedAt = targetTime;
|
||||
updateTimeDisplay(targetTime);
|
||||
|
||||
if (wasPlaying) play();
|
||||
};
|
||||
|
||||
playPauseBtn.addEventListener('click', () => {
|
||||
if (playing) pause(); else play();
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', () => {
|
||||
if (playing) pause();
|
||||
term.reset();
|
||||
eventIndex = 0;
|
||||
pausedAt = 0;
|
||||
updateTimeDisplay(0);
|
||||
});
|
||||
|
||||
progressInput.addEventListener('input', () => {
|
||||
seekTo(parseInt(progressInput.value, 10) / 1000);
|
||||
});
|
||||
|
||||
speedSelect.addEventListener('change', () => {
|
||||
const newSpeed = parseFloat(speedSelect.value);
|
||||
if (playing) {
|
||||
const realElapsed = (Date.now() - startRealTime) / 1000;
|
||||
pausedAt = pausedAt + realElapsed * speed;
|
||||
speed = newSpeed;
|
||||
startRealTime = Date.now();
|
||||
if (playbackTimer) clearTimeout(playbackTimer);
|
||||
scheduleNext();
|
||||
} else {
|
||||
speed = newSpeed;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-play on open.
|
||||
setTimeout(play, 200);
|
||||
|
||||
// Close handler.
|
||||
const close = () => {
|
||||
if (playing) pause();
|
||||
term.dispose();
|
||||
backdrop.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted Export/Import UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ExportImportUI {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the encrypted export modal.
|
||||
*/
|
||||
async showExportModal() {
|
||||
const profiles = this.app.state.profiles || [];
|
||||
if (profiles.length === 0) {
|
||||
this.app.notifications.info('No profiles to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-md');
|
||||
|
||||
let profileListHTML = profiles.map(p => `
|
||||
<label class="export-profile-item">
|
||||
<input type="checkbox" value="${p.id}" checked />
|
||||
<span>${_esc(p.displayName || p.host)} <small class="text-muted">(${p.protocol})</small></span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Export Profiles (Encrypted)</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Select profiles to export</label>
|
||||
<div class="export-profile-list" style="max-height:200px;overflow-y:auto;margin-bottom:12px;">
|
||||
${profileListHTML}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Export password</label>
|
||||
<input type="password" class="form-input export-password" placeholder="Enter a strong password" />
|
||||
<small class="text-muted">This password will be required to import the file.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm password</label>
|
||||
<input type="password" class="form-input export-password-confirm" placeholder="Confirm password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm export-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary export-confirm">Export</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
modal.querySelector('.export-cancel').addEventListener('click', close);
|
||||
|
||||
modal.querySelector('.export-confirm').addEventListener('click', async () => {
|
||||
const password = modal.querySelector('.export-password').value;
|
||||
const confirm = modal.querySelector('.export-password-confirm').value;
|
||||
|
||||
if (!password) {
|
||||
this.app.notifications.error('Please enter a password.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
this.app.notifications.error('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkboxes = modal.querySelectorAll('.export-profile-list input[type="checkbox"]:checked');
|
||||
const selectedIds = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
this.app.notifications.error('Please select at least one profile.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const path = await window.NexusTerminal.wailsService.ExportProfilesEncrypted(selectedIds, password);
|
||||
this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`);
|
||||
close();
|
||||
} catch (err) {
|
||||
if (String(err).includes('cancelled')) {
|
||||
return;
|
||||
}
|
||||
this.app.notifications.error('Export failed: ' + err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the encrypted import modal.
|
||||
*/
|
||||
async showImportModal() {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-md');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Import Profiles (Encrypted)</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Decryption password</label>
|
||||
<input type="password" class="form-input import-password" placeholder="Enter the export password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Conflict strategy</label>
|
||||
<select class="form-input import-strategy">
|
||||
<option value="skip">Skip existing</option>
|
||||
<option value="overwrite">Overwrite existing</option>
|
||||
<option value="duplicate">Create duplicates</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-error import-status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm import-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary import-confirm">Import</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
modal.querySelector('.import-cancel').addEventListener('click', close);
|
||||
|
||||
modal.querySelector('.import-confirm').addEventListener('click', async () => {
|
||||
const password = modal.querySelector('.import-password').value;
|
||||
const strategy = modal.querySelector('.import-strategy').value;
|
||||
const status = modal.querySelector('.import-status');
|
||||
const confirmBtn = modal.querySelector('.import-confirm');
|
||||
status.textContent = '';
|
||||
|
||||
if (!password) {
|
||||
status.textContent = 'Please enter the decryption password.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Importing...';
|
||||
const count = await window.NexusTerminal.wailsService.ImportProfilesEncrypted(password, strategy);
|
||||
this.app.notifications.success(`Imported ${count} profile(s)`);
|
||||
await this.app.loadProfiles();
|
||||
close();
|
||||
} catch (err) {
|
||||
if (String(err).includes('cancelled')) {
|
||||
return;
|
||||
}
|
||||
status.textContent = this._formatImportError(err);
|
||||
this.app.notifications.error('Import failed');
|
||||
} finally {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Import';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_formatImportError(err) {
|
||||
const msg = String(err || 'Unknown import error');
|
||||
return msg.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _esc(s) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = s || '';
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function _formatSeconds(secs) {
|
||||
if (!secs || secs <= 0) return '0:00';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function _formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let val = bytes;
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export to global namespace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.RecordingManager = RecordingManager;
|
||||
window.NexusTerminal.ExportImportUI = ExportImportUI;
|
||||
|
||||
})();
|
||||
@@ -134,6 +134,10 @@ class SecretPrompt {
|
||||
`;
|
||||
if (isNew) {
|
||||
formHtml += `
|
||||
<div class="password-strength-meter">
|
||||
<div class="strength-bar"><div class="strength-fill"></div></div>
|
||||
<span class="strength-label"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm password</label>
|
||||
<input type="password" class="form-input master-confirm"
|
||||
@@ -166,6 +170,19 @@ class SecretPrompt {
|
||||
const errorEl = modal.querySelector('.secret-error');
|
||||
passInput.focus();
|
||||
|
||||
// Wire up the password strength meter for new vault creation.
|
||||
if (isNew) {
|
||||
const strengthFill = modal.querySelector('.strength-fill');
|
||||
const strengthLabel = modal.querySelector('.strength-label');
|
||||
passInput.addEventListener('input', () => {
|
||||
const result = _measureStrength(passInput.value);
|
||||
strengthFill.style.width = result.percent + '%';
|
||||
strengthFill.className = 'strength-fill strength-' + result.level;
|
||||
strengthLabel.textContent = result.label;
|
||||
strengthLabel.className = 'strength-label strength-' + result.level;
|
||||
});
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const pw = passInput.value;
|
||||
if (!pw) {
|
||||
@@ -263,7 +280,11 @@ class SecretPrompt {
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
|
||||
acceptBtn.focus();
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
acceptBtn.focus();
|
||||
});
|
||||
|
||||
acceptBtn.addEventListener('click', () => {
|
||||
this._close();
|
||||
@@ -310,6 +331,42 @@ class SecretPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates password strength based on length and character diversity.
|
||||
* Returns an object with { percent, level, label } for the strength meter.
|
||||
* @param {string} pw
|
||||
* @returns {{ percent: number, level: string, label: string }}
|
||||
* @private
|
||||
*/
|
||||
function _measureStrength(pw) {
|
||||
if (!pw) return { percent: 0, level: 'none', label: '' };
|
||||
|
||||
let score = 0;
|
||||
const len = pw.length;
|
||||
|
||||
// Length contribution (0–40 points)
|
||||
score += Math.min(len * 3, 40);
|
||||
|
||||
// Character-class diversity (0–40 points)
|
||||
const hasLower = /[a-z]/.test(pw);
|
||||
const hasUpper = /[A-Z]/.test(pw);
|
||||
const hasDigit = /[0-9]/.test(pw);
|
||||
const hasSymbol = /[^a-zA-Z0-9]/.test(pw);
|
||||
const classes = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean).length;
|
||||
score += classes * 10;
|
||||
|
||||
// Unique-character ratio bonus (0–20 points)
|
||||
const unique = new Set(pw).size;
|
||||
score += Math.min(Math.floor((unique / Math.max(len, 1)) * 20), 20);
|
||||
|
||||
score = Math.min(score, 100);
|
||||
|
||||
if (score < 25) return { percent: score, level: 'weak', label: 'Weak' };
|
||||
if (score < 50) return { percent: score, level: 'fair', label: 'Fair' };
|
||||
if (score < 75) return { percent: score, level: 'good', label: 'Good' };
|
||||
return { percent: score, level: 'strong', label: 'Strong' };
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SecretPrompt = SecretPrompt;
|
||||
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — snippet / command library.
|
||||
* Provides a panel for managing and sending reusable commands to active sessions.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, dismissContextMenus } =
|
||||
window.NexusTerminal;
|
||||
|
||||
class SnippetLibrary {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
/** @type {Array<Object>} */
|
||||
this.snippets = [];
|
||||
this.searchQuery = "";
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opens the snippet library modal.
|
||||
*/
|
||||
async open() {
|
||||
await this._loadSnippets();
|
||||
this._showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a snippet command to the active session.
|
||||
* Supports {{variable}} placeholders that prompt the user for values.
|
||||
* Built-in variables: {{hostname}}, {{date}}, {{time}}, {{timestamp}}.
|
||||
* @param {string} command
|
||||
*/
|
||||
sendToActiveSession(command) {
|
||||
const sessionId = this.app.state.activeSessionId;
|
||||
if (!sessionId) {
|
||||
this.app.notifications.error("No active session");
|
||||
return;
|
||||
}
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || session.state !== "connected") {
|
||||
this.app.notifications.error("Session is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for template variables
|
||||
const varPattern = /\{\{([^}]+)\}\}/g;
|
||||
const matches = [];
|
||||
let match;
|
||||
while ((match = varPattern.exec(command)) !== null) {
|
||||
matches.push(match[1].trim());
|
||||
}
|
||||
|
||||
// Resolve built-in variables first
|
||||
const builtins = {
|
||||
hostname: session.host || "unknown",
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
time: new Date().toTimeString().split(" ")[0],
|
||||
timestamp: Math.floor(Date.now() / 1000).toString(),
|
||||
};
|
||||
|
||||
let resolved = command;
|
||||
const userVars = [];
|
||||
for (const varName of matches) {
|
||||
const lower = varName.toLowerCase();
|
||||
if (builtins[lower] !== undefined) {
|
||||
resolved = resolved.replace("{{" + varName + "}}", builtins[lower]);
|
||||
} else if (!userVars.includes(varName)) {
|
||||
userVars.push(varName);
|
||||
}
|
||||
}
|
||||
|
||||
if (userVars.length === 0) {
|
||||
this._sendCommand(sessionId, resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for user-defined variables
|
||||
this._promptVariables(userVars, (values) => {
|
||||
let final = resolved;
|
||||
for (let i = 0; i < userVars.length; i++) {
|
||||
final = final.split("{{" + userVars[i] + "}}").join(values[i] || "");
|
||||
}
|
||||
this._sendCommand(sessionId, final);
|
||||
});
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_sendCommand(sessionId, command) {
|
||||
try {
|
||||
const b64 = window.NexusTerminal.utf8ToBase64(command + "\n");
|
||||
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||
this.app.notifications.info("Snippet sent");
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to send snippet: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_promptVariables(varNames, onConfirm) {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
|
||||
let inputsHtml = "";
|
||||
for (const v of varNames) {
|
||||
inputsHtml += `
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">${escapeHtml(v)}</label>
|
||||
<input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Snippet Variables</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">${inputsHtml}</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm btn-ghost snippet-var-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary snippet-var-send">Send</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
const firstInput = modal.querySelector(".snippet-var-input");
|
||||
if (firstInput) firstInput.focus();
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector(".modal-close").addEventListener("click", close);
|
||||
modal
|
||||
.querySelector(".snippet-var-cancel")
|
||||
.addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
modal.querySelector(".snippet-var-send").addEventListener("click", () => {
|
||||
const inputs = modal.querySelectorAll(".snippet-var-input");
|
||||
const values = Array.from(inputs).map((inp) => inp.value);
|
||||
close();
|
||||
onConfirm(values);
|
||||
});
|
||||
|
||||
// Enter to send
|
||||
modal.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
modal.querySelector(".snippet-var-send").click();
|
||||
}
|
||||
if (e.key === "Escape") close();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────
|
||||
|
||||
/** @private */
|
||||
async _loadSnippets() {
|
||||
try {
|
||||
this.snippets = (await window.NexusTerminal.wailsService.ListSnippets()) || [];
|
||||
} catch (err) {
|
||||
this.snippets = [];
|
||||
console.error("Failed to load snippets:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
|
||||
/** @private */
|
||||
_showModal() {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Command Library</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body snippet-body" style="display:flex;gap:var(--space-3);height:400px;">
|
||||
<div class="snippet-list-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
|
||||
<div style="display:flex;gap:var(--space-2);">
|
||||
<input type="text" class="form-input snippet-search" placeholder="Search snippets…" style="flex:1;" />
|
||||
<button class="btn btn-sm btn-primary snippet-add-btn">+ New</button>
|
||||
</div>
|
||||
<div class="snippet-list" style="flex:1;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div class="snippet-detail-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
|
||||
<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">
|
||||
Select a snippet or create a new one
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector(".modal-close").addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
// Search
|
||||
const searchInput = modal.querySelector(".snippet-search");
|
||||
searchInput.addEventListener("input", () => {
|
||||
this.searchQuery = searchInput.value.trim().toLowerCase();
|
||||
this._renderList(modal, close);
|
||||
});
|
||||
|
||||
// New snippet button
|
||||
modal.querySelector(".snippet-add-btn").addEventListener("click", () => {
|
||||
this._showEditor(modal, null, close);
|
||||
});
|
||||
|
||||
this._renderList(modal, close);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_renderList(modal, closeFn) {
|
||||
const listEl = modal.querySelector(".snippet-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = "";
|
||||
|
||||
const filtered = this.snippets.filter((sn) => {
|
||||
if (!this.searchQuery) return true;
|
||||
return (
|
||||
sn.name.toLowerCase().includes(this.searchQuery) ||
|
||||
sn.command.toLowerCase().includes(this.searchQuery) ||
|
||||
(sn.description || "").toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML =
|
||||
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sn of filtered) {
|
||||
const item = createElement("div", "snippet-item", {
|
||||
dataset: { id: sn.id },
|
||||
});
|
||||
item.style.cssText =
|
||||
"padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);";
|
||||
item.innerHTML = `
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
|
||||
`;
|
||||
item.addEventListener("click", () =>
|
||||
this._showDetail(modal, sn, closeFn),
|
||||
);
|
||||
item.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
item.addEventListener("mouseenter", () => {
|
||||
item.style.background = "var(--bg-hover)";
|
||||
});
|
||||
item.addEventListener("mouseleave", () => {
|
||||
item.style.background = "";
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_showDetail(modal, snippet, closeFn) {
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const nameEl = createElement("div", "", {
|
||||
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
|
||||
});
|
||||
detailPanel.appendChild(nameEl);
|
||||
|
||||
if (snippet.description) {
|
||||
const descEl = createElement("div", "", {
|
||||
textContent: snippet.description,
|
||||
});
|
||||
descEl.style.cssText =
|
||||
"font-size:var(--text-sm);color:var(--text-secondary);";
|
||||
detailPanel.appendChild(descEl);
|
||||
}
|
||||
|
||||
const cmdEl = createElement("pre", "", {
|
||||
textContent: snippet.command,
|
||||
});
|
||||
cmdEl.style.cssText =
|
||||
"flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;";
|
||||
detailPanel.appendChild(cmdEl);
|
||||
|
||||
// Action buttons
|
||||
const actions = createElement("div", "", {});
|
||||
actions.style.cssText = "display:flex;gap:var(--space-2);";
|
||||
|
||||
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
|
||||
textContent: "Send to Session",
|
||||
});
|
||||
sendBtn.addEventListener("click", () => {
|
||||
this.sendToActiveSession(snippet.command);
|
||||
});
|
||||
|
||||
const editBtn = createElement("button", "btn btn-sm btn-secondary", {
|
||||
textContent: "Edit",
|
||||
});
|
||||
editBtn.addEventListener("click", () => {
|
||||
this._showEditor(modal, snippet, closeFn);
|
||||
});
|
||||
|
||||
const deleteBtn = createElement("button", "btn btn-sm btn-danger", {
|
||||
textContent: "Delete",
|
||||
});
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.DeleteSnippet(snippet.id);
|
||||
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
|
||||
this._renderList(modal, closeFn);
|
||||
detailPanel.innerHTML =
|
||||
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to delete: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
actions.appendChild(sendBtn);
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
detailPanel.appendChild(actions);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_showEditor(modal, snippet, closeFn) {
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const isNew = !snippet;
|
||||
const title = isNew ? "New Snippet" : "Edit Snippet";
|
||||
|
||||
detailPanel.innerHTML = `
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-input snippet-ed-name" value="${isNew ? "" : escapeHtml(snippet.name)}" />
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? "" : escapeHtml(snippet.description || "")}" />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;display:flex;flex-direction:column;">
|
||||
<label class="form-label">Command</label>
|
||||
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? "" : escapeHtml(snippet.command)}</textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Folder</label>
|
||||
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? "" : escapeHtml(snippet.folder || "")}" placeholder="e.g. Networking, Debugging" />
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);">
|
||||
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
|
||||
<button class="btn btn-sm snippet-cancel-btn">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
detailPanel
|
||||
.querySelector(".snippet-save-btn")
|
||||
.addEventListener("click", async () => {
|
||||
const name = detailPanel
|
||||
.querySelector(".snippet-ed-name")
|
||||
.value.trim();
|
||||
const command = detailPanel.querySelector(".snippet-ed-cmd").value;
|
||||
const description = detailPanel
|
||||
.querySelector(".snippet-ed-desc")
|
||||
.value.trim();
|
||||
const folder = detailPanel
|
||||
.querySelector(".snippet-ed-folder")
|
||||
.value.trim();
|
||||
|
||||
if (!name) {
|
||||
this.app.notifications.error("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
this.app.notifications.error("Command is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
id: isNew ? "" : snippet.id,
|
||||
name,
|
||||
command,
|
||||
description,
|
||||
folder,
|
||||
tags: isNew ? [] : snippet.tags || [],
|
||||
};
|
||||
|
||||
let saved;
|
||||
if (isNew) {
|
||||
saved = await window.NexusTerminal.wailsService.CreateSnippet(data);
|
||||
this.snippets.push(saved);
|
||||
} else {
|
||||
saved = await window.NexusTerminal.wailsService.UpdateSnippet(data);
|
||||
const idx = this.snippets.findIndex((s) => s.id === saved.id);
|
||||
if (idx !== -1) this.snippets[idx] = saved;
|
||||
}
|
||||
|
||||
this._renderList(modal, closeFn);
|
||||
this._showDetail(modal, saved, closeFn);
|
||||
this.app.notifications.info(
|
||||
isNew ? "Snippet created" : "Snippet updated",
|
||||
);
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to save snippet: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
detailPanel
|
||||
.querySelector(".snippet-cancel-btn")
|
||||
.addEventListener("click", () => {
|
||||
if (snippet) {
|
||||
this._showDetail(modal, snippet, closeFn);
|
||||
} else {
|
||||
detailPanel.innerHTML =
|
||||
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
|
||||
})();
|
||||
@@ -0,0 +1,600 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — Split Pane Manager.
|
||||
* Enables splitting session panels into multiple panes, each hosting
|
||||
* its own terminal session. Supports horizontal (top/bottom) and
|
||||
* vertical (left/right) splits with draggable resize dividers.
|
||||
*/
|
||||
|
||||
var MIN_PANE_SIZE = 100; // px
|
||||
|
||||
/**
|
||||
* Unique pane id counter.
|
||||
* @type {number}
|
||||
*/
|
||||
var _paneIdCounter = 0;
|
||||
|
||||
function nextPaneId() {
|
||||
return 'pane-' + (++_paneIdCounter);
|
||||
}
|
||||
|
||||
class SplitPaneManager {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/**
|
||||
* Map of sessionId → split tree root (or null if unsplit).
|
||||
* The tree is a simple object:
|
||||
* Leaf: { type: 'leaf', paneId, el, sessionId }
|
||||
* Branch: { type: 'branch', direction, first, second, el, divider, ratio }
|
||||
* @type {Map<string, Object|null>}
|
||||
*/
|
||||
this.trees = new Map();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits the active session panel vertically (left | right).
|
||||
*/
|
||||
splitVertical() {
|
||||
this._splitActive('vertical');
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the active session panel horizontally (top / bottom).
|
||||
*/
|
||||
splitHorizontal() {
|
||||
this._splitActive('horizontal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all split state for a session (used on tab close).
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
cleanup(sessionId) {
|
||||
this.trees.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fits all terminals visible in the active session's pane tree.
|
||||
*/
|
||||
refitAll() {
|
||||
var activeId = this.app.state.activeSessionId;
|
||||
if (!activeId) return;
|
||||
var tree = this.trees.get(activeId);
|
||||
if (tree) this._refitTree(tree);
|
||||
}
|
||||
|
||||
// ── Core split logic ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits the active session panel.
|
||||
* @param {'horizontal'|'vertical'} direction
|
||||
* @private
|
||||
*/
|
||||
_splitActive(direction) {
|
||||
var sessionId = this.app.state.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
var panel = document.getElementById('panel-' + sessionId);
|
||||
if (!panel) return;
|
||||
|
||||
var tree = this.trees.get(sessionId);
|
||||
|
||||
if (!tree) {
|
||||
// First split — wrap existing content
|
||||
tree = this._initTree(panel, sessionId);
|
||||
}
|
||||
|
||||
// Find the focused leaf (or the first leaf)
|
||||
var leaf = this._findFocusedLeaf(tree) || this._firstLeaf(tree);
|
||||
if (!leaf) return;
|
||||
|
||||
this._splitLeaf(leaf, direction, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the initial tree structure from an unsplit panel.
|
||||
* Wraps the existing terminal-container in a split-pane leaf.
|
||||
* @param {HTMLElement} panel
|
||||
* @param {string} sessionId
|
||||
* @returns {Object} root leaf node
|
||||
* @private
|
||||
*/
|
||||
_initTree(panel, sessionId) {
|
||||
var termContainer = panel.querySelector('.terminal-container');
|
||||
// For non-terminal panels (raw, github), wrap whatever content exists
|
||||
var contentChildren = Array.from(panel.children);
|
||||
|
||||
var paneId = nextPaneId();
|
||||
var paneEl = document.createElement('div');
|
||||
paneEl.className = 'split-pane';
|
||||
paneEl.dataset.paneId = paneId;
|
||||
paneEl.style.flex = '1';
|
||||
|
||||
// Move existing content into the pane
|
||||
contentChildren.forEach(function(child) {
|
||||
paneEl.appendChild(child);
|
||||
});
|
||||
|
||||
// Reset terminal-container from absolute to relative positioning inside a pane
|
||||
if (termContainer) {
|
||||
termContainer.style.position = 'relative';
|
||||
termContainer.style.inset = 'auto';
|
||||
termContainer.style.flex = '1';
|
||||
termContainer.style.width = '100%';
|
||||
termContainer.style.height = '100%';
|
||||
}
|
||||
|
||||
panel.appendChild(paneEl);
|
||||
|
||||
var leaf = {
|
||||
type: 'leaf',
|
||||
paneId: paneId,
|
||||
el: paneEl,
|
||||
sessionId: sessionId,
|
||||
parent: null
|
||||
};
|
||||
|
||||
paneEl._splitNode = leaf;
|
||||
this.trees.set(sessionId, leaf);
|
||||
|
||||
// Add focus tracking
|
||||
this._addFocusTracking(paneEl);
|
||||
|
||||
return leaf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a leaf node into two panes.
|
||||
* @param {Object} leaf - The leaf node to split.
|
||||
* @param {'horizontal'|'vertical'} direction
|
||||
* @param {string} ownerSessionId - The session that owns this panel.
|
||||
* @private
|
||||
*/
|
||||
_splitLeaf(leaf, direction, ownerSessionId) {
|
||||
var parentBranch = leaf.parent;
|
||||
var panel = this._findPanel(ownerSessionId);
|
||||
if (!panel) return;
|
||||
|
||||
// Create a container for the two panes
|
||||
var containerEl = document.createElement('div');
|
||||
containerEl.className = 'split-container split-' + direction;
|
||||
containerEl.style.flex = leaf.el.style.flex || '1';
|
||||
|
||||
// The existing leaf becomes the first child
|
||||
leaf.el.style.flex = '1';
|
||||
|
||||
// Create divider
|
||||
var divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
|
||||
// Create new empty pane
|
||||
var newPaneId = nextPaneId();
|
||||
var newPaneEl = document.createElement('div');
|
||||
newPaneEl.className = 'split-pane';
|
||||
newPaneEl.dataset.paneId = newPaneId;
|
||||
newPaneEl.style.flex = '1';
|
||||
|
||||
// Add a placeholder with session picker to the new pane
|
||||
this._renderPlaceholder(newPaneEl, ownerSessionId);
|
||||
|
||||
// Add close buttons to both panes
|
||||
this._addCloseButton(leaf.el, ownerSessionId);
|
||||
this._addCloseButton(newPaneEl, ownerSessionId);
|
||||
|
||||
// Add focus tracking
|
||||
this._addFocusTracking(newPaneEl);
|
||||
|
||||
// Build the new branch node
|
||||
var newLeaf = {
|
||||
type: 'leaf',
|
||||
paneId: newPaneId,
|
||||
el: newPaneEl,
|
||||
sessionId: null,
|
||||
parent: null
|
||||
};
|
||||
newPaneEl._splitNode = newLeaf;
|
||||
|
||||
var branch = {
|
||||
type: 'branch',
|
||||
direction: direction,
|
||||
first: leaf,
|
||||
second: newLeaf,
|
||||
el: containerEl,
|
||||
divider: divider,
|
||||
ratio: 0.5,
|
||||
parent: parentBranch
|
||||
};
|
||||
|
||||
leaf.parent = branch;
|
||||
newLeaf.parent = branch;
|
||||
containerEl._splitNode = branch;
|
||||
|
||||
// Replace the leaf element in the DOM
|
||||
var leafParent = leaf.el.parentNode;
|
||||
leafParent.insertBefore(containerEl, leaf.el);
|
||||
containerEl.appendChild(leaf.el);
|
||||
containerEl.appendChild(divider);
|
||||
containerEl.appendChild(newPaneEl);
|
||||
|
||||
// Update the tree structure
|
||||
if (parentBranch) {
|
||||
if (parentBranch.first === leaf) {
|
||||
parentBranch.first = branch;
|
||||
} else {
|
||||
parentBranch.second = branch;
|
||||
}
|
||||
} else {
|
||||
// This was the root leaf — the branch becomes the new root
|
||||
this.trees.set(ownerSessionId, branch);
|
||||
}
|
||||
|
||||
// Set up divider dragging
|
||||
this._setupDividerDrag(divider, branch);
|
||||
|
||||
// Refit terminals after layout change
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._refitTree(self.trees.get(ownerSessionId));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pane closing ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Closes a pane and unsplits, keeping the sibling.
|
||||
* @param {string} paneId
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_closePane(paneId, ownerSessionId) {
|
||||
var root = this.trees.get(ownerSessionId);
|
||||
if (!root) return;
|
||||
|
||||
var leaf = this._findLeafById(root, paneId);
|
||||
if (!leaf || !leaf.parent) {
|
||||
// Only one pane left — can't close it
|
||||
return;
|
||||
}
|
||||
|
||||
var branch = leaf.parent;
|
||||
var sibling = (branch.first === leaf) ? branch.second : branch.first;
|
||||
|
||||
// If the closing pane has a terminal session, destroy it (but not the owner session)
|
||||
if (leaf.sessionId && leaf.sessionId !== ownerSessionId) {
|
||||
this.app.terminals.destroyTerminal(leaf.sessionId);
|
||||
}
|
||||
|
||||
// Replace the branch element with the sibling element in the DOM
|
||||
sibling.el.style.flex = branch.el.style.flex || '1';
|
||||
branch.el.parentNode.insertBefore(sibling.el, branch.el);
|
||||
branch.el.remove();
|
||||
|
||||
// Remove close button from sibling if it's now the only pane
|
||||
sibling.parent = branch.parent;
|
||||
if (branch.parent) {
|
||||
if (branch.parent.first === branch) {
|
||||
branch.parent.first = sibling;
|
||||
} else {
|
||||
branch.parent.second = sibling;
|
||||
}
|
||||
} else {
|
||||
// Sibling is now root
|
||||
this.trees.set(ownerSessionId, sibling);
|
||||
}
|
||||
|
||||
// Remove close button if we're back to a single leaf
|
||||
var newRoot = this.trees.get(ownerSessionId);
|
||||
if (newRoot && newRoot.type === 'leaf') {
|
||||
var closeBtn = newRoot.el.querySelector(':scope > .split-pane-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
}
|
||||
|
||||
// Refit terminals
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._refitTree(self.trees.get(ownerSessionId));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Divider drag ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sets up mouse drag behaviour on a divider.
|
||||
* @param {HTMLElement} divider
|
||||
* @param {Object} branch
|
||||
* @private
|
||||
*/
|
||||
_setupDividerDrag(divider, branch) {
|
||||
var self = this;
|
||||
|
||||
divider.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
var isHorizontal = branch.direction === 'horizontal';
|
||||
var bodyClass = isHorizontal ? 'split-resizing-row' : 'split-resizing';
|
||||
document.body.classList.add(bodyClass);
|
||||
divider.classList.add('is-dragging');
|
||||
|
||||
var containerRect = branch.el.getBoundingClientRect();
|
||||
var dividerSize = isHorizontal ? 4 : 4; // divider thickness
|
||||
|
||||
function onMouseMove(e) {
|
||||
var totalSize, mousePos;
|
||||
if (isHorizontal) {
|
||||
totalSize = containerRect.height - dividerSize;
|
||||
mousePos = e.clientY - containerRect.top;
|
||||
} else {
|
||||
totalSize = containerRect.width - dividerSize;
|
||||
mousePos = e.clientX - containerRect.left;
|
||||
}
|
||||
|
||||
var ratio = mousePos / (totalSize + dividerSize);
|
||||
ratio = Math.max(MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
|
||||
ratio = Math.min(1 - MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
|
||||
|
||||
branch.ratio = ratio;
|
||||
branch.first.el.style.flex = ratio + '';
|
||||
branch.second.el.style.flex = (1 - ratio) + '';
|
||||
|
||||
// Refit terminals in this branch
|
||||
self._refitTree(branch);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.body.classList.remove(bodyClass);
|
||||
divider.classList.remove('is-dragging');
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Final refit
|
||||
self._refitTree(branch);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Placeholder / session picker ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a placeholder with a list of available profiles to connect.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_renderPlaceholder(paneEl, ownerSessionId) {
|
||||
var placeholder = document.createElement('div');
|
||||
placeholder.className = 'split-pane-placeholder';
|
||||
|
||||
var icon = document.createElement('div');
|
||||
icon.className = 'split-pane-placeholder-icon';
|
||||
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>';
|
||||
|
||||
var text = document.createElement('div');
|
||||
text.className = 'split-pane-placeholder-text';
|
||||
text.textContent = 'Select a connection for this pane';
|
||||
|
||||
placeholder.appendChild(icon);
|
||||
placeholder.appendChild(text);
|
||||
|
||||
// Build profile list
|
||||
var profiles = this.app.state.profiles || [];
|
||||
if (profiles.length > 0) {
|
||||
var list = document.createElement('div');
|
||||
list.className = 'split-pane-profile-list';
|
||||
|
||||
var self = this;
|
||||
profiles.forEach(function(profile) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'split-pane-profile-btn';
|
||||
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'protocol-badge';
|
||||
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
|
||||
badge.style.color = self._protocolColor(profile.protocol);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.textContent = profile.displayName || profile.host || profile.id;
|
||||
|
||||
btn.appendChild(badge);
|
||||
btn.appendChild(label);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
self._connectInPane(paneEl, profile, ownerSessionId);
|
||||
});
|
||||
|
||||
list.appendChild(btn);
|
||||
});
|
||||
|
||||
placeholder.appendChild(list);
|
||||
}
|
||||
|
||||
paneEl.appendChild(placeholder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects a profile in a specific pane.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @param {Object} profile
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_connectInPane(paneEl, profile, ownerSessionId) {
|
||||
var self = this;
|
||||
var node = paneEl._splitNode;
|
||||
|
||||
// Remove placeholder
|
||||
var placeholder = paneEl.querySelector('.split-pane-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
// Connect via backend
|
||||
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
|
||||
self.app.state.addSession(sessionInfo);
|
||||
|
||||
// Create a terminal container inside this pane
|
||||
var termContainer = document.createElement('div');
|
||||
termContainer.className = 'terminal-container';
|
||||
termContainer.id = 'split-term-' + sessionInfo.id;
|
||||
termContainer.style.position = 'relative';
|
||||
termContainer.style.flex = '1';
|
||||
termContainer.style.width = '100%';
|
||||
termContainer.style.height = '100%';
|
||||
paneEl.appendChild(termContainer);
|
||||
|
||||
// Track which session is in this pane
|
||||
if (node) {
|
||||
node.sessionId = sessionInfo.id;
|
||||
}
|
||||
|
||||
// Create terminal
|
||||
self.app.terminals.createTerminal(sessionInfo.id, termContainer.id, profile);
|
||||
|
||||
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
|
||||
}).catch(function(err) {
|
||||
self.app.notifications.error('Connection failed: ' + err);
|
||||
// Re-render placeholder on failure
|
||||
self._renderPlaceholder(paneEl, ownerSessionId);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tree traversal helpers ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Finds a leaf by paneId in the tree.
|
||||
* @param {Object} node
|
||||
* @param {string} paneId
|
||||
* @returns {Object|null}
|
||||
* @private
|
||||
*/
|
||||
_findLeafById(node, paneId) {
|
||||
if (!node) return null;
|
||||
if (node.type === 'leaf') {
|
||||
return node.paneId === paneId ? node : null;
|
||||
}
|
||||
return this._findLeafById(node.first, paneId) || this._findLeafById(node.second, paneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first leaf node in the tree.
|
||||
* @param {Object} node
|
||||
* @returns {Object|null}
|
||||
* @private
|
||||
*/
|
||||
_firstLeaf(node) {
|
||||
if (!node) return null;
|
||||
if (node.type === 'leaf') return node;
|
||||
return this._firstLeaf(node.first);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the focused leaf node (one whose pane has .is-focused).
|
||||
* @param {Object} node
|
||||
* @returns {Object|null}
|
||||
* @private
|
||||
*/
|
||||
_findFocusedLeaf(node) {
|
||||
if (!node) return null;
|
||||
if (node.type === 'leaf') {
|
||||
return node.el.classList.contains('is-focused') ? node : null;
|
||||
}
|
||||
return this._findFocusedLeaf(node.first) || this._findFocusedLeaf(node.second);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively refits all terminals in a tree node.
|
||||
* @param {Object} node
|
||||
* @private
|
||||
*/
|
||||
_refitTree(node) {
|
||||
if (!node) return;
|
||||
if (node.type === 'leaf') {
|
||||
if (node.sessionId) {
|
||||
this.app.terminals.fitSession(node.sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._refitTree(node.first);
|
||||
this._refitTree(node.second);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the panel element for a session.
|
||||
* @param {string} sessionId
|
||||
* @returns {HTMLElement|null}
|
||||
* @private
|
||||
*/
|
||||
_findPanel(sessionId) {
|
||||
return document.getElementById('panel-' + sessionId);
|
||||
}
|
||||
|
||||
// ── UI helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adds a close button to a pane element.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_addCloseButton(paneEl, ownerSessionId) {
|
||||
// Don't add if already present
|
||||
if (paneEl.querySelector(':scope > .split-pane-close')) return;
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'split-pane-close';
|
||||
btn.textContent = '✕';
|
||||
btn.title = 'Close pane';
|
||||
|
||||
var self = this;
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
self._closePane(paneEl.dataset.paneId, ownerSessionId);
|
||||
});
|
||||
|
||||
paneEl.appendChild(btn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds focus tracking to a pane — marks it as focused when clicked.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @private
|
||||
*/
|
||||
_addFocusTracking(paneEl) {
|
||||
paneEl.addEventListener('mousedown', function() {
|
||||
// Remove focus from all sibling panes
|
||||
var allPanes = paneEl.closest('.session-panel');
|
||||
if (allPanes) {
|
||||
allPanes.querySelectorAll('.split-pane.is-focused').forEach(function(el) {
|
||||
el.classList.remove('is-focused');
|
||||
});
|
||||
}
|
||||
paneEl.classList.add('is-focused');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a colour for a protocol badge.
|
||||
* @param {string} protocol
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_protocolColor(protocol) {
|
||||
var colors = {
|
||||
ssh: '#4ecdc4',
|
||||
telnet: '#ffa726',
|
||||
raw: '#ab47bc',
|
||||
github: '#7c8aff'
|
||||
};
|
||||
return colors[protocol] || '#4ecdc4';
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SplitPaneManager = SplitPaneManager;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,337 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — SSH Key Manager.
|
||||
* Panel for listing, generating, copying, and deleting SSH key pairs.
|
||||
*/
|
||||
|
||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||
|
||||
class SSHKeyManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this._backdrop = null;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
open() {
|
||||
if (this._backdrop) return;
|
||||
this._render();
|
||||
this._loadKeys();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.classList.remove("is-visible");
|
||||
const modal = this._backdrop.querySelector(".modal");
|
||||
if (modal) modal.classList.remove("is-visible");
|
||||
setTimeout(() => {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.remove();
|
||||
this._backdrop = null;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">SSH Key Manager</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sshkey-toolbar">
|
||||
<button class="btn btn-sm btn-accent sshkey-generate-btn">Generate New Key</button>
|
||||
</div>
|
||||
<div class="sshkey-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
this._backdrop = backdrop;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
// Events
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", () => this.close());
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) this.close();
|
||||
});
|
||||
modal
|
||||
.querySelector(".sshkey-generate-btn")
|
||||
.addEventListener("click", () => this._showGenerateForm());
|
||||
}
|
||||
|
||||
async _loadKeys() {
|
||||
const list = this._backdrop.querySelector(".sshkey-list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
|
||||
|
||||
try {
|
||||
const keys = await window.NexusTerminal.wailsService.ListSSHKeyPairs();
|
||||
if (keys.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const key of keys) {
|
||||
list.appendChild(this._renderKeyRow(key));
|
||||
}
|
||||
} catch (err) {
|
||||
list.innerHTML = "";
|
||||
list.appendChild(
|
||||
createElement("div", "sshkey-error", {
|
||||
textContent: "Failed to load keys: " + String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_renderKeyRow(key) {
|
||||
const row = createElement("div", "sshkey-row");
|
||||
|
||||
const info = createElement("div", "sshkey-info");
|
||||
const name = createElement("div", "sshkey-name", {
|
||||
textContent: key.name,
|
||||
});
|
||||
const meta = createElement("div", "sshkey-meta", {
|
||||
textContent: key.type.toUpperCase() + (key.bits ? ` ${key.bits}` : ""),
|
||||
});
|
||||
info.appendChild(name);
|
||||
info.appendChild(meta);
|
||||
|
||||
const actions = createElement("div", "sshkey-actions");
|
||||
|
||||
const copyBtn = createElement("button", "btn btn-xs", {
|
||||
textContent: "Copy Public Key",
|
||||
});
|
||||
copyBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
copyBtn.addEventListener("click", () => this._copyPublicKey(key));
|
||||
|
||||
const deleteBtn = createElement("button", "btn btn-xs btn-danger", {
|
||||
textContent: "Delete",
|
||||
});
|
||||
deleteBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
deleteBtn.addEventListener("click", () => this._confirmDelete(key));
|
||||
|
||||
actions.appendChild(copyBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
async _copyPublicKey(key) {
|
||||
try {
|
||||
const pubKey = await window.NexusTerminal.wailsService.GetSSHPublicKey(key.name);
|
||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||
window.runtime.ClipboardSetText(pubKey);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(pubKey);
|
||||
}
|
||||
this.app.notifications.success("Public key copied to clipboard");
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to copy: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
_confirmDelete(key) {
|
||||
const confirmed = this._showConfirm(
|
||||
"Delete Key",
|
||||
`Delete key pair "${key.name}"? This cannot be undone.`,
|
||||
"Delete",
|
||||
);
|
||||
confirmed.then((ok) => {
|
||||
if (ok) this._deleteKey(key);
|
||||
});
|
||||
}
|
||||
|
||||
async _deleteKey(key) {
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.DeleteSSHKey(key.name);
|
||||
this.app.notifications.success(`Key "${key.name}" deleted`);
|
||||
this._loadKeys();
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Delete failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate form ────────────────────────────────────────────────
|
||||
|
||||
_showGenerateForm() {
|
||||
const list = this._backdrop.querySelector(".sshkey-list");
|
||||
if (!list) return;
|
||||
|
||||
const form = createElement("div", "sshkey-generate-form");
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Key Name</label>
|
||||
<input type="text" class="form-input sshkey-name-input" placeholder="id_ed25519_nexus" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-input sshkey-type-select">
|
||||
<option value="ed25519" selected>Ed25519 (recommended)</option>
|
||||
<option value="rsa">RSA</option>
|
||||
<option value="ecdsa">ECDSA</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sshkey-bits-group" style="display:none">
|
||||
<label class="form-label">Bits</label>
|
||||
<select class="form-input sshkey-bits-select">
|
||||
<option value="4096" selected>4096</option>
|
||||
<option value="2048">2048</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Passphrase (optional)</label>
|
||||
<input type="password" class="form-input sshkey-pass-input" placeholder="Leave blank for no passphrase" autocomplete="off" />
|
||||
</div>
|
||||
<div class="sshkey-form-actions">
|
||||
<button class="btn btn-sm sshkey-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-sm btn-accent sshkey-create-btn">Generate</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Insert form above the key list
|
||||
list.parentNode.insertBefore(form, list);
|
||||
list.style.display = "none";
|
||||
|
||||
// Show bits field for RSA
|
||||
const typeSelect = form.querySelector(".sshkey-type-select");
|
||||
const bitsGroup = form.querySelector(".sshkey-bits-group");
|
||||
typeSelect.addEventListener("change", () => {
|
||||
bitsGroup.style.display = typeSelect.value === "rsa" ? "" : "none";
|
||||
});
|
||||
|
||||
form.querySelector(".sshkey-cancel-btn").addEventListener("click", () => {
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
});
|
||||
|
||||
form
|
||||
.querySelector(".sshkey-create-btn")
|
||||
.addEventListener("click", async () => {
|
||||
const nameInput = form.querySelector(".sshkey-name-input");
|
||||
const name = nameInput.value.trim();
|
||||
const keyType = typeSelect.value;
|
||||
const bits = parseInt(
|
||||
form.querySelector(".sshkey-bits-select").value,
|
||||
10,
|
||||
);
|
||||
const passphrase = form.querySelector(".sshkey-pass-input").value;
|
||||
|
||||
if (!name) {
|
||||
this.app.notifications.error("Key name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.GenerateSSHKey(
|
||||
name,
|
||||
keyType,
|
||||
bits,
|
||||
passphrase,
|
||||
);
|
||||
this.app.notifications.success(
|
||||
`Key "${name}" generated successfully`,
|
||||
);
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
this._loadKeys();
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Generation failed: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelector(".sshkey-name-input").focus();
|
||||
}
|
||||
|
||||
// ── Confirmation helper ──────────────────────────────────────────
|
||||
|
||||
_showConfirm(title, message, confirmLabel) {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
const header = createElement("div", "modal-header");
|
||||
header.appendChild(
|
||||
createElement("h3", "modal-title", { textContent: title }),
|
||||
);
|
||||
header.appendChild(
|
||||
createElement("button", "btn btn-icon btn-xs modal-close", {
|
||||
textContent: "x",
|
||||
type: "button",
|
||||
"aria-label": "Close",
|
||||
}),
|
||||
);
|
||||
|
||||
const body = createElement("div", "modal-body");
|
||||
const messageEl = createElement("p", "", { textContent: message });
|
||||
messageEl.style.fontSize = "var(--text-sm)";
|
||||
messageEl.style.color = "var(--text-secondary)";
|
||||
body.appendChild(messageEl);
|
||||
|
||||
const footer = createElement("div", "modal-footer");
|
||||
footer.appendChild(
|
||||
createElement("button", "btn btn-sm confirm-cancel", {
|
||||
textContent: "Cancel",
|
||||
type: "button",
|
||||
}),
|
||||
);
|
||||
footer.appendChild(
|
||||
createElement("button", "btn btn-sm btn-danger confirm-ok", {
|
||||
textContent: confirmLabel,
|
||||
type: "button",
|
||||
}),
|
||||
);
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = (val) => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
resolve(val);
|
||||
};
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", () => close(false));
|
||||
modal
|
||||
.querySelector(".confirm-cancel")
|
||||
.addEventListener("click", () => close(false));
|
||||
modal
|
||||
.querySelector(".confirm-ok")
|
||||
.addEventListener("click", () => close(true));
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SSHKeyManager = SSHKeyManager;
|
||||
})();
|
||||
@@ -1,32 +1,38 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — application state management.
|
||||
* Provides a centralised store for profiles, sessions, and preferences,
|
||||
* emitting change events so the UI stays in sync.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — application state management.
|
||||
* Provides a centralised store for profiles, sessions, and preferences,
|
||||
* emitting change events so the UI stays in sync.
|
||||
*/
|
||||
|
||||
class State {
|
||||
class State {
|
||||
constructor() {
|
||||
/** @type {Array<Object>} */
|
||||
this.profiles = [];
|
||||
/** @type {Array<Object>} */
|
||||
this.profiles = [];
|
||||
|
||||
/** @type {Map<string, Object>} sessionId → SessionInfo */
|
||||
this.sessions = new Map();
|
||||
/** @type {Map<string, Object>} sessionId → SessionInfo */
|
||||
this.sessions = new Map();
|
||||
|
||||
/** @type {string|null} */
|
||||
this.activeSessionId = null;
|
||||
/** @type {string|null} */
|
||||
this.activeSessionId = null;
|
||||
|
||||
/** @type {Object} */
|
||||
this.preferences = {};
|
||||
/** @type {Object} */
|
||||
this.preferences = {};
|
||||
|
||||
/** @type {boolean} */
|
||||
this.secretStoreUnlocked = false;
|
||||
/** @type {boolean} */
|
||||
this.secretStoreUnlocked = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.secretStoreInitialised = false;
|
||||
/** @type {boolean} */
|
||||
this.secretStoreInitialised = false;
|
||||
|
||||
/** @type {Map<string, Set<Function>>} event name → callbacks */
|
||||
this._listeners = new Map();
|
||||
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
|
||||
this.broadcastMode = false;
|
||||
|
||||
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
|
||||
this.broadcastTargets = null;
|
||||
|
||||
/** @type {Map<string, Set<Function>>} event name → callbacks */
|
||||
this._listeners = new Map();
|
||||
}
|
||||
|
||||
// ── Event emitter ────────────────────────────────────────────────
|
||||
@@ -37,10 +43,10 @@ class State {
|
||||
* @param {Function} callback
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this._listeners.has(event)) {
|
||||
this._listeners.set(event, new Set());
|
||||
}
|
||||
this._listeners.get(event).add(callback);
|
||||
if (!this._listeners.has(event)) {
|
||||
this._listeners.set(event, new Set());
|
||||
}
|
||||
this._listeners.get(event).add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,10 +55,10 @@ class State {
|
||||
* @param {Function} callback
|
||||
*/
|
||||
off(event, callback) {
|
||||
const set = this._listeners.get(event);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
}
|
||||
const set = this._listeners.get(event);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,16 +67,16 @@ class State {
|
||||
* @param {*} data
|
||||
*/
|
||||
emit(event, data) {
|
||||
const set = this._listeners.get(event);
|
||||
if (set) {
|
||||
for (const cb of set) {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error(`State listener error [${event}]:`, err);
|
||||
}
|
||||
}
|
||||
const set = this._listeners.get(event);
|
||||
if (set) {
|
||||
for (const cb of set) {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error(`State listener error [${event}]:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Profiles ─────────────────────────────────────────────────────
|
||||
@@ -80,8 +86,8 @@ class State {
|
||||
* @param {Array<Object>} profiles
|
||||
*/
|
||||
setProfiles(profiles) {
|
||||
this.profiles = profiles;
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.profiles = profiles;
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +95,9 @@ class State {
|
||||
* @param {Object} profile
|
||||
*/
|
||||
addProfile(profile) {
|
||||
this.profiles.push(profile);
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:added', profile);
|
||||
this.profiles.push(profile);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:added", profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,12 +105,12 @@ class State {
|
||||
* @param {Object} profile
|
||||
*/
|
||||
updateProfile(profile) {
|
||||
const idx = this.profiles.findIndex((p) => p.id === profile.id);
|
||||
if (idx !== -1) {
|
||||
this.profiles[idx] = profile;
|
||||
}
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:updated', profile);
|
||||
const idx = this.profiles.findIndex((p) => p.id === profile.id);
|
||||
if (idx !== -1) {
|
||||
this.profiles[idx] = profile;
|
||||
}
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:updated", profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +118,9 @@ class State {
|
||||
* @param {string} id
|
||||
*/
|
||||
removeProfile(id) {
|
||||
this.profiles = this.profiles.filter((p) => p.id !== id);
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:removed', id);
|
||||
this.profiles = this.profiles.filter((p) => p.id !== id);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:removed", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +129,7 @@ class State {
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
getProfile(id) {
|
||||
return this.profiles.find((p) => p.id === id);
|
||||
return this.profiles.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────
|
||||
@@ -133,9 +139,9 @@ class State {
|
||||
* @param {Object} sessionInfo
|
||||
*/
|
||||
addSession(sessionInfo) {
|
||||
this.sessions.set(sessionInfo.id, sessionInfo);
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:added', sessionInfo);
|
||||
this.sessions.set(sessionInfo.id, sessionInfo);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:added", sessionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,12 +150,12 @@ class State {
|
||||
* @param {Object} updates
|
||||
*/
|
||||
updateSession(id, updates) {
|
||||
const existing = this.sessions.get(id);
|
||||
if (existing) {
|
||||
Object.assign(existing, updates);
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:updated', existing);
|
||||
}
|
||||
const existing = this.sessions.get(id);
|
||||
if (existing) {
|
||||
Object.assign(existing, updates);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:updated", existing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,14 +163,15 @@ class State {
|
||||
* @param {string} id
|
||||
*/
|
||||
removeSession(id) {
|
||||
this.sessions.delete(id);
|
||||
if (this.activeSessionId === id) {
|
||||
// Activate the most recent remaining session, or null
|
||||
const remaining = [...this.sessions.keys()];
|
||||
this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
|
||||
}
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:removed', id);
|
||||
this.sessions.delete(id);
|
||||
if (this.activeSessionId === id) {
|
||||
// Activate the most recent remaining session, or null
|
||||
const remaining = [...this.sessions.keys()];
|
||||
this.activeSessionId =
|
||||
remaining.length > 0 ? remaining[remaining.length - 1] : null;
|
||||
}
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:removed", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +180,7 @@ class State {
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
getSession(id) {
|
||||
return this.sessions.get(id);
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,8 +188,8 @@ class State {
|
||||
* @param {string|null} id
|
||||
*/
|
||||
setActiveSession(id) {
|
||||
this.activeSessionId = id;
|
||||
this.emit('session:activated', id);
|
||||
this.activeSessionId = id;
|
||||
this.emit("session:activated", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +197,7 @@ class State {
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
getAllSessions() {
|
||||
return [...this.sessions.values()];
|
||||
return [...this.sessions.values()];
|
||||
}
|
||||
|
||||
// ── Preferences ──────────────────────────────────────────────────
|
||||
@@ -200,8 +207,8 @@ class State {
|
||||
* @param {Object} prefs
|
||||
*/
|
||||
setPreferences(prefs) {
|
||||
this.preferences = prefs;
|
||||
this.emit('preferences:changed', this.preferences);
|
||||
this.preferences = prefs;
|
||||
this.emit("preferences:changed", this.preferences);
|
||||
}
|
||||
|
||||
// ── Secret store ─────────────────────────────────────────────────
|
||||
@@ -211,8 +218,8 @@ class State {
|
||||
* @param {boolean} unlocked
|
||||
*/
|
||||
setSecretStoreState(unlocked) {
|
||||
this.secretStoreUnlocked = unlocked;
|
||||
this.emit('secretStore:changed', unlocked);
|
||||
this.secretStoreUnlocked = unlocked;
|
||||
this.emit("secretStore:changed", unlocked);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,12 +227,11 @@ class State {
|
||||
* @param {boolean} initialised
|
||||
*/
|
||||
setSecretStoreInitialised(initialised) {
|
||||
this.secretStoreInitialised = initialised;
|
||||
this.emit('secretStore:init', initialised);
|
||||
this.secretStoreInitialised = initialised;
|
||||
this.emit("secretStore:init", initialised);
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.State = State;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.State = State;
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — terminal colour themes.
|
||||
* Provides a collection of preset xterm.js colour schemes and a manager
|
||||
* for applying them to terminal instances.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Preset terminal colour themes.
|
||||
* Each entry contains the full xterm.js theme object.
|
||||
*/
|
||||
const THEMES = {
|
||||
charcoal: {
|
||||
label: "Charcoal (Default)",
|
||||
theme: {
|
||||
background: "#1c1c1c",
|
||||
foreground: "#e4e4e4",
|
||||
cursor: "#4ecdc4",
|
||||
cursorAccent: "#1c1c1c",
|
||||
selectionBackground: "rgba(78, 205, 196, 0.3)",
|
||||
selectionForeground: "#ffffff",
|
||||
black: "#1c1c1c",
|
||||
red: "#ef5350",
|
||||
green: "#66bb6a",
|
||||
yellow: "#ffa726",
|
||||
blue: "#42a5f5",
|
||||
magenta: "#ab47bc",
|
||||
cyan: "#4ecdc4",
|
||||
white: "#e4e4e4",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#ff7043",
|
||||
brightGreen: "#81c784",
|
||||
brightYellow: "#ffcc02",
|
||||
brightBlue: "#64b5f6",
|
||||
brightMagenta: "#ce93d8",
|
||||
brightCyan: "#80deea",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
"solarized-dark": {
|
||||
label: "Solarized Dark",
|
||||
theme: {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#93a1a1",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "rgba(147, 161, 161, 0.3)",
|
||||
selectionForeground: "#fdf6e3",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#586e75",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
monokai: {
|
||||
label: "Monokai",
|
||||
theme: {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f0",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "rgba(73, 72, 62, 0.6)",
|
||||
selectionForeground: "#f8f8f2",
|
||||
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: {
|
||||
label: "Nord",
|
||||
theme: {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "rgba(136, 192, 208, 0.3)",
|
||||
selectionForeground: "#eceff4",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
dracula: {
|
||||
label: "Dracula",
|
||||
theme: {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "rgba(68, 71, 90, 0.6)",
|
||||
selectionForeground: "#f8f8f2",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
"one-dark": {
|
||||
label: "One Dark",
|
||||
theme: {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "rgba(82, 139, 255, 0.3)",
|
||||
selectionForeground: "#abb2bf",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
"gruvbox-dark": {
|
||||
label: "Gruvbox Dark",
|
||||
theme: {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "rgba(214, 93, 14, 0.3)",
|
||||
selectionForeground: "#ebdbb2",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
"tokyo-night": {
|
||||
label: "Tokyo Night",
|
||||
theme: {
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#c0caf5",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "rgba(41, 46, 66, 0.9)",
|
||||
selectionForeground: "#c0caf5",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
"catppuccin-mocha": {
|
||||
label: "Catppuccin Mocha",
|
||||
theme: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "rgba(88, 91, 112, 0.5)",
|
||||
selectionForeground: "#cdd6f4",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
"rose-pine": {
|
||||
label: "Rosé Pine",
|
||||
theme: {
|
||||
background: "#191724",
|
||||
foreground: "#e0def4",
|
||||
cursor: "#524f67",
|
||||
cursorAccent: "#e0def4",
|
||||
selectionBackground: "rgba(110, 106, 134, 0.4)",
|
||||
selectionForeground: "#e0def4",
|
||||
black: "#26233a",
|
||||
red: "#eb6f92",
|
||||
green: "#31748f",
|
||||
yellow: "#f6c177",
|
||||
blue: "#9ccfd8",
|
||||
magenta: "#c4a7e7",
|
||||
cyan: "#ebbcba",
|
||||
white: "#e0def4",
|
||||
brightBlack: "#6e6a86",
|
||||
brightRed: "#eb6f92",
|
||||
brightGreen: "#31748f",
|
||||
brightYellow: "#f6c177",
|
||||
brightBlue: "#9ccfd8",
|
||||
brightMagenta: "#c4a7e7",
|
||||
brightCyan: "#ebbcba",
|
||||
brightWhite: "#e0def4",
|
||||
},
|
||||
},
|
||||
|
||||
"solarized-light": {
|
||||
label: "Solarized Light",
|
||||
theme: {
|
||||
background: "#fdf6e3",
|
||||
foreground: "#657b83",
|
||||
cursor: "#586e75",
|
||||
cursorAccent: "#fdf6e3",
|
||||
selectionBackground: "rgba(7, 54, 66, 0.2)",
|
||||
selectionForeground: "#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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the theme object for the given theme name.
|
||||
* Falls back to 'charcoal' if not found.
|
||||
* @param {string} name
|
||||
* @returns {Object} xterm.js theme object
|
||||
*/
|
||||
function getTerminalTheme(name) {
|
||||
const entry = THEMES[name || "charcoal"];
|
||||
return entry ? entry.theme : THEMES.charcoal.theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all theme names and labels for building a selector.
|
||||
* @returns {Array<{value: string, label: string}>}
|
||||
*/
|
||||
function listTerminalThemes() {
|
||||
return Object.entries(THEMES).map(([value, entry]) => ({
|
||||
value,
|
||||
label: entry.label,
|
||||
}));
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
|
||||
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
|
||||
})();
|
||||
@@ -0,0 +1,381 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — Trigger Rules Engine.
|
||||
* Monitors terminal output and fires actions (highlight, notify, sound)
|
||||
* when patterns match. Rules are stored in localStorage.
|
||||
*/
|
||||
|
||||
const { createElement } = window.NexusTerminal;
|
||||
|
||||
const STORAGE_KEY = "nexus:trigger-rules";
|
||||
|
||||
class TriggerEngine {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this._rules = this._load();
|
||||
this._backdrop = null;
|
||||
// Buffer per session to accumulate partial lines
|
||||
this._buffers = new Map();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks incoming terminal data against all enabled rules.
|
||||
* Called from the session:data event handler.
|
||||
* @param {string} sessionId
|
||||
* @param {string} text - decoded terminal text
|
||||
*/
|
||||
check(sessionId, text) {
|
||||
if (this._rules.length === 0) return;
|
||||
|
||||
// Accumulate text and check line by line
|
||||
const buf = (this._buffers.get(sessionId) || "") + text;
|
||||
const lines = buf.split("\n");
|
||||
// Keep the last incomplete line in the buffer
|
||||
this._buffers.set(sessionId, lines.pop());
|
||||
|
||||
for (const line of lines) {
|
||||
this._checkLine(sessionId, line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the trigger rules management panel.
|
||||
*/
|
||||
open() {
|
||||
if (this._backdrop) return;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the trigger rules panel.
|
||||
*/
|
||||
close() {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.classList.remove("is-visible");
|
||||
const modal = this._backdrop.querySelector(".modal");
|
||||
if (modal) modal.classList.remove("is-visible");
|
||||
setTimeout(() => {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.remove();
|
||||
this._backdrop = null;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up session buffer on disconnect.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
cleanup(sessionId) {
|
||||
this._buffers.delete(sessionId);
|
||||
}
|
||||
|
||||
// ── Rule matching ────────────────────────────────────────────────
|
||||
|
||||
_checkLine(sessionId, line) {
|
||||
// Strip ANSI escape sequences for matching
|
||||
const clean = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
||||
|
||||
for (const rule of this._rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
let matched = false;
|
||||
if (rule.isRegex) {
|
||||
try {
|
||||
const re = new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
|
||||
matched = re.test(clean);
|
||||
} catch (_) {
|
||||
// Invalid regex — skip
|
||||
}
|
||||
} else {
|
||||
const haystack = rule.caseSensitive ? clean : clean.toLowerCase();
|
||||
const needle = rule.caseSensitive
|
||||
? rule.pattern
|
||||
: rule.pattern.toLowerCase();
|
||||
matched = haystack.includes(needle);
|
||||
}
|
||||
|
||||
if (!matched) continue;
|
||||
|
||||
// Fire actions
|
||||
if (rule.action === "notify" || rule.action === "both") {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const label = session
|
||||
? session.tabName || session.profileName
|
||||
: sessionId;
|
||||
this.app.notifications.info(
|
||||
`[${label}] Trigger: ${rule.name || rule.pattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.action === "highlight" || rule.action === "both") {
|
||||
// Flash the tab briefly
|
||||
const tabEl = document.querySelector(
|
||||
`.tab[data-session-id="${sessionId}"]`,
|
||||
);
|
||||
if (tabEl) {
|
||||
tabEl.classList.add("tab-trigger-flash");
|
||||
setTimeout(() => tabEl.classList.remove("tab-trigger-flash"), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.action === "sound") {
|
||||
this._playBeep();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_playBeep() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
gain.gain.value = 0.1;
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.15);
|
||||
} catch (_) {
|
||||
// Audio not available — silent
|
||||
}
|
||||
}
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
_save() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._rules));
|
||||
} catch (_) {
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI ───────────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Trigger Rules</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-3)">
|
||||
Rules match against terminal output and fire actions when a pattern is detected.
|
||||
</p>
|
||||
<div class="trigger-toolbar">
|
||||
<button class="btn btn-sm btn-accent trigger-add-btn">Add Rule</button>
|
||||
</div>
|
||||
<div class="trigger-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
this._backdrop = backdrop;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", () => this.close());
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) this.close();
|
||||
});
|
||||
modal
|
||||
.querySelector(".trigger-add-btn")
|
||||
.addEventListener("click", () => this._addRule());
|
||||
|
||||
this._renderList();
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
const list = this._backdrop.querySelector(".trigger-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
if (this._rules.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="trigger-empty">No trigger rules defined</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this._rules.length; i++) {
|
||||
list.appendChild(this._renderRuleRow(this._rules[i], i));
|
||||
}
|
||||
}
|
||||
|
||||
_renderRuleRow(rule, index) {
|
||||
const row = createElement("div", "trigger-row");
|
||||
|
||||
const toggle = createElement("input", "trigger-toggle");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = rule.enabled;
|
||||
toggle.addEventListener("change", () => {
|
||||
this._rules[index].enabled = toggle.checked;
|
||||
this._save();
|
||||
});
|
||||
|
||||
const info = createElement("div", "trigger-info");
|
||||
const name = createElement("div", "trigger-name", {
|
||||
textContent: rule.name || rule.pattern,
|
||||
});
|
||||
const meta = createElement("div", "trigger-meta", {
|
||||
textContent: `${rule.isRegex ? "Regex" : "Text"} · ${rule.action} · ${rule.caseSensitive ? "Case-sensitive" : "Case-insensitive"}`,
|
||||
});
|
||||
info.appendChild(name);
|
||||
info.appendChild(meta);
|
||||
|
||||
const pattern = createElement("code", "trigger-pattern", {
|
||||
textContent: rule.pattern,
|
||||
});
|
||||
|
||||
const actions = createElement("div", "trigger-actions");
|
||||
const editBtn = createElement("button", "btn btn-xs", {
|
||||
textContent: "Edit",
|
||||
});
|
||||
editBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
editBtn.addEventListener("click", () => this._editRule(index));
|
||||
|
||||
const delBtn = createElement("button", "btn btn-xs btn-danger", {
|
||||
textContent: "✕",
|
||||
});
|
||||
delBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
delBtn.addEventListener("click", () => {
|
||||
this._rules.splice(index, 1);
|
||||
this._save();
|
||||
this._renderList();
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(info);
|
||||
row.appendChild(pattern);
|
||||
row.appendChild(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
_addRule() {
|
||||
this._showRuleForm(null, (rule) => {
|
||||
this._rules.push(rule);
|
||||
this._save();
|
||||
this._renderList();
|
||||
});
|
||||
}
|
||||
|
||||
_editRule(index) {
|
||||
this._showRuleForm(this._rules[index], (rule) => {
|
||||
this._rules[index] = rule;
|
||||
this._save();
|
||||
this._renderList();
|
||||
});
|
||||
}
|
||||
|
||||
_showRuleForm(existing, onSave) {
|
||||
const list = this._backdrop.querySelector(".trigger-list");
|
||||
const toolbar = this._backdrop.querySelector(".trigger-toolbar");
|
||||
if (!list) return;
|
||||
|
||||
// Hide list and toolbar
|
||||
list.style.display = "none";
|
||||
if (toolbar) toolbar.style.display = "none";
|
||||
|
||||
const form = createElement("div", "trigger-form");
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name (optional)</label>
|
||||
<input type="text" class="form-input trigger-name-input" placeholder="e.g. Error alert" value="${existing ? existing.name || "" : ""}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pattern</label>
|
||||
<input type="text" class="form-input trigger-pattern-input" placeholder="e.g. ERROR|FATAL|panic" value="${existing ? existing.pattern : ""}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Match Type</label>
|
||||
<select class="form-input trigger-type-select">
|
||||
<option value="text"${existing && !existing.isRegex ? " selected" : ""}>Plain text</option>
|
||||
<option value="regex"${existing && existing.isRegex ? " selected" : ""}>Regex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Action</label>
|
||||
<select class="form-input trigger-action-select">
|
||||
<option value="notify"${existing && existing.action === "notify" ? " selected" : ""}>Notify</option>
|
||||
<option value="highlight"${existing && existing.action === "highlight" ? " selected" : ""}>Highlight tab</option>
|
||||
<option value="both"${existing && existing.action === "both" ? " selected" : ""}>Notify + Highlight</option>
|
||||
<option value="sound"${existing && existing.action === "sound" ? " selected" : ""}>Sound</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" class="trigger-case-check"${existing && existing.caseSensitive ? " checked" : ""} />
|
||||
Case-sensitive
|
||||
</label>
|
||||
</div>
|
||||
<div class="trigger-form-actions">
|
||||
<button class="btn btn-sm trigger-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-sm btn-accent trigger-save-btn">${existing ? "Save" : "Add"}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.parentNode.insertBefore(form, list);
|
||||
|
||||
form
|
||||
.querySelector(".trigger-cancel-btn")
|
||||
.addEventListener("click", () => {
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
if (toolbar) toolbar.style.display = "";
|
||||
});
|
||||
|
||||
form.querySelector(".trigger-save-btn").addEventListener("click", () => {
|
||||
const pattern = form
|
||||
.querySelector(".trigger-pattern-input")
|
||||
.value.trim();
|
||||
if (!pattern) {
|
||||
this.app.notifications.error("Pattern is required");
|
||||
return;
|
||||
}
|
||||
const rule = {
|
||||
name: form.querySelector(".trigger-name-input").value.trim(),
|
||||
pattern: pattern,
|
||||
isRegex: form.querySelector(".trigger-type-select").value === "regex",
|
||||
action: form.querySelector(".trigger-action-select").value,
|
||||
caseSensitive: form.querySelector(".trigger-case-check").checked,
|
||||
enabled: existing ? existing.enabled : true,
|
||||
};
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
if (toolbar) toolbar.style.display = "";
|
||||
onSave(rule);
|
||||
});
|
||||
|
||||
form.querySelector(".trigger-pattern-input").focus();
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.TriggerEngine = TriggerEngine;
|
||||
})();
|
||||
@@ -1,120 +1,178 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — utility functions.
|
||||
* General-purpose helpers used throughout the application.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — utility functions.
|
||||
* General-purpose helpers used throughout the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shorthand for document.querySelector.
|
||||
* @param {string} selector - CSS selector.
|
||||
* @returns {Element|null}
|
||||
*/
|
||||
function $(selector) {
|
||||
/**
|
||||
* Shorthand for document.querySelector.
|
||||
* @param {string} selector - CSS selector.
|
||||
* @returns {Element|null}
|
||||
*/
|
||||
function $(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for document.querySelectorAll.
|
||||
* @param {string} selector - CSS selector.
|
||||
* @returns {NodeListOf<Element>}
|
||||
*/
|
||||
function $$(selector) {
|
||||
/**
|
||||
* Shorthand for document.querySelectorAll.
|
||||
* @param {string} selector - CSS selector.
|
||||
* @returns {NodeListOf<Element>}
|
||||
*/
|
||||
function $$(selector) {
|
||||
return document.querySelectorAll(selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DOM element with optional class name and attributes.
|
||||
* @param {string} tag - HTML tag name.
|
||||
* @param {string} [className] - Space-separated class names.
|
||||
* @param {Object} [attrs] - Attribute key/value pairs.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createElement(tag, className, attrs = {}) {
|
||||
/**
|
||||
* Creates a DOM element with optional class name and attributes.
|
||||
* @param {string} tag - HTML tag name.
|
||||
* @param {string} [className] - Space-separated class names.
|
||||
* @param {Object} [attrs] - Attribute key/value pairs.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createElement(tag, className, attrs = {}) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) {
|
||||
el.className = className;
|
||||
el.className = className;
|
||||
}
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'textContent') {
|
||||
el.textContent = value;
|
||||
} else if (key === 'innerHTML') {
|
||||
el.innerHTML = value;
|
||||
} else if (key === 'dataset') {
|
||||
for (const [dKey, dVal] of Object.entries(value)) {
|
||||
el.dataset[dKey] = dVal;
|
||||
}
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else {
|
||||
el.setAttribute(key, value);
|
||||
if (key === "textContent") {
|
||||
el.textContent = value;
|
||||
} else if (key === "innerHTML") {
|
||||
el.innerHTML = value;
|
||||
} else if (key === "dataset") {
|
||||
for (const [dKey, dVal] of Object.entries(value)) {
|
||||
el.dataset[dKey] = dVal;
|
||||
}
|
||||
} else if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS.
|
||||
* @param {string} str - Raw string.
|
||||
* @returns {string} Escaped string safe for innerHTML.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
/**
|
||||
* Creates a standard modal skeleton without HTML string interpolation.
|
||||
* @param {string} title
|
||||
* @param {string} [sizeClass]
|
||||
* @returns {{backdrop: HTMLElement, modal: HTMLElement, body: HTMLElement, footer: HTMLElement, closeButton: HTMLElement, show: Function, remove: Function}}
|
||||
*/
|
||||
function createModalShell(title, sizeClass = "modal-sm") {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", `modal ${sizeClass}`);
|
||||
const header = createElement("div", "modal-header");
|
||||
const titleEl = createElement("h3", "modal-title", { textContent: title });
|
||||
const closeButton = createElement("button", "btn btn-icon btn-xs modal-close", {
|
||||
textContent: "x",
|
||||
"aria-label": "Close",
|
||||
});
|
||||
const body = createElement("div", "modal-body");
|
||||
const footer = createElement("div", "modal-footer");
|
||||
|
||||
header.appendChild(titleEl);
|
||||
header.appendChild(closeButton);
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(body);
|
||||
modal.appendChild(footer);
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
return {
|
||||
backdrop,
|
||||
modal,
|
||||
body,
|
||||
footer,
|
||||
closeButton,
|
||||
show() {
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
},
|
||||
remove() {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS.
|
||||
* @param {string} str - Raw string.
|
||||
* @returns {string} Escaped string safe for innerHTML.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return str.replace(/[&<>"']/g, (ch) => map[ch]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Date (or ISO string) into a concise timestamp.
|
||||
* @param {Date|string} date
|
||||
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
|
||||
*/
|
||||
function formatTimestamp(date) {
|
||||
if (!date) return '';
|
||||
/**
|
||||
* Formats a Date (or ISO string) into a concise timestamp.
|
||||
* @param {Date|string} date
|
||||
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
|
||||
*/
|
||||
function formatTimestamp(date) {
|
||||
if (!date) return "";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
if (isNaN(d.getTime())) return "";
|
||||
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const mins = String(d.getMinutes()).padStart(2, '0');
|
||||
const secs = String(d.getSeconds()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
const secs = String(d.getSeconds()).padStart(2, "0");
|
||||
|
||||
if (isToday) {
|
||||
return `${hours}:${mins}:${secs}`;
|
||||
return `${hours}:${mins}:${secs}`;
|
||||
}
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a debounced version of the given function.
|
||||
* @param {Function} fn
|
||||
* @param {number} ms - Delay in milliseconds.
|
||||
* @returns {Function}
|
||||
*/
|
||||
function debounce(fn, ms) {
|
||||
/**
|
||||
* Returns a debounced version of the given function.
|
||||
* @param {Function} fn
|
||||
* @param {number} ms - Delay in milliseconds.
|
||||
* @returns {Function}
|
||||
*/
|
||||
function debounce(fn, ms) {
|
||||
let timer = null;
|
||||
return function (...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), ms);
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), ms);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a simple fuzzy match — checks whether every character in
|
||||
* the query appears in order within the text (case-insensitive).
|
||||
* @param {string} text
|
||||
* @param {string} query
|
||||
* @returns {{ match: boolean, score: number }}
|
||||
*/
|
||||
function fuzzyMatch(text, query) {
|
||||
/**
|
||||
* Performs a simple fuzzy match — checks whether every character in
|
||||
* the query appears in order within the text (case-insensitive).
|
||||
* @param {string} text
|
||||
* @param {string} query
|
||||
* @returns {{ match: boolean, score: number }}
|
||||
*/
|
||||
function fuzzyMatch(text, query) {
|
||||
if (!query) return { match: true, score: 1 };
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
@@ -124,139 +182,221 @@ function fuzzyMatch(text, query) {
|
||||
let consecutiveBonus = 0;
|
||||
|
||||
for (let qi = 0; qi < lowerQuery.length; qi++) {
|
||||
const ch = lowerQuery[qi];
|
||||
const foundAt = lowerText.indexOf(ch, textIdx);
|
||||
if (foundAt === -1) {
|
||||
return { match: false, score: 0 };
|
||||
}
|
||||
// Reward consecutive character matches and early matches
|
||||
if (foundAt === textIdx) {
|
||||
consecutiveBonus += 1;
|
||||
} else {
|
||||
consecutiveBonus = 0;
|
||||
}
|
||||
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
|
||||
textIdx = foundAt + 1;
|
||||
const ch = lowerQuery[qi];
|
||||
const foundAt = lowerText.indexOf(ch, textIdx);
|
||||
if (foundAt === -1) {
|
||||
return { match: false, score: 0 };
|
||||
}
|
||||
// Reward consecutive character matches and early matches
|
||||
if (foundAt === textIdx) {
|
||||
consecutiveBonus += 1;
|
||||
} else {
|
||||
consecutiveBonus = 0;
|
||||
}
|
||||
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
|
||||
textIdx = foundAt + 1;
|
||||
}
|
||||
|
||||
return { match: true, score };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-encodes a string.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Encode(str) {
|
||||
/**
|
||||
* Base64-encodes a string.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Encode(str) {
|
||||
return btoa(str);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-decodes a string.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Decode(str) {
|
||||
/**
|
||||
* UTF-8-encodes a string and then base64-encodes the bytes.
|
||||
* Unlike bare btoa(), this handles characters above U+007F correctly.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function utf8ToBase64(str) {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64-decodes a string.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Decode(str) {
|
||||
return atob(str);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new UUID v4 using the browser crypto API.
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateId() {
|
||||
/**
|
||||
* Generates a new UUID v4 using the browser crypto API.
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a byte count into a human-friendly string.
|
||||
* @param {number} bytes
|
||||
* @returns {string} e.g. "1.23 KB"
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
/**
|
||||
* Formats a byte count into a human-friendly string.
|
||||
* @param {number} bytes
|
||||
* @returns {string} e.g. "1.23 KB"
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a string to the given length, appending an ellipsis if needed.
|
||||
* @param {string} str
|
||||
* @param {number} len
|
||||
* @returns {string}
|
||||
*/
|
||||
function truncate(str, len) {
|
||||
if (!str || str.length <= len) return str || '';
|
||||
return str.slice(0, len) + '…';
|
||||
}
|
||||
/**
|
||||
* Truncates a string to the given length, appending an ellipsis if needed.
|
||||
* @param {string} str
|
||||
* @param {number} len
|
||||
* @returns {string}
|
||||
*/
|
||||
function truncate(str, len) {
|
||||
if (!str || str.length <= len) return str || "";
|
||||
return str.slice(0, len) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any open context menus from the DOM.
|
||||
*/
|
||||
function dismissContextMenus() {
|
||||
document.querySelectorAll('.context-menu').forEach((m) => m.remove());
|
||||
}
|
||||
/**
|
||||
* Removes any open context menus from the DOM.
|
||||
*/
|
||||
function dismissContextMenus() {
|
||||
document.querySelectorAll(".context-menu").forEach((m) => m.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the protocol badge colour class.
|
||||
* @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'.
|
||||
* @returns {string}
|
||||
*/
|
||||
function protocolColour(protocol) {
|
||||
/**
|
||||
* Returns the protocol badge colour class.
|
||||
* @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'.
|
||||
* @returns {string}
|
||||
*/
|
||||
function protocolColour(protocol) {
|
||||
switch (protocol) {
|
||||
case 'ssh': return 'proto-ssh';
|
||||
case 'telnet': return 'proto-telnet';
|
||||
case 'raw': return 'proto-raw';
|
||||
case 'github': return 'proto-github';
|
||||
default: return '';
|
||||
case "ssh":
|
||||
return "proto-ssh";
|
||||
case "telnet":
|
||||
return "proto-telnet";
|
||||
case "raw":
|
||||
return "proto-raw";
|
||||
case "github":
|
||||
return "proto-github";
|
||||
case "vnc":
|
||||
return "proto-vnc";
|
||||
case "irc":
|
||||
return "proto-irc";
|
||||
case "local":
|
||||
return "proto-local";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-friendly relative time string for an ISO date.
|
||||
* @param {string} isoDate - ISO 8601 date string.
|
||||
* @returns {string} e.g. "3 hours ago", "2 days ago".
|
||||
*/
|
||||
function relativeTime(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
/**
|
||||
* Returns a human-friendly relative time string for an ISO date.
|
||||
* @param {string} isoDate - ISO 8601 date string.
|
||||
* @returns {string} e.g. "3 hours ago", "2 days ago".
|
||||
*/
|
||||
function relativeTime(isoDate) {
|
||||
if (!isoDate) return "";
|
||||
const d = new Date(isoDate);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
if (isNaN(d.getTime())) return "";
|
||||
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
if (secs < 60) return "just now";
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
|
||||
if (mins < 60) return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`;
|
||||
if (hrs < 24) return hrs === 1 ? "1 hour ago" : `${hrs} hours ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||
if (days < 30) return days === 1 ? "1 day ago" : `${days} days ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`;
|
||||
if (months < 12)
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
const years = Math.floor(months / 12);
|
||||
return years === 1 ? '1 year ago' : `${years} years ago`;
|
||||
}
|
||||
return years === 1 ? "1 year ago" : `${years} years ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-friendly label for a session state.
|
||||
* @param {string} state
|
||||
* @returns {string}
|
||||
*/
|
||||
function stateLabel(state) {
|
||||
/**
|
||||
* Returns a human-friendly label for a session state.
|
||||
* @param {string} state
|
||||
* @returns {string}
|
||||
*/
|
||||
function stateLabel(state) {
|
||||
switch (state) {
|
||||
case 'connecting': return 'Connecting';
|
||||
case 'connected': return 'Connected';
|
||||
case 'disconnected': return 'Disconnected';
|
||||
case 'error': return 'Error';
|
||||
default: return state || 'Unknown';
|
||||
case "connecting":
|
||||
return "Connecting";
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "disconnected":
|
||||
return "Disconnected";
|
||||
case "reconnecting":
|
||||
return "Reconnecting";
|
||||
case "error":
|
||||
return "Error";
|
||||
default:
|
||||
return state || "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel, relativeTime });
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
Object.assign(window.NexusTerminal, {
|
||||
$,
|
||||
$$,
|
||||
createElement,
|
||||
createModalShell,
|
||||
escapeHtml,
|
||||
formatTimestamp,
|
||||
debounce,
|
||||
fuzzyMatch,
|
||||
base64Encode,
|
||||
utf8ToBase64,
|
||||
base64Decode,
|
||||
generateId,
|
||||
formatBytes,
|
||||
truncate,
|
||||
dismissContextMenus,
|
||||
protocolColour,
|
||||
stateLabel,
|
||||
relativeTime,
|
||||
});
|
||||
|
||||
/* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */
|
||||
function disableAutoCapitalise(el) {
|
||||
el.setAttribute("autocapitalize", "none");
|
||||
el.setAttribute("autocorrect", "off");
|
||||
el.setAttribute("spellcheck", "false");
|
||||
}
|
||||
|
||||
// Apply to existing inputs
|
||||
document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise);
|
||||
|
||||
// Observe for dynamically added inputs
|
||||
var inputObserver = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var nodes = mutations[i].addedNodes;
|
||||
for (var j = 0; j < nodes.length; j++) {
|
||||
var node = nodes[j];
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
|
||||
disableAutoCapitalise(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node
|
||||
.querySelectorAll("input, textarea")
|
||||
.forEach(disableAutoCapitalise);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
inputObserver.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — VNC viewer.
|
||||
* Renders a noVNC canvas inside a session panel, connecting to the local
|
||||
* WebSocket proxy started by the Go backend.
|
||||
*/
|
||||
|
||||
const { createElement, $ } = window.NexusTerminal;
|
||||
|
||||
class VNCViewer {
|
||||
constructor() {
|
||||
/** @type {Map<string, VNCViewerInstance>} sessionID → viewer */
|
||||
this._viewers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a VNC viewer panel for a session inside the given container.
|
||||
* @param {string} sessionID
|
||||
* @param {HTMLElement} container - The .session-panel element.
|
||||
* @param {object} profile - The profile object with VNC config.
|
||||
*/
|
||||
createViewer(sessionID, container, profile) {
|
||||
if (this._viewers.has(sessionID)) return;
|
||||
if (!container) return;
|
||||
|
||||
const viewer = new VNCViewerInstance(sessionID, container, profile);
|
||||
this._viewers.set(sessionID, viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects the viewer to the WebSocket URL from the backend.
|
||||
* @param {string} sessionID
|
||||
* @param {string} wsURL
|
||||
*/
|
||||
connect(sessionID, wsURL) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.connect(wsURL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys a viewer for the given session.
|
||||
* @param {string} sessionID
|
||||
*/
|
||||
destroyViewer(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
this._viewers.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the VNC display for the given session.
|
||||
* @param {string} sessionID
|
||||
*/
|
||||
resize(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.resize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VNCViewerInstance {
|
||||
/**
|
||||
* @param {string} sessionID
|
||||
* @param {HTMLElement} container
|
||||
* @param {object} profile
|
||||
*/
|
||||
constructor(sessionID, container, profile) {
|
||||
this.sessionID = sessionID;
|
||||
this.container = container;
|
||||
this.profile = profile;
|
||||
this.rfb = null;
|
||||
|
||||
this._buildUI();
|
||||
}
|
||||
|
||||
_buildUI() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.classList.add("vnc-viewer-panel");
|
||||
|
||||
// Toolbar
|
||||
this.toolbar = createElement("div", "vnc-toolbar");
|
||||
|
||||
const ctrlAltDel = createElement("button", "btn btn-sm btn-vnc-action", {
|
||||
textContent: "Ctrl+Alt+Del",
|
||||
title: "Send Ctrl+Alt+Del",
|
||||
});
|
||||
ctrlAltDel.addEventListener("click", () => this._sendCtrlAltDel());
|
||||
|
||||
const clipboardBtn = createElement(
|
||||
"button",
|
||||
"btn btn-sm btn-vnc-action",
|
||||
{
|
||||
textContent: "Clipboard",
|
||||
title: "Sync clipboard",
|
||||
},
|
||||
);
|
||||
clipboardBtn.addEventListener("click", () => this._syncClipboard());
|
||||
|
||||
const fullscreenBtn = createElement(
|
||||
"button",
|
||||
"btn btn-sm btn-vnc-action",
|
||||
{
|
||||
textContent: "Fullscreen",
|
||||
title: "Toggle fullscreen",
|
||||
},
|
||||
);
|
||||
fullscreenBtn.addEventListener("click", () => this._toggleFullscreen());
|
||||
|
||||
this.statusEl = createElement("span", "vnc-status", {
|
||||
textContent: "Connecting…",
|
||||
});
|
||||
|
||||
this.toolbar.appendChild(this.statusEl);
|
||||
this.toolbar.appendChild(ctrlAltDel);
|
||||
this.toolbar.appendChild(clipboardBtn);
|
||||
this.toolbar.appendChild(fullscreenBtn);
|
||||
|
||||
// VNC display container
|
||||
this.displayEl = createElement("div", "vnc-display");
|
||||
|
||||
this.container.appendChild(this.toolbar);
|
||||
this.container.appendChild(this.displayEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the VNC WebSocket proxy.
|
||||
* @param {string} wsURL - WebSocket URL (ws://127.0.0.1:<port>)
|
||||
*/
|
||||
connect(wsURL) {
|
||||
if (!window.noVNC || !window.noVNC.RFB) {
|
||||
this.statusEl.textContent = "Error: noVNC not loaded";
|
||||
this.statusEl.classList.add("vnc-status-error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const vncConfig = this.profile.vnc || {};
|
||||
|
||||
this.rfb = new window.noVNC.RFB(this.displayEl, wsURL, {
|
||||
shared: vncConfig.sharedSession !== false,
|
||||
credentials: {},
|
||||
});
|
||||
|
||||
this.rfb.viewOnly = !!vncConfig.viewOnly;
|
||||
this.rfb.scaleViewport = vncConfig.scaleToFit !== false;
|
||||
this.rfb.resizeSession = false;
|
||||
this.rfb.clipViewport = false;
|
||||
this.rfb.qualityLevel = vncConfig.quality || 6;
|
||||
this.rfb.compressionLevel = 2;
|
||||
|
||||
// Event handlers
|
||||
this.rfb.addEventListener("connect", () => {
|
||||
this.statusEl.textContent = "Connected";
|
||||
this.statusEl.classList.remove("vnc-status-error");
|
||||
this.statusEl.classList.add("vnc-status-connected");
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("disconnect", (ev) => {
|
||||
const clean = ev.detail.clean;
|
||||
this.statusEl.textContent = clean
|
||||
? "Disconnected"
|
||||
: "Connection lost";
|
||||
this.statusEl.classList.remove("vnc-status-connected");
|
||||
if (!clean) {
|
||||
this.statusEl.classList.add("vnc-status-error");
|
||||
}
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("credentialsrequired", () => {
|
||||
this.statusEl.textContent = "Credentials required";
|
||||
// TODO: prompt for VNC password via modal
|
||||
this._promptCredentials();
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("desktopname", (ev) => {
|
||||
this.statusEl.textContent = "Connected — " + ev.detail.name;
|
||||
});
|
||||
} catch (err) {
|
||||
this.statusEl.textContent = "Error: " + err.message;
|
||||
this.statusEl.classList.add("vnc-status-error");
|
||||
}
|
||||
}
|
||||
|
||||
_sendCtrlAltDel() {
|
||||
if (this.rfb) {
|
||||
this.rfb.sendCtrlAltDel();
|
||||
}
|
||||
}
|
||||
|
||||
async _syncClipboard() {
|
||||
if (!this.rfb) return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
this.rfb.clipboardPasteFrom(text);
|
||||
}
|
||||
} catch (_) {
|
||||
// Clipboard API may not be available in WKWebView
|
||||
}
|
||||
}
|
||||
|
||||
_toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
this.container.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
_promptCredentials() {
|
||||
// Build a simple modal for VNC password
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">VNC Password</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-input" id="vnc-password-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="vnc-password-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="vnc-password-submit">Connect</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.appendChild(modal);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const input = modal.querySelector("#vnc-password-input");
|
||||
const submit = modal.querySelector("#vnc-password-submit");
|
||||
const cancel = modal.querySelector("#vnc-password-cancel");
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => {
|
||||
backdrop.remove();
|
||||
modal.remove();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
submit.addEventListener("click", () => {
|
||||
const pw = input.value;
|
||||
close();
|
||||
if (this.rfb && pw) {
|
||||
this.rfb.sendCredentials({ password: pw });
|
||||
}
|
||||
});
|
||||
|
||||
cancel.addEventListener("click", () => {
|
||||
close();
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit.click();
|
||||
if (e.key === "Escape") cancel.click();
|
||||
});
|
||||
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
resize() {
|
||||
// noVNC handles resizing internally via ResizeObserver on its canvas,
|
||||
// but we can trigger a re-scale if needed.
|
||||
if (this.rfb && this.rfb.scaleViewport) {
|
||||
this.rfb.scaleViewport = true;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.rfb) {
|
||||
this.rfb.disconnect();
|
||||
this.rfb = null;
|
||||
}
|
||||
this.container.innerHTML = "";
|
||||
this.container.classList.remove("vnc-viewer-panel");
|
||||
}
|
||||
}
|
||||
|
||||
// Export to global namespace.
|
||||
window.NexusTerminal.VNCViewer = new VNCViewer();
|
||||
})();
|
||||
@@ -0,0 +1,28 @@
|
||||
(function () {
|
||||
const root = (window.NexusTerminal = window.NexusTerminal || {});
|
||||
|
||||
const directService = () => window.go?.api?.Service || null;
|
||||
const unavailable = (name) =>
|
||||
Promise.reject(new Error(`Wails service method is not available: ${String(name)}`));
|
||||
|
||||
root.wailsService = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "then") return undefined;
|
||||
const service = directService();
|
||||
const value = service?.[prop];
|
||||
if (typeof value === "function") return value.bind(service);
|
||||
if (value !== undefined) return value;
|
||||
return () => unavailable(prop);
|
||||
},
|
||||
has(_target, prop) {
|
||||
return prop in (directService() || {});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
root.wailsServiceReady = Promise.resolve(root.wailsService);
|
||||
root.hasWailsService = () =>
|
||||
Boolean(root.wailsService && window.runtime && directService());
|
||||
})();
|
||||
@@ -1,52 +1,143 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {models} from '../models';
|
||||
import {api} from '../models';
|
||||
import {context} from '../models';
|
||||
|
||||
export function BrowseForShell():Promise<string>;
|
||||
|
||||
export function CancelReconnect(arg1:string):Promise<void>;
|
||||
|
||||
export function CheckForUpdates():Promise<Record<string, any>>;
|
||||
|
||||
export function CleanupRecordings(arg1:number,arg2:number):Promise<Record<string, any>>;
|
||||
|
||||
export function ClearSessionHistory(arg1:string):Promise<void>;
|
||||
|
||||
export function CloseAllSessions():Promise<void>;
|
||||
|
||||
export function ConnectLocal():Promise<models.SessionInfo>;
|
||||
|
||||
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
|
||||
|
||||
export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
|
||||
|
||||
export function CreateProfile(arg1:models.Profile):Promise<models.Profile>;
|
||||
|
||||
export function CreateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
|
||||
|
||||
export function DeleteKnownHost(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteProfile(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteProfileSecrets(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteRecording(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteSSHKey(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteSnippet(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteTOTPSecret(arg1:string):Promise<void>;
|
||||
|
||||
export function DisconnectSession(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||
|
||||
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
||||
|
||||
export function ExportProfiles(arg1:boolean):Promise<string>;
|
||||
|
||||
export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>;
|
||||
|
||||
export function FileExists(arg1:string):Promise<boolean>;
|
||||
|
||||
export function FileIsPrivateKey(arg1:string):Promise<boolean>;
|
||||
|
||||
export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
|
||||
|
||||
export function GetAppInfo():Promise<Record<string, string>>;
|
||||
|
||||
export function GetDefaultShell():Promise<string>;
|
||||
|
||||
export function GetPreferences():Promise<models.AppPreferences>;
|
||||
|
||||
export function GetProfile(arg1:string):Promise<models.Profile>;
|
||||
|
||||
export function GetRecording(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function GetRecordingStorageInfo():Promise<Record<string, any>>;
|
||||
|
||||
export function GetSSHPublicKey(arg1:string):Promise<string>;
|
||||
|
||||
export function GetSessionCwd(arg1:string):Promise<string>;
|
||||
|
||||
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetTOTPCode(arg1:string):Promise<string>;
|
||||
|
||||
export function GetVNCWebSocketURL(arg1:string):Promise<string>;
|
||||
|
||||
export function HasTOTPSecret(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function IRCJoin(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function IRCListRooms(arg1:string):Promise<void>;
|
||||
|
||||
export function IRCPart(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function IRCRooms(arg1:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function IRCUsers(arg1:string,arg2:string):Promise<Array<string>>;
|
||||
|
||||
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ImportSSHConfig(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function InitialiseSecretStore(arg1:string):Promise<void>;
|
||||
|
||||
export function IsRecording(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IsSecretStoreInitialised():Promise<boolean>;
|
||||
|
||||
export function IsSecretStoreUnlocked():Promise<boolean>;
|
||||
|
||||
export function LeviathanExportJSON(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||
|
||||
export function LeviathanExportRange(arg1:string,arg2:number,arg3:number):Promise<string>;
|
||||
|
||||
export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanOpenCompare():Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanOpenFile():Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanRedo():Promise<api.leviathanDocState>;
|
||||
|
||||
export function LeviathanRevert():Promise<api.leviathanDocState>;
|
||||
|
||||
export function LeviathanRunTool(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanSaveWorking():Promise<string>;
|
||||
|
||||
export function LeviathanSetByte(arg1:number,arg2:string):Promise<Record<string, any>>;
|
||||
|
||||
export function LeviathanState():Promise<api.leviathanDocState>;
|
||||
|
||||
export function LeviathanUndo():Promise<api.leviathanDocState>;
|
||||
|
||||
export function ListAuditEntries(arg1:number):Promise<Array<models.AuditEntry>>;
|
||||
|
||||
export function ListFavourites():Promise<Array<models.Profile>>;
|
||||
|
||||
export function ListKnownHosts():Promise<Array<models.KnownHost>>;
|
||||
|
||||
export function ListPortForwards(arg1:string):Promise<Array<models.PortForward>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<models.Profile>>;
|
||||
|
||||
export function ListProfilesByFolder(arg1:string):Promise<Array<models.Profile>>;
|
||||
@@ -55,14 +146,24 @@ export function ListProfilesByProtocol(arg1:string):Promise<Array<models.Profile
|
||||
|
||||
export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
|
||||
|
||||
export function ListRecordings():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function ListSSHKeyPairs():Promise<Array<api.SSHKeyInfo>>;
|
||||
|
||||
export function ListSSHKeys():Promise<Array<string>>;
|
||||
|
||||
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
||||
|
||||
export function ListSnippets():Promise<Array<models.Snippet>>;
|
||||
|
||||
export function LockSecretStore():Promise<void>;
|
||||
|
||||
export function NewProfile(arg1:string):Promise<models.Profile>;
|
||||
|
||||
export function PinSession(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function PingSession(arg1:string):Promise<number>;
|
||||
|
||||
export function ReconnectSession(arg1:string):Promise<void>;
|
||||
|
||||
export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>;
|
||||
@@ -73,6 +174,32 @@ export function RenameSession(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
||||
|
||||
export function SFTPDownload(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SFTPList(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function SFTPMkdir(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SFTPPwd(arg1:string):Promise<string>;
|
||||
|
||||
export function SFTPRemove(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function SFTPStat(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||
|
||||
export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadCancel(arg1:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadChunk(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadFinish(arg1:string):Promise<void>;
|
||||
|
||||
export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise<string>;
|
||||
|
||||
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
|
||||
@@ -91,12 +218,24 @@ export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||
|
||||
export function SetTOTPSecret(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function StartPortForward(arg1:string,arg2:models.PortForward):Promise<void>;
|
||||
|
||||
export function StartRecording(arg1:string,arg2:number,arg3:number):Promise<string>;
|
||||
|
||||
export function StopPortForward(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function StopRecording(arg1:string):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
|
||||
|
||||
export function UnlockSecretStore(arg1:string):Promise<void>;
|
||||
|
||||
export function UpdateProfile(arg1:models.Profile):Promise<models.Profile>;
|
||||
|
||||
export function UpdateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
|
||||
|
||||
export function WriteSession(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function WriteSessionEscaped(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function BrowseForShell() {
|
||||
return window['go']['api']['Service']['BrowseForShell']();
|
||||
}
|
||||
|
||||
export function CancelReconnect(arg1) {
|
||||
return window['go']['api']['Service']['CancelReconnect'](arg1);
|
||||
}
|
||||
|
||||
export function CheckForUpdates() {
|
||||
return window['go']['api']['Service']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function CleanupRecordings(arg1, arg2) {
|
||||
return window['go']['api']['Service']['CleanupRecordings'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ClearSessionHistory(arg1) {
|
||||
return window['go']['api']['Service']['ClearSessionHistory'](arg1);
|
||||
}
|
||||
@@ -10,6 +26,10 @@ export function CloseAllSessions() {
|
||||
return window['go']['api']['Service']['CloseAllSessions']();
|
||||
}
|
||||
|
||||
export function ConnectLocal() {
|
||||
return window['go']['api']['Service']['ConnectLocal']();
|
||||
}
|
||||
|
||||
export function ConnectQuick(arg1) {
|
||||
return window['go']['api']['Service']['ConnectQuick'](arg1);
|
||||
}
|
||||
@@ -22,6 +42,10 @@ export function CreateProfile(arg1) {
|
||||
return window['go']['api']['Service']['CreateProfile'](arg1);
|
||||
}
|
||||
|
||||
export function CreateSnippet(arg1) {
|
||||
return window['go']['api']['Service']['CreateSnippet'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteKnownHost(arg1) {
|
||||
return window['go']['api']['Service']['DeleteKnownHost'](arg1);
|
||||
}
|
||||
@@ -34,10 +58,30 @@ export function DeleteProfileSecrets(arg1) {
|
||||
return window['go']['api']['Service']['DeleteProfileSecrets'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteRecording(arg1) {
|
||||
return window['go']['api']['Service']['DeleteRecording'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteSSHKey(arg1) {
|
||||
return window['go']['api']['Service']['DeleteSSHKey'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteSnippet(arg1) {
|
||||
return window['go']['api']['Service']['DeleteSnippet'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteTOTPSecret(arg1) {
|
||||
return window['go']['api']['Service']['DeleteTOTPSecret'](arg1);
|
||||
}
|
||||
|
||||
export function DisconnectSession(arg1) {
|
||||
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DuplicateProfile(arg1) {
|
||||
return window['go']['api']['Service']['DuplicateProfile'](arg1);
|
||||
}
|
||||
@@ -46,10 +90,30 @@ export function ExportProfiles(arg1) {
|
||||
return window['go']['api']['Service']['ExportProfiles'](arg1);
|
||||
}
|
||||
|
||||
export function ExportProfilesEncrypted(arg1, arg2) {
|
||||
return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FileExists(arg1) {
|
||||
return window['go']['api']['Service']['FileExists'](arg1);
|
||||
}
|
||||
|
||||
export function FileIsPrivateKey(arg1) {
|
||||
return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
|
||||
}
|
||||
|
||||
export function GenerateSSHKey(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function GetAppInfo() {
|
||||
return window['go']['api']['Service']['GetAppInfo']();
|
||||
}
|
||||
|
||||
export function GetDefaultShell() {
|
||||
return window['go']['api']['Service']['GetDefaultShell']();
|
||||
}
|
||||
|
||||
export function GetPreferences() {
|
||||
return window['go']['api']['Service']['GetPreferences']();
|
||||
}
|
||||
@@ -58,18 +122,82 @@ export function GetProfile(arg1) {
|
||||
return window['go']['api']['Service']['GetProfile'](arg1);
|
||||
}
|
||||
|
||||
export function GetRecording(arg1) {
|
||||
return window['go']['api']['Service']['GetRecording'](arg1);
|
||||
}
|
||||
|
||||
export function GetRecordingStorageInfo() {
|
||||
return window['go']['api']['Service']['GetRecordingStorageInfo']();
|
||||
}
|
||||
|
||||
export function GetSSHPublicKey(arg1) {
|
||||
return window['go']['api']['Service']['GetSSHPublicKey'](arg1);
|
||||
}
|
||||
|
||||
export function GetSessionCwd(arg1) {
|
||||
return window['go']['api']['Service']['GetSessionCwd'](arg1);
|
||||
}
|
||||
|
||||
export function GetSessionHistory(arg1) {
|
||||
return window['go']['api']['Service']['GetSessionHistory'](arg1);
|
||||
}
|
||||
|
||||
export function GetTOTPCode(arg1) {
|
||||
return window['go']['api']['Service']['GetTOTPCode'](arg1);
|
||||
}
|
||||
|
||||
export function GetVNCWebSocketURL(arg1) {
|
||||
return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1);
|
||||
}
|
||||
|
||||
export function HasTOTPSecret(arg1) {
|
||||
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
|
||||
}
|
||||
|
||||
export function IRCHandleInput(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function IRCJoin(arg1, arg2) {
|
||||
return window['go']['api']['Service']['IRCJoin'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function IRCListRooms(arg1) {
|
||||
return window['go']['api']['Service']['IRCListRooms'](arg1);
|
||||
}
|
||||
|
||||
export function IRCPart(arg1, arg2) {
|
||||
return window['go']['api']['Service']['IRCPart'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function IRCRooms(arg1) {
|
||||
return window['go']['api']['Service']['IRCRooms'](arg1);
|
||||
}
|
||||
|
||||
export function IRCUsers(arg1, arg2) {
|
||||
return window['go']['api']['Service']['IRCUsers'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ImportProfiles(arg1, arg2) {
|
||||
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ImportProfilesEncrypted(arg1, arg2) {
|
||||
return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ImportSSHConfig(arg1, arg2) {
|
||||
return window['go']['api']['Service']['ImportSSHConfig'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function InitialiseSecretStore(arg1) {
|
||||
return window['go']['api']['Service']['InitialiseSecretStore'](arg1);
|
||||
}
|
||||
|
||||
export function IsRecording(arg1) {
|
||||
return window['go']['api']['Service']['IsRecording'](arg1);
|
||||
}
|
||||
|
||||
export function IsSecretStoreInitialised() {
|
||||
return window['go']['api']['Service']['IsSecretStoreInitialised']();
|
||||
}
|
||||
@@ -78,6 +206,54 @@ export function IsSecretStoreUnlocked() {
|
||||
return window['go']['api']['Service']['IsSecretStoreUnlocked']();
|
||||
}
|
||||
|
||||
export function LeviathanExportJSON(arg1, arg2) {
|
||||
return window['go']['api']['Service']['LeviathanExportJSON'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function LeviathanExportRange(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['LeviathanExportRange'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function LeviathanLoadFile(arg1, arg2) {
|
||||
return window['go']['api']['Service']['LeviathanLoadFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function LeviathanOpenCompare() {
|
||||
return window['go']['api']['Service']['LeviathanOpenCompare']();
|
||||
}
|
||||
|
||||
export function LeviathanOpenFile() {
|
||||
return window['go']['api']['Service']['LeviathanOpenFile']();
|
||||
}
|
||||
|
||||
export function LeviathanRedo() {
|
||||
return window['go']['api']['Service']['LeviathanRedo']();
|
||||
}
|
||||
|
||||
export function LeviathanRevert() {
|
||||
return window['go']['api']['Service']['LeviathanRevert']();
|
||||
}
|
||||
|
||||
export function LeviathanRunTool(arg1, arg2) {
|
||||
return window['go']['api']['Service']['LeviathanRunTool'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function LeviathanSaveWorking() {
|
||||
return window['go']['api']['Service']['LeviathanSaveWorking']();
|
||||
}
|
||||
|
||||
export function LeviathanSetByte(arg1, arg2) {
|
||||
return window['go']['api']['Service']['LeviathanSetByte'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function LeviathanState() {
|
||||
return window['go']['api']['Service']['LeviathanState']();
|
||||
}
|
||||
|
||||
export function LeviathanUndo() {
|
||||
return window['go']['api']['Service']['LeviathanUndo']();
|
||||
}
|
||||
|
||||
export function ListAuditEntries(arg1) {
|
||||
return window['go']['api']['Service']['ListAuditEntries'](arg1);
|
||||
}
|
||||
@@ -90,6 +266,10 @@ export function ListKnownHosts() {
|
||||
return window['go']['api']['Service']['ListKnownHosts']();
|
||||
}
|
||||
|
||||
export function ListPortForwards(arg1) {
|
||||
return window['go']['api']['Service']['ListPortForwards'](arg1);
|
||||
}
|
||||
|
||||
export function ListProfiles() {
|
||||
return window['go']['api']['Service']['ListProfiles']();
|
||||
}
|
||||
@@ -106,10 +286,26 @@ export function ListProfilesByTag(arg1) {
|
||||
return window['go']['api']['Service']['ListProfilesByTag'](arg1);
|
||||
}
|
||||
|
||||
export function ListRecordings() {
|
||||
return window['go']['api']['Service']['ListRecordings']();
|
||||
}
|
||||
|
||||
export function ListSSHKeyPairs() {
|
||||
return window['go']['api']['Service']['ListSSHKeyPairs']();
|
||||
}
|
||||
|
||||
export function ListSSHKeys() {
|
||||
return window['go']['api']['Service']['ListSSHKeys']();
|
||||
}
|
||||
|
||||
export function ListSessions() {
|
||||
return window['go']['api']['Service']['ListSessions']();
|
||||
}
|
||||
|
||||
export function ListSnippets() {
|
||||
return window['go']['api']['Service']['ListSnippets']();
|
||||
}
|
||||
|
||||
export function LockSecretStore() {
|
||||
return window['go']['api']['Service']['LockSecretStore']();
|
||||
}
|
||||
@@ -122,6 +318,10 @@ export function PinSession(arg1, arg2) {
|
||||
return window['go']['api']['Service']['PinSession'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function PingSession(arg1) {
|
||||
return window['go']['api']['Service']['PingSession'](arg1);
|
||||
}
|
||||
|
||||
export function ReconnectSession(arg1) {
|
||||
return window['go']['api']['Service']['ReconnectSession'](arg1);
|
||||
}
|
||||
@@ -142,6 +342,58 @@ export function ResizeSession(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['ResizeSession'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SFTPDownload(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SFTPDownload'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SFTPList(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SFTPList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SFTPMkdir(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SFTPMkdir'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SFTPPwd(arg1) {
|
||||
return window['go']['api']['Service']['SFTPPwd'](arg1);
|
||||
}
|
||||
|
||||
export function SFTPRemove(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SFTPRemove'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SFTPRename(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SFTPStat(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SFTPStat'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SFTPUpload(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SFTPUpload'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SFTPUploadCancel(arg1) {
|
||||
return window['go']['api']['Service']['SFTPUploadCancel'](arg1);
|
||||
}
|
||||
|
||||
export function SFTPUploadChunk(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SFTPUploadChunk'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SFTPUploadData(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['api']['Service']['SFTPUploadData'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function SFTPUploadFinish(arg1) {
|
||||
return window['go']['api']['Service']['SFTPUploadFinish'](arg1);
|
||||
}
|
||||
|
||||
export function SFTPUploadStart(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['api']['Service']['SFTPUploadStart'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function SaveFile(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
|
||||
}
|
||||
@@ -178,6 +430,26 @@ export function SetProfileSecret(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SetTOTPSecret(arg1, arg2) {
|
||||
return window['go']['api']['Service']['SetTOTPSecret'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StartPortForward(arg1, arg2) {
|
||||
return window['go']['api']['Service']['StartPortForward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StartRecording(arg1, arg2, arg3) {
|
||||
return window['go']['api']['Service']['StartRecording'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function StopPortForward(arg1, arg2) {
|
||||
return window['go']['api']['Service']['StopPortForward'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StopRecording(arg1) {
|
||||
return window['go']['api']['Service']['StopRecording'](arg1);
|
||||
}
|
||||
|
||||
export function TestConnection(arg1, arg2) {
|
||||
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
||||
}
|
||||
@@ -190,6 +462,10 @@ export function UpdateProfile(arg1) {
|
||||
return window['go']['api']['Service']['UpdateProfile'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateSnippet(arg1) {
|
||||
return window['go']['api']['Service']['UpdateSnippet'](arg1);
|
||||
}
|
||||
|
||||
export function WriteSession(arg1, arg2) {
|
||||
return window['go']['api']['Service']['WriteSession'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,47 @@
|
||||
export namespace models {
|
||||
export namespace api {
|
||||
|
||||
export class AppPreferences {
|
||||
theme: string;
|
||||
defaultProtocol: string;
|
||||
confirmOnClose: boolean;
|
||||
confirmOnDisconnect: boolean;
|
||||
showStatusBar: boolean;
|
||||
sidebarWidth: number;
|
||||
defaultFontFamily: string;
|
||||
defaultFontSize: number;
|
||||
defaultScrollback: number;
|
||||
defaultCursorStyle: string;
|
||||
export class SSHKeyInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
bits: string;
|
||||
publicKey: string;
|
||||
privateKeyPath: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AppPreferences(source);
|
||||
return new SSHKeyInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.theme = source["theme"];
|
||||
this.defaultProtocol = source["defaultProtocol"];
|
||||
this.confirmOnClose = source["confirmOnClose"];
|
||||
this.confirmOnDisconnect = source["confirmOnDisconnect"];
|
||||
this.showStatusBar = source["showStatusBar"];
|
||||
this.sidebarWidth = source["sidebarWidth"];
|
||||
this.defaultFontFamily = source["defaultFontFamily"];
|
||||
this.defaultFontSize = source["defaultFontSize"];
|
||||
this.defaultScrollback = source["defaultScrollback"];
|
||||
this.defaultCursorStyle = source["defaultCursorStyle"];
|
||||
this.name = source["name"];
|
||||
this.type = source["type"];
|
||||
this.bits = source["bits"];
|
||||
this.publicKey = source["publicKey"];
|
||||
this.privateKeyPath = source["privateKeyPath"];
|
||||
}
|
||||
}
|
||||
export class AuditEntry {
|
||||
id: string;
|
||||
// Go type: time
|
||||
timestamp: any;
|
||||
action: string;
|
||||
profileId?: string;
|
||||
sessionId?: string;
|
||||
details: string;
|
||||
export class leviathanDocState {
|
||||
name: string;
|
||||
size: number;
|
||||
dirty: boolean;
|
||||
undoLen: number;
|
||||
redoLen: number;
|
||||
compare?: leviathanDocState;
|
||||
previewHex: any[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AuditEntry(source);
|
||||
return new leviathanDocState(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.timestamp = this.convertValues(source["timestamp"], null);
|
||||
this.action = source["action"];
|
||||
this.profileId = source["profileId"];
|
||||
this.sessionId = source["sessionId"];
|
||||
this.details = source["details"];
|
||||
this.name = source["name"];
|
||||
this.size = source["size"];
|
||||
this.dirty = source["dirty"];
|
||||
this.undoLen = source["undoLen"];
|
||||
this.redoLen = source["redoLen"];
|
||||
this.compare = this.convertValues(source["compare"], leviathanDocState);
|
||||
this.previewHex = source["previewHex"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -71,6 +62,69 @@ export namespace models {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace models {
|
||||
|
||||
export class AppPreferences {
|
||||
theme: string;
|
||||
terminalTheme: string;
|
||||
defaultProtocol: string;
|
||||
confirmOnClose: boolean;
|
||||
confirmOnDisconnect: boolean;
|
||||
showStatusBar: boolean;
|
||||
sidebarWidth: number;
|
||||
defaultFontFamily: string;
|
||||
defaultFontSize: number;
|
||||
defaultScrollback: number;
|
||||
defaultCursorStyle: string;
|
||||
useNativePaste: boolean;
|
||||
localShell: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AppPreferences(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.theme = source["theme"];
|
||||
this.terminalTheme = source["terminalTheme"];
|
||||
this.defaultProtocol = source["defaultProtocol"];
|
||||
this.confirmOnClose = source["confirmOnClose"];
|
||||
this.confirmOnDisconnect = source["confirmOnDisconnect"];
|
||||
this.showStatusBar = source["showStatusBar"];
|
||||
this.sidebarWidth = source["sidebarWidth"];
|
||||
this.defaultFontFamily = source["defaultFontFamily"];
|
||||
this.defaultFontSize = source["defaultFontSize"];
|
||||
this.defaultScrollback = source["defaultScrollback"];
|
||||
this.defaultCursorStyle = source["defaultCursorStyle"];
|
||||
this.useNativePaste = source["useNativePaste"];
|
||||
this.localShell = source["localShell"];
|
||||
}
|
||||
}
|
||||
export class AuditEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
profileId?: string;
|
||||
sessionId?: string;
|
||||
details: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AuditEntry(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.timestamp = source["timestamp"];
|
||||
this.action = source["action"];
|
||||
this.profileId = source["profileId"];
|
||||
this.sessionId = source["sessionId"];
|
||||
this.details = source["details"];
|
||||
}
|
||||
}
|
||||
export class GitHubConfig {
|
||||
baseURL: string;
|
||||
secretMode: string;
|
||||
@@ -93,6 +147,9 @@ export namespace models {
|
||||
owner: string;
|
||||
description: string;
|
||||
htmlURL: string;
|
||||
defaultBranch: string;
|
||||
latestCommitter: string;
|
||||
latestCommitAt: string;
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
archived: boolean;
|
||||
@@ -112,6 +169,9 @@ export namespace models {
|
||||
this.owner = source["owner"];
|
||||
this.description = source["description"];
|
||||
this.htmlURL = source["htmlURL"];
|
||||
this.defaultBranch = source["defaultBranch"];
|
||||
this.latestCommitter = source["latestCommitter"];
|
||||
this.latestCommitAt = source["latestCommitAt"];
|
||||
this.private = source["private"];
|
||||
this.fork = source["fork"];
|
||||
this.archived = source["archived"];
|
||||
@@ -121,6 +181,54 @@ export namespace models {
|
||||
this.updatedAt = source["updatedAt"];
|
||||
}
|
||||
}
|
||||
export class IRCConfig {
|
||||
nick: string;
|
||||
realName: string;
|
||||
useTLS: boolean;
|
||||
serverPasswordRequired: boolean;
|
||||
serverPasswordMode: string;
|
||||
nickServIdentify: boolean;
|
||||
nickServAccount: string;
|
||||
nickServMode: string;
|
||||
autoJoinRooms: string[];
|
||||
sshTunnel: boolean;
|
||||
sshHost: string;
|
||||
sshPort: number;
|
||||
sshUsername: string;
|
||||
sshAuthMethod: string;
|
||||
sshSecretMode: string;
|
||||
sshPrivateKeyPath: string;
|
||||
sshAgentAuth: boolean;
|
||||
autoReconnect: boolean;
|
||||
maxReconnectTries: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new IRCConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.nick = source["nick"];
|
||||
this.realName = source["realName"];
|
||||
this.useTLS = source["useTLS"];
|
||||
this.serverPasswordRequired = source["serverPasswordRequired"];
|
||||
this.serverPasswordMode = source["serverPasswordMode"];
|
||||
this.nickServIdentify = source["nickServIdentify"];
|
||||
this.nickServAccount = source["nickServAccount"];
|
||||
this.nickServMode = source["nickServMode"];
|
||||
this.autoJoinRooms = source["autoJoinRooms"];
|
||||
this.sshTunnel = source["sshTunnel"];
|
||||
this.sshHost = source["sshHost"];
|
||||
this.sshPort = source["sshPort"];
|
||||
this.sshUsername = source["sshUsername"];
|
||||
this.sshAuthMethod = source["sshAuthMethod"];
|
||||
this.sshSecretMode = source["sshSecretMode"];
|
||||
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
|
||||
this.sshAgentAuth = source["sshAgentAuth"];
|
||||
this.autoReconnect = source["autoReconnect"];
|
||||
this.maxReconnectTries = source["maxReconnectTries"];
|
||||
}
|
||||
}
|
||||
export class JumpHost {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -150,10 +258,8 @@ export namespace models {
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey: string;
|
||||
// Go type: time
|
||||
firstSeen: any;
|
||||
// Go type: time
|
||||
lastSeen: any;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
trusted: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -168,28 +274,26 @@ export namespace models {
|
||||
this.keyType = source["keyType"];
|
||||
this.fingerprint = source["fingerprint"];
|
||||
this.publicKey = source["publicKey"];
|
||||
this.firstSeen = this.convertValues(source["firstSeen"], null);
|
||||
this.lastSeen = this.convertValues(source["lastSeen"], null);
|
||||
this.firstSeen = source["firstSeen"];
|
||||
this.lastSeen = source["lastSeen"];
|
||||
this.trusted = source["trusted"];
|
||||
}
|
||||
}
|
||||
export class LoginScriptStep {
|
||||
type: string;
|
||||
value: string;
|
||||
timeout: number;
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
static createFrom(source: any = {}) {
|
||||
return new LoginScriptStep(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.type = source["type"];
|
||||
this.value = source["value"];
|
||||
this.timeout = source["timeout"];
|
||||
}
|
||||
}
|
||||
export class PayloadPreset {
|
||||
id: string;
|
||||
@@ -209,6 +313,72 @@ export namespace models {
|
||||
this.format = source["format"];
|
||||
}
|
||||
}
|
||||
export class PortForward {
|
||||
id: string;
|
||||
direction: string;
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
destHost: string;
|
||||
destPort: number;
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new PortForward(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.direction = source["direction"];
|
||||
this.bindHost = source["bindHost"];
|
||||
this.bindPort = source["bindPort"];
|
||||
this.destHost = source["destHost"];
|
||||
this.destPort = source["destPort"];
|
||||
this.enabled = source["enabled"];
|
||||
this.label = source["label"];
|
||||
}
|
||||
}
|
||||
export class VNCConfig {
|
||||
vncHost: string;
|
||||
vncPort: number;
|
||||
secretMode: string;
|
||||
viewOnly: boolean;
|
||||
scaleToFit: boolean;
|
||||
quality: number;
|
||||
sharedSession: boolean;
|
||||
sshTunnel: boolean;
|
||||
sshHost: string;
|
||||
sshPort: number;
|
||||
sshUsername: string;
|
||||
sshAuthMethod: string;
|
||||
sshSecretMode: string;
|
||||
sshPrivateKeyPath: string;
|
||||
sshAgentAuth: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new VNCConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.vncHost = source["vncHost"];
|
||||
this.vncPort = source["vncPort"];
|
||||
this.secretMode = source["secretMode"];
|
||||
this.viewOnly = source["viewOnly"];
|
||||
this.scaleToFit = source["scaleToFit"];
|
||||
this.quality = source["quality"];
|
||||
this.sharedSession = source["sharedSession"];
|
||||
this.sshTunnel = source["sshTunnel"];
|
||||
this.sshHost = source["sshHost"];
|
||||
this.sshPort = source["sshPort"];
|
||||
this.sshUsername = source["sshUsername"];
|
||||
this.sshAuthMethod = source["sshAuthMethod"];
|
||||
this.sshSecretMode = source["sshSecretMode"];
|
||||
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
|
||||
this.sshAgentAuth = source["sshAgentAuth"];
|
||||
}
|
||||
}
|
||||
export class RAWConfig {
|
||||
autoReconnect: boolean;
|
||||
lineEnding: string;
|
||||
@@ -256,6 +426,9 @@ export namespace models {
|
||||
encoding: string;
|
||||
negotiationDebug: boolean;
|
||||
lineMode: boolean;
|
||||
autoReconnect: boolean;
|
||||
maxReconnectTries: number;
|
||||
loginScripts: LoginScriptStep[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TelnetConfig(source);
|
||||
@@ -269,7 +442,28 @@ export namespace models {
|
||||
this.encoding = source["encoding"];
|
||||
this.negotiationDebug = source["negotiationDebug"];
|
||||
this.lineMode = source["lineMode"];
|
||||
this.autoReconnect = source["autoReconnect"];
|
||||
this.maxReconnectTries = source["maxReconnectTries"];
|
||||
this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SSHConfig {
|
||||
authMethods: string[];
|
||||
@@ -281,7 +475,19 @@ export namespace models {
|
||||
keepaliveInterval: number;
|
||||
keepaliveCountMax: number;
|
||||
compression: boolean;
|
||||
agentAuth: boolean;
|
||||
agentForwarding: boolean;
|
||||
autoReconnect: boolean;
|
||||
maxReconnectTries: number;
|
||||
loginScripts: LoginScriptStep[];
|
||||
totpSecret: string;
|
||||
proxyType: string;
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
proxyUsername: string;
|
||||
proxyPassword: string;
|
||||
jumpHosts: JumpHost[];
|
||||
portForwards: PortForward[];
|
||||
startupCommand: string;
|
||||
envVars: Record<string, string>;
|
||||
|
||||
@@ -300,7 +506,19 @@ export namespace models {
|
||||
this.keepaliveInterval = source["keepaliveInterval"];
|
||||
this.keepaliveCountMax = source["keepaliveCountMax"];
|
||||
this.compression = source["compression"];
|
||||
this.agentAuth = source["agentAuth"];
|
||||
this.agentForwarding = source["agentForwarding"];
|
||||
this.autoReconnect = source["autoReconnect"];
|
||||
this.maxReconnectTries = source["maxReconnectTries"];
|
||||
this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep);
|
||||
this.totpSecret = source["totpSecret"];
|
||||
this.proxyType = source["proxyType"];
|
||||
this.proxyHost = source["proxyHost"];
|
||||
this.proxyPort = source["proxyPort"];
|
||||
this.proxyUsername = source["proxyUsername"];
|
||||
this.proxyPassword = source["proxyPassword"];
|
||||
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
|
||||
this.portForwards = this.convertValues(source["portForwards"], PortForward);
|
||||
this.startupCommand = source["startupCommand"];
|
||||
this.envVars = source["envVars"];
|
||||
}
|
||||
@@ -336,14 +554,11 @@ export namespace models {
|
||||
environment: string;
|
||||
notes: string;
|
||||
favourite: boolean;
|
||||
// Go type: time
|
||||
lastConnected?: any;
|
||||
lastConnected?: string;
|
||||
connectTimeout: number;
|
||||
readTimeout: number;
|
||||
// Go type: time
|
||||
createdAt: any;
|
||||
// Go type: time
|
||||
updatedAt: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
terminalFontFamily: string;
|
||||
terminalFontSize: number;
|
||||
terminalScrollback: number;
|
||||
@@ -352,6 +567,8 @@ export namespace models {
|
||||
telnet?: TelnetConfig;
|
||||
raw?: RAWConfig;
|
||||
github?: GitHubConfig;
|
||||
vnc?: VNCConfig;
|
||||
irc?: IRCConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Profile(source);
|
||||
@@ -371,11 +588,11 @@ export namespace models {
|
||||
this.environment = source["environment"];
|
||||
this.notes = source["notes"];
|
||||
this.favourite = source["favourite"];
|
||||
this.lastConnected = this.convertValues(source["lastConnected"], null);
|
||||
this.lastConnected = source["lastConnected"];
|
||||
this.connectTimeout = source["connectTimeout"];
|
||||
this.readTimeout = source["readTimeout"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], null);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], null);
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
this.terminalFontFamily = source["terminalFontFamily"];
|
||||
this.terminalFontSize = source["terminalFontSize"];
|
||||
this.terminalScrollback = source["terminalScrollback"];
|
||||
@@ -384,6 +601,8 @@ export namespace models {
|
||||
this.telnet = this.convertValues(source["telnet"], TelnetConfig);
|
||||
this.raw = this.convertValues(source["raw"], RAWConfig);
|
||||
this.github = this.convertValues(source["github"], GitHubConfig);
|
||||
this.vnc = this.convertValues(source["vnc"], VNCConfig);
|
||||
this.irc = this.convertValues(source["irc"], IRCConfig);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -416,8 +635,7 @@ export namespace models {
|
||||
state: string;
|
||||
tabName: string;
|
||||
pinned: boolean;
|
||||
// Go type: time
|
||||
connectedAt: any;
|
||||
connectedAt: string;
|
||||
error?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -435,28 +653,37 @@ export namespace models {
|
||||
this.state = source["state"];
|
||||
this.tabName = source["tabName"];
|
||||
this.pinned = source["pinned"];
|
||||
this.connectedAt = this.convertValues(source["connectedAt"], null);
|
||||
this.connectedAt = source["connectedAt"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Snippet {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
description: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Snippet(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.command = source["command"];
|
||||
this.description = source["description"];
|
||||
this.folder = source["folder"];
|
||||
this.tags = source["tags"];
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,50 @@
|
||||
module github.com/copilot-ssh-client/app
|
||||
module github.com/andydixon/nexus
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hajimehoshi/go-mp3 v0.3.4
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/lrstanley/girc v1.1.1
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/zalando/go-keyring v0.2.5
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/text v0.23.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.2 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
)
|
||||
|
||||