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
120 changed files with 5775 additions and 25820 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@latest
- name: Install frontend dependencies
run: npm install
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 -8
View File
@@ -4,11 +4,11 @@
# ── 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/
*.exe
@@ -58,8 +58,3 @@ vault.json
*.pem
*.key
!build/appicon.png
# Builds
build/releases/v*
build/releases/manifest*
+2 -9
View File
@@ -1,9 +1,7 @@
.PHONY: all build dev clean test test-cover lint install deps frontend-deps 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),)
# Default target
all: build
@@ -22,7 +20,7 @@ dev: deps
# Production build
build: deps
$(WAILS) build -ldflags "-s -w -X github.com/andydixon/nexus/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
$(WAILS) build
# Run tests
test:
@@ -49,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"
@@ -65,5 +59,4 @@ help:
@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 -43
View File
@@ -1,54 +1,399 @@
# Nexus Terminal
**A desktop client for SSH, Telnet, RAW TCP, and GitHub — 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, and GitHub sessions in one app
- Tabbed sessions with split panes, pinning, and reordering
- SFTP browser with drag-and-drop uploads and a directory picker
- 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>
-170
View File
@@ -1,170 +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"
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; }
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() {
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; }
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 "Go: $(go version)"
log "Node: $(node --version)"
log "Wails: $(wails version 2>/dev/null | head -1)"
}
install_deps() {
log "Installing Go dependencies…"
go mod download
log "Installing frontend dependencies…"
cd frontend && npm install && cd ..
}
host_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) 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 version="$4"
local ldflags
ldflags="$(build_ldflags "${version}")"
local tags
tags="$(build_tags)"
local platform="${target_os}/${target_arch}"
local cc=""
if [ "$target_os" != "$host" ]; 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})"
return 0
fi
fi
log "Building ${platform}…"
if [ -n "$cc" ]; then
if [ -n "$tags" ]; then
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi
else
if [ -n "$tags" ]; then
CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi
fi
}
main() {
check_deps
install_deps
local version
version="$(build_version)"
local host
host="$(host_os)"
if [ "$host" = "unknown" ]; then
err "Unsupported host OS for this script. Use macOS or Linux."
exit 1
fi
# Build host targets first
build_target "$host" "amd64" "$host" "$version"
build_target "$host" "arm64" "$host" "$version"
# Cross-build Linux and Windows targets where toolchains are available
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
build_target "linux" "amd64" "$host" "$version"
build_target "linux" "arm64" "$host" "$version"
build_target "windows" "amd64" "$host" "$version"
build_target "windows" "arm64" "$host" "$version"
fi
log "Builds complete. Output: build/bin/"
}
main "$@"
-124
View File
@@ -1,124 +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 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 -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'
Write-Info 'Building production binary (CGO_ENABLED=1)...'
& $wailsPath build -ldflags $ldflags
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
}
}
+1 -46
View File
@@ -17,31 +17,6 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
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 prerequisites
check_deps() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
@@ -87,27 +62,7 @@ build() {
check_deps
install_deps
log "Building production binary…"
local version
version="$(build_version)"
local tags
tags="$(build_tags)"
if [ -n "$tags" ]; then
log "Using Wails build tags: ${tags}"
wails build -tags "$tags" -ldflags "$(build_ldflags "${version}")"
else
wails build -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/"
}
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.
-29
View File
@@ -1,29 +0,0 @@
# Nexus Terminal Overview
Nexus Terminal is a desktop client for managing SSH, Telnet, RAW TCP, and GitHub sessions in one place. It is designed for people who work across multiple servers, environments, or repositories and want a fast, focused workflow with strong credential handling.
## What it does
- Connect to SSH, Telnet, and RAW TCP endpoints with saved profiles or quick connect.
- Manage multiple live sessions with tabs, split panes, and broadcast input.
- Browse and move files on SSH hosts with a built-in SFTP browser.
- Record sessions for later playback.
- Store credentials securely with OS keychain support and an encrypted vault fallback.
## Highlights
- Multi-session tab bar with pinning, reordering, and split panes.
- SFTP browser with drag-and-drop uploads and a directory picker.
- Clipboard-aware terminal with search, snippets, and right-click actions.
- Auto-reconnect, login scripts, and SSH extras such as jump hosts and port forwarding.
- GitHub view for browsing repositories by organisation.
## Who it is for
Nexus Terminal suits operators, developers, and support teams who need to connect to many endpoints quickly, keep sessions organised, and avoid re-entering credentials.
## 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).
-62
View File
@@ -1,62 +0,0 @@
# Nexus Terminal Quick Start
Nexus Terminal is a desktop client for SSH, Telnet, RAW TCP, and GitHub sessions. 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.
- **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.
## 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)
-255
View File
@@ -1,255 +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. Security and credentials
12. Settings
13. 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, 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.
### 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.
- **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. 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.
---
## 12. 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.
---
## 13. 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.
### 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 -262
View File
@@ -1,174 +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" />
</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-title">Nexus Terminal</span>
</div>
</div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header wails-drag">
<div class="sidebar-logo">
<!-- Terminal prompt icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<span class="sidebar-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
@@ -184,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) -->
@@ -299,7 +160,6 @@
<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>
<!-- Application scripts (loaded in dependency order) -->
@@ -308,22 +168,13 @@
<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/app.js"></script>
</body>
</body>
</html>
-487
View File
@@ -9,487 +9,10 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"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/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/xterm": {
@@ -519,16 +42,6 @@
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-unicode11": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xterm-addon-unicode11/-/xterm-addon-unicode11-0.6.0.tgz",
"integrity": "sha512-5pkb8YoS/deRtNqQRw8t640mu+Ga8B2MG3RXGQu0bwgcfr8XiXIRI880TWM49ICAHhTmnOLPzIIBIjEnCq7k2A==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-unicode11 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-web-links/-/xterm-addon-web-links-0.9.0.tgz",
+1 -7
View File
@@ -5,20 +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",
"postinstall": "npm run install:assets && npm run bundle:novnc"
"postinstall": "npm run install:assets"
},
"dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
+1 -1
View File
@@ -1 +1 @@
99c6bbb0236daee684251a5f62bbd974
a25ea4f4df93e50eb6c41092b5187a9d
-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/css/xterm.css",
dest: "src/css/lib/xterm.css",
src: 'xterm/css/xterm.css',
dest: 'src/css/lib/xterm.css',
},
{
src: "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/xterm-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/xterm-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/xterm-addon-unicode11.js",
dest: "src/js/lib/xterm-addon-unicode11.min.js",
},
{
src: "xterm-addon-web-links/lib/xterm-addon-web-links.js",
dest: "src/js/lib/xterm-addon-web-links.min.js",
src: 'xterm-addon-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.');
-4
View File
@@ -1,4 +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";
window.noVNC = { RFB: RFB };
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 -127
View File
@@ -7,195 +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;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
min-height: var(--tab-bar-height);
overflow: hidden;
grid-column: 2;
grid-row: 1;
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;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; /* Firefox */
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; /* Firefox */
}
.tab-list::-webkit-scrollbar {
display: none;
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);
}
File diff suppressed because it is too large Load Diff
-167
View File
@@ -1,167 +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 */
.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;
}
/* 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 -294
View File
@@ -7,430 +7,313 @@
Sidebar header (logo / title / drag region)
----------------------------------------------------------------------- */
.sidebar-header {
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
min-height: var(--tab-bar-height);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
min-height: var(--tab-bar-height);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: var(--space-2);
display: flex;
align-items: center;
gap: var(--space-2);
}
.sidebar-logo svg {
width: 18px;
height: 18px;
color: var(--accent);
flex-shrink: 0;
width: 18px;
height: 18px;
color: var(--accent);
flex-shrink: 0;
}
.sidebar-title {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: 0.02em;
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: 0.02em;
}
/* -----------------------------------------------------------------------
Sidebar 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-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,
@@ -439,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;
}
+164 -319
View File
@@ -8,417 +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.active {
background: var(--bg-base);
color: var(--text-primary);
background: var(--bg-base);
color: var(--text-primary);
}
/* Active indicator — accent bar at the bottom */
.tab.active::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
border-radius: var(--radius-full) var(--radius-full) 0 0;
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
border-radius: var(--radius-full) var(--radius-full) 0 0;
}
/* -----------------------------------------------------------------------
Tab icon (small terminal/protocol icon)
----------------------------------------------------------------------- */
.tab-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--text-tertiary);
width: 14px;
height: 14px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--text-tertiary);
}
.tab.active .tab-icon {
color: var(--text-secondary);
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.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-left: 2px;
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.active .tab-close {
opacity: 1;
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);
}
/* -----------------------------------------------------------------------
@@ -426,41 +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 */
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: 36px;
--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);
}
+417 -1445
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.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);
}
}
},
});
}
// 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.go.api.Service.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.go.api.Service.RefreshGitHubRepos(this.sessionID);
} catch (err) {
this.showError(err.message || "Refresh failed");
}
if (!this._statusEl) return;
this._statusEl.textContent = 'Refreshing…';
try {
await window.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();
})();
+750 -2207
View File
File diff suppressed because it is too large Load Diff
-628
View File
@@ -1,628 +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.go.api.Service.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.go.api.Service.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 = await window.go.api.Service.ListRecordings();
this._showBrowserModal(recordings || []);
} 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.go.api.Service.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.go.api.Service.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
* @private
*/
_showBrowserModal(recordings) {
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>';
}
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;">
${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);
// 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.go.api.Service.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>
<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;
if (!password) {
this.app.notifications.error('Please enter the decryption password.');
return;
}
try {
const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy);
this.app.notifications.success(`Imported ${count} profile(s)`);
await this.app.loadProfiles();
close();
} catch (err) {
if (String(err).includes('cancelled')) {
return;
}
this.app.notifications.error('Import failed: ' + err);
}
});
}
}
// ---------------------------------------------------------------------------
// 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;
})();
-53
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) {
@@ -327,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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+267 -725
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 = btoa(command + "\n");
window.go.api.Service.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.go.api.Service.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.go.api.Service.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.go.api.Service.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.go.api.Service.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.go.api.Service.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.go.api.Service.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.go.api.Service.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.go.api.Service.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.go.api.Service.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 -461
View File
File diff suppressed because it is too large Load Diff
+171 -1298
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 -262
View File
@@ -1,132 +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;
}
}
/**
* 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();
@@ -136,202 +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);
}
}
/**
* 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,
escapeHtml,
formatTimestamp,
debounce,
fuzzyMatch,
base64Encode,
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();
})();
-91
View File
@@ -1,91 +1,42 @@
// 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 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 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>;
@@ -96,8 +47,6 @@ 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>>;
@@ -106,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>>;
@@ -134,24 +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 SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function SaveFile(arg1:string,arg2:string):Promise<string>;
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
@@ -170,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>;
-180
View File
@@ -2,18 +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 ClearSessionHistory(arg1) {
return window['go']['api']['Service']['ClearSessionHistory'](arg1);
}
@@ -22,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);
}
@@ -38,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);
}
@@ -54,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);
}
@@ -86,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']();
}
@@ -118,54 +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 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']();
}
@@ -186,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']();
}
@@ -206,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']();
}
@@ -238,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);
}
@@ -262,42 +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 SFTPUploadData(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['SFTPUploadData'](arg1, arg2, arg3, arg4);
}
export function SaveFile(arg1, arg2) {
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
}
@@ -334,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);
}
@@ -366,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);
}
+74 -209
View File
@@ -1,33 +1,7 @@
export namespace api {
export class SSHKeyInfo {
name: string;
type: string;
bits: string;
publicKey: string;
privateKeyPath: string;
static createFrom(source: any = {}) {
return new SSHKeyInfo(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"];
}
}
}
export namespace models {
export class AppPreferences {
theme: string;
terminalTheme: string;
defaultProtocol: string;
confirmOnClose: boolean;
confirmOnDisconnect: boolean;
@@ -37,8 +11,6 @@ export namespace models {
defaultFontSize: number;
defaultScrollback: number;
defaultCursorStyle: string;
useNativePaste: boolean;
localShell: string;
static createFrom(source: any = {}) {
return new AppPreferences(source);
@@ -47,7 +19,6 @@ export namespace models {
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"];
@@ -57,13 +28,12 @@ export namespace models {
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;
// Go type: time
timestamp: any;
action: string;
profileId?: string;
sessionId?: string;
@@ -76,12 +46,30 @@ export namespace models {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.timestamp = source["timestamp"];
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 {
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 GitHubConfig {
baseURL: string;
@@ -105,9 +93,6 @@ export namespace models {
owner: string;
description: string;
htmlURL: string;
defaultBranch: string;
latestCommitter: string;
latestCommitAt: string;
private: boolean;
fork: boolean;
archived: boolean;
@@ -127,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"];
@@ -168,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 = {}) {
@@ -184,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;
@@ -223,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;
@@ -336,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);
@@ -352,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[];
@@ -385,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>;
@@ -416,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"];
}
@@ -464,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;
@@ -477,7 +352,6 @@ export namespace models {
telnet?: TelnetConfig;
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
@@ -497,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"];
@@ -510,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 {
@@ -543,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 = {}) {
@@ -561,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 -23
View File
@@ -1,46 +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/mattn/go-sqlite3 v1.14.44
github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0
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.51.0
golang.org/x/net v0.54.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.44.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
)
+41 -50
View File
@@ -1,33 +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/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/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=
@@ -41,64 +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/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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.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 -963
View File
File diff suppressed because it is too large Load Diff
+9 -39
View File
@@ -6,20 +6,18 @@ package app
import (
"context"
"log"
"path/filepath"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/andydixon/nexus/internal/api"
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/config"
"github.com/andydixon/nexus/internal/knownhosts"
"github.com/andydixon/nexus/internal/preferences"
"github.com/andydixon/nexus/internal/recording"
"github.com/andydixon/nexus/internal/secrets"
"github.com/andydixon/nexus/internal/sessions"
"github.com/andydixon/nexus/internal/storage"
"github.com/copilot-ssh-client/app/internal/api"
"github.com/copilot-ssh-client/app/internal/audit"
"github.com/copilot-ssh-client/app/internal/config"
"github.com/copilot-ssh-client/app/internal/knownhosts"
"github.com/copilot-ssh-client/app/internal/preferences"
"github.com/copilot-ssh-client/app/internal/secrets"
"github.com/copilot-ssh-client/app/internal/sessions"
"github.com/copilot-ssh-client/app/internal/storage"
)
// App is the central application struct. Wails binds its lifecycle methods
@@ -99,12 +97,8 @@ func (a *App) Startup(ctx context.Context) {
log.Printf("warning: failed to load preferences, using defaults: %v", err)
}
// Initialise session recording store.
recordDir := filepath.Join(cfg.DataDir, "recordings")
recordStore := recording.NewStore(recordDir)
// Initialise the session manager.
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog, recordStore)
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog)
a.SessionManager.SetContext(ctx)
// Wire the API service with all backing stores now that they exist.
@@ -146,27 +140,3 @@ func (a *App) DomReady(ctx context.Context) {
})
log.Println("DOM ready – backend:ready event emitted")
}
// EmitOpenAbout tells the frontend to open the About tab.
func (a *App) EmitOpenAbout() {
a.mu.Lock()
ctx := a.ctx
a.mu.Unlock()
if ctx == nil {
log.Println("unable to open About: application context not initialised")
return
}
runtime.EventsEmit(ctx, "app:open-about", nil)
}
// RequestQuit asks the runtime to quit the application.
func (a *App) RequestQuit() {
a.mu.Lock()
ctx := a.ctx
a.mu.Unlock()
if ctx == nil {
log.Println("unable to quit: application context not initialised")
return
}
runtime.Quit(ctx)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"time"
"github.com/andydixon/nexus/internal/models"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/google/uuid"
)
-188
View File
@@ -1,188 +0,0 @@
// Package exportimport provides encrypted profile export and import.
// Profiles are serialised to JSON, then encrypted with AES-256-GCM using
// an Argon2id-derived key from a user-supplied password. No hard-coded
// secrets are used. The file format includes a version byte, salt, nonce,
// and ciphertext — everything needed for self-contained decryption.
package exportimport
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"time"
"golang.org/x/crypto/argon2"
"github.com/andydixon/nexus/internal/models"
)
const (
// File format version. Increment if the envelope format changes.
formatVersion byte = 1
// Argon2id parameters for export encryption (same strength as the vault).
exportArgonTime = 3
exportArgonMemory = 128 * 1024 // 128 MiB
exportArgonThreads = 4
exportArgonKeyLen = 32 // AES-256
saltLength = 32
nonceLength = 12 // AES-GCM standard nonce size
)
// ExportBundle is the cleartext payload that gets encrypted.
type ExportBundle struct {
Version int `json:"version"`
ExportedAt string `json:"exportedAt"` // ISO 8601
Profiles []models.Profile `json:"profiles"`
Count int `json:"count"`
}
// EncryptAndExport serialises the given profiles, encrypts the JSON with
// AES-256-GCM (Argon2id-derived key), and writes the result to the file.
//
// File layout: [1 byte version][32 bytes salt][12 bytes nonce][ciphertext]
func EncryptAndExport(profiles []models.Profile, password, filePath string) error {
if password == "" {
return errors.New("exportimport: password cannot be empty")
}
if len(profiles) == 0 {
return errors.New("exportimport: no profiles to export")
}
bundle := ExportBundle{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Profiles: profiles,
Count: len(profiles),
}
plaintext, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return fmt.Errorf("exportimport: failed to serialise profiles: %w", err)
}
// Generate random salt and nonce.
salt := make([]byte, saltLength)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return fmt.Errorf("exportimport: failed to generate salt: %w", err)
}
nonce := make([]byte, nonceLength)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("exportimport: failed to generate nonce: %w", err)
}
// Derive encryption key from password.
key := argon2.IDKey(
[]byte(password),
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
// Encrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
if err != nil {
return fmt.Errorf("exportimport: cipher init failed: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("exportimport: GCM init failed: %w", err)
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
// Write the file: version + salt + nonce + ciphertext.
f, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("exportimport: failed to create file: %w", err)
}
defer f.Close()
if _, err := f.Write([]byte{formatVersion}); err != nil {
return fmt.Errorf("exportimport: write version failed: %w", err)
}
if _, err := f.Write(salt); err != nil {
return fmt.Errorf("exportimport: write salt failed: %w", err)
}
if _, err := f.Write(nonce); err != nil {
return fmt.Errorf("exportimport: write nonce failed: %w", err)
}
if _, err := f.Write(ciphertext); err != nil {
return fmt.Errorf("exportimport: write ciphertext failed: %w", err)
}
return f.Sync()
}
// DecryptAndImport reads an encrypted export file, decrypts it using the
// given password, and returns the contained profiles.
func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
if password == "" {
return nil, errors.New("exportimport: password cannot be empty")
}
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("exportimport: failed to read file: %w", err)
}
// Minimum size: version(1) + salt(32) + nonce(12) + at least 1 byte ciphertext + GCM tag(16).
minSize := 1 + saltLength + nonceLength + 1 + 16
if len(data) < minSize {
return nil, errors.New("exportimport: file is too small or corrupt")
}
// Parse envelope.
version := data[0]
if version != formatVersion {
return nil, fmt.Errorf("exportimport: unsupported format version %d", version)
}
salt := data[1 : 1+saltLength]
nonce := data[1+saltLength : 1+saltLength+nonceLength]
ciphertext := data[1+saltLength+nonceLength:]
// Derive decryption key.
key := argon2.IDKey(
[]byte(password),
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
// Decrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("exportimport: cipher init failed: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("exportimport: GCM init failed: %w", err)
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, errors.New("exportimport: decryption failed — wrong password or corrupt file")
}
// Parse the JSON bundle.
var bundle ExportBundle
if err := json.Unmarshal(plaintext, &bundle); err != nil {
return nil, fmt.Errorf("exportimport: invalid bundle JSON: %w", err)
}
return bundle.Profiles, nil
}
-131
View File
@@ -1,131 +0,0 @@
package exportimport
import (
"os"
"path/filepath"
"testing"
"github.com/andydixon/nexus/internal/models"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test-export.nexus")
profiles := []models.Profile{
{
ID: "test-1",
DisplayName: "Test Server",
Protocol: models.ProtocolSSH,
Host: "example.com",
Port: 22,
Username: "admin",
},
{
ID: "test-2",
DisplayName: "Dev Box",
Protocol: models.ProtocolSSH,
Host: "dev.example.com",
Port: 2222,
Username: "developer",
},
}
password := "strong-test-password-2024!"
// Encrypt and export.
if err := EncryptAndExport(profiles, password, path); err != nil {
t.Fatalf("EncryptAndExport failed: %v", err)
}
// Verify file exists and has content.
info, err := os.Stat(path)
if err != nil {
t.Fatalf("exported file not found: %v", err)
}
if info.Size() == 0 {
t.Fatal("exported file is empty")
}
// Decrypt and import.
imported, err := DecryptAndImport(path, password)
if err != nil {
t.Fatalf("DecryptAndImport failed: %v", err)
}
if len(imported) != len(profiles) {
t.Fatalf("expected %d profiles, got %d", len(profiles), len(imported))
}
for i, p := range imported {
if p.ID != profiles[i].ID {
t.Errorf("profile %d: expected ID %q, got %q", i, profiles[i].ID, p.ID)
}
if p.DisplayName != profiles[i].DisplayName {
t.Errorf("profile %d: expected DisplayName %q, got %q", i, profiles[i].DisplayName, p.DisplayName)
}
if p.Host != profiles[i].Host {
t.Errorf("profile %d: expected Host %q, got %q", i, profiles[i].Host, p.Host)
}
}
}
func TestDecryptWrongPassword(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test-export.nexus")
profiles := []models.Profile{
{
ID: "test-1",
DisplayName: "Test",
Protocol: models.ProtocolSSH,
Host: "example.com",
Port: 22,
},
}
if err := EncryptAndExport(profiles, "correct-password", path); err != nil {
t.Fatalf("EncryptAndExport failed: %v", err)
}
_, err := DecryptAndImport(path, "wrong-password")
if err == nil {
t.Fatal("expected error with wrong password, got nil")
}
}
func TestEncryptEmptyProfiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.nexus")
err := EncryptAndExport(nil, "password", path)
if err == nil {
t.Fatal("expected error for empty profiles, got nil")
}
}
func TestEncryptEmptyPassword(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.nexus")
profiles := []models.Profile{{ID: "1"}}
err := EncryptAndExport(profiles, "", path)
if err == nil {
t.Fatal("expected error for empty password, got nil")
}
}
func TestDecryptCorruptFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "corrupt.nexus")
// Write garbage data.
if err := os.WriteFile(path, []byte("corrupt"), 0600); err != nil {
t.Fatal(err)
}
_, err := DecryptAndImport(path, "password")
if err == nil {
t.Fatal("expected error for corrupt file, got nil")
}
}
+1 -155
View File
@@ -9,14 +9,11 @@ import (
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/andydixon/nexus/internal/models"
"github.com/copilot-ssh-client/app/internal/models"
)
// Client wraps the GitHub REST API for repository listing.
@@ -45,7 +42,6 @@ type apiRepo struct {
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
@@ -62,15 +58,6 @@ type apiRepo struct {
// paginating through the API. Results are sorted by pushed_at descending
// (most recently active first).
func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
return c.fetchRepos(ctx, nil)
}
// FetchReposWithProgress fetches repositories and reports enrichment progress.
func (c *Client) FetchReposWithProgress(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) {
return c.fetchRepos(ctx, progress)
}
func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) {
var all []apiRepo
page := 1
@@ -133,7 +120,6 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
Owner: r.Owner.Login,
Description: r.Description,
HTMLURL: r.HTMLURL,
DefaultBranch: r.DefaultBranch,
Private: r.Private,
Fork: r.Fork,
Archived: r.Archived,
@@ -143,149 +129,9 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
}
}
if progress != nil {
progress(0, len(result))
}
// Enrich with latest commit metadata (best-effort).
sem := make(chan struct{}, 5)
var wg sync.WaitGroup
var done int32
for i := range result {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
ref := result[idx].DefaultBranch
if ref == "" {
ref = "HEAD"
}
committer, committedAt, err := c.FetchLatestCommit(ctx, result[idx].FullName, ref)
if err != nil {
if progress != nil {
current := int(atomic.AddInt32(&done, 1))
progress(current, len(result))
}
return
}
result[idx].LatestCommitter = committer
result[idx].LatestCommitAt = committedAt
if progress != nil {
current := int(atomic.AddInt32(&done, 1))
progress(current, len(result))
}
}(i)
}
wg.Wait()
return result, nil
}
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
url := fmt.Sprintf("%s/repos/%s/commits/%s", c.baseURL, fullName, ref)
if ref == "" {
url = fmt.Sprintf("%s/repos/%s/commits/HEAD", c.baseURL, fullName)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", "", fmt.Errorf("building request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "NexusTerminal/1.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("fetching commit: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return "", "", fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
}
var payload struct {
Commit struct {
Committer struct {
Name string `json:"name"`
Date time.Time `json:"date"`
} `json:"committer"`
Author struct {
Name string `json:"name"`
Date time.Time `json:"date"`
} `json:"author"`
} `json:"commit"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", "", fmt.Errorf("decoding commit: %w", err)
}
name := payload.Commit.Committer.Name
date := payload.Commit.Committer.Date
if name == "" {
name = payload.Commit.Author.Name
date = payload.Commit.Author.Date
}
if name == "" || date.IsZero() {
return "", "", fmt.Errorf("commit metadata missing")
}
return name, date.Format(time.RFC3339), nil
}
// DownloadRepoArchive downloads a repository zip archive to the given path.
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
if fullName == "" {
return fmt.Errorf("repository name cannot be empty")
}
url := fmt.Sprintf("%s/repos/%s/zipball", c.baseURL, fullName)
if ref != "" {
url = url + "/" + ref
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("building request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "NexusTerminal/1.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("downloading archive: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
}
file, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
return fmt.Errorf("writing archive: %w", err)
}
return nil
}
// ValidateToken makes a lightweight call to /user to confirm the token works.
func (c *Client) ValidateToken(ctx context.Context) (string, error) {
url := c.baseURL + "/user"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"time"
"github.com/andydixon/nexus/internal/models"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/google/uuid"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package knownhosts
import (
"testing"
"github.com/andydixon/nexus/internal/models"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
-190
View File
@@ -1,190 +0,0 @@
// Package localshell provides a local PTY-based terminal session.
// It spawns a shell process and bridges I/O for the frontend terminal.
package localshell
import (
"fmt"
"os"
"os/exec"
"runtime"
"sync"
"github.com/creack/pty"
)
// Client manages a local shell PTY session.
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
ptmx *os.File
shell string
onData func([]byte)
onError func(error)
onClose func()
closed bool
cols uint16
rows uint16
}
// Config holds configuration for a local shell session.
type Config struct {
Shell string // Path to the shell binary (e.g. /bin/zsh)
Cols uint16
Rows uint16
OnData func([]byte)
OnError func(error)
OnClose func()
}
// DefaultShell returns the system default shell.
func DefaultShell() string {
if runtime.GOOS == "windows" {
// Prefer PowerShell if available, else cmd.exe.
if ps, err := exec.LookPath("powershell.exe"); err == nil {
return ps
}
return "cmd.exe"
}
// Unix: use SHELL env var, fallback to /bin/sh.
if sh := os.Getenv("SHELL"); sh != "" {
return sh
}
return "/bin/sh"
}
// NewClient creates a new local shell client.
func NewClient(cfg Config) *Client {
shell := cfg.Shell
if shell == "" {
shell = DefaultShell()
}
cols := cfg.Cols
if cols == 0 {
cols = 80
}
rows := cfg.Rows
if rows == 0 {
rows = 24
}
return &Client{
shell: shell,
cols: cols,
rows: rows,
onData: cfg.OnData,
onError: cfg.OnError,
onClose: cfg.OnClose,
}
}
// Start spawns the shell process and begins reading output.
func (c *Client) Start() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ptmx != nil {
return fmt.Errorf("shell already running")
}
cmd := exec.Command(c.shell)
cmd.Env = os.Environ()
// Start in the user's home directory.
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
}
// Set TERM for proper terminal behaviour.
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: c.cols,
Rows: c.rows,
})
if err != nil {
return fmt.Errorf("failed to start shell %q: %w", c.shell, err)
}
c.cmd = cmd
c.ptmx = ptmx
// Read loop — forward PTY output to the frontend.
go c.readLoop()
// Wait for process exit.
go func() {
_ = cmd.Wait()
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.onClose != nil {
c.onClose()
}
}()
return nil
}
// Write sends data to the shell's stdin (PTY).
func (c *Client) Write(data []byte) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return fmt.Errorf("shell not running")
}
_, err := ptmx.Write(data)
return err
}
// Resize updates the PTY window size.
func (c *Client) Resize(cols, rows uint16) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return nil
}
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close terminates the shell process and PTY.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.closed = true
if c.ptmx != nil {
c.ptmx.Close()
c.ptmx = nil
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
}
func (c *Client) readLoop() {
buf := make([]byte, 32*1024)
for {
n, err := c.ptmx.Read(buf)
if n > 0 && c.onData != nil {
c.onData(buf[:n])
}
if err != nil {
c.mu.Lock()
wasClosed := c.closed
c.mu.Unlock()
if !wasClosed && c.onError != nil {
c.onError(err)
}
return
}
}
}
-17
View File
@@ -12,7 +12,6 @@ const (
DefaultTelnetPort = 23
DefaultRAWPort = 23
DefaultGitHubPort = 443
DefaultVNCPort = 5900
)
// Default timeout values (in seconds).
@@ -40,7 +39,6 @@ const (
func DefaultAppPreferences() AppPreferences {
return AppPreferences{
Theme: "dark",
TerminalTheme: "charcoal",
DefaultProtocol: string(ProtocolSSH),
ConfirmOnClose: true,
ConfirmOnDisconnect: true,
@@ -50,8 +48,6 @@ func DefaultAppPreferences() AppPreferences {
DefaultFontSize: DefaultFontSize,
DefaultScrollback: DefaultScrollback,
DefaultCursorStyle: DefaultCursorStyle,
UseNativePaste: false,
LocalShell: "", // empty means use system default
}
}
@@ -84,7 +80,6 @@ func NewProfile(protocol Protocol) Profile {
KeepaliveInterval: DefaultKeepaliveInterval,
KeepaliveCountMax: DefaultKeepaliveCountMax,
JumpHosts: []JumpHost{},
PortForwards: []PortForward{},
EnvVars: map[string]string{},
}
case ProtocolTelnet:
@@ -110,18 +105,6 @@ func NewProfile(protocol Protocol) Profile {
SecretMode: SecretModeSaved,
RefreshIntervalSecs: 300,
}
case ProtocolVNC:
p.Port = DefaultVNCPort
p.VNC = &VNCConfig{
VNCPort: DefaultVNCPort,
SecretMode: SecretModeSaved,
ScaleToFit: true,
SharedSession: true,
Quality: 6,
SSHPort: DefaultSSHPort,
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
}
}
return p
+13 -113
View File
@@ -12,8 +12,6 @@ const (
ProtocolTelnet Protocol = "telnet"
ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc"
ProtocolLocal Protocol = "local"
)
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
@@ -59,11 +57,11 @@ type Profile struct {
Environment string `json:"environment"`
Notes string `json:"notes"`
Favourite bool `json:"favourite"`
LastConnected *time.Time `json:"lastConnected" ts_type:"string"`
LastConnected *time.Time `json:"lastConnected"`
ConnectTimeout int `json:"connectTimeout"` // seconds
ReadTimeout int `json:"readTimeout"` // seconds
CreatedAt time.Time `json:"createdAt" ts_type:"string"`
UpdatedAt time.Time `json:"updatedAt" ts_type:"string"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Terminal preferences
TerminalFontFamily string `json:"terminalFontFamily"`
@@ -82,9 +80,6 @@ type Profile struct {
// GitHub-specific configuration
GitHub *GitHubConfig `json:"github,omitempty"`
// VNC-specific configuration
VNC *VNCConfig `json:"vnc,omitempty"`
}
// SSHConfig holds SSH-specific settings for a connection profile.
@@ -98,30 +93,11 @@ type SSHConfig struct {
KeepaliveInterval int `json:"keepaliveInterval"`
KeepaliveCountMax int `json:"keepaliveCountMax"`
Compression bool `json:"compression"`
AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
TOTPSecret string `json:"totpSecret"` // TOTP secret key for 2FA auto-fill (stored encrypted)
ProxyType string `json:"proxyType"` // "none", "socks5", "http"
ProxyHost string `json:"proxyHost"`
ProxyPort int `json:"proxyPort"`
ProxyUsername string `json:"proxyUsername"`
ProxyPassword string `json:"proxyPassword"` // stored in vault for production; inline for simplicity
JumpHosts []JumpHost `json:"jumpHosts"`
PortForwards []PortForward `json:"portForwards"`
StartupCommand string `json:"startupCommand"`
EnvVars map[string]string `json:"envVars"`
}
// LoginScriptStep represents a single step in an expect-style auto-login sequence.
type LoginScriptStep struct {
Type string `json:"type"` // "wait" or "send"
Value string `json:"value"` // text to wait for / text to send
Timeout int `json:"timeout"` // max seconds to wait (for "wait" steps; 0 = use default 10s)
}
// JumpHost describes a proxy/bastion host used for SSH multi-hop connections.
type JumpHost struct {
Host string `json:"host"`
@@ -132,37 +108,14 @@ type JumpHost struct {
PrivateKeyPath string `json:"privateKeyPath"`
}
// PortForwardDirection indicates the tunnel direction.
type PortForwardDirection string
const (
PortForwardLocal PortForwardDirection = "local"
PortForwardRemote PortForwardDirection = "remote"
)
// PortForward defines a single SSH port-forwarding rule.
type PortForward struct {
ID string `json:"id"`
Direction PortForwardDirection `json:"direction"`
BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote)
BindPort int `json:"bindPort"`
DestHost string `json:"destHost"` // target address to forward to
DestPort int `json:"destPort"`
Enabled bool `json:"enabled"` // whether to activate on connect
Label string `json:"label"` // optional descriptive label
}
// TelnetConfig holds Telnet-specific settings for a connection profile.
type TelnetConfig struct {
LocalEcho bool `json:"localEcho"`
LineEnding LineEnding `json:"lineEnding"`
TerminalType string `json:"terminalType"`
Encoding string `json:"encoding"`
NegotiationDebug bool `json:"negotiationDebug"`
LineMode bool `json:"lineMode"`
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
LocalEcho bool `json:"localEcho"`
LineEnding LineEnding `json:"lineEnding"`
TerminalType string `json:"terminalType"`
Encoding string `json:"encoding"`
NegotiationDebug bool `json:"negotiationDebug"`
LineMode bool `json:"lineMode"`
}
// RAWConfig holds RAW TCP-specific settings for a connection profile.
@@ -197,40 +150,6 @@ type GitHubConfig struct {
RefreshIntervalSecs int `json:"refreshIntervalSecs"`
}
// VNCConfig holds VNC-specific settings for a connection profile.
type VNCConfig struct {
// VNCHost is the VNC server address (defaults to profile.Host if empty).
VNCHost string `json:"vncHost"`
// VNCPort is the VNC server port (5900 + display number, default 5900).
VNCPort int `json:"vncPort"`
// SecretMode controls how the VNC password is stored.
SecretMode SecretMode `json:"secretMode"`
// ViewOnly disables sending keyboard and mouse events to the server.
ViewOnly bool `json:"viewOnly"`
// ScaleToFit scales the remote framebuffer to fit the panel.
ScaleToFit bool `json:"scaleToFit"`
// Quality controls JPEG compression quality (1-9, 0 = default).
Quality int `json:"quality"`
// SharedSession allows other VNC viewers to remain connected.
SharedSession bool `json:"sharedSession"`
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
}
// GitHubRepo is a single repository returned to the frontend for display.
type GitHubRepo struct {
FullName string `json:"fullName"`
@@ -238,9 +157,6 @@ type GitHubRepo struct {
Owner string `json:"owner"`
Description string `json:"description"`
HTMLURL string `json:"htmlURL"`
DefaultBranch string `json:"defaultBranch"`
LatestCommitter string `json:"latestCommitter"`
LatestCommitAt string `json:"latestCommitAt"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
@@ -257,7 +173,6 @@ const (
SessionConnecting SessionState = "connecting"
SessionConnected SessionState = "connected"
SessionDisconnected SessionState = "disconnected"
SessionReconnecting SessionState = "reconnecting"
SessionError SessionState = "error"
)
@@ -272,7 +187,7 @@ type SessionInfo struct {
State SessionState `json:"state"`
TabName string `json:"tabName"`
Pinned bool `json:"pinned"`
ConnectedAt time.Time `json:"connectedAt" ts_type:"string"`
ConnectedAt time.Time `json:"connectedAt"`
Error string `json:"error,omitempty"`
}
@@ -284,15 +199,14 @@ type KnownHost struct {
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"publicKey"`
FirstSeen time.Time `json:"firstSeen" ts_type:"string"`
LastSeen time.Time `json:"lastSeen" ts_type:"string"`
FirstSeen time.Time `json:"firstSeen"`
LastSeen time.Time `json:"lastSeen"`
Trusted bool `json:"trusted"`
}
// AppPreferences stores user-level application settings.
type AppPreferences struct {
Theme string `json:"theme"`
TerminalTheme string `json:"terminalTheme"` // colour scheme name (e.g. "charcoal", "solarized-dark")
DefaultProtocol string `json:"defaultProtocol"`
ConfirmOnClose bool `json:"confirmOnClose"`
ConfirmOnDisconnect bool `json:"confirmOnDisconnect"`
@@ -302,28 +216,14 @@ type AppPreferences struct {
DefaultFontSize int `json:"defaultFontSize"`
DefaultScrollback int `json:"defaultScrollback"`
DefaultCursorStyle string `json:"defaultCursorStyle"`
UseNativePaste bool `json:"useNativePaste"`
LocalShell string `json:"localShell"` // path to shell binary for local terminal
}
// AuditEntry records an auditable action within the application.
type AuditEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp" ts_type:"string"`
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
ProfileID string `json:"profileId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Details string `json:"details"`
}
// Snippet stores a reusable command for the snippet library.
type Snippet struct {
ID string `json:"id"`
Name string `json:"name"`
Command string `json:"command"`
Description string `json:"description"`
Folder string `json:"folder"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"createdAt" ts_type:"string"`
UpdatedAt time.Time `json:"updatedAt" ts_type:"string"`
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"sync"
"github.com/andydixon/nexus/internal/models"
"github.com/copilot-ssh-client/app/internal/models"
)
// PreferenceStore is the persistence interface used by the Manager.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/andydixon/nexus/internal/models"
"github.com/copilot-ssh-client/app/internal/models"
)
// Client manages a raw TCP connection.
-368
View File
@@ -1,368 +0,0 @@
// Package recording provides session recording in asciicast v2 format.
// Recordings capture terminal I/O with timestamps, enabling playback of
// complete sessions for auditing, training, or troubleshooting.
//
// File format: https://docs.asciinema.org/manual/asciicast/v2/
//
// The header line is a JSON object with metadata (version, dimensions, etc.)
// followed by one JSON array per event: [elapsed_secs, event_type, data].
package recording
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// EventType distinguishes input from output events.
type EventType string
const (
// EventOutput represents data sent from the remote host to the terminal.
EventOutput EventType = "o"
// EventInput represents data sent from the user to the remote host.
EventInput EventType = "i"
)
// Header is the asciicast v2 file header (first line).
type Header struct {
Version int `json:"version"`
Width int `json:"width"`
Height int `json:"height"`
Timestamp int64 `json:"timestamp"`
Title string `json:"title,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
// Event is a single recorded I/O event: [elapsed_seconds, type, data].
type Event struct {
Elapsed float64 `json:"-"`
Type EventType `json:"-"`
Data string `json:"-"`
}
// MarshalJSON serialises an event as the asciicast v2 array format.
func (e Event) MarshalJSON() ([]byte, error) {
return json.Marshal([]interface{}{e.Elapsed, string(e.Type), e.Data})
}
// RecordingMeta describes a saved recording without loading the full data.
type RecordingMeta struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Title string `json:"title"`
Width int `json:"width"`
Height int `json:"height"`
Duration float64 `json:"duration"` // seconds
Size int64 `json:"size"` // file size in bytes
CreatedAt time.Time `json:"createdAt"`
FilePath string `json:"filePath"`
}
// Recorder captures terminal I/O for a single session.
type Recorder struct {
mu sync.Mutex
file *os.File
encoder *json.Encoder
startTime time.Time
header Header
eventCount int
closed bool
filePath string
}
// NewRecorder creates a new recording file and writes the header.
func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("recording: failed to create directory: %w", err)
}
filename := fmt.Sprintf("%s.cast", id)
path := filepath.Join(dir, filename)
f, err := os.Create(path)
if err != nil {
return nil, fmt.Errorf("recording: failed to create file: %w", err)
}
now := time.Now()
hdr := Header{
Version: 2,
Width: cols,
Height: rows,
Timestamp: now.Unix(),
Title: title,
Env: map[string]string{
"TERM": "xterm-256color",
},
}
enc := json.NewEncoder(f)
enc.SetEscapeHTML(false)
if err := enc.Encode(hdr); err != nil {
f.Close()
os.Remove(path)
return nil, fmt.Errorf("recording: failed to write header: %w", err)
}
return &Recorder{
file: f,
encoder: enc,
startTime: now,
header: hdr,
filePath: path,
}, nil
}
// WriteOutput records terminal output (data from remote host).
func (r *Recorder) WriteOutput(data []byte) {
r.writeEvent(EventOutput, data)
}
// WriteInput records terminal input (data from user).
func (r *Recorder) WriteInput(data []byte) {
r.writeEvent(EventInput, data)
}
// writeEvent appends a timestamped event to the recording file.
func (r *Recorder) writeEvent(eventType EventType, data []byte) {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed || r.file == nil {
return
}
elapsed := time.Since(r.startTime).Seconds()
evt := Event{
Elapsed: elapsed,
Type: eventType,
Data: string(data),
}
// Write as a JSON array line: [elapsed, "o"/"i", "data"]
if err := r.encoder.Encode(evt); err != nil {
// Non-fatal — continue recording even if one event fails.
return
}
r.eventCount++
}
// UpdateSize records a terminal resize (not part of asciicast v2 events,
// but useful for future playback fidelity). Currently a no-op placeholder.
func (r *Recorder) UpdateSize(cols, rows int) {
// asciicast v2 does not have a standard resize event. Future extension
// could use a custom event type like "r" with JSON dimensions.
}
// Close flushes and closes the recording file.
func (r *Recorder) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return nil
}
r.closed = true
if r.file != nil {
if err := r.file.Sync(); err != nil {
r.file.Close()
return fmt.Errorf("recording: sync failed: %w", err)
}
return r.file.Close()
}
return nil
}
// Duration returns the elapsed time since recording started.
func (r *Recorder) Duration() float64 {
r.mu.Lock()
defer r.mu.Unlock()
return time.Since(r.startTime).Seconds()
}
// FilePath returns the absolute path to the recording file.
func (r *Recorder) FilePath() string {
return r.filePath
}
// EventCount returns the number of events recorded so far.
func (r *Recorder) EventCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.eventCount
}
// ---------------------------------------------------------------------------
// Recording store — manages listing, reading, and deleting recordings.
// ---------------------------------------------------------------------------
// Store manages the recording directory.
type Store struct {
dir string
}
// NewStore creates a recording store at the given directory.
func NewStore(dir string) *Store {
return &Store{dir: dir}
}
// Dir returns the recordings directory path.
func (s *Store) Dir() string {
return s.dir
}
// List returns metadata for all recordings in the store.
func (s *Store) List() ([]RecordingMeta, error) {
if err := os.MkdirAll(s.dir, 0700); err != nil {
return nil, err
}
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, fmt.Errorf("recording: failed to read directory: %w", err)
}
var results []RecordingMeta
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cast") {
continue
}
meta, err := s.readMeta(entry.Name())
if err != nil {
continue // skip unreadable files
}
results = append(results, *meta)
}
// Sort by creation time, newest first.
sort.Slice(results, func(i, j int) bool {
return results[i].CreatedAt.After(results[j].CreatedAt)
})
return results, nil
}
// Get reads the full recording (header + events) from a file.
func (s *Store) Get(id string) (*Header, []Event, error) {
path := filepath.Join(s.dir, id+".cast")
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("recording: failed to read file: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 1 {
return nil, nil, fmt.Errorf("recording: empty file")
}
// Parse header (first line).
var hdr Header
if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil {
return nil, nil, fmt.Errorf("recording: invalid header: %w", err)
}
// Parse events (remaining lines).
events := make([]Event, 0, len(lines)-1)
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var raw []json.RawMessage
if err := json.Unmarshal([]byte(line), &raw); err != nil {
continue
}
if len(raw) != 3 {
continue
}
var elapsed float64
var evtType, evtData string
if err := json.Unmarshal(raw[0], &elapsed); err != nil {
continue
}
if err := json.Unmarshal(raw[1], &evtType); err != nil {
continue
}
if err := json.Unmarshal(raw[2], &evtData); err != nil {
continue
}
events = append(events, Event{
Elapsed: elapsed,
Type: EventType(evtType),
Data: evtData,
})
}
return &hdr, events, nil
}
// Delete removes a recording file.
func (s *Store) Delete(id string) error {
path := filepath.Join(s.dir, id+".cast")
if err := os.Remove(path); err != nil {
return fmt.Errorf("recording: failed to delete: %w", err)
}
return nil
}
// readMeta reads just the header from a .cast file to produce metadata.
func (s *Store) readMeta(filename string) (*RecordingMeta, error) {
path := filepath.Join(s.dir, filename)
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
var hdr Header
dec := json.NewDecoder(f)
if err := dec.Decode(&hdr); err != nil {
return nil, err
}
// Calculate duration by scanning for last event timestamp.
var duration float64
for dec.More() {
var raw json.RawMessage
if err := dec.Decode(&raw); err != nil {
break
}
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err != nil || len(arr) < 1 {
continue
}
var elapsed float64
if err := json.Unmarshal(arr[0], &elapsed); err == nil {
duration = elapsed
}
}
id := strings.TrimSuffix(filename, ".cast")
return &RecordingMeta{
ID: id,
SessionID: id, // recording ID matches session ID by default
Title: hdr.Title,
Width: hdr.Width,
Height: hdr.Height,
Duration: duration,
Size: info.Size(),
CreatedAt: time.Unix(hdr.Timestamp, 0),
FilePath: path,
}, nil
}
-165
View File
@@ -1,165 +0,0 @@
package recording
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRecorderWriteAndClose(t *testing.T) {
dir := t.TempDir()
id := "test-session-001"
rec, err := NewRecorder(dir, id, "Test Recording", 80, 24)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
// Write some output events.
rec.WriteOutput([]byte("Welcome to the server\r\n"))
rec.WriteOutput([]byte("$ "))
// Write some input events.
rec.WriteInput([]byte("ls -la\r\n"))
// Write more output.
rec.WriteOutput([]byte("total 42\r\n"))
if rec.EventCount() != 4 {
t.Errorf("expected 4 events, got %d", rec.EventCount())
}
if err := rec.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
// Verify the file exists and has correct format.
path := filepath.Join(dir, id+".cast")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read recording: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 5 { // 1 header + 4 events
t.Fatalf("expected at least 5 lines, got %d", len(lines))
}
// Verify header.
var hdr Header
if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil {
t.Fatalf("failed to parse header: %v", err)
}
if hdr.Version != 2 {
t.Errorf("expected version 2, got %d", hdr.Version)
}
if hdr.Width != 80 || hdr.Height != 24 {
t.Errorf("expected 80x24, got %dx%d", hdr.Width, hdr.Height)
}
// Verify first event is output.
var evt []json.RawMessage
if err := json.Unmarshal([]byte(lines[1]), &evt); err != nil {
t.Fatalf("failed to parse first event: %v", err)
}
if len(evt) != 3 {
t.Fatalf("expected 3-element array, got %d", len(evt))
}
var evtType string
json.Unmarshal(evt[1], &evtType)
if evtType != "o" {
t.Errorf("expected event type 'o', got %q", evtType)
}
}
func TestStoreListAndGet(t *testing.T) {
dir := t.TempDir()
// Create a recording.
rec, err := NewRecorder(dir, "session-abc", "My Session", 120, 40)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
rec.WriteOutput([]byte("hello world\r\n"))
time.Sleep(10 * time.Millisecond)
rec.WriteOutput([]byte("done\r\n"))
rec.Close()
// Use the store to list.
store := NewStore(dir)
metas, err := store.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(metas) != 1 {
t.Fatalf("expected 1 recording, got %d", len(metas))
}
if metas[0].ID != "session-abc" {
t.Errorf("expected ID 'session-abc', got %q", metas[0].ID)
}
if metas[0].Width != 120 || metas[0].Height != 40 {
t.Errorf("unexpected dimensions: %dx%d", metas[0].Width, metas[0].Height)
}
// Read full recording.
hdr, events, err := store.Get("session-abc")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if hdr.Title != "My Session" {
t.Errorf("expected title 'My Session', got %q", hdr.Title)
}
if len(events) != 2 {
t.Errorf("expected 2 events, got %d", len(events))
}
}
func TestStoreDelete(t *testing.T) {
dir := t.TempDir()
rec, err := NewRecorder(dir, "to-delete", "Delete Me", 80, 24)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
rec.WriteOutput([]byte("test\n"))
rec.Close()
store := NewStore(dir)
// Verify it exists.
metas, _ := store.List()
if len(metas) != 1 {
t.Fatalf("expected 1 recording, got %d", len(metas))
}
// Delete.
if err := store.Delete("to-delete"); err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify it's gone.
metas, _ = store.List()
if len(metas) != 0 {
t.Errorf("expected 0 recordings, got %d", len(metas))
}
}
func TestDoubleClose(t *testing.T) {
dir := t.TempDir()
rec, err := NewRecorder(dir, "double-close", "Test", 80, 24)
if err != nil {
t.Fatalf("NewRecorder failed: %v", err)
}
rec.WriteOutput([]byte("test"))
if err := rec.Close(); err != nil {
t.Fatalf("first Close failed: %v", err)
}
// Second close should be a no-op.
if err := rec.Close(); err != nil {
t.Fatalf("second Close failed: %v", err)
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ const (
// Argon2id key derivation parameters.
argonTime = 3
argonMemory = 128 * 1024 // 128 MiB — deliberately strong for a desktop app
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -3,7 +3,7 @@ package sessions
import (
"testing"
"github.com/andydixon/nexus/internal/models"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
@@ -13,7 +13,7 @@ import (
func TestNewManager(t *testing.T) {
// NewManager requires backing services; pass nil for lightweight tests
// where no actual connections are made.
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
if mgr == nil {
t.Fatal("NewManager() returned nil")
}
@@ -30,7 +30,7 @@ func TestNewManager(t *testing.T) {
// ---------------------------------------------------------------------------
func TestListSessionsEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
sessions := mgr.ListSessions()
if len(sessions) != 0 {
t.Errorf("expected 0 sessions, got %d", len(sessions))
@@ -42,7 +42,7 @@ func TestListSessionsEmpty(t *testing.T) {
// ---------------------------------------------------------------------------
func TestCreateTabName(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
tests := []struct {
name string
@@ -93,7 +93,7 @@ func TestCreateTabName(t *testing.T) {
// ---------------------------------------------------------------------------
func TestRenameSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
err := mgr.RenameSession("nonexistent-id", "New Name")
if err == nil {
t.Error("expected error when renaming non-existent session")
@@ -105,7 +105,7 @@ func TestRenameSessionNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestPinSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
err := mgr.PinSession("nonexistent-id", true)
if err == nil {
t.Error("expected error when pinning non-existent session")
@@ -117,7 +117,7 @@ func TestPinSessionNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestGetSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
_, err := mgr.GetSession("nonexistent-id")
if err == nil {
t.Error("expected error when getting non-existent session")
@@ -129,7 +129,7 @@ func TestGetSessionNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestDisconnectNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
err := mgr.Disconnect("nonexistent-id")
if err == nil {
t.Error("expected error when disconnecting non-existent session")
@@ -141,7 +141,7 @@ func TestDisconnectNotFound(t *testing.T) {
// ---------------------------------------------------------------------------
func TestCloseAllEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
mgr := NewManager(nil, nil, nil, nil)
// Should not panic.
mgr.CloseAll()
if len(mgr.ListSessions()) != 0 {
-326
View File
@@ -1,326 +0,0 @@
// Package sftpclient provides an SFTP file browser and transfer capability
// over an established SSH connection. It wraps github.com/pkg/sftp to provide
// directory listing, upload, download, rename, delete, and mkdir operations
// that the frontend can call through the Wails API.
//
// The client is created per-session — each SSH session can have at most one
// SFTP subsystem active.
package sftpclient
import (
"bytes"
"fmt"
"io"
"os"
"path"
"sort"
"strings"
"sync"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// FileEntry represents a single file or directory in a remote listing.
type FileEntry struct {
Name string `json:"name"`
Path string `json:"path"` // full remote path
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
Mode string `json:"mode"` // Unix permission string, e.g. "-rwxr-xr-x"
ModifiedAt int64 `json:"modifiedAt"` // Unix epoch milliseconds
Owner string `json:"owner"` // best-effort UID
}
// TransferProgress reports the state of a file transfer.
type TransferProgress struct {
Transferred int64 `json:"transferred"` // bytes transferred so far
Total int64 `json:"total"` // total bytes (0 if unknown)
Path string `json:"path"` // file being transferred
Done bool `json:"done"`
Error string `json:"error,omitempty"`
}
// Client wraps an SFTP session.
type Client struct {
mu sync.Mutex
sftp *sftp.Client
closed bool
}
// NewClient creates an SFTP subsystem over an existing SSH connection.
func NewClient(sshConn *ssh.Client) (*Client, error) {
sc, err := sftp.NewClient(sshConn)
if err != nil {
return nil, fmt.Errorf("sftpclient: failed to start SFTP subsystem: %w", err)
}
return &Client{sftp: sc}, nil
}
// Close shuts down the SFTP subsystem.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
if c.sftp != nil {
return c.sftp.Close()
}
return nil
}
// List returns the contents of a remote directory.
func (c *Client) List(remotePath string) ([]FileEntry, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return nil, fmt.Errorf("sftpclient: client is closed")
}
entries, err := c.sftp.ReadDir(remotePath)
if err != nil {
return nil, fmt.Errorf("sftpclient: failed to list %s: %w", remotePath, err)
}
result := make([]FileEntry, 0, len(entries))
for _, e := range entries {
fe := FileEntry{
Name: e.Name(),
Path: path.Join(remotePath, e.Name()),
Size: e.Size(),
IsDir: e.IsDir(),
Mode: e.Mode().String(),
ModifiedAt: e.ModTime().UnixMilli(),
}
result = append(result, fe)
}
// Sort: directories first, then alphabetically.
sort.Slice(result, func(i, j int) bool {
if result[i].IsDir != result[j].IsDir {
return result[i].IsDir
}
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
})
return result, nil
}
// Stat returns information about a single remote path.
func (c *Client) Stat(remotePath string) (*FileEntry, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return nil, fmt.Errorf("sftpclient: client is closed")
}
info, err := c.sftp.Stat(remotePath)
if err != nil {
return nil, fmt.Errorf("sftpclient: stat failed for %s: %w", remotePath, err)
}
return &FileEntry{
Name: info.Name(),
Path: remotePath,
Size: info.Size(),
IsDir: info.IsDir(),
Mode: info.Mode().String(),
ModifiedAt: info.ModTime().UnixMilli(),
}, nil
}
// Mkdir creates a remote directory.
func (c *Client) Mkdir(remotePath string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return fmt.Errorf("sftpclient: client is closed")
}
return c.sftp.Mkdir(remotePath)
}
// MkdirAll creates a remote directory and all parents.
func (c *Client) MkdirAll(remotePath string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return fmt.Errorf("sftpclient: client is closed")
}
return c.sftp.MkdirAll(remotePath)
}
// Remove deletes a remote file or empty directory.
func (c *Client) Remove(remotePath string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return fmt.Errorf("sftpclient: client is closed")
}
return c.sftp.Remove(remotePath)
}
// Rename moves or renames a remote file/directory.
func (c *Client) Rename(oldPath, newPath string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return fmt.Errorf("sftpclient: client is closed")
}
return c.sftp.Rename(oldPath, newPath)
}
// Download copies a remote file to a local path. The onProgress callback
// is invoked periodically with transfer progress (may be nil).
func (c *Client) Download(remotePath, localPath string, onProgress func(TransferProgress)) error {
c.mu.Lock()
if c.closed || c.sftp == nil {
c.mu.Unlock()
return fmt.Errorf("sftpclient: client is closed")
}
// Get file size for progress reporting.
info, err := c.sftp.Stat(remotePath)
if err != nil {
c.mu.Unlock()
return fmt.Errorf("sftpclient: stat failed: %w", err)
}
totalSize := info.Size()
remote, err := c.sftp.Open(remotePath)
if err != nil {
c.mu.Unlock()
return fmt.Errorf("sftpclient: failed to open remote file: %w", err)
}
c.mu.Unlock()
defer remote.Close()
local, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("sftpclient: failed to create local file: %w", err)
}
defer local.Close()
return c.copyWithProgress(remote, local, totalSize, remotePath, onProgress)
}
// Upload copies a local file to a remote path. The onProgress callback
// is invoked periodically with transfer progress (may be nil).
func (c *Client) Upload(localPath, remotePath string, onProgress func(TransferProgress)) error {
local, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("sftpclient: failed to open local file: %w", err)
}
defer local.Close()
info, err := local.Stat()
if err != nil {
return fmt.Errorf("sftpclient: failed to stat local file: %w", err)
}
totalSize := info.Size()
c.mu.Lock()
if c.closed || c.sftp == nil {
c.mu.Unlock()
return fmt.Errorf("sftpclient: client is closed")
}
remote, err := c.sftp.Create(remotePath)
if err != nil {
c.mu.Unlock()
return fmt.Errorf("sftpclient: failed to create remote file: %w", err)
}
c.mu.Unlock()
defer remote.Close()
return c.copyWithProgress(local, remote, totalSize, remotePath, onProgress)
}
// UploadBytes writes in-memory data to a remote file path.
func (c *Client) UploadBytes(remotePath string, data []byte, onProgress func(TransferProgress)) error {
c.mu.Lock()
if c.closed || c.sftp == nil {
c.mu.Unlock()
return fmt.Errorf("sftpclient: client is closed")
}
remote, err := c.sftp.Create(remotePath)
if err != nil {
c.mu.Unlock()
return fmt.Errorf("sftpclient: failed to create remote file: %w", err)
}
c.mu.Unlock()
defer remote.Close()
reader := bytes.NewReader(data)
return c.copyWithProgress(reader, remote, int64(len(data)), remotePath, onProgress)
}
// Pwd returns the current working directory on the remote host.
func (c *Client) Pwd() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return "", fmt.Errorf("sftpclient: client is closed")
}
return c.sftp.Getwd()
}
// copyWithProgress copies from src to dst, reporting progress periodically.
func (c *Client) copyWithProgress(src io.Reader, dst io.Writer, totalSize int64, filePath string, onProgress func(TransferProgress)) error {
buf := make([]byte, 32*1024) // 32 KB buffer
var transferred int64
lastReport := time.Now()
for {
n, err := src.Read(buf)
if n > 0 {
nw, wErr := dst.Write(buf[:n])
if wErr != nil {
return fmt.Errorf("sftpclient: write failed: %w", wErr)
}
transferred += int64(nw)
// Report progress at most every 100ms.
if onProgress != nil && time.Since(lastReport) > 100*time.Millisecond {
onProgress(TransferProgress{
Transferred: transferred,
Total: totalSize,
Path: filePath,
})
lastReport = time.Now()
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("sftpclient: read failed: %w", err)
}
}
// Final progress report.
if onProgress != nil {
onProgress(TransferProgress{
Transferred: transferred,
Total: totalSize,
Path: filePath,
Done: true,
})
}
return nil
}

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