Author SHA1 Message Date
andy b36aaa7751 build(deps): bump golang.org/x/net from 0.35.0 to 0.38.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 00:00:00 +00:00
208 changed files with 6316 additions and 62283 deletions
-109
View File
@@ -1,109 +0,0 @@
# 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)
-137
View File
@@ -1,137 +0,0 @@
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
+3 -10
View File
@@ -4,15 +4,13 @@
# ── 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
@@ -60,8 +58,3 @@ vault.json
*.pem
*.key
!build/appicon.png
# Builds
build/releases/v*
build/releases/manifest*
-787
View File
@@ -1,787 +0,0 @@
# 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.
+2 -29
View File
@@ -1,13 +1,7 @@
.PHONY: all build build-compressed dev clean test test-cover lint install deps frontend-deps upx-check run package release help
.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package 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
@@ -16,17 +10,6 @@ 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
@@ -37,11 +20,7 @@ dev: deps
# Production build
build: deps
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)"
$(WAILS) build
# Run tests
test:
@@ -68,10 +47,6 @@ 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"
@@ -79,11 +54,9 @@ 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"
+388 -44
View File
@@ -1,55 +1,399 @@
# Nexus Terminal
**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.**
**A premium desktop SSH, Telnet, and RAW TCP client** built with [Wails v2](https://wails.io/) (Go backend, web frontend).
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence.
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.
## Highlights
---
- 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
## 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
- Session recording and playback
- Secure credential storage with OS keychain and encrypted vault fallback
- SSH agent forwarding
- Encrypted profile export/import
- Master password strength meter
- Automatic update checking
## Quick start
---
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.
## Licence
Prefer a one-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** to quick connect.
This project is licensed under the MIT Licence. See [LICENCE](LICENCE) for details.
If a session drops, use the **Reconnect** button in the disconnected overlay.
---
## 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 |
<sub>Built with ❤ using [Wails](https://wails.io/), [xterm.js](https://xtermjs.org/), and [Go](https://go.dev/).</sub>
@@ -1,126 +0,0 @@
# 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.
-752
View File
@@ -1,752 +0,0 @@
# 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.
@@ -1,81 +0,0 @@
# 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.
-135
View File
@@ -1,135 +0,0 @@
# 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.
-302
View File
@@ -1,302 +0,0 @@
#!/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 "$@"
-143
View File
@@ -1,143 +0,0 @@
# 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
}
}
+16 -186
View File
@@ -6,7 +6,6 @@ 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'
@@ -18,154 +17,25 @@ 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() {
check_go
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
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; }
check_linux_deps
resolve_wails
ensure_upx
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
log "All prerequisites found"
log "Go: $(go version)"
log "Node: $(node --version)"
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
log "Wails: $(wails version 2>/dev/null | head -1)"
}
# Install dependencies
@@ -174,11 +44,7 @@ install_deps() {
go mod download
log "Installing frontend dependencies…"
if [ -f frontend/package-lock.json ]; then
(cd frontend && npm ci)
else
(cd frontend && npm install)
fi
cd frontend && npm install && cd ..
log "Dependencies installed"
}
@@ -188,7 +54,7 @@ dev() {
check_deps
install_deps
log "Starting development mode…"
wails_cmd dev
wails dev
}
# Production build
@@ -196,49 +62,14 @@ build() {
check_deps
install_deps
log "Building production binary…"
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
wails build
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
@@ -247,9 +78,8 @@ clean() {
rm -rf build/bin
rm -rf frontend/node_modules
rm -rf frontend/src/js/lib
if command -v go >/dev/null 2>&1; then
go clean -testcache
fi
rm -rf frontend/src/css/lib
go clean -testcache
log "Clean complete"
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

-69
View File
@@ -1,69 +0,0 @@
<!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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

-137
View File
@@ -1,137 +0,0 @@
<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)">&gt;_</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>

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

-15
View File
@@ -1,15 +0,0 @@
{
"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}}"
}
}
}
-15
View File
@@ -1,15 +0,0 @@
<?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>
-77
View File
@@ -1,77 +0,0 @@
# 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.
-310
View File
@@ -1,310 +0,0 @@
# 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).
-71
View File
@@ -1,71 +0,0 @@
# 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)
-343
View File
@@ -1,343 +0,0 @@
# 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.
+113 -282
View File
@@ -1,190 +1,82 @@
<!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/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/leviathan.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/forms.css">
<link rel="stylesheet" href="/src/css/modals.css">
<link rel="stylesheet" href="/src/css/github.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-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>
<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>
<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-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
<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
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
@@ -200,109 +92,62 @@
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 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>
<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-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>
<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>
</div>
<!-- Modal container (dynamically populated) -->
@@ -315,35 +160,21 @@
<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/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>
+34 -525
View File
@@ -9,538 +9,47 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@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"
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-web-links": "0.9.0"
}
},
"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==",
"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.",
"license": "MIT"
},
"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-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-unicode11": {
"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": {
"version": "0.9.0",
"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==",
"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.",
"license": "MIT",
"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"
"peerDependencies": {
"xterm": "^5.0.0"
}
}
}
+5 -12
View File
@@ -5,21 +5,14 @@
"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",
"test": "node scripts/test-regressions.js",
"postinstall": "npm run install:assets && npm run bundle:novnc"
"postinstall": "npm run install:assets"
},
"dependencies": {
"@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"
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-web-links": "0.9.0"
}
}
+1 -1
View File
@@ -1 +1 @@
2f3e27ceb2ed8494c242e0b06d9f4da3
a25ea4f4df93e50eb6c41092b5187a9d
-69
View File
@@ -28,7 +28,6 @@ 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',
];
/**
@@ -47,14 +46,6 @@ 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.
*/
@@ -64,53 +55,6 @@ 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/.
@@ -121,10 +65,6 @@ function copyAssets() {
cwd: frontendDir,
stdio: 'inherit',
});
execSync('node scripts/bundle-novnc.js', {
cwd: frontendDir,
stdio: 'inherit',
});
}
/**
@@ -132,7 +72,6 @@ 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)
*/
@@ -155,13 +94,6 @@ 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.');
}
@@ -219,7 +151,6 @@ try {
}
buildDist();
cleanupDistAssets();
if (!validate()) {
console.error('[build] Validation failed — some required files are missing.');
-43
View File
@@ -1,43 +0,0 @@
/**
* 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);
});
+17 -21
View File
@@ -4,38 +4,34 @@
*
* 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/xterm/css/xterm.css",
dest: "src/css/lib/xterm.css",
src: 'xterm/css/xterm.css',
dest: 'src/css/lib/xterm.css',
},
{
src: "@xterm/xterm/lib/xterm.js",
dest: "src/js/lib/xterm.min.js",
src: 'xterm/lib/xterm.js',
dest: 'src/js/lib/xterm.min.js',
},
{
src: "@xterm/addon-fit/lib/addon-fit.js",
dest: "src/js/lib/xterm-addon-fit.min.js",
src: 'xterm-addon-fit/lib/xterm-addon-fit.js',
dest: 'src/js/lib/xterm-addon-fit.min.js',
},
{
src: "@xterm/addon-search/lib/addon-search.js",
dest: "src/js/lib/xterm-addon-search.min.js",
src: 'xterm-addon-search/lib/xterm-addon-search.js',
dest: 'src/js/lib/xterm-addon-search.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",
src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js',
dest: 'src/js/lib/xterm-addon-web-links.min.js',
},
];
@@ -53,7 +49,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)) {
@@ -69,7 +65,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) {
@@ -84,4 +80,4 @@ if (failures > 0) {
process.exit(1);
}
console.log("[copy-assets] All assets copied successfully.");
console.log('[copy-assets] All assets copied successfully.');
-7
View File
@@ -1,7 +0,0 @@
// 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 };
-438
View File
@@ -1,438 +0,0 @@
#!/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",
"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/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");
File diff suppressed because it is too large Load Diff
+251 -477
View File
@@ -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,433 +158,354 @@
.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.2);
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.20);
}
.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.2);
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.20);
}
.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);
}
.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);
color: var(--danger);
}
/* -----------------------------------------------------------------------
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: 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);
display: grid;
grid-template-columns: 1fr 80px 1fr auto;
gap: var(--space-2);
align-items: center;
}
/* -----------------------------------------------------------------------
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;
}
/* -----------------------------------------------------------------------
@@ -592,154 +513,7 @@
----------------------------------------------------------------------- */
.form-input-sm,
.form-select-sm {
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;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
+139 -177
View File
@@ -4,301 +4,263 @@
========================================================================= */
.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;
}
.github-repo-commit {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: 2px;
}
.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;
}
.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);
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-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);
}
+81 -167
View File
@@ -7,235 +7,149 @@
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;
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;
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;
}
.tab-list {
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;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; /* Firefox */
}
.tab-list::-webkit-scrollbar {
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;
display: none;
}
/* -----------------------------------------------------------------------
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: 0;
position: absolute;
inset: 0;
padding: var(--space-1);
}
/* -----------------------------------------------------------------------
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;
}
.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;
flex: 1;
}
/* Status bar indicator dots */
.status-bar .status-dot {
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);
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); }
/* -----------------------------------------------------------------------
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);
}
-752
View File
@@ -1,752 +0,0 @@
/* 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);
}
}
+5 -81
View File
@@ -112,6 +112,10 @@
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
@@ -136,7 +140,7 @@
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
@@ -148,21 +152,6 @@
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;
@@ -218,68 +207,3 @@
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;
}
File diff suppressed because it is too large Load Diff
-201
View File
@@ -1,201 +0,0 @@
/* ── 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);
}
-514
View File
@@ -1,514 +0,0 @@
/* ==========================================================================
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);
}
+177 -388
View File
@@ -7,524 +7,313 @@
Sidebar header (logo / title / drag region)
----------------------------------------------------------------------- */
.sidebar-header {
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;
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;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
.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;
}
.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;
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: 0.02em;
}
/* -----------------------------------------------------------------------
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);
}
/* -----------------------------------------------------------------------
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);
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: var(--space-2);
}
/* -----------------------------------------------------------------------
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;
}
.sidebar-section-header.sidebar-section-header-group .section-title {
color: var(--sidebar-group-title);
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:hover {
color: var(--text-secondary);
background: var(--bg-hover);
}
.sidebar-section-header.sidebar-section-header-group:hover .section-title {
color: var(--sidebar-group-title);
color: var(--text-secondary);
background: var(--bg-hover);
}
.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: 1px;
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);
}
.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);
background: var(--bg-active);
}
/* 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 indicator icon */
/* Protocol colour indicator dot */
.sidebar-item .protocol-indicator {
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-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-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);
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-tertiary);
}
.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);
}
.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);
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 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,
@@ -533,19 +322,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;
}
-223
View File
@@ -1,223 +0,0 @@
/* ==========================================================================
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;
}
+168 -349
View File
@@ -8,421 +8,262 @@
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;
min-width: 0;
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;
}
.tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
background: var(--bg-hover);
color: var(--text-primary);
}
/* Active tab */
.tab.tab-active {
background: var(--bg-active);
color: var(--text-primary);
.tab.active {
background: var(--bg-base);
color: var(--text-primary);
}
/* Active indicator — accent bar at the bottom */
.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.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.tab-active .tab-icon {
color: var(--text-secondary);
.tab.active .tab-icon {
color: var(--text-secondary);
}
/* -----------------------------------------------------------------------
Tab label
----------------------------------------------------------------------- */
.tab-label,
.tab-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
.tab-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* -----------------------------------------------------------------------
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);
}
.tab-badge.badge-vnc {
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
background: rgba(124, 138, 255, 0.15);
color: var(--protocol-github);
}
/* -----------------------------------------------------------------------
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-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);
}
.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); }
/* -----------------------------------------------------------------------
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: 0 2px 0 0;
padding: 0;
border: 0;
background: none;
line-height: 1;
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;
}
.tab:hover .tab-close,
.tab.tab-active .tab-close {
opacity: 1;
.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);
}
/* -----------------------------------------------------------------------
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;
background: var(--bg-active);
}
/* -----------------------------------------------------------------------
@@ -430,63 +271,41 @@
----------------------------------------------------------------------- */
.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 */
}
/* -----------------------------------------------------------------------
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);
position: absolute;
inset: 0;
z-index: 1;
/* Transparent hit area for right-click context menu */
}
/* -----------------------------------------------------------------------
Modified indicator (unsaved changes)
----------------------------------------------------------------------- */
.tab.is-modified .tab-close::before {
content: "●";
font-size: 8px;
color: var(--accent);
content: '●';
font-size: 8px;
color: var(--accent);
}
+203 -275
View File
@@ -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,227 +18,157 @@
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;
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;
flex: 1;
position: relative;
overflow: hidden;
/* Slight padding so terminal text doesn't hug edges */
padding: var(--space-1);
}
/* xterm.js overrides for consistent look */
.terminal-container .xterm,
.session-panel .xterm {
flex: 1;
height: 100%;
min-height: 0;
padding: 3px 0 0 3px;
flex: 1;
height: 100%;
min-height: 0;
padding: var(--space-1);
}
.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;
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);
font-size: var(--text-xs);
color: var(--text-tertiary);
padding: 0 var(--space-1);
white-space: nowrap;
}
/* -----------------------------------------------------------------------
@@ -247,233 +177,231 @@
/* 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);
}
+92 -109
View File
@@ -5,154 +5,137 @@
========================================================================== */
: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;
--sidebar-group-title: #b89d5f;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--text-tertiary: #707070;
--text-disabled: #4a4a4a;
--text-inverse: #1c1c1c;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
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-vnc: #e85d4a;
--protocol-local: #8bc34a;
--protocol-ssh: #4ecdc4;
--protocol-telnet: #ffa726;
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
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.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);
--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);
/* -----------------------------------------------------------------------
/* -----------------------------------------------------------------------
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: 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;
--sidebar-width: 260px;
--sidebar-width-collapsed: 48px;
--tab-bar-height: 36px;
--toolbar-height: 40px;
--status-bar-height: 24px;
}
-78
View File
@@ -1,78 +0,0 @@
/* 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);
}
+422 -1565
View File
File diff suppressed because it is too large Load Diff
+284 -343
View File
@@ -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 @@
* 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,192 +64,151 @@
* @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) {
// 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: `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"),
});
// Quick connect
cmds.push({
id: "quick-connect",
label: "Quick Connect…",
section: "Actions",
action: () => this.app.quickConnect(),
});
// Local terminal
cmds.push({
id: "local-terminal",
label: "Open Local Terminal",
section: "Actions",
action: () => this.app.connectLocal(),
});
// 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: "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"),
id: 'new-ssh',
label: 'New SSH Connection',
section: 'Actions',
shortcut: '⌘N',
action: () => this.app.profileEditor.openNew('ssh'),
});
cmds.push({
id: "split-horizontal",
label: "Split Pane Down",
section: "Terminal",
shortcut: "⌘⇧E",
action: () => this.app.splitPane("horizontal"),
id: 'new-telnet',
label: 'New Telnet Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('telnet'),
});
cmds.push({
id: "clear-terminal",
label: "Clear Terminal",
section: "Terminal",
action: () =>
this.app.terminals.clearTerminal(this.app.state.activeSessionId),
id: 'new-raw',
label: 'New RAW Connection',
section: 'Actions',
action: () => this.app.profileEditor.openNew('raw'),
});
cmds.push({
id: "reset-terminal",
label: "Reset Terminal",
section: "Terminal",
action: () =>
this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
return cmds;
// 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);
},
});
// 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: '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;
}
/**
@@ -258,17 +217,14 @@
* @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);
}
/**
@@ -276,85 +232,82 @@
* @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);
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;
}
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();
});
}
/**
@@ -364,65 +317,52 @@
* @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;
}
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);
if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
list.appendChild(empty);
return;
}
const item = createElement(
"div",
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
);
item._command = cmd;
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Label
const label = createElement("span", "palette-item-label", {
textContent: cmd.label,
});
item.appendChild(label);
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
list.appendChild(sectionEl);
}
// Detail
if (cmd.detail) {
const detail = createElement("span", "palette-item-detail", {
textContent: cmd.detail,
});
item.appendChild(detail);
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);
}
// 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);
}
}
/**
@@ -431,16 +371,17 @@
* @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;
})();
+241 -353
View File
@@ -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,15 +21,11 @@
* @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();
}
/**
@@ -37,11 +33,11 @@
* @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);
}
}
/**
@@ -51,22 +47,10 @@
* @param {Array} repos
*/
updateRepos(sessionID, 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);
}
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.setData(login, repos);
}
}
/**
@@ -75,98 +59,98 @@
* @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;
}
/**
@@ -175,20 +159,10 @@
* @param {Array} repos
*/
setData(login, repos) {
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 || "";
this._login = login;
this._repos = repos || [];
this._updateStatus();
this._renderList();
}
/**
@@ -196,108 +170,89 @@
* @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;
}
const commitTimestamp = (repo) => {
const raw = repo.latestCommitAt || repo.pushedAt || repo.updatedAt || "";
const ts = new Date(raw).getTime();
return Number.isNaN(ts) ? 0 : ts;
};
// 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);
}
// 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);
}
// 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);
});
// Sort repos within each group by latest commit (newest first)
for (const repos of groups.values()) {
repos.sort((a, b) => commitTimestamp(b) - commitTimestamp(a));
}
for (const owner of sortedKeys) {
const repos = groups.get(owner);
// 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 = `
// 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));
// Repo cards
for (const repo of repos) {
groupEl.appendChild(this._renderRepoCard(repo));
}
this._listEl.appendChild(groupEl);
}
this._listEl.appendChild(groupEl);
}
}
/**
@@ -306,145 +261,77 @@
* @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);
}
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");
}
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);
}
});
actions.appendChild(visitLink);
topRow.appendChild(actions);
card.appendChild(topRow);
card.appendChild(topRow);
// Description
if (repo.description) {
const desc = createElement('div', 'github-repo-desc');
desc.textContent = repo.description;
card.appendChild(desc);
}
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);
}
// Meta row: language, stars, pushed
const meta = createElement('div', 'github-repo-meta');
// Description
if (repo.description) {
const desc = createElement("div", "github-repo-desc");
desc.textContent = repo.description;
card.appendChild(desc);
}
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);
}
// Meta row: language, stars, pushed
const meta = createElement("div", "github-repo-meta");
if (repo.stars > 0) {
const stars = createElement('span', 'github-repo-stars');
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
meta.appendChild(stars);
}
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);
}
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.stars > 0) {
const stars = createElement("span", "github-repo-stars");
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
meta.appendChild(stars);
}
card.appendChild(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);
}
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");
}
return card;
}
async _manualRefresh() {
if (!this._statusEl) return;
this._statusEl.textContent = "Refreshing…";
try {
await window.NexusTerminal.wailsService.RefreshGitHubRepos(this.sessionID);
} catch (err) {
this.showError(err.message || "Refresh failed");
}
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');
}
}
/**
@@ -454,33 +341,33 @@
* @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';
}
/**
@@ -488,10 +375,11 @@
* @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();
})();
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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()}(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
!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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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()}(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
!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
+1 -1
View File
File diff suppressed because one or more lines are too long
+750 -2218
View File
File diff suppressed because it is too large Load Diff
+351 -406
View File
@@ -1,31 +1,30 @@
(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 ───────────────────────────────────────────────────
@@ -37,85 +36,71 @@
* @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,
});
}
/**
@@ -123,45 +108,45 @@
* @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);
}
}
/**
@@ -169,17 +154,17 @@
* @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);
}
}
/**
@@ -187,13 +172,13 @@
* @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';
}
/**
@@ -201,24 +186,24 @@
* @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.NexusTerminal.wailsService.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.go.api.Service.SaveSessionCapture(sessionId, content);
this.app.notifications.success('Capture saved');
} catch (err) {
this.app.notifications.error('Save failed: ' + err);
}
}
/**
@@ -226,50 +211,45 @@
* @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 {
// 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 = "";
try {
const b64 = btoa(dataToSend);
await window.go.api.Service.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);
}
}
/**
@@ -277,7 +257,7 @@
* @param {string} sessionId
*/
destroyViewer(sessionId) {
this.viewers.delete(sessionId);
this.viewers.delete(sessionId);
}
// ── Rendering ────────────────────────────────────────────────────
@@ -290,97 +270,76 @@
* @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);
// 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,
});
btn.addEventListener("click", () => {
input.value += cb.value;
input.focus();
});
quickBtns.appendChild(btn);
}
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);
// 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);
}
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(formatSelect);
// Input
const input = createElement('input', 'form-input raw-send-input', {
type: 'text',
placeholder: 'Type data to send…',
});
bar.appendChild(presetSelect);
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.send(sessionId);
});
bar.appendChild(input);
// Send button
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
textContent: "Send",
});
sendBtn.addEventListener("click", () => this.send(sessionId));
bar.appendChild(sendBtn);
// 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);
}
bar.appendChild(quickBtns);
return bar;
// 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.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);
return bar;
}
/**
@@ -390,18 +349,18 @@
* @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);
}
/**
@@ -410,11 +369,11 @@
* @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);
}
/**
@@ -425,24 +384,21 @@
* @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;
}
/**
@@ -452,14 +408,11 @@
* @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;
}
/**
@@ -470,34 +423,30 @@
* @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;
}
/**
@@ -507,39 +456,39 @@
* @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) : ".";
// 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}|`);
}
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
}
return lines.join("\n");
return lines.join('\n');
}
// ── Internal helpers ─────────────────────────────────────────────
@@ -551,17 +500,17 @@
* @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);
}
/**
@@ -570,9 +519,9 @@
* @private
*/
_scrollToBottom(viewer) {
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
}
/**
@@ -582,12 +531,12 @@
* @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;
}
/**
@@ -597,29 +546,25 @@
* @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 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 match;
}
});
});
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.RawViewer = RawViewer;
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.RawViewer = RawViewer;
})();
-691
View File
@@ -1,691 +0,0 @@
(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">&times;</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">&times;</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">&times;</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">&times;</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;
})();
+1 -58
View File
@@ -134,10 +134,6 @@ 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"
@@ -170,19 +166,6 @@ 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) {
@@ -280,11 +263,7 @@ class SecretPrompt {
document.body.appendChild(overlay);
this._overlay = overlay;
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
modal.classList.add('is-visible');
acceptBtn.focus();
});
acceptBtn.focus();
acceptBtn.addEventListener('click', () => {
this._close();
@@ -331,42 +310,6 @@ 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;
+297 -1015
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+269 -774
View File
File diff suppressed because it is too large Load Diff
-454
View File
@@ -1,454 +0,0 @@
(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">&times;</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">&times;</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;
})();
-600
View File
@@ -1,600 +0,0 @@
(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;
})();
-310
View File
@@ -1,310 +0,0 @@
(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">&times;</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 = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
}
}
_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");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${title}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
</div>
`;
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;
})();
+84 -90
View File
@@ -1,38 +1,32 @@
(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 {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();
/** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map();
}
// ── Event emitter ────────────────────────────────────────────────
@@ -43,10 +37,10 @@
* @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);
}
/**
@@ -55,10 +49,10 @@
* @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);
}
}
/**
@@ -67,16 +61,16 @@
* @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 ─────────────────────────────────────────────────────
@@ -86,8 +80,8 @@
* @param {Array<Object>} profiles
*/
setProfiles(profiles) {
this.profiles = profiles;
this.emit("profiles:changed", this.profiles);
this.profiles = profiles;
this.emit('profiles:changed', this.profiles);
}
/**
@@ -95,9 +89,9 @@
* @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);
}
/**
@@ -105,12 +99,12 @@
* @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);
}
/**
@@ -118,9 +112,9 @@
* @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);
}
/**
@@ -129,7 +123,7 @@
* @returns {Object|undefined}
*/
getProfile(id) {
return this.profiles.find((p) => p.id === id);
return this.profiles.find((p) => p.id === id);
}
// ── Sessions ─────────────────────────────────────────────────────
@@ -139,9 +133,9 @@
* @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);
}
/**
@@ -150,12 +144,12 @@
* @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);
}
}
/**
@@ -163,15 +157,14 @@
* @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);
}
/**
@@ -180,7 +173,7 @@
* @returns {Object|undefined}
*/
getSession(id) {
return this.sessions.get(id);
return this.sessions.get(id);
}
/**
@@ -188,8 +181,8 @@
* @param {string|null} id
*/
setActiveSession(id) {
this.activeSessionId = id;
this.emit("session:activated", id);
this.activeSessionId = id;
this.emit('session:activated', id);
}
/**
@@ -197,7 +190,7 @@
* @returns {Array<Object>}
*/
getAllSessions() {
return [...this.sessions.values()];
return [...this.sessions.values()];
}
// ── Preferences ──────────────────────────────────────────────────
@@ -207,8 +200,8 @@
* @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 ─────────────────────────────────────────────────
@@ -218,8 +211,8 @@
* @param {boolean} unlocked
*/
setSecretStoreState(unlocked) {
this.secretStoreUnlocked = unlocked;
this.emit("secretStore:changed", unlocked);
this.secretStoreUnlocked = unlocked;
this.emit('secretStore:changed', unlocked);
}
/**
@@ -227,11 +220,12 @@
* @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;
})();
+343 -682
View File
File diff suppressed because it is too large Load Diff
+171 -1422
View File
File diff suppressed because it is too large Load Diff
-347
View File
@@ -1,347 +0,0 @@
(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;
})();
-381
View File
@@ -1,381 +0,0 @@
(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">&times;</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;
})();
+187 -325
View File
@@ -1,178 +1,120 @@
(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;
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);
}
} else if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
return el;
}
}
/**
* 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 "";
/**
* 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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
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();
@@ -182,219 +124,139 @@
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);
}
}
/**
* 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) {
/**
* 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";
case "vnc":
return "proto-vnc";
case "local":
return "proto-local";
default:
return "";
case 'ssh': return 'proto-ssh';
case 'telnet': return 'proto-telnet';
case 'raw': return 'proto-raw';
case 'github': return 'proto-github';
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 "reconnecting":
return "Reconnecting";
case "error":
return "Error";
default:
return state || "Unknown";
case 'connecting': return 'Connecting';
case 'connected': return 'Connected';
case 'disconnected': return 'Disconnected';
case 'error': return 'Error';
default: return state || 'Unknown';
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
Object.assign(window.NexusTerminal, {
$,
$$,
createElement,
createModalShell,
escapeHtml,
formatTimestamp,
debounce,
fuzzyMatch,
base64Encode,
utf8ToBase64,
base64Decode,
generateId,
formatBytes,
truncate,
dismissContextMenus,
protocolColour,
stateLabel,
relativeTime,
});
window.NexusTerminal = window.NexusTerminal || {};
Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, 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 });
})();
-295
View File
@@ -1,295 +0,0 @@
(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();
})();
-28
View File
@@ -1,28 +0,0 @@
(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());
})();
-127
View File
@@ -1,131 +1,52 @@
// 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 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>>;
@@ -134,24 +55,14 @@ 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>>;
@@ -162,32 +73,6 @@ 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>;
@@ -206,24 +91,12 @@ 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>;
-252
View File
@@ -2,22 +2,6 @@
// 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);
}
@@ -26,10 +10,6 @@ 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);
}
@@ -42,10 +22,6 @@ 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);
}
@@ -58,30 +34,10 @@ 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);
}
@@ -90,30 +46,10 @@ 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']();
}
@@ -122,58 +58,18 @@ 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 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']();
}
@@ -182,54 +78,6 @@ 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);
}
@@ -242,10 +90,6 @@ 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']();
}
@@ -262,26 +106,10 @@ 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']();
}
@@ -294,10 +122,6 @@ 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);
}
@@ -318,58 +142,6 @@ 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);
}
@@ -406,26 +178,6 @@ 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);
}
@@ -438,10 +190,6 @@ 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);
}
+91 -268
View File
@@ -1,47 +1,56 @@
export namespace api {
export namespace models {
export class SSHKeyInfo {
name: string;
type: string;
bits: string;
publicKey: string;
privateKeyPath: string;
export class AppPreferences {
theme: string;
defaultProtocol: string;
confirmOnClose: boolean;
confirmOnDisconnect: boolean;
showStatusBar: boolean;
sidebarWidth: number;
defaultFontFamily: string;
defaultFontSize: number;
defaultScrollback: number;
defaultCursorStyle: string;
static createFrom(source: any = {}) {
return new SSHKeyInfo(source);
return new AppPreferences(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.type = source["type"];
this.bits = source["bits"];
this.publicKey = source["publicKey"];
this.privateKeyPath = source["privateKeyPath"];
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"];
}
}
export class leviathanDocState {
name: string;
size: number;
dirty: boolean;
undoLen: number;
redoLen: number;
compare?: leviathanDocState;
previewHex: any[];
export class AuditEntry {
id: string;
// Go type: time
timestamp: any;
action: string;
profileId?: string;
sessionId?: string;
details: string;
static createFrom(source: any = {}) {
return new leviathanDocState(source);
return new AuditEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
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"];
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"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -62,69 +71,6 @@ export namespace api {
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;
@@ -147,9 +93,6 @@ export namespace models {
owner: string;
description: string;
htmlURL: string;
defaultBranch: string;
latestCommitter: string;
latestCommitAt: string;
private: boolean;
fork: boolean;
archived: boolean;
@@ -169,9 +112,6 @@ 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"];
@@ -210,8 +150,10 @@ export namespace models {
keyType: string;
fingerprint: string;
publicKey: string;
firstSeen: string;
lastSeen: string;
// Go type: time
firstSeen: any;
// Go type: time
lastSeen: any;
trusted: boolean;
static createFrom(source: any = {}) {
@@ -226,26 +168,28 @@ export namespace models {
this.keyType = source["keyType"];
this.fingerprint = source["fingerprint"];
this.publicKey = source["publicKey"];
this.firstSeen = source["firstSeen"];
this.lastSeen = source["lastSeen"];
this.firstSeen = this.convertValues(source["firstSeen"], null);
this.lastSeen = this.convertValues(source["lastSeen"], null);
this.trusted = source["trusted"];
}
}
export class LoginScriptStep {
type: string;
value: string;
timeout: number;
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"];
}
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 PayloadPreset {
id: string;
@@ -265,72 +209,6 @@ 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;
@@ -378,9 +256,6 @@ export namespace models {
encoding: string;
negotiationDebug: boolean;
lineMode: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
loginScripts: LoginScriptStep[];
static createFrom(source: any = {}) {
return new TelnetConfig(source);
@@ -394,28 +269,7 @@ 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[];
@@ -427,19 +281,7 @@ 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>;
@@ -458,19 +300,7 @@ 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"];
}
@@ -506,11 +336,14 @@ export namespace models {
environment: string;
notes: string;
favourite: boolean;
lastConnected?: string;
// Go type: time
lastConnected?: any;
connectTimeout: number;
readTimeout: number;
createdAt: string;
updatedAt: string;
// Go type: time
createdAt: any;
// Go type: time
updatedAt: any;
terminalFontFamily: string;
terminalFontSize: number;
terminalScrollback: number;
@@ -519,7 +352,6 @@ export namespace models {
telnet?: TelnetConfig;
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
@@ -539,11 +371,11 @@ export namespace models {
this.environment = source["environment"];
this.notes = source["notes"];
this.favourite = source["favourite"];
this.lastConnected = source["lastConnected"];
this.lastConnected = this.convertValues(source["lastConnected"], null);
this.connectTimeout = source["connectTimeout"];
this.readTimeout = source["readTimeout"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
this.createdAt = this.convertValues(source["createdAt"], null);
this.updatedAt = this.convertValues(source["updatedAt"], null);
this.terminalFontFamily = source["terminalFontFamily"];
this.terminalFontSize = source["terminalFontSize"];
this.terminalScrollback = source["terminalScrollback"];
@@ -552,7 +384,6 @@ 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);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -585,7 +416,8 @@ export namespace models {
state: string;
tabName: string;
pinned: boolean;
connectedAt: string;
// Go type: time
connectedAt: any;
error?: string;
static createFrom(source: any = {}) {
@@ -603,37 +435,28 @@ export namespace models {
this.state = source["state"];
this.tabName = source["tabName"];
this.pinned = source["pinned"];
this.connectedAt = source["connectedAt"];
this.connectedAt = this.convertValues(source["connectedAt"], null);
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"];
}
}
}
+19 -26
View File
@@ -1,49 +1,42 @@
module github.com/andydixon/nexus
module github.com/copilot-ssh-client/app
go 1.25.0
go 1.23.0
require (
github.com/creack/pty v1.1.24
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/klauspost/compress v1.17.11
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/mattn/go-sqlite3 v1.14.22
github.com/wailsapp/wails/v2 v2.12.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
github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.36.0
golang.org/x/text v0.23.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/boombuler/barcode v1.1.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/go-ole/go-ole v1.3.0 // 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/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/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.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.53.0 // indirect
github.com/samber/lo v1.49.1 // 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.23 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
)
+41 -58
View File
@@ -1,38 +1,27 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
@@ -46,67 +35,61 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -1262
View File
File diff suppressed because it is too large Load Diff
-277
View File
@@ -1,277 +0,0 @@
package api
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/leviathan/document"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/storage"
)
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
privPath, pubPath, err := sshKeyPairPaths("deploy_key-01")
if err != nil {
t.Fatalf("sshKeyPairPaths() error = %v", err)
}
wantPriv := filepath.Join(home, ".ssh", "deploy_key-01")
if privPath != wantPriv {
t.Fatalf("private path = %q, want %q", privPath, wantPriv)
}
if pubPath != wantPriv+".pub" {
t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub")
}
}
func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("../outside")
if err == nil {
t.Fatal("expected traversal key name to be rejected")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Fatalf("error = %q, want invalid characters", err.Error())
}
}
func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("nested/key")
if err == nil {
t.Fatal("expected nested key path to be rejected")
}
}
func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
profile := models.NewProfile(models.ProtocolSSH)
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
got := redactProfileSecrets([]*models.Profile{&profile})
if got[0].SSH.ProxyPassword != "" {
t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword)
}
if got[0].SSH.TOTPSecret != "" {
t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret)
}
if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" {
t.Fatal("redaction mutated the original profile")
}
}
func TestCreateProfileRejectsUnsafeGitHubBaseURL(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolGitHub)
profile.DisplayName = "Unsafe GitHub"
profile.GitHub.BaseURL = "http://evil.example/api/v3"
if _, err := svc.CreateProfile(profile); err == nil {
t.Fatal("expected unsafe GitHub base URL to be rejected")
}
}
func TestImportProfilesRejectsNullEntry(t *testing.T) {
svc := newTestService(t)
_, err := svc.ImportProfiles(`[null]`, "skip")
if err == nil {
t.Fatal("expected null import entry to be rejected")
}
if !strings.Contains(err.Error(), "entry 0 is null") {
t.Fatalf("error = %q, want null entry detail", err.Error())
}
}
func TestImportProfilesRejectsOversizedJSON(t *testing.T) {
svc := newTestService(t)
oversized := strings.Repeat(" ", maxProfileImportJSONBytes+1)
_, err := svc.ImportProfiles(oversized, "skip")
if err == nil {
t.Fatal("expected oversized import JSON to be rejected")
}
}
func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxInlineSFTPUploadBytes+1))
err := svc.SFTPUploadData("missing-session", "/", "large.bin", payload)
if err == nil {
t.Fatal("expected oversized upload payload to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %q, want too large", err.Error())
}
}
func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1))
err := svc.SFTPUploadChunk("missing-upload", payload)
if err == nil {
t.Fatal("expected oversized upload chunk to be rejected")
}
if !strings.Contains(err.Error(), "chunk is too large") {
t.Fatalf("error = %q, want chunk is too large", err.Error())
}
}
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
got, err := safeSFTPUploadName(`../report <final>.txt`)
if err != nil {
t.Fatalf("safeSFTPUploadName() error = %v", err)
}
if got != "report <final>.txt" {
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
}
}
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
svc := newTestService(t)
data := `[
{
"displayName": "Raw Device",
"protocol": "raw",
"host": "device.local",
"raw": {}
}
]`
count, err := svc.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles() error = %v", err)
}
if count != 1 {
t.Fatalf("imported = %d, want 1", count)
}
profiles, err := svc.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles() error = %v", err)
}
if len(profiles) != 1 {
t.Fatalf("profiles = %d, want 1", len(profiles))
}
if profiles[0].Port != models.DefaultRAWPort {
t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort)
}
if profiles[0].RAW.Encoding != "utf-8" {
t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding)
}
}
func TestLeviathanDocumentBytesRejectsOversizedDocument(t *testing.T) {
doc := document.New("large.bin", []byte("abcd"))
_, err := leviathanDocumentBytes(doc, 3, "test")
if err == nil {
t.Fatal("expected oversized document to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWriteLeviathanDocumentFileWritesPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcd"))
if _, err := doc.Set(1, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "out.bin")
if err := writeLeviathanDocumentFile(path, doc); err != nil {
t.Fatalf("writeLeviathanDocumentFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "aZcd" {
t.Fatalf("written bytes = %q, want %q", string(got), "aZcd")
}
}
func TestWriteLeviathanDocumentRangeFileWritesSelectedPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcdef"))
if _, err := doc.Set(3, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "range.bin")
if err := writeLeviathanDocumentRangeFile(path, doc, 2, 3); err != nil {
t.Fatalf("writeLeviathanDocumentRangeFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "cZe" {
t.Fatalf("written bytes = %q, want %q", string(got), "cZe")
}
}
func TestReadLeviathanSmallFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.img")
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readLeviathanSmallFile(path, 3, "image")
if err == nil {
t.Fatal("expected oversized file to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadSSHPublicKeyFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.pub")
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxSSHPublicKeyBytes+1)), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readSSHPublicKeyFile(path)
if err == nil {
t.Fatal("expected oversized public key to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolVNC)
profile.DisplayName = "VNC"
profile.Host = "vnc.local"
profile.VNC.SecretMode = "prompt"
profile.VNC.SSHSecretMode = "prompt"
got, err := svc.CreateProfile(profile)
if err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if got.VNC.SecretMode != models.SecretModeAskEveryTime {
t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime)
}
if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime {
t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime)
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return NewService(store, nil, nil, nil, audit.NewLogger(store), nil)
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More